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.
This commit is contained in:
Daniel García committed 2026-11-14 14:24:22 +01:00
1 parent e03151d307
commit 85e87dd340
4 files changed
+305 -8

No files matched your search

+3 -2
View File
@@ -1244,10 +1244,11 @@ struct CommonUnlockData {
v2_upgrade_token: Option<V2UpgradeTokenData>,
}
/// 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,
}
+166 -2
View File
@@ -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<Route> {
routes![
@@ -94,6 +94,9 @@ pub fn routes() -> Vec<Route> {
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<MembershipId>,
}
/// 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: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L46-L83>
#[get("/organizations/<org_id>/users/keys/pending-v2-upgrades?<query..>")]
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<Value> = 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::<V2UpgradeTokenData>(&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<V2UpgradeData>,
}
#[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<String>,
}
/// 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: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L85-L108>
#[post("/organizations/<org_id>/users/keys/v2-upgrades", data = "<data>")]
async fn post_v2_upgrades(
org_id: OrganizationId,
data: Json<V2UpgradesData>,
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: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L473-L484>
#[get("/organizations/<org_id>/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/<org_id>/users/<member_id>/reset-password-details")]
async fn get_reset_password_details(
org_id: OrganizationId,
+100 -4
View File
@@ -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<String, Value>) -> Result<HashSet<CustomPermission>, crate::Error> {
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, 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<PendingV2Upgrade> {
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<bool, Error> {
// 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::<String>),
))
.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,
+36
View File
@@ -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: <https://github.com/bitwarden/sdk-internal/blob/6238472451e4fa7b5c592b4b24a84e950a258957/crates/bitwarden-crypto/src/enc_string/asymmetric.rs#L85-L119>
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(""));
}
}