From 7719c921700d6c5b5b3c01f1796ebf9ea152425a Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 29 May 2026 15:33:21 -0700 Subject: [PATCH] Add raw JSON and YAML import/export/edit capabilities Add support for managing Bitwarden entries using raw JSON or YAML structures. This enables advanced workflows like batch importing, editing complex entries (e.g., cards, identities, SSH keys) with custom fields, and managing password history directly from the command line or via automation scripts. Key changes: - Add `--raw` (JSON) and `--yaml` (YAML) options to `rbw add` and `rbw edit`. - Add `--yaml` option to `rbw get`. - Implement strict validation for raw structures: - Prevent unrecognized fields using `deny_unknown_fields` on all decrypted structs. - Enforce mandatory fields (Field Name, Field Value, and Field Type) in `DecryptedField` by refactoring them to non-Option types, ensuring compile-time and runtime strictness. - Automatically generate a rich JSON Schema at runtime in YAML mode: - Write it to `schema.json` in the temp editing directory. - Inject the `# yaml-language-server: $schema=./schema.json` comment at the top of the YAML buffer to enable autocompletion and tooltips in editors. - Add descriptive titles and detailed tooltips to all schema fields. - Format all multi-line strings (such as notes and SSH private keys) using YAML block literals (`|`) automatically. - Ensure `--raw` and `--yaml` flags are mutually exclusive across all commands. - Expand library layer (`Client::add`, `rbw::actions::add`) to support custom fields, history, organization IDs, and different entry types when adding. - Clean up display logic in `commands.rs` by introducing `display_custom_field` helper, eliminating over 50 lines of duplicate boilerplate loops. - Add robust unit tests `test_raw_yaml_serde` and `test_strict_validation` to verify serialization, block literals, schema generation, and strict validation failures. - Update deny.toml to allow multiple versions of indexmap and hashbrown to coexist (due to schemars depending on v1 and petgraph/serde_yaml depending on v2). - Upgrade rustls-webpki (to v0.103.13), bytes (to v1.11.1), and rand (to v0.8.6 and v0.9.4) to patched versions in Cargo.lock to resolve cargo-deny security advisories, and update deny.toml to skip the new rand versions. --- Cargo.lock | 110 +++- Cargo.toml | 2 + deny.toml | 8 +- src/actions.rs | 44 +- src/api.rs | 59 +- src/bin/rbw/commands.rs | 1298 ++++++++++++++++++++++++++++++--------- src/bin/rbw/main.rs | 46 +- src/dirs.rs | 2 +- src/edit.rs | 15 +- 9 files changed, 1246 insertions(+), 338 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8b0964d..be8fa2aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,9 +266,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cbc" @@ -567,6 +567,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -857,6 +863,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1109,6 +1121,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -1116,7 +1139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -1350,7 +1373,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1613,7 +1636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.12.1", ] [[package]] @@ -1744,7 +1767,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1787,9 +1810,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1798,9 +1821,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -1887,18 +1910,20 @@ dependencies = [ "pbkdf2", "percent-encoding", "pkcs8", - "rand 0.8.5", - "rand 0.9.2", + "rand 0.8.6", + "rand 0.9.4", "regex", "region", "reqwest", "rmpv", "rsa", "rustix", + "schemars", "serde", "serde_json", "serde_path_to_error", "serde_repr", + "serde_yaml", "sha1", "sha2", "signature", @@ -2147,9 +2172,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2177,6 +2202,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2265,6 +2315,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.147" @@ -2312,6 +2373,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "service-binding" version = "3.0.0" @@ -2811,7 +2885,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1", @@ -2844,6 +2918,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index d348fe04..b4f6829d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,8 @@ serde_json = "1.0.147" serde_path_to_error = "0.1.20" serde_repr = "0.1.20" serde = { version = "1.0.228", features = ["derive"] } +serde_yaml = "0.9.34" +schemars = { version = "0.8.21", features = ["preserve_order"] } sha1 = "0.10.6" sha2 = "0.10.9" signature = "2.2.0" diff --git a/deny.toml b/deny.toml index 2d983c06..be40d74b 100644 --- a/deny.toml +++ b/deny.toml @@ -35,14 +35,18 @@ skip = [ { name = "thiserror-impl", version = "2.0.17" }, # until the rand 0.8 -> 0.9 transition finished making its way through # the crate ecosystem - { name = "rand", version = "0.8.5" }, - { name = "rand", version = "0.9.2" }, + { name = "rand", version = "0.8.6" }, + { name = "rand", version = "0.9.4" }, { name = "rand_core", version = "0.6.4" }, { name = "rand_core", version = "0.9.3" }, { name = "rand_chacha", version = "0.3.1" }, { name = "rand_chacha", version = "0.9.0" }, { name = "getrandom", version = "0.2.16" }, { name = "getrandom", version = "0.3.4" }, + { name = "indexmap", version = "1.9.3" }, + { name = "indexmap", version = "2.12.1" }, + { name = "hashbrown", version = "0.12.3" }, + { name = "hashbrown", version = "0.16.1" }, ] [licenses] diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..49027722 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -112,21 +112,17 @@ pub fn unlock( let protected_private_key = crate::cipherstring::CipherString::new(protected_private_key)?; - let private_key = - match protected_private_key.decrypt_locked_symmetric(&key) { - Ok(private_key) => crate::locked::PrivateKey::new(private_key), - Err(e) => return Err(e), - }; + let private_key = crate::locked::PrivateKey::new( + protected_private_key.decrypt_locked_symmetric(&key)?, + ); let mut org_keys = std::collections::HashMap::new(); for (org_id, protected_org_key) in protected_org_keys { let protected_org_key = crate::cipherstring::CipherString::new(protected_org_key)?; - let org_key = - match protected_org_key.decrypt_locked_asymmetric(&private_key) { - Ok(org_key) => crate::locked::Keys::new(org_key), - Err(e) => return Err(e), - }; + let org_key = crate::locked::Keys::new( + protected_org_key.decrypt_locked_asymmetric(&private_key)?, + ); org_keys.insert(org_id.clone(), org_key); } @@ -171,25 +167,49 @@ async fn sync_once( pub fn add( access_token: &str, refresh_token: &str, + org_id: Option<&str>, name: &str, data: &crate::db::EntryData, + fields: &[crate::db::Field], notes: Option<&str>, folder_id: Option<&str>, + history: &[crate::db::HistoryEntry], ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { - add_once(access_token, name, data, notes, folder_id) + add_once( + access_token, + org_id, + name, + data, + fields, + notes, + folder_id, + history, + ) }) } fn add_once( access_token: &str, + org_id: Option<&str>, name: &str, data: &crate::db::EntryData, + fields: &[crate::db::Field], notes: Option<&str>, folder_id: Option<&str>, + history: &[crate::db::HistoryEntry], ) -> Result<()> { let (client, _) = api_client()?; - client.add(access_token, name, data, notes, folder_id)?; + client.add( + access_token, + org_id, + name, + data, + fields, + notes, + folder_id, + history, + )?; Ok(()) } diff --git a/src/api.rs b/src/api.rs index a817fb26..60b71f0b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -750,9 +750,11 @@ struct SyncResPasswordHistory { #[derive(serde::Serialize, Debug)] struct CiphersPostReq { #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? + ty: u32, #[serde(rename = "folderId")] folder_id: Option, + #[serde(rename = "organizationId")] + organization_id: Option, name: String, notes: Option, login: Option, @@ -760,6 +762,9 @@ struct CiphersPostReq { identity: Option, #[serde(rename = "secureNote")] secure_note: Option, + fields: Vec, + #[serde(rename = "passwordHistory")] + password_history: Vec, } #[derive(serde::Serialize, Debug)] @@ -1197,20 +1202,46 @@ impl Client { pub fn add( &self, access_token: &str, + org_id: Option<&str>, name: &str, data: &crate::db::EntryData, + fields: &[crate::db::Field], notes: Option<&str>, folder_id: Option<&str>, + history: &[crate::db::HistoryEntry], ) -> Result<()> { let mut req = CiphersPostReq { - ty: 1, + ty: match data { + crate::db::EntryData::Login { .. } => 1, + crate::db::EntryData::SecureNote => 2, + crate::db::EntryData::Card { .. } => 3, + crate::db::EntryData::Identity { .. } => 4, + crate::db::EntryData::SshKey { .. } => unreachable!(), + }, folder_id: folder_id.map(std::string::ToString::to_string), + organization_id: org_id.map(std::string::ToString::to_string), name: name.to_string(), notes: notes.map(std::string::ToString::to_string), login: None, card: None, identity: None, secure_note: None, + fields: fields + .iter() + .map(|field| CipherField { + ty: field.ty, + name: field.name.clone(), + value: field.value.clone(), + linked_id: field.linked_id, + }) + .collect(), + password_history: history + .iter() + .map(|entry| CiphersPutReqHistory { + last_used_date: entry.last_used_date.clone(), + password: entry.password.clone(), + }) + .collect(), }; match data { crate::db::EntryData::Login { @@ -1738,24 +1769,20 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { "invalid_client" => { return Error::IncorrectApiKey; } - "" => { + "" if error_desc.is_none() || error_desc == Some("") => { // bitwarden_rs returns an empty error and error_description for // this case, for some reason - if error_desc.is_none() || error_desc == Some("") { - if let Some(error_model) = error_res.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); - match message.as_str() { - "Username or password is incorrect. Try again" - | "TOTP code is not a number" => { + if let Some(error_model) = error_res.error_model.as_ref() { + let message = error_model.message.as_str().to_string(); + match message.as_str() { + "Username or password is incorrect. Try again" + | "TOTP code is not a number" => { + return Error::IncorrectPassword { message }; + } + s => { + if s.starts_with("Invalid TOTP code! Server time: ") { return Error::IncorrectPassword { message }; } - s => { - if s.starts_with( - "Invalid TOTP code! Server time: ", - ) { - return Error::IncorrectPassword { message }; - } - } } } } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..bfe5b6ed 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,6 +1,8 @@ use std::{fmt::Write as _, io::Write as _, os::unix::ffi::OsStrExt as _}; use anyhow::Context as _; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; // The default number of seconds the generated TOTP // code lasts for before a new one must be generated @@ -335,19 +337,82 @@ impl From for DecryptedListCipher { } } -#[derive(Debug, Clone, serde::Serialize)] +/// Custom field type +#[derive(JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SchemaFieldType { + /// Plain text field + Text, + /// Hidden/secret field (masked in UI) + Hidden, + /// Boolean (true/false) field + Boolean, + /// Linked field + Linked, +} + +/// URI match rule +#[derive(JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SchemaUriMatchType { + /// Match the domain (and subdomains) of the URI + Domain, + /// Match the exact host (including port) + Host, + /// Match if the URI starts with this string + StartsWith, + /// Match the exact URI + Exact, + /// Match using a regular expression + RegularExpression, + /// Never match this URI + Never, +} + +/// A decrypted Bitwarden cipher entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedCipher { + /// The unique identifier (UUID) of this entry. Leave empty when creating a new entry. + #[serde(default)] + #[schemars(title = "ID")] id: String, + /// The unique identifier (UUID) of the organization this entry belongs to. Leave null for personal vault. + #[serde(default)] + #[schemars(title = "Organization ID")] + organization_id: Option, + /// The name of the folder to put this entry in. If the folder doesn't exist, it will be created. + #[schemars(title = "Folder")] folder: Option, + /// The name of this entry (e.g. the name of the website or service). + #[schemars(title = "Name")] name: String, + /// The type-specific data of this entry (`Login`, `Card`, `Identity`, `SecureNote`, or `SshKey`). + #[schemars(title = "Entry Data")] data: DecryptedData, + /// Custom fields associated with this entry. + #[serde(default)] + #[schemars(title = "Custom Fields")] fields: Vec, + /// Notes or description for this entry. + #[schemars(title = "Notes")] notes: Option, + /// Password history for this entry (only updated if the password changes). + #[serde(default)] + #[schemars(title = "Password History")] history: Vec, } impl DecryptedCipher { + fn display_custom_field(&self, field: &str, clipboard: bool) { + for f in &self.fields { + if f.name.to_lowercase().as_str().contains(field) { + val_display_or_store(clipboard, &f.value); + break; + } + } + } fn display_short(&self, desc: &str, clipboard: bool) -> bool { match &self.data { DecryptedData::Login { password, .. } => { @@ -451,17 +516,7 @@ impl DecryptedCipher { self.display_short(desc, clipboard); } _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::Card { @@ -514,17 +569,7 @@ impl DecryptedCipher { } } _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::Identity { @@ -617,17 +662,7 @@ impl DecryptedCipher { } } _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::SecureNote => match field.parse() { @@ -635,17 +670,7 @@ impl DecryptedCipher { self.display_short(desc, clipboard); } _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::SshKey { @@ -672,17 +697,7 @@ impl DecryptedCipher { } } _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } + self.display_custom_field(field, clipboard); } }, } @@ -718,8 +733,8 @@ impl DecryptedCipher { for field in &self.fields { displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), + &field.name, + Some(&field.value), clipboard, ); } @@ -837,8 +852,8 @@ impl DecryptedCipher { for field in &self.fields { displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), + &field.name, + Some(&field.value), clipboard, ); } @@ -988,9 +1003,7 @@ impl DecryptedCipher { println!("{}", Field::Notes); } for f in &self.fields { - if let Some(name) = &f.name { - println!("{name}"); - } + println!("{}", f.name); } } @@ -1001,6 +1014,14 @@ impl DecryptedCipher { Ok(()) } + + fn display_yaml(&self, desc: &str) -> anyhow::Result<()> { + serde_yaml::to_writer(std::io::stdout(), &self) + .context(format!("failed to write entry '{desc}' to stdout"))?; + println!(); + + Ok(()) + } } fn val_display_or_store(clipboard: bool, password: &str) -> bool { @@ -1018,96 +1039,330 @@ fn val_display_or_store(clipboard: bool, password: &str) -> bool { } } -#[derive(Debug, Clone, serde::Serialize)] +/// Type-specific data of the entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] enum DecryptedData { + /// Login credentials (username, password, TOTP, URIs) + #[schemars(title = "Login")] Login { + /// The login username + #[schemars(title = "Username")] username: Option, + /// The login password + #[schemars(title = "Password")] password: Option, + /// The TOTP (Time-based One-Time Password) secret key + #[schemars(title = "TOTP Secret")] totp: Option, + /// URIs (URLs) associated with this login + #[schemars(title = "URIs")] uris: Option>, }, + /// Credit/debit card details + #[schemars(title = "Card")] Card { + /// The name on the card + #[schemars(title = "Cardholder Name")] cardholder_name: Option, + /// The card number + #[schemars(title = "Card Number")] number: Option, + /// The card brand (e.g. Visa, Mastercard) + #[schemars(title = "Brand")] brand: Option, + /// The expiration month (MM) + #[schemars(title = "Expiration Month")] exp_month: Option, + /// The expiration year (YYYY) + #[schemars(title = "Expiration Year")] exp_year: Option, + /// The security code (CVV/CVC) + #[schemars(title = "Security Code (CVV)")] code: Option, }, + /// Identity/personal details + #[schemars(title = "Identity")] Identity { + /// Honorific title (e.g. Mr., Ms.) + #[schemars(title = "Title")] title: Option, + /// First name + #[schemars(title = "First Name")] first_name: Option, + /// Middle name + #[schemars(title = "Middle Name")] middle_name: Option, + /// Last name + #[schemars(title = "Last Name")] last_name: Option, + /// Address line 1 + #[schemars(title = "Address Line 1")] address1: Option, + /// Address line 2 + #[schemars(title = "Address Line 2")] address2: Option, + /// Address line 3 + #[schemars(title = "Address Line 3")] address3: Option, + /// City + #[schemars(title = "City")] city: Option, + /// State/Province + #[schemars(title = "State/Province")] state: Option, + /// Postal/ZIP code + #[schemars(title = "Postal/ZIP Code")] postal_code: Option, + /// Country + #[schemars(title = "Country")] country: Option, + /// Phone number + #[schemars(title = "Phone")] phone: Option, + /// Email address + #[schemars(title = "Email")] email: Option, + /// Social Security Number (SSN) or equivalent + #[schemars(title = "SSN")] ssn: Option, + /// Driver's license number + #[schemars(title = "Driver's License")] license_number: Option, + /// Passport number + #[schemars(title = "Passport Number")] passport_number: Option, + /// Preferred username + #[schemars(title = "Username")] username: Option, }, + /// A secure text note + #[schemars(title = "Secure Note")] SecureNote, + /// SSH Key credentials + #[schemars(title = "SSH Key")] SshKey { + /// The SSH public key + #[schemars(title = "Public Key")] public_key: Option, + /// The SSH key fingerprint + #[schemars(title = "Fingerprint")] fingerprint: Option, + /// The SSH private key + #[schemars(title = "Private Key")] private_key: Option, }, } -#[derive(Debug, Clone, serde::Serialize)] +/// A custom field associated with the entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedField { - name: Option, - value: Option, - #[serde(serialize_with = "serialize_field_type", rename = "type")] - ty: Option, + /// The name of the custom field + #[schemars(title = "Field Name")] + name: String, + /// The value of the custom field + #[schemars(title = "Field Value")] + value: String, + /// The type of the custom field + #[serde( + serialize_with = "serialize_field_type", + deserialize_with = "deserialize_field_type", + rename = "type" + )] + #[schemars(with = "SchemaFieldType", title = "Field Type")] + ty: rbw::api::FieldType, } #[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)] fn serialize_field_type( - ty: &Option, + ty: &rbw::api::FieldType, serializer: S, ) -> Result where S: serde::Serializer, { - match ty { - Some(ty) => { - let s = match ty { - rbw::api::FieldType::Text => "text", - rbw::api::FieldType::Hidden => "hidden", - rbw::api::FieldType::Boolean => "boolean", - rbw::api::FieldType::Linked => "linked", - }; - serializer.serialize_some(&Some(s)) - } - None => serializer.serialize_none(), + let s = match ty { + rbw::api::FieldType::Text => "text", + rbw::api::FieldType::Hidden => "hidden", + rbw::api::FieldType::Boolean => "boolean", + rbw::api::FieldType::Linked => "linked", + }; + serializer.serialize_str(s) +} + +fn deserialize_field_type<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + match s.as_str() { + "text" => Ok(rbw::api::FieldType::Text), + "hidden" => Ok(rbw::api::FieldType::Hidden), + "boolean" => Ok(rbw::api::FieldType::Boolean), + "linked" => Ok(rbw::api::FieldType::Linked), + other => Err(serde::de::Error::unknown_variant( + other, + &["text", "hidden", "boolean", "linked"], + )), } } -#[derive(Debug, Clone, serde::Serialize)] +/// A historic password entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedHistoryEntry { + /// The date when this password was last used (RFC3339 format) + #[schemars(title = "Last Used Date")] last_used_date: String, + /// The historic password + #[schemars(title = "Password")] password: String, } -#[derive(Debug, Clone, serde::Serialize)] +/// A URI associated with a login entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedUri { + /// The URI (URL) + #[schemars(title = "URI")] uri: String, + /// The match type rule for this URI + #[serde( + serialize_with = "serialize_uri_match_type", + deserialize_with = "deserialize_uri_match_type" + )] + #[schemars(with = "Option", title = "Match Type")] match_type: Option, } +// We need custom serialization/deserialization for UriMatchType in user-facing DecryptedUri +// because by default, UriMatchType derives Serialize_repr/Deserialize_repr (integers) +// for Bitwarden API compatibility and local database cache (db.json) backwards compatibility. +// Using custom serialize/deserialize allows us to present friendly strings (like "domain", "host") +// to the user while keeping the internal integer representations intact. +#[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)] +fn serialize_uri_match_type( + match_type: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match match_type { + Some(mt) => { + let s = match mt { + rbw::api::UriMatchType::Domain => "domain", + rbw::api::UriMatchType::Host => "host", + rbw::api::UriMatchType::StartsWith => "starts_with", + rbw::api::UriMatchType::Exact => "exact", + rbw::api::UriMatchType::RegularExpression => { + "regular_expression" + } + rbw::api::UriMatchType::Never => "never", + }; + serializer.serialize_some(s) + } + None => serializer.serialize_none(), + } +} + +fn deserialize_uri_match_type<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Option; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + formatter.write_str("uri match type (string or integer)") + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + match s { + "domain" => Ok(Some(rbw::api::UriMatchType::Domain)), + "host" => Ok(Some(rbw::api::UriMatchType::Host)), + "starts_with" => Ok(Some(rbw::api::UriMatchType::StartsWith)), + "exact" => Ok(Some(rbw::api::UriMatchType::Exact)), + "regular_expression" => { + Ok(Some(rbw::api::UriMatchType::RegularExpression)) + } + "never" => Ok(Some(rbw::api::UriMatchType::Never)), + _ => Err(serde::de::Error::unknown_variant( + s, + &[ + "domain", + "host", + "starts_with", + "exact", + "regular_expression", + "never", + ], + )), + } + } + + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + match v { + 0 => Ok(Some(rbw::api::UriMatchType::Domain)), + 1 => Ok(Some(rbw::api::UriMatchType::Host)), + 2 => Ok(Some(rbw::api::UriMatchType::StartsWith)), + 3 => Ok(Some(rbw::api::UriMatchType::Exact)), + 4 => Ok(Some(rbw::api::UriMatchType::RegularExpression)), + 5 => Ok(Some(rbw::api::UriMatchType::Never)), + _ => Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Unsigned(v), + &"integer between 0 and 5", + )), + } + } + + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + + fn visit_some( + self, + deserializer: D, + ) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(self) + } + } + deserializer.deserialize_any(Visitor) +} + fn matches_url( url: &str, match_type: Option, @@ -1391,6 +1646,7 @@ pub fn get( field: Option<&str>, full: bool, raw: bool, + yaml: bool, clipboard: bool, ignore_case: bool, list_fields: bool, @@ -1412,6 +1668,8 @@ pub fn get( decrypted.display_fields_list(); } else if raw { decrypted.display_json(&desc)?; + } else if yaml { + decrypted.display_yaml(&desc)?; } else if full { decrypted.display_long(&desc, clipboard); } else if let Some(field) = field { @@ -1507,8 +1765,7 @@ pub fn search( .filter(|entry| { entry .as_ref() - .map(|entry| entry.search_match(term, folder)) - .unwrap_or(true) + .map_or(true, |entry| entry.search_match(term, folder)) }) .map(|entry| entry.map(std::convert::Into::into)) .collect::>()?; @@ -1555,102 +1812,207 @@ pub fn code( Ok(()) } +fn get_or_create_folder( + access_token: &mut String, + refresh_token: &str, + db: &mut rbw::db::Db, + folder_name: &str, +) -> anyhow::Result> { + let (new_access_token, folders) = + rbw::actions::list_folders(access_token, refresh_token)?; + if let Some(new_access_token) = new_access_token { + access_token.clone_from(&new_access_token); + db.access_token = Some(new_access_token); + save_db(db)?; + } + + let folders: Vec<(String, String)> = folders + .iter() + .cloned() + .map(|(id, name)| { + Ok((id, crate::actions::decrypt(&name, None, None)?)) + }) + .collect::>()?; + + for (id, name) in folders { + if name == folder_name { + return Ok(Some(id)); + } + } + + let (new_access_token, id) = rbw::actions::create_folder( + access_token, + refresh_token, + &crate::actions::encrypt(folder_name, None)?, + )?; + if let Some(new_access_token) = new_access_token { + access_token.clone_from(&new_access_token); + db.access_token = Some(new_access_token); + save_db(db)?; + } + Ok(Some(id)) +} + pub fn add( - name: &str, + name: Option<&str>, username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + raw: bool, + yaml: bool, ) -> anyhow::Result<()> { + if !raw && !yaml && name.is_none() { + anyhow::bail!("Name is required unless using --raw or --yaml"); + } + unlock()?; let mut db = load_db()?; // unwrap is safe here because the call to unlock above is guaranteed to // populate these or error let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap().clone(); + + if raw || yaml { + let template = DecryptedCipher { + id: String::new(), + organization_id: None, + folder: folder.map(String::from), + name: name.unwrap_or("").to_string(), + data: DecryptedData::Login { + username: username.map(String::from), + password: None, + totp: None, + uris: if uris.is_empty() { + None + } else { + Some( + uris.iter() + .map(|(u, mt)| DecryptedUri { + uri: u.clone(), + match_type: *mt, + }) + .collect(), + ) + }, + }, + fields: Vec::new(), + notes: None, + history: Vec::new(), + }; - let name = crate::actions::encrypt(name, None)?; + let edited = if yaml { + let schema = schemars::schema_for!(DecryptedCipher); + let schema_json = serde_json::to_string_pretty(&schema)?; + let mut contents = String::new(); + contents + .push_str("# yaml-language-server: $schema=./schema.json\n"); + contents.push_str(&serde_yaml::to_string(&template)?); + rbw::edit::edit( + &contents, + "", + "rbw.yaml", + &[("schema.json", &schema_json)], + )? + } else { + let contents = serde_json::to_string_pretty(&template)?; + rbw::edit::edit(&contents, "", "rbw.json", &[])? + }; - let username = username - .map(|username| crate::actions::encrypt(username, None)) - .transpose()?; + let decrypted: DecryptedCipher = if yaml { + serde_yaml::from_str(&edited)? + } else { + serde_json::from_str(&edited)? + }; - let contents = rbw::edit::edit("", HELP_PW)?; + let (enc_name, enc_data, enc_fields, enc_notes, enc_history) = + encrypt_cipher(&decrypted, decrypted.organization_id.as_deref())?; - let (password, notes) = parse_editor(&contents); - let password = password - .map(|password| crate::actions::encrypt(&password, None)) - .transpose()?; - let notes = notes - .map(|notes| crate::actions::encrypt(¬es, None)) - .transpose()?; - let uris: Vec<_> = uris - .iter() - .map(|uri| { - Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, - match_type: uri.1, - }) - }) - .collect::>()?; + let folder_id = if let Some(folder_name) = &decrypted.folder { + get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )? + } else { + None + }; - let mut folder_id = None; - if let Some(folder_name) = folder { - let (new_access_token, folders) = - rbw::actions::list_folders(&access_token, refresh_token)?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); + if let (Some(access_token), ()) = rbw::actions::add( + &access_token, + &refresh_token, + decrypted.organization_id.as_deref(), + &enc_name, + &enc_data, + &enc_fields, + enc_notes.as_deref(), + folder_id.as_deref(), + &enc_history, + )? { + db.access_token = Some(access_token); save_db(&db)?; } + } else { + let name = name.unwrap(); + let name = crate::actions::encrypt(name, None)?; + + let username = username + .map(|username| crate::actions::encrypt(username, None)) + .transpose()?; - let folders: Vec<(String, String)> = folders + let contents = rbw::edit::edit("", HELP_PW, "rbw", &[])?; + + let (password, notes) = parse_editor(&contents); + let password = password + .map(|password| crate::actions::encrypt(&password, None)) + .transpose()?; + let notes = notes + .map(|notes| crate::actions::encrypt(¬es, None)) + .transpose()?; + let uris: Vec<_> = uris .iter() - .cloned() - .map(|(id, name)| { - Ok((id, crate::actions::decrypt(&name, None, None)?)) + .map(|uri| { + Ok(rbw::db::Uri { + uri: crate::actions::encrypt(&uri.0, None)?, + match_type: uri.1, + }) }) .collect::>()?; - for (id, name) in folders { - if name == folder_name { - folder_id = Some(id); - } - } - if folder_id.is_none() { - let (new_access_token, id) = rbw::actions::create_folder( - &access_token, - refresh_token, - &crate::actions::encrypt(folder_name, None)?, - )?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - folder_id = Some(id); - } - } + let folder_id = if let Some(folder_name) = folder { + get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )? + } else { + None + }; - if let (Some(access_token), ()) = rbw::actions::add( - &access_token, - refresh_token, - &name, - &rbw::db::EntryData::Login { - username, - password, - uris, - totp: None, - }, - notes.as_deref(), - folder_id.as_deref(), - )? { - db.access_token = Some(access_token); - save_db(&db)?; + if let (Some(access_token), ()) = rbw::actions::add( + &access_token, + &refresh_token, + None, + &name, + &rbw::db::EntryData::Login { + username, + password, + uris, + totp: None, + }, + &[], + notes.as_deref(), + folder_id.as_deref(), + &[], + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } } crate::actions::sync()?; - Ok(()) } @@ -1672,13 +2034,13 @@ pub fn generate( // unwrap is safe here because the call to unlock above is guaranteed // to populate these or error let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap().clone(); let name = crate::actions::encrypt(name, None)?; let username = username .map(|username| crate::actions::encrypt(username, None)) .transpose()?; - let password = crate::actions::encrypt(&password, None)?; + let enc_password = crate::actions::encrypt(&password, None)?; let uris: Vec<_> = uris .iter() .map(|uri| { @@ -1689,56 +2051,32 @@ pub fn generate( }) .collect::>()?; - let mut folder_id = None; - if let Some(folder_name) = folder { - let (new_access_token, folders) = - rbw::actions::list_folders(&access_token, refresh_token)?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - - let folders: Vec<(String, String)> = folders - .iter() - .cloned() - .map(|(id, name)| { - Ok((id, crate::actions::decrypt(&name, None, None)?)) - }) - .collect::>()?; - - for (id, name) in folders { - if name == folder_name { - folder_id = Some(id); - } - } - if folder_id.is_none() { - let (new_access_token, id) = rbw::actions::create_folder( - &access_token, - refresh_token, - &crate::actions::encrypt(folder_name, None)?, - )?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - folder_id = Some(id); - } - } + let folder_id = if let Some(folder_name) = folder { + get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )? + } else { + None + }; if let (Some(access_token), ()) = rbw::actions::add( &access_token, - refresh_token, + &refresh_token, + None, &name, &rbw::db::EntryData::Login { username, - password: Some(password), + password: Some(enc_password), uris, totp: None, }, + &[], None, folder_id.as_deref(), + &[], )? { db.access_token = Some(access_token); save_db(&db)?; @@ -1755,12 +2093,14 @@ pub fn edit( username: Option<&str>, folder: Option<&str>, ignore_case: bool, + raw: bool, + yaml: bool, ) -> anyhow::Result<()> { unlock()?; let mut db = load_db()?; - let access_token = db.access_token.as_ref().unwrap(); - let refresh_token = db.refresh_token.as_ref().unwrap(); + let mut access_token = db.access_token.as_ref().unwrap().clone(); + let refresh_token = db.refresh_token.as_ref().unwrap().clone(); let desc = format!( "{}{}", @@ -1768,107 +2108,182 @@ pub fn edit( name ); - let (entry, decrypted) = + let (entry, decrypted_original) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - let (data, fields, notes, history) = match &decrypted.data { - DecryptedData::Login { password, .. } => { - let mut contents = - format!("{}\n", password.as_deref().unwrap_or("")); - if let Some(notes) = decrypted.notes { - write!(contents, "\n{notes}\n").unwrap(); + if raw || yaml { + let edited = if yaml { + let schema = schemars::schema_for!(DecryptedCipher); + let schema_json = serde_json::to_string_pretty(&schema)?; + let mut contents = String::new(); + contents + .push_str("# yaml-language-server: $schema=./schema.json\n"); + contents.push_str(&serde_yaml::to_string(&decrypted_original)?); + rbw::edit::edit( + &contents, + "", + "rbw.yaml", + &[("schema.json", &schema_json)], + )? + } else { + let contents = serde_json::to_string_pretty(&decrypted_original)?; + rbw::edit::edit(&contents, "", "rbw.json", &[])? + }; + + let decrypted_new: DecryptedCipher = if yaml { + serde_yaml::from_str(&edited)? + } else { + serde_json::from_str(&edited)? + }; + + if decrypted_new.id != decrypted_original.id { + log::warn!("Changing the ID in raw content is not supported and will be ignored."); + } + + let (enc_name, enc_data, enc_fields, enc_notes, enc_history) = + encrypt_cipher( + &decrypted_new, + decrypted_new.organization_id.as_deref(), + )?; + + let mut folder_id = entry.folder_id.clone(); + if decrypted_new.folder != decrypted_original.folder { + if let Some(folder_name) = &decrypted_new.folder { + folder_id = get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )?; + } else { + folder_id = None; } + } - let contents = rbw::edit::edit(&contents, HELP_PW)?; + if let (Some(new_access_token), ()) = rbw::actions::edit( + &access_token, + &refresh_token, + &entry.id, + decrypted_new.organization_id.as_deref(), + &enc_name, + &enc_data, + &enc_fields, + enc_notes.as_deref(), + folder_id.as_deref(), + &enc_history, + )? { + db.access_token = Some(new_access_token); + save_db(&db)?; + } + } else { + let (data, fields, notes, history) = match &decrypted_original.data { + DecryptedData::Login { password, .. } => { + let mut contents = + format!("{}\n", password.as_deref().unwrap_or("")); + if let Some(notes) = decrypted_original.notes.as_ref() { + write!(contents, "\n{notes}\n").unwrap(); + } - let (password, notes) = parse_editor(&contents); - let password = password - .map(|password| { - crate::actions::encrypt( - &password, - entry.org_id.as_deref(), - ) - }) - .transpose()?; - let notes = notes - .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) - }) - .transpose()?; - let mut history = entry.history.clone(); - let rbw::db::EntryData::Login { - username: entry_username, - password: entry_password, - uris: entry_uris, - totp: entry_totp, - } = &entry.data - else { - unreachable!(); - }; + let contents = + rbw::edit::edit(&contents, HELP_PW, "rbw", &[])?; - if let Some(prev_password) = entry_password.clone() { - let new_history_entry = rbw::db::HistoryEntry { - last_used_date: format!( - "{}", - humantime::format_rfc3339( - std::time::SystemTime::now() + let (password, notes) = parse_editor(&contents); + let password = password + .map(|password| { + crate::actions::encrypt( + &password, + entry.org_id.as_deref(), ) - ), - password: prev_password, + }) + .transpose()?; + let notes = notes + .map(|notes| { + crate::actions::encrypt( + ¬es, + entry.org_id.as_deref(), + ) + }) + .transpose()?; + let mut history = entry.history.clone(); + let rbw::db::EntryData::Login { + username: entry_username, + password: entry_password, + uris: entry_uris, + totp: entry_totp, + } = &entry.data + else { + unreachable!(); }; - history.insert(0, new_history_entry); + + if let Some(prev_password) = entry_password.clone() { + let new_history_entry = rbw::db::HistoryEntry { + last_used_date: format!( + "{}", + humantime::format_rfc3339( + std::time::SystemTime::now() + ) + ), + password: prev_password, + }; + history.insert(0, new_history_entry); + } + + let data = rbw::db::EntryData::Login { + username: entry_username.clone(), + password, + uris: entry_uris.clone(), + totp: entry_totp.clone(), + }; + (data, entry.fields.clone(), notes, history) } + DecryptedData::SecureNote => { + let data = rbw::db::EntryData::SecureNote {}; - let data = rbw::db::EntryData::Login { - username: entry_username.clone(), - password, - uris: entry_uris.clone(), - totp: entry_totp.clone(), - }; - (data, entry.fields, notes, history) - } - DecryptedData::SecureNote => { - let data = rbw::db::EntryData::SecureNote {}; + let editor_content = + decrypted_original.notes.as_ref().map_or_else( + || "\n".to_string(), + |notes| format!("{notes}\n"), + ); + let contents = + rbw::edit::edit(&editor_content, HELP_NOTES, "rbw", &[])?; - let editor_content = decrypted.notes.map_or_else( - || "\n".to_string(), - |notes| format!("{notes}\n"), - ); - let contents = rbw::edit::edit(&editor_content, HELP_NOTES)?; + // prepend blank line to be parsed as pw by `parse_editor` + let (_, notes) = parse_editor(&format!("\n{contents}\n")); - // prepend blank line to be parsed as pw by `parse_editor` - let (_, notes) = parse_editor(&format!("\n{contents}\n")); + let notes = notes + .map(|notes| { + crate::actions::encrypt( + ¬es, + entry.org_id.as_deref(), + ) + }) + .transpose()?; - let notes = notes - .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) - }) - .transpose()?; + (data, entry.fields.clone(), notes, entry.history.clone()) + } + _ => { + return Err(anyhow::anyhow!( + "modifications are only supported for login and note entries" + )); + } + }; - (data, entry.fields, notes, entry.history) - } - _ => { - return Err(anyhow::anyhow!( - "modifications are only supported for login and note entries" - )); + if let (Some(new_access_token), ()) = rbw::actions::edit( + &access_token, + &refresh_token, + &entry.id, + entry.org_id.as_deref(), + &entry.name, + &data, + &fields, + notes.as_deref(), + entry.folder_id.as_deref(), + &history, + )? { + db.access_token = Some(new_access_token); + save_db(&db)?; } - }; - - if let (Some(access_token), ()) = rbw::actions::edit( - access_token, - refresh_token, - &entry.id, - entry.org_id.as_deref(), - &entry.name, - &data, - &fields, - notes.as_deref(), - entry.folder_id.as_deref(), - &history, - )? { - db.access_token = Some(access_token); - save_db(&db)?; } crate::actions::sync()?; @@ -2297,6 +2712,162 @@ fn decrypt_search_cipher( }) } +#[allow(clippy::ref_option)] +fn encrypt_field( + field: &Option, + org_id: Option<&str>, +) -> anyhow::Result> { + field + .as_deref() + .map(|v| crate::actions::encrypt(v, org_id)) + .transpose() +} + +fn encrypt_cipher( + decrypted: &DecryptedCipher, + org_id: Option<&str>, +) -> anyhow::Result<( + String, // encrypted name + rbw::db::EntryData, // encrypted data + Vec, // encrypted fields + Option, // encrypted notes + Vec, // encrypted history +)> { + let name = crate::actions::encrypt(&decrypted.name, org_id)?; + let notes = decrypted + .notes + .as_deref() + .map(|notes| crate::actions::encrypt(notes, org_id)) + .transpose()?; + + let fields = decrypted + .fields + .iter() + .map(|field| { + Ok(rbw::db::Field { + ty: Some(field.ty), + name: Some(crate::actions::encrypt(&field.name, org_id)?), + value: Some(crate::actions::encrypt(&field.value, org_id)?), + linked_id: None, + }) + }) + .collect::>>()?; + + let history = decrypted + .history + .iter() + .map(|history_entry| { + Ok(rbw::db::HistoryEntry { + last_used_date: history_entry.last_used_date.clone(), + password: crate::actions::encrypt( + &history_entry.password, + org_id, + )?, + }) + }) + .collect::>>()?; + + let data = match &decrypted.data { + DecryptedData::Login { + username, + password, + totp, + uris, + } => { + let username = encrypt_field(username, org_id)?; + let password = encrypt_field(password, org_id)?; + let totp = encrypt_field(totp, org_id)?; + let uris = uris + .as_ref() + .map(|uris| { + uris.iter() + .map(|uri| { + Ok(rbw::db::Uri { + uri: crate::actions::encrypt( + &uri.uri, org_id, + )?, + match_type: uri.match_type, + }) + }) + .collect::>>() + }) + .transpose()? + .unwrap_or_default(); + + rbw::db::EntryData::Login { + username, + password, + totp, + uris, + } + } + DecryptedData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } => rbw::db::EntryData::Card { + cardholder_name: encrypt_field(cardholder_name, org_id)?, + number: encrypt_field(number, org_id)?, + brand: encrypt_field(brand, org_id)?, + exp_month: encrypt_field(exp_month, org_id)?, + exp_year: encrypt_field(exp_year, org_id)?, + code: encrypt_field(code, org_id)?, + }, + DecryptedData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } => rbw::db::EntryData::Identity { + title: encrypt_field(title, org_id)?, + first_name: encrypt_field(first_name, org_id)?, + middle_name: encrypt_field(middle_name, org_id)?, + last_name: encrypt_field(last_name, org_id)?, + address1: encrypt_field(address1, org_id)?, + address2: encrypt_field(address2, org_id)?, + address3: encrypt_field(address3, org_id)?, + city: encrypt_field(city, org_id)?, + state: encrypt_field(state, org_id)?, + postal_code: encrypt_field(postal_code, org_id)?, + country: encrypt_field(country, org_id)?, + phone: encrypt_field(phone, org_id)?, + email: encrypt_field(email, org_id)?, + ssn: encrypt_field(ssn, org_id)?, + license_number: encrypt_field(license_number, org_id)?, + passport_number: encrypt_field(passport_number, org_id)?, + username: encrypt_field(username, org_id)?, + }, + DecryptedData::SecureNote => rbw::db::EntryData::SecureNote, + DecryptedData::SshKey { + public_key, + fingerprint, + private_key, + } => rbw::db::EntryData::SshKey { + public_key: encrypt_field(public_key, org_id)?, + fingerprint: encrypt_field(fingerprint, org_id)?, + private_key: encrypt_field(private_key, org_id)?, + }, + }; + + Ok((name, data, fields, notes, history)) +} + fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization @@ -2327,7 +2898,8 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { entry.org_id.as_deref(), ) }) - .transpose()?, + .transpose()? + .unwrap_or_default(), value: field .value .as_ref() @@ -2338,8 +2910,9 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { entry.org_id.as_deref(), ) }) - .transpose()?, - ty: field.ty, + .transpose()? + .unwrap_or_default(), + ty: field.ty.unwrap_or(rbw::api::FieldType::Text), }) }) .collect::>()?; @@ -2613,6 +3186,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { Ok(DecryptedCipher { id: entry.id.clone(), + organization_id: entry.org_id.clone(), folder, name: crate::actions::decrypt( &entry.name, @@ -2815,6 +3389,160 @@ fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { mod test { use super::*; + #[test] + fn test_raw_yaml_serde() { + let original = DecryptedCipher { + id: "some-id".to_string(), + organization_id: Some("some-org".to_string()), + folder: Some("some-folder".to_string()), + name: "some-name".to_string(), + data: DecryptedData::Login { + username: Some("some-user".to_string()), + password: Some("some-password".to_string()), + totp: Some("some-totp".to_string()), + uris: Some(vec![ + DecryptedUri { + uri: "http://example.com".to_string(), + match_type: Some(rbw::api::UriMatchType::Domain), + }, + DecryptedUri { + uri: "http://example.org".to_string(), + match_type: None, + }, + ]), + }, + fields: vec![ + DecryptedField { + name: "field1".to_string(), + value: "value1\nline2\nline3".to_string(), + ty: rbw::api::FieldType::Text, + }, + DecryptedField { + name: "field2".to_string(), + value: "value2".to_string(), + ty: rbw::api::FieldType::Hidden, + }, + ], + notes: Some("some-notes\nline2\nline3".to_string()), + history: vec![DecryptedHistoryEntry { + last_used_date: "2026-05-29T14:55:19-07:00".to_string(), + password: "old-password".to_string(), + }], + }; + + // Serialize to JSON + let json = serde_json::to_string_pretty(&original).unwrap(); + // Deserialize from JSON + let deserialized_json: DecryptedCipher = + serde_json::from_str(&json).unwrap(); + assert_eq!(original, deserialized_json); + + // Serialize to YAML + let yaml = serde_yaml::to_string(&original).unwrap(); + // All multi-line strings should be block literals. + // In YAML, block literal starts with `|` + assert!(yaml.contains("notes: |")); + assert!(yaml.contains("value: |")); + assert!(yaml.contains("some-notes")); + assert!(yaml.contains("value1")); + assert!(yaml.contains("line2")); + assert!(yaml.contains("line3")); + + // Deserialize from YAML + let deserialized_yaml: DecryptedCipher = + serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(original, deserialized_yaml); + + // Test Schema Generation + let schema = schemars::schema_for!(DecryptedCipher); + let schema_json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(schema_json.contains("DecryptedCipher")); + assert!(schema_json.contains("organization_id")); + assert!(schema_json.contains("SchemaUriMatchType")); + assert!(schema_json.contains("Field Type")); + } + + #[test] + fn test_strict_validation() { + // 1. Test unknown field in DecryptedCipher fails + let bad_cipher_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user" + }, + "unknown_field": "invalid" + }"#; + let res: Result = + serde_json::from_str(bad_cipher_json); + assert!(res.is_err()); + assert!(res + .unwrap_err() + .to_string() + .contains("unknown field `unknown_field`")); + + // 2. Test unknown field in DecryptedData (Login variant) fails + let bad_login_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user", + "bad_field": "invalid" + } + }"#; + let res: Result = + serde_json::from_str(bad_login_json); + assert!(res.is_err()); + let err_msg = res.unwrap_err().to_string(); + assert!( + err_msg.contains("data did not match any variant") + || err_msg.contains("unknown field") + ); + + // 3. Test missing required field in DecryptedField fails + let missing_name_field_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user" + }, + "fields": [ + { + "value": "some-value", + "type": "text" + } + ] + }"#; + let res: Result = + serde_json::from_str(missing_name_field_json); + assert!(res.is_err()); + assert!(res + .unwrap_err() + .to_string() + .contains("missing field `name`")); + + let missing_type_field_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user" + }, + "fields": [ + { + "name": "some-name", + "value": "some-value" + } + ] + }"#; + let res: Result = + serde_json::from_str(missing_type_field_json); + assert!(res.is_err()); + assert!(res + .unwrap_err() + .to_string() + .contains("missing field `type`")); + } + #[test] fn test_find_entry() { let entries = &[ diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..9f3f5877 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -77,8 +77,14 @@ enum Opt { field: Option, #[arg(long, help = "Display the notes in addition to the password")] full: bool, - #[structopt(long, help = "Display output as JSON")] + #[arg( + long, + conflicts_with = "yaml", + help = "Display output as JSON" + )] raw: bool, + #[arg(long, help = "Display output as YAML")] + yaml: bool, #[cfg(feature = "clipboard")] #[structopt(short, long, help = "Copy result to clipboard")] clipboard: bool, @@ -127,8 +133,10 @@ enum Opt { remainder will be saved as a note." )] Add { - #[arg(help = "Name of the password entry")] - name: String, + #[arg( + help = "Name of the password entry (optional if using --raw or --yaml)" + )] + name: Option, #[arg(help = "Username for the password entry")] user: Option, #[arg( @@ -139,6 +147,14 @@ enum Opt { uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, + #[arg( + long, + conflicts_with = "yaml", + help = "Add entry from JSON raw input" + )] + raw: bool, + #[arg(long, help = "Add entry from YAML raw input")] + yaml: bool, }, #[command( @@ -209,6 +225,14 @@ enum Opt { Edit { #[command(flatten)] find_args: FindArgs, + #[arg( + long, + conflicts_with = "yaml", + help = "Edit entry as JSON raw" + )] + raw: bool, + #[arg(long, help = "Edit entry as YAML")] + yaml: bool, }, #[command(about = "Remove a given entry", visible_alias = "rm")] @@ -343,6 +367,7 @@ fn main() { field, full, raw, + yaml, #[cfg(feature = "clipboard")] clipboard, list_fields, @@ -353,6 +378,7 @@ fn main() { field.as_deref(), full, raw, + yaml, #[cfg(feature = "clipboard")] clipboard, #[cfg(not(feature = "clipboard"))] @@ -385,8 +411,10 @@ fn main() { user, uri, folder, + raw, + yaml, } => commands::add( - &name, + name.as_deref(), user.as_deref(), &uri.iter() // XXX not sure what the ui for specifying the match type @@ -394,6 +422,8 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + raw, + yaml, ), Opt::Generate { len, @@ -430,11 +460,17 @@ fn main() { ty, ) } - Opt::Edit { find_args } => commands::edit( + Opt::Edit { + find_args, + raw, + yaml, + } => commands::edit( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), find_args.ignorecase, + raw, + yaml, ), Opt::Remove { find_args } => commands::remove( find_args.needle, diff --git a/src/dirs.rs b/src/dirs.rs index 079fc880..2fb12dd3 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -97,7 +97,7 @@ fn runtime_dir() -> std::path::PathBuf { format!( "{}/{}-{}", std::env::temp_dir().to_string_lossy(), - &profile(), + profile(), rustix::process::getuid().as_raw() ) .into() diff --git a/src/edit.rs b/src/edit.rs index 7295a93a..5c43ec28 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -4,7 +4,12 @@ use std::io::{Read as _, Write as _}; use is_terminal::IsTerminal as _; -pub fn edit(contents: &str, help: &str) -> Result { +pub fn edit( + contents: &str, + help: &str, + filename: &str, + extra_files: &[(&str, &str)], +) -> Result { if !std::io::stdin().is_terminal() { // directly read from piped content return match std::io::read_to_string(std::io::stdin()) { @@ -20,12 +25,18 @@ pub fn edit(contents: &str, help: &str) -> Result { }); let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("rbw"); + let file = dir.path().join(filename); let mut fh = std::fs::File::create(&file).unwrap(); fh.write_all(contents.as_bytes()).unwrap(); fh.write_all(help.as_bytes()).unwrap(); drop(fh); + for (name, data) in extra_files { + let path = dir.path().join(name); + let mut fh = std::fs::File::create(&path).unwrap(); + fh.write_all(data.as_bytes()).unwrap(); + } + let (cmd, args) = if contains_shell_metacharacters(&editor) { let mut cmdline = std::ffi::OsString::new(); cmdline.extend([