diff --git a/README.md b/README.md index eb9074b8..6e42cb79 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,23 @@ flag will show the output as JSON. In addition to matching against the name, you can pass a UUID as the name to search for the entry with that id, or a URL to search for an entry with a matching website entry. +### Organizations and collections + +Entries shared with you through an organization are part of your vault, and +`rbw list --fields name,org,collection` will show which organization and +collections each entry belongs to. When multiple entries share a name, you +can disambiguate with `--org ` or `--collection ` on `rbw get` +(as well as `code`, `edit`, `remove`, `history`, and `search`). + +`rbw add` and `rbw generate` can create entries directly in an organization +with `--org --collection ` (an organization entry must be +placed in at least one collection, and `--collection` may be given multiple +times). `rbw edit --set-collection ` replaces the set of collections +an existing organization entry belongs to without editing its contents, +which allows moving entries between collections of the same organization. Moving entries into or out of +an organization is not currently supported, and neither is creating or +managing the collections themselves. + *Note to users of the official Bitwarden server (at bitwarden.com)*: The official server has a tendency to detect command line traffic as bot traffic (see [this issue](https://github.com/bitwarden/cli/issues/383) for details). In diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..2dc7e800 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); } @@ -136,15 +132,7 @@ pub fn unlock( pub async fn sync( access_token: &str, refresh_token: &str, -) -> Result<( - Option, - ( - String, - String, - std::collections::HashMap, - Vec, - ), -)> { +) -> Result<(Option, crate::api::SyncData)> { with_exchange_refresh_token_async( access_token, refresh_token, @@ -156,18 +144,12 @@ pub async fn sync( .await } -async fn sync_once( - access_token: &str, -) -> Result<( - String, - String, - std::collections::HashMap, - Vec, -)> { +async fn sync_once(access_token: &str) -> Result { let (client, _) = api_client_async().await?; client.sync(access_token).await } +#[allow(clippy::too_many_arguments)] pub fn add( access_token: &str, refresh_token: &str, @@ -175,21 +157,42 @@ pub fn add( data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, + org_id: Option<&str>, + collection_ids: &[String], ) -> 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, + name, + data, + notes, + folder_id, + org_id, + collection_ids, + ) }) } +#[allow(clippy::too_many_arguments)] fn add_once( access_token: &str, name: &str, data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, + org_id: Option<&str>, + collection_ids: &[String], ) -> Result<()> { let (client, _) = api_client()?; - client.add(access_token, name, data, notes, folder_id)?; + client.add( + access_token, + name, + data, + notes, + folder_id, + org_id, + collection_ids, + )?; Ok(()) } @@ -246,6 +249,27 @@ fn edit_once( Ok(()) } +pub fn set_collections( + access_token: &str, + refresh_token: &str, + id: &str, + collection_ids: &[String], +) -> Result<(Option, ())> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + set_collections_once(access_token, id, collection_ids) + }) +} + +fn set_collections_once( + access_token: &str, + id: &str, + collection_ids: &[String], +) -> Result<()> { + let (client, _) = api_client()?; + client.set_collections(access_token, id, collection_ids)?; + Ok(()) +} + pub fn remove( access_token: &str, refresh_token: &str, diff --git a/src/api.rs b/src/api.rs index a817fb26..22b10d80 100644 --- a/src/api.rs +++ b/src/api.rs @@ -386,6 +386,16 @@ struct SendEmailLoginReq { sso_email_2fa_session_token: String, } +#[derive(Debug)] +pub struct SyncData { + pub protected_key: String, + pub protected_private_key: String, + pub protected_org_keys: std::collections::HashMap, + pub organizations: Vec, + pub collections: Vec, + pub entries: Vec, +} + #[derive(serde::Deserialize, Debug)] struct SyncRes { #[serde(rename = "Ciphers", alias = "ciphers")] @@ -394,6 +404,8 @@ struct SyncRes { profile: SyncResProfile, #[serde(rename = "Folders", alias = "folders")] folders: Vec, + #[serde(rename = "Collections", alias = "collections", default)] + collections: Vec, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] @@ -404,6 +416,8 @@ struct SyncResCipher { folder_id: Option, #[serde(rename = "OrganizationId", alias = "organizationId")] organization_id: Option, + #[serde(rename = "CollectionIds", alias = "collectionIds", default)] + collection_ids: Option>, #[serde(rename = "Name", alias = "name")] name: String, #[serde(rename = "Login", alias = "login")] @@ -542,6 +556,7 @@ impl SyncResCipher { Some(crate::db::Entry { id: self.id.clone(), org_id: self.organization_id.clone(), + collection_ids: self.collection_ids.clone().unwrap_or_default(), folder, folder_id: folder_id.map(std::string::ToString::to_string), name: self.name.clone(), @@ -571,6 +586,19 @@ struct SyncResProfileOrganization { id: String, #[serde(rename = "Key", alias = "key")] key: String, + // defensive: don't fail the whole sync on a server that omits the name + #[serde(rename = "Name", alias = "name", default)] + name: String, +} + +#[derive(serde::Deserialize, Debug)] +struct SyncResCollection { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "OrganizationId", alias = "organizationId")] + organization_id: String, + #[serde(rename = "Name", alias = "name")] + name: String, } #[derive(serde::Deserialize, Debug, Clone)] @@ -753,6 +781,8 @@ struct CiphersPostReq { ty: u32, // XXX what are the valid types? #[serde(rename = "folderId")] folder_id: Option, + #[serde(rename = "organizationId")] + organization_id: Option, name: String, notes: Option, login: Option, @@ -762,6 +792,16 @@ struct CiphersPostReq { secure_note: Option, } +// creating a cipher in an organization requires the /ciphers/create +// endpoint, which wraps the cipher in an envelope carrying the collection +// ids +#[derive(serde::Serialize, Debug)] +struct CiphersCreateReq { + cipher: CiphersPostReq, + #[serde(rename = "collectionIds")] + collection_ids: Vec, +} + #[derive(serde::Serialize, Debug)] struct CiphersPutReq { #[serde(rename = "type")] @@ -782,6 +822,12 @@ struct CiphersPutReq { password_history: Vec, } +#[derive(serde::Serialize, Debug)] +struct CiphersIdCollectionsPutReq { + #[serde(rename = "collectionIds")] + collection_ids: Vec, +} + #[derive(serde::Serialize, Debug)] struct CiphersPutReqHistory { #[serde(rename = "LastUsedDate")] @@ -1145,15 +1191,7 @@ impl Client { Ok((sso_code, sso_code_verifier, callback_url)) } - pub async fn sync( - &self, - access_token: &str, - ) -> Result<( - String, - String, - std::collections::HashMap, - Vec, - )> { + pub async fn sync(&self, access_token: &str) -> Result { let client = self.reqwest_client().await?; let res = client .get(self.api_url("/sync")) @@ -1167,23 +1205,47 @@ impl Client { reqwest::StatusCode::OK => { let sync_res: SyncRes = res.json_with_path().await?; let folders = sync_res.folders.clone(); - let ciphers = sync_res + let entries = sync_res .ciphers .iter() .filter_map(|cipher| cipher.to_entry(&folders)) .collect(); - let org_keys = sync_res + let protected_org_keys = sync_res .profile .organizations .iter() .map(|org| (org.id.clone(), org.key.clone())) .collect(); - Ok(( - sync_res.profile.key, - sync_res.profile.private_key, - org_keys, - ciphers, - )) + let organizations = sync_res + .profile + .organizations + .iter() + .map(|org| crate::db::Org { + id: org.id.clone(), + name: if org.name.is_empty() { + org.id.clone() + } else { + org.name.clone() + }, + }) + .collect(); + let collections = sync_res + .collections + .iter() + .map(|collection| crate::db::Collection { + id: collection.id.clone(), + org_id: collection.organization_id.clone(), + name: collection.name.clone(), + }) + .collect(); + Ok(SyncData { + protected_key: sync_res.profile.key, + protected_private_key: sync_res.profile.private_key, + protected_org_keys, + organizations, + collections, + entries, + }) } reqwest::StatusCode::UNAUTHORIZED => { Err(Error::RequestUnauthorized) @@ -1194,6 +1256,7 @@ impl Client { } } + #[allow(clippy::too_many_arguments)] pub fn add( &self, access_token: &str, @@ -1201,10 +1264,13 @@ impl Client { data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, + org_id: Option<&str>, + collection_ids: &[String], ) -> Result<()> { let mut req = CiphersPostReq { ty: 1, 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, @@ -1300,10 +1366,18 @@ impl Client { crate::db::EntryData::SshKey { .. } => unreachable!(), } let client = reqwest::blocking::Client::new(); - let res = client - .post(self.api_url("/ciphers")) + let req = if org_id.is_some() { + client.post(self.api_url("/ciphers/create")).json( + &CiphersCreateReq { + cipher: req, + collection_ids: collection_ids.to_vec(), + }, + ) + } else { + client.post(self.api_url("/ciphers")).json(&req) + }; + let res = req .header("Authorization", format!("Bearer {access_token}")) - .json(&req) .send() .map_err(|source| Error::Reqwest { source })?; match res.status() { @@ -1467,6 +1541,34 @@ impl Client { } } + // replaces the set of collections an organization cipher belongs to + pub fn set_collections( + &self, + access_token: &str, + id: &str, + collection_ids: &[String], + ) -> Result<()> { + let req = CiphersIdCollectionsPutReq { + collection_ids: collection_ids.to_vec(), + }; + let client = reqwest::blocking::Client::new(); + let res = client + .put(self.api_url(&format!("/ciphers/{id}/collections"))) + .header("Authorization", format!("Bearer {access_token}")) + .json(&req) + .send() + .map_err(|source| Error::Reqwest { source })?; + match res.status() { + reqwest::StatusCode::OK => Ok(()), + reqwest::StatusCode::UNAUTHORIZED => { + Err(Error::RequestUnauthorized) + } + _ => Err(Error::RequestFailed { + status: res.status().as_u16(), + }), + } + } + pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { let client = reqwest::blocking::Client::new(); let res = client @@ -1738,24 +1840,20 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { "invalid_client" => { return Error::IncorrectApiKey; } - "" => { - // 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" => { + // 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" => { + 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 }; - } - } } } } @@ -1766,3 +1864,181 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { log::warn!("unexpected error received during login: {error_res:?}"); Error::RequestFailed { status: code } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sync_res_camel_case() -> &'static str { + r#"{ + "ciphers": [{ + "id": "cipher-id", + "folderId": null, + "organizationId": "org-id", + "collectionIds": ["collection-id-1", "collection-id-2"], + "name": "encrypted-name", + "login": { + "username": null, + "password": null, + "totp": null, + "uris": null + }, + "notes": null, + "passwordHistory": null, + "fields": null, + "deletedDate": null, + "key": null, + "reprompt": 0 + }], + "profile": { + "key": "profile-key", + "privateKey": "profile-private-key", + "organizations": [{ + "id": "org-id", + "key": "org-key", + "name": "org-name" + }] + }, + "folders": [], + "collections": [{ + "id": "collection-id-1", + "organizationId": "org-id", + "name": "encrypted-collection-name", + "readOnly": false + }] + }"# + } + + fn sync_res_pascal_case() -> &'static str { + r#"{ + "Ciphers": [{ + "Id": "cipher-id", + "FolderId": null, + "OrganizationId": "org-id", + "CollectionIds": ["collection-id-1", "collection-id-2"], + "Name": "encrypted-name", + "Login": { + "Username": null, + "Password": null, + "Totp": null, + "Uris": null + }, + "Notes": null, + "PasswordHistory": null, + "Fields": null, + "DeletedDate": null, + "Key": null, + "Reprompt": 0 + }], + "Profile": { + "Key": "profile-key", + "PrivateKey": "profile-private-key", + "Organizations": [{ + "Id": "org-id", + "Key": "org-key", + "Name": "org-name" + }] + }, + "Folders": [], + "Collections": [{ + "Id": "collection-id-1", + "OrganizationId": "org-id", + "Name": "encrypted-collection-name", + "ReadOnly": false + }] + }"# + } + + fn assert_sync_res(sync_res: &SyncRes) { + assert_eq!(sync_res.profile.organizations.len(), 1); + assert_eq!(sync_res.profile.organizations[0].id, "org-id"); + assert_eq!(sync_res.profile.organizations[0].name, "org-name"); + + assert_eq!(sync_res.collections.len(), 1); + assert_eq!(sync_res.collections[0].id, "collection-id-1"); + assert_eq!(sync_res.collections[0].organization_id, "org-id"); + assert_eq!(sync_res.collections[0].name, "encrypted-collection-name"); + + assert_eq!(sync_res.ciphers.len(), 1); + let entry = sync_res.ciphers[0].to_entry(&[]).unwrap(); + assert_eq!(entry.org_id.as_deref(), Some("org-id")); + assert_eq!( + entry.collection_ids, + vec![ + "collection-id-1".to_string(), + "collection-id-2".to_string() + ] + ); + } + + #[test] + fn test_parse_sync_res_camel_case() { + let sync_res: SyncRes = + serde_json::from_str(sync_res_camel_case()).unwrap(); + assert_sync_res(&sync_res); + } + + #[test] + fn test_parse_sync_res_pascal_case() { + let sync_res: SyncRes = + serde_json::from_str(sync_res_pascal_case()).unwrap(); + assert_sync_res(&sync_res); + } + + // servers may omit the collections array and per-cipher collectionIds + #[test] + fn test_parse_sync_res_without_collections() { + let json = r#"{ + "ciphers": [{ + "id": "cipher-id", + "folderId": null, + "organizationId": null, + "name": "encrypted-name", + "login": { + "username": null, + "password": null, + "totp": null, + "uris": null + }, + "notes": null, + "passwordHistory": null, + "fields": null, + "deletedDate": null, + "key": null, + "reprompt": 0 + }], + "profile": { + "key": "profile-key", + "privateKey": "profile-private-key", + "organizations": [] + }, + "folders": [] + }"#; + let sync_res: SyncRes = serde_json::from_str(json).unwrap(); + assert!(sync_res.collections.is_empty()); + let entry = sync_res.ciphers[0].to_entry(&[]).unwrap(); + assert_eq!(entry.org_id, None); + assert!(entry.collection_ids.is_empty()); + } + + // a server that omits organization names must not fail the whole sync + #[test] + fn test_parse_sync_res_without_org_name() { + let json = r#"{ + "ciphers": [], + "profile": { + "key": "profile-key", + "privateKey": "profile-private-key", + "organizations": [{ + "id": "org-id", + "key": "org-key" + }] + }, + "folders": [] + }"#; + let sync_res: SyncRes = serde_json::from_str(json).unwrap(); + assert_eq!(sync_res.profile.organizations.len(), 1); + assert_eq!(sync_res.profile.organizations[0].id, "org-id"); + assert_eq!(sync_res.profile.organizations[0].name, ""); + } +} diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..e7a42e3e 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -521,20 +521,23 @@ pub async fn sync( } else { return Err(anyhow::anyhow!("failed to find refresh token in db")); }; - let ( - access_token, - (protected_key, protected_private_key, protected_org_keys, entries), - ) = rbw::actions::sync(&access_token, &refresh_token) + let (access_token, sync_data) = + rbw::actions::sync(&access_token, &refresh_token) + .await + .context("failed to sync database from server")?; + state + .lock() .await - .context("failed to sync database from server")?; - state.lock().await.set_master_password_reprompt(&entries); + .set_master_password_reprompt(&sync_data.entries); if let Some(access_token) = access_token { db.access_token = Some(access_token); } - db.protected_key = Some(protected_key); - db.protected_private_key = Some(protected_private_key); - db.protected_org_keys = protected_org_keys; - db.entries = entries; + db.protected_key = Some(sync_data.protected_key); + db.protected_private_key = Some(sync_data.protected_private_key); + db.protected_org_keys = sync_data.protected_org_keys; + db.organizations = sync_data.organizations; + db.collections = sync_data.collections; + db.entries = sync_data.entries; save_db(&db).await?; if let Err(e) = subscribe_to_notifications(state.clone()).await { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..b8d325c5 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -184,6 +184,8 @@ struct DecryptedListCipher { name: Option, user: Option, folder: Option, + org: Option, + collections: Option>, uris: Option>, #[serde(rename = "type")] entry_type: Option, @@ -330,6 +332,8 @@ impl From for DecryptedListCipher { name: Some(value.name), user: value.user, folder: value.folder, + org: None, + collections: None, uris: Some(value.uris.into_iter().map(|(s, _)| s).collect()), } } @@ -340,6 +344,8 @@ impl From for DecryptedListCipher { struct DecryptedCipher { id: String, folder: Option, + org: Option, + collections: Vec, name: String, data: DecryptedData, fields: Vec, @@ -1184,6 +1190,8 @@ enum ListField { Name, User, Folder, + Org, + Collections, Uri, EntryType, } @@ -1195,6 +1203,8 @@ impl ListField { Self::Name, Self::User, Self::Folder, + Self::Org, + Self::Collections, Self::Uri, Self::EntryType, ] @@ -1210,6 +1220,8 @@ impl std::convert::TryFrom<&String> for ListField { "id" => Self::Id, "user" => Self::User, "folder" => Self::Folder, + "org" => Self::Org, + "collection" => Self::Collections, "type" => Self::EntryType, _ => return Err(anyhow::anyhow!("unknown field {s}")), }) @@ -1371,10 +1383,20 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { unlock()?; let db = load_db()?; + let orgs = if fields.contains(&ListField::Org) { + org_map(&db) + } else { + std::collections::HashMap::new() + }; + let collections = if fields.contains(&ListField::Collections) { + collection_map(&db, None) + } else { + std::collections::HashMap::new() + }; let mut entries: Vec = db .entries .iter() - .map(|entry| decrypt_list_cipher(entry, &fields)) + .map(|entry| decrypt_list_cipher(entry, &fields, &orgs, &collections)) .collect::>()?; entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); @@ -1384,10 +1406,13 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { } #[allow(clippy::fn_params_excessive_bools)] +#[allow(clippy::too_many_arguments)] pub fn get( needle: Needle, user: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, field: Option<&str>, full: bool, raw: bool, @@ -1406,7 +1431,7 @@ pub fn get( ); let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) + find_entry(&db, needle, user, folder, org, collection, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if list_fields { decrypted.display_fields_list(); @@ -1450,6 +1475,16 @@ fn print_entry_list( String::new, std::string::ToString::to_string, ), + ListField::Org => entry.org.as_ref().map_or_else( + String::new, + std::string::ToString::to_string, + ), + ListField::Collections => entry + .collections + .as_ref() + .map_or_else(String::new, |collections| { + collections.join(",") + }), ListField::Uri => { // "uri" is not listed in the TryFrom // implementation, so there's no way to try to @@ -1485,6 +1520,8 @@ pub fn search( term: &str, fields: &[String], folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, raw: bool, ) -> anyhow::Result<()> { let fields: Vec = if raw { @@ -1500,17 +1537,53 @@ pub fn search( let db = load_db()?; + // build the collection name map at most once, shared between filter + // resolution and display + let collections = + if fields.contains(&ListField::Collections) || collection.is_some() { + collection_map(&db, None) + } else { + std::collections::HashMap::new() + }; + let (org_ids, collection_ids) = + resolve_filters(&db, org, collection, Some(&collections), false)?; + let orgs = if fields.contains(&ListField::Org) { + org_map(&db) + } else { + std::collections::HashMap::new() + }; + let mut entries: Vec = db .entries .iter() - .map(decrypt_search_cipher) .filter(|entry| { - entry - .as_ref() - .map(|entry| entry.search_match(term, folder)) - .unwrap_or(true) + entry_matches_filters( + entry, + org_ids.as_ref(), + collection_ids.as_ref(), + ) + }) + .map(|entry| { + decrypt_search_cipher(entry).map(|decrypted| (entry, decrypted)) + }) + .filter(|res| { + res.as_ref().map_or(true, |(_, decrypted)| { + decrypted.search_match(term, folder) + }) + }) + .map(|res| { + res.map(|(entry, decrypted)| { + let mut list_cipher: DecryptedListCipher = decrypted.into(); + if fields.contains(&ListField::Org) { + list_cipher.org = entry_org_name(entry, &orgs); + } + if fields.contains(&ListField::Collections) { + list_cipher.collections = + Some(entry_collection_names(entry, &collections)); + } + list_cipher + }) }) - .map(|entry| entry.map(std::convert::Into::into)) .collect::>()?; entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); @@ -1523,6 +1596,8 @@ pub fn code( needle: Needle, user: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, clipboard: bool, ignore_case: bool, ) -> anyhow::Result<()> { @@ -1537,7 +1612,7 @@ pub fn code( ); let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) + find_entry(&db, needle, user, folder, org, collection, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let DecryptedData::Login { totp, .. } = decrypted.data { @@ -1560,6 +1635,8 @@ pub fn add( username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + org: Option<&str>, + collections: &[String], ) -> anyhow::Result<()> { unlock()?; @@ -1569,26 +1646,31 @@ 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)?; + // ciphers in an organization are encrypted with the organization key + let (org_id, collection_ids) = + resolve_org_and_collections(&db, org, collections)?; + let org_id = org_id.as_deref(); + + let name = crate::actions::encrypt(name, org_id)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| crate::actions::encrypt(username, org_id)) .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, org_id)) .transpose()?; let notes = notes - .map(|notes| crate::actions::encrypt(¬es, None)) + .map(|notes| crate::actions::encrypt(¬es, org_id)) .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, org_id)?, match_type: uri.1, }) }) @@ -1644,6 +1726,8 @@ pub fn add( }, notes.as_deref(), folder_id.as_deref(), + org_id, + &collection_ids, )? { db.access_token = Some(access_token); save_db(&db)?; @@ -1659,11 +1743,12 @@ pub fn generate( username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + org: Option<&str>, + collections: &[String], len: usize, ty: rbw::pwgen::Type, ) -> anyhow::Result<()> { let password = rbw::pwgen::pwgen(ty, len); - println!("{password}"); if let Some(name) = name { unlock()?; @@ -1674,16 +1759,25 @@ 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)?; + // ciphers in an organization are encrypted with the organization + // key. resolve names before printing the password, so that a + // password that can't be saved isn't printed. + let (org_id, collection_ids) = + resolve_org_and_collections(&db, org, collections)?; + let org_id = org_id.as_deref(); + + println!("{password}"); + + let name = crate::actions::encrypt(name, org_id)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| crate::actions::encrypt(username, org_id)) .transpose()?; - let password = crate::actions::encrypt(&password, None)?; + let password = crate::actions::encrypt(&password, org_id)?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, + uri: crate::actions::encrypt(&uri.0, org_id)?, match_type: uri.1, }) }) @@ -1739,12 +1833,16 @@ pub fn generate( }, None, folder_id.as_deref(), + org_id, + &collection_ids, )? { db.access_token = Some(access_token); save_db(&db)?; } crate::actions::sync()?; + } else { + println!("{password}"); } Ok(()) @@ -1754,6 +1852,9 @@ pub fn edit( name: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, + set_collections: &[String], ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -1769,9 +1870,41 @@ pub fn edit( ); let (entry, decrypted) = - find_entry(&db, name, username, folder, ignore_case) + find_entry(&db, name, username, folder, org, collection, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; + // --set-collection only changes which collections the entry belongs + // to, without editing its contents (so it works for every entry type + // and doesn't open an editor). moving an entry between collections + // doesn't require re-encryption, since all collections of an + // organization share the organization key. + if !set_collections.is_empty() { + let Some(org_id) = entry.org_id.as_deref() else { + return Err(anyhow::anyhow!( + "'{desc}' is not an organization entry, so it can't be \ + placed in a collection" + )); + }; + let collection_ids = resolve_collection_names( + &db, + org_id, + set_collections, + ignore_case, + )?; + if let (Some(access_token), ()) = rbw::actions::set_collections( + access_token, + refresh_token, + &entry.id, + &collection_ids, + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } + + crate::actions::sync()?; + return Ok(()); + } + let (data, fields, notes, history) = match &decrypted.data { DecryptedData::Login { password, .. } => { let mut contents = @@ -1879,6 +2012,8 @@ pub fn remove( name: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -1893,8 +2028,9 @@ pub fn remove( name ); - let (entry, _) = find_entry(&db, name, username, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (entry, _) = + find_entry(&db, name, username, folder, org, collection, ignore_case) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let (Some(access_token), ()) = rbw::actions::remove(access_token, refresh_token, &entry.id)? @@ -1912,6 +2048,8 @@ pub fn history( name: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -1924,8 +2062,9 @@ pub fn history( name ); - let (_, decrypted) = find_entry(&db, name, username, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (_, decrypted) = + find_entry(&db, name, username, folder, org, collection, ignore_case) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; for history in decrypted.history { println!("{}: {}", history.last_used_date, history.password); } @@ -2015,12 +2154,24 @@ fn find_entry( mut needle: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, ignore_case: bool, ) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { + let (org_ids, collection_ids) = + resolve_filters(db, org, collection, None, ignore_case)?; + if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok((cipher.clone(), decrypt_cipher(cipher)?)); + if !entry_matches_filters( + cipher, + org_ids.as_ref(), + collection_ids.as_ref(), + ) { + return Err(anyhow::anyhow!("no entry found")); + } + return Ok((cipher.clone(), decrypt_cipher(cipher, db)?)); } } needle = Needle::Name(s); @@ -2029,6 +2180,13 @@ fn find_entry( let ciphers: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = db .entries .iter() + .filter(|entry| { + entry_matches_filters( + entry, + org_ids.as_ref(), + collection_ids.as_ref(), + ) + }) .map(|entry| { decrypt_search_cipher(entry) .map(|decrypted| (entry.clone(), decrypted)) @@ -2036,7 +2194,7 @@ fn find_entry( .collect::>()?; let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - let decrypted_entry = decrypt_cipher(&entry)?; + let decrypted_entry = decrypt_cipher(&entry, db)?; Ok((entry, decrypted_entry)) } @@ -2122,9 +2280,246 @@ fn decrypt_field( } } +fn org_map(db: &rbw::db::Db) -> std::collections::HashMap { + db.organizations + .iter() + .map(|org| (org.id.clone(), org.name.clone())) + .collect() +} + +fn org_name(db: &rbw::db::Db, org_id: &str) -> String { + db.organizations + .iter() + .find(|org| org.id == org_id) + .map_or_else(|| org_id.to_string(), |org| org.name.clone()) +} + +// collection names are encrypted with the organization key, unlike folder +// names. failure to decrypt a single collection name shouldn't break +// displaying entries entirely, so fall back to displaying the collection id +// in that case. +fn decrypt_collection_name(collection: &rbw::db::Collection) -> String { + crate::actions::decrypt(&collection.name, None, Some(&collection.org_id)) + .unwrap_or_else(|e| { + log::warn!("failed to decrypt collection name: {e}"); + collection.id.clone() + }) +} + +// decrypts each collection name once (each decryption is an agent round +// trip), optionally restricted to the collections of a single organization +fn collection_map( + db: &rbw::db::Db, + org_id: Option<&str>, +) -> std::collections::HashMap { + db.collections + .iter() + .filter(|collection| { + org_id.is_none_or(|org_id| collection.org_id == org_id) + }) + .map(|collection| { + (collection.id.clone(), decrypt_collection_name(collection)) + }) + .collect() +} + +fn entry_org_name( + entry: &rbw::db::Entry, + orgs: &std::collections::HashMap, +) -> Option { + entry.org_id.as_ref().map(|org_id| { + orgs.get(org_id).cloned().unwrap_or_else(|| org_id.clone()) + }) +} + +fn entry_collection_names( + entry: &rbw::db::Entry, + collections: &std::collections::HashMap, +) -> Vec { + entry + .collection_ids + .iter() + .map(|id| collections.get(id).cloned().unwrap_or_else(|| id.clone())) + .collect() +} + +fn match_name(name: &str, given_name: &str, ignore_case: bool) -> bool { + if ignore_case { + name.to_lowercase() == given_name.to_lowercase() + } else { + name == given_name + } +} + +fn resolve_org_filter( + organizations: &[rbw::db::Org], + name: &str, + ignore_case: bool, +) -> anyhow::Result> { + let org_ids: std::collections::HashSet = organizations + .iter() + .filter(|org| match_name(&org.name, name, ignore_case)) + .map(|org| org.id.clone()) + .collect(); + if org_ids.is_empty() { + let known: Vec<&str> = + organizations.iter().map(|org| org.name.as_str()).collect(); + Err(anyhow::anyhow!( + "no organization found matching '{}' (known organizations: {})", + name, + known.join(", ") + )) + } else { + Ok(org_ids) + } +} + +fn resolve_collection_filter( + collections: &[rbw::db::Collection], + names: &std::collections::HashMap, + org_ids: Option<&std::collections::HashSet>, + name: &str, + ignore_case: bool, +) -> anyhow::Result> { + let candidates: Vec<&rbw::db::Collection> = collections + .iter() + .filter(|collection| { + org_ids.is_none_or(|ids| ids.contains(&collection.org_id)) + }) + .collect(); + let collection_ids: std::collections::HashSet = candidates + .iter() + .filter(|collection| { + names + .get(&collection.id) + .is_some_and(|n| match_name(n, name, ignore_case)) + }) + .map(|collection| collection.id.clone()) + .collect(); + if collection_ids.is_empty() { + let known: Vec<&str> = candidates + .iter() + .filter_map(|collection| { + names.get(&collection.id).map(String::as_str) + }) + .collect(); + Err(anyhow::anyhow!( + "no collection found matching '{}' (known collections: {})", + name, + known.join(", ") + )) + } else { + Ok(collection_ids) + } +} + +type EntryFilters = ( + Option>, + Option>, +); + +fn resolve_filters( + db: &rbw::db::Db, + org: Option<&str>, + collection: Option<&str>, + collection_names: Option<&std::collections::HashMap>, + ignore_case: bool, +) -> anyhow::Result { + let org_ids = org + .map(|name| resolve_org_filter(&db.organizations, name, ignore_case)) + .transpose()?; + let collection_ids = collection + .map(|name| { + let owned = + collection_names.is_none().then(|| collection_map(db, None)); + resolve_collection_filter( + &db.collections, + collection_names.or(owned.as_ref()).unwrap(), + org_ids.as_ref(), + name, + ignore_case, + ) + }) + .transpose()?; + Ok((org_ids, collection_ids)) +} + +// resolves an organization name and collection names to ids for creating a +// new cipher. unlike the search filters, this must resolve each name to +// exactly one id. +fn resolve_org_and_collections( + db: &rbw::db::Db, + org: Option<&str>, + collections: &[String], +) -> anyhow::Result<(Option, Vec)> { + let Some(org_name) = org else { + return Ok((None, vec![])); + }; + let org_ids = resolve_org_filter(&db.organizations, org_name, false)?; + if org_ids.len() > 1 { + return Err(anyhow::anyhow!( + "multiple organizations found matching '{org_name}'" + )); + } + let org_id = org_ids.into_iter().next().unwrap(); + if collections.is_empty() { + return Err(anyhow::anyhow!( + "an organization item must be placed in at least one \ + collection (use --collection)" + )); + } + let collection_ids = + resolve_collection_names(db, &org_id, collections, false)?; + Ok((Some(org_id), collection_ids)) +} + +// resolves collection names to ids within a single organization. each name +// must resolve to exactly one id. +fn resolve_collection_names( + db: &rbw::db::Db, + org_id: &str, + collections: &[String], + ignore_case: bool, +) -> anyhow::Result> { + let names = collection_map(db, Some(org_id)); + let org_id_filter: std::collections::HashSet = + std::iter::once(org_id.to_string()).collect(); + let mut collection_ids = Vec::new(); + for name in collections { + let ids = resolve_collection_filter( + &db.collections, + &names, + Some(&org_id_filter), + name, + ignore_case, + )?; + if ids.len() > 1 { + return Err(anyhow::anyhow!( + "multiple collections found matching '{name}'" + )); + } + collection_ids.push(ids.into_iter().next().unwrap()); + } + Ok(collection_ids) +} + +fn entry_matches_filters( + entry: &rbw::db::Entry, + org_ids: Option<&std::collections::HashSet>, + collection_ids: Option<&std::collections::HashSet>, +) -> bool { + org_ids.is_none_or(|ids| { + entry.org_id.as_ref().is_some_and(|id| ids.contains(id)) + }) && collection_ids.is_none_or(|ids| { + entry.collection_ids.iter().any(|id| ids.contains(id)) + }) +} + fn decrypt_list_cipher( entry: &rbw::db::Entry, fields: &[ListField], + orgs: &std::collections::HashMap, + collections: &std::collections::HashMap, ) -> anyhow::Result { let id = entry.id.clone(); let name = if fields.contains(&ListField::Name) { @@ -2160,6 +2555,16 @@ fn decrypt_list_cipher( } else { None }; + let org = if fields.contains(&ListField::Org) { + entry_org_name(entry, orgs) + } else { + None + }; + let collections = if fields.contains(&ListField::Collections) { + Some(entry_collection_names(entry, collections)) + } else { + None + }; let uris = if fields.contains(&ListField::Uri) { match &entry.data { rbw::db::EntryData::Login { uris, .. } => Some( @@ -2195,6 +2600,8 @@ fn decrypt_list_cipher( name, user, folder, + org, + collections, uris, entry_type, }) @@ -2297,7 +2704,10 @@ fn decrypt_search_cipher( }) } -fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { +fn decrypt_cipher( + entry: &rbw::db::Entry, + db: &rbw::db::Db, +) -> 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 let folder = entry @@ -2312,6 +2722,21 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { None } }; + let org = entry.org_id.as_ref().map(|org_id| org_name(db, org_id)); + // only decrypt the names of the collections this entry belongs to + let collections = entry + .collection_ids + .iter() + .map(|collection_id| { + db.collections + .iter() + .find(|collection| &collection.id == collection_id) + .map_or_else( + || collection_id.clone(), + decrypt_collection_name, + ) + }) + .collect(); let fields = entry .fields .iter() @@ -2614,6 +3039,8 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { Ok(DecryptedCipher { id: entry.id.clone(), folder, + org, + collections, name: crate::actions::decrypt( &entry.name, entry.key.as_deref(), @@ -4135,6 +4562,7 @@ mod test { rbw::db::Entry { id: id.to_string(), org_id: None, + collection_ids: vec![], folder: folder.map(|_| "encrypted folder name".to_string()), folder_id: None, name: "this is the encrypted name".to_string(), @@ -4175,4 +4603,203 @@ mod test { }, ) } + + fn make_orgs_and_collections( + ) -> (Vec, Vec) { + let organizations = vec![ + rbw::db::Org { + id: "org-id-1".to_string(), + name: "org one".to_string(), + }, + rbw::db::Org { + id: "org-id-2".to_string(), + name: "org two".to_string(), + }, + ]; + let collections = vec![ + rbw::db::Collection { + id: "collection-id-1".to_string(), + org_id: "org-id-1".to_string(), + name: "encrypted-name-1".to_string(), + }, + rbw::db::Collection { + id: "collection-id-2".to_string(), + org_id: "org-id-2".to_string(), + name: "encrypted-name-2".to_string(), + }, + rbw::db::Collection { + id: "collection-id-3".to_string(), + org_id: "org-id-2".to_string(), + name: "encrypted-name-3".to_string(), + }, + ]; + (organizations, collections) + } + + fn collection_names() -> std::collections::HashMap { + [ + ("collection-id-1", "shared"), + ("collection-id-2", "shared"), + ("collection-id-3", "admin passwords"), + ] + .iter() + .map(|(id, name)| ((*id).to_string(), (*name).to_string())) + .collect() + } + + #[test] + fn test_resolve_org_filter() { + let (organizations, _) = make_orgs_and_collections(); + + assert_eq!( + resolve_org_filter(&organizations, "org one", false).unwrap(), + std::iter::once("org-id-1".to_string()).collect() + ); + assert_eq!( + resolve_org_filter(&organizations, "ORG Two", true).unwrap(), + std::iter::once("org-id-2".to_string()).collect() + ); + // exact match required, no substring matching + assert!(resolve_org_filter(&organizations, "org", false).is_err()); + // case sensitive without ignore_case + assert!(resolve_org_filter(&organizations, "ORG Two", false).is_err()); + let err = resolve_org_filter(&organizations, "nonexistent", false) + .unwrap_err(); + assert!(err.to_string().contains("org one")); + assert!(err.to_string().contains("org two")); + } + + #[test] + fn test_resolve_collection_filter() { + let (_, collections) = make_orgs_and_collections(); + let names = collection_names(); + + // both orgs have a collection named "shared" + assert_eq!( + resolve_collection_filter( + &collections, + &names, + None, + "shared", + false + ) + .unwrap(), + ["collection-id-1".to_string(), "collection-id-2".to_string()] + .into_iter() + .collect() + ); + // restricting to an org restricts the candidates + let org_ids: std::collections::HashSet = + std::iter::once("org-id-1".to_string()).collect(); + assert_eq!( + resolve_collection_filter( + &collections, + &names, + Some(&org_ids), + "shared", + false + ) + .unwrap(), + std::iter::once("collection-id-1".to_string()).collect() + ); + assert_eq!( + resolve_collection_filter( + &collections, + &names, + None, + "ADMIN Passwords", + true + ) + .unwrap(), + std::iter::once("collection-id-3".to_string()).collect() + ); + // "admin passwords" only exists in org two + let err = resolve_collection_filter( + &collections, + &names, + Some(&org_ids), + "admin passwords", + false, + ) + .unwrap_err(); + assert!(err.to_string().contains("shared")); + assert!(!err.to_string().contains("admin passwords (")); + } + + #[test] + fn test_entry_matches_filters() { + let (mut entry, _) = make_entry("name", None, None, &[]); + let org_ids: std::collections::HashSet = + std::iter::once("org-id-1".to_string()).collect(); + let collection_ids: std::collections::HashSet = + std::iter::once("collection-id-1".to_string()).collect(); + + // no filters matches everything + assert!(entry_matches_filters(&entry, None, None)); + // a personal entry doesn't match any org or collection filter + assert!(!entry_matches_filters(&entry, Some(&org_ids), None)); + assert!(!entry_matches_filters(&entry, None, Some(&collection_ids))); + + entry.org_id = Some("org-id-1".to_string()); + assert!(entry_matches_filters(&entry, Some(&org_ids), None)); + // an org entry without collections ("unassigned") doesn't match any + // collection filter + assert!(!entry_matches_filters(&entry, None, Some(&collection_ids))); + + entry.collection_ids = vec![ + "collection-id-1".to_string(), + "collection-id-2".to_string(), + ]; + assert!(entry_matches_filters( + &entry, + Some(&org_ids), + Some(&collection_ids) + )); + + entry.org_id = Some("org-id-2".to_string()); + assert!(!entry_matches_filters(&entry, Some(&org_ids), None)); + assert!(entry_matches_filters(&entry, None, Some(&collection_ids))); + } + + #[test] + fn test_find_entry_uuid_respects_filters() { + let (organizations, collections) = make_orgs_and_collections(); + let (entry, _) = make_entry("name", None, None, &[]); + let uuid = uuid::Uuid::parse_str(&entry.id).unwrap(); + let mut db = rbw::db::Db::new(); + db.organizations = organizations; + db.collections = collections; + db.entries = vec![entry]; + + // a uuid lookup with an org filter the entry doesn't match must + // fail instead of ignoring the filter + let err = find_entry( + &db, + Needle::Uuid(uuid, uuid.to_string()), + None, + None, + Some("org one"), + None, + false, + ) + .unwrap_err(); + assert!(err.to_string().contains("no entry found"), "{err}"); + + // and the filter names must be validated for uuid lookups too + let err = find_entry( + &db, + Needle::Uuid(uuid, uuid.to_string()), + None, + None, + Some("nonexistent"), + None, + false, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("no organization found matching 'nonexistent'"), + "{err}" + ); + } } diff --git a/src/bin/rbw/completion/rbw.bash b/src/bin/rbw/completion/rbw.bash index 0b1f2ae8..1ca2d18b 100644 --- a/src/bin/rbw/completion/rbw.bash +++ b/src/bin/rbw/completion/rbw.bash @@ -5,7 +5,7 @@ _rbw_wrapper() { if [[ "${COMP_WORDS[1]}" == "get" ]] && [[ $COMP_CWORD -gt 1 ]]; then for (( i=2; i < COMP_CWORD; i++ )); do case "${COMP_WORDS[i]}" in - --folder|-f|--field) + --folder|--org|--collection|-f|--field) (( i++ )) if [ "${COMP_WORDS[i]}" == "=" ]; then (( i++ )) @@ -70,7 +70,7 @@ _rbw_wrapper() { fi if [[ "$cur" == -* ]]; then - res="-f --field --full --raw --clipboard -i --ignorecase -h --help $res" + res="-f --field --full --raw --clipboard -i --ignorecase -h --help --org --collection $res" if [ -z "$folder" ]; then res="--folder $res" fi diff --git a/src/bin/rbw/completion/rbw.fish b/src/bin/rbw/completion/rbw.fish index 3cbb1ec9..c34199ed 100644 --- a/src/bin/rbw/completion/rbw.fish +++ b/src/bin/rbw/completion/rbw.fish @@ -2,7 +2,7 @@ function __fish_rbw_get_completion_name set -l cmd (commandline -xpc) set -e cmd[1] # rbw - argparse -i folder= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd + argparse -i folder= org= collection= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd set -e argv[1] # get set -l candidates (command rbw list --fields name,folder,user) @@ -57,7 +57,7 @@ function __fish_rbw_get_completion_fields set -e cmd[-1] # -f/--field end - argparse -i folder= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd + argparse -i folder= org= collection= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd set -e argv[1] # get if test (count $argv) -gt 0 @@ -72,6 +72,8 @@ complete -f -c rbw -n '__fish_seen_subcommand_from get' -s i -l ignorecase -d 'I complete -f -c rbw -n '__fish_seen_subcommand_from get' -s f -l field -r -d 'Field to get' -a '(__fish_rbw_get_completion_fields)' complete -f -c rbw -n '__fish_seen_subcommand_from get' -s l -l list-fields -r -d 'List fields in this entry' complete -f -c rbw -n '__fish_seen_subcommand_from get' -l folder -r -d 'Folder name to search in' -a '(command rbw list --fields folder)' +complete -f -c rbw -n '__fish_seen_subcommand_from get' -l org -r -d 'Organization name to search in' -a '(command rbw list --fields org)' +complete -f -c rbw -n '__fish_seen_subcommand_from get' -l collection -r -d 'Collection name to search in' -a '(command rbw list --fields collection | string split ,)' complete -f -c rbw -n '__fish_seen_subcommand_from get' -l full -d 'Display the notes in addition to the password' 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' diff --git a/src/bin/rbw/completion/rbw.zsh b/src/bin/rbw/completion/rbw.zsh index b1739e78..ff507cc2 100644 --- a/src/bin/rbw/completion/rbw.zsh +++ b/src/bin/rbw/completion/rbw.zsh @@ -7,7 +7,7 @@ _rbw_wrapper() { cur="${(Q)words[i]}" case "$cur" in - --folder|-f|--field) + --folder|--org|--collection|-f|--field) (( i++ )) ;; -*) @@ -60,7 +60,7 @@ _rbw_wrapper() { fi if [[ "$cur" == -* ]] && [[ "$cur" != "--folder="* ]]; then - res=$'-f\n--field\n--full\n--raw\n--clipboard\n-i\n--ignorecase\n-h\n--help\n'"$res" + res=$'-f\n--field\n--full\n--raw\n--clipboard\n-i\n--ignorecase\n-h\n--help\n--org\n--collection\n'"$res" if [ -z "$folder" ]; then res=$'--folder\n'"$res" fi diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..29ecd39a 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -15,6 +15,10 @@ struct FindArgs { user: Option, #[arg(long, help = "Folder name to search in")] folder: Option, + #[arg(long, help = "Organization name to search in")] + org: Option, + #[arg(long, help = "Collection name to search in")] + collection: Option, #[arg(short, long, help = "Ignore case")] ignorecase: bool, } @@ -59,7 +63,8 @@ enum Opt { #[arg( long, help = "Fields to display. \ - Available options are id, name, user, folder, type. \ + Available options are id, name, user, folder, org, \ + collection, type. \ Multiple fields will be separated by tabs.", default_value = "name", use_value_delimiter = true @@ -93,7 +98,8 @@ enum Opt { #[arg( long, help = "Fields to display. \ - Available options are id, name, user, folder. \ + Available options are id, name, user, folder, org, \ + collection. \ Multiple fields will be separated by tabs.", default_value = "name", use_value_delimiter = true @@ -101,6 +107,10 @@ enum Opt { fields: Vec, #[arg(long, help = "Folder name to search in")] folder: Option, + #[arg(long, help = "Organization name to search in")] + org: Option, + #[arg(long, help = "Collection name to search in")] + collection: Option, #[structopt(long, help = "Display output as JSON")] raw: bool, }, @@ -139,6 +149,21 @@ enum Opt { uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, + #[arg( + long, + help = "Organization for the password entry \ + (requires --collection)", + requires = "collection" + )] + org: Option, + #[arg( + long, + help = "Collection for the password entry \ + (requires --org, may be given multiple times)", + number_of_values = 1, + requires = "org" + )] + collection: Vec, }, #[command( @@ -169,6 +194,21 @@ enum Opt { uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, + #[arg( + long, + help = "Organization for the password entry \ + (requires --collection)", + requires = "collection" + )] + org: Option, + #[arg( + long, + help = "Collection for the password entry \ + (requires --org, may be given multiple times)", + number_of_values = 1, + requires = "org" + )] + collection: Vec, #[arg( long = "no-symbols", help = "Generate a password with no special characters" @@ -209,6 +249,15 @@ enum Opt { Edit { #[command(flatten)] find_args: FindArgs, + #[arg( + long, + help = "Set the collections the entry belongs to, replacing \ + its current collections, without editing its contents \ + (only valid for entries in an organization, may be given \ + multiple times)", + number_of_values = 1 + )] + set_collection: Vec, }, #[command(about = "Remove a given entry", visible_alias = "rm")] @@ -350,6 +399,8 @@ fn main() { find_args.needle.clone(), find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), field.as_deref(), full, raw, @@ -364,8 +415,17 @@ fn main() { term, fields, folder, + org, + collection, + raw, + } => commands::search( + &term, + &fields, + folder.as_deref(), + org.as_deref(), + collection.as_deref(), raw, - } => commands::search(&term, &fields, folder.as_deref(), raw), + ), Opt::Code { find_args, #[cfg(feature = "clipboard")] @@ -374,6 +434,8 @@ fn main() { find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), #[cfg(feature = "clipboard")] clipboard, #[cfg(not(feature = "clipboard"))] @@ -385,6 +447,8 @@ fn main() { user, uri, folder, + org, + collection, } => commands::add( &name, user.as_deref(), @@ -394,6 +458,8 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + org.as_deref(), + &collection, ), Opt::Generate { len, @@ -401,6 +467,8 @@ fn main() { user, uri, folder, + org, + collection, no_symbols, only_numbers, nonconfusables, @@ -426,26 +494,38 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + org.as_deref(), + &collection, len, ty, ) } - Opt::Edit { find_args } => commands::edit( + Opt::Edit { + find_args, + set_collection, + } => commands::edit( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), + &set_collection, find_args.ignorecase, ), Opt::Remove { find_args } => commands::remove( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), find_args.ignorecase, ), Opt::History { find_args } => commands::history( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), find_args.ignorecase, ), Opt::Lock => commands::lock(), @@ -523,3 +603,57 @@ fn main() { std::process::exit(1); } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_cli() { + Opt::command().debug_assert(); + } + + // creating an entry in an organization requires a collection, and a + // collection is meaningless without an organization + #[test] + fn test_org_and_collection_require_each_other() { + for args in [ + &["rbw", "add", "name", "--org", "org"][..], + &["rbw", "add", "name", "--collection", "collection"][..], + &["rbw", "generate", "12", "name", "--org", "org"][..], + &[ + "rbw", + "generate", + "12", + "name", + "--collection", + "collection", + ][..], + ] { + assert!(Opt::try_parse_from(args).is_err(), "{args:?}"); + } + for args in [ + &[ + "rbw", + "add", + "name", + "--org", + "org", + "--collection", + "collection", + ][..], + &[ + "rbw", + "generate", + "12", + "name", + "--org", + "org", + "--collection", + "collection", + ][..], + ] { + assert!(Opt::try_parse_from(args).is_ok(), "{args:?}"); + } + } +} diff --git a/src/db.rs b/src/db.rs index fec0af7c..c1661407 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,6 +10,8 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; pub struct Entry { pub id: String, pub org_id: Option, + #[serde(default)] + pub collection_ids: Vec, pub folder: Option, pub folder_id: Option, pub name: String, @@ -175,6 +177,25 @@ pub struct HistoryEntry { pub password: String, } +#[derive( + serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, +)] +pub struct Org { + pub id: String, + // organization names are plaintext in the sync response + pub name: String, +} + +#[derive( + serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, +)] +pub struct Collection { + pub id: String, + pub org_id: String, + // collection names are encrypted with the organization key + pub name: String, +} + #[derive(serde::Serialize, serde::Deserialize, Default, Debug)] pub struct Db { pub access_token: Option, @@ -187,6 +208,10 @@ pub struct Db { pub protected_key: Option, pub protected_private_key: Option, pub protected_org_keys: std::collections::HashMap, + #[serde(default)] + pub organizations: Vec, + #[serde(default)] + pub collections: Vec, pub entries: Vec, } @@ -314,3 +339,83 @@ impl Db { || self.protected_key.is_none() } } + +#[cfg(test)] +mod tests { + use super::*; + + // db files written before organizations/collections support must still + // deserialize + #[test] + fn test_deserialize_old_format() { + let json = r#"{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "kdf": null, + "iterations": 600000, + "memory": null, + "parallelism": null, + "protected_key": "protected-key", + "protected_private_key": "protected-private-key", + "protected_org_keys": { + "org-id": "protected-org-key" + }, + "entries": [{ + "id": "entry-id", + "org_id": "org-id", + "folder": null, + "folder_id": null, + "name": "encrypted-name", + "data": { + "Login": { + "username": null, + "password": null, + "totp": null, + "uris": [] + } + }, + "fields": [], + "notes": null, + "history": [], + "key": null, + "master_password_reprompt": 0 + }] + }"#; + let db: Db = serde_json::from_str(json).unwrap(); + assert!(db.organizations.is_empty()); + assert!(db.collections.is_empty()); + assert_eq!(db.entries.len(), 1); + assert!(db.entries[0].collection_ids.is_empty()); + } + + #[test] + fn test_roundtrip_organizations_and_collections() { + let mut db = Db::new(); + db.organizations = vec![Org { + id: "org-id".to_string(), + name: "org-name".to_string(), + }]; + db.collections = vec![Collection { + id: "collection-id".to_string(), + org_id: "org-id".to_string(), + name: "encrypted-collection-name".to_string(), + }]; + let json = serde_json::to_string(&db).unwrap(); + let db: Db = serde_json::from_str(&json).unwrap(); + assert_eq!( + db.organizations, + vec![Org { + id: "org-id".to_string(), + name: "org-name".to_string(), + }] + ); + assert_eq!( + db.collections, + vec![Collection { + id: "collection-id".to_string(), + org_id: "org-id".to_string(), + name: "encrypted-collection-name".to_string(), + }] + ); + } +} 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()