From 14eaa78c62382a28f2ae406567fa97c1283179ab Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 29 May 2026 15:33:21 -0700 Subject: [PATCH 1/4] 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 | 50 +- src/api.rs | 98 ++- src/bin/rbw-agent/actions.rs | 15 +- src/bin/rbw-agent/agent.rs | 7 +- src/bin/rbw/actions.rs | 2 + src/bin/rbw/commands.rs | 1539 +++++++++++++++++++++++++++------- src/bin/rbw/main.rs | 46 +- src/dirs.rs | 2 +- src/edit.rs | 15 +- src/protocol.rs | 2 + 13 files changed, 1547 insertions(+), 349 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..310bbf50 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,28 +167,53 @@ 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(()) } +#[allow(clippy::too_many_arguments)] pub fn edit( access_token: &str, refresh_token: &str, @@ -204,6 +225,7 @@ pub fn edit( notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], + key: Option<&str>, ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { edit_once( @@ -216,10 +238,12 @@ pub fn edit( notes, folder_uuid, history, + key, ) }) } +#[allow(clippy::too_many_arguments)] fn edit_once( access_token: &str, id: &str, @@ -230,6 +254,7 @@ fn edit_once( notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], + key: Option<&str>, ) -> Result<()> { let (client, _) = api_client()?; client.edit( @@ -242,6 +267,7 @@ fn edit_once( notes, folder_uuid, history, + key, )?; Ok(()) } diff --git a/src/api.rs b/src/api.rs index a817fb26..c01937b0 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,12 +762,17 @@ struct CiphersPostReq { identity: Option, #[serde(rename = "secureNote")] secure_note: Option, + #[serde(rename = "sshKey")] + ssh_key: Option, + fields: Vec, + #[serde(rename = "passwordHistory")] + password_history: Vec, } #[derive(serde::Serialize, Debug)] struct CiphersPutReq { #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? + ty: u32, #[serde(rename = "folderId")] folder_id: Option, #[serde(rename = "organizationId")] @@ -778,8 +785,12 @@ struct CiphersPutReq { fields: Vec, #[serde(rename = "secureNote")] secure_note: Option, + #[serde(rename = "sshKey")] + ssh_key: Option, #[serde(rename = "passwordHistory")] password_history: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, } #[derive(serde::Serialize, Debug)] @@ -1197,20 +1208,47 @@ 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 { .. } => 5, + }, 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, + ssh_key: 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 { @@ -1297,7 +1335,17 @@ impl Client { crate::db::EntryData::SecureNote => { req.secure_note = Some(CipherSecureNote {}); } - crate::db::EntryData::SshKey { .. } => unreachable!(), + crate::db::EntryData::SshKey { + private_key, + public_key, + fingerprint, + } => { + req.ssh_key = Some(CipherSshKey { + private_key: private_key.clone(), + public_key: public_key.clone(), + fingerprint: fingerprint.clone(), + }); + } } let client = reqwest::blocking::Client::new(); let res = client @@ -1317,6 +1365,7 @@ impl Client { } } + #[allow(clippy::too_many_arguments)] pub fn edit( &self, access_token: &str, @@ -1328,6 +1377,7 @@ impl Client { notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], + key: Option<&str>, ) -> Result<()> { let mut req = CiphersPutReq { ty: match data { @@ -1335,7 +1385,7 @@ impl Client { crate::db::EntryData::SecureNote => 2, crate::db::EntryData::Card { .. } => 3, crate::db::EntryData::Identity { .. } => 4, - crate::db::EntryData::SshKey { .. } => unreachable!(), + crate::db::EntryData::SshKey { .. } => 5, }, folder_id: folder_uuid.map(std::string::ToString::to_string), organization_id: org_id.map(std::string::ToString::to_string), @@ -1345,6 +1395,7 @@ impl Client { card: None, identity: None, secure_note: None, + ssh_key: None, fields: fields .iter() .map(|field| CipherField { @@ -1361,6 +1412,7 @@ impl Client { password: entry.password.clone(), }) .collect(), + key: key.map(std::string::ToString::to_string), }; match data { crate::db::EntryData::Login { @@ -1447,7 +1499,17 @@ impl Client { crate::db::EntryData::SecureNote => { req.secure_note = Some(CipherSecureNote {}); } - crate::db::EntryData::SshKey { .. } => unreachable!(), + crate::db::EntryData::SshKey { + private_key, + public_key, + fingerprint, + } => { + req.ssh_key = Some(CipherSshKey { + private_key: private_key.clone(), + public_key: public_key.clone(), + fingerprint: fingerprint.clone(), + }); + } } let client = reqwest::blocking::Client::new(); let res = client @@ -1738,24 +1800,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-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..b7b4e41c 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -692,6 +692,7 @@ pub async fn encrypt( sock: &mut crate::sock::Sock, state: std::sync::Arc>, plaintext: &str, + entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result<()> { let state = state.lock().await; @@ -700,8 +701,20 @@ pub async fn encrypt( "failed to find encryption keys in in-memory state" )); }; + let entry_key = if let Some(entry_key) = entry_key { + let key_cipherstring = + rbw::cipherstring::CipherString::new(entry_key) + .context("failed to parse individual item encryption key")?; + Some(rbw::locked::Keys::new( + key_cipherstring.decrypt_locked_symmetric(keys).context( + "failed to decrypt individual item encryption key", + )?, + )) + } else { + None + }; let cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric( - keys, + entry_key.as_ref().unwrap_or(keys), plaintext.as_bytes(), ) .context("failed to encrypt plaintext secret")?; diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 1691ed51..aac7de5f 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -162,11 +162,16 @@ async fn handle_request( .await?; true } - rbw::protocol::Action::Encrypt { plaintext, org_id } => { + rbw::protocol::Action::Encrypt { + plaintext, + entry_key, + org_id, + } => { crate::actions::encrypt( sock, state.clone(), plaintext, + entry_key.as_deref(), org_id.as_deref(), ) .await?; diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index a0a34e8c..1edd3964 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -107,6 +107,7 @@ pub fn decrypt( pub fn encrypt( plaintext: &str, + entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { let mut sock = connect()?; @@ -114,6 +115,7 @@ pub fn encrypt( get_environment(), rbw::protocol::Action::Encrypt { plaintext: plaintext.to_string(), + entry_key: entry_key.map(std::string::ToString::to_string), org_id: org_id.map(std::string::ToString::to_string), }, ))?; diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..d271fa3d 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,400 @@ fn val_display_or_store(clipboard: bool, password: &str) -> bool { } } -#[derive(Debug, Clone, serde::Serialize)] -#[serde(untagged)] +/// Type-specific data of the entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged, deny_unknown_fields, remote = "Self")] #[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)] +impl Serialize for DecryptedData { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + Self::serialize(self, serializer) + } +} + +impl<'de> Deserialize<'de> for DecryptedData { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let opt = + Option::>::deserialize( + deserializer, + )?; + let Some(mut map) = opt else { + return Ok(Self::SecureNote); + }; + if map.is_empty() { + return Ok(Self::SecureNote); + } + if let Some(type_val) = map.remove("type") { + let type_str = type_val.as_str().ok_or_else(|| { + serde::de::Error::custom("expected string for `type`") + })?; + match type_str { + "secure_note" | "note" => { + if let Some(key) = map.keys().next() { + return Err(serde::de::Error::unknown_field(key, &[])); + } + return Ok(Self::SecureNote); + } + "login" => { + map.entry("password").or_insert(serde_json::Value::Null); + } + "card" => { + map.entry("cardholder_name") + .or_insert(serde_json::Value::Null); + } + "identity" => { + map.entry("title").or_insert(serde_json::Value::Null); + } + "ssh_key" => { + map.entry("public_key").or_insert(serde_json::Value::Null); + } + other => { + return Err(serde::de::Error::unknown_variant( + other, + &[ + "login", + "card", + "identity", + "secure_note", + "note", + "ssh_key", + ], + )); + } + } + } + Self::deserialize(serde_json::Value::Object(map)) + .map_err(serde::de::Error::custom) + } +} + +/// 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, + /// The linked field ID (for linked fields) + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option", title = "Linked Field ID")] + linked_id: Option, } #[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)] -#[cfg_attr(test, derive(Eq, PartialEq))] +/// A historic password entry +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] 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 +1716,7 @@ pub fn get( field: Option<&str>, full: bool, raw: bool, + yaml: bool, clipboard: bool, ignore_case: bool, list_fields: bool, @@ -1412,6 +1738,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 +1835,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 +1882,243 @@ 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, 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)) +} + +#[allow(clippy::too_many_lines)] pub fn add( - name: &str, + name: Option<&str>, username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + raw: bool, + yaml: bool, ) -> anyhow::Result<()> { + use is_terminal::IsTerminal as _; + + 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 mut decrypted: DecryptedCipher = if yaml { + serde_yaml::from_str(&edited)? + } else { + serde_json::from_str(&edited)? + }; - let contents = rbw::edit::edit("", HELP_PW)?; + if !std::io::stdin().is_terminal() { + if let Some(name) = name { + decrypted.name = name.to_string(); + } + if let Some(folder) = folder { + decrypted.folder = Some(folder.to_string()); + } + if let DecryptedData::Login { + username: dec_username, + uris: dec_uris, + .. + } = &mut decrypted.data + { + if let Some(username) = username { + *dec_username = Some(username.to_string()); + } + if !uris.is_empty() { + *dec_uris = Some( + uris.iter() + .map(|(u, mt)| DecryptedUri { + uri: u.clone(), + match_type: *mt, + }) + .collect(), + ); + } + } + } - 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 (enc_name, enc_data, enc_fields, enc_notes, enc_history) = + encrypt_cipher( + &decrypted, + None, + decrypted.organization_id.as_deref(), + )?; - 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); + 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 + }; + + 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, None)?; + + let username = username + .map(|username| crate::actions::encrypt(username, None, 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, None)) + .transpose()?; + let notes = notes + .map(|notes| crate::actions::encrypt(¬es, None, 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, 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,73 +2140,49 @@ 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 name = crate::actions::encrypt(name, None, None)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| crate::actions::encrypt(username, None, None)) .transpose()?; - let password = crate::actions::encrypt(&password, None)?; + let enc_password = crate::actions::encrypt(&password, None, None)?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, + uri: crate::actions::encrypt(&uri.0, None, None)?, match_type: uri.1, }) }) .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)?; @@ -1750,17 +2194,20 @@ pub fn generate( Ok(()) } +#[allow(clippy::too_many_lines)] pub fn edit( name: Needle, 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 +2215,250 @@ 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 mut 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."); + } + if decrypted_new.organization_id != decrypted_original.organization_id { + log::warn!("Changing the organization_id in raw content is not supported and will be ignored."); + } + + if matches!(decrypted_original.data, DecryptedData::Identity { .. }) { + if let DecryptedData::Login { + username, + password: None, + totp: None, + uris: None, + } = &decrypted_new.data + { + decrypted_new.data = DecryptedData::Identity { + title: None, + first_name: None, + middle_name: None, + last_name: None, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, + phone: None, + email: None, + ssn: None, + license_number: None, + passport_number: None, + username: username.clone(), + }; } + } - let contents = rbw::edit::edit(&contents, HELP_PW)?; + if let ( + DecryptedData::Login { + password: Some(prev_pw), + .. + }, + DecryptedData::Login { + password: new_password, + .. + }, + ) = (&decrypted_original.data, &decrypted_new.data) + { + if new_password.as_ref() != Some(prev_pw) + && decrypted_new.history == decrypted_original.history + { + decrypted_new.history.insert( + 0, + DecryptedHistoryEntry { + last_used_date: format!( + "{}", + humantime::format_rfc3339( + std::time::SystemTime::now() + ) + ), + password: prev_pw.clone(), + }, + ); + } + } - 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 (enc_name, enc_data, enc_fields, enc_notes, enc_history) = + encrypt_cipher( + &decrypted_new, + entry.key.as_deref(), + entry.org_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; + } + } + + if let (Some(new_access_token), ()) = rbw::actions::edit( + &access_token, + &refresh_token, + &entry.id, + entry.org_id.as_deref(), + &enc_name, + &enc_data, + &enc_fields, + enc_notes.as_deref(), + folder_id.as_deref(), + &enc_history, + entry.key.as_deref(), + )? { + 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 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.key.as_deref(), + entry.org_id.as_deref(), ) - ), - password: prev_password, + }) + .transpose()?; + let notes = notes + .map(|notes| { + crate::actions::encrypt( + ¬es, + entry.key.as_deref(), + 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.key.as_deref(), + 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, + entry.key.as_deref(), + )? { + 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 +2887,174 @@ fn decrypt_search_cipher( }) } +#[allow(clippy::ref_option)] +fn encrypt_field( + field: &Option, + entry_key: Option<&str>, + org_id: Option<&str>, +) -> anyhow::Result> { + field + .as_deref() + .map(|v| crate::actions::encrypt(v, entry_key, org_id)) + .transpose() +} + +#[allow(clippy::too_many_lines)] +fn encrypt_cipher( + decrypted: &DecryptedCipher, + entry_key: Option<&str>, + 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, entry_key, org_id)?; + let notes = decrypted + .notes + .as_deref() + .map(|notes| crate::actions::encrypt(notes, entry_key, 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, + entry_key, + org_id, + )?), + value: Some(crate::actions::encrypt( + &field.value, + entry_key, + org_id, + )?), + linked_id: field.linked_id, + }) + }) + .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, + entry_key, + org_id, + )?, + }) + }) + .collect::>>()?; + + let data = match &decrypted.data { + DecryptedData::Login { + username, + password, + totp, + uris, + } => { + let username = encrypt_field(username, entry_key, org_id)?; + let password = encrypt_field(password, entry_key, org_id)?; + let totp = encrypt_field(totp, entry_key, org_id)?; + let uris = uris + .as_ref() + .map(|uris| { + uris.iter() + .map(|uri| { + Ok(rbw::db::Uri { + uri: crate::actions::encrypt( + &uri.uri, entry_key, 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, entry_key, org_id)?, + number: encrypt_field(number, entry_key, org_id)?, + brand: encrypt_field(brand, entry_key, org_id)?, + exp_month: encrypt_field(exp_month, entry_key, org_id)?, + exp_year: encrypt_field(exp_year, entry_key, org_id)?, + code: encrypt_field(code, entry_key, 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, entry_key, org_id)?, + first_name: encrypt_field(first_name, entry_key, org_id)?, + middle_name: encrypt_field(middle_name, entry_key, org_id)?, + last_name: encrypt_field(last_name, entry_key, org_id)?, + address1: encrypt_field(address1, entry_key, org_id)?, + address2: encrypt_field(address2, entry_key, org_id)?, + address3: encrypt_field(address3, entry_key, org_id)?, + city: encrypt_field(city, entry_key, org_id)?, + state: encrypt_field(state, entry_key, org_id)?, + postal_code: encrypt_field(postal_code, entry_key, org_id)?, + country: encrypt_field(country, entry_key, org_id)?, + phone: encrypt_field(phone, entry_key, org_id)?, + email: encrypt_field(email, entry_key, org_id)?, + ssn: encrypt_field(ssn, entry_key, org_id)?, + license_number: encrypt_field(license_number, entry_key, org_id)?, + passport_number: encrypt_field(passport_number, entry_key, org_id)?, + username: encrypt_field(username, entry_key, 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, entry_key, org_id)?, + fingerprint: encrypt_field(fingerprint, entry_key, org_id)?, + private_key: encrypt_field(private_key, entry_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 +3085,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 +3097,10 @@ 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), + linked_id: field.linked_id, }) }) .collect::>()?; @@ -2613,6 +3374,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 +3577,203 @@ 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, + linked_id: None, + }, + DecryptedField { + name: "field2".to_string(), + value: "value2".to_string(), + ty: rbw::api::FieldType::Hidden, + linked_id: Some(rbw::api::LinkedIdType::LoginUsername), + }, + ], + 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_decrypted_data_disambiguation() { + // 1. Empty map `data: {}` deserializes as SecureNote + let empty_map_json = r#"{ + "name": "my-note", + "data": {}, + "notes": "secret note" + }"#; + let cipher: DecryptedCipher = + serde_json::from_str(empty_map_json).unwrap(); + assert_eq!(cipher.data, DecryptedData::SecureNote); + + // 2. Explicit `"type": "identity"` with only `username` deserializes as Identity + let identity_json = r#"{ + "name": "my-identity", + "data": { + "type": "identity", + "username": "alice" + } + }"#; + let cipher: DecryptedCipher = + serde_json::from_str(identity_json).unwrap(); + assert!(matches!( + cipher.data, + DecryptedData::Identity { + username: Some(ref u), + .. + } if u == "alice" + )); + + // 3. Explicit `"type": "card"` with no other fields deserializes as Card + let card_json = r#"{ + "name": "my-card", + "data": { + "type": "card" + } + }"#; + let cipher: DecryptedCipher = serde_json::from_str(card_json).unwrap(); + assert!(matches!(cipher.data, DecryptedData::Card { .. })); + } + + #[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([ diff --git a/src/protocol.rs b/src/protocol.rs index ec0c06eb..e64cd11c 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -181,6 +181,8 @@ pub enum Action { }, Encrypt { plaintext: String, + #[serde(default)] + entry_key: Option, org_id: Option, }, ClipboardStore { From 660fa9999a6250bbbcd42f912b63c381598bb590 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Tue, 7 Jul 2026 10:20:16 -0700 Subject: [PATCH 2/4] Add native messaging host for Bitwarden extension Introduce a native messaging integration to act as a bridge between the official Bitwarden browser extension and the local rbw-agent daemon. This allows the browser extension to unlock itself using rbw-agent. - Add GetMasterKey action to rbw-agent's protocol & actions to combine the 32-byte encryption and MAC keys into the 64-byte key required by the extension. - Implement 'browser-integration host' subcommand performing RSA-OAEP key exchange, AES-256-CBC-HMAC encryption, and local agent key extraction. - Implement 'browser-integration install'/'uninstall' to set up native messaging JSON manifests and executable shell scripts for various detected browsers (Chrome, Firefox, Chromium, Brave, Vivaldi, LibreWolf, Waterfox), with safety checks to avoid overwriting official desktop app manifests without '--force'. --- README.md | 23 + src/base64.rs | 6 + src/bin/rbw-agent/actions.rs | 29 ++ src/bin/rbw-agent/agent.rs | 5 + src/bin/rbw/actions.rs | 4 +- src/bin/rbw/browser.rs | 861 +++++++++++++++++++++++++++++++++++ src/bin/rbw/main.rs | 61 +++ src/protocol.rs | 2 + 8 files changed, 989 insertions(+), 2 deletions(-) create mode 100644 src/bin/rbw/browser.rs diff --git a/README.md b/README.md index eb9074b8..57096a72 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,29 @@ export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/rbw/ssh-agent-socket" If you're using a profile, the socket will be located at `"XDG_RUNTIME_DIR/rbw-/ssh-agent-socket"`. +### Browser Extension Integration + +`rbw` can act as the native messaging host for the official Bitwarden browser +extension. This allows the browser extension to unlock itself using +`rbw-agent`. + +To configure this, run the installation command: +```sh +rbw browser-integration install +``` +This will automatically scan for supported browsers (Firefox, Chrome, +Chromium, Brave, Vivaldi, LibreWolf, Waterfox) and register the native +messaging host. + +To clean up and remove the manifests: +```sh +rbw browser-integration uninstall +``` + +Run `rbw browser-integration install --help` for details on forcing +installation (`--force`) or specifying a custom `rbw` executable path +(`--rbw-path`). + ### 2FA support `rbw` supports the following 2FA mechanisms : diff --git a/src/base64.rs b/src/base64.rs index 86971bc8..82b32e54 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -13,3 +13,9 @@ pub fn decode>( ) -> Result, base64::DecodeError> { base64::engine::general_purpose::STANDARD.decode(input) } + +pub fn decode_url_safe_no_pad>( + input: T, +) -> Result, base64::DecodeError> { + base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(input) +} diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index b7b4e41c..1537df8e 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -963,3 +963,32 @@ pub async fn find_ssh_private_key( Err(anyhow::anyhow!("No matching private key found")) } + +pub async fn get_master_key( + sock: &mut crate::sock::Sock, + state: std::sync::Arc>, + environment: &rbw::protocol::Environment, +) -> anyhow::Result<()> { + use zeroize::Zeroize as _; + + unlock_state(state.clone(), environment).await?; + + let state = state.lock().await; + let keys = state.priv_key.as_ref().context("agent is locked")?; + + let mut combined_keys = Vec::with_capacity(64); + combined_keys.extend_from_slice(keys.enc_key()); + combined_keys.extend_from_slice(keys.mac_key()); + + let key_b64 = rbw::base64::encode(&combined_keys); + combined_keys.zeroize(); + + let mut resp = rbw::protocol::Response::MasterKey { key: key_b64 }; + let res = sock.send(&resp).await; + if let rbw::protocol::Response::MasterKey { ref mut key } = resp { + key.zeroize(); + } + res?; + + Ok(()) +} diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index aac7de5f..62bf18bc 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -182,6 +182,11 @@ async fn handle_request( .await?; true } + rbw::protocol::Action::GetMasterKey => { + crate::actions::get_master_key(sock, state.clone(), &environment) + .await?; + true + } rbw::protocol::Action::Quit => std::process::exit(0), rbw::protocol::Action::Version => { crate::actions::version(sock).await?; diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index 1edd3964..0c5b51dd 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -168,7 +168,7 @@ fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { } } -fn connect() -> anyhow::Result { +pub fn connect() -> anyhow::Result { crate::sock::Sock::connect().with_context(|| { let log = rbw::dirs::agent_stderr_file(); format!( @@ -189,7 +189,7 @@ fn wait_for_exit(pid: rustix::process::Pid) { } } -fn get_environment() -> rbw::protocol::Environment { +pub fn get_environment() -> rbw::protocol::Environment { let tty = std::env::var_os("RBW_TTY").or_else(|| { rustix::termios::ttyname(std::io::stdin(), vec![]) .ok() diff --git a/src/bin/rbw/browser.rs b/src/bin/rbw/browser.rs new file mode 100644 index 00000000..1a2194c3 --- /dev/null +++ b/src/bin/rbw/browser.rs @@ -0,0 +1,861 @@ +use anyhow::{anyhow, Context as _, Result}; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::{from_slice, from_value, json, to_vec, Value}; +use std::fs; +use std::io::{self, BufReader, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use aes::Aes256; +use cbc::cipher::{ + block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit, +}; +use hmac::{Hmac, Mac}; +use rand::Rng as _; +use rand_8 as rand; +use rsa::{pkcs8::DecodePublicKey, Oaep, RsaPublicKey}; +use sha1::Sha1; +use sha2::Sha256; + +#[derive( + Copy, + Clone, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, + clap::ValueEnum, +)] +#[serde(rename_all = "lowercase")] +pub enum Browser { + Firefox, + Chrome, + Chromium, + Brave, + Vivaldi, + #[value(name = "librewolf")] + LibreWolf, + Waterfox, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncString { + #[serde(rename = "encryptionType")] + enc_type: i32, + data: String, + iv: String, + mac: String, +} + +impl EncString { + pub fn new(data: &[u8], iv: &[u8; 16], mac: &[u8; 32]) -> Self { + Self { + enc_type: 2, + data: rbw::base64::encode(data), + iv: rbw::base64::encode(iv), + mac: rbw::base64::encode(mac), + } + } + + pub fn data(&self) -> Result> { + rbw::base64::decode(&self.data) + .map_err(|e| anyhow!("failed to decode data: {e:?}")) + } + + pub fn iv(&self) -> Result> { + rbw::base64::decode(&self.iv) + .map_err(|e| anyhow!("failed to decode iv: {e:?}")) + } + + pub fn mac(&self) -> Result> { + rbw::base64::decode(&self.mac) + .map_err(|e| anyhow!("failed to decode mac: {e:?}")) + } +} + +impl std::fmt::Display for EncString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}.{}|{}|{}", + self.enc_type, self.iv, self.data, self.mac + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct EncryptedMessage { + command: String, + #[serde(rename = "messageId")] + message_id: i64, + #[serde(rename = "userId")] + user_id: Option, +} + +#[derive(Debug, Clone)] +pub enum ResponseData { + Number(i32), + Bool(bool), +} + +impl Serialize for ResponseData { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Number(n) => serializer.serialize_i32(*n), + Self::Bool(b) => serializer.serialize_bool(*b), + } + } +} + +impl From for ResponseData { + fn from(n: i32) -> Self { + Self::Number(n) + } +} + +impl From for ResponseData { + fn from(b: bool) -> Self { + Self::Bool(b) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ResponseMessage { + timestamp: u64, + command: String, + #[serde(rename = "messageId")] + message_id: i64, + response: ResponseData, + #[serde(rename = "userKeyB64")] + key: Option, +} + +impl ResponseMessage { + pub fn new>( + command: &str, + message_id: i64, + response: T, + ) -> Self { + Self::with_key(command, message_id, response, None) + } + + pub fn with_key>( + command: &str, + message_id: i64, + response: T, + key: Option, + ) -> Self { + Self { + timestamp: u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX), + command: command.to_string(), + message_id, + response: response.into(), + key, + } + } +} + +impl Drop for ResponseMessage { + fn drop(&mut self) { + use zeroize::Zeroize as _; + if let Some(key) = &mut self.key { + key.zeroize(); + } + } +} + +struct Aes256CbcHmacKey { + enc_key: [u8; 32], + mac_key: [u8; 32], +} + +impl Drop for Aes256CbcHmacKey { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.enc_key.zeroize(); + self.mac_key.zeroize(); + } +} + +impl Aes256CbcHmacKey { + fn new() -> Self { + let mut rng = rand::thread_rng(); + let enc_key = rng.gen(); + let mac_key = rng.gen(); + Self { enc_key, mac_key } + } + + fn to_vec(&self) -> Vec { + let mut key_vec = Vec::with_capacity(64); + key_vec.extend_from_slice(&self.enc_key); + key_vec.extend_from_slice(&self.mac_key); + key_vec + } + + fn decrypt(&self, iv: &[u8], mac: &[u8], data: &[u8]) -> Result> { + // Verify MAC + let mut hmac = Hmac::::new_from_slice(&self.mac_key)?; + hmac.update(iv); + hmac.update(data); + hmac.verify_slice(mac) + .map_err(|_| anyhow!("MAC verification failed"))?; + + // Decrypt + let decryptor = + cbc::Decryptor::::new_from_slices(&self.enc_key, iv) + .map_err(|e| anyhow!("invalid key or IV length: {e}"))?; + let decrypted = decryptor + .decrypt_padded_vec_mut::(data) + .map_err(|e| anyhow!("AES decrypt error: {e:?}"))?; + Ok(decrypted) + } + + fn encrypt(&self, msg: &[u8]) -> Result { + let iv = rand::thread_rng().gen::<[u8; 16]>(); + let encryptor = + cbc::Encryptor::::new(&self.enc_key.into(), &iv.into()); + let data = encryptor.encrypt_padded_vec_mut::(msg); + + // Generate MAC + let mut hmac = Hmac::::new_from_slice(&self.mac_key)?; + hmac.update(&iv); + hmac.update(&data); + let mac: [u8; 32] = hmac.finalize().into_bytes().into(); + + Ok(EncString::new(&data, &iv, &mac)) + } +} + +pub fn rsa_encrypt(public_key_b64: &str, message: &[u8]) -> Result { + let public_key = rbw::base64::decode(public_key_b64) + .map_err(|e| anyhow!("failed to decode public key: {e:?}"))?; + let public_key = RsaPublicKey::from_public_key_der(&public_key)?; + let mut rng = rand::thread_rng(); + let padding = Oaep::new::(); + let ct = public_key.encrypt(&mut rng, padding, message)?; + Ok(rbw::base64::encode(&ct)) +} + +const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024; + +pub fn host() -> Result<()> { + use zeroize::Zeroize as _; + + let mut session_key: Option = None; + let mut r = BufReader::new(io::stdin()); + + send_msg(&json!({ + "command": "connected", + "app_id": "com.8bit.bitwarden" + }))?; + + while let Some(msg_buf) = read_message(&mut r)? { + let msg = from_slice::(&msg_buf)?; + let app_id = msg + .get("appId") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("Missing 'appId' field"))?; + + let message_field = msg + .get("message") + .ok_or_else(|| anyhow!("Missing 'message' field"))?; + + if let Some(command) = + message_field.get("command").and_then(Value::as_str) + { + if command == "setupEncryption" { + let public_key_b64 = message_field + .get("publicKey") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("Missing 'publicKey' field"))?; + + let key = Aes256CbcHmacKey::new(); + let mut raw_key = key.to_vec(); + let encrypted_secret_res = + rsa_encrypt(public_key_b64, &raw_key); + raw_key.zeroize(); + let encrypted_secret = encrypted_secret_res?; + + session_key = Some(key); + + send_msg(&json!({ + "command": "setupEncryption", + "appId": app_id, + "sharedSecret": encrypted_secret + }))?; + continue; + } + } + + // Encrypted message + let key = session_key + .as_ref() + .ok_or_else(|| anyhow!("Session not initialized"))?; + let enc_str: EncString = from_value(message_field.clone())?; + if enc_str.enc_type != 2 { + anyhow::bail!("unsupported encryption type: {}", enc_str.enc_type); + } + + let mut decrypted_bytes = + key.decrypt(&enc_str.iv()?, &enc_str.mac()?, &enc_str.data()?)?; + + let decrypted_msg_res = + from_slice::(&decrypted_bytes); + decrypted_bytes.zeroize(); + let decrypted_msg = decrypted_msg_res?; + + let response_message = handle_decrypted_message(&decrypted_msg)?; + + // Encrypt the response message + let mut serialized_response = to_vec(&response_message)?; + let encrypted_response_res = key.encrypt(&serialized_response); + serialized_response.zeroize(); + let encrypted_response_str = encrypted_response_res?; + + send_msg(&json!({ + "appId": app_id, + "messageId": decrypted_msg.message_id, + "message": { + "encryptedString": encrypted_response_str.to_string() + } + }))?; + } + + Ok(()) +} + +fn send_msg(msg: &Value) -> Result<()> { + let serialized = to_vec(msg)?; + io::stdout() + .write_all(&u32::try_from(serialized.len()).unwrap().to_ne_bytes())?; + io::stdout().write_all(&serialized)?; + io::stdout().flush()?; + Ok(()) +} + +fn read_message(reader: &mut R) -> Result>> { + let mut len_buf = [0u8; 4]; + let mut read_len = 0; + while read_len < 4 { + match reader.read(&mut len_buf[read_len..]) { + Ok(0) if read_len == 0 => return Ok(None), // Clean EOF + Ok(0) => { + return Err( + io::Error::from(io::ErrorKind::UnexpectedEof).into() + ) + } + Ok(n) => read_len += n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e.into()), + } + } + let len = u32::from_ne_bytes(len_buf); + if len > MAX_MESSAGE_SIZE { + anyhow::bail!("message size {len} exceeds maximum allowed size"); + } + let mut msg_buf = vec![0u8; usize::try_from(len)?]; + reader.read_exact(&mut msg_buf)?; + Ok(Some(msg_buf)) +} + +fn extract_jwt_sub(token: &str) -> Option { + let payload_b64 = token.split('.').nth(1)?; + let payload_bytes = rbw::base64::decode_url_safe_no_pad(payload_b64).ok()?; + let payload: Value = serde_json::from_slice(&payload_bytes).ok()?; + payload.get("sub")?.as_str().map(str::to_string) +} + +fn db_matches_user(user_id: Option<&str>) -> bool { + let Ok(config) = rbw::config::Config::load() else { + return false; + }; + let Some(email) = config.email.as_ref() else { + return false; + }; + let server = config.server_name(); + if !rbw::dirs::db_file(&server, email).exists() { + return false; + } + if let Some(expected_user_id) = user_id.filter(|s| !s.is_empty()) { + if let Ok(db) = rbw::db::Db::load(&server, email) { + if let Some(sub) = + db.access_token.as_deref().and_then(extract_jwt_sub) + { + if !sub.eq_ignore_ascii_case(expected_user_id) { + return false; + } + } + } + } + true +} + +fn handle_decrypted_message( + msg: &EncryptedMessage, +) -> Result { + match msg.command.as_str() { + "getBiometricsStatus" => { + Ok(ResponseMessage::new( + "getBiometricsStatus", + msg.message_id, + true, // biometrics available + )) + } + "authenticateWithBiometrics" => Ok(ResponseMessage::new( + "authenticateWithBiometrics", + msg.message_id, + true, + )), + "getBiometricsStatusForUser" => { + let available = db_matches_user(msg.user_id.as_deref()); + + Ok(ResponseMessage::new( + "getBiometricsStatusForUser", + msg.message_id, + if available { 0 } else { 4 }, // 0 = Available, 4 = Not Configured + )) + } + "unlockWithBiometricsForUser" => { + if !db_matches_user(msg.user_id.as_deref()) { + return Ok(ResponseMessage::new( + "unlockWithBiometricsForUser", + msg.message_id, + false, + )); + } + get_master_key_from_agent().map_or_else( + |e| { + log::warn!( + "failed to unlock with master key from agent: {e:#}" + ); + Ok(ResponseMessage::new( + "unlockWithBiometricsForUser", + msg.message_id, + false, + )) + }, + |key| { + Ok(ResponseMessage::with_key( + "unlockWithBiometricsForUser", + msg.message_id, + true, + Some(key), + )) + }, + ) + } + _ => Err(anyhow!("unsupported command: {cmd}", cmd = msg.command)), + } +} + +fn get_master_key_from_agent() -> Result { + crate::commands::unlock()?; + + let mut sock = crate::actions::connect()?; + let environment = crate::actions::get_environment(); + + let request = rbw::protocol::Request::new( + environment, + rbw::protocol::Action::GetMasterKey, + ); + + sock.send(&request)?; + match sock.recv()? { + rbw::protocol::Response::MasterKey { key } => Ok(key), + rbw::protocol::Response::Error { error } => { + Err(anyhow!("agent error: {error}")) + } + r => Err(anyhow!("unexpected response from agent: {r:?}")), + } +} + +struct BrowserConfig { + browser: Browser, + name: &'static str, + manifest_dir: PathBuf, + is_firefox: bool, +} + +fn get_browsers() -> Result> { + let base_dirs = directories::BaseDirs::new() + .context("failed to find home directory")?; + let home = base_dirs.home_dir(); + let mut browsers = Vec::new(); + + #[cfg(target_os = "linux")] + { + browsers.push(BrowserConfig { + browser: Browser::Firefox, + name: "firefox", + manifest_dir: home.join(".mozilla/native-messaging-hosts"), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Chrome, + name: "chrome", + manifest_dir: home + .join(".config/google-chrome/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Chromium, + name: "chromium", + manifest_dir: home.join(".config/chromium/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Brave, + name: "brave", + manifest_dir: home.join( + ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts", + ), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Vivaldi, + name: "vivaldi", + manifest_dir: home.join(".config/vivaldi/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::LibreWolf, + name: "librewolf", + manifest_dir: home.join(".librewolf/native-messaging-hosts"), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Waterfox, + name: "waterfox", + manifest_dir: home.join(".waterfox/native-messaging-hosts"), + is_firefox: true, + }); + } + + #[cfg(target_os = "macos")] + { + browsers.push(BrowserConfig { + browser: Browser::Firefox, + name: "firefox", + manifest_dir: home.join( + "Library/Application Support/Mozilla/NativeMessagingHosts", + ), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Chrome, + name: "chrome", + manifest_dir: home.join("Library/Application Support/Google/Chrome/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Chromium, + name: "chromium", + manifest_dir: home.join( + "Library/Application Support/Chromium/NativeMessagingHosts", + ), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Brave, + name: "brave", + manifest_dir: home.join("Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Vivaldi, + name: "vivaldi", + manifest_dir: home.join( + "Library/Application Support/Vivaldi/NativeMessagingHosts", + ), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::LibreWolf, + name: "librewolf", + manifest_dir: home.join( + "Library/Application Support/LibreWolf/NativeMessagingHosts", + ), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Waterfox, + name: "waterfox", + manifest_dir: home.join( + "Library/Application Support/Waterfox/NativeMessagingHosts", + ), + is_firefox: true, + }); + } + + Ok(browsers) +} + +fn get_target_browsers( + browser: Option, +) -> Result> { + let all_browsers = get_browsers()?; + if let Some(b) = browser { + Ok(all_browsers + .into_iter() + .filter(|bc| bc.browser == b) + .collect()) + } else { + Ok(all_browsers + .into_iter() + .filter(|bc| { + let config_root = bc.manifest_dir.parent().unwrap(); + config_root.exists() || bc.manifest_dir.exists() + }) + .collect()) + } +} + +fn normalize_lexical(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +fn resolve_without_symlinks(path: &Path) -> Result { + let abs = std::path::absolute(path)?; + Ok(normalize_lexical(&abs).to_string_lossy().into_owned()) +} + +fn resolve_in_path(cmd: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(cmd); + if candidate.is_file() { + if let Ok(resolved) = resolve_without_symlinks(&candidate) { + return Some(resolved); + } + } + } + None +} + +fn resolve_executable_path(rbw_path: Option<&str>) -> Result { + if let Some(path) = rbw_path { + let path_obj = Path::new(path); + if path_obj.components().count() > 1 { + resolve_without_symlinks(path_obj) + .context("failed to resolve explicit rbw path") + } else { + Ok(path.to_string()) + } + } else { + let argv0 = + std::env::args().next().context("failed to get argv[0]")?; + let argv0_path = Path::new(&argv0); + if argv0_path.components().count() > 1 { + resolve_without_symlinks(argv0_path) + .context("failed to resolve current executable path") + } else { + Ok(resolve_in_path(&argv0).unwrap_or(argv0)) + } + } +} + +pub fn install( + browser: Option, + force: bool, + rbw_path: Option<&str>, +) -> Result<()> { + let resolved_path = resolve_executable_path(rbw_path)?; + + println!("Using rbw executable path: {resolved_path}"); + + let target_browsers = get_target_browsers(browser)?; + + if target_browsers.is_empty() { + println!("No installed browsers detected."); + return Ok(()); + } + + for bc in target_browsers { + println!("Installing integration for {}...", bc.name); + + fs::create_dir_all(&bc.manifest_dir).context(format!( + "failed to create manifest directory for {}", + bc.name + ))?; + + let manifest_file = bc.manifest_dir.join("com.8bit.bitwarden.json"); + let wrapper_file = bc.manifest_dir.join("rbw-browser-bridge"); + + if manifest_file.exists() && !force { + let existing_manifest = fs::read_to_string(&manifest_file) + .context("failed to read existing manifest")?; + if !existing_manifest.contains("rbw-browser-bridge") { + anyhow::bail!( + "A native messaging manifest already exists for {name} and does not seem to belong to rbw.\n\ + Use --force to overwrite it.", + name = bc.name + ); + } + } + + let escaped_path = resolved_path.replace('\'', "'\\''"); + let wrapper_content = format!( + "#!/bin/sh\nexec '{escaped_path}' browser-integration host \"$@\"\n" + ); + fs::write(&wrapper_file, wrapper_content) + .context("failed to write wrapper script")?; + + let mut perms = fs::metadata(&wrapper_file)?.permissions(); + perms.set_mode(perms.mode() | 0o111); + fs::set_permissions(&wrapper_file, perms)?; + + let manifest_json = if bc.is_firefox { + json!({ + "name": "com.8bit.bitwarden", + "description": "Bitwarden desktop <-> browser bridge (rbw)", + "path": wrapper_file.to_string_lossy(), + "type": "stdio", + "allowed_extensions": [ + "{446900e4-71c2-419f-a6a7-df9c091e268b}" + ] + }) + } else { + json!({ + "name": "com.8bit.bitwarden", + "description": "Bitwarden desktop <-> browser bridge (rbw)", + "path": wrapper_file.to_string_lossy(), + "type": "stdio", + "allowed_origins": [ + "chrome-extension://nngcebflmdojfhhmbhghlhiedafojfal/", + "chrome-extension://jbkfoedcorcdjbeocggbefkkoptjpcif/" + ] + }) + }; + + let manifest_content = serde_json::to_string_pretty(&manifest_json)?; + fs::write(&manifest_file, manifest_content) + .context("failed to write manifest file")?; + + println!( + "Successfully installed integration for {name}.", + name = bc.name + ); + } + + Ok(()) +} + +pub fn uninstall(browser: Option) -> Result<()> { + let target_browsers = get_target_browsers(browser)?; + + for bc in target_browsers { + let manifest_file = bc.manifest_dir.join("com.8bit.bitwarden.json"); + let wrapper_file = bc.manifest_dir.join("rbw-browser-bridge"); + + if manifest_file.exists() { + let existing_manifest = fs::read_to_string(&manifest_file) + .context("failed to read existing manifest")?; + if existing_manifest.contains("rbw-browser-bridge") { + println!("Uninstalling integration for {}...", bc.name); + fs::remove_file(&manifest_file) + .context("failed to remove manifest file")?; + if wrapper_file.exists() { + fs::remove_file(&wrapper_file) + .context("failed to remove wrapper script")?; + } + println!( + "Successfully uninstalled integration for {}.", + bc.name + ); + } else { + println!( + "Manifest for {} does not belong to rbw, skipping.", + bc.name + ); + } + } else { + println!("Integration for {} not installed, skipping.", bc.name); + } + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_decrypt_validates_iv_length() { + let key = Aes256CbcHmacKey::new(); + + // Invalid IV length (8 bytes instead of 16) — previously panicked + let mut mac = + Hmac::::new_from_slice(&key.mac_key).expect("valid key"); + mac.update(&[0u8; 8]); + mac.update(&[0u8; 16]); + let tag = mac.finalize().into_bytes(); + + let err = key.decrypt(&[0u8; 8], &tag, &[0u8; 16]).unwrap_err(); + assert!(err.to_string().contains("invalid key or IV length")); + } + + #[test] + fn test_read_message_limits_and_eof() { + // Clean EOF returns Ok(None) + let mut empty = std::io::Cursor::new(Vec::::new()); + assert!(read_message(&mut empty).unwrap().is_none()); + + // Truncated header returns Err + let mut short_hdr = std::io::Cursor::new(vec![1u8, 2]); + assert!(read_message(&mut short_hdr).is_err()); + + // Oversized frame (> MAX_MESSAGE_SIZE) returns Err without allocating + let oversized_len = (MAX_MESSAGE_SIZE + 1).to_ne_bytes(); + let mut oversized = std::io::Cursor::new(oversized_len.to_vec()); + let err = read_message(&mut oversized).unwrap_err(); + assert!(err.to_string().contains("exceeds maximum allowed size")); + + // Valid frame round-trips + let payload = br#"{"command":"ping"}"#; + let mut valid = Vec::new(); + valid.extend_from_slice( + &u32::try_from(payload.len()).unwrap().to_ne_bytes(), + ); + valid.extend_from_slice(payload); + let mut cursor = std::io::Cursor::new(valid); + assert_eq!(read_message(&mut cursor).unwrap().unwrap(), payload); + } + + #[test] + fn test_extract_jwt_sub() { + // Header: {"alg":"none"}, Payload: {"sub":"user-1234"} + let token = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTEyMzQifQ.sig"; + assert_eq!(extract_jwt_sub(token).as_deref(), Some("user-1234")); + } + + #[test] + fn test_normalize_lexical() { + let p = Path::new("/home/user/./bin/../.nix-profile/bin/rbw"); + assert_eq!( + normalize_lexical(p), + PathBuf::from("/home/user/.nix-profile/bin/rbw") + ); + } +} diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 9f3f5877..6f9cc899 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -4,6 +4,7 @@ use anyhow::Context as _; use clap::{CommandFactory as _, Parser as _}; mod actions; +mod browser; mod commands; mod sock; @@ -261,6 +262,15 @@ enum Opt { about = "Generate completion script for the given shell" )] GenCompletions { shell: CompletionShell }, + + #[command( + name = "browser-integration", + about = "Manage browser extension integration" + )] + BrowserIntegration { + #[command(subcommand)] + action: BrowserIntegrationAction, + }, } impl Opt { @@ -287,6 +297,9 @@ impl Opt { Self::Purge => "purge".to_string(), Self::StopAgent => "stop-agent".to_string(), Self::GenCompletions { .. } => "gen-completions".to_string(), + Self::BrowserIntegration { action } => { + format!("browser-integration {}", action.subcommand_name()) + } } } } @@ -302,6 +315,43 @@ enum CompletionShell { Fig, } +#[derive(Debug, clap::Subcommand)] +pub enum BrowserIntegrationAction { + #[command(hide = true, about = "Run the native messaging host loop")] + Host, + #[command(about = "Install the native messaging manifests")] + Install { + #[arg(value_enum, help = "Specific browser to install for")] + browser: Option, + #[arg( + long, + help = "Force installation even if a manifest already exists" + )] + force: bool, + #[arg( + long, + help = "Explicit path to the rbw binary to use in the wrapper script" + )] + rbw_path: Option, + }, + #[command(about = "Uninstall the native messaging manifests")] + Uninstall { + #[arg(value_enum, help = "Specific browser to uninstall from")] + browser: Option, + }, +} + +impl BrowserIntegrationAction { + fn subcommand_name(&self) -> String { + match self { + Self::Host => "host", + Self::Install { .. } => "install", + Self::Uninstall { .. } => "uninstall", + } + .to_string() + } +} + #[derive(Debug, clap::Parser)] enum Config { #[command(about = "Show the values of all configuration settings")] @@ -487,6 +537,17 @@ fn main() { Opt::Lock => commands::lock(), Opt::Purge => commands::purge(), Opt::StopAgent => commands::stop_agent(), + Opt::BrowserIntegration { action } => match action { + BrowserIntegrationAction::Host => browser::host(), + BrowserIntegrationAction::Install { + browser, + force, + rbw_path, + } => browser::install(browser, force, rbw_path.as_deref()), + BrowserIntegrationAction::Uninstall { browser } => { + browser::uninstall(browser) + } + }, Opt::GenCompletions { shell } => { match shell { CompletionShell::Bash => { diff --git a/src/protocol.rs b/src/protocol.rs index e64cd11c..c9c48c92 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -188,6 +188,7 @@ pub enum Action { ClipboardStore { text: String, }, + GetMasterKey, Quit, Version, } @@ -200,4 +201,5 @@ pub enum Response { Decrypt { plaintext: String }, Encrypt { cipherstring: String }, Version { version: u32 }, + MasterKey { key: String }, } From 05395cea4fd9446b373d88eaf86e177273a42f66 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Thu, 24 Sep 2026 23:03:41 -0700 Subject: [PATCH 3/4] Add Nix flake with shell completions support --- .gitignore | 1 + flake.lock | 27 ++++++++++ flake.nix | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.gitignore b/.gitignore index 1dccbebf..e7a0c60b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /pkg +/result /target /target-ra diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..5f236ab8 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1790185690, + "narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4975466d324710c576dc11ad614684e6bd8cad8e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..c4506aea --- /dev/null +++ b/flake.nix @@ -0,0 +1,145 @@ +{ + description = "Unofficial Bitwarden CLI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); + + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + + mkRbw = + { + lib, + stdenv, + rustPlatform, + openssl, + pkg-config, + installShellFiles, + bash, + # rbw-fzf + withFzf ? false, + fzf, + perl, + # rbw-rofi + withRofi ? false, + rofi, + xclip, + # pass-import + withPass ? false, + pass, + }: + rustPlatform.buildRustPackage { + pname = cargoToml.package.name; + version = cargoToml.package.version; + + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./Cargo.lock + ./src + ./bin + ]; + }; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + nativeBuildInputs = [ + installShellFiles + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ pkg-config ]; + + buildInputs = [ bash ]; # for git-credential-rbw + + preConfigure = lib.optionalString stdenv.hostPlatform.isLinux '' + export OPENSSL_INCLUDE_DIR="${openssl.dev}/include" + export OPENSSL_LIB_DIR="${lib.getLib openssl}/lib" + ''; + + postInstall = '' + install -Dm755 -t $out/bin bin/git-credential-rbw + '' + + lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) '' + installShellCompletion --cmd rbw \ + --bash <($out/bin/rbw gen-completions bash) \ + --fish <($out/bin/rbw gen-completions fish) \ + --nushell <($out/bin/rbw gen-completions nushell) \ + --zsh <($out/bin/rbw gen-completions zsh) + '' + + lib.optionalString withFzf '' + install -Dm755 -t $out/bin bin/rbw-fzf + substituteInPlace $out/bin/rbw-fzf \ + --replace-fail fzf ${fzf}/bin/fzf \ + --replace-fail perl ${perl}/bin/perl + '' + + lib.optionalString withRofi '' + install -Dm755 -t $out/bin bin/rbw-rofi + substituteInPlace $out/bin/rbw-rofi \ + --replace-fail rofi ${rofi}/bin/rofi \ + --replace-fail xclip ${xclip}/bin/xclip + '' + + lib.optionalString withPass '' + install -Dm755 -t $out/bin bin/pass-import + substituteInPlace $out/bin/pass-import \ + --replace-fail pass ${pass}/bin/pass + ''; + + meta = { + description = "Unofficial command line client for Bitwarden"; + homepage = "https://crates.io/crates/rbw"; + license = lib.licenses.mit; + mainProgram = "rbw"; + }; + }; + in + { + packages = forAllSystems ( + system: + let + pkgs = nixpkgsFor.${system}; + rbw = pkgs.callPackage mkRbw { }; + in + { + inherit rbw; + default = rbw; + } + ); + + overlays.default = final: _prev: { + rbw = final.callPackage mkRbw { }; + }; + + devShells = forAllSystems ( + system: + let + pkgs = nixpkgsFor.${system}; + in + { + default = pkgs.mkShell { + inputsFrom = [ self.packages.${system}.default ]; + packages = with pkgs; [ + cargo + rustc + rustfmt + clippy + rust-analyzer + ]; + }; + } + ); + }; +} From d379d43d07c44476325da4bd49c84b4950cd04c4 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Fri, 25 Sep 2026 14:24:06 -0700 Subject: [PATCH 4/4] Add --ssh-key option to rbw add --- Cargo.lock | 123 ++++++++++++++++ Cargo.toml | 1 + src/bin/rbw/commands.rs | 313 ++++++++++++++++++++++++++++++++++++++-- src/bin/rbw/main.rs | 12 +- 4 files changed, 438 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be8fa2aa..1e3b8251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -13,6 +23,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -213,6 +237,17 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -252,6 +287,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -301,6 +346,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "cipher" version = "0.4.4" @@ -455,6 +511,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -852,6 +917,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "group" version = "0.13.0" @@ -1493,6 +1568,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -1678,6 +1759,29 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.12.0" @@ -1928,6 +2032,7 @@ dependencies = [ "sha2", "signature", "ssh-agent-lib", + "ssh-key", "tempfile", "terminal_size", "textwrap", @@ -2514,8 +2619,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", "cipher", + "ctr", + "poly1305", "ssh-encoding", + "subtle", ] [[package]] @@ -2535,6 +2647,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ + "bcrypt-pbkdf", "ed25519-dalek", "num-bigint-dig", "p256", @@ -2918,6 +3031,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index b4f6829d..c24da2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ sha1 = "0.10.6" sha2 = "0.10.9" signature = "2.2.0" ssh-agent-lib = "0.5.1" +ssh-key = { version = "0.6.7", features = ["encryption"] } tempfile = "3.24.0" terminal_size = "0.4.3" textwrap = "0.16.2" diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d271fa3d..55f347db 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1923,19 +1923,179 @@ fn get_or_create_folder( Ok(Some(id)) } +#[derive(Debug)] +struct ParsedSshKey { + default_name: Option, + private_key: zeroize::Zeroizing, + public_key: String, + fingerprint: String, +} + +fn parse_ssh_private_key_str( + raw: &str, + path_hint: Option<&std::path::Path>, + mut get_passphrase: F, +) -> anyhow::Result +where + F: FnMut(Option<&str>) -> anyhow::Result>>, +{ + let mut priv_key = match ssh_agent_lib::ssh_key::PrivateKey::from_openssh( + raw, + ) { + Ok(k) => k, + Err(e) => { + if ssh_agent_lib::ssh_key::PublicKey::from_openssh(raw).is_ok() { + anyhow::bail!( + "expected an OpenSSH private key, but found a public key (pass the private key file, not the .pub file)" + ); + } + return Err( + anyhow::Error::new(e).context("failed to parse OpenSSH private key") + ); + } + }; + + if priv_key.is_encrypted() { + for i in 1_u8..=3 { + let err = if i > 1 { + Some(format!("Incorrect passphrase (attempt {i}/3)")) + } else { + None + }; + let passphrase = get_passphrase(err.as_deref())?; + match priv_key.decrypt(passphrase.as_slice()) { + Ok(decrypted) => { + priv_key = decrypted; + break; + } + Err(e) => { + if i == 3 { + return Err(anyhow::Error::new(e) + .context("failed to decrypt SSH private key")); + } + } + } + } + } + + if !matches!( + priv_key.algorithm(), + ssh_agent_lib::ssh_key::Algorithm::Ed25519 + | ssh_agent_lib::ssh_key::Algorithm::Rsa { .. } + ) { + log::warn!( + "SSH key algorithm '{}' is not currently supported by rbw-agent's SSH agent for signing, but will still be stored in Bitwarden.", + priv_key.algorithm() + ); + } + + let comment = priv_key.comment().trim(); + let default_name = if comment.is_empty() { + path_hint + .filter(|p| *p != std::path::Path::new("-")) + .and_then(std::path::Path::file_name) + .and_then(std::ffi::OsStr::to_str) + .filter(|s| !s.is_empty()) + .map(String::from) + } else { + Some(comment.to_string()) + }; + + let public_key = priv_key + .public_key() + .to_openssh() + .context("failed to format SSH public key")?; + let fingerprint = priv_key + .fingerprint(ssh_agent_lib::ssh_key::HashAlg::Sha256) + .to_string(); + let openssh_priv = priv_key + .to_openssh(ssh_agent_lib::ssh_key::LineEnding::LF) + .context("failed to format unencrypted OpenSSH private key")?; + + Ok(ParsedSshKey { + default_name, + private_key: zeroize::Zeroizing::new((*openssh_priv).clone()), + public_key, + fingerprint, + }) +} + +fn load_ssh_key(path: &std::path::Path) -> anyhow::Result { + let raw = if path == std::path::Path::new("-") { + zeroize::Zeroizing::new( + std::io::read_to_string(std::io::stdin()) + .context("failed to read SSH key from stdin")?, + ) + } else { + zeroize::Zeroizing::new(std::fs::read_to_string(path).with_context( + || format!("failed to read SSH key file '{}'", path.display()), + )?) + }; + + parse_ssh_private_key_str(&raw, Some(path), |err| { + let pinentry = rbw::config::Config::load() + .map_or_else(|_| rbw::config::default_pinentry(), |c| c.pinentry); + let environment = crate::actions::get_environment(); + let desc = format!( + "Enter passphrase to decrypt SSH key '{}'", + path.display() + ); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to initialize async runtime for pinentry")?; + let pw = rt + .block_on(rbw::pinentry::getpin( + &pinentry, + "SSH Key Passphrase", + &desc, + err, + &environment, + true, + )) + .context("failed to read SSH key passphrase from pinentry")?; + Ok(zeroize::Zeroizing::new(pw.password().to_vec())) + }) +} + #[allow(clippy::too_many_lines)] pub fn add( name: Option<&str>, username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + ssh_key: Option<&std::path::Path>, raw: bool, yaml: bool, ) -> anyhow::Result<()> { use is_terminal::IsTerminal as _; - if !raw && !yaml && name.is_none() { - anyhow::bail!("Name is required unless using --raw or --yaml"); + if !raw && !yaml && ssh_key.is_none() && name.is_none() { + anyhow::bail!( + "Name is required unless using --raw, --yaml, or --ssh-key" + ); + } + + if (raw || yaml) + && ssh_key == Some(std::path::Path::new("-")) + && !std::io::stdin().is_terminal() + { + anyhow::bail!( + "Cannot read both --ssh-key and --raw/--yaml input from stdin" + ); + } + + let parsed_ssh = ssh_key.map(load_ssh_key).transpose()?; + let resolved_name = name.map(String::from).or_else(|| { + parsed_ssh + .as_ref() + .and_then(|s| s.default_name.clone()) + }); + + if !raw && !yaml && resolved_name.is_none() { + anyhow::bail!( + "Name is required when SSH key has no comment and is read from stdin" + ); } unlock()?; @@ -1947,12 +2107,8 @@ pub fn add( 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 { + let data = parsed_ssh.as_ref().map_or_else( + || DecryptedData::Login { username: username.map(String::from), password: None, totp: None, @@ -1969,6 +2125,19 @@ pub fn add( ) }, }, + |ssh| DecryptedData::SshKey { + public_key: Some(ssh.public_key.clone()), + fingerprint: Some(ssh.fingerprint.clone()), + private_key: Some((*ssh.private_key).clone()), + }, + ); + + let template = DecryptedCipher { + id: String::new(), + organization_id: None, + folder: folder.map(String::from), + name: resolved_name.as_deref().unwrap_or("").to_string(), + data, fields: Vec::new(), notes: None, history: Vec::new(), @@ -1999,7 +2168,7 @@ pub fn add( }; if !std::io::stdin().is_terminal() { - if let Some(name) = name { + if let Some(name) = resolved_name.as_deref() { decrypted.name = name.to_string(); } if let Some(folder) = folder { @@ -2059,6 +2228,45 @@ pub fn add( db.access_token = Some(access_token); save_db(&db)?; } + } else if let Some(ssh) = parsed_ssh { + let name = resolved_name.unwrap(); + let enc_name = crate::actions::encrypt(&name, None, None)?; + let enc_private_key = + crate::actions::encrypt(&ssh.private_key, None, None)?; + let enc_public_key = + crate::actions::encrypt(&ssh.public_key, None, None)?; + let enc_fingerprint = + crate::actions::encrypt(&ssh.fingerprint, None, None)?; + + 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, + None, + &enc_name, + &rbw::db::EntryData::SshKey { + private_key: Some(enc_private_key), + public_key: Some(enc_public_key), + fingerprint: Some(enc_fingerprint), + }, + &[], + None, + folder_id.as_deref(), + &[], + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } } else { let name = name.unwrap(); let name = crate::actions::encrypt(name, None, None)?; @@ -3693,6 +3901,93 @@ mod test { assert!(matches!(cipher.data, DecryptedData::Card { .. })); } + #[test] + fn test_parse_ssh_private_key() { + use ssh_agent_lib::ssh_key::{LineEnding, PrivateKey}; + + let mut rng = rand_8::rngs::OsRng; + let keypair = + ssh_agent_lib::ssh_key::private::Ed25519Keypair::random(&mut rng); + let priv_with_comment = PrivateKey::new( + ssh_agent_lib::ssh_key::private::KeypairData::Ed25519( + keypair.clone(), + ), + "alice@example.com", + ) + .unwrap(); + let openssh_str = + priv_with_comment.to_openssh(LineEnding::LF).unwrap(); + + // 1. Key comment takes priority over filename for default_name + let parsed = parse_ssh_private_key_str( + &openssh_str, + Some(std::path::Path::new("/home/alice/.ssh/id_ed25519")), + |_| unreachable!(), + ) + .unwrap(); + assert_eq!(parsed.default_name.as_deref(), Some("alice@example.com")); + assert!(parsed.public_key.starts_with("ssh-ed25519 ")); + assert!(parsed.fingerprint.starts_with("SHA256:")); + + // 2. Empty comment falls back to filename + let priv_no_comment = PrivateKey::new( + ssh_agent_lib::ssh_key::private::KeypairData::Ed25519(keypair), + "", + ) + .unwrap(); + let openssh_no_comment = + priv_no_comment.to_openssh(LineEnding::LF).unwrap(); + let parsed_no_comment = parse_ssh_private_key_str( + &openssh_no_comment, + Some(std::path::Path::new("/home/alice/.ssh/id_ed25519")), + |_| unreachable!(), + ) + .unwrap(); + assert_eq!( + parsed_no_comment.default_name.as_deref(), + Some("id_ed25519") + ); + + // 3. Passphrase-encrypted key decrypts (with retry on wrong passphrase) and outputs unencrypted OpenSSH key + let encrypted_priv = + priv_with_comment.encrypt(&mut rng, b"correct-horse").unwrap(); + let encrypted_openssh = + encrypted_priv.to_openssh(LineEnding::LF).unwrap(); + let mut attempts = 0; + let parsed_enc = parse_ssh_private_key_str( + &encrypted_openssh, + None, + |err| { + attempts += 1; + if attempts == 1 { + assert!(err.is_none()); + Ok(zeroize::Zeroizing::new(b"wrong-pass".to_vec())) + } else { + assert!(err.unwrap().contains("attempt 2/3")); + Ok(zeroize::Zeroizing::new(b"correct-horse".to_vec())) + } + }, + ) + .unwrap(); + assert_eq!(attempts, 2); + let reparsed = + PrivateKey::from_openssh(&*parsed_enc.private_key).unwrap(); + assert!(!reparsed.is_encrypted()); + assert_eq!(parsed_enc.public_key, parsed.public_key); + assert_eq!(parsed_enc.fingerprint, parsed.fingerprint); + + // 4. Passing a public key file produces a clear error + let pub_err = parse_ssh_private_key_str( + &parsed.public_key, + Some(std::path::Path::new("id_ed25519.pub")), + |_| unreachable!(), + ) + .unwrap_err(); + assert!(pub_err + .to_string() + .contains("expected an OpenSSH private key, but found a public key")); + } + #[test] fn test_strict_validation() { // 1. Test unknown field in DecryptedCipher fails diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 6f9cc899..a4317bbc 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -135,7 +135,7 @@ enum Opt { )] Add { #[arg( - help = "Name of the password entry (optional if using --raw or --yaml)" + help = "Name of the entry (optional if using --raw, --yaml, or --ssh-key)" )] name: Option, #[arg(help = "Username for the password entry")] @@ -146,8 +146,14 @@ enum Opt { number_of_values = 1 )] uri: Vec, - #[arg(long, help = "Folder for the password entry")] + #[arg(long, help = "Folder for the entry")] folder: Option, + #[arg( + long = "ssh-key", + conflicts_with_all = ["user", "uri"], + help = "Path to an OpenSSH private key file to add as an SSH key entry (or '-' for stdin)" + )] + ssh_key: Option, #[arg( long, conflicts_with = "yaml", @@ -461,6 +467,7 @@ fn main() { user, uri, folder, + ssh_key, raw, yaml, } => commands::add( @@ -472,6 +479,7 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + ssh_key.as_deref(), raw, yaml, ),