From d02e5ae6e7d6acfb6ff63fe6b721c1ddb619412e Mon Sep 17 00:00:00 2001 From: shan Date: Fri, 14 Aug 2026 11:03:28 +0800 Subject: [PATCH] encrypt with item key when editing keyed entries --- src/actions.rs | 4 ++ src/api.rs | 3 ++ src/bin/rbw-agent/actions.rs | 97 ++++++++++++++++++++++++++++++++---- src/bin/rbw-agent/agent.rs | 7 ++- src/bin/rbw/actions.rs | 2 + src/bin/rbw/commands.rs | 36 ++++++++----- src/protocol.rs | 1 + 7 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..43059c86 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -198,6 +198,7 @@ pub fn edit( refresh_token: &str, id: &str, org_id: Option<&str>, + key: Option<&str>, name: &str, data: &crate::db::EntryData, fields: &[crate::db::Field], @@ -210,6 +211,7 @@ pub fn edit( access_token, id, org_id, + key, name, data, fields, @@ -224,6 +226,7 @@ fn edit_once( access_token: &str, id: &str, org_id: Option<&str>, + key: Option<&str>, name: &str, data: &crate::db::EntryData, fields: &[crate::db::Field], @@ -236,6 +239,7 @@ fn edit_once( access_token, id, org_id, + key, name, data, fields, diff --git a/src/api.rs b/src/api.rs index a817fb26..98b6031d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -770,6 +770,7 @@ struct CiphersPutReq { folder_id: Option, #[serde(rename = "organizationId")] organization_id: Option, + key: Option, name: String, notes: Option, login: Option, @@ -1322,6 +1323,7 @@ impl Client { access_token: &str, id: &str, org_id: Option<&str>, + key: Option<&str>, name: &str, data: &crate::db::EntryData, fields: &[crate::db::Field], @@ -1339,6 +1341,7 @@ impl Client { }, folder_id: folder_uuid.map(std::string::ToString::to_string), organization_id: org_id.map(std::string::ToString::to_string), + key: key.map(std::string::ToString::to_string), name: name.to_string(), notes: notes.map(std::string::ToString::to_string), login: None, diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..f886985e 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -693,22 +693,46 @@ pub async fn encrypt( state: std::sync::Arc>, plaintext: &str, org_id: Option<&str>, + entry_key: Option<&str>, ) -> anyhow::Result<()> { let state = state.lock().await; - let Some(keys) = state.key(org_id) else { - return Err(anyhow::anyhow!( - "failed to find encryption keys in in-memory state" - )); + let keys = state.key(org_id).ok_or_else(|| { + anyhow::anyhow!("failed to find encryption keys in in-memory state") + })?; + let cipherstring = encrypt_with_key(keys, entry_key, plaintext)?; + + respond_encrypt(sock, cipherstring).await?; + + Ok(()) +} + +fn encrypt_with_key( + keys: &rbw::locked::Keys, + entry_key: Option<&str>, + plaintext: &str, +) -> anyhow::Result { + let cipher_keys; + let keys = match entry_key { + Some(entry_key) => { + let key_cipherstring = rbw::cipherstring::CipherString::new( + entry_key, + ) + .context("failed to parse individual item encryption key")?; + cipher_keys = rbw::locked::Keys::new( + key_cipherstring.decrypt_locked_symmetric(keys).context( + "failed to decrypt individual item encryption key", + )?, + ); + &cipher_keys + } + None => keys, }; - let cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric( + rbw::cipherstring::CipherString::encrypt_symmetric( keys, plaintext.as_bytes(), ) - .context("failed to encrypt plaintext secret")?; - - respond_encrypt(sock, cipherstring.to_string()).await?; - - Ok(()) + .context("failed to encrypt plaintext secret") + .map(|c| c.to_string()) } #[cfg(feature = "clipboard")] @@ -950,3 +974,56 @@ pub async fn find_ssh_private_key( Err(anyhow::anyhow!("No matching private key found")) } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_keys(seed: u8) -> rbw::locked::Keys { + let mut v = rbw::locked::Vec::new(); + v.extend((0..64).map(|i| seed.wrapping_add(i))); + rbw::locked::Keys::new(v) + } + + #[test] + fn encrypt_with_item_key_roundtrip() { + let master = test_keys(1); + let item = test_keys(100); + + // the wrapped item key as stored in the cipher's `key` field + let mut full_key = item.enc_key().to_vec(); + full_key.extend_from_slice(item.mac_key()); + let wrapped = rbw::cipherstring::CipherString::encrypt_symmetric( + &master, &full_key, + ) + .unwrap() + .to_string(); + + // encrypt with the item key (the fixed path) + let ct = encrypt_with_key(&master, Some(&wrapped), "secret").unwrap(); + + // decrypting with the item key succeeds + let plain = rbw::cipherstring::CipherString::new(&ct) + .unwrap() + .decrypt_symmetric(&master, Some(&item)) + .unwrap(); + assert_eq!(plain, b"secret"); + + // decrypting with the master key (the old broken behaviour) fails + assert!(rbw::cipherstring::CipherString::new(&ct) + .unwrap() + .decrypt_symmetric(&master, None) + .is_err()); + } + + #[test] + fn encrypt_without_item_key_uses_master_key() { + let master = test_keys(2); + let ct = encrypt_with_key(&master, None, "secret").unwrap(); + let plain = rbw::cipherstring::CipherString::new(&ct) + .unwrap() + .decrypt_symmetric(&master, None) + .unwrap(); + assert_eq!(plain, b"secret"); + } +} diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 1691ed51..f01dafac 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -162,12 +162,17 @@ async fn handle_request( .await?; true } - rbw::protocol::Action::Encrypt { plaintext, org_id } => { + rbw::protocol::Action::Encrypt { + plaintext, + org_id, + entry_key, + } => { crate::actions::encrypt( sock, state.clone(), plaintext, org_id.as_deref(), + entry_key.as_deref(), ) .await?; true diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index a0a34e8c..a8cf90cd 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -108,6 +108,7 @@ pub fn decrypt( pub fn encrypt( plaintext: &str, org_id: Option<&str>, + entry_key: Option<&str>, ) -> anyhow::Result { let mut sock = connect()?; sock.send(&rbw::protocol::Request::new( @@ -115,6 +116,7 @@ pub fn encrypt( rbw::protocol::Action::Encrypt { plaintext: plaintext.to_string(), org_id: org_id.map(std::string::ToString::to_string), + entry_key: entry_key.map(std::string::ToString::to_string), }, ))?; diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..b7eb9597 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1569,26 +1569,26 @@ pub fn add( let mut access_token = db.access_token.as_ref().unwrap().clone(); let refresh_token = db.refresh_token.as_ref().unwrap(); - 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 contents = rbw::edit::edit("", HELP_PW)?; let (password, notes) = parse_editor(&contents); let password = password - .map(|password| crate::actions::encrypt(&password, None)) + .map(|password| crate::actions::encrypt(&password, None, None)) .transpose()?; let notes = notes - .map(|notes| crate::actions::encrypt(¬es, None)) + .map(|notes| crate::actions::encrypt(¬es, None, None)) .transpose()?; 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, }) }) @@ -1621,7 +1621,7 @@ pub fn add( let (new_access_token, id) = rbw::actions::create_folder( &access_token, refresh_token, - &crate::actions::encrypt(folder_name, None)?, + &crate::actions::encrypt(folder_name, None, None)?, )?; if let Some(new_access_token) = new_access_token { access_token.clone_from(&new_access_token); @@ -1674,16 +1674,16 @@ pub fn generate( let mut access_token = db.access_token.as_ref().unwrap().clone(); let refresh_token = db.refresh_token.as_ref().unwrap(); - 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 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, }) }) @@ -1716,7 +1716,7 @@ pub fn generate( let (new_access_token, id) = rbw::actions::create_folder( &access_token, refresh_token, - &crate::actions::encrypt(folder_name, None)?, + &crate::actions::encrypt(folder_name, None, None)?, )?; if let Some(new_access_token) = new_access_token { access_token.clone_from(&new_access_token); @@ -1788,12 +1788,17 @@ pub fn edit( crate::actions::encrypt( &password, entry.org_id.as_deref(), + entry.key.as_deref(), ) }) .transpose()?; let notes = notes .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) + crate::actions::encrypt( + ¬es, + entry.org_id.as_deref(), + entry.key.as_deref(), + ) }) .transpose()?; let mut history = entry.history.clone(); @@ -1842,7 +1847,11 @@ pub fn edit( let notes = notes .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) + crate::actions::encrypt( + ¬es, + entry.org_id.as_deref(), + entry.key.as_deref(), + ) }) .transpose()?; @@ -1860,6 +1869,7 @@ pub fn edit( refresh_token, &entry.id, entry.org_id.as_deref(), + entry.key.as_deref(), &entry.name, &data, &fields, diff --git a/src/protocol.rs b/src/protocol.rs index ec0c06eb..b521a22d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -182,6 +182,7 @@ pub enum Action { Encrypt { plaintext: String, org_id: Option, + entry_key: Option, }, ClipboardStore { text: String,