diff --git a/Cargo.lock b/Cargo.lock index 49ace13e..8361589c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,10 +420,12 @@ dependencies = [ "base64", "bitwarden-core", "bitwarden-crypto", + "bitwarden-fido", "bitwarden-vault", "chrono", "credential-exchange-types", "csv", + "rand", "schemars", "serde", "serde_json", diff --git a/crates/bitwarden-exporters/Cargo.toml b/crates/bitwarden-exporters/Cargo.toml index 77bab663..f8641efc 100644 --- a/crates/bitwarden-exporters/Cargo.toml +++ b/crates/bitwarden-exporters/Cargo.toml @@ -21,6 +21,7 @@ uniffi = ["dep:uniffi"] # Uniffi bindings base64 = ">=0.22.1, <0.23" bitwarden-core = { workspace = true } bitwarden-crypto = { workspace = true } +bitwarden-fido = { workspace = true } bitwarden-vault = { workspace = true } chrono = { workspace = true, features = ["std"] } credential-exchange-types = { git = "https://github.com/bitwarden/credential-exchange.git", rev = "b5b5fa3faab7a1aab4efba779f5f74c7ec0f3b35" } @@ -32,5 +33,8 @@ thiserror = { workspace = true } uniffi = { workspace = true, optional = true } uuid = { workspace = true } +[dev-dependencies] +rand = ">=0.8.5, <0.9" + [lints] workspace = true diff --git a/crates/bitwarden-exporters/src/csv.rs b/crates/bitwarden-exporters/src/csv.rs index a0dcd104..a21f8b3c 100644 --- a/crates/bitwarden-exporters/src/csv.rs +++ b/crates/bitwarden-exporters/src/csv.rs @@ -139,6 +139,7 @@ mod tests { r#match: None, }], totp: None, + fido2_credentials: None, })), favorite: false, reprompt: 0, @@ -160,6 +161,7 @@ mod tests { r#match: None, }], totp: Some("steam://ABCD123".to_string()), + fido2_credentials: None, })), favorite: true, reprompt: 0, diff --git a/crates/bitwarden-exporters/src/cxp/mod.rs b/crates/bitwarden-exporters/src/cxp/mod.rs index 95f71c8c..7471ef0a 100644 --- a/crates/bitwarden-exporters/src/cxp/mod.rs +++ b/crates/bitwarden-exporters/src/cxp/mod.rs @@ -1,14 +1,19 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + +use bitwarden_core::MissingFieldError; use bitwarden_crypto::generate_random_bytes; +use bitwarden_fido::{string_to_guid_bytes, InvalidGuid}; use credential_exchange_types::{ format::{ Account as CxpAccount, BasicAuthCredential, Credential, EditableField, FieldType, Item, - ItemType, + ItemType, PasskeyCredential, }, B64Url, }; +use thiserror::Error; use uuid::Uuid; -use crate::{Cipher, CipherType, Login}; +use crate::{Cipher, CipherType, Fido2Credential, Login}; mod error; pub use error::CxpError; @@ -70,7 +75,26 @@ impl From for ItemType { impl From for Vec { fn from(login: Login) -> Self { - vec![Credential::BasicAuth(BasicAuthCredential { + let mut credentials = vec![]; + + credentials.push(Credential::BasicAuth(login.clone().into())); + + if let Some(fido2_credentials) = login.fido2_credentials { + for fido2_credential in fido2_credentials { + let c = fido2_credential.try_into(); + if let Ok(c) = c { + credentials.push(Credential::Passkey(c)) + } + } + } + + credentials + } +} + +impl From for BasicAuthCredential { + fn from(login: Login) -> Self { + BasicAuthCredential { urls: login .login_uris .into_iter() @@ -88,7 +112,46 @@ impl From for Vec { value, label: None, }), - })] + } + } +} + +#[derive(Error, Debug)] +pub enum PasskeyError { + #[error("Counter is not zero")] + CounterNotZero, + #[error(transparent)] + InvalidGuid(InvalidGuid), + #[error(transparent)] + MissingField(MissingFieldError), + #[error(transparent)] + InvalidBase64(#[from] base64::DecodeError), +} + +impl TryFrom for PasskeyCredential { + type Error = PasskeyError; + + fn try_from(value: Fido2Credential) -> Result { + if value.counter > 0 { + return Err(PasskeyError::CounterNotZero); + } + + Ok(PasskeyCredential { + credential_id: string_to_guid_bytes(&value.credential_id) + .map_err(PasskeyError::InvalidGuid)? + .into(), + rp_id: value.rp_id, + user_name: value.user_name.unwrap_or("".to_string()), + user_display_name: value.user_display_name.unwrap_or("".to_string()), + user_handle: value + .user_handle + .map(|v| URL_SAFE_NO_PAD.decode(v)) + .transpose()? + .map(|v| v.into()) + .ok_or(PasskeyError::MissingField(MissingFieldError("user_handle")))?, + key: URL_SAFE_NO_PAD.decode(value.key_value)?.into(), + fido2_extensions: vec![], + }) } } @@ -114,7 +177,7 @@ mod tests { use chrono::{DateTime, Utc}; use super::*; - use crate::{CipherType, Field, Login, LoginUri}; + use crate::{CipherType, Fido2Credential, Field, Login, LoginUri}; #[test] fn test_login_to_item() { @@ -133,6 +196,21 @@ mod tests { r#match: None, }], totp: Some("ABC".to_string()), + fido2_credentials: Some(vec![Fido2Credential { + credential_id: "52217b91-73f1-4fea-b3f2-54a7959fd5aa".to_string(), + key_type: "public-key".to_string(), + key_algorithm: "ECDSA".to_string(), + key_curve: "P-256".to_string(), + key_value: URL_SAFE_NO_PAD.encode([0, 1, 2, 3, 4, 5, 6]), + rp_id: "123".to_string(), + user_handle: Some(URL_SAFE_NO_PAD.encode([0, 1, 2, 3, 4, 5, 6])), + user_name: None, + counter: 0, + rp_name: None, + user_display_name: None, + discoverable: "true".to_string(), + creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(), + }]), })), favorite: true, @@ -192,7 +270,7 @@ mod tests { assert_eq!(item.ty, ItemType::Login); assert_eq!(item.title, "Bitwarden"); assert_eq!(item.subtitle, None); - assert_eq!(item.credentials.len(), 1); + assert_eq!(item.credentials.len(), 2); assert_eq!(item.tags, None); assert!(item.extensions.is_none()); @@ -217,5 +295,14 @@ mod tests { } _ => panic!("Expected Credential::BasicAuth"), } + + let credential = &item.credentials[1]; + + match credential { + Credential::Passkey(passkey) => { + assert_eq!(passkey.credential_id.to_string(), "UiF7kXPxT-qz8lSnlZ_Vqg"); + } + _ => panic!("Expected Credential::Passkey"), + } } } diff --git a/crates/bitwarden-exporters/src/encrypted_json.rs b/crates/bitwarden-exporters/src/encrypted_json.rs index 0378bd9f..32f1c930 100644 --- a/crates/bitwarden-exporters/src/encrypted_json.rs +++ b/crates/bitwarden-exporters/src/encrypted_json.rs @@ -111,6 +111,7 @@ mod tests { r#match: None, }], totp: Some("ABC".to_string()), + fido2_credentials: None, })), favorite: true, diff --git a/crates/bitwarden-exporters/src/error.rs b/crates/bitwarden-exporters/src/error.rs index 0add924d..00cfe2e0 100644 --- a/crates/bitwarden-exporters/src/error.rs +++ b/crates/bitwarden-exporters/src/error.rs @@ -20,4 +20,6 @@ pub enum ExportError { BitwardenError(#[from] bitwarden_core::Error), #[error(transparent)] BitwardenCryptoError(#[from] bitwarden_crypto::CryptoError), + #[error(transparent)] + CipherError(#[from] bitwarden_vault::CipherError), } diff --git a/crates/bitwarden-exporters/src/export.rs b/crates/bitwarden-exporters/src/export.rs index 40e31922..9a9eb3c1 100644 --- a/crates/bitwarden-exporters/src/export.rs +++ b/crates/bitwarden-exporters/src/export.rs @@ -1,6 +1,6 @@ use bitwarden_core::Client; use bitwarden_crypto::KeyDecryptable; -use bitwarden_vault::{Cipher, CipherView, Collection, Folder, FolderView}; +use bitwarden_vault::{Cipher, Collection, Folder, FolderView}; use crate::{ csv::export_csv, @@ -22,8 +22,10 @@ pub(crate) fn export_vault( let folders: Vec = folders.decrypt_with_key(key)?; let folders: Vec = folders.into_iter().flat_map(|f| f.try_into()).collect(); - let ciphers: Vec = ciphers.decrypt_with_key(key)?; - let ciphers: Vec = ciphers.into_iter().flat_map(|c| c.try_into()).collect(); + let ciphers: Vec = ciphers + .into_iter() + .flat_map(|c| crate::Cipher::from_cipher(&enc, c)) + .collect(); match format { ExportFormat::Csv => Ok(export_csv(folders, ciphers)?), @@ -51,10 +53,11 @@ pub(crate) fn export_cxf( ciphers: Vec, ) -> Result { let enc = client.internal.get_encryption_settings()?; - let key = enc.get_key(&None)?; - let ciphers: Vec = ciphers.decrypt_with_key(key)?; - let ciphers: Vec = ciphers.into_iter().flat_map(|c| c.try_into()).collect(); + let ciphers: Vec = ciphers + .into_iter() + .flat_map(|c| crate::Cipher::from_cipher(&enc, c)) + .collect(); Ok(build_cxf(account, ciphers)?) } diff --git a/crates/bitwarden-exporters/src/json.rs b/crates/bitwarden-exporters/src/json.rs index 58895914..fdce9e36 100644 --- a/crates/bitwarden-exporters/src/json.rs +++ b/crates/bitwarden-exporters/src/json.rs @@ -314,6 +314,7 @@ mod tests { r#match: None, }], totp: Some("ABC".to_string()), + fido2_credentials: None, })), favorite: true, @@ -705,6 +706,7 @@ mod tests { r#match: None, }], totp: Some("ABC".to_string()), + fido2_credentials: None, })), favorite: true, diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs index c22b49b0..f34a6ad0 100644 --- a/crates/bitwarden-exporters/src/lib.rs +++ b/crates/bitwarden-exporters/src/lib.rs @@ -97,6 +97,8 @@ pub struct Login { pub password: Option, pub login_uris: Vec, pub totp: Option, + + pub fido2_credentials: Option>, } #[derive(Clone)] @@ -105,6 +107,23 @@ pub struct LoginUri { pub r#match: Option, } +#[derive(Clone)] +pub struct Fido2Credential { + pub credential_id: String, + pub key_type: String, + pub key_algorithm: String, + pub key_curve: String, + pub key_value: String, + pub rp_id: String, + pub user_handle: Option, + pub user_name: Option, + pub counter: u32, + pub rp_name: Option, + pub user_display_name: Option, + pub discoverable: String, + pub creation_date: DateTime, +} + #[derive(Clone)] pub struct Card { pub cardholder_name: Option, diff --git a/crates/bitwarden-exporters/src/models.rs b/crates/bitwarden-exporters/src/models.rs index 1d2e80d9..2ad79555 100644 --- a/crates/bitwarden-exporters/src/models.rs +++ b/crates/bitwarden-exporters/src/models.rs @@ -1,6 +1,8 @@ use bitwarden_core::{require, MissingFieldError}; +use bitwarden_crypto::{KeyContainer, KeyDecryptable, LocateKey}; use bitwarden_vault::{ - CipherType, CipherView, FieldView, FolderView, LoginUriView, SecureNoteType, + CardView, Cipher, CipherType, CipherView, Fido2CredentialFullView, FieldView, FolderView, + IdentityView, LoginUriView, SecureNoteType, SecureNoteView, SshKeyView, }; impl TryFrom for crate::Folder { @@ -14,13 +16,18 @@ impl TryFrom for crate::Folder { } } -impl TryFrom for crate::Cipher { - type Error = MissingFieldError; +impl crate::Cipher { + pub(crate) fn from_cipher( + enc: &dyn KeyContainer, + cipher: Cipher, + ) -> Result { + let key = cipher.locate_key(enc, &None)?; + let view: CipherView = cipher.decrypt_with_key(key)?; - fn try_from(value: CipherView) -> Result { - let r = match value.r#type { + let r = match view.r#type { CipherType::Login => { - let l = require!(value.login); + let l = require!(view.login.clone()); + crate::CipherType::Login(Box::new(crate::Login { username: l.username, password: l.password, @@ -31,80 +38,144 @@ impl TryFrom for crate::Cipher { .map(|u| u.into()) .collect(), totp: l.totp, + fido2_credentials: { + if l.fido2_credentials.is_some() { + let credentials = view.get_fido2_credentials(enc)?; + if credentials.is_empty() { + None + } else { + Some(credentials.into_iter().map(|c| c.into()).collect()) + } + } else { + None + } + }, })) } - CipherType::SecureNote => crate::CipherType::SecureNote(Box::new(crate::SecureNote { - r#type: value - .secure_note - .map(|t| t.r#type) - .unwrap_or(SecureNoteType::Generic) - .into(), - })), + CipherType::SecureNote => { + let s = require!(view.secure_note); + crate::CipherType::SecureNote(Box::new(s.into())) + } CipherType::Card => { - let c = require!(value.card); - crate::CipherType::Card(Box::new(crate::Card { - cardholder_name: c.cardholder_name, - exp_month: c.exp_month, - exp_year: c.exp_year, - code: c.code, - brand: c.brand, - number: c.number, - })) + let c = require!(view.card); + crate::CipherType::Card(Box::new(c.into())) } CipherType::Identity => { - let i = require!(value.identity); - crate::CipherType::Identity(Box::new(crate::Identity { - title: i.title, - first_name: i.first_name, - middle_name: i.middle_name, - last_name: i.last_name, - address1: i.address1, - address2: i.address2, - address3: i.address3, - city: i.city, - state: i.state, - postal_code: i.postal_code, - country: i.country, - company: i.company, - email: i.email, - phone: i.phone, - ssn: i.ssn, - username: i.username, - passport_number: i.passport_number, - license_number: i.license_number, - })) + let i = require!(view.identity); + crate::CipherType::Identity(Box::new(i.into())) } CipherType::SshKey => { - let s = require!(value.ssh_key); - crate::CipherType::SshKey(Box::new(crate::SshKey { - private_key: s.private_key, - public_key: s.public_key, - fingerprint: s.fingerprint, - })) + let s = require!(view.ssh_key); + crate::CipherType::SshKey(Box::new(s.into())) } }; Ok(Self { - id: require!(value.id), - folder_id: value.folder_id, - name: value.name, - notes: value.notes, + id: require!(view.id), + folder_id: view.folder_id, + name: view.name, + notes: view.notes, r#type: r, - favorite: value.favorite, - reprompt: value.reprompt as u8, - fields: value + favorite: view.favorite, + reprompt: view.reprompt as u8, + fields: view .fields .unwrap_or_default() .into_iter() .map(|f| f.into()) .collect(), - revision_date: value.revision_date, - creation_date: value.creation_date, - deleted_date: value.deleted_date, + revision_date: view.revision_date, + creation_date: view.creation_date, + deleted_date: view.deleted_date, }) } } +impl From for crate::LoginUri { + fn from(value: LoginUriView) -> Self { + Self { + r#match: value.r#match.map(|v| v as u8), + uri: value.uri, + } + } +} + +impl From for crate::Fido2Credential { + fn from(value: Fido2CredentialFullView) -> Self { + Self { + credential_id: value.credential_id, + key_type: value.key_type, + key_algorithm: value.key_algorithm, + key_curve: value.key_curve, + key_value: value.key_value, + rp_id: value.rp_id, + user_handle: value.user_handle, + user_name: value.user_name, + counter: value.counter.parse().expect("Invalid counter"), + rp_name: value.rp_name, + user_display_name: value.user_display_name, + discoverable: value.discoverable, + creation_date: value.creation_date, + } + } +} + +impl From for crate::SecureNote { + fn from(view: SecureNoteView) -> Self { + crate::SecureNote { + r#type: view.r#type.into(), + } + } +} + +impl From for crate::Card { + fn from(view: CardView) -> Self { + crate::Card { + cardholder_name: view.cardholder_name, + exp_month: view.exp_month, + exp_year: view.exp_year, + code: view.code, + brand: view.brand, + number: view.number, + } + } +} + +impl From for crate::Identity { + fn from(view: IdentityView) -> Self { + crate::Identity { + title: view.title, + first_name: view.first_name, + middle_name: view.middle_name, + last_name: view.last_name, + address1: view.address1, + address2: view.address2, + address3: view.address3, + city: view.city, + state: view.state, + postal_code: view.postal_code, + country: view.country, + company: view.company, + email: view.email, + phone: view.phone, + ssn: view.ssn, + username: view.username, + passport_number: view.passport_number, + license_number: view.license_number, + } + } +} + +impl From for crate::SshKey { + fn from(view: SshKeyView) -> Self { + crate::SshKey { + private_key: view.private_key, + public_key: view.public_key, + fingerprint: view.fingerprint, + } + } +} + impl From for crate::Field { fn from(value: FieldView) -> Self { Self { @@ -116,15 +187,6 @@ impl From for crate::Field { } } -impl From for crate::LoginUri { - fn from(value: LoginUriView) -> Self { - Self { - r#match: value.r#match.map(|v| v as u8), - uri: value.uri, - } - } -} - impl From for crate::SecureNoteType { fn from(value: SecureNoteType) -> Self { match value { @@ -135,8 +197,10 @@ impl From for crate::SecureNoteType { #[cfg(test)] mod tests { + use bitwarden_crypto::{CryptoError, KeyContainer, KeyEncryptable, SymmetricCryptoKey}; use bitwarden_vault::{CipherRepromptType, LoginView}; use chrono::{DateTime, Utc}; + use uuid::Uuid; use super::*; @@ -157,8 +221,17 @@ mod tests { assert_eq!(f.name, "test_name".to_string()); } + struct MockKeyContainer(SymmetricCryptoKey); + impl KeyContainer for MockKeyContainer { + fn get_key<'a>(&'a self, _: &Option) -> Result<&'a SymmetricCryptoKey, CryptoError> { + Ok(&self.0) + } + } + #[test] fn test_try_from_cipher_view_login() { + let enc = MockKeyContainer(SymmetricCryptoKey::generate(rand::thread_rng())); + let cipher_view = CipherView { r#type: CipherType::Login, login: Some(LoginView { @@ -194,8 +267,11 @@ mod tests { deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), }; + let encrypted = cipher_view + .encrypt_with_key(enc.get_key(&None).unwrap()) + .unwrap(); - let cipher: crate::Cipher = cipher_view.try_into().unwrap(); + let cipher: crate::Cipher = crate::Cipher::from_cipher(&enc, encrypted).unwrap(); assert_eq!( cipher.id, diff --git a/crates/bitwarden-vault/src/cipher/mod.rs b/crates/bitwarden-vault/src/cipher/mod.rs index a4a6088d..fa2ce0d2 100644 --- a/crates/bitwarden-vault/src/cipher/mod.rs +++ b/crates/bitwarden-vault/src/cipher/mod.rs @@ -13,10 +13,13 @@ pub(crate) mod ssh_key; pub use attachment::{ Attachment, AttachmentEncryptResult, AttachmentFile, AttachmentFileView, AttachmentView, }; +pub use card::CardView; pub use cipher::{Cipher, CipherError, CipherListView, CipherRepromptType, CipherType, CipherView}; pub use field::FieldView; +pub use identity::IdentityView; pub use login::{ Fido2Credential, Fido2CredentialFullView, Fido2CredentialNewView, Fido2CredentialView, Login, LoginUriView, LoginView, }; -pub use secure_note::SecureNoteType; +pub use secure_note::{SecureNoteType, SecureNoteView}; +pub use ssh_key::SshKeyView;