diff --git a/Cargo.lock b/Cargo.lock index c4b8d76d1..44aaee484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,8 @@ dependencies = [ name = "bitwarden-exporters" version = "0.1.0" dependencies = [ + "base64 0.21.7", + "bitwarden-crypto", "chrono", "csv", "serde", @@ -3813,6 +3815,7 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" dependencies = [ + "getrandom 0.2.12", "serde", ] diff --git a/crates/bitwarden-crypto/src/keys/master_key.rs b/crates/bitwarden-crypto/src/keys/master_key.rs index 0a435ed88..aff403c59 100644 --- a/crates/bitwarden-crypto/src/keys/master_key.rs +++ b/crates/bitwarden-crypto/src/keys/master_key.rs @@ -1,16 +1,11 @@ -use std::{num::NonZeroU32, pin::Pin}; +use std::num::NonZeroU32; -use aes::cipher::typenum::U32; use base64::{engine::general_purpose::STANDARD, Engine}; -use generic_array::GenericArray; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use sha2::Digest; -use crate::{ - util::{self, hkdf_expand}, - EncString, KeyDecryptable, Result, SymmetricCryptoKey, UserKey, -}; +use super::utils::{derive_kdf_key, stretch_kdf_key}; +use crate::{util, EncString, KeyDecryptable, Result, SymmetricCryptoKey, UserKey}; #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -45,7 +40,7 @@ impl MasterKey { /// Derives a users master key from their password, email and KDF. pub fn derive(password: &[u8], email: &[u8], kdf: &Kdf) -> Result { - derive_key(password, email, kdf).map(Self) + derive_kdf_key(password, email, kdf).map(Self) } /// Derive the master key hash, used for local and remote password validation. @@ -62,14 +57,14 @@ impl MasterKey { /// Decrypt the users user key pub fn decrypt_user_key(&self, user_key: EncString) -> Result { - let stretched_key = stretch_master_key(self)?; + let stretched_key = stretch_kdf_key(&self.0)?; let mut dec: Vec = user_key.decrypt_with_key(&stretched_key)?; SymmetricCryptoKey::try_from(dec.as_mut_slice()) } pub fn encrypt_user_key(&self, user_key: &SymmetricCryptoKey) -> Result { - let stretched_key = stretch_master_key(self)?; + let stretched_key = stretch_kdf_key(&self.0)?; EncString::encrypt_aes256_hmac( user_key.to_vec().as_slice(), @@ -89,55 +84,13 @@ fn make_user_key( Ok((UserKey::new(user_key), protected)) } -/// Derive a generic key from a secret and salt using the provided KDF. -fn derive_key(secret: &[u8], salt: &[u8], kdf: &Kdf) -> Result { - let mut hash = match kdf { - Kdf::PBKDF2 { iterations } => crate::util::pbkdf2(secret, salt, iterations.get()), - - Kdf::Argon2id { - iterations, - memory, - parallelism, - } => { - use argon2::*; - - let argon = Argon2::new( - Algorithm::Argon2id, - Version::V0x13, - Params::new( - memory.get() * 1024, // Convert MiB to KiB - iterations.get(), - parallelism.get(), - Some(32), - ) - .unwrap(), - ); - - let salt_sha = sha2::Sha256::new().chain_update(salt).finalize(); - - let mut hash = [0u8; 32]; - argon - .hash_password_into(secret, &salt_sha, &mut hash) - .unwrap(); - hash - } - }; - SymmetricCryptoKey::try_from(hash.as_mut_slice()) -} - -fn stretch_master_key(master_key: &MasterKey) -> Result { - let key: Pin>> = hkdf_expand(&master_key.0.key, Some("enc"))?; - let mac_key: Pin>> = hkdf_expand(&master_key.0.key, Some("mac"))?; - Ok(SymmetricCryptoKey::new(key, Some(mac_key))) -} - #[cfg(test)] mod tests { use std::num::NonZeroU32; use rand::SeedableRng; - use super::{make_user_key, stretch_master_key, HashPurpose, Kdf, MasterKey}; + use super::{make_user_key, HashPurpose, Kdf, MasterKey}; use crate::{keys::symmetric_crypto_key::derive_symmetric_key, SymmetricCryptoKey}; #[test] @@ -184,37 +137,6 @@ mod tests { assert_eq!(None, master_key.0.mac_key); } - #[test] - fn test_stretch_master_key() { - let master_key = MasterKey(SymmetricCryptoKey::new( - Box::pin( - [ - 31, 79, 104, 226, 150, 71, 177, 90, 194, 80, 172, 209, 17, 129, 132, 81, 138, - 167, 69, 167, 254, 149, 2, 27, 39, 197, 64, 42, 22, 195, 86, 75, - ] - .into(), - ), - None, - )); - - let stretched = stretch_master_key(&master_key).unwrap(); - - assert_eq!( - [ - 111, 31, 178, 45, 238, 152, 37, 114, 143, 215, 124, 83, 135, 173, 195, 23, 142, - 134, 120, 249, 61, 132, 163, 182, 113, 197, 189, 204, 188, 21, 237, 96 - ], - stretched.key.as_slice() - ); - assert_eq!( - [ - 221, 127, 206, 234, 101, 27, 202, 38, 86, 52, 34, 28, 78, 28, 185, 16, 48, 61, 127, - 166, 209, 247, 194, 87, 232, 26, 48, 85, 193, 249, 179, 155 - ], - stretched.mac_key.as_ref().unwrap().as_slice() - ); - } - #[test] fn test_password_hash_pbkdf2() { let password = "asdfasdf".as_bytes(); diff --git a/crates/bitwarden-crypto/src/keys/mod.rs b/crates/bitwarden-crypto/src/keys/mod.rs index 285e58b55..5bfd32b8b 100644 --- a/crates/bitwarden-crypto/src/keys/mod.rs +++ b/crates/bitwarden-crypto/src/keys/mod.rs @@ -12,8 +12,10 @@ mod asymmetric_crypto_key; pub use asymmetric_crypto_key::{ AsymmetricCryptoKey, AsymmetricEncryptable, AsymmetricPublicCryptoKey, }; - mod user_key; pub use user_key::UserKey; mod device_key; pub use device_key::{DeviceKey, TrustDeviceResponse}; +mod pin_key; +pub use pin_key::PinKey; +mod utils; diff --git a/crates/bitwarden-crypto/src/keys/pin_key.rs b/crates/bitwarden-crypto/src/keys/pin_key.rs new file mode 100644 index 000000000..475b7ffd9 --- /dev/null +++ b/crates/bitwarden-crypto/src/keys/pin_key.rs @@ -0,0 +1,39 @@ +use crate::{ + keys::{ + key_encryptable::CryptoKey, + utils::{derive_kdf_key, stretch_kdf_key}, + }, + EncString, Kdf, KeyEncryptable, Result, SymmetricCryptoKey, +}; + +/// Pin Key. +/// +/// Derived from a specific password, used for pin encryption and exports. +pub struct PinKey(SymmetricCryptoKey); + +impl PinKey { + pub fn new(key: SymmetricCryptoKey) -> Self { + Self(key) + } + + /// Derives a users pin key from their password, email and KDF. + pub fn derive(password: &[u8], salt: &[u8], kdf: &Kdf) -> Result { + derive_kdf_key(password, salt, kdf).map(Self) + } +} + +impl CryptoKey for PinKey {} + +impl KeyEncryptable for &[u8] { + fn encrypt_with_key(self, key: &PinKey) -> Result { + let stretched_key = stretch_kdf_key(&key.0)?; + + self.encrypt_with_key(&stretched_key) + } +} + +impl KeyEncryptable for String { + fn encrypt_with_key(self, key: &PinKey) -> Result { + self.as_bytes().encrypt_with_key(key) + } +} diff --git a/crates/bitwarden-crypto/src/keys/utils.rs b/crates/bitwarden-crypto/src/keys/utils.rs new file mode 100644 index 000000000..d83e212d0 --- /dev/null +++ b/crates/bitwarden-crypto/src/keys/utils.rs @@ -0,0 +1,85 @@ +use std::pin::Pin; + +use generic_array::{typenum::U32, GenericArray}; +use sha2::Digest; + +use crate::{util::hkdf_expand, Kdf, Result, SymmetricCryptoKey}; + +/// Derive a generic key from a secret and salt using the provided KDF. +pub(super) fn derive_kdf_key(secret: &[u8], salt: &[u8], kdf: &Kdf) -> Result { + let mut hash = match kdf { + Kdf::PBKDF2 { iterations } => crate::util::pbkdf2(secret, salt, iterations.get()), + + Kdf::Argon2id { + iterations, + memory, + parallelism, + } => { + use argon2::*; + + let argon = Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + Params::new( + memory.get() * 1024, // Convert MiB to KiB + iterations.get(), + parallelism.get(), + Some(32), + ) + .unwrap(), + ); + + let salt_sha = sha2::Sha256::new().chain_update(salt).finalize(); + + let mut hash = [0u8; 32]; + argon + .hash_password_into(secret, &salt_sha, &mut hash) + .unwrap(); + hash + } + }; + SymmetricCryptoKey::try_from(hash.as_mut_slice()) +} + +pub(super) fn stretch_kdf_key(k: &SymmetricCryptoKey) -> Result { + let key: Pin>> = hkdf_expand(&k.key, Some("enc"))?; + let mac_key: Pin>> = hkdf_expand(&k.key, Some("mac"))?; + + Ok(SymmetricCryptoKey::new(key, Some(mac_key))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stretch_kdf_key() { + let key = SymmetricCryptoKey::new( + Box::pin( + [ + 31, 79, 104, 226, 150, 71, 177, 90, 194, 80, 172, 209, 17, 129, 132, 81, 138, + 167, 69, 167, 254, 149, 2, 27, 39, 197, 64, 42, 22, 195, 86, 75, + ] + .into(), + ), + None, + ); + + let stretched = stretch_kdf_key(&key).unwrap(); + + assert_eq!( + [ + 111, 31, 178, 45, 238, 152, 37, 114, 143, 215, 124, 83, 135, 173, 195, 23, 142, + 134, 120, 249, 61, 132, 163, 182, 113, 197, 189, 204, 188, 21, 237, 96 + ], + stretched.key.as_slice() + ); + assert_eq!( + [ + 221, 127, 206, 234, 101, 27, 202, 38, 86, 52, 34, 28, 78, 28, 185, 16, 48, 61, 127, + 166, 209, 247, 194, 87, 232, 26, 48, 85, 193, 249, 179, 155 + ], + stretched.mac_key.as_ref().unwrap().as_slice() + ); + } +} diff --git a/crates/bitwarden-exporters/Cargo.toml b/crates/bitwarden-exporters/Cargo.toml index 0008fbb49..40316437f 100644 --- a/crates/bitwarden-exporters/Cargo.toml +++ b/crates/bitwarden-exporters/Cargo.toml @@ -14,6 +14,8 @@ rust-version = "1.57" exclude = ["/resources"] [dependencies] +base64 = ">=0.21.2, <0.22" +bitwarden-crypto = { path = "../bitwarden-crypto", version = "=0.1.0" } chrono = { version = ">=0.4.26, <0.5", features = [ "clock", "serde", @@ -23,4 +25,4 @@ csv = "1.3.0" serde = { version = ">=1.0, <2.0", features = ["derive"] } serde_json = ">=1.0.96, <2.0" thiserror = ">=1.0.40, <2.0" -uuid = { version = ">=1.3.3, <2.0", features = ["serde"] } +uuid = { version = ">=1.3.3, <2.0", features = ["serde", "v4"] } diff --git a/crates/bitwarden-exporters/src/encrypted_json.rs b/crates/bitwarden-exporters/src/encrypted_json.rs new file mode 100644 index 000000000..1bbfd2660 --- /dev/null +++ b/crates/bitwarden-exporters/src/encrypted_json.rs @@ -0,0 +1,246 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use bitwarden_crypto::{generate_random_bytes, Kdf, KeyEncryptable, PinKey}; +use serde::Serialize; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + json::{self, export_json}, + Cipher, Folder, +}; + +#[derive(Error, Debug)] +pub enum EncryptedJsonError { + #[error(transparent)] + JsonExport(#[from] json::JsonError), + + #[error("JSON error: {0}")] + Serde(#[from] serde_json::Error), + + #[error("Cryptography error, {0}")] + Crypto(#[from] bitwarden_crypto::CryptoError), +} + +pub(crate) fn export_encrypted_json( + folders: Vec, + ciphers: Vec, + password: String, + kdf: Kdf, +) -> Result { + let decrypted_export = export_json(folders, ciphers)?; + + let (kdf_type, kdf_iterations, kdf_memory, kdf_parallelism) = match kdf { + Kdf::PBKDF2 { iterations } => (0, iterations.get(), None, None), + Kdf::Argon2id { + iterations, + memory, + parallelism, + } => ( + 1, + iterations.get(), + Some(memory.get()), + Some(parallelism.get()), + ), + }; + + let salt: [u8; 16] = generate_random_bytes(); + let salt = STANDARD.encode(salt); + let key = PinKey::derive(password.as_bytes(), salt.as_bytes(), &kdf)?; + + let enc_key_validation = Uuid::new_v4().to_string(); + + let encrypted_export = EncryptedJsonExport { + encrypted: true, + password_protected: true, + salt, + kdf_type, + kdf_iterations, + kdf_memory, + kdf_parallelism, + enc_key_validation: enc_key_validation.encrypt_with_key(&key)?.to_string(), + data: decrypted_export.encrypt_with_key(&key)?.to_string(), + }; + + Ok(serde_json::to_string_pretty(&encrypted_export)?) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EncryptedJsonExport { + encrypted: bool, + password_protected: bool, + salt: String, + kdf_type: u32, + kdf_iterations: u32, + kdf_memory: Option, + kdf_parallelism: Option, + #[serde(rename = "encKeyValidation_DO_NOT_EDIT")] + enc_key_validation: String, + data: String, +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use super::*; + use crate::{ + Card, Cipher, CipherType, Field, Identity, Login, LoginUri, SecureNote, SecureNoteType, + }; + + #[test] + pub fn test_export() { + let _export = export_encrypted_json( + vec![Folder { + id: "942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap(), + name: "Important".to_string(), + }], + vec![ + Cipher { + id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(), + folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()), + + name: "Bitwarden".to_string(), + notes: Some("My note".to_string()), + + r#type: CipherType::Login(Box::new(Login { + username: Some("test@bitwarden.com".to_string()), + password: Some("asdfasdfasdf".to_string()), + login_uris: vec![LoginUri { + uri: Some("https://vault.bitwarden.com".to_string()), + r#match: None, + }], + totp: Some("ABC".to_string()), + })), + + favorite: true, + reprompt: 0, + + fields: vec![ + Field { + name: Some("Text".to_string()), + value: Some("A".to_string()), + r#type: 0, + linked_id: None, + }, + Field { + name: Some("Hidden".to_string()), + value: Some("B".to_string()), + r#type: 1, + linked_id: None, + }, + Field { + name: Some("Boolean (true)".to_string()), + value: Some("true".to_string()), + r#type: 2, + linked_id: None, + }, + Field { + name: Some("Boolean (false)".to_string()), + value: Some("false".to_string()), + r#type: 2, + linked_id: None, + }, + Field { + name: Some("Linked".to_string()), + value: None, + r#type: 3, + linked_id: Some(101), + }, + ], + + revision_date: "2024-01-30T14:09:33.753Z".parse().unwrap(), + creation_date: "2024-01-30T11:23:54.416Z".parse().unwrap(), + deleted_date: None, + }, + Cipher { + id: "23f0f877-42b1-4820-a850-b10700bc41eb".parse().unwrap(), + folder_id: None, + + name: "My secure note".to_string(), + notes: Some("Very secure!".to_string()), + + r#type: CipherType::SecureNote(Box::new(SecureNote { + r#type: SecureNoteType::Generic, + })), + + favorite: false, + reprompt: 0, + + fields: vec![], + + revision_date: "2024-01-30T11:25:25.466Z".parse().unwrap(), + creation_date: "2024-01-30T11:25:25.466Z".parse().unwrap(), + deleted_date: None, + }, + Cipher { + id: "3ed8de45-48ee-4e26-a2dc-b10701276c53".parse().unwrap(), + folder_id: None, + + name: "My card".to_string(), + notes: None, + + r#type: CipherType::Card(Box::new(Card { + cardholder_name: Some("John Doe".to_string()), + exp_month: Some("1".to_string()), + exp_year: Some("2032".to_string()), + code: Some("123".to_string()), + brand: Some("Visa".to_string()), + number: Some("4111111111111111".to_string()), + })), + + favorite: false, + reprompt: 0, + + fields: vec![], + + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + }, + Cipher { + id: "41cc3bc1-c3d9-4637-876c-b10701273712".parse().unwrap(), + folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()), + + name: "My identity".to_string(), + notes: None, + + r#type: CipherType::Identity(Box::new(Identity { + title: Some("Mr".to_string()), + first_name: Some("John".to_string()), + middle_name: None, + last_name: Some("Doe".to_string()), + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, + company: Some("Bitwarden".to_string()), + email: None, + phone: None, + ssn: None, + username: Some("JDoe".to_string()), + passport_number: None, + license_number: None, + })), + + favorite: false, + reprompt: 0, + + fields: vec![], + + revision_date: "2024-01-30T17:54:50.706Z".parse().unwrap(), + creation_date: "2024-01-30T17:54:50.706Z".parse().unwrap(), + deleted_date: None, + }, + ], + "password".to_string(), + Kdf::PBKDF2 { + iterations: NonZeroU32::new(600_000).unwrap(), + }, + ) + .unwrap(); + } +} diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs index bb690fbc1..814633489 100644 --- a/crates/bitwarden-exporters/src/lib.rs +++ b/crates/bitwarden-exporters/src/lib.rs @@ -1,3 +1,4 @@ +use bitwarden_crypto::Kdf; use chrono::{DateTime, Utc}; use thiserror::Error; use uuid::Uuid; @@ -6,11 +7,13 @@ mod csv; use csv::export_csv; mod json; use json::export_json; +mod encrypted_json; +use encrypted_json::export_encrypted_json; pub enum Format { Csv, Json, - EncryptedJson { password: String }, + EncryptedJson { password: String, kdf: Kdf }, } /// Export representation of a Bitwarden folder. @@ -127,6 +130,8 @@ pub enum ExportError { Csv(#[from] csv::CsvError), #[error("JSON error: {0}")] Json(#[from] json::JsonError), + #[error("Encrypted JSON error: {0}")] + EncryptedJsonError(#[from] encrypted_json::EncryptedJsonError), } pub fn export( @@ -137,6 +142,8 @@ pub fn export( match format { Format::Csv => Ok(export_csv(folders, ciphers)?), Format::Json => Ok(export_json(folders, ciphers)?), - Format::EncryptedJson { password: _ } => todo!(), + Format::EncryptedJson { password, kdf } => { + Ok(export_encrypted_json(folders, ciphers, password, kdf)?) + } } } diff --git a/crates/bitwarden/src/tool/exporters/mod.rs b/crates/bitwarden/src/tool/exporters/mod.rs index cbdb5bb86..9e9e99ed5 100644 --- a/crates/bitwarden/src/tool/exporters/mod.rs +++ b/crates/bitwarden/src/tool/exporters/mod.rs @@ -3,6 +3,7 @@ use bitwarden_exporters::export; use schemars::JsonSchema; use crate::{ + client::{LoginMethod, UserLoginMethod}, error::{Error, Result}, vault::{ login::LoginUriView, Cipher, CipherType, CipherView, Collection, FieldView, Folder, @@ -38,7 +39,35 @@ pub(super) fn export_vault( let ciphers: Vec = ciphers.into_iter().flat_map(|c| c.try_into()).collect(); - Ok(export(folders, ciphers, format.into())?) + let format = convert_format(client, format)?; + + Ok(export(folders, ciphers, format)?) +} + +fn convert_format( + client: &Client, + format: ExportFormat, +) -> Result { + let login_method = client + .login_method + .as_ref() + .ok_or(Error::NotAuthenticated)?; + + let kdf = match login_method { + LoginMethod::User( + UserLoginMethod::Username { kdf, .. } | UserLoginMethod::ApiKey { kdf, .. }, + ) => kdf, + _ => return Err(Error::NotAuthenticated), + }; + + Ok(match format { + ExportFormat::Csv => bitwarden_exporters::Format::Csv, + ExportFormat::Json => bitwarden_exporters::Format::Json, + ExportFormat::EncryptedJson { password } => bitwarden_exporters::Format::EncryptedJson { + password, + kdf: kdf.clone(), + }, + }) } pub(super) fn export_organization_vault( @@ -173,18 +202,11 @@ impl From for bitwarden_exporters::SecureNoteType { } } -impl From for bitwarden_exporters::Format { - fn from(value: ExportFormat) -> Self { - match value { - ExportFormat::Csv => Self::Csv, - ExportFormat::Json => Self::Json, - ExportFormat::EncryptedJson { password } => Self::EncryptedJson { password }, - } - } -} - #[cfg(test)] mod tests { + use std::num::NonZeroU32; + + use bitwarden_crypto::Kdf; use chrono::{DateTime, Utc}; use super::*; @@ -276,19 +298,32 @@ mod tests { } #[test] - fn test_from_export_format() { + fn test_convert_format() { + let mut client = Client::new(None); + client.set_login_method(LoginMethod::User(UserLoginMethod::Username { + client_id: "7b821276-e27c-400b-9853-606393c87f18".to_owned(), + email: "test@bitwarden.com".to_owned(), + kdf: Kdf::PBKDF2 { + iterations: NonZeroU32::new(600_000).unwrap(), + }, + })); + assert!(matches!( - bitwarden_exporters::Format::from(ExportFormat::Csv), + convert_format(&client, ExportFormat::Csv).unwrap(), bitwarden_exporters::Format::Csv )); assert!(matches!( - bitwarden_exporters::Format::from(ExportFormat::Json), + convert_format(&client, ExportFormat::Json).unwrap(), bitwarden_exporters::Format::Json )); assert!(matches!( - bitwarden_exporters::Format::from(ExportFormat::EncryptedJson { - password: "password".to_string() - }), + convert_format( + &client, + ExportFormat::EncryptedJson { + password: "password".to_string() + } + ) + .unwrap(), bitwarden_exporters::Format::EncryptedJson { .. } )); }