diff --git a/migrations/mysql/2026-10-10-120000_add_v2_upgrade_token/down.sql b/migrations/mysql/2026-10-10-120000_add_v2_upgrade_token/down.sql new file mode 100644 index 00000000..89e12c15 --- /dev/null +++ b/migrations/mysql/2026-10-10-120000_add_v2_upgrade_token/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN v2_upgrade_token; +ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/mysql/2026-10-10-120000_add_v2_upgrade_token/up.sql b/migrations/mysql/2026-10-10-120000_add_v2_upgrade_token/up.sql new file mode 100644 index 00000000..45c58491 --- /dev/null +++ b/migrations/mysql/2026-10-10-120000_add_v2_upgrade_token/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; +ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/migrations/postgresql/2026-10-10-120000_add_v2_upgrade_token/down.sql b/migrations/postgresql/2026-10-10-120000_add_v2_upgrade_token/down.sql new file mode 100644 index 00000000..89e12c15 --- /dev/null +++ b/migrations/postgresql/2026-10-10-120000_add_v2_upgrade_token/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN v2_upgrade_token; +ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/postgresql/2026-10-10-120000_add_v2_upgrade_token/up.sql b/migrations/postgresql/2026-10-10-120000_add_v2_upgrade_token/up.sql new file mode 100644 index 00000000..45c58491 --- /dev/null +++ b/migrations/postgresql/2026-10-10-120000_add_v2_upgrade_token/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; +ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/migrations/sqlite/2026-10-10-120000_add_v2_upgrade_token/down.sql b/migrations/sqlite/2026-10-10-120000_add_v2_upgrade_token/down.sql new file mode 100644 index 00000000..89e12c15 --- /dev/null +++ b/migrations/sqlite/2026-10-10-120000_add_v2_upgrade_token/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN v2_upgrade_token; +ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/sqlite/2026-10-10-120000_add_v2_upgrade_token/up.sql b/migrations/sqlite/2026-10-10-120000_add_v2_upgrade_token/up.sql new file mode 100644 index 00000000..45c58491 --- /dev/null +++ b/migrations/sqlite/2026-10-10-120000_add_v2_upgrade_token/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; +ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 9f6625ac..4084d718 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use chrono::Utc; +use num_traits::FromPrimitive; use rocket::{ http::Status, request::{FromRequest, Outcome, Request}, @@ -11,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + AnonymousNotify, ApiResult, EmptyResult, JsonResult, LogOutReason, Notify, PasswordOrOtpData, core::{accept_org_invite, log_user_event, two_factor::email}, master_password_policy, register_push_device, unregister_push_device, }, @@ -31,7 +32,7 @@ use crate::{ }; use super::{ - ciphers::{CipherData, update_cipher_from_data}, + ciphers::{CipherData, rotate_cipher_data, validate_rotated_cipher}, sends::SendData, }; @@ -48,8 +49,10 @@ pub fn routes() -> Vec { post_password, post_set_password, post_kdf, + get_key_rotation_data, post_rotatekey, post_user_key, + post_rotate_user_keys, post_sstamp, post_email_token, post_email, @@ -336,6 +339,21 @@ impl AccountKeysData { } } +impl WrappedAccountCryptographicState { + /// v2-only: the nested pair stands in for the top-level keys, so a partial state can't pass as v1. + fn validate(self) -> ApiResult { + let key_pair = self.public_key_encryption_key_pair; + AccountKeysData { + user_key_encrypted_account_private_key: Some(key_pair.wrapped_private_key.clone()), + account_public_key: Some(key_pair.public_key.clone()), + public_key_encryption_key_pair: Some(key_pair), + signature_key_pair: Some(self.signature_key_pair), + security_state: Some(self.security_state), + } + .validate() + } +} + impl From for ValidatedAccountKeys { fn from(keys: KeysData) -> Self { Self { @@ -965,7 +983,8 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("KDF settings must be equal for authentication and unlock") } - if user.email != authentication_data.salt || user.email != unlock_data.salt { + let salt = user.master_password_salt(); + if salt != authentication_data.salt || salt != unlock_data.salt { err!("Invalid master password salt") } @@ -1088,6 +1107,17 @@ fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResu Ok(()) } +/// A rotation's unlock data has to wrap the new user key. Neither id is sent by clients that predate them. +/// +/// Ref: +fn validate_contained_key_id(contained_key_id: Option<&KeyId>, new_user_key_id: Option<&KeyId>) -> EmptyResult { + match (contained_key_id, new_user_key_id) { + (None, None) => Ok(()), + (Some(contained), Some(new)) if contained == new => Ok(()), + _ => err!("Invalid user key sent in master-password unlock data."), + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChangeKdfData { @@ -1142,50 +1172,71 @@ struct UpdateFolderData { #[serde(rename_all = "camelCase")] struct UpdateEmergencyAccessData { id: EmergencyAccessId, - key_encrypted: String, + key_encrypted: Option, + // Only validated, upstream doesn't store it either + wait_time_days: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateResetPasswordData { organization_id: OrganizationId, - reset_password_key: String, + // Absent in a v1 -> v2 upgrade, which keeps the key the organization already holds + reset_password_key: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct KeyData { account_unlock_data: RotateAccountUnlockData, - account_keys: RotateAccountKeys, + account_keys: AccountKeysData, account_data: RotateAccountData, old_master_key_authentication_hash: String, + new_user_key_id: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RotateAccountUnlockData { - emergency_access_unlock_data: Vec, master_password_unlock_data: MasterPasswordUnlockData, - organization_account_recovery_unlock_data: Vec, + #[serde(flatten)] + common: CommonUnlockData, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MasterPasswordUnlockData { - kdf_type: i32, - kdf_iterations: i32, - kdf_parallelism: Option, - kdf_memory: Option, + #[serde(flatten)] + kdf: KDFData, email: String, master_key_authentication_hash: String, master_key_encrypted_user_key: String, + master_password_hint: Option, + master_password_salt: Option, + contained_key_id: Option, } +/// The unlock data of both rotation endpoints; passkey and device keys are ignored, as we support neither. +/// +/// Ref: #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RotateAccountKeys { - user_key_encrypted_account_private_key: String, - account_public_key: String, +struct CommonUnlockData { + emergency_access_unlock_data: Vec, + organization_account_recovery_unlock_data: Vec, + #[expect(dead_code, reason = "Required like upstream, but unused")] + passkey_unlock_data: Vec, + #[expect(dead_code, reason = "Required like upstream, but unused")] + device_key_unlock_data: Vec, + v2_upgrade_token: Option, +} + +/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct V2UpgradeTokenData { + wrapped_user_key1: String, + wrapped_user_key2: String, } #[derive(Deserialize)] @@ -1196,27 +1247,116 @@ struct RotateAccountData { sends: Vec, } -fn validate_keydata( - data: &KeyData, +/// Body of `rotate-user-keys`, which rotates the keys without touching the master password. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RotateUserKeysData { + wrapped_account_cryptographic_state: WrappedAccountCryptographicState, + unlock_data: CommonUnlockData, + account_data: RotateAccountData, + unlock_method_data: UnlockMethodData, + new_user_key_id: Option, +} + +/// The v2-only account cryptographic state, where all three parts are mandatory. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WrappedAccountCryptographicState { + public_key_encryption_key_pair: PublicKeyEncryptionKeyPairData, + signature_key_pair: SignatureKeyPairData, + security_state: SecurityStateData, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnlockMethodData { + unlock_method: i32, + master_password_unlock_data: Option, + key_connector_key_wrapped_user_key: Option, +} + +/// Ref: +#[derive(num_derive::FromPrimitive)] +enum UnlockMethod { + Tde = 0, + MasterPassword = 1, + KeyConnector = 2, +} + +/// The rotation checks on the new keys: the public key never changes, nor the verifying key of a v2 account. +/// +/// Ref: +async fn validate_rotation_account_keys(keys: &ValidatedAccountKeys, user: &User, conn: &DbConn) -> EmptyResult { + if user.public_key.as_ref() != Some(&keys.public_key) { + err!("Changing the asymmetric keypair is not possible during key rotation") + } + + let Some(v2) = &keys.v2 else { + if user.is_v2() { + err!("Cannot downgrade an account from v2 to v1 encryption during key rotation") + } + // A v1 rotation: the private key stays wrapped by an AES-CBC-HMAC user key + if enc_string_type(&keys.private_key) != Some(ENC_TYPE_AES_CBC_256_HMAC_SHA256) { + err!("The provided account private key was not wrapped with AES-256-CBC-HMAC") + } + return Ok(()); + }; + + // Both a v2 rotation and the v1 -> v2 upgrade end up with a COSE user key wrapping the private keys + if enc_string_type(&v2.signing_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { + err!("The provided signing key data is not wrapped with XChaCha20-Poly1305.") + } + if enc_string_type(&keys.private_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { + err!("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.") + } + if v2.verifying_key.is_empty() || v2.signed_public_key.is_empty() || v2.security_state.is_empty() { + err!("The v2 account keys are missing the verifying key, signed public key or security state") + } + + if !user.is_v2() { + // The v1 -> v2 upgrade, which is where the signature key pair comes from + return Ok(()); + } + + let Some(key_pair) = UserSignatureKeyPair::find_by_user(&user.uuid, conn).await else { + err!("The account is missing its signature key pair") + }; + if key_pair.verifying_key != v2.verifying_key { + err!("Changing the verifying key is not possible during key rotation") + } + + Ok(()) +} + +/// `AesCbc256_HmacSha256_B64`, the v1 user key +const ENC_TYPE_AES_CBC_256_HMAC_SHA256: &str = "2"; +/// `CoseEncrypt0B64`, the v2 user key +const ENC_TYPE_COSE_ENCRYPT0: &str = "7"; + +/// The encryption type of an EncString, the number before the first `.`. +fn enc_string_type(enc_string: &str) -> Option<&str> { + enc_string.split_once('.').map(|(enc_type, _)| enc_type) +} + +impl KDFData { + /// Whether these are the settings the user already has, which a key rotation can't change. + fn is_unchanged_for(&self, user: &User) -> bool { + user.client_kdf_type == self.kdf + && user.client_kdf_iter == self.kdf_iterations + && user.client_kdf_memory == self.kdf_memory + && user.client_kdf_parallelism == self.kdf_parallelism + } +} + +/// Every existing item must be in the rotation, or it would be unreadable afterwards. +fn validate_rotation_data( + data: &RotateData, existing_ciphers: &[Cipher], existing_folders: &[Folder], existing_emergency_access: &[EmergencyAccess], existing_memberships: &[Membership], existing_sends: &[Send], - user: &User, ) -> EmptyResult { - if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type - || user.client_kdf_iter != data.account_unlock_data.master_password_unlock_data.kdf_iterations - || user.client_kdf_memory != data.account_unlock_data.master_password_unlock_data.kdf_memory - || user.client_kdf_parallelism != data.account_unlock_data.master_password_unlock_data.kdf_parallelism - || user.email != data.account_unlock_data.master_password_unlock_data.email - { - err!("Changing the kdf variant or email is not supported during key rotation"); - } - if user.public_key.as_ref() != Some(&data.account_keys.account_public_key) { - err!("Changing the asymmetric keypair is not possible during key rotation") - } - // Check that we're correctly rotating all the user's ciphers let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::>(); let provided_cipher_ids = data @@ -1241,12 +1381,8 @@ fn validate_keydata( // Check that we're correctly rotating all the user's emergency access keys let existing_emergency_access_ids = existing_emergency_access.iter().map(|ea| &ea.uuid).collect::>(); - let provided_emergency_access_ids = data - .account_unlock_data - .emergency_access_unlock_data - .iter() - .map(|ea| &ea.id) - .collect::>(); + let provided_emergency_access_ids = + data.unlock_data.emergency_access_unlock_data.iter().map(|ea| &ea.id).collect::>(); if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) { err!("All existing emergency access keys must be included in the rotation") } @@ -1255,7 +1391,7 @@ fn validate_keydata( let existing_reset_password_ids = existing_memberships.iter().map(|m| &m.org_uuid).collect::>(); let provided_reset_password_ids = data - .account_unlock_data + .unlock_data .organization_account_recovery_unlock_data .iter() .map(|rp| &rp.organization_id) @@ -1274,137 +1410,430 @@ fn validate_keydata( Ok(()) } -#[post("/accounts/key-management/rotate-user-account-keys", data = "")] -async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - // TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything. - let data: KeyData = data.into_inner(); +/// The keys a rotation re-shares the new user key with. The SDK needs all four lists, even when empty. +/// +/// Ref: +#[get("/accounts/key-management/key-rotation-data")] +async fn get_key_rotation_data(headers: Headers, conn: DbConn) -> JsonResult { + let user_id = &headers.user.uuid; - if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { - err!("Invalid password") + let mut organization_data = Vec::new(); + for membership in Membership::find_by_user(user_id, &conn).await { + // Only memberships actually enrolled in account recovery take part in the rotation. + if membership.reset_password_key.is_none() { + continue; + } + let Some(org) = Organization::find_by_uuid(&membership.org_uuid, &conn).await else { + continue; + }; + let Some(public_key) = org.public_key else { + continue; + }; + organization_data.push(json!({ + "organizationId": org.uuid, + "organizationName": org.name, + "organizationPublicKey": public_key, + "object": "organizationPasswordResetKeyData", + })); + } + + let mut emergency_access_data = Vec::new(); + for emergency_access in EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await { + // Without a stored key there is nothing to re-share. + if emergency_access.key_encrypted.is_none() { + continue; + } + let Some(grantee_id) = emergency_access.grantee_uuid.clone() else { + continue; + }; + let Some(grantee) = User::find_by_uuid(&grantee_id, &conn).await else { + continue; + }; + let Some(public_key) = grantee.public_key.clone() else { + continue; + }; + emergency_access_data.push(json!({ + "id": emergency_access.uuid, + "granteeId": grantee_id, + "granteeName": grantee.name, + "granteeEmail": grantee.email, + "publicKey": public_key, + "object": "emergencyAccessKeyData", + })); + } + + Ok(Json(json!({ + "organizationPasswordResetKeyData": organization_data, + "emergencyAccessKeyData": emergency_access_data, + "trustedDeviceKeyData": [], + "passkeyKeyData": [], + "object": "keyRotationData", + }))) +} + +/// Everything a rotation replaces, once each endpoint's wrapper has been peeled off. +struct RotateData { + account_keys: ValidatedAccountKeys, + account_data: RotateAccountData, + unlock_data: CommonUnlockData, + /// `None` from clients that predate key ids. + new_user_key_id: Option, + /// The new user key, wrapped by the master key. + wrapped_user_key: String, + /// Only for `rotate-user-account-keys`: the new password hash and hint. + new_password: Option<(String, Option)>, +} + +/// Upstream's `[StringLength(max)]`, which counts UTF-16 code units. +fn validate_max_length(value: &str, max: usize, field: &str) -> EmptyResult { + if value.encode_utf16().count() > max { + err!(format!("The field {field} must be a string with a maximum length of {max}.")) + } + Ok(()) +} + +impl KDFData { + /// Upstream's check that the parameters fit the KDF type. + fn validate_parameters(&self) -> EmptyResult { + let has_argon2_parameters = (self.kdf_memory.is_some(), self.kdf_parallelism.is_some()); + if self.kdf == UserKdfType::Pbkdf2 as i32 { + if has_argon2_parameters != (false, false) { + err!("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256") + } + } else if self.kdf == UserKdfType::Argon2id as i32 { + if has_argon2_parameters != (true, true) { + err!("KdfMemory and KdfParallelism must have values for Argon2id") + } + } else { + err!("Invalid KdfType") + } + Ok(()) } +} - // Rotating v2 keys, or upgrading to them, would leave an account that can't be unlocked here - if headers.user.is_v2() { - err!("Key rotation is not supported for v2 accounts") +impl MasterPasswordUnlockData { + /// Upstream's model validation. + /// + /// Ref: + fn validate(&self) -> EmptyResult { + self.kdf.validate_parameters()?; + if !crate::util::is_valid_email(&self.email) { + err!("The Email field is not a supported e-mail address format.") + } + validate_max_length(&self.email, 256, "Email")?; + required(Some(self.master_key_authentication_hash.clone()), "MasterKeyAuthenticationHash")?; + validate_max_length(&self.master_key_authentication_hash, 300, "MasterKeyAuthenticationHash")?; + required(Some(self.master_key_encrypted_user_key.clone()), "MasterKeyEncryptedUserKey")?; + if let Some(hint) = &self.master_password_hint { + validate_max_length(hint, 50, "MasterPasswordHint")?; + } + if self.master_password_salt.as_ref().is_some_and(|salt| salt.encode_utf16().count() > 256) { + err!("The field MasterPasswordSalt must be a string or array type with a maximum length of '256'.") + } + Ok(()) } - if !data.account_keys.user_key_encrypted_account_private_key.starts_with("2.") { - err!("The provided account private key was not wrapped with AES-256-CBC-HMAC") +} + +impl CommonUnlockData { + /// Upstream's model validation. + fn validate(&self) -> EmptyResult { + // Ref: + for emergency_access in &self.emergency_access_unlock_data { + if !emergency_access.wait_time_days.is_some_and(|days| (1..=i32::from(i16::MAX)).contains(&days)) { + err!("The field WaitTimeDays must be between 1 and 32767.") + } + } + if let Some(token) = &self.v2_upgrade_token { + required(Some(token.wrapped_user_key1.clone()), "WrappedUserKey1")?; + required(Some(token.wrapped_user_key2.clone()), "WrappedUserKey2")?; + } + Ok(()) } +} + +/// The model validation of the parts both rotation endpoints share, which upstream runs before anything else. +fn validate_rotation_request(account_data: &RotateAccountData, unlock_data: &CommonUnlockData) -> EmptyResult { + unlock_data.validate()?; // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. - Cipher::validate_cipher_data(&data.account_data.ciphers)?; + Cipher::validate_cipher_data(&account_data.ciphers)?; - let user_id = &headers.user.uuid; + for cipher in &account_data.ciphers { + if cipher.id.is_none() { + err!("The Id field is required.") + } + cipher.validate_type_data(cipher.is_blob())?; + } + if account_data.sends.iter().any(|send| send.id.is_none()) { + err!("The Id field is required.") + } + Ok(()) +} - // TODO: Ideally we'd do everything after this point in a single transaction. +#[post("/accounts/key-management/rotate-user-account-keys", data = "")] +async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { + let data: KeyData = data.into_inner(); + + let unlock_data = data.account_unlock_data.master_password_unlock_data; + validate_max_length(&data.old_master_key_authentication_hash, 300, "OldMasterKeyAuthenticationHash")?; + unlock_data.validate()?; + validate_rotation_request(&data.account_data, &data.account_unlock_data.common)?; + if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { + err!("Invalid password") + } + + // Ref: + let salt = unlock_data.master_password_salt.as_ref().unwrap_or(&unlock_data.email); + if !unlock_data.kdf.is_unchanged_for(&headers.user) || *salt != headers.user.master_password_salt() { + err!("The provided master password unlock data is not valid for this user.") + } + validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; + let password_hint = clean_password_hint(unlock_data.master_password_hint.as_ref()); + enforce_password_hint_setting(password_hint.as_ref())?; + + let mut common = data.account_unlock_data.common; + // Like upstream, this endpoint logs every session out, so an upgrade token is never kept + // Ref: + common.v2_upgrade_token = None; + + rotate_account( + RotateData { + account_keys: data.account_keys.validate()?, + account_data: data.account_data, + unlock_data: common, + new_user_key_id: data.new_user_key_id, + wrapped_user_key: unlock_data.master_key_encrypted_user_key, + new_password: Some((unlock_data.master_key_authentication_hash, password_hint)), + }, + headers, + conn, + nt, + ) + .await +} + +/// Both rotation endpoints: every check first, then the re-encrypted data and the new keys are saved. +async fn rotate_account(data: RotateData, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { + let user_id = &headers.user.uuid; let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await; let mut existing_folders = Folder::find_by_user(user_id, &conn).await; let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await; + // Like upstream, only the ones with a key take part + existing_emergency_access.retain(|ea| ea.key_encrypted.is_some()); let mut existing_memberships = Membership::find_by_user(user_id, &conn).await; // We only rotate the reset password key if it is set. existing_memberships.retain(|m| m.reset_password_key.is_some()); let mut existing_sends = Send::find_by_user(user_id, &conn).await; - validate_keydata( + validate_rotation_data( &data, &existing_ciphers, &existing_folders, &existing_emergency_access, &existing_memberships, &existing_sends, - &headers.user, )?; - // Update folder data + validate_rotation_account_keys(&data.account_keys, &headers.user, &conn).await?; + + // An upgrade keeps the sessions; for an account already on v2 the token means nothing and is dropped + let is_upgrade = data.unlock_data.v2_upgrade_token.is_some() && !headers.user.is_v2(); + let upgrade_token = match &data.unlock_data.v2_upgrade_token { + Some(token) if is_upgrade => Some(serde_json::to_string(token)?), + _ => None, + }; + + // An upgrade can't re-wrap the recovery keys without a trust prompt, so they're kept and get the token + // Ref: + for membership in &existing_memberships { + let has_key = data + .unlock_data + .organization_account_recovery_unlock_data + .iter() + .find(|rp| rp.organization_id == membership.org_uuid) + .and_then(|rp| rp.reset_password_key.as_deref()) + .is_some_and(|key| !key.trim().is_empty()); + if is_upgrade && has_key { + err!("Account recovery keys cannot be rotated during a V1 to V2 upgrade rotation.") + } + if !is_upgrade && !has_key { + err!("Account recovery keys cannot be null or empty during rotation.") + } + } + + // Ref: + for emergency_access in &existing_emergency_access { + if data + .unlock_data + .emergency_access_unlock_data + .iter() + .find(|ea| ea.id == emergency_access.uuid) + .is_some_and(|ea| ea.key_encrypted.is_none()) + { + err!("Emergency access keys cannot be set to null during rotation.") + } + } + + // Saving's own checks, so that they can't fail halfway + for cipher_data in data.account_data.ciphers.iter().filter(|c| c.organization_id.is_none()) { + if let Some(cipher) = existing_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) { + validate_rotated_cipher(cipher, cipher_data, &headers, &conn).await?; + } + } + + // TODO: Ideally we'd do everything after this point in a single transaction. + + // Update folder data, ignoring ids that aren't the user's, like upstream for folder_data in data.account_data.folders { // Skip `null` folder id entries. // See: https://github.com/bitwarden/clients/issues/8453 - if let Some(folder_id) = folder_data.id { - let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else { - err!("Folder doesn't exist") - }; - + if let Some(saved_folder) = folder_data.id.and_then(|id| existing_folders.iter_mut().find(|f| f.uuid == id)) { saved_folder.name = folder_data.name; saved_folder.save(&conn).await?; } } - // Update emergency access data - for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data { - let Some(saved_emergency_access) = - existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id) - else { - err!("Emergency access doesn't exist or is not owned by the user") - }; - - saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted); - saved_emergency_access.save(&conn).await?; + // Like upstream, the unlock data below comes from the first entry for each, as checked above + for saved_emergency_access in &mut existing_emergency_access { + if let Some(emergency_access_data) = + data.unlock_data.emergency_access_unlock_data.iter().find(|ea| ea.id == saved_emergency_access.uuid) + { + saved_emergency_access.key_encrypted.clone_from(&emergency_access_data.key_encrypted); + saved_emergency_access.save(&conn).await?; + } } - // Update reset password data - for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data { - let Some(membership) = - existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id) - else { - err!("Reset password doesn't exist") - }; - - membership.reset_password_key = Some(reset_password_data.reset_password_key); - membership.save(&conn).await?; + for membership in &mut existing_memberships { + if let Some(reset_password_data) = data + .unlock_data + .organization_account_recovery_unlock_data + .iter() + .find(|rp| rp.organization_id == membership.org_uuid) + { + if !is_upgrade { + membership.reset_password_key.clone_from(&reset_password_data.reset_password_key); + } + membership.v2_upgrade_token.clone_from(&upgrade_token); + membership.save(&conn).await?; + } } // Update send data for send_data in data.account_data.sends { - let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) else { - err!("Send doesn't exist") - }; - - // Like upstream, only the key changes on rotation - send.akey = send_data.key; - send.save(&conn).await?; + if let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) { + // Like upstream, only the key changes on rotation + send.akey = send_data.key; + send.save(&conn).await?; + } } // Update cipher data for cipher_data in data.account_data.ciphers { - if cipher_data.organization_id.is_none() { - let Some(saved_cipher) = + if cipher_data.organization_id.is_none() + && let Some(saved_cipher) = cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id)) - else { - err!("Cipher doesn't exist") - }; - - // Prevent triggering cipher updates via WebSockets by settings UpdateType::None - // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. - // We force the users to logout after the user has been saved to try and prevent these issues. - update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; + { + // No cipher pushes: the other sessions still hold the old user key + rotate_cipher_data(saved_cipher, cipher_data, &headers, &conn, &nt).await?; } } // Update user data let mut user = headers.user; + user.v2_upgrade_token = upgrade_token; - user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key); - user.set_password( - &data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash, - Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key), - true, - None, - &conn, - ) - .await?; + data.account_keys.apply(&mut user)?; + // The old id names a key that no longer exists, so it is replaced even when the new key has none. + user.key_id = data.new_user_key_id; - let save_result = user.save(&conn).await; + user.akey = data.wrapped_user_key; + if let Some((new_password_hash, new_password_hint)) = data.new_password { + // A password change ends every session, as `/accounts/password` does + user.password_hint = new_password_hint; + user.set_password(&new_password_hash, None, true, None, &conn).await?; + } else if !is_upgrade { + // An upgrade keeps the sessions alive, see `is_upgrade` above + user.reset_security_stamp_after_key_rotation(&conn).await?; + } + + // The key pair first: if saving the user then fails, an upgraded account stays v1 instead of broken + data.account_keys.save_signature_key_pair(&user.uuid, &conn).await?; + user.save(&conn).await?; // Prevent logging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. - nt.send_logout(&user, Some(&headers.device), &conn).await; + // On an upgrade, the reason lets clients with `pm-31050-no-logout-key-upgrade-rotation` sync instead + let reason = is_upgrade.then_some(LogOutReason::KeyRotation); + nt.send_logout_with_reason(&user, Some(&headers.device), reason, &conn).await; - save_result + Ok(()) +} + +/// Rotates the keys without changing the master password; like upstream, the session is the only proof. +/// +/// Ref: +#[post("/accounts/key-management/rotate-user-keys", data = "")] +async fn post_rotate_user_keys( + data: Json, + headers: Headers, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let data: RotateUserKeysData = data.into_inner(); + validate_rotation_request(&data.account_data, &data.unlock_data)?; + + // Ref: + let wrapped_user_key = match UnlockMethod::from_i32(data.unlock_method_data.unlock_method) { + Some(UnlockMethod::MasterPassword) => { + let (Some(unlock_data), None) = ( + data.unlock_method_data.master_password_unlock_data, + data.unlock_method_data.key_connector_key_wrapped_user_key, + ) else { + err!( + "Invalid MasterPassword unlock method request, MasterPasswordUnlockData must be provided and KeyConnectorKeyWrappedUserKey must be null" + ) + }; + required(Some(unlock_data.master_key_wrapped_user_key.clone()), "MasterKeyWrappedUserKey")?; + required(Some(unlock_data.salt.clone()), "Salt")?; + validate_max_length(&unlock_data.salt, 256, "Salt")?; + + // Ref: + if headers.user.password_hash.is_empty() || headers.user.akey.is_empty() { + err!("User is in an invalid state for master password key rotation.") + } + if unlock_data.salt != headers.user.master_password_salt() { + err!("Invalid master password salt.") + } + if !unlock_data.kdf.is_unchanged_for(&headers.user) { + err!("Invalid KDF settings.") + } + validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; + unlock_data.master_key_wrapped_user_key + } + Some(UnlockMethod::Tde) => err!("Trusted device encryption is not supported"), + Some(UnlockMethod::KeyConnector) => err!("Key connector is not supported"), + None => err!("Unrecognized unlock method"), + }; + + rotate_account( + RotateData { + account_keys: data.wrapped_account_cryptographic_state.validate()?, + account_data: data.account_data, + unlock_data: data.unlock_data, + new_user_key_id: data.new_user_key_id, + wrapped_user_key, + new_password: None, + }, + headers, + conn, + nt, + ) + .await } #[derive(Deserialize)] diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index a5198577..63e587bb 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -190,7 +190,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option EmptyResult { + let type_data = match self.r#type { + 1 => &self.login, + 2 => &self.secure_note, + 3 => &self.card, + 4 => &self.identity, + 5 => &self.ssh_key, + 6 => &self.bank_account, + 7 => &self.drivers_license, + 8 => &self.passport, + _ => err!("Invalid type"), + }; + if !is_blob && type_data.is_none() { + err!("Data missing") + } + Ok(()) + } + /// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob. /// /// Ref: @@ -544,6 +566,40 @@ pub async fn update_cipher_from_data( conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, +) -> EmptyResult { + enforce_personal_ownership_policy(Some(&data), headers, conn).await?; + save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut).await +} + +/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply. +pub async fn rotate_cipher_data( + cipher: &mut Cipher, + data: CipherData, + headers: &Headers, + conn: &DbConn, + nt: &Notify<'_>, +) -> EmptyResult { + save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None).await +} + +/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved +pub async fn validate_rotated_cipher( + cipher: &Cipher, + data: &CipherData, + headers: &Headers, + conn: &DbConn, +) -> EmptyResult { + validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await +} + +async fn save_cipher_data( + cipher: &mut Cipher, + data: CipherData, + headers: &Headers, + shared_to_collections: Option>, + conn: &DbConn, + nt: &Notify<'_>, + ut: UpdateType, ) -> EmptyResult { // Cleanup cipher data, like removing the 'Response' key. // This key is somewhere generated during Javascript so no way for us this fix this. @@ -559,8 +615,6 @@ pub async fn update_cipher_from_data( json_data } - enforce_personal_ownership_policy(Some(&data), headers, conn).await?; - let is_blob = data.is_blob(); data.validate_content(is_blob)?; validate_cipher_update(cipher, &data, headers, conn, ut).await?; @@ -1155,7 +1209,9 @@ async fn put_cipher_share_selected( err!("You must select at least one collection.") } for cipher in &data.ciphers { - cipher.validate_content(cipher.is_blob())?; + let is_blob = cipher.is_blob(); + cipher.validate_content(is_blob)?; + cipher.validate_type_data(is_blob)?; } for cipher in &data.ciphers { validate_encrypted_for_user(cipher, &headers.user.uuid)?; @@ -1223,7 +1279,9 @@ async fn share_cipher_by_uuid( }; // For the same reason, the other checks of saving - data.cipher.validate_content(data.cipher.is_blob())?; + let is_blob = data.cipher.is_blob(); + data.cipher.validate_content(is_blob)?; + data.cipher.validate_type_data(is_blob)?; validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?; let mut shared_to_collections = vec![]; diff --git a/src/api/identity.rs b/src/api/identity.rs index 09fdf896..15c439ef 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -545,7 +545,7 @@ async fn authenticated_response( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.email + "Salt": user.master_password_salt() }) } else { Value::Null @@ -691,7 +691,7 @@ async fn user_api_key_login( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.email + "Salt": user.master_password_salt() }) } else { Value::Null diff --git a/src/api/mod.rs b/src/api/mod.rs index 8baa221d..44637935 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -23,7 +23,7 @@ pub use crate::api::{ icons::routes as icons_routes, identity::routes as identity_routes, notifications::routes as notifications_routes, - notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, + notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, push::{ push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, unregister_push_device, diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 32204916..f91c6eaf 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -411,23 +411,35 @@ impl WebSocketUsers { } pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) { + self.send_logout_with_reason(user, acting_device, None, conn).await; + } + + /// A logout that clients may handle differently depending on `reason` + pub async fn send_logout_with_reason( + &self, + user: &User, + acting_device: Option<&Device>, + reason: Option, + conn: &DbConn, + ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let acting_device_id = acting_device.map(|d| d.uuid.clone()); - let data = create_update( - vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], - UpdateType::LogOut, - acting_device_id, - ); + let mut payload = + vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))]; + if let Some(reason) = reason { + payload.push(("Reason".into(), (reason as i32).into())); + } + let data = create_update(payload, UpdateType::LogOut, acting_device_id); if CONFIG.enable_websocket() { self.send_update(&user.uuid, &data).await; } if CONFIG.push_enabled() { - push_logout(user, acting_device, conn).await; + push_logout(user, acting_device, reason, conn).await; } } @@ -695,6 +707,14 @@ fn create_ping() -> Vec { serialize(&Value::Array(vec![6.into()])) } +/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet. +/// +/// Ref: +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum LogOutReason { + KeyRotation = 1, +} + // https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs #[derive(Copy, Clone, Eq, PartialEq)] pub enum UpdateType { diff --git a/src/api/push.rs b/src/api/push.rs index e87a0985..323d1310 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -12,7 +12,7 @@ use tokio::sync::RwLock; use crate::{ CONFIG, - api::{ApiResult, EmptyResult, UpdateType}, + api::{ApiResult, EmptyResult, LogOutReason, UpdateType}, db::{ DbConn, models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, @@ -188,7 +188,7 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device } } -pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) { +pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": user.uuid, @@ -198,7 +198,8 @@ pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbC "type": UpdateType::LogOut as i32, "payload": { "userId": user.uuid, - "date": format_date(&user.updated_at) + "date": format_date(&user.updated_at), + "reason": reason.map(|r| r as i32), }, "clientType": null, "installationId": null diff --git a/src/auth.rs b/src/auth.rs index 57720382..2686b5b0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -34,7 +34,7 @@ use crate::{ models::{ AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, - SendFileId, SendId, User, UserId, UserStampException, + SendFileId, SendId, User, UserId, }, }, error::Error, @@ -685,31 +685,12 @@ impl<'r> FromRequest<'r> for Headers { }; if user.security_stamp != claims.sstamp { - if let Some(stamp_exception) = - user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::(s).ok()) - { - let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { - err_handler!("Error getting current route for stamp exception") - }; + let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { + err_handler!("Error getting current route for stamp exception") + }; - // Check if the stamp exception has expired first. - // Then, check if the current route matches any of the allowed routes. - // After that check the stamp in exception matches the one in the claims. - if Utc::now().timestamp() > stamp_exception.expire { - // If the stamp exception has been expired remove it from the database. - // This prevents checking this stamp exception for new requests. - let mut user = user; - user.reset_stamp_exception(); - if let Err(e) = user.save(&conn).await { - error!("Error updating user: {e:#?}"); - } - err_handler!("Stamp exception is expired") - } else if !stamp_exception.routes.contains(¤t_route.to_owned()) { - err_handler!("Invalid security stamp: Current route and exception route do not match") - } else if stamp_exception.security_stamp != claims.sstamp { - err_handler!("Invalid security stamp for matched stamp exception") - } - } else { + // Expired exceptions are only dropped on reset; writing them back here could undo a newer reset + if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) { err_handler!("Invalid security stamp") } } diff --git a/src/config.rs b/src/config.rs index 4599efd7..2c44e60d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1435,8 +1435,11 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ // Key Management Team "biometrics-sdk-ipc", "windows-native-credential-sync", + "pm-30144-sdk-key-rotation", + "pm-31050-no-logout-key-upgrade-rotation", "enable-account-encryption-v2-jit-password-registration", "pm-27278-v2-password-registration", + "force-upgrade-v2-encryption", // Mobile Team "pm-34171-card-scanner", // Platform Team diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 8521557c..5131a0fd 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -40,5 +40,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; -pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException}; +pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType}; pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 72684782..47262ae6 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -54,6 +54,8 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + /// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us. + pub v2_upgrade_token: Option, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -271,6 +273,7 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, + v2_upgrade_token: None, } } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index e7b639c6..71654cc7 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -6,6 +6,7 @@ use serde_json::Value; use crate::{ CONFIG, api::EmptyResult, + auth::DEFAULT_ACCESS_VALIDITY, crypto, db::{ DbConn, @@ -77,6 +78,8 @@ pub struct User { pub signed_public_key: Option, pub security_state: Option, pub security_version: Option, + /// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us. + pub v2_upgrade_token: Option, } #[derive(Identifiable, Queryable, Insertable)] @@ -105,13 +108,28 @@ enum UserStatus { _Disabled = 2, } +/// A previous security stamp that is still accepted, until `expire`. #[derive(Serialize, Deserialize)] pub struct UserStampException { - pub routes: Vec, + /// The routes the stamp is still accepted on, or `None` for any route. + pub routes: Option>, pub security_stamp: String, pub expire: i64, } +impl UserStampException { + pub fn is_expired(&self) -> bool { + Utc::now().timestamp() > self.expire + } + + /// Whether this exception lets a token carrying `security_stamp` through on `route`. + pub fn allows(&self, security_stamp: &str, route: &str) -> bool { + !self.is_expired() + && self.security_stamp == security_stamp + && self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route)) + } +} + /// Local methods impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; @@ -168,6 +186,7 @@ impl User { signed_public_key: None, security_state: None, security_version: None, + v2_upgrade_token: None, } } @@ -184,6 +203,7 @@ impl User { ) } + /// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email pub fn master_password_salt(&self) -> String { self.email.trim().to_lowercase() } @@ -238,10 +258,33 @@ impl User { pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult { self.security_stamp = get_uuid(); + // Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace + let mut exceptions = self.stamp_exceptions(); + exceptions.retain(|e| e.routes.is_some() && !e.is_expired()); + self.set_stamp_exceptions(&exceptions); Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?; Ok(()) } + /// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh. + pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult { + let previous_stamp = self.security_stamp.clone(); + let mut grace: Vec = + self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect(); + + self.reset_security_stamp(conn).await?; + + grace.push(UserStampException { + routes: None, + security_stamp: previous_stamp, + expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(), + }); + let mut exceptions = self.stamp_exceptions(); + exceptions.extend(grace); + self.set_stamp_exceptions(&exceptions); + Ok(()) + } + /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// /// # Arguments @@ -250,17 +293,30 @@ impl User { /// After these 2 minutes this stamp will expire. /// pub fn set_stamp_exception(&mut self, route_exception: Vec) { - let stamp_exception = UserStampException { - routes: route_exception, + self.set_stamp_exceptions(&[UserStampException { + routes: Some(route_exception), security_stamp: self.security_stamp.clone(), expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(), + }]); + } + + /// The previous security stamps that are still accepted, expired ones included. + pub fn stamp_exceptions(&self) -> Vec { + let Some(stored) = self.stamp_exception.as_deref() else { + return Vec::new(); }; - self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default()); + // Before key rotations kept sessions alive, only a single route exception was stored. + serde_json::from_str::>(stored) + .or_else(|_| serde_json::from_str::(stored).map(|e| vec![e])) + .unwrap_or_default() } - /// Resets the stamp_exception to prevent re-use of the previous security-stamp - pub fn reset_stamp_exception(&mut self) { - self.stamp_exception = None; + fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) { + self.stamp_exception = if exceptions.is_empty() { + None + } else { + Some(serde_json::to_string(&exceptions).unwrap_or_default()) + }; } pub fn display_name(&self) -> &str { @@ -331,6 +387,10 @@ impl User { }) } + pub fn v2_upgrade_token_json(&self) -> Option { + self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok()) + } + pub async fn to_json(&self, conn: &DbConn) -> Value { let mut orgs_json = Vec::new(); for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { diff --git a/src/db/schema.rs b/src/db/schema.rs index 70580596..c909de58 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -222,6 +222,7 @@ table! { signed_public_key -> Nullable, security_state -> Nullable, security_version -> Nullable, + v2_upgrade_token -> Nullable, } } @@ -259,6 +260,7 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, + v2_upgrade_token -> Nullable, } }