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([