Skip to content

[PM-23653] Add support for ssh keys #375

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/bitwarden-exporters/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ bitwarden-core = { workspace = true }
bitwarden-crypto = { workspace = true }
bitwarden-error = { workspace = true }
bitwarden-fido = { workspace = true }
bitwarden-ssh = { workspace = true }
bitwarden-vault = { workspace = true }
chrono = { workspace = true, features = ["std"] }
credential-exchange-format = ">=0.1, <0.2"
Expand Down
2 changes: 1 addition & 1 deletion crates/bitwarden-exporters/src/cxf/api_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use credential_exchange_format::ApiKeyCredential;
use crate::{cxf::editable_field::create_field, Field};

/// Convert API key credentials to custom fields
pub fn api_key_to_fields(api_key: &ApiKeyCredential) -> Vec<Field> {
pub(super) fn api_key_to_fields(api_key: &ApiKeyCredential) -> Vec<Field> {
[
api_key.key.as_ref().map(|key| create_field("API Key", key)),
api_key
Expand Down
37 changes: 33 additions & 4 deletions crates/bitwarden-exporters/src/cxf/import.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
use chrono::{DateTime, Utc};
use credential_exchange_format::{
Account as CxfAccount, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential,
Item, PasskeyCredential, WifiCredential,
Item, PasskeyCredential, SshKeyCredential, WifiCredential,
};

use crate::{
cxf::{
api_key::api_key_to_fields,
login::{to_fields, to_login},
ssh::to_ssh,
wifi::wifi_to_fields,
CxfError,
},
Expand Down Expand Up @@ -121,6 +122,29 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
})
}

// SSH Key credentials
if let Some(ssh) = grouped.ssh.first() {
match to_ssh(ssh) {
Ok((ssh_key, fields)) => {
output.push(ImportingCipher {
folder_id: None, // TODO: Handle folders
name: value.title.clone(),
notes: None,
r#type: CipherType::SshKey(Box::new(ssh_key)),
favorite: false,
reprompt: 0,
fields: [fields, scope.map(to_fields).unwrap_or_default()].concat(),
revision_date,
creation_date,
deleted_date: None,
})
}
Err(_) => {
// Include information about the failed items, or import as note?
}
}
}

output
}

Expand Down Expand Up @@ -150,12 +174,16 @@ fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials
Credential::BasicAuth(basic_auth) => Some(basic_auth.as_ref()),
_ => None,
}),
credit_card: filter_credentials(&credentials, |c| match c {
Credential::CreditCard(credit_card) => Some(credit_card.as_ref()),
_ => None,
}),
passkey: filter_credentials(&credentials, |c| match c {
Credential::Passkey(passkey) => Some(passkey.as_ref()),
_ => None,
}),
credit_card: filter_credentials(&credentials, |c| match c {
Credential::CreditCard(credit_card) => Some(credit_card.as_ref()),
ssh: filter_credentials(&credentials, |c| match c {
Credential::SshKey(ssh) => Some(ssh.as_ref()),
_ => None,
}),
wifi: filter_credentials(&credentials, |c| match c {
Expand All @@ -168,8 +196,9 @@ fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials
struct GroupedCredentials {
api_key: Vec<ApiKeyCredential>,
basic_auth: Vec<BasicAuthCredential>,
passkey: Vec<PasskeyCredential>,
credit_card: Vec<CreditCardCredential>,
passkey: Vec<PasskeyCredential>,
ssh: Vec<SshKeyCredential>,
wifi: Vec<WifiCredential>,
}

Expand Down
1 change: 1 addition & 0 deletions crates/bitwarden-exporters/src/cxf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ mod api_key;
mod card;
mod editable_field;
mod login;
mod ssh;
mod wifi;
99 changes: 99 additions & 0 deletions crates/bitwarden-exporters/src/cxf/ssh.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use bitwarden_ssh::{error::SshKeyImportError, import::import_der_key};
use bitwarden_vault::FieldType;
use credential_exchange_format::SshKeyCredential;

use crate::{cxf::editable_field::create_field, Field, SshKey};

/// Convert SSH key credentials to SshKey and custom fields
pub(super) fn to_ssh(
credential: &SshKeyCredential,
) -> Result<(SshKey, Vec<Field>), SshKeyImportError> {
// Convert to OpenSSH format
let encoded_key: Vec<u8> = credential.private_key.as_ref().into();
let encoded_key = import_der_key(&encoded_key)?;

let ssh = SshKey {
private_key: encoded_key.private_key,
public_key: encoded_key.public_key,
fingerprint: encoded_key.fingerprint,
};

let fields = [
credential.key_comment.as_ref().map(|comment| Field {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note (non-actionable): In the agent, we treat the name of the cipher as the comment, and advertise it as such. We may want to research (not here) how other clients treat comment vs name, to not disrupt user experience. If someone comes over from another product which uses a separate key comment field, suddenly all their ciphers may be named something different.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we will have a lot of follow up work as other credential managers implements credential exchange and we discover edge cases ๐Ÿ˜ฐ

name: Some("Key Comment".into()),
value: Some(comment.into()),
r#type: FieldType::Text as u8,
linked_id: None,
}),
credential
.creation_date
.as_ref()
.map(|date| create_field("Creation Date", date)),
credential
.expiry_date
.as_ref()
.map(|date| create_field("Expiry Date", date)),
credential
.key_generation_source
.as_ref()
.map(|source| create_field("Key Generation Source", source)),
]
.into_iter()
.flatten()
.collect();

Ok((ssh, fields))
}

#[cfg(test)]
mod tests {
use bitwarden_vault::FieldType;
use chrono::NaiveDate;
use credential_exchange_format::EditableFieldDate;

use super::*;

#[test]
fn test_to_ssh() {
let credential = SshKeyCredential {
key_type: "ssh-ed25519".into(),
private_key: "MC4CAQAwBQYDK2VwBCIEID-U9VakauO4Fsv4b_znpDHcdYg74U68siZjnWLPn7Q1"
.try_into()
.unwrap(),
key_comment: Some("Work SSH Key".into()),
creation_date: Some(
EditableFieldDate(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()).into(),
),
expiry_date: Some(
EditableFieldDate(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()).into(),
),
key_generation_source: Some("Generated using OpenSSH".to_owned().into()),
};

let (ssh, fields) = to_ssh(&credential).unwrap();

assert_eq!(ssh.private_key, "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDQiCIk4t4YPC6bOSb7CLzac/vC+ZudqhYqY00cxqr8zAAAAIilFVdupRVX\nbgAAAAtzc2gtZWQyNTUxOQAAACDQiCIk4t4YPC6bOSb7CLzac/vC+ZudqhYqY00cxqr8zA\nAAAEA/lPVWpGrjuBbL+G/856Qx3HWIO+FOvLImY51iz5+0NdCIIiTi3hg8Lps5JvsIvNpz\n+8L5m52qFipjTRzGqvzMAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----\n");
assert_eq!(
ssh.public_key,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINCIIiTi3hg8Lps5JvsIvNpz+8L5m52qFipjTRzGqvzM"
);
assert_eq!(
ssh.fingerprint,
"SHA256:mZ0BOhUVicE81yPEpFJrv1rEXB2R3Y3t5nh/riicTvs"
);

assert_eq!(fields.len(), 4);
assert_eq!(
fields[0],
Field {
name: Some("Key Comment".to_string()),
value: Some("Work SSH Key".to_string()),
r#type: FieldType::Text as u8,
linked_id: None,
}
);
assert_eq!(fields[1].value.as_deref(), Some("2023-01-01"));
assert_eq!(fields[2].value.as_deref(), Some("2025-01-01"));
assert_eq!(fields[3].value.as_deref(), Some("Generated using OpenSSH"));
}
}
2 changes: 1 addition & 1 deletion crates/bitwarden-exporters/src/cxf/wifi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use credential_exchange_format::WifiCredential;
use crate::{cxf::editable_field::create_field, Field};

/// Convert WiFi credentials to custom fields following the CXF mapping convention
pub fn wifi_to_fields(wifi: &WifiCredential) -> Vec<Field> {
pub(super) fn wifi_to_fields(wifi: &WifiCredential) -> Vec<Field> {
[
// SSID: Text field
wifi.ssid.as_ref().map(|ssid| create_field("SSID", ssid)),
Expand Down
42 changes: 24 additions & 18 deletions crates/bitwarden-ssh/src/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,11 @@ pub fn import_key(
}
}

fn import_pkcs8_key(
encoded_key: String,
password: Option<String>,
) -> Result<SshKeyView, SshKeyImportError> {
let doc = if let Some(password) = password {
SecretDocument::from_pkcs8_encrypted_pem(&encoded_key, password.as_bytes()).map_err(
|err| match err {
pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
},
)?
} else {
SecretDocument::from_pkcs8_pem(&encoded_key).map_err(|_| SshKeyImportError::ParsingError)?
};

/// Import a DER encoded private key, and returns a decoded [SshKeyView]. This is primarily used for
/// importing SSH keys from other Credential Managers through Credential Exchange.
pub fn import_der_key(encoded_key: &[u8]) -> Result<SshKeyView, SshKeyImportError> {
let private_key_info =
PrivateKeyInfo::from_der(doc.as_bytes()).map_err(|_| SshKeyImportError::ParsingError)?;
PrivateKeyInfo::from_der(encoded_key).map_err(|_| SshKeyImportError::ParsingError)?;

let private_key = match private_key_info.algorithm.oid {
ed25519::pkcs8::ALGORITHM_OID => {
Expand All @@ -75,6 +61,26 @@ fn import_pkcs8_key(
ssh_private_key_to_view(private_key).map_err(|_| SshKeyImportError::ParsingError)
}

fn import_pkcs8_key(
encoded_key: String,
password: Option<String>,
) -> Result<SshKeyView, SshKeyImportError> {
let doc = if let Some(password) = password {
SecretDocument::from_pkcs8_encrypted_pem(&encoded_key, password.as_bytes()).map_err(
|err| match err {
pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
},
)?
} else {
SecretDocument::from_pkcs8_pem(&encoded_key).map_err(|_| SshKeyImportError::ParsingError)?
};

import_der_key(doc.as_bytes())
}

fn import_openssh_key(
encoded_key: String,
password: Option<String>,
Expand Down
Loading