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..fefdd5a5 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); @@ -1754,13 +1754,14 @@ pub fn edit( name: Needle, username: Option<&str>, folder: Option<&str>, + field: Option<&str>, ignore_case: 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 access_token = db.access_token.clone().unwrap(); + let refresh_token = db.refresh_token.clone().unwrap(); let desc = format!( "{}{}", @@ -1772,11 +1773,48 @@ pub fn edit( find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (name, data, fields, notes, history) = if let Some(field) = field { + edit_field(&entry, &decrypted, field)? + } else { + edit_default(&entry, &decrypted)? + }; + + if let (Some(access_token), ()) = rbw::actions::edit( + &access_token, + &refresh_token, + &entry.id, + entry.org_id.as_deref(), + entry.key.as_deref(), + &name, + &data, + &fields, + notes.as_deref(), + entry.folder_id.as_deref(), + &history, + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } + + crate::actions::sync()?; + Ok(()) +} + +fn edit_default( + entry: &rbw::db::Entry, + decrypted: &DecryptedCipher, +) -> anyhow::Result<( + String, + rbw::db::EntryData, + Vec, + Option, + Vec, +)> { 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 { + if let Some(notes) = decrypted.notes.clone() { write!(contents, "\n{notes}\n").unwrap(); } @@ -1788,12 +1826,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(); @@ -1826,12 +1869,12 @@ pub fn edit( uris: entry_uris.clone(), totp: entry_totp.clone(), }; - (data, entry.fields, notes, history) + (data, entry.fields.clone(), notes, history) } DecryptedData::SecureNote => { let data = rbw::db::EntryData::SecureNote {}; - let editor_content = decrypted.notes.map_or_else( + let editor_content = decrypted.notes.clone().map_or_else( || "\n".to_string(), |notes| format!("{notes}\n"), ); @@ -1842,11 +1885,15 @@ 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()?; - (data, entry.fields, notes, entry.history) + (data, entry.fields.clone(), notes, entry.history.clone()) } _ => { return Err(anyhow::anyhow!( @@ -1854,27 +1901,227 @@ pub fn edit( )); } }; + Ok((entry.name.clone(), data, fields, notes, history)) +} - 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)?; +fn edit_field( + entry: &rbw::db::Entry, + decrypted: &DecryptedCipher, + field: &str, +) -> anyhow::Result<( + String, + rbw::db::EntryData, + Vec, + Option, + Vec, +)> { + let encrypt = |plain: &str| { + crate::actions::encrypt( + plain, + entry.org_id.as_deref(), + entry.key.as_deref(), + ) + }; + + let field_name = field.to_lowercase(); + let parsed = field_name.parse::().ok(); + + let current = match (&parsed, &decrypted.data) { + (Some(Field::Name), _) => Some(decrypted.name.clone()), + (Some(Field::Notes), _) => decrypted.notes.clone(), + (Some(Field::Username), DecryptedData::Login { username, .. }) => { + username.clone() + } + (Some(Field::Password), DecryptedData::Login { password, .. }) => { + password.clone() + } + (Some(Field::Totp), DecryptedData::Login { totp, .. }) => { + totp.clone() + } + (Some(Field::Uris), DecryptedData::Login { uris, .. }) => { + uris.as_ref().map(|uris| { + uris.iter() + .map(|u| u.uri.clone()) + .collect::>() + .join("\n") + }) + } + (_, _) => decrypted + .fields + .iter() + .find(|f| { + f.name + .as_deref() + .is_some_and(|n| n.to_lowercase().contains(&field_name)) + }) + .and_then(|f| f.value.clone()), + }; + + let contents = rbw::edit::edit( + &format!("{}\n", current.as_deref().unwrap_or("")), + HELP_FIELD, + )?; + let new_value = parse_field_value(&contents); + + let mut history = entry.history.clone(); + let mut data = entry.data.clone(); + + match (&parsed, &mut data) { + (Some(Field::Name), _) => { + let name = new_value + .as_deref() + .map(encrypt) + .transpose()? + .unwrap_or_default(); + return Ok(( + name, + data, + entry.fields.clone(), + entry.notes.clone(), + history, + )); + } + (Some(Field::Notes), _) => { + let notes = new_value.as_deref().map(encrypt).transpose()?; + return Ok(( + entry.name.clone(), + data, + entry.fields.clone(), + notes, + history, + )); + } + ( + Some(Field::Password), + rbw::db::EntryData::Login { + password: entry_password, + .. + }, + ) => { + if let Some(prev_password) = entry_password.clone() { + history.insert( + 0, + rbw::db::HistoryEntry { + last_used_date: format!( + "{}", + humantime::format_rfc3339( + std::time::SystemTime::now() + ) + ), + password: prev_password, + }, + ); + } + *entry_password = + new_value.as_deref().map(encrypt).transpose()?; + } + ( + Some(Field::Username), + rbw::db::EntryData::Login { username, .. }, + ) => { + *username = new_value.as_deref().map(encrypt).transpose()?; + } + (Some(Field::Totp), rbw::db::EntryData::Login { totp, .. }) => { + *totp = new_value.as_deref().map(encrypt).transpose()?; + } + (Some(Field::Uris), rbw::db::EntryData::Login { uris, .. }) => { + let old_match = + if let DecryptedData::Login { uris: old, .. } = + &decrypted.data + { + old.as_ref() + .map(|uris| { + uris.iter() + .map(|u| (u.uri.clone(), u.match_type)) + .collect::>() + }) + .unwrap_or_default() + } else { + std::collections::HashMap::new() + }; + *uris = new_value + .map(|value| { + value + .lines() + .map(|uri| { + Ok(rbw::db::Uri { + uri: encrypt(uri)?, + match_type: old_match + .get(uri) + .copied() + .flatten(), + }) + }) + .collect::>>() + }) + .transpose()? + .unwrap_or_default(); + } + (Some(_), _) => { + anyhow::bail!( + "field '{field}' is not available for this entry type" + ); + } + (None, _) => { + let mut fields = entry.fields.clone(); + let mut matched = false; + for f in &mut fields { + if f.name + .as_deref() + .is_some_and(|n| n.to_lowercase().contains(&field_name)) + { + f.value = + new_value.as_deref().map(encrypt).transpose()?; + matched = true; + break; + } + } + if !matched { + anyhow::bail!("no field named '{field}' found on this entry"); + } + return Ok(( + entry.name.clone(), + data, + fields, + entry.notes.clone(), + history, + )); + } } - crate::actions::sync()?; - Ok(()) + Ok(( + entry.name.clone(), + data, + entry.fields.clone(), + entry.notes.clone(), + history, + )) } +fn parse_field_value(contents: &str) -> Option { + let mut value = contents + .lines() + .filter(|line| !line.starts_with('#')) + .fold(String::new(), |mut acc, line| { + acc.push_str(line); + acc.push('\n'); + acc + }); + while value.ends_with('\n') { + value.pop(); + } + if value.is_empty() { + None + } else { + Some(value) + } +} + +const HELP_FIELD: &str = r" +# The content of this file will be saved as the new field value. +# Lines with leading # will be ignored. +"; + pub fn remove( name: Needle, username: Option<&str>, @@ -2815,6 +3062,21 @@ fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { mod test { use super::*; + #[test] + fn test_parse_field_value() { + assert_eq!(parse_field_value("hello\n"), Some("hello".into())); + assert_eq!( + parse_field_value("line1\nline2\n"), + Some("line1\nline2".into()) + ); + assert_eq!( + parse_field_value("# comment\nvalue\n"), + Some("value".into()) + ); + assert_eq!(parse_field_value("\n# nothing\n"), None); + assert_eq!(parse_field_value(""), None); + } + #[test] fn test_find_entry() { let entries = &[ diff --git a/src/bin/rbw/completion/rbw.fish b/src/bin/rbw/completion/rbw.fish index 3cbb1ec9..39c62e56 100644 --- a/src/bin/rbw/completion/rbw.fish +++ b/src/bin/rbw/completion/rbw.fish @@ -52,9 +52,14 @@ end function __fish_rbw_get_completion_fields set -l cmd (commandline -xpc) + set -l token (commandline -xpt) set -e cmd[1] # rbw - if test -z "$(commandline -xpt)" + if test -z "$token" set -e cmd[-1] # -f/--field + else if not string match -q -- '-*' "$token" + # commandline -xpc excludes the token currently being completed, + # so feed it back to argparse as the --field value + set -a cmd $token end argparse -i folder= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd @@ -76,3 +81,9 @@ complete -f -c rbw -n '__fish_seen_subcommand_from get' -l full -d 'Display the complete -f -c rbw -n '__fish_seen_subcommand_from get' -l raw -d 'Display output as JSON' complete -f -c rbw -n '__fish_seen_subcommand_from get' -s c -l clipboard -d 'Copy result to clipboard' complete -f -c rbw -n '__fish_seen_subcommand_from get' -s h -l help -d 'Print help' + +# Complete options for `rbw edit` +complete -f -c rbw -n '__fish_seen_subcommand_from edit' -s f -l field -r -d 'Field to edit' -a '(__fish_rbw_get_completion_fields)' +complete -f -c rbw -n '__fish_seen_subcommand_from edit' -l folder -r -d 'Folder name to search in' -a '(command rbw list --fields folder)' +complete -f -c rbw -n '__fish_seen_subcommand_from edit' -s i -l ignorecase -d 'Ignore case' +complete -f -c rbw -n '__fish_seen_subcommand_from edit' -s h -l help -d 'Print help' diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..4dda56ca 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -209,6 +209,12 @@ enum Opt { Edit { #[command(flatten)] find_args: FindArgs, + #[arg( + short = 'f', + long, + help = "Edit a single field (e.g. username, name, notes, totp, or a custom field name)" + )] + field: Option, }, #[command(about = "Remove a given entry", visible_alias = "rm")] @@ -430,10 +436,11 @@ fn main() { ty, ) } - Opt::Edit { find_args } => commands::edit( + Opt::Edit { find_args, field } => commands::edit( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + field.as_deref(), find_args.ignorecase, ), Opt::Remove { find_args } => commands::remove( 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,