diff --git a/Cargo.lock b/Cargo.lock index bf824644c..9f82acb7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,6 +504,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 d8aeae591..69734a510 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 = { git = "https://github.com/bitwarden/credential-exchange", rev = "38e8a013c13644f832c457555baaa536fe481b77" } diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index a6ec1b0d0..d2da77457 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -2,8 +2,8 @@ use chrono::{DateTime, Utc}; use credential_exchange_format::{ Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential, DriversLicenseCredential, IdentityDocumentCredential, Item, - NoteCredential, PasskeyCredential, PassportCredential, PersonNameCredential, TotpCredential, - WifiCredential, + NoteCredential, PasskeyCredential, PassportCredential, PersonNameCredential, SshKeyCredential, + TotpCredential, WifiCredential, }; use crate::{ @@ -16,6 +16,7 @@ use crate::{ }, login::to_login, note::extract_note_content, + ssh::to_ssh, wifi::wifi_to_fields, CxfError, }, @@ -126,6 +127,16 @@ pub(super) fn parse_item(value: Item) -> Vec { add_item(CipherType::Identity(Box::new(identity)), custom_fields); }); + // SSH Key credentials + if let Some(ssh) = grouped.ssh.first() { + match to_ssh(ssh) { + Ok((ssh_key, fields)) => add_item(CipherType::SshKey(Box::new(ssh_key)), fields), + Err(_) => { + // Include information about the failed items, or import as note? + } + } + } + // Standalone Note credentials -> Secure Note (only if no other credentials exist) if !grouped.note.is_empty() && output.is_empty() { let standalone_note_content = grouped.note.first().map(extract_note_content); @@ -172,12 +183,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, }), totp: filter_credentials(&credentials, |c| match c { @@ -216,18 +231,19 @@ fn group_credentials_by_type(credentials: Vec) -> GroupedCredentials } struct GroupedCredentials { + address: Vec, api_key: Vec, basic_auth: Vec, - passkey: Vec, credit_card: Vec, - totp: Vec, - wifi: Vec, - address: Vec, - passport: Vec, - person_name: Vec, drivers_license: Vec, identity_document: Vec, note: Vec, + passkey: Vec, + passport: Vec, + person_name: Vec, + ssh: Vec, + totp: Vec, + wifi: Vec, } #[cfg(test)] diff --git a/crates/bitwarden-exporters/src/cxf/mod.rs b/crates/bitwarden-exporters/src/cxf/mod.rs index 74da53bdf..0ffe2844c 100644 --- a/crates/bitwarden-exporters/src/cxf/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/mod.rs @@ -20,4 +20,5 @@ mod identity; mod import_sample_tests; mod login; mod note; +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..d5846f466 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/ssh.rs @@ -0,0 +1,99 @@ +use bitwarden_ssh::{error::SshKeyImportError, import::import_pkcs8_der}; +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_pkcs8_der(&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: "MIIG_QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQCn4-QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN-mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn-vtb49xPzIv-M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl-s4mUyr_qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ-EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT_1chb8GCTDT-2DCBRApbsIg6TOBVS-PR6emAQ3eZzUW0-3_oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo_s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe-1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt-ys__5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0CAwEAAQKCAYAA2SDMf7OBHw1OGM9OQa1ZS4u-ktfQHhn31-FxbrhWGp-lDt8gYABVf6Y4dKN6rMtn7D9gVSAlZCAn3Hx8aWAvcXHaspxe9YXiZDTh-Kd8EIXxBQn-TiDA5LH0dryABqmMp20vYKtR7OS3lIIXfFBSrBMwdunKzLwmKwZLWq0SWf6vVbwpxRyR9CyByodF6DjmZK3QB2qQ3jqlL1HWXL0VnyArY7HLvUvfLLK4vMPqnsSH-FdHvhcEhwqMlWT44g-fhqWtCJNnjDgLK3FPbI8Pz9TF8dWJvOmp5Q6iSBua1e9x2LizVuNSqiFc7ZTLeoG4nDj7T2BtqB0E1rNUDEN1aBo-UZmHJK7LrzfW_B-ssi2WwIpfxYa1lO6HFod5_YQiXV1GunyH1chCsbvOFtXvAHASO4HTKlJNbWhRF1GXqnKpAaHDPCVuwp3eq6Yf0oLbXrL3KFZ3jwWiWbpQXRVvpqzaJwZn3CN1yQgYS9j17a9wrPky-BoJxXjZ_oImWLECgcEA0lkLwiHvmTYFTCC7PN938Agk9_NQs5PQ18MRn9OJmyfSpYqf_gNp-Md7xUgtF_MTif7uelp2J7DYf6fj9EYf9g4EuW-SQgFP4pfiJn1-zGFeTQq1ISvwjsA4E8ZSt-GIumjZTg6YiL1_A79u4wm24swt7iqnVViOPtPGOM34S1tAamjZzq2eZDmAF6pAfmuTMdinCMR1E1kNJYbxeqLiqQCXuwBBnHOOOJofN3AkvzjRUBB9udvniqYxH3PQcxPxAoHBAMxT5KwBhZhnJedYN87Kkcpl7xdMkpU8b-aXeZoNykCeoC-wgIQexnSWmFk4HPkCNxvCWlbkOT1MHrTAKFnaOww23Ob-Vi6A9n0rozo9vtoJig114GB0gUqEmtfLhO1P5AE8yzogE-ILHyp0BqXt8vGIfzpDnCkN-GKl8gOOMPrR4NAcLO-Rshc5nLs7BGB4SEi126Y6mSfp85m0--1QhWMz9HzqJEHCWKVcZYdCdEONP9js04EUnK33KtlJIWzZTQKBwAT0pBpGwmZRp35Lpx2gBitZhcVxrg0NBnaO2fNyAGPvZD8SLQLHAdAiov_a23Uc_PDbWLL5Pp9gwzj-s5glrssVOXdE8aUscr1b5rARdNNL1_Tos6u8ZUZ3sNqGaZx7a8U4gyYboexWyo9EC1C-AdkGBm7-AkM4euFwC9N6xsa_t5zKK5d676hc0m-8SxivYCBkgkrqlfeGuZCQxU-mVsC0it6U-va8ojUjLGkZ80OuCwBf4xZl3-acU7vx9o8_gQKBwB7BrhU6MWrsc-cr_1KQaXum9mNyckomi82RFYvb8Yrilcg38FBy9XqNRKeBa9MLw1HZYpHbzsXsVF7u4eQMloDTLVNUC5L6dKAI1owoyTa24uH90WWTg_a8mTZMe1jhgrew-AJq27NV6z4PswR9GenDmyshDDudz7rBsflZCQRoXUfWRelV7BHU6UPBsXn4ASF4xnRyM6WvcKy9coKZcUqqgm3fLM_9OizCCMJgfXHBrE-x7nBqst746qlEedSRrQKBwQCVYwwKCHNlZxl0_NMkDJ-hp7_InHF6mz_3VO58iCb19TLDVUC2dDGPXNYwWTT9PclefwV5HNBHcAfTzgB4dpQyNiDyV914HL7DFEGduoPnwBYjeFre54v0YjjnskjJO7myircdbdX__i-7LMUw5aZZXCC8a5BD_rdV6IKJWJG5QBXbe5fVf1XwOjBTzlhIPIqhNFfSu-mFikp5BRwHGBqsKMju6inYmW6YADeY_SvOQjDEB37RqGZxqyIx8V2ZYwU" + .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-----\n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\nScrfcq2UkhbNlNAAAAAAEC\n-----END OPENSSH PRIVATE KEY-----\n"); + assert_eq!( + ssh.public_key, + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0=" + ); + assert_eq!( + ssh.fingerprint, + "SHA256:vWqZh87vgxDk0eDx0VqWR001mXyFGTdRF4Q2JVW/Q9w" + ); + + 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-ssh/src/import.rs b/crates/bitwarden-ssh/src/import.rs index 04c75caa5..3fb5d8a0c 100644 --- a/crates/bitwarden-ssh/src/import.rs +++ b/crates/bitwarden-ssh/src/import.rs @@ -32,6 +32,12 @@ pub fn import_key( } } +/// 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_pkcs8_der(encoded_key: &[u8]) -> Result { + import_der_key(encoded_key) +} + fn import_pkcs8_key( encoded_key: String, password: Option, @@ -49,8 +55,12 @@ fn import_pkcs8_key( SecretDocument::from_pkcs8_pem(&encoded_key).map_err(|_| SshKeyImportError::ParsingError)? }; + import_der_key(doc.as_bytes()) +} + +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 => {