From 85e87dd34050ab50529b8a654a7342e520ff2d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Fri, 13 Nov 2026 09:28:05 +0100 Subject: [PATCH] Add the account recovery V2 upgrade routes GET /organizations/{orgId}/users/keys/pending-v2-upgrades lists, a page at a time, the memberships whose account recovery key still wraps the member's V1 user key after an upgrade to V2 encryption, with the upgrade token the admin re-wraps it from. POST .../v2-upgrades stores the re-wrapped keys in one transaction, or unenrolls the members whose token holds no usable key. Only Owners and Admins, an Admin never for Owners, and a membership that changed since the read is skipped. GET /organizations/{orgId}/private-key gives the organization's private key these need, to everyone with Manage users as upstream. The SDK's encrypted migrator calls all three. --- src/api/core/accounts.rs | 5 +- src/api/core/organizations.rs | 168 +++++++++++++++++++++++++++++++++- src/db/models/organization.rs | 104 ++++++++++++++++++++- src/util.rs | 36 ++++++++ 4 files changed, 305 insertions(+), 8 deletions(-) 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/organizations.rs b/src/api/core/organizations.rs index 2703b9ea..3a2fa820 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, @@ -3364,6 +3367,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, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 7eb2260b..9fb0c6b1 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)] @@ -291,7 +303,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()) { @@ -1256,6 +1268,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("")); + } +}