diff --git a/Cargo.lock b/Cargo.lock index d2eb06438..84a308020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -2633,16 +2633,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "1.0.3" @@ -3255,8 +3245,8 @@ dependencies = [ [[package]] name = "passkey" -version = "0.2.0" -source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" +version = "0.5.0" +source = "git+https://github.com/iinuwa/passkey-rs?rev=99829ad1886ecd695564a226c807b87ec3e2cff7#99829ad1886ecd695564a226c807b87ec3e2cff7" dependencies = [ "passkey-authenticator", "passkey-client", @@ -3266,8 +3256,8 @@ dependencies = [ [[package]] name = "passkey-authenticator" -version = "0.2.0" -source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" +version = "0.5.0" +source = "git+https://github.com/iinuwa/passkey-rs?rev=99829ad1886ecd695564a226c807b87ec3e2cff7#99829ad1886ecd695564a226c807b87ec3e2cff7" dependencies = [ "async-trait", "coset", @@ -3279,12 +3269,13 @@ dependencies = [ [[package]] name = "passkey-client" -version = "0.2.0" -source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" +version = "0.5.0" +source = "git+https://github.com/iinuwa/passkey-rs?rev=99829ad1886ecd695564a226c807b87ec3e2cff7#99829ad1886ecd695564a226c807b87ec3e2cff7" dependencies = [ "ciborium", "coset", - "idna 0.5.0", + "idna", + "itertools 0.14.0", "nom", "passkey-authenticator", "passkey-types", @@ -3297,24 +3288,27 @@ dependencies = [ [[package]] name = "passkey-transports" version = "0.1.0" -source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" +source = "git+https://github.com/iinuwa/passkey-rs?rev=99829ad1886ecd695564a226c807b87ec3e2cff7#99829ad1886ecd695564a226c807b87ec3e2cff7" [[package]] name = "passkey-types" -version = "0.2.1" -source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" +version = "0.5.0" +source = "git+https://github.com/iinuwa/passkey-rs?rev=99829ad1886ecd695564a226c807b87ec3e2cff7#99829ad1886ecd695564a226c807b87ec3e2cff7" dependencies = [ "bitflags 2.9.1", "ciborium", "coset", "data-encoding", "getrandom 0.2.16", + "hmac", "indexmap 2.9.0", "rand 0.8.5", "serde", "serde_json", "sha2", "strum", + "url", + "zeroize", ] [[package]] @@ -3594,8 +3588,8 @@ dependencies = [ [[package]] name = "public-suffix" -version = "0.1.1" -source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" +version = "0.1.3" +source = "git+https://github.com/iinuwa/passkey-rs?rev=99829ad1886ecd695564a226c807b87ec3e2cff7#99829ad1886ecd695564a226c807b87ec3e2cff7" [[package]] name = "quick-xml" @@ -5107,27 +5101,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -5307,8 +5286,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna 1.0.3", + "idna", "percent-encoding", + "serde", ] [[package]] @@ -5341,7 +5321,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" dependencies = [ - "idna 1.0.3", + "idna", "once_cell", "regex", "serde", diff --git a/crates/bitwarden-api-api/src/models/cipher_fido2_credential_model.rs b/crates/bitwarden-api-api/src/models/cipher_fido2_credential_model.rs index 09e761b41..c7d77667b 100644 --- a/crates/bitwarden-api-api/src/models/cipher_fido2_credential_model.rs +++ b/crates/bitwarden-api-api/src/models/cipher_fido2_credential_model.rs @@ -38,6 +38,8 @@ pub struct CipherFido2CredentialModel { pub counter: Option, #[serde(rename = "discoverable", skip_serializing_if = "Option::is_none")] pub discoverable: Option, + #[serde(rename = "hmac_secret", skip_serializing_if = "Option::is_none")] + pub hmac_secret: Option, #[serde(rename = "creationDate")] pub creation_date: String, } @@ -57,6 +59,7 @@ impl CipherFido2CredentialModel { user_display_name: None, counter: None, discoverable: None, + hmac_secret: None, creation_date, } } diff --git a/crates/bitwarden-core/src/key_management/crypto.rs b/crates/bitwarden-core/src/key_management/crypto.rs index aa53b9245..b6fa67a10 100644 --- a/crates/bitwarden-core/src/key_management/crypto.rs +++ b/crates/bitwarden-core/src/key_management/crypto.rs @@ -8,9 +8,10 @@ use std::collections::HashMap; use bitwarden_crypto::{ AsymmetricCryptoKey, CoseSerializable, CryptoError, EncString, Kdf, KeyDecryptable, - KeyEncryptable, MasterKey, Pkcs8PrivateKeyBytes, PrimitiveEncryptable, SignatureAlgorithm, - SignedPublicKey, SigningKey, SpkiPublicKeyBytes, SymmetricCryptoKey, UnsignedSharedKey, - UserKey, dangerous_get_v2_rotated_account_keys, safe::PasswordProtectedKeyEnvelopeError, + KeyEncryptable, MasterKey, Pkcs8PrivateKeyBytes, PrimitiveEncryptable, RotateableKeySet, + SignatureAlgorithm, SignedPublicKey, SigningKey, SpkiPublicKeyBytes, SymmetricCryptoKey, + UnsignedSharedKey, UserKey, dangerous_get_v2_rotated_account_keys, + derive_symmetric_key_from_prf, safe::PasswordProtectedKeyEnvelopeError, }; use bitwarden_encoding::B64; use bitwarden_error::bitwarden_error; @@ -498,6 +499,16 @@ fn derive_pin_protected_user_key( Ok(derived_key.encrypt_user_key(user_key)?) } +pub(super) fn make_prf_user_key_set( + client: &Client, + prf: B64, +) -> Result { + let prf_key = derive_symmetric_key_from_prf(prf.as_bytes())?; + let ctx = client.internal.get_key_store().context(); + let key_set = RotateableKeySet::new(&ctx, &prf_key, SymmetricKeyId::User)?; + Ok(key_set) +} + #[allow(missing_docs)] #[bitwarden_error(flat)] #[derive(Debug, thiserror::Error)] diff --git a/crates/bitwarden-core/src/key_management/crypto_client.rs b/crates/bitwarden-core/src/key_management/crypto_client.rs index 08a18168f..712a86006 100644 --- a/crates/bitwarden-core/src/key_management/crypto_client.rs +++ b/crates/bitwarden-core/src/key_management/crypto_client.rs @@ -1,4 +1,4 @@ -use bitwarden_crypto::{CryptoError, Decryptable, Kdf}; +use bitwarden_crypto::{CryptoError, Decryptable, Kdf, RotateableKeySet}; #[cfg(feature = "internal")] use bitwarden_crypto::{EncString, UnsignedSharedKey}; use bitwarden_encoding::B64; @@ -18,7 +18,7 @@ use crate::key_management::{ crypto::{ DerivePinKeyResponse, InitOrgCryptoRequest, InitUserCryptoRequest, UpdatePasswordResponse, derive_pin_key, derive_pin_user_key, enroll_admin_password_reset, get_user_encryption_key, - initialize_org_crypto, initialize_user_crypto, + initialize_org_crypto, initialize_user_crypto, make_prf_user_key_set, }, }; use crate::{ @@ -172,6 +172,12 @@ impl CryptoClient { derive_pin_user_key(&self.client, encrypted_pin) } + /// Creates a new rotateable key set for the current user key protected + /// by a key derived from the given PRF. + pub fn make_prf_user_key_set(&self, prf: B64) -> Result { + make_prf_user_key_set(&self.client, prf) + } + /// Prepares the account for being enrolled in the admin password reset feature. This encrypts /// the users [UserKey][bitwarden_crypto::UserKey] with the organization's public key. pub fn enroll_admin_password_reset( diff --git a/crates/bitwarden-crypto/src/keys/mod.rs b/crates/bitwarden-crypto/src/keys/mod.rs index 1e6cda4db..f0a263ddb 100644 --- a/crates/bitwarden-crypto/src/keys/mod.rs +++ b/crates/bitwarden-crypto/src/keys/mod.rs @@ -33,4 +33,6 @@ pub use kdf::{ default_pbkdf2_iterations, }; pub(crate) use key_id::{KEY_ID_SIZE, KeyId}; +mod prf; pub(crate) mod utils; +pub use prf::derive_symmetric_key_from_prf; diff --git a/crates/bitwarden-crypto/src/keys/prf.rs b/crates/bitwarden-crypto/src/keys/prf.rs new file mode 100644 index 000000000..188b2ffc2 --- /dev/null +++ b/crates/bitwarden-crypto/src/keys/prf.rs @@ -0,0 +1,56 @@ +use crate::{CryptoError, SymmetricCryptoKey, utils::stretch_key}; + +/// Takes the output of a PRF and derives a symmetric key. +/// +/// The PRF output must be at least 32 bytes long. +pub fn derive_symmetric_key_from_prf(prf: &[u8]) -> Result { + let (secret, _) = prf.split_at_checked(32).ok_or(CryptoError::InvalidKeyLen)?; + let secret: [u8; 32] = secret.try_into().expect("length to be 32 bytes"); + // Don't allow uninitialized PRFs + if secret.iter().all(|b| *b == b'\0') { + return Err(CryptoError::ZeroNumber); + } + Ok(SymmetricCryptoKey::Aes256CbcHmacKey(stretch_key( + &Box::pin(secret.into()), + )?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prf_succeeds() { + let prf = pseudorandom_bytes(32); + let key = derive_symmetric_key_from_prf(&prf).unwrap(); + assert!(matches!(key, SymmetricCryptoKey::Aes256CbcHmacKey(_))); + } + + #[test] + fn test_zero_key_fails() { + let prf: Vec = (0..32).map(|_| 0).collect(); + let err = derive_symmetric_key_from_prf(&prf).unwrap_err(); + assert!(matches!(err, CryptoError::ZeroNumber)); + } + + #[test] + fn test_short_prf_fails() { + let prf = pseudorandom_bytes(9); + let err = derive_symmetric_key_from_prf(&prf).unwrap_err(); + assert!(matches!(err, CryptoError::InvalidKeyLen)); + } + + #[test] + fn test_long_prf_truncated_to_proper_length() { + let long_prf = pseudorandom_bytes(33); + let prf = pseudorandom_bytes(32); + let key1 = derive_symmetric_key_from_prf(&long_prf).unwrap(); + let key2 = derive_symmetric_key_from_prf(&prf).unwrap(); + assert_eq!(key1, key2); + } + + /// This returns the same bytes deterministically for a given length. + fn pseudorandom_bytes(len: usize) -> Vec { + (0..len).map(|x| (x % 255) as u8).collect() + } +} diff --git a/crates/bitwarden-crypto/src/lib.rs b/crates/bitwarden-crypto/src/lib.rs index 34de79131..a76d20168 100644 --- a/crates/bitwarden-crypto/src/lib.rs +++ b/crates/bitwarden-crypto/src/lib.rs @@ -32,7 +32,8 @@ mod wordlist; pub use wordlist::EFF_LONG_WORD_LIST; mod store; pub use store::{ - KeyStore, KeyStoreContext, RotatedUserKeys, dangerous_get_v2_rotated_account_keys, + KeyStore, KeyStoreContext, RotateableKeySet, RotatedUserKeys, + dangerous_get_v2_rotated_account_keys, }; mod cose; pub use cose::CoseSerializable; diff --git a/crates/bitwarden-crypto/src/store/key_rotation.rs b/crates/bitwarden-crypto/src/store/key_rotation.rs index 0d97331c2..a3d0d27b2 100644 --- a/crates/bitwarden-crypto/src/store/key_rotation.rs +++ b/crates/bitwarden-crypto/src/store/key_rotation.rs @@ -1,7 +1,10 @@ +use serde::{Deserialize, Serialize}; + use crate::{ - CoseKeyBytes, CoseSerializable, CryptoError, EncString, KeyEncryptable, KeyIds, - KeyStoreContext, SignedPublicKey, SignedPublicKeyMessage, SpkiPublicKeyBytes, - SymmetricCryptoKey, + AsymmetricCryptoKey, AsymmetricPublicCryptoKey, CoseKeyBytes, CoseSerializable, CryptoError, + EncString, KeyDecryptable, KeyEncryptable, KeyIds, KeyStoreContext, Pkcs8PrivateKeyBytes, + SignedPublicKey, SignedPublicKeyMessage, SpkiPublicKeyBytes, SymmetricCryptoKey, + UnsignedSharedKey, }; /// Rotated set of account keys @@ -45,6 +48,122 @@ pub fn dangerous_get_v2_rotated_account_keys( }) } +/// A set of keys where a given `DownstreamKey` is protected by an encrypted public/private +/// key-pair. The `DownstreamKey` is used to encrypt/decrypt data, while the public/private key-pair +/// is used to rotate the `DownstreamKey`. +/// +/// The `PrivateKey` is protected by an `UpstreamKey`, such as a `DeviceKey`, or `PrfKey`, +/// and the `PublicKey` is protected by the `DownstreamKey`. This setup allows: +/// +/// - Access to `DownstreamKey` by knowing the `UpstreamKey` +/// - Rotation to a `NewDownstreamKey` by knowing the current `DownstreamKey`, without needing +/// access to the `UpstreamKey` +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct RotateableKeySet { + /// `DownstreamKey` protected by encapsulation key + encapsulated_downstream_key: UnsignedSharedKey, + /// Encapsulation key protected by `DownstreamKey` + encrypted_encapsulation_key: EncString, + /// Decapsulation key protected by `UpstreamKey` + encrypted_decapsulation_key: EncString, +} + +impl RotateableKeySet { + /// Create a set of keys to allow access to the downstream key via the provided + /// upstream key while allowing the downstream key to be rotated. + pub fn new( + ctx: &KeyStoreContext, + upstream_key: &SymmetricCryptoKey, + downstream_key_id: Ids::Symmetric, + ) -> Result { + let key_pair = AsymmetricCryptoKey::make(crate::PublicKeyEncryptionAlgorithm::RsaOaepSha1); + + // This uses this deprecated method and other methods directly on the other keys + // rather than the key store context because we don't want the keys to + // wind up being stored in the borrowed context. + #[allow(deprecated)] + let downstream_key = ctx.dangerous_get_symmetric_key(downstream_key_id)?; + // encapsulate downstream key + let encapsulated_downstream_key = + UnsignedSharedKey::encapsulate_key_unsigned(downstream_key, &key_pair.to_public_key())?; + + // wrap decapsulation key with upstream key + let encrypted_decapsulation_key = key_pair.to_der()?.encrypt_with_key(upstream_key)?; + + // wrap encapsulation key with downstream key + // Note: Usually, a public key is - by definition - public, so this should not be necessary. + // The specific use-case for this function is to enable rotateable key sets, where + // the "public key" is not public, with the intent of preventing the server from being able + // to overwrite the downstream key unlocked by the rotateable keyset. + let encrypted_encapsulation_key = key_pair + .to_public_key() + .to_der()? + .encrypt_with_key(downstream_key)?; + + Ok(RotateableKeySet { + encapsulated_downstream_key, + encrypted_encapsulation_key, + encrypted_decapsulation_key, + }) + } + + // TODO: Eventually, the webauthn-login-strategy service should be migrated + // to use this method, and we can remove the #[allow(dead_code)] attribute. + #[allow(dead_code)] + fn unlock( + &self, + ctx: &mut KeyStoreContext, + upstream_key: &SymmetricCryptoKey, + downstream_key_id: Ids::Symmetric, + ) -> Result<(), CryptoError> { + let priv_key_bytes: Vec = self + .encrypted_decapsulation_key + .decrypt_with_key(upstream_key)?; + let decapsulation_key = + AsymmetricCryptoKey::from_der(&Pkcs8PrivateKeyBytes::from(priv_key_bytes))?; + let downstream_key = self + .encapsulated_downstream_key + .decapsulate_key_unsigned(&decapsulation_key)?; + #[allow(deprecated)] + ctx.set_symmetric_key(downstream_key_id, downstream_key)?; + Ok(()) + } +} + +#[allow(dead_code)] +fn rotate_key_set( + ctx: &KeyStoreContext, + key_set: RotateableKeySet, + old_downstream_key_id: Ids::Symmetric, + new_downstream_key_id: Ids::Symmetric, +) -> Result { + let pub_key_bytes = ctx.decrypt_data_with_symmetric_key( + old_downstream_key_id, + &key_set.encrypted_encapsulation_key, + )?; + let pub_key = SpkiPublicKeyBytes::from(pub_key_bytes); + let encapsulation_key = AsymmetricPublicCryptoKey::from_der(&pub_key)?; + // TODO: There is no method to store only the public key in the store, so we + // have pull out the downstream key to encapsulate it manually. + #[allow(deprecated)] + let new_downstream_key = ctx.dangerous_get_symmetric_key(new_downstream_key_id)?; + let new_encapsulated_key = + UnsignedSharedKey::encapsulate_key_unsigned(new_downstream_key, &encapsulation_key)?; + let new_encrypted_encapsulation_key = pub_key.encrypt_with_key(new_downstream_key)?; + Ok(RotateableKeySet { + encapsulated_downstream_key: new_encapsulated_key, + encrypted_encapsulation_key: new_encrypted_encapsulation_key, + encrypted_decapsulation_key: key_set.encrypted_decapsulation_key, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -137,4 +256,79 @@ mod tests { .unwrap() ); } + + #[test] + fn test_rotateable_key_set_can_unlock() { + // generate initial keys + let upstream_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key(); + // set up store + let store: KeyStore = KeyStore::default(); + let mut ctx = store.context_mut(); + let original_downstream_key_id = TestSymmKey::A(0); + ctx.generate_symmetric_key(original_downstream_key_id) + .unwrap(); + + // create key set + let key_set = + RotateableKeySet::new(&ctx, &upstream_key, original_downstream_key_id).unwrap(); + + // unlock key set + let unwrapped_downstream_key_id = TestSymmKey::A(1); + key_set + .unlock(&mut ctx, &upstream_key, unwrapped_downstream_key_id) + .unwrap(); + + #[allow(deprecated)] + let original_downstream_key = ctx + .dangerous_get_symmetric_key(original_downstream_key_id) + .unwrap(); + #[allow(deprecated)] + let unwrapped_downstream_key = ctx + .dangerous_get_symmetric_key(unwrapped_downstream_key_id) + .unwrap(); + assert_eq!(original_downstream_key, unwrapped_downstream_key); + } + + #[test] + fn test_rotateable_key_set_rotation() { + // generate initial keys + let upstream_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key(); + // set up store + let store: KeyStore = KeyStore::default(); + let mut ctx = store.context_mut(); + let original_downstream_key_id = TestSymmKey::A(1); + ctx.generate_symmetric_key(original_downstream_key_id) + .unwrap(); + + // create key set + let key_set = + RotateableKeySet::new(&ctx, &upstream_key, original_downstream_key_id).unwrap(); + + // rotate + let new_downstream_key_id = TestSymmKey::A(2_1); + ctx.generate_symmetric_key(new_downstream_key_id).unwrap(); + let new_key_set = rotate_key_set( + &ctx, + key_set, + original_downstream_key_id, + new_downstream_key_id, + ) + .unwrap(); + + // After rotation, the new key set should be unlocked by the same + // upstream key and return the new downstream key. + let unwrapped_downstream_key_id = TestSymmKey::A(2_2); + new_key_set + .unlock(&mut ctx, &upstream_key, unwrapped_downstream_key_id) + .unwrap(); + #[allow(deprecated)] + let new_downstream_key = ctx + .dangerous_get_symmetric_key(new_downstream_key_id) + .unwrap(); + #[allow(deprecated)] + let unwrapped_downstream_key = ctx + .dangerous_get_symmetric_key(unwrapped_downstream_key_id) + .unwrap(); + assert_eq!(new_downstream_key, unwrapped_downstream_key); + } } diff --git a/crates/bitwarden-exporters/src/cxf/export.rs b/crates/bitwarden-exporters/src/cxf/export.rs index ea066161a..6b884a897 100644 --- a/crates/bitwarden-exporters/src/cxf/export.rs +++ b/crates/bitwarden-exporters/src/cxf/export.rs @@ -210,6 +210,7 @@ mod tests { rp_name: None, user_display_name: None, discoverable: "true".to_string(), + hmac_secret: Some("AAECAwQFBg".to_string()), creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(), }]), })), diff --git a/crates/bitwarden-exporters/src/cxf/login.rs b/crates/bitwarden-exporters/src/cxf/login.rs index d2eb81391..43d7e9735 100644 --- a/crates/bitwarden-exporters/src/cxf/login.rs +++ b/crates/bitwarden-exporters/src/cxf/login.rs @@ -89,6 +89,11 @@ pub(super) fn to_login( rp_name: Some(p.rp_id.clone()), user_display_name: Some(p.user_display_name.clone()), discoverable: "true".to_string(), + hmac_secret: p + .fido2_extensions + .as_ref() + .and_then(|ext| ext.hmac_credentials.as_ref()) + .map(|s| s.cred_with_uv.to_string()), creation_date, }] }), @@ -283,6 +288,7 @@ mod tests { rp_name: None, user_display_name: None, discoverable: "true".to_string(), + hmac_secret: Some("AAECAwQFBg".to_string()), creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(), }; diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs index 5c30c9644..a4dfb05ac 100644 --- a/crates/bitwarden-exporters/src/lib.rs +++ b/crates/bitwarden-exporters/src/lib.rs @@ -223,6 +223,7 @@ impl From for CipherView { Self { id: None, + device_bound: false, organization_id: None, folder_id: value.folder_id.map(FolderId::new), collection_ids: vec![], @@ -345,6 +346,7 @@ pub struct Fido2Credential { pub rp_name: Option, pub user_display_name: Option, pub discoverable: String, + pub hmac_secret: Option, pub creation_date: DateTime, } @@ -363,6 +365,7 @@ impl From for Fido2CredentialFullView { rp_name: value.rp_name, user_display_name: value.user_display_name, discoverable: value.discoverable, + hmac_secret: value.hmac_secret, creation_date: value.creation_date, } } diff --git a/crates/bitwarden-exporters/src/models.rs b/crates/bitwarden-exporters/src/models.rs index 0c069a535..ec9cbc368 100644 --- a/crates/bitwarden-exporters/src/models.rs +++ b/crates/bitwarden-exporters/src/models.rs @@ -116,6 +116,7 @@ impl From for crate::Fido2Credential { rp_name: value.rp_name, user_display_name: value.user_display_name, discoverable: value.discoverable, + hmac_secret: value.hmac_secret, creation_date: value.creation_date, } } @@ -258,6 +259,7 @@ mod tests { }), id: Some(CipherId::new(test_id)), organization_id: None, + device_bound: false, folder_id: None, collection_ids: vec![], key: None, @@ -310,6 +312,7 @@ mod tests { }), id: Some(CipherId::new(test_id)), organization_id: None, + device_bound: false, folder_id: None, collection_ids: vec![], key: None, diff --git a/crates/bitwarden-fido/Cargo.toml b/crates/bitwarden-fido/Cargo.toml index 9d8637b96..5a715cd8f 100644 --- a/crates/bitwarden-fido/Cargo.toml +++ b/crates/bitwarden-fido/Cargo.toml @@ -28,8 +28,8 @@ coset = ">=0.3.7, <0.4" itertools = ">=0.13.0, <0.15" log = { workspace = true } p256 = ">=0.13.2, <0.14" -passkey = { git = "https://github.com/bitwarden/passkey-rs", rev = "3b764633ebc6576c07bdd12ee14d8e5c87b494ed" } -passkey-client = { git = "https://github.com/bitwarden/passkey-rs", rev = "3b764633ebc6576c07bdd12ee14d8e5c87b494ed", features = [ +passkey = { git = "https://github.com/iinuwa/passkey-rs", rev = "99829ad1886ecd695564a226c807b87ec3e2cff7" } +passkey-client = { git = "https://github.com/iinuwa/passkey-rs", rev = "99829ad1886ecd695564a226c807b87ec3e2cff7", features = [ "android-asset-validation", ] } reqwest = { workspace = true } diff --git a/crates/bitwarden-fido/src/authenticator.rs b/crates/bitwarden-fido/src/authenticator.rs index 6093142e6..b8e345251 100644 --- a/crates/bitwarden-fido/src/authenticator.rs +++ b/crates/bitwarden-fido/src/authenticator.rs @@ -1,15 +1,21 @@ use std::sync::Mutex; -use bitwarden_core::Client; -use bitwarden_crypto::CryptoError; +use bitwarden_core::{Client, key_management::SymmetricKeyId}; +use bitwarden_crypto::{CompositeEncryptable, CryptoError, SymmetricCryptoKey}; use bitwarden_vault::{CipherError, CipherView, EncryptionContext}; use itertools::Itertools; use log::error; use passkey::{ - authenticator::{Authenticator, DiscoverabilitySupport, StoreInfo, UIHint, UserCheck}, + authenticator::{ + Authenticator, DiscoverabilitySupport, StoreInfo, UIHint, UserCheck, + extensions::HmacSecretConfig, + }, types::{ Passkey, - ctap2::{self, Ctap2Error, StatusCode, VendorError}, + ctap2::{ + self, Ctap2Error, StatusCode, VendorError, get_assertion, + make_credential::ExtensionInputs, + }, }, }; use thiserror::Error; @@ -101,6 +107,7 @@ pub struct Fido2Authenticator<'a> { pub client: &'a Client, pub user_interface: &'a dyn Fido2UserInterface, pub credential_store: &'a dyn Fido2CredentialStore, + pub encryption_key: Option, pub(crate) selected_cipher: Mutex>, pub(crate) requested_uv: Mutex>, @@ -112,11 +119,13 @@ impl<'a> Fido2Authenticator<'a> { client: &'a Client, user_interface: &'a dyn Fido2UserInterface, credential_store: &'a dyn Fido2CredentialStore, + encryption_key: Option, ) -> Fido2Authenticator<'a> { Fido2Authenticator { client, user_interface, credential_store, + encryption_key, selected_cipher: Mutex::new(None), requested_uv: Mutex::new(None), } @@ -156,10 +165,7 @@ impl<'a> Fido2Authenticator<'a> { .exclude_list .map(|x| x.into_iter().map(TryInto::try_into).collect()) .transpose()?, - extensions: request - .extensions - .map(|e| serde_json::from_str(&e)) - .transpose()?, + extensions: request.extensions.map(ExtensionInputs::from), options: passkey::types::ctap2::make_credential::Options { rk: request.options.rk, up: true, @@ -182,11 +188,13 @@ impl<'a> Fido2Authenticator<'a> { .attested_credential_data .ok_or(MakeCredentialError::MissingAttestedCredentialData)?; let credential_id = attested_credential_data.credential_id().to_vec(); + let extensions = response.unsigned_extension_outputs.into(); Ok(MakeCredentialResult { authenticator_data, attestation_object, credential_id, + extensions, }) } @@ -215,10 +223,7 @@ impl<'a> Fido2Authenticator<'a> { .collect::, _>>() }) .transpose()?, - extensions: request - .extensions - .map(|e| serde_json::from_str(&e)) - .transpose()?, + extensions: request.extensions.map(get_assertion::ExtensionInputs::from), options: passkey::types::ctap2::make_credential::Options { rk: request.options.rk, up: true, @@ -237,6 +242,7 @@ impl<'a> Fido2Authenticator<'a> { let selected_credential = self.get_selected_credential()?; let authenticator_data = response.auth_data.to_vec(); let credential_id = string_to_guid_bytes(&selected_credential.credential.credential_id)?; + let extensions = response.unsigned_extension_outputs.into(); Ok(GetAssertionResult { credential_id, @@ -248,6 +254,7 @@ impl<'a> Fido2Authenticator<'a> { .id .into(), selected_credential, + extensions, }) } @@ -303,6 +310,7 @@ impl<'a> Fido2Authenticator<'a> { authenticator: self, }, ) + .hmac_secret(HmacSecretConfig::new_with_uv_only().enable_on_make_credential()) } async fn convert_requested_uv(&self, uv: UV) -> bool { @@ -327,7 +335,15 @@ impl<'a> Fido2Authenticator<'a> { .clone() .ok_or(GetSelectedCredentialError::NoSelectedCredential)?; - let creds = cipher.decrypt_fido2_credentials(&mut key_store.context())?; + let mut ctx = if let Some(encryption_key) = &self.encryption_key { + let key = SymmetricKeyId::Local("device_key"); + let mut ctx = key_store.context(); + ctx.set_symmetric_key(key, encryption_key.clone())?; + ctx + } else { + key_store.context() + }; + let creds = cipher.decrypt_fido2_credentials(&mut ctx)?; let credential = creds .first() @@ -353,6 +369,7 @@ impl passkey::authenticator::CredentialStore for CredentialStoreImpl<'_> { &self, ids: Option<&[passkey::types::webauthn::PublicKeyCredentialDescriptor]>, rp_id: &str, + _user_handle: Option<&[u8]>, ) -> Result, StatusCode> { #[derive(Debug, Error)] enum InnerError { @@ -394,9 +411,13 @@ impl passkey::authenticator::CredentialStore for CredentialStoreImpl<'_> { // When using the credential for authentication we have to ask the user to pick one. if this.create_credential { + let mut ctx = key_store.context(); + if let Some(device_key) = &this.authenticator.encryption_key { + ctx.set_symmetric_key(SymmetricKeyId::Local("device_key"), device_key.clone())?; + } Ok(creds .into_iter() - .map(|c| CipherViewContainer::new(c, &mut key_store.context())) + .map(|c| CipherViewContainer::new(c, &mut ctx)) .collect::>()?) } else { let picked = this @@ -412,10 +433,11 @@ impl passkey::authenticator::CredentialStore for CredentialStoreImpl<'_> { .expect("Mutex is not poisoned") .replace(picked.clone()); - Ok(vec![CipherViewContainer::new( - picked, - &mut key_store.context(), - )?]) + let mut ctx = key_store.context(); + if let Some(device_key) = &this.authenticator.encryption_key { + ctx.set_symmetric_key(SymmetricKeyId::Local("device_key"), device_key.clone())?; + } + Ok(vec![CipherViewContainer::new(picked, &mut ctx)?]) } } @@ -478,7 +500,13 @@ impl passkey::authenticator::CredentialStore for CredentialStoreImpl<'_> { let key_store = this.authenticator.client.internal.get_key_store(); - selected.set_new_fido2_credentials(&mut key_store.context(), vec![cred])?; + { + let mut ctx = key_store.context(); + if let Some(device_key) = &this.authenticator.encryption_key { + ctx.set_symmetric_key(SymmetricKeyId::Local("device_key"), device_key.clone())?; + } + selected.set_new_fido2_credentials(&mut ctx, vec![cred])?; + } // Store the updated credential for later use this.authenticator @@ -487,13 +515,20 @@ impl passkey::authenticator::CredentialStore for CredentialStoreImpl<'_> { .expect("Mutex is not poisoned") .replace(selected.clone()); - // Encrypt the updated cipher before sending it to the clients to be stored - let encrypted = key_store.encrypt(selected)?; + let cipher = if let Some(encryption_key) = &this.authenticator.encryption_key { + let key = SymmetricKeyId::Local("device_key"); + let mut ctx = key_store.context(); + ctx.set_symmetric_key(key, encryption_key.clone())?; + selected.encrypt_composite(&mut ctx, key)? + } else { + // Encrypt the updated cipher before sending it to the clients to be stored + key_store.encrypt(selected)? + }; this.authenticator .credential_store .save_credential(EncryptionContext { - cipher: encrypted, + cipher, encrypted_for: user_id, }) .await?; @@ -621,13 +656,16 @@ impl passkey::authenticator::UserValidationMethod for UserValidationMethodImpl<' let new_credential = try_from_credential_new_view(user, rp) .map_err(|_| Ctap2Error::InvalidCredential)?; - let (cipher_view, user_check) = self + let (mut cipher_view, user_check) = self .authenticator .user_interface .check_user_and_pick_credential_for_creation(options, new_credential) .await .map_err(|_| Ctap2Error::OperationDenied)?; + if self.authenticator.encryption_key.is_some() { + cipher_view.device_bound = true; + } self.authenticator .selected_cipher .lock() diff --git a/crates/bitwarden-fido/src/client.rs b/crates/bitwarden-fido/src/client.rs index 9e58fdaef..59a0ba245 100644 --- a/crates/bitwarden-fido/src/client.rs +++ b/crates/bitwarden-fido/src/client.rs @@ -130,10 +130,7 @@ impl Fido2Client<'_> { cred_props: result .client_extension_results .cred_props - .map(|c| CredPropsResult { - rk: c.discoverable, - authenticator_display_name: c.authenticator_display_name, - }), + .map(|c| CredPropsResult { rk: c.discoverable }), }, response: AuthenticatorAssertionResponse { client_data_json: result.response.client_data_json.into(), diff --git a/crates/bitwarden-fido/src/client_fido.rs b/crates/bitwarden-fido/src/client_fido.rs index c03d5627f..43e356f65 100644 --- a/crates/bitwarden-fido/src/client_fido.rs +++ b/crates/bitwarden-fido/src/client_fido.rs @@ -1,4 +1,5 @@ -use bitwarden_core::Client; +use bitwarden_core::{Client, key_management::SymmetricKeyId}; +use bitwarden_crypto::SymmetricCryptoKey; use bitwarden_vault::CipherView; use thiserror::Error; @@ -32,8 +33,14 @@ impl ClientFido2 { &'a self, user_interface: &'a dyn Fido2UserInterface, credential_store: &'a dyn Fido2CredentialStore, + encryption_key: Option, ) -> Fido2Authenticator<'a> { - Fido2Authenticator::new(&self.client, user_interface, credential_store) + Fido2Authenticator::new( + &self.client, + user_interface, + credential_store, + encryption_key, + ) } #[allow(missing_docs)] @@ -43,7 +50,7 @@ impl ClientFido2 { credential_store: &'a dyn Fido2CredentialStore, ) -> Fido2Client<'a> { Fido2Client { - authenticator: self.create_authenticator(user_interface, credential_store), + authenticator: self.create_authenticator(user_interface, credential_store, None), } } @@ -51,12 +58,22 @@ impl ClientFido2 { pub fn decrypt_fido2_autofill_credentials( &self, cipher_view: CipherView, + encryption_key: Option, ) -> Result, DecryptFido2AutofillCredentialsError> { let key_store = self.client.internal.get_key_store(); + let mut ctx = key_store.context(); + if let Some(key) = encryption_key { + ctx.set_symmetric_key(SymmetricKeyId::Local("device_key"), key.clone()) + .map_err(|err| { + DecryptFido2AutofillCredentialsError::Fido2CredentialAutofillView( + Fido2CredentialAutofillViewError::Crypto(err), + ) + })?; + } Ok(Fido2CredentialAutofillView::from_cipher_view( &cipher_view, - &mut key_store.context(), + &mut ctx, )?) } } diff --git a/crates/bitwarden-fido/src/lib.rs b/crates/bitwarden-fido/src/lib.rs index d75a5f32b..a94d00fb7 100644 --- a/crates/bitwarden-fido/src/lib.rs +++ b/crates/bitwarden-fido/src/lib.rs @@ -7,7 +7,7 @@ use bitwarden_vault::{ CipherError, CipherView, Fido2CredentialFullView, Fido2CredentialNewView, Fido2CredentialView, }; use crypto::{CoseKeyToPkcs8Error, PrivateKeyFromSecretKeyError}; -use passkey::types::{Passkey, ctap2::Aaguid}; +use passkey::types::{CredentialExtensions, Passkey, StoredHmacSecret, ctap2::Aaguid}; #[cfg(feature = "uniffi")] uniffi::setup_scaffolding!(); @@ -117,6 +117,17 @@ fn try_from_credential_full_view(value: Fido2CredentialFullView) -> Result Result = value.credential_id.into(); let key_value = B64Url::from(cose_key_to_pkcs8(&value.key)?).to_string(); let user_handle = B64Url::from(user.id.to_vec()).to_string(); + let hmac_secret = value + .extensions + .hmac_secret + .as_ref() + .map(|s| B64Url::from(s.cred_with_uv.as_ref()).to_string()); Ok(Fido2CredentialFullView { credential_id: guid_bytes_to_string(&cred_id)?, @@ -215,6 +238,7 @@ pub(crate) fn try_from_credential_full( user_name: user.name, user_display_name: user.display_name, discoverable: options.rk.to_string(), + hmac_secret, creation_date: chrono::offset::Utc::now(), }) } diff --git a/crates/bitwarden-fido/src/types.rs b/crates/bitwarden-fido/src/types.rs index ff9c95235..b3a7b02de 100644 --- a/crates/bitwarden-fido/src/types.rs +++ b/crates/bitwarden-fido/src/types.rs @@ -1,10 +1,18 @@ -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashMap}; use bitwarden_core::key_management::KeyIds; use bitwarden_crypto::{CryptoError, KeyStoreContext}; use bitwarden_encoding::{B64Url, NotB64UrlEncodedError}; use bitwarden_vault::{CipherListView, CipherListViewType, CipherView, LoginListView}; -use passkey::types::webauthn::UserVerificationRequirement; +use passkey::types::{ + Bytes, + crypto::sha256, + ctap2::{ + extensions::{AuthenticatorPrfInputs, AuthenticatorPrfValues}, + get_assertion, make_credential, + }, + webauthn::UserVerificationRequirement, +}; use reqwest::Url; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -223,8 +231,6 @@ impl TryFrom } } -pub type Extensions = Option; - #[allow(missing_docs)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct MakeCredentialRequest { @@ -234,7 +240,7 @@ pub struct MakeCredentialRequest { pub pub_key_cred_params: Vec, pub exclude_list: Option>, pub options: Options, - pub extensions: Extensions, + pub extensions: Option, } #[allow(missing_docs)] @@ -243,6 +249,82 @@ pub struct MakeCredentialResult { pub authenticator_data: Vec, pub attestation_object: Vec, pub credential_id: Vec, + /// Mix of CTAP unsigned extension output and WebAuthn client extensions + /// output returned by the authenticator + pub extensions: MakeCredentialExtensionsOutput, +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct MakeCredentialExtensionsInput { + prf: Option, +} + +impl From + for passkey::types::ctap2::make_credential::ExtensionInputs +{ + fn from(value: MakeCredentialExtensionsInput) -> Self { + Self { + hmac_secret: None, + hmac_secret_mc: None, + prf: value.prf.map(AuthenticatorPrfInputs::from), + } + } +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct MakeCredentialExtensionsOutput { + pub prf: Option, +} + +impl From> for MakeCredentialExtensionsOutput { + fn from(value: Option) -> Self { + if let Some(ext) = value { + MakeCredentialExtensionsOutput::from(ext) + } else { + MakeCredentialExtensionsOutput { prf: None } + } + } +} + +impl From for MakeCredentialExtensionsOutput { + fn from(value: make_credential::UnsignedExtensionOutputs) -> Self { + let prf = value.prf.map(|prf| MakeCredentialPrfOutput { + enabled: prf.enabled, + results: prf.results.map(|v| PrfValues { + first: v.first.to_vec(), + second: v.second.map(|second| second.to_vec()), + }), + }); + MakeCredentialExtensionsOutput { prf } + } +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct MakeCredentialPrfInput { + eval: Option, +} + +impl From for AuthenticatorPrfInputs { + fn from(value: MakeCredentialPrfInput) -> Self { + Self { + eval: value.eval.map(AuthenticatorPrfValues::from), + eval_by_credential: None, + } + } +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct MakeCredentialPrfOutput { + pub enabled: bool, + pub results: Option, } #[allow(missing_docs)] @@ -252,7 +334,7 @@ pub struct GetAssertionRequest { pub client_data_hash: Vec, pub allow_list: Option>, pub options: Options, - pub extensions: Extensions, + pub extensions: Option, } #[allow(missing_docs)] @@ -327,6 +409,87 @@ pub struct GetAssertionResult { pub user_handle: Vec, pub selected_credential: SelectedCredential, + /// Mix of CTAP unsigned extension output and WebAuthn client extension output. + /// Signed extensions can be retrieved from authenticator data. + pub extensions: GetAssertionExtensionsOutput, +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct GetAssertionExtensionsInput { + prf: Option, +} + +impl From for get_assertion::ExtensionInputs { + fn from(value: GetAssertionExtensionsInput) -> Self { + Self { + hmac_secret: None, + prf: value.prf.map(AuthenticatorPrfInputs::from), + } + } +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct GetAssertionExtensionsOutput { + pub prf: Option, +} + +impl From> for GetAssertionExtensionsOutput { + fn from(value: Option) -> Self { + if let Some(value) = value { + value.into() + } else { + Self { prf: None } + } + } +} + +impl From for GetAssertionExtensionsOutput { + fn from(value: get_assertion::UnsignedExtensionOutputs) -> Self { + let prf = value.prf.map(|prf| GetAssertionPrfOutput { + results: PrfValues { + first: prf.results.first.to_vec(), + second: prf.results.second.map(|second| second.to_vec()), + }, + }); + GetAssertionExtensionsOutput { prf } + } +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct GetAssertionPrfInput { + eval: Option, + eval_by_credential: Option, PrfValues>>, +} + +impl From for AuthenticatorPrfInputs { + fn from(value: GetAssertionPrfInput) -> Self { + let eval_by_credential = if let Some(values) = value.eval_by_credential { + let map: HashMap = values + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + Some(map) + } else { + None + }; + Self { + eval: value.eval.map(AuthenticatorPrfValues::from), + eval_by_credential, + } + } +} + +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct GetAssertionPrfOutput { + pub results: PrfValues, } #[allow(missing_docs)] @@ -362,6 +525,27 @@ impl passkey::client::ClientData> for ClientData { } } +#[allow(missing_docs)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug)] +pub struct PrfValues { + pub first: Vec, + pub second: Option>, +} + +impl From for AuthenticatorPrfValues { + fn from(value: PrfValues) -> Self { + // passkey-rs expects the salt to be hashed already according to + // WebAuthn PRF extension client processing rules. + let prefix = b"WebAuthn PRF\0".as_slice(); + let first = sha256(&[prefix, value.first.as_ref()].concat()); + let second = value + .second + .map(|second| sha256(&[prefix, second.as_ref()].concat())); + Self { first, second } + } +} + #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct ClientExtensionResults { pub cred_props: Option, @@ -370,14 +554,12 @@ pub struct ClientExtensionResults { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct CredPropsResult { pub rk: Option, - pub authenticator_display_name: Option, } impl From for CredPropsResult { fn from(value: passkey::types::webauthn::CredentialPropertiesOutput) -> Self { Self { rk: value.discoverable, - authenticator_display_name: value.authenticator_display_name, } } } @@ -481,7 +663,7 @@ impl TryFrom for passkey::client::UnverifiedAssetLink<'_> { Cow::from(value.package_name), value.sha256_cert_fingerprint.as_str(), Cow::from(value.host), - asset_link_url, + asset_link_url.expect("Is asset_link_url ever null?"), ) .map_err(|e| InvalidOriginError(format!("{e:?}"))) } @@ -489,9 +671,20 @@ impl TryFrom for passkey::client::UnverifiedAssetLink<'_> { #[cfg(test)] mod tests { + use passkey::types::ctap2::{ + extensions::{ + AuthenticatorPrfGetOutputs, AuthenticatorPrfMakeOutputs, AuthenticatorPrfValues, + }, + get_assertion, make_credential, + }; use serde::{Deserialize, Serialize}; use super::AndroidClientData; + use crate::types::{ + GetAssertionExtensionsInput, GetAssertionExtensionsOutput, GetAssertionPrfInput, + MakeCredentialExtensionsInput, MakeCredentialExtensionsOutput, MakeCredentialPrfInput, + PrfValues, + }; // This is a stripped down of the passkey-rs implementation, to test the // serialization of the `ClientData` enum, and to make sure that () and None @@ -545,4 +738,121 @@ mod tests { r#"{"origin":"https://example.com","androidPackageName":"com.example.app"}"# ); } + + #[test] + fn test_transform_make_credential_extension_input() { + let salt1 = b"salt1".to_vec(); + let salt2 = b"salt2".to_vec(); + let input = MakeCredentialExtensionsInput { + prf: Some(MakeCredentialPrfInput { + eval: Some(PrfValues { + first: salt1.clone(), + second: Some(salt2.clone()), + }), + }), + }; + let transformed = make_credential::ExtensionInputs::from(input); + // SHA-256(UTF-8("WebAuthn PRF") || 0x00 || salt1) + let hashed_first = [ + 0x2A, 0x19, 0x90, 0xF9, 0xC9, 0xBB, 0xFE, 0x1B, 0xBF, 0x56, 0xAB, 0xEE, 0x2B, 0x5A, + 0x0F, 0x59, 0xBE, 0x5F, 0x63, 0x3A, 0x35, 0xC2, 0xA5, 0xF0, 0x7D, 0x85, 0x53, 0x3E, + 0xEE, 0xCB, 0xDD, 0x3C, + ]; + assert_eq!( + hashed_first, + transformed + .prf + .as_ref() + .unwrap() + .eval + .as_ref() + .unwrap() + .first + ); + // SHA-256(UTF-8("WebAuthn PRF") || 0x00 || salt2) + let hashed_second = [ + 0xA6, 0x42, 0xFA, 0x8B, 0x6E, 0xAC, 0x68, 0xD3, 0x73, 0xCF, 0x08, 0xEA, 0xC8, 0x5E, + 0x1D, 0x62, 0x9B, 0x50, 0x10, 0x6D, 0x60, 0xEB, 0x92, 0x48, 0xEC, 0xB6, 0x54, 0xE2, + 0x94, 0x9A, 0xDD, 0x65, + ]; + assert_eq!( + hashed_second, + transformed.prf.unwrap().eval.unwrap().second.unwrap() + ); + } + + #[test] + fn test_transform_make_credential_extension_output() { + let prf1: Vec = (0..32).collect(); + let output = make_credential::UnsignedExtensionOutputs { + prf: Some(AuthenticatorPrfMakeOutputs { + enabled: true, + results: Some(AuthenticatorPrfValues { + first: prf1.clone().try_into().unwrap(), + second: None, + }), + }), + }; + let transformed = MakeCredentialExtensionsOutput::from(output); + assert!(transformed.prf.as_ref().unwrap().enabled); + assert_eq!(prf1, transformed.prf.unwrap().results.unwrap().first); + } + + #[test] + fn test_transform_get_assertion_extension_input() { + let salt1 = b"salt1".to_vec(); + let salt2 = b"salt2".to_vec(); + let input = GetAssertionExtensionsInput { + prf: Some(GetAssertionPrfInput { + eval: Some(PrfValues { + first: salt1.clone(), + second: Some(salt2.clone()), + }), + eval_by_credential: None, + }), + }; + let transformed = get_assertion::ExtensionInputs::from(input); + // SHA-256(UTF-8("WebAuthn PRF") || 0x00 || salt1) + let hashed_first = [ + 0x2A, 0x19, 0x90, 0xF9, 0xC9, 0xBB, 0xFE, 0x1B, 0xBF, 0x56, 0xAB, 0xEE, 0x2B, 0x5A, + 0x0F, 0x59, 0xBE, 0x5F, 0x63, 0x3A, 0x35, 0xC2, 0xA5, 0xF0, 0x7D, 0x85, 0x53, 0x3E, + 0xEE, 0xCB, 0xDD, 0x3C, + ]; + assert_eq!( + hashed_first, + transformed + .prf + .as_ref() + .unwrap() + .eval + .as_ref() + .unwrap() + .first + ); + // SHA-256(UTF-8("WebAuthn PRF") || 0x00 || salt2) + let hashed_second = [ + 0xA6, 0x42, 0xFA, 0x8B, 0x6E, 0xAC, 0x68, 0xD3, 0x73, 0xCF, 0x08, 0xEA, 0xC8, 0x5E, + 0x1D, 0x62, 0x9B, 0x50, 0x10, 0x6D, 0x60, 0xEB, 0x92, 0x48, 0xEC, 0xB6, 0x54, 0xE2, + 0x94, 0x9A, 0xDD, 0x65, + ]; + assert_eq!( + hashed_second, + transformed.prf.unwrap().eval.unwrap().second.unwrap() + ); + } + + #[test] + fn test_transform_get_assertion_extension_output() { + let prf1: Vec = (0..32).collect(); + let output = get_assertion::UnsignedExtensionOutputs { + prf: Some(AuthenticatorPrfGetOutputs { + results: AuthenticatorPrfValues { + first: prf1.clone().try_into().unwrap(), + second: None, + }, + }), + }; + let transformed = GetAssertionExtensionsOutput::from(output); + assert_eq!(prf1, transformed.prf.unwrap().results.first); + } } diff --git a/crates/bitwarden-uniffi/src/crypto.rs b/crates/bitwarden-uniffi/src/crypto.rs index d43881ed0..d253059ad 100644 --- a/crates/bitwarden-uniffi/src/crypto.rs +++ b/crates/bitwarden-uniffi/src/crypto.rs @@ -2,7 +2,7 @@ use bitwarden_core::key_management::crypto::{ DeriveKeyConnectorRequest, DerivePinKeyResponse, EnrollPinResponse, InitOrgCryptoRequest, InitUserCryptoRequest, UpdateKdfResponse, UpdatePasswordResponse, }; -use bitwarden_crypto::{EncString, Kdf, UnsignedSharedKey}; +use bitwarden_crypto::{EncString, Kdf, RotateableKeySet, UnsignedSharedKey}; use bitwarden_encoding::B64; use crate::error::Result; @@ -88,6 +88,12 @@ impl CryptoClient { Ok(self.0.derive_key_connector(request)?) } + /// Creates the a new rotateable key set for the current user key protected + /// by a key derived from the given PRF. + pub fn make_prf_user_key_set(&self, prf: B64) -> Result { + Ok(self.0.make_prf_user_key_set(prf)?) + } + /// Create the data necessary to update the user's kdf settings. The user's encryption key is /// re-encrypted for the password under the new kdf settings. This returns the new encrypted /// user key and the new password hash but does not update sdk state. diff --git a/crates/bitwarden-uniffi/src/platform/fido2.rs b/crates/bitwarden-uniffi/src/platform/fido2.rs index a16c12dea..6bba2290e 100644 --- a/crates/bitwarden-uniffi/src/platform/fido2.rs +++ b/crates/bitwarden-uniffi/src/platform/fido2.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use bitwarden_crypto::{BitwardenLegacyKeyBytes, SymmetricCryptoKey}; use bitwarden_fido::{ CheckUserOptions, ClientData, Fido2CallbackError as BitFido2CallbackError, Fido2CredentialAutofillView, GetAssertionRequest, GetAssertionResult, MakeCredentialRequest, @@ -16,7 +17,7 @@ pub struct ClientFido2(pub(crate) bitwarden_fido::ClientFido2); #[uniffi::export] impl ClientFido2 { - pub fn authenticator( + pub fn vault_authenticator( &self, user_interface: Arc, credential_store: Arc, @@ -25,6 +26,23 @@ impl ClientFido2 { self.0.clone(), user_interface, credential_store, + None, + )) + } + + pub fn device_authenticator( + &self, + user_interface: Arc, + credential_store: Arc, + encryption_key: Vec, + ) -> Arc { + let key = + SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(encryption_key)).unwrap(); + Arc::new(ClientFido2Authenticator( + self.0.clone(), + user_interface, + credential_store, + Some(key), )) } @@ -37,16 +55,22 @@ impl ClientFido2 { self.0.clone(), user_interface, credential_store, + None, ))) } pub fn decrypt_fido2_autofill_credentials( &self, cipher_view: CipherView, + encryption_key: Option>, ) -> Result> { + let key = encryption_key + .map(|key| SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key))) + .transpose()?; + let result = self .0 - .decrypt_fido2_autofill_credentials(cipher_view) + .decrypt_fido2_autofill_credentials(cipher_view, key) .map_err(Error::DecryptFido2AutofillCredentials)?; Ok(result) @@ -58,6 +82,7 @@ pub struct ClientFido2Authenticator( pub(crate) bitwarden_fido::ClientFido2, pub(crate) Arc, pub(crate) Arc, + pub(crate) Option, ); #[uniffi::export] @@ -68,7 +93,7 @@ impl ClientFido2Authenticator { ) -> Result { let ui = UniffiTraitBridge(self.1.as_ref()); let cs = UniffiTraitBridge(self.2.as_ref()); - let mut auth = self.0.create_authenticator(&ui, &cs); + let mut auth = self.0.create_authenticator(&ui, &cs, self.3.clone()); let result = auth .make_credential(request) @@ -80,7 +105,7 @@ impl ClientFido2Authenticator { pub async fn get_assertion(&self, request: GetAssertionRequest) -> Result { let ui = UniffiTraitBridge(self.1.as_ref()); let cs = UniffiTraitBridge(self.2.as_ref()); - let mut auth = self.0.create_authenticator(&ui, &cs); + let mut auth = self.0.create_authenticator(&ui, &cs, self.3.clone()); let result = auth .get_assertion(request) @@ -95,7 +120,7 @@ impl ClientFido2Authenticator { ) -> Result> { let ui = UniffiTraitBridge(self.1.as_ref()); let cs = UniffiTraitBridge(self.2.as_ref()); - let mut auth = self.0.create_authenticator(&ui, &cs); + let mut auth = self.0.create_authenticator(&ui, &cs, self.3.clone()); let result = auth .silently_discover_credentials(rp_id) @@ -107,7 +132,7 @@ impl ClientFido2Authenticator { pub async fn credentials_for_autofill(&self) -> Result> { let ui = UniffiTraitBridge(self.1.as_ref()); let cs = UniffiTraitBridge(self.2.as_ref()); - let mut auth = self.0.create_authenticator(&ui, &cs); + let mut auth = self.0.create_authenticator(&ui, &cs, self.3.clone()); let result = auth .credentials_for_autofill() diff --git a/crates/bitwarden-uniffi/src/vault/ciphers.rs b/crates/bitwarden-uniffi/src/vault/ciphers.rs index 6508558eb..7645e5755 100644 --- a/crates/bitwarden-uniffi/src/vault/ciphers.rs +++ b/crates/bitwarden-uniffi/src/vault/ciphers.rs @@ -41,8 +41,11 @@ impl CiphersClient { pub fn decrypt_fido2_credentials( &self, cipher_view: CipherView, + encryption_key: Option>, ) -> Result> { - Ok(self.0.decrypt_fido2_credentials(cipher_view)?) + Ok(self + .0 + .decrypt_fido2_credentials(cipher_view, encryption_key)?) } /// Move a cipher to an organization, reencrypting the cipher key if necessary diff --git a/crates/bitwarden-uniffi/swift/build.sh b/crates/bitwarden-uniffi/swift/build.sh index a10e78b95..73a25efbf 100755 --- a/crates/bitwarden-uniffi/swift/build.sh +++ b/crates/bitwarden-uniffi/swift/build.sh @@ -9,23 +9,31 @@ cd "$(dirname "$0")" rm -rf BitwardenFFI.xcframework rm -rf tmp -mkdir -p tmp/target/universal-ios-sim/release - # Build native library export IPHONEOS_DEPLOYMENT_TARGET="13.0" export RUSTFLAGS="-C link-arg=-Wl,-application_extension" -cargo build --package bitwarden-uniffi --target aarch64-apple-ios-sim --release -cargo build --package bitwarden-uniffi --target aarch64-apple-ios --release -cargo build --package bitwarden-uniffi --target x86_64-apple-ios --release +if [[ $DEBUG_MODE = "true" ]]; then + PROFILE="debug" + PROFILE_FLAG="" +else + PROFILE="release" + PROFILE_FLAG="--release" +fi +echo "$PROFILE_FLAG" +cargo build --package bitwarden-uniffi --target aarch64-apple-ios-sim $PROFILE_FLAG +cargo build --package bitwarden-uniffi --target aarch64-apple-ios $PROFILE_FLAG +cargo build --package bitwarden-uniffi --target x86_64-apple-ios $PROFILE_FLAG + +mkdir -p tmp/target/universal-ios-sim/$PROFILE # Create universal libraries -lipo -create ../../../target/aarch64-apple-ios-sim/release/libbitwarden_uniffi.a \ - ../../../target/x86_64-apple-ios/release/libbitwarden_uniffi.a \ - -output ./tmp/target/universal-ios-sim/release/libbitwarden_uniffi.a +lipo -create ../../../target/aarch64-apple-ios-sim/$PROFILE/libbitwarden_uniffi.a \ + ../../../target/x86_64-apple-ios/$PROFILE/libbitwarden_uniffi.a \ + -output ./tmp/target/universal-ios-sim/$PROFILE/libbitwarden_uniffi.a # Generate swift bindings cargo run -p uniffi-bindgen generate \ - ../../../target/aarch64-apple-ios-sim/release/libbitwarden_uniffi.dylib \ + ../../../target/aarch64-apple-ios-sim/$PROFILE/libbitwarden_uniffi.dylib \ --library \ --language swift \ --no-format \ @@ -41,9 +49,9 @@ cat ./tmp/bindings/*.modulemap > ./tmp/Headers/module.modulemap # Build xcframework xcodebuild -create-xcframework \ - -library ../../../target/aarch64-apple-ios/release/libbitwarden_uniffi.a \ + -library ../../../target/aarch64-apple-ios/$PROFILE/libbitwarden_uniffi.a \ -headers ./tmp/Headers \ - -library ./tmp/target/universal-ios-sim/release/libbitwarden_uniffi.a \ + -library ./tmp/target/universal-ios-sim/$PROFILE/libbitwarden_uniffi.a \ -headers ./tmp/Headers \ -output ./BitwardenFFI.xcframework diff --git a/crates/bitwarden-vault/src/cipher/attachment.rs b/crates/bitwarden-vault/src/cipher/attachment.rs index ba8c9bb5d..ba7b06bc4 100644 --- a/crates/bitwarden-vault/src/cipher/attachment.rs +++ b/crates/bitwarden-vault/src/cipher/attachment.rs @@ -262,6 +262,7 @@ mod tests { cipher: Cipher { id: None, organization_id: None, + device_bound: false, folder_id: None, collection_ids: Vec::new(), key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()), @@ -317,6 +318,7 @@ mod tests { let cipher = Cipher { id: None, organization_id: None, + device_bound: false, folder_id: None, collection_ids: Vec::new(), key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()), @@ -376,6 +378,7 @@ mod tests { let cipher = Cipher { id: None, organization_id: None, + device_bound: false, folder_id: None, collection_ids: Vec::new(), key: None, diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 9c6b36173..4bc1e91bc 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -108,6 +108,7 @@ pub struct EncryptionContext { pub struct Cipher { pub id: Option, pub organization_id: Option, + pub device_bound: bool, pub folder_id: Option, pub collection_ids: Vec, @@ -153,6 +154,7 @@ bitwarden_state::register_repository_item!(Cipher, "Cipher"); pub struct CipherView { pub id: Option, pub organization_id: Option, + pub device_bound: bool, pub folder_id: Option, pub collection_ids: Vec, @@ -310,6 +312,7 @@ impl CompositeEncryptable for CipherView { Ok(Cipher { id: cipher_view.id, organization_id: cipher_view.organization_id, + device_bound: cipher_view.device_bound, folder_id: cipher_view.folder_id, collection_ids: cipher_view.collection_ids, key: cipher_view.key, @@ -356,6 +359,7 @@ impl Decryptable for Cipher { let mut cipher = CipherView { id: self.id, organization_id: self.organization_id, + device_bound: self.device_bound, folder_id: self.folder_id, collection_ids: self.collection_ids.clone(), key: self.key.clone(), @@ -706,9 +710,10 @@ impl IdentifyKey for Cipher { impl IdentifyKey for CipherView { fn key_identifier(&self) -> SymmetricKeyId { - match self.organization_id { - Some(organization_id) => SymmetricKeyId::Organization(organization_id), - None => SymmetricKeyId::User, + match (self.organization_id, self.device_bound) { + (Some(organization_id), _) => SymmetricKeyId::Organization(organization_id), + (_, true) => SymmetricKeyId::Local("device_key"), + _ => SymmetricKeyId::User, } } } @@ -771,6 +776,8 @@ impl TryFrom for Cipher { revision_date: require!(cipher.revision_date).parse()?, key: EncString::try_from_optional(cipher.key)?, archived_date: cipher.archived_date.map(|d| d.parse()).transpose()?, + // TODO(II): need to forward this to other models + device_bound: false, }) } } @@ -823,6 +830,7 @@ mod tests { }), id: Some(test_id), organization_id: None, + device_bound: false, folder_id: None, collection_ids: vec![], key: None, @@ -863,6 +871,7 @@ mod tests { rp_name: None, user_display_name: None, discoverable: "true".to_string().encrypt(ctx, key).unwrap(), + hmac_secret: Some("123".to_string().encrypt(ctx, key).unwrap()), creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(), } } @@ -911,6 +920,7 @@ mod tests { deleted_date: None, revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), archived_date: None, + device_bound: false, }; let view: CipherListView = key_store.decrypt(&cipher).unwrap(); diff --git a/crates/bitwarden-vault/src/cipher/cipher_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client.rs index 060e1bcfd..90671393a 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client.rs @@ -1,14 +1,18 @@ use bitwarden_core::{Client, OrganizationId, key_management::SymmetricKeyId}; -use bitwarden_crypto::{CompositeEncryptable, IdentifyKey, SymmetricCryptoKey}; +#[cfg(feature = "wasm")] +use bitwarden_crypto::CompositeEncryptable; +use bitwarden_crypto::{BitwardenLegacyKeyBytes, IdentifyKey, SymmetricCryptoKey}; #[cfg(feature = "wasm")] use bitwarden_encoding::B64; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; use super::EncryptionContext; +#[cfg(feature = "wasm")] +use crate::Fido2CredentialFullView; use crate::{ Cipher, CipherError, CipherListView, CipherView, DecryptError, EncryptError, - Fido2CredentialFullView, cipher::cipher::DecryptCipherListResult, + cipher::cipher::DecryptCipherListResult, }; #[allow(missing_docs)] @@ -129,9 +133,16 @@ impl CiphersClient { pub fn decrypt_fido2_credentials( &self, cipher_view: CipherView, + encryption_key: Option>, ) -> Result, DecryptError> { let key_store = self.client.internal.get_key_store(); - let credentials = cipher_view.decrypt_fido2_credentials(&mut key_store.context())?; + let mut ctx = key_store.context(); + if let Some(key) = encryption_key { + let key = SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key))?; + ctx.set_symmetric_key(SymmetricKeyId::Local("device_key"), key)?; + } + + let credentials = cipher_view.decrypt_fido2_credentials(&mut ctx)?; Ok(credentials) } @@ -189,6 +200,7 @@ mod tests { Cipher { id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()), organization_id: None, + device_bound: false, folder_id: None, collection_ids: vec![], key: None, @@ -240,6 +252,7 @@ mod tests { }), id: Some(test_id), organization_id: None, + device_bound: false, folder_id: None, collection_ids: vec![], key: None, @@ -298,6 +311,7 @@ mod tests { .decrypt_list(vec![Cipher { id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()), organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()), + device_bound: false, folder_id: None, collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()], key: None, diff --git a/crates/bitwarden-vault/src/cipher/login.rs b/crates/bitwarden-vault/src/cipher/login.rs index 814f68385..02a45250b 100644 --- a/crates/bitwarden-vault/src/cipher/login.rs +++ b/crates/bitwarden-vault/src/cipher/login.rs @@ -101,6 +101,7 @@ pub struct Fido2Credential { pub rp_name: Option, pub user_display_name: Option, pub discoverable: EncString, + pub hmac_secret: Option, pub creation_date: DateTime, } @@ -137,6 +138,9 @@ pub struct Fido2CredentialView { pub rp_name: Option, pub user_display_name: Option, pub discoverable: String, + // This value doesn't need to be returned to the client + // so we keep it encrypted until we need it + pub hmac_secret: Option, pub creation_date: DateTime, } @@ -159,6 +163,7 @@ pub struct Fido2CredentialFullView { pub rp_name: Option, pub user_display_name: Option, pub discoverable: String, + pub hmac_secret: Option, pub creation_date: DateTime, } @@ -225,6 +230,11 @@ impl CompositeEncryptable for Fido2Cred rp_name: self.rp_name.encrypt(ctx, key)?, user_display_name: self.user_display_name.encrypt(ctx, key)?, discoverable: self.discoverable.encrypt(ctx, key)?, + hmac_secret: self + .hmac_secret + .as_ref() + .map(|s| s.encrypt(ctx, key)) + .transpose()?, creation_date: self.creation_date, }) } @@ -249,6 +259,7 @@ impl Decryptable for Fido2Crede rp_name: self.rp_name.decrypt(ctx, key)?, user_display_name: self.user_display_name.decrypt(ctx, key)?, discoverable: self.discoverable.decrypt(ctx, key)?, + hmac_secret: self.hmac_secret.decrypt(ctx, key)?, creation_date: self.creation_date, }) } @@ -273,6 +284,7 @@ impl Decryptable for Fido2Crede rp_name: self.rp_name.clone(), user_display_name: self.user_display_name.clone(), discoverable: self.discoverable.clone(), + hmac_secret: self.hmac_secret.decrypt(ctx, key)?, creation_date: self.creation_date, }) } @@ -438,6 +450,7 @@ impl CompositeEncryptable for Fido2Cred rp_name: self.rp_name.encrypt(ctx, key)?, user_display_name: self.user_display_name.encrypt(ctx, key)?, discoverable: self.discoverable.encrypt(ctx, key)?, + hmac_secret: self.hmac_secret.clone(), creation_date: self.creation_date, }) } @@ -462,6 +475,7 @@ impl Decryptable for Fido2Credentia rp_name: self.rp_name.decrypt(ctx, key)?, user_display_name: self.user_display_name.decrypt(ctx, key)?, discoverable: self.discoverable.decrypt(ctx, key)?, + hmac_secret: self.hmac_secret.clone(), creation_date: self.creation_date, }) } @@ -557,6 +571,9 @@ impl TryFrom for Fido2Cre .ok() .flatten(), discoverable: require!(value.discoverable).parse()?, + hmac_secret: EncString::try_from_optional(value.hmac_secret) + .ok() + .flatten(), creation_date: value.creation_date.parse()?, }) } diff --git a/crates/bitwarden-vault/src/cipher/secure_note.rs b/crates/bitwarden-vault/src/cipher/secure_note.rs index 022fc651a..e1c106cca 100644 --- a/crates/bitwarden-vault/src/cipher/secure_note.rs +++ b/crates/bitwarden-vault/src/cipher/secure_note.rs @@ -140,6 +140,7 @@ mod tests { deleted_date: None, revision_date: "2024-01-01T00:00:00.000Z".parse().unwrap(), archived_date: None, + device_bound: false, } } diff --git a/crates/bitwarden-vault/src/collection_client.rs b/crates/bitwarden-vault/src/collection_client.rs index f74cf7be2..230d82099 100644 --- a/crates/bitwarden-vault/src/collection_client.rs +++ b/crates/bitwarden-vault/src/collection_client.rs @@ -5,6 +5,7 @@ use bitwarden_collections::{ tree::{NodeItem, Tree}, }; use bitwarden_core::Client; +#[cfg(feature = "wasm")] use serde::{Deserialize, Serialize}; #[cfg(feature = "wasm")] use tsify::Tsify;