diff --git a/Cargo.lock b/Cargo.lock index 04f97f266..0e18e836b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,6 +503,7 @@ dependencies = [ "bitwarden-crypto", "bitwarden-error", "bitwarden-fido", + "bitwarden-ssh", "bitwarden-vault", "chrono", "credential-exchange-format", diff --git a/crates/bitwarden-exporters/Cargo.toml b/crates/bitwarden-exporters/Cargo.toml index 51fc4d05e..20d888a96 100644 --- a/crates/bitwarden-exporters/Cargo.toml +++ b/crates/bitwarden-exporters/Cargo.toml @@ -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" diff --git a/crates/bitwarden-exporters/src/cxf/api_key.rs b/crates/bitwarden-exporters/src/cxf/api_key.rs index b234ef2d9..04c3af3da 100644 --- a/crates/bitwarden-exporters/src/cxf/api_key.rs +++ b/crates/bitwarden-exporters/src/cxf/api_key.rs @@ -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 { +pub(super) fn api_key_to_fields(api_key: &ApiKeyCredential) -> Vec { [ api_key.key.as_ref().map(|key| create_field("API Key", key)), api_key diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index 7d420f72f..f2f0ddd67 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -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, }, @@ -121,6 +122,29 @@ fn parse_item(value: Item) -> Vec { }) } + // 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 } @@ -150,12 +174,16 @@ fn group_credentials_by_type(credentials: Vec) -> 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 { @@ -168,8 +196,9 @@ fn group_credentials_by_type(credentials: Vec) -> GroupedCredentials struct GroupedCredentials { api_key: Vec, basic_auth: Vec, - passkey: Vec, credit_card: Vec, + passkey: Vec, + ssh: Vec, wifi: Vec, } diff --git a/crates/bitwarden-exporters/src/cxf/mod.rs b/crates/bitwarden-exporters/src/cxf/mod.rs index dcd558ead..35af56771 100644 --- a/crates/bitwarden-exporters/src/cxf/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/mod.rs @@ -16,4 +16,5 @@ mod api_key; mod card; mod editable_field; mod login; +mod ssh; mod wifi; diff --git a/crates/bitwarden-exporters/src/cxf/ssh.rs b/crates/bitwarden-exporters/src/cxf/ssh.rs new file mode 100644 index 000000000..381cf6554 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/ssh.rs @@ -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), SshKeyImportError> { + // Convert to OpenSSH format + let encoded_key: Vec = 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 { + 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")); + } +} diff --git a/crates/bitwarden-exporters/src/cxf/wifi.rs b/crates/bitwarden-exporters/src/cxf/wifi.rs index 910d630be..f25f7265b 100644 --- a/crates/bitwarden-exporters/src/cxf/wifi.rs +++ b/crates/bitwarden-exporters/src/cxf/wifi.rs @@ -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 { +pub(super) fn wifi_to_fields(wifi: &WifiCredential) -> Vec { [ // SSID: Text field wifi.ssid.as_ref().map(|ssid| create_field("SSID", ssid)), diff --git a/crates/bitwarden-ssh/src/import.rs b/crates/bitwarden-ssh/src/import.rs index 04c75caa5..752437cc7 100644 --- a/crates/bitwarden-ssh/src/import.rs +++ b/crates/bitwarden-ssh/src/import.rs @@ -32,25 +32,11 @@ pub fn import_key( } } -fn import_pkcs8_key( - encoded_key: String, - password: Option, -) -> Result { - 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 { 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 => { @@ -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, +) -> Result { + 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,