diff --git a/Cargo.lock b/Cargo.lock index b2728124b..eec63aa53 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", @@ -3812,6 +3814,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..097e41d38 100644 --- a/crates/bitwarden-crypto/src/keys/master_key.rs +++ b/crates/bitwarden-crypto/src/keys/master_key.rs @@ -77,6 +77,16 @@ impl MasterKey { &stretched_key.key, ) } + + pub fn encrypt(&self, data: &[u8]) -> Result { + let stretched_key = stretch_master_key(self)?; + + EncString::encrypt_aes256_hmac( + data, + stretched_key.mac_key.as_ref().unwrap(), + &stretched_key.key, + ) + } } /// Generate a new random user key and encrypt it with the master key. 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..5ca3c4066 --- /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, MasterKey}; +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 = MasterKey::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: key.encrypt(enc_key_validation.as_bytes())?.to_string(), + data: key.encrypt(decrypted_export.as_bytes())?.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 { .. } )); }