diff --git a/.env.template b/.env.template index 84b7e732..2d5c5d8e 100644 --- a/.env.template +++ b/.env.template @@ -411,9 +411,6 @@ ## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0) ## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers. ## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet. -## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1) -## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0) -## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2025.11.0 or newer and send their version, which rules out some older and third-party clients. # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= ## Require new device emails. When a user logs in an email is required to be sent. diff --git a/.typos.toml b/.typos.toml index dabcadb4..87c0c4a6 100644 --- a/.typos.toml +++ b/.typos.toml @@ -14,8 +14,9 @@ extend-ignore-re = [ # In SMTP it's called HELO, so ignore it "(?i)helo_name", "Server name sent during.+HELO", - # COSE Is short for CBOR Object Signing and Encryption - "(?i)cose", + # COSE Is short for CBOR Object Signing and Encryption, ignore these specific items + "COSEKey", + "COSEAlgorithm", # Ignore this specific string as it's valid "Ensure they are valid OTPs", # This word is misspelled upstream diff --git a/Cargo.toml b/Cargo.toml index 1717ec63..6b4e8965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] } +diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] } diesel_migrations = "2.3.2" derive_more = { version = "2.1.1", features = [ diff --git a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/mysql/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index 09abbaee..00000000 --- a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the --- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT. -ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL; diff --git a/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index 73edfa6a..00000000 --- a/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,14 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid CHAR(36) NOT NULL PRIMARY KEY, - user_uuid CHAR(36) NOT NULL UNIQUE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE -); diff --git a/migrations/mysql/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/mysql/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -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-08-120000_cipher_data_longtext/down.sql b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index aab2e86a..00000000 --- a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- TEXT has no size limit here, only MySQL needed the change. --- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op. -SELECT 1; diff --git a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index d3186ef0..00000000 --- a/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid CHAR(36) NOT NULL PRIMARY KEY, - user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL -); diff --git a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/postgresql/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -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-08-120000_cipher_data_longtext/down.sql b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql deleted file mode 100644 index f18eb816..00000000 --- a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1 +0,0 @@ --- TEXT has no size limit here, only MySQL needed the change diff --git a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql deleted file mode 100644 index 49700440..00000000 --- a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS user_signature_key_pairs; - -ALTER TABLE users DROP COLUMN signed_public_key; -ALTER TABLE users DROP COLUMN security_state; -ALTER TABLE users DROP COLUMN security_version; diff --git a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql deleted file mode 100644 index c15e5b5e..00000000 --- a/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN signed_public_key TEXT; -ALTER TABLE users ADD COLUMN security_state TEXT; -ALTER TABLE users ADD COLUMN security_version INTEGER; - -CREATE TABLE user_signature_key_pairs ( - uuid TEXT NOT NULL PRIMARY KEY, - user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, - signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 - signing_key TEXT NOT NULL, - verifying_key TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -); diff --git a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/down.sql b/migrations/sqlite/2026-11-02-120000_reset_user_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql b/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql deleted file mode 100644 index eeffa854..00000000 --- a/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Key rotations before this kept the id of the old user key, so every new --- cipher was refused from then on. Stale ids can't be told apart, so clear --- all of them; clients record their current key's id again when it's empty. -UPDATE users SET key_id = NULL; diff --git a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql deleted file mode 100644 index 89e12c15..00000000 --- a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users DROP COLUMN v2_upgrade_token; -ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql deleted file mode 100644 index 45c58491..00000000 --- a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; -ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/src/api/admin.rs b/src/api/admin.rs index 40df44ae..5e51306c 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -445,7 +445,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notif if res.is_ok() { nt.send_logout(&user, None, &conn).await; if has_push_device { - push_logout_to_relay(&user, None, None); + push_logout_to_relay(&user, None); } } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 699cbe95..69918212 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use chrono::Utc; -use num_traits::FromPrimitive; use rocket::{ http::Status, request::{FromRequest, Outcome, Request}, @@ -12,7 +11,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - AnonymousNotify, ApiResult, EmptyResult, JsonResult, LogOutReason, Notify, PasswordOrOtpData, + AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{accept_org_invite, log_user_event, two_factor::email}, master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device, }, @@ -23,8 +22,8 @@ use crate::{ models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, - MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, - User, UserId, UserKdfType, UserSignatureKeyPair, + MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, + UserKdfType, }, }, mail, @@ -32,7 +31,7 @@ use crate::{ }; use super::{ - ciphers::{CipherData, rotate_cipher_data, validate_rotated_cipher}, + ciphers::{CipherData, update_cipher_from_data}, sends::SendData, }; @@ -43,16 +42,13 @@ pub fn routes() -> Vec { post_profile, put_avatar, get_public_keys, - get_account_public_keys, get_keys, post_keys, 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, @@ -117,9 +113,6 @@ pub struct RegisterData { #[serde(alias = "userAsymmetricKeys")] keys: Option, - // Supersedes `keys`, and the only way a v2 account can be registered. - account_keys: Option, - master_password_hint: Option, organization_user_id: Option, @@ -131,6 +124,36 @@ pub struct RegisterData { org_invite_token: Option, } +impl RegisterData { + fn hash(&self) -> String { + self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned() + } + + fn kdf(&self) -> &KDFData { + self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf) + } + + fn key(&self) -> String { + self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned() + } + + // When comparing with salt, email need to be normalized: + // - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171 + fn unprocessable(&self) -> bool { + let mut unprocessable = false; + *self.compat.fold( + |_| &false, + |rdcu| { + let email = self.email.trim().to_lowercase(); + unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf + || rdcu.master_password_authentication.salt != email + || rdcu.master_password_unlock.salt != email; + &unprocessable + }, + ) + } +} + #[derive(Debug, Deserialize)] struct RegisterDataOld { #[serde(flatten)] @@ -170,42 +193,6 @@ impl RegisterDataCompat { RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu), } } - - fn hash(&self) -> String { - self.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned() - } - - fn kdf(&self) -> &KDFData { - self.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf) - } - - fn key(&self) -> String { - self.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned() - } - - /// The id of the user key, which only the current format carries. - fn key_id(&self) -> Option { - match self { - RegisterDataCompat::RegisterDataOld(_) => None, - RegisterDataCompat::RegisterDataCur(rdcu) => rdcu.master_password_unlock.contained_key_id.clone(), - } - } - - // When comparing with salt, email need to be normalized: - // - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171 - fn unprocessable(&self, email: &str) -> bool { - let mut unprocessable = false; - *self.fold( - |_| &false, - |rdcu| { - let email = email.trim().to_lowercase(); - unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf - || rdcu.master_password_authentication.salt != email - || rdcu.master_password_unlock.salt != email; - &unprocessable - }, - ) - } } #[derive(Debug, Deserialize)] @@ -215,214 +202,6 @@ struct KeysData { public_key: String, } -/// Upstream's implicit `[Required]` on a non-nullable string: present and not blank. -fn required(value: Option, field: &str) -> ApiResult { - match value { - Some(value) if !value.trim().is_empty() => Ok(value), - _ => err!(format!("The {field} field is required.")), - } -} - -/// The `accountKeys` payload: a v1 key pair, or v2 with a signature key pair and security state. -/// -/// Ref: -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AccountKeysData { - // Required like upstream, but only used without `public_key_encryption_key_pair` - user_key_encrypted_account_private_key: Option, - account_public_key: Option, - - public_key_encryption_key_pair: Option, - signature_key_pair: Option, - security_state: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PublicKeyEncryptionKeyPairData { - wrapped_private_key: String, - public_key: String, - signed_public_key: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SignatureKeyPairData { - signature_algorithm: String, - wrapped_signing_key: String, - verifying_key: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SecurityStateData { - security_state: String, - security_version: i32, -} - -struct ValidatedAccountKeys { - private_key: String, - public_key: String, - v2: Option, -} - -struct ValidatedV2AccountKeys { - signed_public_key: String, - signing_key: String, - verifying_key: String, - signature_algorithm: SignatureAlgorithm, - security_state: String, - security_version: i32, -} - -impl AccountKeysData { - /// Upstream's model checks, plus the v2 fields all or none: no client can unlock a partial v2 state. - fn validate(self) -> ApiResult { - let top_private_key = - required(self.user_key_encrypted_account_private_key, "UserKeyEncryptedAccountPrivateKey")?; - let top_public_key = required(self.account_public_key, "AccountPublicKey")?; - let (private_key, public_key, signed_public_key) = match self.public_key_encryption_key_pair { - Some(key_pair) => ( - required(Some(key_pair.wrapped_private_key), "WrappedPrivateKey")?, - required(Some(key_pair.public_key), "PublicKey")?, - key_pair.signed_public_key, - ), - // Older clients only send the top-level fields, which are always v1 - None => (top_private_key, top_public_key, None), - }; - - if let Some(signature_key_pair) = &self.signature_key_pair { - required(Some(signature_key_pair.signature_algorithm.clone()), "SignatureAlgorithm")?; - required(Some(signature_key_pair.wrapped_signing_key.clone()), "WrappedSigningKey")?; - required(Some(signature_key_pair.verifying_key.clone()), "VerifyingKey")?; - } - if let Some(security_state) = &self.security_state { - required(Some(security_state.security_state.clone()), "SecurityState")?; - if security_state.security_state.encode_utf16().count() > 10_000 { - err!("The field SecurityState must be a string with a maximum length of 10000.") - } - } - - let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) { - (Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => { - // Upstream fails on a null one, but takes an empty one that no client can unlock with - let signed_public_key = required(Some(signed_public_key), "SignedPublicKey")?; - let Some(signature_algorithm) = SignatureAlgorithm::parse(&signature_key_pair.signature_algorithm) - else { - err!(format!( - "Unsupported signature algorithm: {}", - signature_key_pair.signature_algorithm.escape_debug() - )) - }; - - Some(ValidatedV2AccountKeys { - signed_public_key, - signing_key: signature_key_pair.wrapped_signing_key, - verifying_key: signature_key_pair.verifying_key, - signature_algorithm, - security_state: security_state.security_state, - security_version: security_state.security_version, - }) - } - (None, None, None) => None, - _ => err!( - "Invalid account keys: the signed public key, signature key pair and security state must either all be present or all be absent" - ), - }; - - Ok(ValidatedAccountKeys { - private_key, - public_key, - v2, - }) - } -} - -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 { - private_key: keys.encrypted_private_key, - public_key: keys.public_key, - v2: None, - } - } -} - -impl ValidatedAccountKeys { - /// The keys of a registration: `accountKeys` only for a v2 account, like upstream. - /// - /// Ref: - fn for_registration(account_keys: Option, keys: Option) -> ApiResult { - if let Some(account_keys) = account_keys { - let account_keys = account_keys.validate()?; - if account_keys.is_v2() { - return Ok(account_keys); - } - } - let Some(keys) = keys else { - err!("PublicKey and WrappedPrivateKey not found in RequestModel") - }; - Ok(keys.into()) - } - - fn is_v2(&self) -> bool { - self.v2.is_some() - } - - /// Sets the keys on the user. Save [`Self::save_signature_key_pair`] before the user: a v2 user without its key - /// pair can't unlock, while a key pair is ignored until its user is v2, and replaced on the next try. - fn apply(&self, user: &mut User) -> EmptyResult { - user.private_key = Some(self.private_key.clone()); - user.public_key = Some(self.public_key.clone()); - - user.signed_public_key = self.v2.as_ref().map(|v2| v2.signed_public_key.clone()); - user.security_state = self.v2.as_ref().map(|v2| v2.security_state.clone()); - user.security_version = self.v2.as_ref().map(|v2| v2.security_version); - - Ok(()) - } - - /// Saves the signature key pair, which needs the user to exist for its foreign key. - async fn save_signature_key_pair(&self, user_id: &UserId, conn: &DbConn) -> EmptyResult { - // Skip if the account is v1, since v1 accounts don't have a signature key pair. - let Some(v2) = &self.v2 else { - return Ok(()); - }; - - let mut key_pair = match UserSignatureKeyPair::find_by_user(user_id, conn).await { - Some(mut key_pair) => { - key_pair.signature_algorithm = v2.signature_algorithm as i32; - key_pair.signing_key.clone_from(&v2.signing_key); - key_pair.verifying_key.clone_from(&v2.verifying_key); - key_pair - } - None => UserSignatureKeyPair::new( - user_id.clone(), - v2.signature_algorithm, - v2.signing_key.clone(), - v2.verifying_key.clone(), - ), - }; - key_pair.save(conn).await - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MasterPasswordAuthentication { @@ -443,19 +222,17 @@ pub struct MasterPasswordUnlock { #[serde(alias = "masterKeyWrappedUserKey")] key: String, - contained_key_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetPasswordData { #[serde(flatten)] - compat: RegisterDataCompat, + kdf: KDFData, + key: String, keys: Option, - // Supersedes `keys`, and the only way a v2 account can be initialized here. - account_keys: Option, - + master_password_hash: String, master_password_hint: Option, org_identifier: Option, } @@ -497,7 +274,7 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { let mut name = None; let mut pending_emergency_access = None; - if data.compat.unprocessable(&data.email) { + if data.unprocessable() { err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); } @@ -566,15 +343,12 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { err!("The field Name must be a string with a maximum length of 50."); } - // Check against the password hint setting and the keys here so if they fail, - // the user can retry without losing their invitation below. + // Check against the password hint setting here so if it fails, the user + // can retry without losing their invitation below. let password_hint = clean_password_hint(data.master_password_hint.as_ref()); enforce_password_hint_setting(password_hint.as_ref())?; - let account_keys = ValidatedAccountKeys::for_registration(data.account_keys, data.keys)?; - let existing_user = User::find_by_mail(&email, &conn).await; - let is_new_user = existing_user.is_none(); - let mut user = match existing_user { + let mut user = match User::find_by_mail(&email, &conn).await { Some(user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") @@ -619,9 +393,9 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { // Make sure we don't leave a lingering invitation. Invitation::take(&email, &conn).await; - set_kdf_data(&mut user, data.compat.kdf())?; + set_kdf_data(&mut user, data.kdf())?; - user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?; + user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?; user.password_hint = password_hint; // Add extra fields if present @@ -629,10 +403,9 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { user.name = name; } - account_keys.apply(&mut user)?; - // Like upstream, only a v2 registration records the key id; v1 accounts report it through `user-key-id` - if account_keys.is_v2() { - user.key_id = data.compat.key_id(); + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); } if email_verified { @@ -654,14 +427,6 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { } } - // The key pair first, as in a rotation, but its foreign key needs the user: a new one is saved before without a - // password or keys, like an invited user, so a failure in between leaves a registration that can be retried. - if is_new_user && account_keys.is_v2() { - let mut unregistered = User::new(&user.email, None); - unregistered.uuid.clone_from(&user.uuid); - unregistered.save(&conn).await?; - } - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; user.save(&conn).await?; // accept any open emergency access invitations @@ -676,81 +441,25 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { }))) } -/// Upstream's shape checks of the set-password body, on the raw JSON that the untagged compat would hide. -/// -/// Ref: -fn validate_set_password_shape(body: &Value) -> EmptyResult { - let has = |name: &str| { - body.as_object().is_some_and(|o| o.iter().any(|(k, v)| k.eq_ignore_ascii_case(name) && !v.is_null())) - }; - - if has("accountKeys") && has("keys") { - err!("Cannot specify both AccountKeys and Keys. Provide exactly one keypair.") - } - let (authentication, unlock) = (has("masterPasswordAuthentication"), has("masterPasswordUnlock")); - if authentication != unlock { - err!( - "Must provide both MasterPasswordAuthentication and MasterPasswordUnlock together. Cannot provide one without the other." - ) - } - if authentication && (has("masterPasswordHash") || has("key") || has("kdf")) { - err!( - "Cannot mix modern (MasterPasswordAuthentication/MasterPasswordUnlock) and legacy (MasterPasswordHash/Key/Kdf) fields. Provide one shape or the other." - ) - } - Ok(()) -} - #[post("/accounts/set-password", data = "")] -async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data = data.into_inner(); - validate_set_password_shape(&data)?; - let Ok(data) = serde_json::from_value::(data) else { - err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code) - }; - if data.master_password_hint.as_ref().is_some_and(|h| h.encode_utf16().count() > 50) { - err!("The field MasterPasswordHint must be a string with a maximum length of 50.") - } +async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: SetPasswordData = data.into_inner(); let mut user = headers.user; if user.private_key.is_some() || !user.password_hash.is_empty() { err!("Account already initialized, cannot set password") } - if data.compat.unprocessable(&user.email) { - err_code!("Unexpected SetPasswordData format", Status::UnprocessableEntity.code); - } - // Check against the password hint setting here so if it fails, // the user can retry without losing their invitation below. let password_hint = clean_password_hint(data.master_password_hint.as_ref()); enforce_password_hint_setting(password_hint.as_ref())?; - // Like upstream, `accountKeys` only through the v2 JIT flow - // Ref: - let account_keys = match data.account_keys { - Some(account_keys) => { - if !matches!(data.compat, RegisterDataCompat::RegisterDataCur(_)) - || !crate::util::is_client_feature_flag_enabled( - "enable-account-encryption-v2-jit-password-registration", - ) - { - err!("Request includes V2 AccountKeys but V2 encryption is not enabled.") - } - let account_keys = account_keys.validate()?; - if !account_keys.is_v2() { - err!("AccountKeys are only supported for V2 encryption.") - } - Some(account_keys) - } - None => data.keys.map(ValidatedAccountKeys::from), - }; - - set_kdf_data(&mut user, data.compat.kdf())?; + set_kdf_data(&mut user, &data.kdf)?; user.set_password( - &data.compat.hash(), - Some(data.compat.key()), + &data.master_password_hash, + Some(data.key), false, Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp &conn, @@ -758,12 +467,9 @@ async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> .await?; user.password_hint = password_hint; - if let Some(ref account_keys) = account_keys { - account_keys.apply(&mut user)?; - // As in `register`, only a v2 account records the user key id here - if account_keys.is_v2() { - user.key_id = data.compat.key_id(); - } + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); } if let Some(identifier) = data.org_identifier @@ -789,10 +495,6 @@ async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - // The key pair first, as in a rotation: if saving the user then fails, the password isn't set either - if let Some(account_keys) = account_keys { - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; - } user.save(&conn).await?; Ok(Json(json!({ @@ -875,19 +577,8 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js }))) } -#[get("/users//keys")] -async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult { - let user = match User::find_by_uuid(&user_id, &conn).await { - Some(user) if user.public_key.is_some() => user, - Some(_) => err_code!("User has no public_key", Status::NotFound.code), - None => err_code!("User doesn't exist", Status::NotFound.code), - }; - - Ok(Json(user.public_keys_json(&conn).await)) -} - #[get("/accounts/keys")] -async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult { +fn get_keys(headers: Headers) -> JsonResult { let user = headers.user; // The SDK reads a 404 as the user having no key pair yet @@ -899,67 +590,29 @@ async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult { "key": (!user.akey.is_empty()).then_some(&user.akey), "publicKey": user.public_key, "privateKey": user.private_key, - "accountKeys": user.account_keys_json(&conn).await, + "accountKeys": user.account_keys_json(), "object": "keys", }))) } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PostKeysData { - // Required like upstream, even next to `accountKeys` - public_key: Option, - encrypted_private_key: Option, - account_keys: Option, - // The id of the user key these account keys belong to, only honored for v2 `accountKeys` - user_key_id: Option, -} - #[post("/accounts/keys", data = "")] -async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PostKeysData = data.into_inner(); +async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: KeysData = data.into_inner(); let mut user = headers.user; - // Only for an account without keys, replacing them is what a key rotation does if user.private_key.is_some() || user.public_key.is_some() { err!("User has existing keypair") } - // Ref: - let public_key = required(data.public_key, "PublicKey")?; - let encrypted_private_key = required(data.encrypted_private_key, "EncryptedPrivateKey")?; + user.private_key = Some(data.encrypted_private_key); + user.public_key = Some(data.public_key); - // `accountKeys` supersedes the flat keys when both are sent - let account_keys = match data.account_keys { - Some(account_keys) => { - let account_keys = account_keys.validate()?; - if !account_keys.is_v2() { - err!("AccountKeys are only supported for V2 encryption.") - } - // A client that predates key ids sends none, and reports it later through `user-key-id` - if data.user_key_id.is_some() { - user.key_id = data.user_key_id; - } - account_keys - } - None => KeysData { - encrypted_private_key, - public_key, - } - .into(), - }; - - account_keys.apply(&mut user)?; - // The key pair first, as in a rotation: if saving the user then fails, the account stays without keys - account_keys.save_signature_key_pair(&user.uuid, &conn).await?; user.save(&conn).await?; Ok(Json(json!({ - "key": (!user.akey.is_empty()).then_some(&user.akey), "privateKey": user.private_key, "publicKey": user.public_key, - "accountKeys": user.account_keys_json(&conn).await, "object":"keys" }))) } @@ -994,13 +647,10 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("KDF settings must be equal for authentication and unlock") } - let salt = user.master_password_salt(); - if salt != authentication_data.salt || salt != unlock_data.salt { + if user.email != authentication_data.salt || user.email != unlock_data.salt { err!("Invalid master password salt") } - validate_key_id_unchanged(&user, &unlock_data)?; - (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { (new_master_password_hash, new_key) @@ -1103,30 +753,6 @@ pub(super) struct UnlockData { salt: String, kdf: KDFData, pub(super) master_key_wrapped_user_key: String, - contained_key_id: Option, -} - -/// A password or KDF change keeps the user key, so its key id must be the current one, when both are known. -/// -/// Ref: -fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResult { - if let (Some(current), Some(contained)) = (&user.key_id, &unlock_data.contained_key_id) - && current != contained - { - err!("Invalid user key sent in master-password unlock data.") - } - 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)] @@ -1147,8 +773,6 @@ async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: data.authentication_data.check(&headers.user, &data.unlock_data)?; - validate_key_id_unchanged(&headers.user, &data.unlock_data)?; - let mut user = headers.user; set_kdf_data(&mut user, &data.unlock_data.kdf)?; @@ -1183,71 +807,50 @@ struct UpdateFolderData { #[serde(rename_all = "camelCase")] struct UpdateEmergencyAccessData { id: EmergencyAccessId, - key_encrypted: Option, - // Only validated, upstream doesn't store it either - wait_time_days: Option, + key_encrypted: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateResetPasswordData { organization_id: OrganizationId, - // Absent in a v1 -> v2 upgrade, which keeps the key the organization already holds - reset_password_key: Option, + reset_password_key: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct KeyData { account_unlock_data: RotateAccountUnlockData, - account_keys: AccountKeysData, + account_keys: RotateAccountKeys, 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, - #[serde(flatten)] - common: CommonUnlockData, + organization_account_recovery_unlock_data: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MasterPasswordUnlockData { - #[serde(flatten)] - kdf: KDFData, + kdf_type: i32, + kdf_iterations: i32, + kdf_parallelism: Option, + kdf_memory: Option, 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 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, +struct RotateAccountKeys { + user_key_encrypted_account_private_key: String, + account_public_key: String, } #[derive(Deserialize)] @@ -1258,116 +861,27 @@ struct RotateAccountData { sends: Vec, } -/// 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, +fn validate_keydata( + data: &KeyData, 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 @@ -1392,8 +906,12 @@ fn validate_rotation_data( // 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.unlock_data.emergency_access_unlock_data.iter().map(|ea| &ea.id).collect::>(); + let provided_emergency_access_ids = data + .account_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") } @@ -1402,7 +920,7 @@ fn validate_rotation_data( let existing_reset_password_ids = existing_memberships.iter().map(|m| &m.org_uuid).collect::>(); let provided_reset_password_ids = data - .unlock_data + .account_unlock_data .organization_account_recovery_unlock_data .iter() .map(|rp| &rp.organization_id) @@ -1421,427 +939,129 @@ fn validate_rotation_data( Ok(()) } -/// 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; - - 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(()) - } -} - -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(()) - } -} - -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(&account_data.ciphers)?; - - if account_data.ciphers.iter().any(|cipher| cipher.id.is_none()) { - err!("The Id field is required.") - } - if account_data.sends.iter().any(|send| send.id.is_none()) { - err!("The Id field is required.") - } - 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(); - 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())?; + // 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)?; - 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; + + // TODO: Ideally we'd do everything after this point in a single transaction. + 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_rotation_data( + validate_keydata( &data, &existing_ciphers, &existing_folders, &existing_emergency_access, &existing_memberships, &existing_sends, + &headers.user, )?; - 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 + // Update folder data for folder_data in data.account_data.folders { // Skip `null` folder id entries. // See: https://github.com/bitwarden/clients/issues/8453 - if let Some(saved_folder) = folder_data.id.and_then(|id| existing_folders.iter_mut().find(|f| f.uuid == id)) { + 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") + }; + saved_folder.name = folder_data.name; saved_folder.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 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?; } - 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 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?; } // Update send data for send_data in data.account_data.sends { - 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?; - } + 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?; } // 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)) - { - // No cipher pushes: the other sessions still hold the old user key - rotate_cipher_data(saved_cipher, cipher_data, &headers, &conn, &nt).await?; + 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?; } } // Update user data let mut user = headers.user; - user.v2_upgrade_token = upgrade_token; - 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; + 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?; - 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?; + let save_result = 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. - // On an upgrade, the reason lets clients sync instead - let reason = is_upgrade.then_some(LogOutReason::KeyRotation); - nt.send_logout_with_reason(&user, Some(&headers.device), reason, &conn).await; + nt.send_logout(&user, Some(&headers.device), &conn).await; - 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 + save_result } #[derive(Deserialize)] @@ -1853,9 +1073,8 @@ struct KeyIdData { #[post("/accounts/key-management/user-key-id", data = "")] async fn post_user_key(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let mut user = headers.user; - // Only a backfill for accounts that have none. Afterwards the id changes with the key, in a rotation. if user.key_id.is_some() { - err!("User key id is already set.") + err_code!("Unexpected data", Status::UnprocessableEntity.code); } user.key_id = Some(data.into_inner().user_key_id); @@ -2107,7 +1326,7 @@ async fn post_delete_recover_token(data: Json, conn: DbC nt.send_logout(&user, None, &conn).await; if has_push_device { - push_logout_to_relay(&user, None, None); + push_logout_to_relay(&user, None); } Ok(()) } @@ -2135,7 +1354,7 @@ async fn delete_account(data: Json, headers: Headers, conn: D nt.send_logout(&user, None, &conn).await; if has_push_device { - push_logout_to_relay(&user, None, None); + push_logout_to_relay(&user, None); } Ok(()) } @@ -2739,10 +1958,10 @@ mod tests { ) .unwrap(); - assert!(!data.compat.unprocessable(&data.email)); - assert_eq!(data.compat.hash(), "hash"); - assert_eq!(data.compat.key(), "key"); - assert_eq!(data.compat.kdf().kdf_iterations, 600_000); + assert!(!data.unprocessable()); + assert_eq!(data.hash(), "hash"); + assert_eq!(data.key(), "key"); + assert_eq!(data.kdf().kdf_iterations, 600_000); assert!(data.keys.is_some()); assert_eq!(data.email_verification_token.as_deref(), Some("token")); } @@ -2764,10 +1983,10 @@ mod tests { ) .unwrap(); - assert!(!data.compat.unprocessable(&data.email)); - assert_eq!(data.compat.hash(), "hash"); - assert_eq!(data.compat.key(), "key"); - assert_eq!(data.compat.kdf().kdf_iterations, 600_000); + assert!(!data.unprocessable()); + assert_eq!(data.hash(), "hash"); + assert_eq!(data.key(), "key"); + assert_eq!(data.kdf().kdf_iterations, 600_000); assert!(data.keys.is_some()); } } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index f70f2ccb..97f5fd49 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -24,7 +24,6 @@ use crate::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId, - is_data_blob_encrypted, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -190,22 +189,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option, - // Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent - pub encrypted_for: Option, + pub encrypted_for: UserId, // Added in web-v2025.6.0 // Added in web-v2025.8.1, Optional for compat pub encrypted_by_key_id: Option, @@ -293,8 +284,7 @@ pub struct CipherData { Passport = 8 */ pub r#type: i32, - // Absent on a blob-encrypted cipher, whose name is sealed inside `data` - pub name: Option, + pub name: String, pub notes: Option, fields: Option, @@ -308,9 +298,6 @@ pub struct CipherData { drivers_license: Option, passport: Option, - // The sealed blob of a v2 account's cipher, which replaces all of the fields above - data: Option, - favorite: Option, reprompt: Option, @@ -332,98 +319,6 @@ pub struct CipherData { archived_date: Option, } -/// A field of a [`CipherData`] that fails upstream's model validation. -#[derive(Debug)] -pub struct CipherValidationError { - pub field: &'static str, - pub message: String, -} - -impl From for crate::Error { - fn from(e: CipherValidationError) -> Self { - Self::new_msg(e.message) - } -} - -/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field. -/// -/// Ref: -fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult { - if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) { - err!("Cipher was not encrypted for the current user. Please try again.") - } - Ok(()) -} - -/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known. -/// -/// Ref: -fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult { - validate_encrypted_for_user(data, &user.uuid)?; - - if !is_org_cipher - && let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id) - && cipher_key_id != user_key_id - { - err!("Cipher was not encrypted with the current user key. Please try again.") - } - Ok(()) -} - -/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data` -const MAX_CIPHER_DATA_LENGTH: usize = 500_000; - -impl CipherData { - /// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result. - pub fn is_blob(&self) -> bool { - self.data.as_deref().is_some_and(is_data_blob_encrypted) - } - - /// Saving's last checks: a known type, and its data unless it's a blob. - pub fn validate_type_data(&self, is_blob: bool) -> 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: - /// and - pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> { - if let Some(data) = &self.data - && data.len() > MAX_CIPHER_DATA_LENGTH - { - return Err(CipherValidationError { - field: "Data", - message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."), - }); - } - - // A blob carries the name inside it, so only the other formats need one - if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) { - return Err(CipherValidationError { - field: "Name", - message: String::from("The Name field is required."), - }); - } - - Ok(()) - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PartialCipherData { @@ -457,14 +352,16 @@ async fn post_ciphers_create( ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); - validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?; + if data.cipher.encrypted_for != headers.user.uuid { + err_code!("Invalid user cipher", Status::UnprocessableEntity.code); + } // This check is usually only needed in update_cipher_from_data(), but we // need it here as well to avoid creating an empty cipher in the call to // cipher.save() below. enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?; - let mut cipher = Cipher::new(data.cipher.r#type); + let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.save(&conn).await?; @@ -488,7 +385,16 @@ async fn post_ciphers_create( async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: CipherData = data.into_inner(); - validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?; + if data.encrypted_for != headers.user.uuid { + err_code!("Invalid user cipher", Status::UnprocessableEntity.code); + } + + if let Some(cipher_key_id) = &data.encrypted_by_key_id + && let Some(user_key_id) = &headers.user.key_id + && cipher_key_id != user_key_id + { + err_code!("Invalid key cipher", Status::UnprocessableEntity.code); + } // The web/browser clients set this field to null as expected, but the // mobile clients seem to set the invalid value `0001-01-01T00:00:00`, @@ -496,7 +402,7 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt // needed when creating a new cipher, so just ignore it unconditionally. data.last_known_revision_date = None; - let mut cipher = Cipher::new(data.r#type); + let mut cipher = Cipher::new(data.r#type, data.name.clone()); update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) @@ -520,44 +426,6 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } -/// The checks of saving a cipher, for callers that run them before writing anything else -async fn validate_cipher_update( - cipher: &Cipher, - data: &CipherData, - headers: &Headers, - conn: &DbConn, - ut: UpdateType, -) -> EmptyResult { - // Check that the client isn't updating an existing cipher with stale data. - // And only perform this check when not importing ciphers, else the date/time check will fail. - if ut != UpdateType::None - && let Some(dt) = &data.last_known_revision_date - { - match NaiveDateTime::parse_from_str(dt, "%+") { - // ISO 8601 format - Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()), - Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { - err!("The client copy of this cipher is out of date. Resync the client and try again.") - } - Ok(_) => (), - } - } - - if let Some(note) = &data.notes { - let max_note_size = CONFIG._max_note_size(); - if note.len() > max_note_size { - err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters.")) - } - } - - if let Some(folder_id) = &data.folder_id - && Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() - { - err!("Invalid folder", "Folder does not exist or belongs to another user"); - } - Ok(()) -} - pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, @@ -566,40 +434,6 @@ 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. @@ -615,15 +449,34 @@ async fn save_cipher_data( json_data } - let is_blob = data.is_blob(); - data.validate_content(is_blob)?; - data.validate_type_data(is_blob)?; - validate_cipher_update(cipher, &data, headers, conn, ut).await?; + enforce_personal_ownership_policy(Some(&data), headers, conn).await?; + + // Check that the client isn't updating an existing cipher with stale data. + // And only perform this check when not importing ciphers, else the date/time check will fail. + if ut != UpdateType::None + && let Some(dt) = data.last_known_revision_date + { + match NaiveDateTime::parse_from_str(&dt, "%+") { + // ISO 8601 format + Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()), + Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { + err!("The client copy of this cipher is out of date. Resync the client and try again.") + } + Ok(_) => (), + } + } if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id { err!("Organization mismatch. Please resync the client before updating the cipher") } + if let Some(note) = &data.notes { + let max_note_size = CONFIG._max_note_size(); + if note.len() > max_note_size { + err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters.")) + } + } + // Check if this cipher is being transferred from a personal to an organization vault let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); @@ -654,6 +507,12 @@ async fn save_cipher_data( cipher.user_uuid = Some(headers.user.uuid.clone()); } + if let Some(ref folder_id) = data.folder_id + && Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() + { + err!("Invalid folder", "Folder does not exist or belongs to another user"); + } + // Modify attachments name and keys when rotating if let Some(attachments) = data.attachments2 { for (id, attachment) in attachments { @@ -692,36 +551,26 @@ async fn save_cipher_data( _ => err!("Invalid type"), }; - if let Some(blob) = data.data.filter(|_| is_blob) { - // A blob holds everything, the name included; the column can't be null, so it's left empty - // TODO: Make `ciphers.name` nullable and store `None` here instead. - cipher.name = String::new(); - cipher.notes = None; - cipher.fields = None; - cipher.password_history = None; - cipher.data = blob; - } else { - let Some(mut type_data) = type_data_opt else { - err!("Data missing") - }; + let type_data = if let Some(mut data) = type_data_opt { // Remove the 'Response' key from the base object. - if let Some(data_obj) = type_data.as_object_mut() { + if let Some(data_obj) = data.as_object_mut() { data_obj.remove("response"); } // Remove the 'Response' key from every Uri. - if type_data["uris"].is_array() { - type_data["uris"] = clean_cipher_data(type_data["uris"].clone()); + if data["uris"].is_array() { + data["uris"] = clean_cipher_data(data["uris"].clone()); } - - // `validate_content` made sure there is a name - cipher.name = data.name.unwrap_or_default(); - cipher.notes = data.notes; - cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string()); - cipher.password_history = data.password_history.map(|f| f.to_string()); - cipher.data = type_data.to_string(); - } + data + } else { + err!("Data missing") + }; cipher.key = data.key; + cipher.name = data.name; + cipher.notes = data.notes; + cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string()); + cipher.data = type_data.to_string(); + cipher.password_history = data.password_history.map(|f| f.to_string()); cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32); cipher.save(conn).await?; @@ -821,7 +670,7 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC // Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58 cipher_data.organization_id = None; - let mut cipher = Cipher::new(cipher_data.r#type); + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } @@ -894,9 +743,6 @@ async fn put_cipher( err!("Cipher is not write accessible") } - // Against the cipher we hold rather than the organization the client claims, like upstream - validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?; - // Moving a cipher into an organization is only possible through the share endpoints if cipher.organization_uuid != data.organization_id { err!("Organization mismatch. Please resync the client before updating the cipher") @@ -1173,20 +1019,13 @@ async fn put_cipher_share( ) -> JsonResult { let data: ShareCipherData = data.into_inner(); - // Upstream's `PutShare` checks, before anything is written. - // Ref: - // and if data.cipher.organization_id.is_none() { err!("Cipher OrganizationId is required.") } + if data.collection_ids.is_empty() { err!("You must select at least one collection.") } - if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid)) - { - err_code!("Cipher doesn't exist", Status::NotFound.code) - } - validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?; share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } @@ -1246,35 +1085,21 @@ async fn put_cipher_share_selected( ) -> JsonResult { let data: ShareSelectedCipherData = data.into_inner(); - // Upstream's `PutShareMany` checks, before anything is written. - // Ref: - // and if data.ciphers.is_empty() { err!("You must select at least one cipher.") } - if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) { - err!("All Ciphers must have an Id and OrganizationId.") - } - if data.ciphers.iter().map(|c| &c.organization_id).collect::>().len() != 1 { - err!("All ciphers must be for the same organization.") - } + if data.collection_ids.is_empty() { err!("You must select at least one collection.") } + for cipher in &data.ciphers { - 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)?; - } - let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await; - for cipher_data in &data.ciphers { - let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else { - err!("Trying to share ciphers that you do not own.") - }; - validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?; + if cipher.id.is_none() { + err!("Request missing ids field") + } + if cipher.organization_id.is_none() { + err!("Cipher OrganizationId is required.") + } } let mut responses = Vec::with_capacity(data.ciphers.len()); @@ -1336,22 +1161,6 @@ async fn share_cipher_by_uuid( err!("Organization mismatch. Please resync the client before updating the cipher") } - // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. - // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item - let ut = if let Some(ut) = override_ut { - ut - } else if data.cipher.last_known_revision_date.is_some() { - UpdateType::SyncCipherUpdate - } else { - UpdateType::SyncCipherCreate - }; - - // For the same reason, the other checks of saving - 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![]; if let Some(organization_id) = &data.cipher.organization_id { @@ -1370,6 +1179,16 @@ async fn share_cipher_by_uuid( } } + // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. + // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item + let ut = if let Some(ut) = override_ut { + ut + } else if data.cipher.last_known_revision_date.is_some() { + UpdateType::SyncCipherUpdate + } else { + UpdateType::SyncCipherCreate + }; + update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index bca0745f..607db45b 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -8,7 +8,7 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, @@ -1866,7 +1866,7 @@ async fn post_org_import( cipher_data.folder_id = None; // Replace the client-provided, unvalidated organizationId with the real target org cipher_data.organization_id = Some(org_id.clone()); - let mut cipher = Cipher::new(cipher_data.r#type); + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data( &mut cipher, cipher_data, @@ -2869,26 +2869,8 @@ 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 { - // 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!({ - "object": "organizationPublicKey", - "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 { +async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { @@ -2901,30 +2883,11 @@ async fn get_organization_public_key( }))) } -/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org, -/// so it is made once and its private half dropped. -async fn fake_sso_org_public_key() -> ApiResult { - static PUBLIC_KEY: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?; - Some(data_encoding::BASE64.encode(&der)) - }); - - let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else { - err!("Failed to generate the organization key") - }; - Ok(public_key) -} - // 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: OrgMemberHeaders, conn: DbConn) -> JsonResult { + get_organization_public_key(org_id, headers, conn).await } // Will allow to reset 2FA too diff --git a/src/api/identity.rs b/src/api/identity.rs index 832a9d05..4608ed77 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -77,14 +77,14 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - password_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await + password_login(data, &mut user_id, &conn, &client_header.ip).await } "client_credentials" => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?; check_is_some(data.scope.as_ref(), "scope cannot be blank")?; - api_key_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await + api_key_login(data, &mut user_id, &conn, &client_header.ip).await } "authorization_code" if CONFIG.sso_enabled() => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; @@ -95,7 +95,7 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - sso_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).await + sso_login(data, &mut user_id, &conn, &client_header.ip).await } "authorization_code" => err!("SSO sign-in is not available"), "send_access" => { @@ -179,13 +179,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR } // After exchanging the code we need to check first if 2FA is needed before continuing -async fn sso_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - ip: &ClientIp, - client_version: Option<&semver::Version>, -) -> JsonResult { +async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login @@ -331,8 +325,6 @@ async fn sso_login( (user, device, None, None) } Some((user, _)) if !user.enabled => { - // Set in both arms, not only after the match, so that their failed logins are logged - *user_id = Some(user.uuid.clone()); err!( "This user has been disabled", format!("IP: {}. Username: {}.", ip.ip, user.email), @@ -342,9 +334,6 @@ async fn sso_login( ) } Some((mut user, sso_user)) => { - *user_id = Some(user.uuid.clone()); - check_client_version(&user, client_version, ip)?; - let mut device = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; @@ -379,13 +368,7 @@ async fn sso_login( authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } -async fn password_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - ip: &ClientIp, - client_version: Option<&semver::Version>, -) -> JsonResult { +async fn password_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -455,8 +438,6 @@ async fn password_login( ) } - check_client_version(&user, client_version, ip)?; - // Change the KDF Iterations (only when not logging in with an auth request) if data.auth_request.is_none() { kdf_upgrade(&mut user, password, conn).await?; @@ -518,47 +499,6 @@ async fn password_login( authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } -/// Like upstream's `ClientVersionValidator`, a V2 account ([`User::has_v2_encryption`]) logs in only from a client that -/// sends its version and is at least 2025.11.0 (upstream also refuses a missing header for V1 accounts). Called right -/// after the credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2. -/// -/// Ref: -fn check_client_version(user: &User, client_version: Option<&semver::Version>, ip: &ClientIp) -> EmptyResult { - let Some((error, description)) = client_version_error(user.has_v2_encryption(), client_version) else { - return Ok(()); - }; - Err(crate::error::Error::from(( - format!("IP: {}. Username: {}. {error}", ip.ip, user.email), - json!({ - "error": error, - "error_description": description, - "ErrorModel": { "Message": description, "Object": "error" }, - }), - )) - .with_event(crate::error::ErrorEvent { - event: EventType::UserFailedLogIn, - })) -} - -/// The error id and message of [`check_client_version`], `None` when the client may log in. -fn client_version_error(is_v2: bool, client_version: Option<&semver::Version>) -> Option<(&'static str, &'static str)> { - const MIN_V2_CLIENT: semver::Version = semver::Version::new(2025, 11, 0); - - if !is_v2 { - return None; - } - match client_version { - None => Some(( - "version_header_missing", - "No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"", - )), - Some(version) if *version < MIN_V2_CLIENT => { - Some(("invalid_client_version", "Please update your app to continue using Bitwarden")) - } - Some(_) => None, - } -} - async fn authenticated_response( user: &User, device: &mut Device, @@ -606,13 +546,24 @@ 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.master_password_salt() + "Salt": user.email }) } else { Value::Null }; - let account_keys = user.account_keys_json(conn).await; + let account_keys = if user.private_key.is_some() { + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": user.private_key, + "publicKey": user.public_key, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }) + } else { + Value::Null + }; let mut result = json!({ "access_token": auth_tokens.access_token(), @@ -647,13 +598,7 @@ async fn authenticated_response( Ok(Json(result)) } -async fn api_key_login( - data: ConnectData, - user_id: &mut Option, - conn: &DbConn, - ip: &ClientIp, - client_version: Option<&semver::Version>, -) -> JsonResult { +async fn api_key_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; @@ -665,7 +610,7 @@ async fn api_key_login( check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - user_api_key_login(data, user_id, conn, ip, client_version).await + user_api_key_login(data, user_id, conn, ip).await } Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), @@ -677,7 +622,6 @@ async fn user_api_key_login( user_id: &mut Option, conn: &DbConn, ip: &ClientIp, - client_version: Option<&semver::Version>, ) -> JsonResult { // Get the user via the client_id let client_id = data.client_id.as_ref().unwrap(); @@ -715,8 +659,6 @@ async fn user_api_key_login( ) } - check_client_version(&user, client_version, ip)?; - let mut device = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && device.is_new() { @@ -761,13 +703,24 @@ 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.master_password_salt() + "Salt": user.email }) } else { Value::Null }; - let account_keys = user.account_keys_json(conn).await; + let account_keys = if user.private_key.is_some() { + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": user.private_key, + "publicKey": user.public_key, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }) + } else { + Value::Null + }; // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. @@ -1398,24 +1351,3 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, Ok(Redirect::temporary(String::from(auth_url))) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_client_version_error() { - let version = |v| semver::Version::parse(v).unwrap(); - // V1 accounts aren't checked, with or without a version - assert_eq!(client_version_error(false, None), None); - assert_eq!(client_version_error(false, Some(&version("2024.1.0"))), None); - // V2 accounts need the header and 2025.11.0 or newer - assert_eq!(client_version_error(true, None).map(|e| e.0), Some("version_header_missing")); - assert_eq!( - client_version_error(true, Some(&version("2025.10.3"))).map(|e| e.0), - Some("invalid_client_version") - ); - assert_eq!(client_version_error(true, Some(&version("2025.11.0"))), None); - assert_eq!(client_version_error(true, Some(&version("2026.10.1"))), None); - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs index 42c6d53d..450c2113 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, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, + notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, push::{ push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, 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 304ce447..3bce7219 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -412,35 +412,23 @@ 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 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); + let data = create_update( + vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], + 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, reason, conn).await; + push_logout(user, acting_device, conn).await; } } @@ -704,14 +692,6 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id serialize(&value) } -/// 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 dab31c22..4da790e4 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, LogOutReason, UpdateType}, + api::{ApiResult, EmptyResult, UpdateType}, db::{ DbConn, models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, @@ -192,15 +192,15 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device } } -pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option, conn: &DbConn) { +pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { - push_logout_to_relay(user, acting_device, reason); + push_logout_to_relay(user, acting_device); } } // Sends the logout without checking for push devices first. // Needed after deleting a user, when the devices are already gone from the database. -pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: Option) { +pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) { tokio::task::spawn(send_to_push_relay(json!({ "userId": user.uuid, "organizationId": (), @@ -209,8 +209,7 @@ pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: "type": UpdateType::LogOut as i32, "payload": { "userId": user.uuid, - "date": format_date(&user.updated_at), - "reason": reason.map(|r| r as i32), + "date": format_date(&user.updated_at) }, "clientType": null, "installationId": null diff --git a/src/auth.rs b/src/auth.rs index ff858c0e..cedbb130 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, + SendFileId, SendId, User, UserId, UserStampException, }, }, error::Error, @@ -632,9 +632,6 @@ impl<'r> FromRequest<'r> for Host { pub struct ClientHeaders { pub device_type: i32, pub ip: ClientIp, - /// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this - /// doesn't log an error, as not every client sends it. - pub client_version: Option, } #[rocket::async_trait] @@ -651,13 +648,10 @@ impl<'r> FromRequest<'r> for ClientHeaders { .get_one("device-type") .and_then(|d| d.parse().ok()) .unwrap_or(DeviceType::UnknownBrowser as i32); - let client_version = - request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok()); Outcome::Success(ClientHeaders { device_type, ip, - client_version, }) } } @@ -713,12 +707,31 @@ impl<'r> FromRequest<'r> for Headers { }; if user.security_stamp != claims.sstamp { - let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { - err_handler!("Error getting current route for stamp exception") - }; + 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") + }; - // 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)) { + // 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 { err_handler!("Invalid security stamp") } } diff --git a/src/config.rs b/src/config.rs index 3fb8ac9e..a565a93e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -261,6 +261,10 @@ macro_rules! make_config { } async fn from_file() -> Result, Error> { + // The Fs operator would create the folder if it doesn't exist, which we never want + if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() { + return Ok(None); + } let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let config_bytes = match operator.read(&CONFIG_FILENAME).await { Ok(bytes) => bytes, @@ -1512,8 +1516,6 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ // Key Management Team "biometrics-sdk-ipc", "windows-native-credential-sync", - "enable-account-encryption-v2-jit-password-registration", - "pm-27278-v2-password-registration", // Mobile Team "pm-34171-card-scanner", // Platform Team diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index f87d61d8..db8e9bf4 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -19,7 +19,7 @@ use crate::{ }, }, error::MapResult, - util::{LowerCase, convert_json_key_lcase_first}, + util::LowerCase, }; use macros::UuidFromParam; @@ -63,18 +63,6 @@ pub struct Cipher { pub reprompt: Option, } -/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key. -/// -/// Ref: -pub fn is_data_blob_encrypted(data: &str) -> bool { - serde_json::from_str::(data).is_ok_and(|d| is_blob_value(&d)) -} - -/// [`is_data_blob_encrypted`] for `data` that was already parsed. -fn is_blob_value(data: &Value) -> bool { - data.get("format_version").is_some() -} - pub enum RepromptType { None = 0, Password = 1, @@ -82,8 +70,7 @@ pub enum RepromptType { /// Local methods impl Cipher { - /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data` - pub fn new(atype: i32) -> Self { + pub fn new(atype: i32, name: String) -> Self { let now = Utc::now().naive_utc(); Self { @@ -97,7 +84,7 @@ impl Cipher { key: None, atype, - name: String::new(), + name, notes: None, fields: None, @@ -123,16 +110,6 @@ impl Cipher { .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); } - let is_blob = cipher.is_blob(); - if let Err(e) = cipher.validate_content(is_blob) { - validation_errors - .insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap()); - } - if let Err(e) = cipher.validate_type_data(is_blob) { - validation_errors - .insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap()); - } - // Validate the password history if it contains `null` values and if so, return a warning if let Some(Value::Array(password_history)) = &cipher.password_history { for pwh in password_history { @@ -177,10 +154,6 @@ impl Cipher { ) -> Result { use crate::util::{format_date, validate_and_format_date}; - // Parsed once, since `data` can be large and this runs for every cipher in a sync - let type_data = serde_json::from_str::(&self.data); - let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value); - let mut attachments_json: Value = Value::Null; if let Some(cipher_sync_data) = cipher_sync_data { if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) @@ -277,9 +250,63 @@ impl Cipher { }) .unwrap_or_default(); - // Like upstream, fields and password history stored as NULL are sent as null, not `[]` - let fields_json = self.fields.is_some().then_some(fields_json); - let password_history_json = self.password_history.is_some().then_some(password_history_json); + // Get the type_data or a default to an empty json object '{}'. + // If not passing an empty object, mobile clients will crash. + let mut type_data_json = serde_json::from_str::>(&self.data) + .inspect_err(|_| warn!("Error parsing data field for {}", self.uuid)) + .map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data); + + // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream + // Set the first element of the Uris array as Uri, this is needed several (mobile) clients. + if self.atype == 1 { + // Upstream always has an `uri` key/value + type_data_json["uri"] = Value::Null; + if let Some(uris) = type_data_json["uris"].as_array_mut() + && !uris.is_empty() + { + // Fix uri match values first, they are only allowed to be a number or null + // If it is a string, convert it to an int or null if that fails + for uri in &mut *uris { + if uri["match"].is_string() { + let match_value = match uri["match"].as_str().unwrap_or_default().parse::() { + Ok(n) => json!(n), + _ => Value::Null, + }; + uri["match"] = match_value; + } + } + type_data_json["uri"] = uris[0]["uri"].clone(); + } + + // Check if `passwordRevisionDate` is a valid date, else convert it + if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { + type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); + } + } + + // Fix secure note issues when data is invalid + // This breaks at least the native mobile clients + if self.atype == 2 { + match type_data_json { + Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {} + _ => { + type_data_json = json!({"type": 0}); + } + } + } + + // Fix invalid SSH Entries + // This breaks at least the native mobile client if invalid + // The only way to fix this is by setting type_data_json to `null` + // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it + if self.atype == 5 + && (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty) + || type_data_json["privateKey"].as_str().is_none_or(str::is_empty) + || type_data_json["publicKey"].as_str().is_none_or(str::is_empty)) + { + warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); + type_data_json = Value::Null; + } let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { @@ -376,84 +403,10 @@ impl Cipher { _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), }; - if is_blob_encrypted { - // Like upstream, sent as-is with the structured fields null - json_object["data"] = json!(self.data); - json_object["name"] = Value::Null; - } else { - json_object[key] = self.legacy_type_data_json(type_data); - } + json_object[key] = type_data_json; Ok(json_object) } - /// The per-type data of a legacy cipher, with fixups for values known to break clients. - fn legacy_type_data_json(&self, type_data: Result) -> Value { - use crate::util::validate_and_format_date; - - // Get the type_data or a default to an empty json object '{}'. - // If not passing an empty object, mobile clients will crash. - let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data { - convert_json_key_lcase_first(data) - } else { - warn!("Error parsing data field for {}", self.uuid); - Value::Object(serde_json::Map::new()) - }; - - // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream - // Set the first element of the Uris array as Uri, this is needed several (mobile) clients. - if self.atype == 1 { - // Upstream always has an `uri` key/value - type_data_json["uri"] = Value::Null; - if let Some(uris) = type_data_json["uris"].as_array_mut() - && !uris.is_empty() - { - // Fix uri match values first, they are only allowed to be a number or null - // If it is a string, convert it to an int or null if that fails - for uri in &mut *uris { - if uri["match"].is_string() { - let match_value = match uri["match"].as_str().unwrap_or_default().parse::() { - Ok(n) => json!(n), - _ => Value::Null, - }; - uri["match"] = match_value; - } - } - type_data_json["uri"] = uris[0]["uri"].clone(); - } - - // Check if `passwordRevisionDate` is a valid date, else convert it - if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { - type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); - } - } - - // Fix secure note issues when data is invalid - // This breaks at least the native mobile clients - if self.atype == 2 { - match type_data_json { - Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {} - _ => { - type_data_json = json!({"type": 0}); - } - } - } - - // Fix invalid SSH Entries - // This breaks at least the native mobile client if invalid - // The only way to fix this is by setting type_data_json to `null` - // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it - if self.atype == 5 - && (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty) - || type_data_json["privateKey"].as_str().is_none_or(str::is_empty) - || type_data_json["publicKey"].as_str().is_none_or(str::is_empty)) - { - warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); - type_data_json = Value::Null; - } - - type_data_json - } - pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { let mut user_uuids = Vec::new(); match self.user_uuid { diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 32681652..8f62bf0b 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -17,12 +17,11 @@ mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; -mod user_signature_key_pair; pub use self::archive::Archive; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; -pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted}; +pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::emergency_access::{ @@ -42,5 +41,4 @@ 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}; -pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair}; +pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index c6f70f89..b7fcdfdf 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -69,9 +69,6 @@ pub struct Membership { access_event_logs: bool, access_import_export: bool, access_reports: bool, - - /// 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)] @@ -410,8 +407,6 @@ impl Membership { access_event_logs: false, access_import_export: false, access_reports: false, - - v2_upgrade_token: None, } } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 20126001..30541428 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -6,7 +6,6 @@ use serde_json::Value; use crate::{ CONFIG, api::EmptyResult, - auth::DEFAULT_ACCESS_VALIDITY, crypto, db::{ DbConn, @@ -21,7 +20,7 @@ use macros::UuidFromParam; use super::{ AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, - TwoFactorIncomplete, UserSignatureKeyPair, + TwoFactorIncomplete, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] @@ -73,13 +72,6 @@ pub struct User { pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. pub key_id: Option, - - // The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2` - 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)] @@ -108,28 +100,13 @@ enum UserStatus { _Disabled = 2, } -/// A previous security stamp that is still accepted, until `expire`. #[derive(Serialize, Deserialize)] pub struct UserStampException { - /// The routes the stamp is still accepted on, or `None` for any route. - pub routes: Option>, + pub routes: Vec, 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; @@ -182,31 +159,9 @@ impl User { external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. key_id: None, - - signed_public_key: None, - security_state: None, - security_version: None, - v2_upgrade_token: None, } } - pub fn is_v2(&self) -> bool { - self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some() - } - - /// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2. - /// Upstream only uses it to decide which accounts need a v2-capable client. - /// - /// Ref: - pub fn has_v2_encryption(&self) -> bool { - self.security_version == Some(2) - && self - .private_key - .as_deref() - .and_then(|private_key| private_key.split_once('.')) - .is_some_and(|(enc_type, _)| enc_type.parse::() == Ok(7)) - } - pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash( password.as_bytes(), @@ -216,7 +171,6 @@ 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() } @@ -271,33 +225,10 @@ 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 @@ -306,30 +237,17 @@ impl User { /// After these 2 minutes this stamp will expire. /// pub fn set_stamp_exception(&mut self, route_exception: Vec) { - self.set_stamp_exceptions(&[UserStampException { - routes: Some(route_exception), + let stamp_exception = UserStampException { + routes: route_exception, security_stamp: self.security_stamp.clone(), expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(), - }]); + }; + self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default()); } - /// 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(); - }; - // 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() - } - - 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()) - }; + /// Resets the stamp_exception to prevent re-use of the previous security-stamp + pub fn reset_stamp_exception(&mut self) { + self.stamp_exception = None; } pub fn display_name(&self) -> &str { @@ -348,37 +266,18 @@ impl User { !CONFIG.mail_enabled() || self.verified_at.is_some() } - async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option { - if !self.is_v2() { - return None; - } - UserSignatureKeyPair::find_by_user(&self.uuid, conn).await - } - /// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair - pub async fn account_keys_json(&self, conn: &DbConn) -> Value { + pub fn account_keys_json(&self) -> Value { if self.private_key.is_some() { - let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await { - Some(key_pair) => ( - json!(self.signed_public_key), - key_pair.to_json(), - json!({ - "securityState": self.security_state, - "securityVersion": self.security_version, - }), - ), - None => (Value::Null, Value::Null, Value::Null), - }; - json!({ "publicKeyEncryptionKeyPair": { "wrappedPrivateKey": self.private_key, "publicKey": self.public_key, - "signedPublicKey": signed_public_key, + "signedPublicKey": null, "object": "publicKeyEncryptionKeyPair", }, - "securityState": security_state, - "signatureKeyPair": signature_key_pair, + "securityState": null, + "signatureKeyPair": null, "object": "privateKeys" }) } else { @@ -386,24 +285,6 @@ impl User { } } - pub async fn public_keys_json(&self, conn: &DbConn) -> Value { - let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await { - Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)), - None => (Value::Null, Value::Null), - }; - - json!({ - "publicKey": self.public_key, - "signedPublicKey": signed_public_key, - "verifyingKey": verifying_key, - "object": "publicKeys" - }) - } - - 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 { @@ -424,7 +305,7 @@ impl User { UserStatus::Enabled }; - let account_keys = self.account_keys_json(conn).await; + let account_keys = self.account_keys_json(); json!({ "_status": status as i32, @@ -501,7 +382,6 @@ impl User { Device::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; - UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any conn.run(move |conn| { @@ -673,26 +553,25 @@ impl Invitation { pub struct UserId(String); #[derive( - Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, AsRef, Deref, Display, From, UuidFromParam, + Clone, + Debug, + DieselNewType, + FromForm, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + AsRef, + Deref, + Display, + From, + UuidFromParam, )] #[deref(forward)] #[from(forward)] pub struct KeyId(String); -/// Like upstream, a key id is 32 lowercase hex characters. Key ids are compared as strings, so one in any other -/// format would never match the id the clients send. -/// -/// Ref: -impl<'de> serde::Deserialize<'de> for KeyId { - fn deserialize>(deserializer: D) -> Result { - let key_id = String::deserialize(deserializer)?; - if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { - return Err(serde::de::Error::custom("Key id must be 32 lowercase hex characters")); - } - Ok(Self(key_id)) - } -} - impl SsoUser { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: @@ -746,39 +625,3 @@ impl SsoUser { .await } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_key_id_format() { - let key_id: KeyId = serde_json::from_str(r#""0123456789abcdef0123456789abcdef""#).unwrap(); - assert_eq!(key_id.as_ref(), "0123456789abcdef0123456789abcdef"); - - for invalid in ["", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef", "0123456789abcdef0123456789abcdeg"] - { - assert!(serde_json::from_str::(&format!("\"{invalid}\"")).is_err(), "{invalid:?}"); - } - - // An absent key id stays optional - assert_eq!(serde_json::from_str::>("null").unwrap(), None); - } - - #[test] - fn test_has_v2_encryption() { - let user = |private_key: Option<&str>, security_version: Option| { - let mut user = User::new("user@example.com", None); - user.private_key = private_key.map(String::from); - user.security_version = security_version; - user - }; - - assert!(user(Some("7.cose"), Some(2)).has_v2_encryption()); - assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption()); - assert!(!user(Some("7.cose"), None).has_v2_encryption()); - assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption()); - assert!(!user(Some("7"), Some(2)).has_v2_encryption()); - assert!(!user(None, Some(2)).has_v2_encryption()); - } -} diff --git a/src/db/models/user_signature_key_pair.rs b/src/db/models/user_signature_key_pair.rs deleted file mode 100644 index 72509761..00000000 --- a/src/db/models/user_signature_key_pair.rs +++ /dev/null @@ -1,132 +0,0 @@ -use chrono::{NaiveDateTime, Utc}; -use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; -use serde_json::Value; - -use crate::{ - api::EmptyResult, - db::{DbConn, schema::user_signature_key_pairs}, - error::MapResult, - util::get_uuid, -}; - -use super::UserId; - -/// A user's signature key pair, part of the v2 state, in its own table like upstream. -/// -/// Ref: -#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] -#[diesel(table_name = user_signature_key_pairs)] -#[diesel(treat_none_as_null = true)] -#[diesel(primary_key(uuid))] -pub struct UserSignatureKeyPair { - pub uuid: UserSignatureKeyPairId, - pub user_uuid: UserId, - - pub signature_algorithm: i32, - /// The signing (private) key, wrapped by the user key. - pub signing_key: String, - /// The COSE-encoded public verifying key. - pub verifying_key: String, - - pub created_at: NaiveDateTime, - pub updated_at: NaiveDateTime, -} - -/// Ref: -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SignatureAlgorithm { - Ed25519 = 0, - MlDsa44 = 1, -} - -impl SignatureAlgorithm { - pub fn parse(algorithm: &str) -> Option { - match algorithm { - "ed25519" => Some(Self::Ed25519), - "mldsa44" => Some(Self::MlDsa44), - _ => None, - } - } -} - -/// Local methods -impl UserSignatureKeyPair { - pub fn new( - user_uuid: UserId, - signature_algorithm: SignatureAlgorithm, - signing_key: String, - verifying_key: String, - ) -> Self { - let now = Utc::now().naive_utc(); - - Self { - uuid: UserSignatureKeyPairId(get_uuid()), - user_uuid, - signature_algorithm: signature_algorithm as i32, - signing_key, - verifying_key, - created_at: now, - updated_at: now, - } - } - - pub fn to_json(&self) -> Value { - json!({ - "wrappedSigningKey": self.signing_key, - "verifyingKey": self.verifying_key, - "object": "signatureKeyPair", - }) - } -} - -/// Database methods -impl UserSignatureKeyPair { - pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { - self.updated_at = Utc::now().naive_utc(); - - db_run! { conn: - mysql { - diesel::insert_into(user_signature_key_pairs::table) - .values(&*self) - .on_conflict(diesel::dsl::DuplicatedKeys) - .do_update() - .set(&*self) - .execute(conn) - .map_res("Error saving user signature key pair") - } - postgresql, sqlite { - diesel::insert_into(user_signature_key_pairs::table) - .values(&*self) - .on_conflict(user_signature_key_pairs::user_uuid) - .do_update() - .set(&*self) - .execute(conn) - .map_res("Error saving user signature key pair") - } - } - } - - /// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`. - pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| { - user_signature_key_pairs::table - .filter(user_signature_key_pairs::user_uuid.eq(user_uuid)) - .first::(conn) - .ok() - }) - .await - } - - pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { - conn.run(move |conn| { - diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))) - .execute(conn) - .map_res("Error deleting user signature key pairs") - }) - .await - } -} - -#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct UserSignatureKeyPairId(String); diff --git a/src/db/schema.rs b/src/db/schema.rs index 98792674..e1b9a78d 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -219,22 +219,6 @@ table! { avatar_color -> Nullable, external_id -> Nullable, key_id -> Nullable, - signed_public_key -> Nullable, - security_state -> Nullable, - security_version -> Nullable, - v2_upgrade_token -> Nullable, - } -} - -table! { - user_signature_key_pairs (uuid) { - uuid -> Text, - user_uuid -> Text, - signature_algorithm -> Integer, - signing_key -> Text, - verifying_key -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, } } @@ -269,7 +253,6 @@ table! { access_event_logs -> Bool, access_import_export -> Bool, access_reports -> Bool, - v2_upgrade_token -> Nullable, } } @@ -410,7 +393,6 @@ joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); joinable!(sso_users -> users (user_uuid)); -joinable!(user_signature_key_pairs -> users (user_uuid)); allow_tables_to_appear_in_same_query!( archives, @@ -437,5 +419,4 @@ allow_tables_to_appear_in_same_query!( collections_groups, event, auth_requests, - user_signature_key_pairs, ); diff --git a/src/util.rs b/src/util.rs index 696c3360..369e255b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -899,12 +899,6 @@ pub fn parse_experimental_client_feature_flags( .collect() } -/// Whether the admin enabled this supported client feature flag -pub fn is_client_feature_flag_enabled(flag: &str) -> bool { - parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly) - .contains_key(flag) -} - /// TODO: This is extracted from IpAddr::is_global, which is unstable: /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged