From 714c2d40d1811c13c751b6de9a9fe0ae51f61583 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 6 Feb 2024 11:52:38 +0100 Subject: [PATCH] [PM-3438] Vault exports (#561) Vault export implementation. New crate `bitwarden-exporters` which defines it's own data structure, isolating it from the other code areas. It primarily focuses on converting a data model to csv and json. Currently all data has to be decrypted independently of what is required for the export which is an area of improvement for the future. --- .prettierignore | 3 + .vscode/settings.json | 2 + Cargo.lock | 34 + crates/bitwarden-exporters/Cargo.toml | 26 + crates/bitwarden-exporters/README.md | 6 + .../resources/json_export.json | 146 ++++ crates/bitwarden-exporters/src/csv.rs | 266 ++++++ crates/bitwarden-exporters/src/json.rs | 762 ++++++++++++++++++ crates/bitwarden-exporters/src/lib.rs | 142 ++++ crates/bitwarden/Cargo.toml | 1 + crates/bitwarden/src/error.rs | 5 + .../src/tool/exporters/client_exporter.rs | 6 +- crates/bitwarden/src/tool/exporters/mod.rs | 278 ++++++- crates/bitwarden/src/vault/cipher/field.rs | 8 +- .../bitwarden/src/vault/cipher/linked_id.rs | 8 +- crates/bitwarden/src/vault/cipher/mod.rs | 7 +- .../bitwarden/src/vault/cipher/secure_note.rs | 2 +- crates/bitwarden/src/vault/folder.rs | 6 +- crates/bitwarden/src/vault/mod.rs | 7 +- 19 files changed, 1685 insertions(+), 30 deletions(-) create mode 100644 crates/bitwarden-exporters/Cargo.toml create mode 100644 crates/bitwarden-exporters/README.md create mode 100644 crates/bitwarden-exporters/resources/json_export.json create mode 100644 crates/bitwarden-exporters/src/csv.rs create mode 100644 crates/bitwarden-exporters/src/json.rs create mode 100644 crates/bitwarden-exporters/src/lib.rs diff --git a/.prettierignore b/.prettierignore index d5ffe5a0e..97474cca9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,6 @@ schemas /crates/bitwarden-napi/src-ts/bitwarden_client/schemas.ts about.hbs support/docs/template.hbs + +# Test fixtures +crates/bitwarden-exporters/resources/* diff --git a/.vscode/settings.json b/.vscode/settings.json index 63c3dccaa..e92fcfb76 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,8 +15,10 @@ "Pbkdf", "PKCS8", "repr", + "reprompt", "reqwest", "schemars", + "totp", "uniffi", "wordlist", "zxcvbn" diff --git a/Cargo.lock b/Cargo.lock index 98274e54f..b2728124b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,6 +352,7 @@ dependencies = [ "bitwarden-api-api", "bitwarden-api-identity", "bitwarden-crypto", + "bitwarden-exporters", "bitwarden-generators", "chrono", "getrandom 0.2.12", @@ -451,6 +452,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bitwarden-exporters" +version = "0.1.0" +dependencies = [ + "chrono", + "csv", + "serde", + "serde_json", + "thiserror", + "uuid", +] + [[package]] name = "bitwarden-generators" version = "0.1.0" @@ -999,6 +1012,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.6" diff --git a/crates/bitwarden-exporters/Cargo.toml b/crates/bitwarden-exporters/Cargo.toml new file mode 100644 index 000000000..0008fbb49 --- /dev/null +++ b/crates/bitwarden-exporters/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "bitwarden-exporters" +version = "0.1.0" +authors = ["Bitwarden Inc"] +license-file = "LICENSE" +repository = "https://github.com/bitwarden/sdk" +homepage = "https://bitwarden.com" +description = """ +Internal crate for the bitwarden crate. Do not use. +""" +keywords = ["bitwarden"] +edition = "2021" +rust-version = "1.57" +exclude = ["/resources"] + +[dependencies] +chrono = { version = ">=0.4.26, <0.5", features = [ + "clock", + "serde", + "std", +], default-features = false } +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"] } diff --git a/crates/bitwarden-exporters/README.md b/crates/bitwarden-exporters/README.md new file mode 100644 index 000000000..59936680f --- /dev/null +++ b/crates/bitwarden-exporters/README.md @@ -0,0 +1,6 @@ +# Bitwarden Exporters + +This is an internal crate for the Bitwarden SDK do not depend on this directly and use the +[`bitwarden`](https://crates.io/crates/bitwarden) crate instead. + +This crate does not follow semantic versioning and the public interface may change at any time. diff --git a/crates/bitwarden-exporters/resources/json_export.json b/crates/bitwarden-exporters/resources/json_export.json new file mode 100644 index 000000000..ad5380550 --- /dev/null +++ b/crates/bitwarden-exporters/resources/json_export.json @@ -0,0 +1,146 @@ +{ + "encrypted": false, + "folders": [ + { + "id": "942e2984-1b9a-453b-b039-b107012713b9", + "name": "Important" + } + ], + "items": [ + { + "id": "25c8c414-b446-48e9-a1bd-b10700bbd740", + "folderId": "942e2984-1b9a-453b-b039-b107012713b9", + "organizationId": null, + "collectionIds": null, + "name": "Bitwarden", + "notes": "My note", + "type": 1, + "login": { + "username": "test@bitwarden.com", + "password": "asdfasdfasdf", + "uris": [ + { + "uri": "https://vault.bitwarden.com", + "match": null + } + ], + "totp": "ABC", + "fido2Credentials": [] + }, + "favorite": true, + "reprompt": 0, + "fields": [ + { + "name": "Text", + "value": "A", + "type": 0, + "linkedId": null + }, + { + "name": "Hidden", + "value": "B", + "type": 1, + "linkedId": null + }, + { + "name": "Boolean (true)", + "value": "true", + "type": 2, + "linkedId": null + }, + { + "name": "Boolean (false)", + "value": "false", + "type": 2, + "linkedId": null + }, + { + "name": "Linked", + "value": null, + "type": 3, + "linkedId": 101 + } + ], + "passwordHistory": null, + "revisionDate": "2024-01-30T14:09:33.753Z", + "creationDate": "2024-01-30T11:23:54.416Z", + "deletedDate": null + }, + { + "id": "23f0f877-42b1-4820-a850-b10700bc41eb", + "folderId": null, + "organizationId": null, + "collectionIds": null, + "name": "My secure note", + "notes": "Very secure!", + "type": 2, + "secureNote": { + "type": 0 + }, + "favorite": false, + "reprompt": 0, + "passwordHistory": null, + "revisionDate": "2024-01-30T11:25:25.466Z", + "creationDate": "2024-01-30T11:25:25.466Z", + "deletedDate": null + }, + { + "id": "3ed8de45-48ee-4e26-a2dc-b10701276c53", + "folderId": null, + "organizationId": null, + "collectionIds": null, + "name": "My card", + "notes": null, + "type": 3, + "card": { + "cardholderName": "John Doe", + "expMonth": "1", + "expYear": "2032", + "code": "123", + "brand": "Visa", + "number": "4111111111111111" + }, + "favorite": false, + "reprompt": 0, + "passwordHistory": null, + "revisionDate": "2024-01-30T17:55:36.150Z", + "creationDate": "2024-01-30T17:55:36.150Z", + "deletedDate": null + }, + { + "id": "41cc3bc1-c3d9-4637-876c-b10701273712", + "folderId": "942e2984-1b9a-453b-b039-b107012713b9", + "organizationId": null, + "collectionIds": null, + "name": "My identity", + "notes": null, + "type": 4, + "identity": { + "title": "Mr", + "firstName": "John", + "middleName": null, + "lastName": "Doe", + "address1": null, + "address2": null, + "address3": null, + "city": null, + "state": null, + "postalCode": null, + "country": null, + "company": "Bitwarden", + "email": null, + "phone": null, + "ssn": null, + "username": "JDoe", + "passportNumber": null, + "licenseNumber": null + }, + "favorite": false, + "reprompt": 0, + "passwordHistory": null, + "revisionDate": "2024-01-30T17:54:50.706Z", + "creationDate": "2024-01-30T17:54:50.706Z", + "deletedDate": null + } + ] +} \ No newline at end of file diff --git a/crates/bitwarden-exporters/src/csv.rs b/crates/bitwarden-exporters/src/csv.rs new file mode 100644 index 000000000..644eeb030 --- /dev/null +++ b/crates/bitwarden-exporters/src/csv.rs @@ -0,0 +1,266 @@ +use std::collections::HashMap; + +use csv::Writer; +use serde::Serializer; +use thiserror::Error; +use uuid::Uuid; + +use crate::{Cipher, CipherType, Field, Folder}; + +#[derive(Debug, Error)] +pub enum CsvError { + #[error("CSV error")] + Csv, +} + +pub(crate) fn export_csv(folders: Vec, ciphers: Vec) -> Result { + let folders: HashMap = folders.into_iter().map(|f| (f.id, f.name)).collect(); + + let rows = ciphers + .into_iter() + .filter(|c| matches!(c.r#type, CipherType::Login(_) | CipherType::SecureNote(_))) + .map(|c| { + let login = if let CipherType::Login(l) = &c.r#type { + Some(l) + } else { + None + }; + + CsvRow { + folder: c + .folder_id + .and_then(|f| folders.get(&f)) + .map(|f| f.to_owned()), + favorite: c.favorite, + r#type: c.r#type.to_string(), + name: c.name.to_owned(), + notes: c.notes.to_owned(), + fields: c.fields, + reprompt: c.reprompt, + login_uri: login + .map(|l| l.login_uris.iter().flat_map(|l| l.uri.clone()).collect()) + .unwrap_or_default(), + login_username: login.and_then(|l| l.username.clone()), + login_password: login.and_then(|l| l.password.clone()), + login_totp: login.and_then(|l| l.totp.clone()), + } + }); + + let mut wtr = Writer::from_writer(vec![]); + for row in rows { + wtr.serialize(row).unwrap(); + } + + String::from_utf8(wtr.into_inner().map_err(|_| CsvError::Csv)?).map_err(|_| CsvError::Csv) +} + +/// CSV export format. See https://bitwarden.com/help/condition-bitwarden-import/#condition-a-csv +/// +/// Be careful when changing this struct to maintain compatibility with old exports. +#[derive(serde::Serialize)] +struct CsvRow { + folder: Option, + #[serde(serialize_with = "bool_serialize")] + favorite: bool, + r#type: String, + name: String, + notes: Option, + #[serde(serialize_with = "fields_serialize")] + fields: Vec, + reprompt: u8, + #[serde(serialize_with = "vec_serialize")] + login_uri: Vec, + login_username: Option, + login_password: Option, + login_totp: Option, +} + +fn vec_serialize(x: &[String], s: S) -> Result +where + S: Serializer, +{ + s.serialize_str(x.join(",").as_str()) +} + +fn bool_serialize(x: &bool, s: S) -> Result +where + S: Serializer, +{ + s.serialize_str(if *x { "1" } else { "" }) +} + +fn fields_serialize(x: &[Field], s: S) -> Result +where + S: Serializer, +{ + s.serialize_str( + x.iter() + .map(|f| { + format!( + "{}: {}", + f.name.to_owned().unwrap_or_default(), + f.value.to_owned().unwrap_or_default() + ) + }) + .collect::>() + .join("\n") + .as_str(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Card, Identity, Login, LoginUri}; + + #[test] + fn test_export_csv() { + let folders = vec![ + Folder { + id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(), + name: "Test Folder A".to_string(), + }, + Folder { + id: "583e7665-0126-4d37-9139-b0d20184dd86".parse().unwrap(), + name: "Test Folder B".to_string(), + }, + ]; + let ciphers = vec![ + Cipher { + id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(), + folder_id: None, + name: "test@bitwarden.com".to_string(), + notes: None, + r#type: CipherType::Login(Box::new(Login { + username: Some("test@bitwarden.com".to_string()), + password: Some("Abc123".to_string()), + login_uris: vec![LoginUri { + uri: Some("https://google.com".to_string()), + r#match: None, + }], + totp: None, + })), + favorite: false, + reprompt: 0, + fields: vec![], + revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + deleted_date: None, + }, + Cipher { + id: "7dd81bd0-cc72-4f42-96e7-b0fc014e71a3".parse().unwrap(), + folder_id: Some("583e7665-0126-4d37-9139-b0d20184dd86".parse().unwrap()), + name: "Steam Account".to_string(), + notes: None, + r#type: CipherType::Login(Box::new(Login { + username: Some("steam".to_string()), + password: Some("3Pvb8u7EfbV*nJ".to_string()), + login_uris: vec![LoginUri { + uri: Some("https://steampowered.com".to_string()), + r#match: None, + }], + totp: Some("steam://ABCD123".to_string()), + })), + favorite: true, + reprompt: 0, + fields: vec![ + Field { + name: Some("Test".to_string()), + value: Some("v".to_string()), + r#type: 0, + linked_id: None, + }, + Field { + name: Some("Hidden".to_string()), + value: Some("asdfer".to_string()), + r#type: 1, + linked_id: None, + }, + ], + revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + deleted_date: None, + }, + ]; + + let csv = export_csv(folders, ciphers).unwrap(); + let expected = [ + "folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp", + ",,login,test@bitwarden.com,,,0,https://google.com,test@bitwarden.com,Abc123,", + "Test Folder B,1,login,Steam Account,,\"Test: v\nHidden: asdfer\",0,https://steampowered.com,steam,3Pvb8u7EfbV*nJ,steam://ABCD123", + "", + ].join("\n"); + + assert_eq!(csv, expected); + } + + #[test] + fn test_export_ignore_card() { + let folders = vec![]; + let ciphers = vec![Cipher { + id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(), + folder_id: None, + name: "My Card".to_string(), + notes: None, + r#type: CipherType::Card(Box::new(Card { + cardholder_name: None, + exp_month: None, + exp_year: None, + code: None, + brand: None, + number: None, + })), + favorite: false, + reprompt: 0, + fields: vec![], + revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + deleted_date: None, + }]; + + let csv = export_csv(folders, ciphers).unwrap(); + + assert_eq!(csv, ""); + } + + #[test] + fn test_export_ignore_identity() { + let folders = vec![]; + let ciphers = vec![Cipher { + id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(), + folder_id: None, + name: "My Identity".to_string(), + notes: None, + r#type: CipherType::Identity(Box::new(Identity { + title: None, + first_name: None, + middle_name: None, + last_name: None, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, + company: None, + email: None, + phone: None, + ssn: None, + username: None, + passport_number: None, + license_number: None, + })), + favorite: false, + reprompt: 0, + fields: vec![], + revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + deleted_date: None, + }]; + + let csv = export_csv(folders, ciphers).unwrap(); + + assert_eq!(csv, ""); + } +} diff --git a/crates/bitwarden-exporters/src/json.rs b/crates/bitwarden-exporters/src/json.rs new file mode 100644 index 000000000..3f6c72c1f --- /dev/null +++ b/crates/bitwarden-exporters/src/json.rs @@ -0,0 +1,762 @@ +use chrono::{DateTime, Utc}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{Card, Cipher, CipherType, Field, Folder, Identity, Login, LoginUri, SecureNote}; + +#[derive(Error, Debug)] +pub enum JsonError { + #[error("JSON error: {0}")] + Serde(#[from] serde_json::Error), +} + +pub(crate) fn export_json(folders: Vec, ciphers: Vec) -> Result { + let export = JsonExport { + encrypted: false, + folders: folders.into_iter().map(|f| f.into()).collect(), + items: ciphers.into_iter().map(|c| c.into()).collect(), + }; + + Ok(serde_json::to_string_pretty(&export)?) +} + +/// JSON export format. These are intentionally decoupled from the internal data structures to +/// ensure internal changes are not reflected in the public exports. +/// +/// Be careful about changing these structs to maintain compatibility with old exporters/importers. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonExport { + encrypted: bool, + folders: Vec, + items: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonFolder { + id: Uuid, + name: String, +} + +impl From for JsonFolder { + fn from(folder: Folder) -> Self { + JsonFolder { + id: folder.id, + name: folder.name, + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonCipher { + id: Uuid, + folder_id: Option, + // Organizational IDs which are always empty in personal exports + organization_id: Option, + collection_ids: Option>, + + name: String, + notes: Option, + + r#type: u8, + #[serde(skip_serializing_if = "Option::is_none")] + login: Option, + #[serde(skip_serializing_if = "Option::is_none")] + identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + card: Option, + #[serde(skip_serializing_if = "Option::is_none")] + secure_note: Option, + + favorite: bool, + reprompt: u8, + + #[serde(skip_serializing_if = "Vec::is_empty")] + fields: Vec, + password_history: Option>, + + revision_date: DateTime, + creation_date: DateTime, + deleted_date: Option>, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonLogin { + username: Option, + password: Option, + uris: Vec, + totp: Option, + fido2_credentials: Vec, +} + +impl From for JsonLogin { + fn from(login: Login) -> Self { + JsonLogin { + username: login.username, + password: login.password, + uris: login.login_uris.into_iter().map(|u| u.into()).collect(), + totp: login.totp, + fido2_credentials: vec![], + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonLoginUri { + uri: Option, + r#match: Option, +} + +impl From for JsonLoginUri { + fn from(login_uri: LoginUri) -> Self { + JsonLoginUri { + uri: login_uri.uri, + r#match: login_uri.r#match, + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonSecureNote { + r#type: u8, +} + +impl From for JsonSecureNote { + fn from(note: SecureNote) -> Self { + JsonSecureNote { + r#type: note.r#type as u8, + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonCard { + cardholder_name: Option, + exp_month: Option, + exp_year: Option, + code: Option, + brand: Option, + number: Option, +} + +impl From for JsonCard { + fn from(card: Card) -> Self { + JsonCard { + cardholder_name: card.cardholder_name, + exp_month: card.exp_month, + exp_year: card.exp_year, + code: card.code, + brand: card.brand, + number: card.number, + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonIdentity { + title: Option, + first_name: Option, + middle_name: Option, + last_name: Option, + address1: Option, + address2: Option, + address3: Option, + city: Option, + state: Option, + postal_code: Option, + country: Option, + company: Option, + email: Option, + phone: Option, + ssn: Option, + username: Option, + passport_number: Option, + license_number: Option, +} + +impl From for JsonIdentity { + fn from(identity: Identity) -> Self { + JsonIdentity { + title: identity.title, + first_name: identity.first_name, + middle_name: identity.middle_name, + last_name: identity.last_name, + address1: identity.address1, + address2: identity.address2, + address3: identity.address3, + city: identity.city, + state: identity.state, + postal_code: identity.postal_code, + country: identity.country, + company: identity.company, + email: identity.email, + phone: identity.phone, + ssn: identity.ssn, + username: identity.username, + passport_number: identity.passport_number, + license_number: identity.license_number, + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonField { + name: Option, + value: Option, + r#type: u8, + linked_id: Option, +} + +impl From for JsonField { + fn from(field: Field) -> Self { + JsonField { + name: field.name, + value: field.value, + r#type: field.r#type, + linked_id: field.linked_id, + } + } +} + +impl From for JsonCipher { + fn from(cipher: Cipher) -> Self { + let r#type = match cipher.r#type { + CipherType::Login(_) => 1, + CipherType::SecureNote(_) => 2, + CipherType::Card(_) => 3, + CipherType::Identity(_) => 4, + }; + + let (login, secure_note, card, identity) = match cipher.r#type { + CipherType::Login(l) => (Some((*l).into()), None, None, None), + CipherType::SecureNote(s) => (None, Some((*s).into()), None, None), + CipherType::Card(c) => (None, None, Some((*c).into()), None), + CipherType::Identity(i) => (None, None, None, Some((*i).into())), + }; + + JsonCipher { + id: cipher.id, + folder_id: cipher.folder_id, + organization_id: None, + collection_ids: None, + name: cipher.name, + notes: cipher.notes, + r#type, + login, + identity, + card, + secure_note, + favorite: cipher.favorite, + reprompt: cipher.reprompt, + fields: cipher.fields.into_iter().map(|f| f.into()).collect(), + password_history: None, + revision_date: cipher.revision_date, + creation_date: cipher.creation_date, + deleted_date: cipher.deleted_date, + } + } +} + +#[cfg(test)] +mod tests { + use std::{fs, io::Read, path::PathBuf}; + + use super::*; + use crate::{Cipher, Field, LoginUri, SecureNoteType}; + + #[test] + fn test_convert_login() { + let cipher = 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, + }; + + let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap(); + + let expected = r#"{ + "passwordHistory": null, + "revisionDate": "2024-01-30T14:09:33.753Z", + "creationDate": "2024-01-30T11:23:54.416Z", + "deletedDate": null, + "id": "25c8c414-b446-48e9-a1bd-b10700bbd740", + "organizationId": null, + "folderId": "942e2984-1b9a-453b-b039-b107012713b9", + "type": 1, + "reprompt": 0, + "name": "Bitwarden", + "notes": "My note", + "favorite": true, + "fields": [ + { + "name": "Text", + "value": "A", + "type": 0, + "linkedId": null + }, + { + "name": "Hidden", + "value": "B", + "type": 1, + "linkedId": null + }, + { + "name": "Boolean (true)", + "value": "true", + "type": 2, + "linkedId": null + }, + { + "name": "Boolean (false)", + "value": "false", + "type": 2, + "linkedId": null + }, + { + "name": "Linked", + "value": null, + "type": 3, + "linkedId": 101 + } + ], + "login": { + "fido2Credentials": [], + "uris": [ + { + "match": null, + "uri": "https://vault.bitwarden.com" + } + ], + "username": "test@bitwarden.com", + "password": "asdfasdfasdf", + "totp": "ABC" + }, + "collectionIds": null + }"#; + + assert_eq!( + json.parse::().unwrap(), + expected.parse::().unwrap() + ) + } + + #[test] + fn test_convert_secure_note() { + let cipher = 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, + }; + + let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap(); + + let expected = r#"{ + "passwordHistory": null, + "revisionDate": "2024-01-30T11:25:25.466Z", + "creationDate": "2024-01-30T11:25:25.466Z", + "deletedDate": null, + "id": "23f0f877-42b1-4820-a850-b10700bc41eb", + "organizationId": null, + "folderId": null, + "type": 2, + "reprompt": 0, + "name": "My secure note", + "notes": "Very secure!", + "favorite": false, + "secureNote": { + "type": 0 + }, + "collectionIds": null + }"#; + + assert_eq!( + json.parse::().unwrap(), + expected.parse::().unwrap() + ) + } + + #[test] + fn test_convert_card() { + let cipher = 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, + }; + + let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap(); + + let expected = r#"{ + "passwordHistory": null, + "revisionDate": "2024-01-30T17:55:36.150Z", + "creationDate": "2024-01-30T17:55:36.150Z", + "deletedDate": null, + "id": "3ed8de45-48ee-4e26-a2dc-b10701276c53", + "organizationId": null, + "folderId": null, + "type": 3, + "reprompt": 0, + "name": "My card", + "notes": null, + "favorite": false, + "card": { + "cardholderName": "John Doe", + "brand": "Visa", + "number": "4111111111111111", + "expMonth": "1", + "expYear": "2032", + "code": "123" + }, + "collectionIds": null + }"#; + + assert_eq!( + json.parse::().unwrap(), + expected.parse::().unwrap() + ) + } + + #[test] + fn test_convert_identity() { + let cipher = 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, + }; + + let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap(); + + let expected = r#"{ + "passwordHistory": null, + "revisionDate": "2024-01-30T17:54:50.706Z", + "creationDate": "2024-01-30T17:54:50.706Z", + "deletedDate": null, + "id": "41cc3bc1-c3d9-4637-876c-b10701273712", + "organizationId": null, + "folderId": "942e2984-1b9a-453b-b039-b107012713b9", + "type": 4, + "reprompt": 0, + "name": "My identity", + "notes": null, + "favorite": false, + "identity": { + "title": "Mr", + "firstName": "John", + "middleName": null, + "lastName": "Doe", + "address1": null, + "address2": null, + "address3": null, + "city": null, + "state": null, + "postalCode": null, + "country": null, + "company": "Bitwarden", + "email": null, + "phone": null, + "ssn": null, + "username": "JDoe", + "passportNumber": null, + "licenseNumber": null + }, + "collectionIds": null + }"#; + + assert_eq!( + json.parse::().unwrap(), + expected.parse::().unwrap() + ) + } + + #[test] + pub fn test_export() { + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("resources"); + d.push("json_export.json"); + + let mut file = fs::File::open(d).unwrap(); + + let mut expected = String::new(); + file.read_to_string(&mut expected).unwrap(); + + let export = export_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, + }, + ], + ) + .unwrap(); + + assert_eq!( + export.parse::().unwrap(), + expected.parse::().unwrap() + ) + } +} diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs new file mode 100644 index 000000000..bb690fbc1 --- /dev/null +++ b/crates/bitwarden-exporters/src/lib.rs @@ -0,0 +1,142 @@ +use chrono::{DateTime, Utc}; +use thiserror::Error; +use uuid::Uuid; + +mod csv; +use csv::export_csv; +mod json; +use json::export_json; + +pub enum Format { + Csv, + Json, + EncryptedJson { password: String }, +} + +/// Export representation of a Bitwarden folder. +/// +/// These are mostly duplicated from the `bitwarden` vault models to facilitate a stable export API +/// that is not tied to the internal vault models. We may revisit this in the future. +pub struct Folder { + pub id: Uuid, + pub name: String, +} + +/// Export representation of a Bitwarden cipher. +/// +/// These are mostly duplicated from the `bitwarden` vault models to facilitate a stable export API +/// that is not tied to the internal vault models. We may revisit this in the future. +pub struct Cipher { + pub id: Uuid, + pub folder_id: Option, + + pub name: String, + pub notes: Option, + + pub r#type: CipherType, + + pub favorite: bool, + pub reprompt: u8, + + pub fields: Vec, + + pub revision_date: DateTime, + pub creation_date: DateTime, + pub deleted_date: Option>, +} + +#[derive(Clone)] +pub struct Field { + pub name: Option, + pub value: Option, + pub r#type: u8, + pub linked_id: Option, +} + +pub enum CipherType { + Login(Box), + SecureNote(Box), + Card(Box), + Identity(Box), +} + +impl ToString for CipherType { + fn to_string(&self) -> String { + match self { + CipherType::Login(_) => "login".to_string(), + CipherType::SecureNote(_) => "note".to_string(), + CipherType::Card(_) => "card".to_string(), + CipherType::Identity(_) => "identity".to_string(), + } + } +} + +pub struct Login { + pub username: Option, + pub password: Option, + pub login_uris: Vec, + pub totp: Option, +} + +pub struct LoginUri { + pub uri: Option, + pub r#match: Option, +} + +pub struct Card { + pub cardholder_name: Option, + pub exp_month: Option, + pub exp_year: Option, + pub code: Option, + pub brand: Option, + pub number: Option, +} + +pub struct SecureNote { + pub r#type: SecureNoteType, +} + +pub enum SecureNoteType { + Generic = 0, +} + +pub struct Identity { + pub title: Option, + pub first_name: Option, + pub middle_name: Option, + pub last_name: Option, + pub address1: Option, + pub address2: Option, + pub address3: Option, + pub city: Option, + pub state: Option, + pub postal_code: Option, + pub country: Option, + pub company: Option, + pub email: Option, + pub phone: Option, + pub ssn: Option, + pub username: Option, + pub passport_number: Option, + pub license_number: Option, +} + +#[derive(Error, Debug)] +pub enum ExportError { + #[error("CSV error: {0}")] + Csv(#[from] csv::CsvError), + #[error("JSON error: {0}")] + Json(#[from] json::JsonError), +} + +pub fn export( + folders: Vec, + ciphers: Vec, + format: Format, +) -> Result { + match format { + Format::Csv => Ok(export_csv(folders, ciphers)?), + Format::Json => Ok(export_json(folders, ciphers)?), + Format::EncryptedJson { password: _ } => todo!(), + } +} diff --git a/crates/bitwarden/Cargo.toml b/crates/bitwarden/Cargo.toml index d9508258b..c6f580282 100644 --- a/crates/bitwarden/Cargo.toml +++ b/crates/bitwarden/Cargo.toml @@ -31,6 +31,7 @@ base64 = ">=0.21.2, <0.22" bitwarden-api-api = { path = "../bitwarden-api-api", version = "=0.2.3" } bitwarden-api-identity = { path = "../bitwarden-api-identity", version = "=0.2.3" } bitwarden-crypto = { path = "../bitwarden-crypto", version = "=0.1.0" } +bitwarden-exporters = { path = "../bitwarden-exporters", version = "0.1.0" } bitwarden-generators = { path = "../bitwarden-generators", version = "0.1.0" } chrono = { version = ">=0.4.26, <0.5", features = [ "clock", diff --git a/crates/bitwarden/src/error.rs b/crates/bitwarden/src/error.rs index 173557b04..ed5d27c3e 100644 --- a/crates/bitwarden/src/error.rs +++ b/crates/bitwarden/src/error.rs @@ -4,6 +4,7 @@ use std::{borrow::Cow, fmt::Debug}; use bitwarden_api_api::apis::Error as ApiError; use bitwarden_api_identity::apis::Error as IdentityError; +use bitwarden_exporters::ExportError; use bitwarden_generators::{PassphraseError, PasswordError, UsernameError}; use reqwest::StatusCode; use thiserror::Error; @@ -50,6 +51,7 @@ pub enum Error { #[error("The state file could not be read")] InvalidStateFile, + // Generators #[error(transparent)] UsernameError(#[from] UsernameError), #[error(transparent)] @@ -57,6 +59,9 @@ pub enum Error { #[error(transparent)] PasswordError(#[from] PasswordError), + #[error(transparent)] + ExportError(#[from] ExportError), + #[error("Internal error: {0}")] Internal(Cow<'static, str>), } diff --git a/crates/bitwarden/src/tool/exporters/client_exporter.rs b/crates/bitwarden/src/tool/exporters/client_exporter.rs index 9e0dfd5fc..05eb737f3 100644 --- a/crates/bitwarden/src/tool/exporters/client_exporter.rs +++ b/crates/bitwarden/src/tool/exporters/client_exporter.rs @@ -6,7 +6,7 @@ use crate::{ }; pub struct ClientExporters<'a> { - pub(crate) _client: &'a crate::Client, + pub(crate) client: &'a crate::Client, } impl<'a> ClientExporters<'a> { @@ -17,7 +17,7 @@ impl<'a> ClientExporters<'a> { ciphers: Vec, format: ExportFormat, ) -> Result { - export_vault(folders, ciphers, format) + export_vault(self.client, folders, ciphers, format) } pub async fn export_organization_vault( @@ -32,6 +32,6 @@ impl<'a> ClientExporters<'a> { impl<'a> Client { pub fn exporters(&'a self) -> ClientExporters<'a> { - ClientExporters { _client: self } + ClientExporters { client: self } } } diff --git a/crates/bitwarden/src/tool/exporters/mod.rs b/crates/bitwarden/src/tool/exporters/mod.rs index d03ddeb77..cbdb5bb86 100644 --- a/crates/bitwarden/src/tool/exporters/mod.rs +++ b/crates/bitwarden/src/tool/exporters/mod.rs @@ -1,8 +1,14 @@ +use bitwarden_crypto::Decryptable; +use bitwarden_exporters::export; use schemars::JsonSchema; use crate::{ - error::Result, - vault::{Cipher, Collection, Folder}, + error::{Error, Result}, + vault::{ + login::LoginUriView, Cipher, CipherType, CipherView, Collection, FieldView, Folder, + FolderView, SecureNoteType, + }, + Client, }; mod client_exporter; @@ -13,21 +19,26 @@ pub use client_exporter::ClientExporters; pub enum ExportFormat { Csv, Json, - AccountEncryptedJson, // TODO: Should we deprecate this option completely? EncryptedJson { password: String }, } pub(super) fn export_vault( - _folders: Vec, - _ciphers: Vec, + client: &Client, + folders: Vec, + ciphers: Vec, format: ExportFormat, ) -> Result { - Ok(match format { - ExportFormat::Csv => "Csv".to_owned(), - ExportFormat::Json => "Json".to_owned(), - ExportFormat::AccountEncryptedJson => "AccountEncryptedJson".to_owned(), - ExportFormat::EncryptedJson { .. } => "EncryptedJson".to_owned(), - }) + let enc = client.get_encryption_settings()?; + + let folders: Vec = folders.decrypt(enc, &None)?; + let folders: Vec = + folders.into_iter().flat_map(|f| f.try_into()).collect(); + + let ciphers: Vec = ciphers.decrypt(enc, &None)?; + let ciphers: Vec = + ciphers.into_iter().flat_map(|c| c.try_into()).collect(); + + Ok(export(folders, ciphers, format.into())?) } pub(super) fn export_organization_vault( @@ -37,3 +48,248 @@ pub(super) fn export_organization_vault( ) -> Result { todo!(); } + +impl TryFrom for bitwarden_exporters::Folder { + type Error = Error; + + fn try_from(value: FolderView) -> Result { + Ok(Self { + id: value.id.ok_or(Error::MissingFields)?, + name: value.name, + }) + } +} + +impl TryFrom for bitwarden_exporters::Cipher { + type Error = Error; + + fn try_from(value: CipherView) -> Result { + let r = match value.r#type { + CipherType::Login => { + let l = value.login.ok_or(Error::MissingFields)?; + bitwarden_exporters::CipherType::Login(Box::new(bitwarden_exporters::Login { + username: l.username, + password: l.password, + login_uris: l + .uris + .unwrap_or_default() + .into_iter() + .map(|u| u.into()) + .collect(), + totp: l.totp, + })) + } + CipherType::SecureNote => bitwarden_exporters::CipherType::SecureNote(Box::new( + bitwarden_exporters::SecureNote { + r#type: value + .secure_note + .map(|t| t.r#type) + .unwrap_or(SecureNoteType::Generic) + .into(), + }, + )), + CipherType::Card => { + let c = value.card.ok_or(Error::MissingFields)?; + bitwarden_exporters::CipherType::Card(Box::new(bitwarden_exporters::Card { + cardholder_name: c.cardholder_name, + exp_month: c.exp_month, + exp_year: c.exp_year, + code: c.code, + brand: c.brand, + number: c.number, + })) + } + CipherType::Identity => { + let i = value.identity.ok_or(Error::MissingFields)?; + bitwarden_exporters::CipherType::Identity(Box::new(bitwarden_exporters::Identity { + title: i.title, + first_name: i.first_name, + middle_name: i.middle_name, + last_name: i.last_name, + address1: i.address1, + address2: i.address2, + address3: i.address3, + city: i.city, + state: i.state, + postal_code: i.postal_code, + country: i.country, + company: i.company, + email: i.email, + phone: i.phone, + ssn: i.ssn, + username: i.username, + passport_number: i.passport_number, + license_number: i.license_number, + })) + } + }; + + Ok(Self { + id: value.id.ok_or(Error::MissingFields)?, + folder_id: value.folder_id, + name: value.name, + notes: value.notes, + r#type: r, + favorite: value.favorite, + reprompt: value.reprompt as u8, + fields: value + .fields + .unwrap_or_default() + .into_iter() + .map(|f| f.into()) + .collect(), + revision_date: value.revision_date, + creation_date: value.creation_date, + deleted_date: value.deleted_date, + }) + } +} + +impl From for bitwarden_exporters::Field { + fn from(value: FieldView) -> Self { + Self { + name: value.name, + value: value.value, + r#type: value.r#type as u8, + linked_id: value.linked_id.map(|id| id.into()), + } + } +} + +impl From for bitwarden_exporters::LoginUri { + fn from(value: LoginUriView) -> Self { + Self { + r#match: value.r#match.map(|v| v as u8), + uri: value.uri, + } + } +} + +impl From for bitwarden_exporters::SecureNoteType { + fn from(value: SecureNoteType) -> Self { + match value { + SecureNoteType::Generic => bitwarden_exporters::SecureNoteType::Generic, + } + } +} + +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 chrono::{DateTime, Utc}; + + use super::*; + use crate::vault::{login::LoginView, CipherRepromptType}; + + #[test] + fn test_try_from_folder_view() { + let view = FolderView { + id: Some("fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap()), + name: "test_name".to_string(), + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + }; + + let f: bitwarden_exporters::Folder = view.try_into().unwrap(); + + assert_eq!( + f.id, + "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap() + ); + assert_eq!(f.name, "test_name".to_string()); + } + + #[test] + fn test_try_from_cipher_view_login() { + let cipher_view = CipherView { + r#type: CipherType::Login, + login: Some(LoginView { + username: Some("test_username".to_string()), + password: Some("test_password".to_string()), + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + }), + id: "fd411a1a-fec8-4070-985d-0e6560860e69".parse().ok(), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: "My login".to_string(), + notes: None, + identity: None, + card: None, + secure_note: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: true, + edit: true, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + }; + + let cipher: bitwarden_exporters::Cipher = cipher_view.try_into().unwrap(); + + assert_eq!( + cipher.id, + "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap() + ); + assert_eq!(cipher.folder_id, None); + assert_eq!(cipher.name, "My login".to_string()); + assert_eq!(cipher.notes, None); + assert!(!cipher.favorite); + assert_eq!(cipher.reprompt, 0); + assert!(cipher.fields.is_empty()); + assert_eq!( + cipher.revision_date, + "2024-01-30T17:55:36.150Z".parse::>().unwrap() + ); + assert_eq!( + cipher.creation_date, + "2024-01-30T17:55:36.150Z".parse::>().unwrap() + ); + assert_eq!(cipher.deleted_date, None); + + if let bitwarden_exporters::CipherType::Login(l) = cipher.r#type { + assert_eq!(l.username, Some("test_username".to_string())); + assert_eq!(l.password, Some("test_password".to_string())); + assert!(l.login_uris.is_empty()); + assert_eq!(l.totp, None); + } else { + panic!("Expected login type"); + } + } + + #[test] + fn test_from_export_format() { + assert!(matches!( + bitwarden_exporters::Format::from(ExportFormat::Csv), + bitwarden_exporters::Format::Csv + )); + assert!(matches!( + bitwarden_exporters::Format::from(ExportFormat::Json), + bitwarden_exporters::Format::Json + )); + assert!(matches!( + bitwarden_exporters::Format::from(ExportFormat::EncryptedJson { + password: "password".to_string() + }), + bitwarden_exporters::Format::EncryptedJson { .. } + )); + } +} diff --git a/crates/bitwarden/src/vault/cipher/field.rs b/crates/bitwarden/src/vault/cipher/field.rs index db896474f..bde713a05 100644 --- a/crates/bitwarden/src/vault/cipher/field.rs +++ b/crates/bitwarden/src/vault/cipher/field.rs @@ -34,11 +34,11 @@ pub struct Field { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "mobile", derive(uniffi::Record))] pub struct FieldView { - name: Option, - value: Option, - r#type: FieldType, + pub(crate) name: Option, + pub(crate) value: Option, + pub(crate) r#type: FieldType, - linked_id: Option, + pub(crate) linked_id: Option, } impl KeyEncryptable for FieldView { diff --git a/crates/bitwarden/src/vault/cipher/linked_id.rs b/crates/bitwarden/src/vault/cipher/linked_id.rs index 6fb676dfe..77429438e 100644 --- a/crates/bitwarden/src/vault/cipher/linked_id.rs +++ b/crates/bitwarden/src/vault/cipher/linked_id.rs @@ -25,7 +25,13 @@ impl UniffiCustomTypeConverter for LinkedIdType { } fn from_custom(obj: Self) -> Self::Builtin { - serde_json::to_value(obj) + obj.into() + } +} + +impl From for u32 { + fn from(v: LinkedIdType) -> Self { + serde_json::to_value(v) .expect("LinkedIdType should be serializable") .as_u64() .expect("Not a numeric enum value") as u32 diff --git a/crates/bitwarden/src/vault/cipher/mod.rs b/crates/bitwarden/src/vault/cipher/mod.rs index c891f439d..c2b49eb37 100644 --- a/crates/bitwarden/src/vault/cipher/mod.rs +++ b/crates/bitwarden/src/vault/cipher/mod.rs @@ -9,4 +9,9 @@ pub(crate) mod local_data; pub(crate) mod login; pub(crate) mod secure_note; -pub use cipher::{Cipher, CipherListView, CipherView}; +pub use attachment::{ + Attachment, AttachmentEncryptResult, AttachmentFile, AttachmentFileView, AttachmentView, +}; +pub use cipher::{Cipher, CipherListView, CipherRepromptType, CipherType, CipherView}; +pub use field::FieldView; +pub use secure_note::SecureNoteType; diff --git a/crates/bitwarden/src/vault/cipher/secure_note.rs b/crates/bitwarden/src/vault/cipher/secure_note.rs index 8f7069ee1..2433a9c2a 100644 --- a/crates/bitwarden/src/vault/cipher/secure_note.rs +++ b/crates/bitwarden/src/vault/cipher/secure_note.rs @@ -24,7 +24,7 @@ pub struct SecureNote { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "mobile", derive(uniffi::Record))] pub struct SecureNoteView { - r#type: SecureNoteType, + pub(crate) r#type: SecureNoteType, } impl KeyEncryptable for SecureNoteView { diff --git a/crates/bitwarden/src/vault/folder.rs b/crates/bitwarden/src/vault/folder.rs index 17d1d40aa..edd1cac42 100644 --- a/crates/bitwarden/src/vault/folder.rs +++ b/crates/bitwarden/src/vault/folder.rs @@ -22,9 +22,9 @@ pub struct Folder { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "mobile", derive(uniffi::Record))] pub struct FolderView { - id: Option, - name: String, - revision_date: DateTime, + pub id: Option, + pub name: String, + pub revision_date: DateTime, } impl LocateKey for FolderView {} diff --git a/crates/bitwarden/src/vault/mod.rs b/crates/bitwarden/src/vault/mod.rs index dce7b0e04..2addfec6b 100644 --- a/crates/bitwarden/src/vault/mod.rs +++ b/crates/bitwarden/src/vault/mod.rs @@ -6,12 +6,7 @@ mod send; #[cfg(feature = "mobile")] mod totp; -pub use cipher::{ - attachment::{ - Attachment, AttachmentEncryptResult, AttachmentFile, AttachmentFileView, AttachmentView, - }, - Cipher, CipherListView, CipherView, -}; +pub use cipher::*; pub use collection::{Collection, CollectionView}; pub use folder::{Folder, FolderView}; pub use password_history::{PasswordHistory, PasswordHistoryView};