diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index b2dd5845..414cdc16 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1244,10 +1244,11 @@ struct CommonUnlockData { v2_upgrade_token: Option, } -/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. +/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. Stored on the user and +/// the memberships as this serializes, and read back with it (the account recovery V2 upgrade routes). #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -struct V2UpgradeTokenData { +pub(crate) struct V2UpgradeTokenData { wrapped_user_key1: String, wrapped_user_key2: String, } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 410c7ab5..19139127 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -1565,15 +1565,16 @@ async fn get_attachment_data( }; if scope == CipherAccessScope::OrganizationAdmin { - // NotFound as in Bitwarden, the web vault then falls back to the attachment's own URL + // NotFound as in Bitwarden. Its web vault then falls back to the attachment's URL from the listing, which isn't + // served here without a token, so a member who can open the item themselves gets the signed URL instead. let Some(org_id) = cipher.organization_uuid.as_ref() else { err_code!("Cipher is not owned by an organization", Status::NotFound.code) }; - if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) + let is_admin = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) .await .is_some_and(|member| member.can_edit_any_collection()) - || !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await - { + && cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await; + if !is_admin && !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { err_code!("Cipher is not accessible", Status::NotFound.code) } } else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await { @@ -1581,7 +1582,9 @@ async fn get_attachment_data( } match Attachment::find_by_id(&attachment_id, &conn).await { - Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)), + Some(attachment) if cipher_id == attachment.cipher_uuid => { + Ok(Json(attachment.to_json_for_download(&headers.host).await?)) + } Some(_) => err!("Attachment doesn't belong to cipher"), None => err!("Attachment doesn't exist"), } diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 773697e5..b2d8fb59 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -15,8 +15,9 @@ use crate::{ db::{ DbConn, DbPool, models::{ - Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, - EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, + Attachment, AttachmentId, Cipher, CipherId, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, + EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType, + OrgPolicy, TwoFactor, User, UserId, }, }, mail, @@ -42,6 +43,7 @@ pub fn routes() -> Vec { takeover_emergency_access, password_emergency_access, view_emergency_access, + get_emergency_access_attachment, policies_emergency_access, ] } @@ -616,6 +618,46 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con }))) } +/// An attachment of the grantor's for a grantee with approved view access, with a `url` to download it from: the view +/// lists the attachments with one that only names the file. Only the grantor's own ciphers, the ones the view lists. +/// +/// Ref: +#[get("/emergency-access///attachment/")] +async fn get_emergency_access_attachment( + emer_id: EmergencyAccessId, + cipher_id: CipherId, + attachment_id: AttachmentId, + headers: Headers, + conn: DbConn, +) -> JsonResult { + check_emergency_access_enabled()?; + + let Some(emergency_access) = + EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await + else { + err!("Emergency access not valid.") + }; + + if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) { + err!("Emergency access not valid.") + } + + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + if cipher.user_uuid.as_ref() != Some(&emergency_access.grantor_uuid) || cipher.organization_uuid.is_some() { + err!("Cipher is not accessible") + } + + match Attachment::find_by_id(&attachment_id, &conn).await { + Some(attachment) if attachment.cipher_uuid == cipher_id => { + Ok(Json(attachment.to_json_for_download(&headers.host).await?)) + } + Some(_) => err!("Attachment doesn't belong to cipher"), + None => err!("Attachment doesn't exist"), + } +} + #[post("/emergency-access//takeover")] async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 693c17ff..4593e6b0 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -21,7 +21,7 @@ use crate::{ models::{ Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, - Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, + Invitation, KeyId, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled, }, @@ -31,7 +31,7 @@ use crate::{ util::{NumberOrString, convert_json_key_lcase_first}, }; -use super::accounts::{AuthenticationData, UnlockData}; +use super::accounts::{AuthenticationData, UnlockData, V2UpgradeTokenData}; pub fn routes() -> Vec { routes![ @@ -94,6 +94,9 @@ pub fn routes() -> Vec { get_group_members, put_reset_password_enrollment, get_reset_password_details, + get_pending_v2_upgrades, + post_v2_upgrades, + get_organization_private_key, put_recover_account, get_org_export, post_api_key, @@ -3236,12 +3239,7 @@ struct OrganizationUserRecoverAccountRequest { // But the clients do not seem to use this at all // Just add it here in case they will #[get("/organizations//public-key")] -async fn get_organization_public_key( - org_id: OrganizationId, - headers: Headers, - member: Result, - conn: DbConn, -) -> JsonResult { +async fn get_organization_public_key(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult { // SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() { return Ok(Json(json!({ @@ -3249,14 +3247,16 @@ async fn get_organization_public_key( "publicKey": fake_sso_org_public_key().await?, }))); } - let member = match member { - Ok(member) => member, - // The guard already logged it - Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)), - }; - if org_id != member.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); + // Only for members, as `OrgMemberHeaders` checks it, but here, so that the fake org above neither logs a + // membership error nor authenticates the request twice: a known role, and not revoked (invited members are, + // while joining) + let is_member = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await.is_some_and(|member| { + MembershipType::from_i32(member.atype).is_some() + && MembershipStatus::from_i32(member.status).is_some_and(|status| status != MembershipStatus::Revoked) + }); + if !is_member { + err_code!("You need to be a Member of the Organization to call this endpoint", Status::Unauthorized.code) } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Organization not found") @@ -3285,13 +3285,8 @@ async fn fake_sso_org_public_key() -> ApiResult { // Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492 #[get("/organizations//keys")] -async fn get_organization_keys( - org_id: OrganizationId, - headers: Headers, - member: Result, - conn: DbConn, -) -> JsonResult { - get_organization_public_key(org_id, headers, member, conn).await +async fn get_organization_keys(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult { + get_organization_public_key(org_id, headers, conn).await } // Will allow to reset 2FA too @@ -3412,6 +3407,167 @@ async fn recover_account( Ok(()) } +#[derive(FromForm)] +struct PendingV2UpgradesQuery { + #[field(name = "continuationToken")] + continuation_token: Option, +} + +/// The memberships whose account recovery key an Owner or Admin still has to re-wrap with the member's V2 user key, +/// one page at a time: the client posts each page to `v2-upgrades` and reads the next one with the continuation +/// token, until it is null. An Admin gets every member but the Owners, whose key material only an Owner can read. +/// +/// Ref: +#[get("/organizations//users/keys/pending-v2-upgrades?")] +async fn get_pending_v2_upgrades( + org_id: OrganizationId, + query: PendingV2UpgradesQuery, + headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + let include_owners = headers.membership_type == MembershipType::Owner; + let pending = + Membership::find_pending_v2_upgrades(&org_id, include_owners, query.continuation_token.as_ref(), &conn).await; + + let continuation_token = if i64::try_from(pending.len()).is_ok_and(|len| len == Membership::MAX_V2_UPGRADES) { + pending.last().map(|upgrade| upgrade.member_id.clone()) + } else { + None + }; + let data: Vec = pending + .into_iter() + .map(|upgrade| { + json!({ + "object": "organizationUserPendingV2Upgrade", + "organizationUserId": upgrade.member_id, + "userKeyId": upgrade.user_key_id, + "accountRecoveryKey": upgrade.account_recovery_key, + // Null when the stored token can't be read: it holds no usable key, so the admin unenrolls the member + "v2UpgradeToken": serde_json::from_str::(&upgrade.v2_upgrade_token).ok(), + }) + }) + .collect(); + + Ok(Json(json!({ + "object": "list", + "data": data, + "continuationToken": continuation_token, + }))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct V2UpgradesData { + upgrades: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct V2UpgradeData { + organization_user_id: MembershipId, + /// The id of the user key the new account recovery key wraps, which must still be the member's + user_key_id: KeyId, + /// The member's V2 user key wrapped with the organization's public key, or none to unenroll the member (when the + /// upgrade token holds no usable key) + account_recovery_key: Option, +} + +/// Stores the account recovery keys an Owner or Admin re-wrapped with members' V2 user keys, or unenrolls the members +/// whose entry has no key. Every member named must be one the caller can recover in this organization. A membership +/// that changed since the read (rotated again, unenrolled, upgraded already, promoted) is skipped and stays pending. +/// +/// Ref: +#[post("/organizations//users/keys/v2-upgrades", data = "")] +async fn post_v2_upgrades( + org_id: OrganizationId, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + let upgrades = data.into_inner().upgrades; + if upgrades.is_empty() || i64::try_from(upgrades.len()).map_or(true, |len| len > Membership::MAX_V2_UPGRADES) { + err!(format!("The field Upgrades must contain between 1 and {} entries.", Membership::MAX_V2_UPGRADES)) + } + let mut seen = HashSet::new(); + if !upgrades.iter().all(|upgrade| seen.insert(&upgrade.organization_user_id)) { + err!("An organization user can only be upgraded once per request.") + } + // A key or its absence: the token the key could be rebuilt from is consumed with the write, so a malformed key is + // refused rather than stored + if upgrades + .iter() + .filter_map(|upgrade| upgrade.account_recovery_key.as_deref()) + .any(|key| !crate::util::is_valid_rsa_encrypted_string(key)) + { + err!("The field AccountRecoveryKey is not a valid encrypted string.") + } + + let include_owners = headers.membership_type == MembershipType::Owner; + let mut members = Vec::with_capacity(upgrades.len()); + for upgrade in &upgrades { + match Membership::find_by_uuid_and_org(&upgrade.organization_user_id, &org_id, &conn).await { + Some(member) if include_owners || member.atype != MembershipType::Owner => members.push(member), + _ => err_code!("Resource not found.", Status::NotFound.code), + } + } + + // Every write in one transaction, as upstream's single statement, so a failure leaves the whole batch pending + let applied = conn + .transaction(async || { + let mut applied = Vec::with_capacity(members.len()); + for (member, upgrade) in members.iter().zip(&upgrades) { + let key = upgrade.account_recovery_key.as_deref(); + if member.apply_v2_upgrade(include_owners, &upgrade.user_key_id, key, &conn).await? { + applied.push((member, upgrade)); + } + } + Ok(applied) + }) + .await?; + + for (member, upgrade) in applied { + User::update_uuid_revision(&member.user_uuid, &conn).await; + // A re-wrapped key isn't logged: the member stays enrolled + if upgrade.account_recovery_key.is_none() { + log_event( + EventType::OrganizationUserResetPasswordWithdraw, + &upgrade.organization_user_id, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + } + } + Ok(()) +} + +/// The organization's private key, wrapped with the organization key: the account recovery V2 upgrade (the SDK's +/// encrypted migrator) needs it to unwrap the members' V1 user keys. Open to everyone with Manage users, as upstream. +/// +/// Ref: +#[get("/organizations//private-key")] +async fn get_organization_private_key(org_id: OrganizationId, headers: ManageUsersHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { + err_code!("Resource not found.", Status::NotFound.code) + }; + Ok(Json(json!({ + "object": "organizationPrivateKey", + "privateKey": org.private_key, + }))) +} + #[get("/organizations//users//reset-password-details")] async fn get_reset_password_details( org_id: OrganizationId, @@ -3524,6 +3680,9 @@ async fn put_reset_password_enrollment( } membership.reset_password_key = reset_password_key; + // A new key already wraps the current user key and a withdrawal leaves none. An upgrade token left from an earlier + // V2 upgrade would only get the member unenrolled by the account recovery V2 upgrade. + membership.v2_upgrade_token = None; membership.save(&conn).await?; let event_type = if membership.reset_password_key.is_some() { diff --git a/src/db/models/attachment.rs b/src/db/models/attachment.rs index 659648d0..52d6d207 100644 --- a/src/db/models/attachment.rs +++ b/src/db/models/attachment.rs @@ -65,16 +65,33 @@ impl Attachment { } } - pub async fn to_json(&self, host: &str) -> Result { - Ok(json!({ + /// The attachment as sync and the cipher responses list it. Its `url` is a leftover from the old download flow. + /// Clients get a signed URL from the attachment routes (`to_json_for_download`) instead, only the web vault and the + /// CLI read this one, as a fallback when those routes answer 404. + /// + /// Ref: + pub fn to_json(&self, host: &str) -> Value { + self.json_with_url(&format!("{host}/attachments/{}/{}", self.cipher_uuid, self.id)) + } + + /// The attachment with a `url` the client can download it from for a short while, for the attachment routes + /// (`GET /ciphers/{id}/attachment/{attachmentId}`, its admin variant, the emergency access one). + /// + /// Ref: + pub async fn to_json_for_download(&self, host: &str) -> Result { + Ok(self.json_with_url(&self.get_url(host).await?)) + } + + fn json_with_url(&self, url: &str) -> Value { + json!({ "id": self.id, - "url": self.get_url(host).await?, + "url": url, "fileName": self.file_name, "size": self.file_size.to_string(), "sizeName": crate::util::get_display_size(self.file_size), "key": self.akey, "object": "attachment" - })) + }) } } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 68f97c13..97807279 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -270,7 +270,7 @@ impl Cipher { { let mut attachments_json_vec = vec![]; for attachment in attachments { - attachments_json_vec.push(attachment.to_json(host).await?); + attachments_json_vec.push(attachment.to_json(host)); } attachments_json = Value::Array(attachments_json_vec); } @@ -279,7 +279,7 @@ impl Cipher { if !attachments.is_empty() { let mut attachments_json_vec = vec![]; for attachment in attachments { - attachments_json_vec.push(attachment.to_json(host).await?); + attachments_json_vec.push(attachment.to_json(host)); } attachments_json = Value::Array(attachments_json_vec); } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 4936742e..26c02509 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -19,15 +19,27 @@ use crate::{ organizations, users, users_collections, users_organizations, }, }, - error::MapResult, + error::{Error, MapResult}, }; use macros::UuidFromParam; use super::{ - Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, - TwoFactor, User, UserId, + Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, KeyId, OrgPolicy, + OrgPolicyType, TwoFactor, User, UserId, }; +/// A membership whose account recovery key still wraps the member's V1 user key: an upgrade to V2 encryption can't +/// re-wrap it (that needs the member to trust the organization's key), so it left the token the admin re-wraps it from. +#[derive(Queryable)] +pub struct PendingV2Upgrade { + pub member_id: MembershipId, + /// The member's current user key id, which the re-wrapped key must be sent back with + pub user_key_id: KeyId, + /// The V1 user key, wrapped with the organization's public key + pub account_recovery_key: String, + pub v2_upgrade_token: String, +} + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = organizations)] #[diesel(treat_none_as_null = true)] @@ -293,7 +305,7 @@ impl CustomPermission { /// replaces the member's permissions, so a malformed value must not silently remove one. Granting a /// permission this version does not enforce is rejected as well. The clients also send permissions /// Vaultwarden does not know at all, like `manageSso`, those are ignored. - pub fn requested(permissions: &HashMap) -> Result, crate::Error> { + pub fn requested(permissions: &HashMap) -> Result, Error> { let mut granted = HashSet::new(); for permission in CustomPermission::ALL { match permissions.get(permission.json_key()) { @@ -1258,6 +1270,90 @@ impl Membership { .await } + /// The most account recovery V2 upgrades one request reads or writes, like upstream's + /// `OrganizationUserV2UpgradesRequestModel.MaxUpgrades`. + pub const MAX_V2_UPGRADES: i64 = 100; + + /// The next page of memberships of this organization whose account recovery key the admin still has to re-wrap + /// with the member's V2 user key: enrolled, carrying an upgrade token, of a user with a key id (the re-wrapped key + /// is checked against it), and Owners only for an Owner. Ordered by id, after `after`, at most `MAX_V2_UPGRADES`. + pub async fn find_pending_v2_upgrades( + org_uuid: &OrganizationId, + include_owners: bool, + after: Option<&MembershipId>, + conn: &DbConn, + ) -> Vec { + conn.run(move |conn| { + let mut query = users_organizations::table + .inner_join(users::table) + .filter(users_organizations::org_uuid.eq(org_uuid)) + .filter(users_organizations::v2_upgrade_token.is_not_null()) + .filter(users_organizations::reset_password_key.is_not_null()) + .filter(users::key_id.is_not_null()) + .into_boxed(); + if !include_owners { + query = query.filter(users_organizations::atype.ne(MembershipType::Owner as i32)); + } + if let Some(after) = after { + query = query.filter(users_organizations::uuid.gt(after)); + } + query + .order(users_organizations::uuid) + .limit(Self::MAX_V2_UPGRADES) + // The filters above make sure these are set + .select(( + users_organizations::uuid, + users::key_id.assume_not_null(), + users_organizations::reset_password_key.assume_not_null(), + users_organizations::v2_upgrade_token.assume_not_null(), + )) + .load(conn) + .unwrap_or_default() + }) + .await + } + + /// Replaces the account recovery key of a member who upgraded to V2 encryption with `account_recovery_key`, or + /// unenrolls them when it is `None`, and consumes the upgrade token, so it can't be replayed. Only while the + /// membership is still enrolled, carrying the token, not an Owner unless `include_owners`, and its user's key id is + /// still `user_key_id` (the key the new recovery key wraps). Returns whether it applied, otherwise the membership + /// stays pending. It runs inside a transaction, so it leaves the user's revision to the caller. + pub async fn apply_v2_upgrade( + &self, + include_owners: bool, + user_key_id: &KeyId, + account_recovery_key: Option<&str>, + conn: &DbConn, + ) -> Result { + // An empty list excludes no one + let excluded = if include_owners { + vec![] + } else { + vec![MembershipType::Owner as i32] + }; + let rows: usize = conn + .run(move |conn| { + diesel::update( + users_organizations::table + .filter(users_organizations::uuid.eq(&self.uuid)) + .filter(users_organizations::v2_upgrade_token.is_not_null()) + .filter(users_organizations::reset_password_key.is_not_null()) + .filter(users_organizations::atype.ne_all(excluded)) + .filter(diesel::dsl::exists( + users::table.filter(users::uuid.eq(&self.user_uuid)).filter(users::key_id.eq(user_key_id)), + )), + ) + .set(( + users_organizations::reset_password_key.eq(account_recovery_key), + users_organizations::v2_upgrade_token.eq(None::), + )) + .execute(conn) + .map_res("Error saving membership") + }) + .await?; + Ok(rows > 0) + } + pub async fn count_confirmed_by_org_and_type( org_uuid: &OrganizationId, atype: MembershipType, diff --git a/src/util.rs b/src/util.rs index 696c3360..409867b3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -995,6 +995,22 @@ pub async fn save_temp_file( Ok(()) } +/// Whether `value` has the form of an RSA-encrypted string (types 3 to 6), the form of the account recovery key: the +/// type and its number of `|` pieces like the clients' `UnsignedSharedKey` parser, and each piece canonical, padded +/// base64, which is what the clients send. +/// +/// Ref: +pub fn is_valid_rsa_encrypted_string(value: &str) -> bool { + let (pieces, rest) = match value.split_once('.') { + Some(("3" | "4", rest)) => (1, rest), + Some(("5" | "6", rest)) => (2, rest), + _ => return false, + }; + let parts: Vec<&str> = rest.split('|').collect(); + parts.len() == pieces + && parts.iter().all(|part| !part.is_empty() && data_encoding::BASE64.decode(part.as_bytes()).is_ok()) +} + /// These are some tests to check that the implementations match /// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17 /// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct @@ -1073,3 +1089,23 @@ mod query_log_tests { assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}=")); } } + +#[cfg(test)] +mod encrypted_string_tests { + use super::is_valid_rsa_encrypted_string as valid; + + #[test] + fn rsa_encrypted_strings_are_validated() { + assert!(valid("3.YWJjZA==")); + assert!(valid("4.YWJjZA==")); + assert!(valid("6.YWJjZA==|YWJjZA==")); + assert!(!valid("2.YWJjZA==|YWJjZA==|YWJjZA=="), "symmetric type"); + assert!(!valid("YWJjZA=="), "no type"); + assert!(!valid("4.not base64!")); + assert!(!valid("4.YWJjZA==|YWJjZA=="), "one piece too many"); + assert!(!valid("5.YWJjZA=="), "one piece too few"); + assert!(!valid("4.YWJjZB=="), "padding bits set"); + assert!(!valid("4."), "empty piece"); + assert!(!valid("")); + } +} diff --git a/tools/sdk-live-tests/seed_vector.py b/tools/sdk-live-tests/seed_vector.py index 85db6f07..85ce044f 100644 --- a/tools/sdk-live-tests/seed_vector.py +++ b/tools/sdk-live-tests/seed_vector.py @@ -26,11 +26,12 @@ def main() -> int: print("no master password") return SKIP - # Without attachments: the rotation test compares the vault before and after, and our sync gives each attachment - # a link with a fresh download token every time (upstream's is the same on every sync). A vector's legacy - # attachments, without a key of their own, would also make the SDK refuse to rotate at all. + # Without the legacy attachments that have no key of their own: the SDK refuses to rotate a vault holding one + # (`OldAttachments`), on any server. for cipher in vector.get("vault", {}).get("ciphers", []): - cipher.get("encrypted", {}).pop("attachments", None) + encrypted = cipher.get("encrypted", {}) + if encrypted.get("attachments"): + encrypted["attachments"] = [a for a in encrypted["attachments"] if a.get("key")] or None credentials = base64.b64encode(f"{username}:{password}".encode()).decode() request = urllib.request.Request(