diff --git a/.env.template b/.env.template index 2d5c5d8e..7484053f 100644 --- a/.env.template +++ b/.env.template @@ -411,6 +411,9 @@ ## - "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 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one. # 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 87c0c4a6..dabcadb4 100644 --- a/.typos.toml +++ b/.typos.toml @@ -14,9 +14,8 @@ 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, ignore these specific items - "COSEKey", - "COSEAlgorithm", + # COSE Is short for CBOR Object Signing and Encryption + "(?i)cose", # 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 6b4e8965..1717ec63 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"] } +diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] } 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 new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..09abbaee --- /dev/null +++ b/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql @@ -0,0 +1,3 @@ +-- 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 new file mode 100644 index 00000000..49700440 --- /dev/null +++ b/migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..73edfa6a --- /dev/null +++ b/migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..eeffa854 --- /dev/null +++ b/migrations/mysql/2026-11-02-120000_reset_user_key_id/up.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 00000000..89e12c15 --- /dev/null +++ b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN v2_upgrade_token; +ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql new file mode 100644 index 00000000..45c58491 --- /dev/null +++ b/migrations/mysql/2026-11-04-120000_add_v2_upgrade_token/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; +ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..aab2e86a --- /dev/null +++ b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql @@ -0,0 +1,3 @@ +-- 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 new file mode 100644 index 00000000..49700440 --- /dev/null +++ b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..d3186ef0 --- /dev/null +++ b/migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..eeffa854 --- /dev/null +++ b/migrations/postgresql/2026-11-02-120000_reset_user_key_id/up.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 00000000..89e12c15 --- /dev/null +++ b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN v2_upgrade_token; +ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql new file mode 100644 index 00000000..45c58491 --- /dev/null +++ b/migrations/postgresql/2026-11-04-120000_add_v2_upgrade_token/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; +ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..f18eb816 --- /dev/null +++ b/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql @@ -0,0 +1 @@ +-- 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 new file mode 100644 index 00000000..49700440 --- /dev/null +++ b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..c15e5b5e --- /dev/null +++ b/migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..eeffa854 --- /dev/null +++ b/migrations/sqlite/2026-11-02-120000_reset_user_key_id/up.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 00000000..89e12c15 --- /dev/null +++ b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN v2_upgrade_token; +ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; diff --git a/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql new file mode 100644 index 00000000..45c58491 --- /dev/null +++ b/migrations/sqlite/2026-11-04-120000_add_v2_upgrade_token/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; +ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; diff --git a/src/api/admin.rs b/src/api/admin.rs index 5e51306c..40df44ae 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); + push_logout_to_relay(&user, None, None); } } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 69918212..b2dd5845 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use chrono::Utc; +use num_traits::FromPrimitive; use rocket::{ http::Status, request::{FromRequest, Outcome, Request}, @@ -11,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + AnonymousNotify, ApiResult, EmptyResult, JsonResult, LogOutReason, Notify, PasswordOrOtpData, core::{accept_org_invite, log_user_event, two_factor::email}, master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device, }, @@ -22,8 +23,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, User, UserId, - UserKdfType, + MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, + User, UserId, UserKdfType, UserSignatureKeyPair, }, }, mail, @@ -31,7 +32,7 @@ use crate::{ }; use super::{ - ciphers::{CipherData, update_cipher_from_data}, + ciphers::{CipherData, rotate_cipher_data, validate_rotated_cipher}, sends::SendData, }; @@ -42,13 +43,16 @@ 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, @@ -113,6 +117,9 @@ 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, @@ -124,36 +131,6 @@ 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)] @@ -193,6 +170,42 @@ 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)] @@ -202,6 +215,215 @@ 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 saved for a user who isn't v2 yet is ignored until it is, and replaced on + /// the next try. A rotation of a v2 account replaces its key pair, see `rotate_account`. + 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 { @@ -222,17 +444,19 @@ pub struct MasterPasswordUnlock { #[serde(alias = "masterKeyWrappedUserKey")] key: String, + contained_key_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetPasswordData { #[serde(flatten)] - kdf: KDFData, + compat: RegisterDataCompat, - key: String, keys: Option, - master_password_hash: String, + // Supersedes `keys`, and the only way a v2 account can be initialized here. + account_keys: Option, + master_password_hint: Option, org_identifier: Option, } @@ -274,7 +498,7 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { let mut name = None; let mut pending_emergency_access = None; - if data.unprocessable() { + if data.compat.unprocessable(&data.email) { err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); } @@ -343,12 +567,15 @@ 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 here so if it fails, the user - // can retry without losing their invitation below. + // Check against the password hint setting and the keys here so if they fail, + // 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 mut user = match User::find_by_mail(&email, &conn).await { + let existing_user = User::find_by_mail(&email, &conn).await; + let is_new_user = existing_user.is_none(); + let mut user = match existing_user { Some(user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") @@ -393,9 +620,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.kdf())?; + set_kdf_data(&mut user, data.compat.kdf())?; - user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?; + user.set_password(&data.compat.hash(), Some(data.compat.key()), true, None, &conn).await?; user.password_hint = password_hint; // Add extra fields if present @@ -403,9 +630,10 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { user.name = name; } - if let Some(keys) = data.keys { - user.private_key = Some(keys.encrypted_private_key); - user.public_key = Some(keys.public_key); + 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 email_verified { @@ -427,6 +655,14 @@ 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 @@ -441,25 +677,81 @@ 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: SetPasswordData = data.into_inner(); +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.") + } 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())?; - set_kdf_data(&mut user, &data.kdf)?; + // 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())?; user.set_password( - &data.master_password_hash, - Some(data.key), + &data.compat.hash(), + Some(data.compat.key()), false, Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp &conn, @@ -467,9 +759,12 @@ async fn post_set_password(data: Json, headers: Headers, conn: .await?; user.password_hint = password_hint; - if let Some(keys) = data.keys { - user.private_key = Some(keys.encrypted_private_key); - user.public_key = Some(keys.public_key); + 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(identifier) = data.org_identifier @@ -495,6 +790,10 @@ async fn post_set_password(data: Json, headers: Headers, conn: 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!({ @@ -577,8 +876,19 @@ 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")] -fn get_keys(headers: Headers) -> JsonResult { +async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult { let user = headers.user; // The SDK reads a 404 as the user having no key pair yet @@ -590,29 +900,67 @@ fn get_keys(headers: Headers) -> JsonResult { "key": (!user.akey.is_empty()).then_some(&user.akey), "publicKey": user.public_key, "privateKey": user.private_key, - "accountKeys": user.account_keys_json(), + "accountKeys": user.account_keys_json(&conn).await, "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: KeysData = data.into_inner(); +async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + let data: PostKeysData = 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") } - user.private_key = Some(data.encrypted_private_key); - user.public_key = Some(data.public_key); + // Ref: + let public_key = required(data.public_key, "PublicKey")?; + let encrypted_private_key = required(data.encrypted_private_key, "EncryptedPrivateKey")?; + // `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" }))) } @@ -647,10 +995,13 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("KDF settings must be equal for authentication and unlock") } - if user.email != authentication_data.salt || user.email != unlock_data.salt { + let salt = user.master_password_salt(); + if salt != authentication_data.salt || salt != unlock_data.salt { err!("Invalid master password salt") } + 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) @@ -753,6 +1104,30 @@ 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)] @@ -773,6 +1148,8 @@ 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)?; @@ -807,50 +1184,72 @@ struct UpdateFolderData { #[serde(rename_all = "camelCase")] struct UpdateEmergencyAccessData { id: EmergencyAccessId, - key_encrypted: String, + key_encrypted: Option, + // Only validated, upstream doesn't store it either + wait_time_days: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateResetPasswordData { organization_id: OrganizationId, - reset_password_key: String, + // Absent in a v1 -> v2 upgrade, which keeps the key the organization already holds + reset_password_key: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct KeyData { account_unlock_data: RotateAccountUnlockData, - account_keys: RotateAccountKeys, + account_keys: AccountKeysData, account_data: RotateAccountData, old_master_key_authentication_hash: String, + old_user_key_id: Option, + new_user_key_id: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RotateAccountUnlockData { - emergency_access_unlock_data: Vec, master_password_unlock_data: MasterPasswordUnlockData, - organization_account_recovery_unlock_data: Vec, + #[serde(flatten)] + common: CommonUnlockData, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MasterPasswordUnlockData { - kdf_type: i32, - kdf_iterations: i32, - kdf_parallelism: Option, - kdf_memory: Option, + #[serde(flatten)] + kdf: KDFData, email: String, master_key_authentication_hash: String, master_key_encrypted_user_key: String, + master_password_hint: Option, + master_password_salt: Option, + contained_key_id: Option, } +/// The unlock data of both rotation endpoints; passkey and device keys are ignored, as we support neither. +/// +/// Ref: #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RotateAccountKeys { - user_key_encrypted_account_private_key: String, - account_public_key: String, +struct CommonUnlockData { + emergency_access_unlock_data: Vec, + organization_account_recovery_unlock_data: Vec, + #[expect(dead_code, reason = "Required like upstream, but unused")] + passkey_unlock_data: Vec, + #[expect(dead_code, reason = "Required like upstream, but unused")] + device_key_unlock_data: Vec, + v2_upgrade_token: Option, +} + +/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct V2UpgradeTokenData { + wrapped_user_key1: String, + wrapped_user_key2: String, } #[derive(Deserialize)] @@ -861,27 +1260,117 @@ struct RotateAccountData { sends: Vec, } -fn validate_keydata( - data: &KeyData, +/// Body of `rotate-user-keys`, which rotates the keys without touching the master password. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RotateUserKeysData { + wrapped_account_cryptographic_state: WrappedAccountCryptographicState, + unlock_data: CommonUnlockData, + account_data: RotateAccountData, + unlock_method_data: UnlockMethodData, + old_user_key_id: Option, + new_user_key_id: Option, +} + +/// The v2-only account cryptographic state, where all three parts are mandatory. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WrappedAccountCryptographicState { + public_key_encryption_key_pair: PublicKeyEncryptionKeyPairData, + signature_key_pair: SignatureKeyPairData, + security_state: SecurityStateData, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnlockMethodData { + unlock_method: i32, + master_password_unlock_data: Option, + key_connector_key_wrapped_user_key: Option, +} + +/// Ref: +#[derive(num_derive::FromPrimitive)] +enum UnlockMethod { + Tde = 0, + MasterPassword = 1, + KeyConnector = 2, +} + +/// The rotation checks on the new keys: the public key never changes, nor the verifying key of a v2 account. +/// +/// Ref: +async fn validate_rotation_account_keys(keys: &ValidatedAccountKeys, user: &User, conn: &DbConn) -> EmptyResult { + if user.public_key.as_ref() != Some(&keys.public_key) { + err!("Changing the asymmetric keypair is not possible during key rotation") + } + + let Some(v2) = &keys.v2 else { + if user.is_v2() { + err!("Cannot downgrade an account from v2 to v1 encryption during key rotation") + } + // A v1 rotation: the private key stays wrapped by an AES-CBC-HMAC user key + if enc_string_type(&keys.private_key) != Some(ENC_TYPE_AES_CBC_256_HMAC_SHA256) { + err!("The provided account private key was not wrapped with AES-256-CBC-HMAC") + } + return Ok(()); + }; + + // Both a v2 rotation and the v1 -> v2 upgrade end up with a COSE user key wrapping the private keys + if enc_string_type(&v2.signing_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { + err!("The provided signing key data is not wrapped with XChaCha20-Poly1305.") + } + if enc_string_type(&keys.private_key) != Some(ENC_TYPE_COSE_ENCRYPT0) { + err!("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.") + } + if v2.verifying_key.is_empty() || v2.signed_public_key.is_empty() || v2.security_state.is_empty() { + err!("The v2 account keys are missing the verifying key, signed public key or security state") + } + + if !user.is_v2() { + // The v1 -> v2 upgrade, which is where the signature key pair comes from + return Ok(()); + } + + let Some(key_pair) = UserSignatureKeyPair::find_by_user(&user.uuid, conn).await else { + err!("The account is missing its signature key pair") + }; + if key_pair.verifying_key != v2.verifying_key { + err!("Changing the verifying key is not possible during key rotation") + } + + Ok(()) +} + +/// `AesCbc256_HmacSha256_B64`, the v1 user key +const ENC_TYPE_AES_CBC_256_HMAC_SHA256: &str = "2"; +/// `CoseEncrypt0B64`, the v2 user key +const ENC_TYPE_COSE_ENCRYPT0: &str = "7"; + +/// The encryption type of an EncString, the number before the first `.`. +fn enc_string_type(enc_string: &str) -> Option<&str> { + enc_string.split_once('.').map(|(enc_type, _)| enc_type) +} + +impl KDFData { + /// Whether these are the settings the user already has, which a key rotation can't change. + fn is_unchanged_for(&self, user: &User) -> bool { + user.client_kdf_type == self.kdf + && user.client_kdf_iter == self.kdf_iterations + && user.client_kdf_memory == self.kdf_memory + && user.client_kdf_parallelism == self.kdf_parallelism + } +} + +/// Every existing item must be in the rotation, or it would be unreadable afterwards. +fn validate_rotation_data( + data: &RotateData, existing_ciphers: &[Cipher], existing_folders: &[Folder], existing_emergency_access: &[EmergencyAccess], existing_memberships: &[Membership], existing_sends: &[Send], - user: &User, ) -> EmptyResult { - if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type - || user.client_kdf_iter != data.account_unlock_data.master_password_unlock_data.kdf_iterations - || user.client_kdf_memory != data.account_unlock_data.master_password_unlock_data.kdf_memory - || user.client_kdf_parallelism != data.account_unlock_data.master_password_unlock_data.kdf_parallelism - || user.email != data.account_unlock_data.master_password_unlock_data.email - { - err!("Changing the kdf variant or email is not supported during key rotation"); - } - if user.public_key.as_ref() != Some(&data.account_keys.account_public_key) { - err!("Changing the asymmetric keypair is not possible during key rotation") - } - // Check that we're correctly rotating all the user's ciphers let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::>(); let provided_cipher_ids = data @@ -906,12 +1395,8 @@ fn validate_keydata( // Check that we're correctly rotating all the user's emergency access keys let existing_emergency_access_ids = existing_emergency_access.iter().map(|ea| &ea.uuid).collect::>(); - let provided_emergency_access_ids = data - .account_unlock_data - .emergency_access_unlock_data - .iter() - .map(|ea| &ea.id) - .collect::>(); + let provided_emergency_access_ids = + data.unlock_data.emergency_access_unlock_data.iter().map(|ea| &ea.id).collect::>(); if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) { err!("All existing emergency access keys must be included in the rotation") } @@ -920,7 +1405,7 @@ fn validate_keydata( let existing_reset_password_ids = existing_memberships.iter().map(|m| &m.org_uuid).collect::>(); let provided_reset_password_ids = data - .account_unlock_data + .unlock_data .organization_account_recovery_unlock_data .iter() .map(|rp| &rp.organization_id) @@ -939,129 +1424,441 @@ fn validate_keydata( Ok(()) } -#[post("/accounts/key-management/rotate-user-account-keys", data = "")] -async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - // TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything. - let data: KeyData = data.into_inner(); +/// The keys a rotation re-shares the new user key with. The SDK needs all four lists, even when empty. +/// +/// Ref: +#[get("/accounts/key-management/key-rotation-data")] +async fn get_key_rotation_data(headers: Headers, conn: DbConn) -> JsonResult { + let user_id = &headers.user.uuid; - if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { - err!("Invalid password") + let mut organization_data = Vec::new(); + for membership in Membership::find_by_user(user_id, &conn).await { + // Only memberships actually enrolled in account recovery take part in the rotation. + if membership.reset_password_key.is_none() { + continue; + } + let Some(org) = Organization::find_by_uuid(&membership.org_uuid, &conn).await else { + continue; + }; + let Some(public_key) = org.public_key else { + continue; + }; + organization_data.push(json!({ + "organizationId": org.uuid, + "organizationName": org.name, + "organizationPublicKey": public_key, + "object": "organizationPasswordResetKeyData", + })); } + let mut emergency_access_data = Vec::new(); + for emergency_access in EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await { + // Without a stored key there is nothing to re-share. + if emergency_access.key_encrypted.is_none() { + continue; + } + let Some(grantee_id) = emergency_access.grantee_uuid.clone() else { + continue; + }; + let Some(grantee) = User::find_by_uuid(&grantee_id, &conn).await else { + continue; + }; + let Some(public_key) = grantee.public_key.clone() else { + continue; + }; + emergency_access_data.push(json!({ + "id": emergency_access.uuid, + "granteeId": grantee_id, + "granteeName": grantee.name, + "granteeEmail": grantee.email, + "publicKey": public_key, + "object": "emergencyAccessKeyData", + })); + } + + Ok(Json(json!({ + "organizationPasswordResetKeyData": organization_data, + "emergencyAccessKeyData": emergency_access_data, + "trustedDeviceKeyData": [], + "passkeyKeyData": [], + "object": "keyRotationData", + }))) +} + +/// Everything a rotation replaces, once each endpoint's wrapper has been peeled off. +struct RotateData { + account_keys: ValidatedAccountKeys, + account_data: RotateAccountData, + unlock_data: CommonUnlockData, + /// The id of the key the client rotates from, `None` from clients that don't send it. + old_user_key_id: Option, + /// `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(&data.account_data.ciphers)?; + 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 { + let data: KeyData = data.into_inner(); + + let unlock_data = data.account_unlock_data.master_password_unlock_data; + validate_max_length(&data.old_master_key_authentication_hash, 300, "OldMasterKeyAuthenticationHash")?; + unlock_data.validate()?; + validate_rotation_request(&data.account_data, &data.account_unlock_data.common)?; + + if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { + err!("Invalid password") + } + + // Ref: + let salt = unlock_data.master_password_salt.as_ref().unwrap_or(&unlock_data.email); + if !unlock_data.kdf.is_unchanged_for(&headers.user) || *salt != headers.user.master_password_salt() { + err!("The provided master password unlock data is not valid for this user.") + } + validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; + let password_hint = clean_password_hint(unlock_data.master_password_hint.as_ref()); + enforce_password_hint_setting(password_hint.as_ref())?; + + let mut common = data.account_unlock_data.common; + // Like upstream, this endpoint logs every session out, so an upgrade token is never kept + // Ref: + common.v2_upgrade_token = None; + + rotate_account( + RotateData { + account_keys: data.account_keys.validate()?, + account_data: data.account_data, + unlock_data: common, + old_user_key_id: data.old_user_key_id, + 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_keydata( + validate_rotation_data( &data, &existing_ciphers, &existing_folders, &existing_emergency_access, &existing_memberships, &existing_sends, - &headers.user, )?; - // Update folder data + // Like upstream, a client that rotates a user key that was already replaced has to sync first + // Ref: + if let (Some(current), Some(old)) = (&headers.user.key_id, &data.old_user_key_id) + && current != old + { + err_code!("The user key being rotated is not the current user key. Sync and try again.", Status::Conflict.code) + } + + validate_rotation_account_keys(&data.account_keys, &headers.user, &conn).await?; + + // An upgrade keeps the sessions; for an account already on v2 the token means nothing and is dropped + let is_upgrade = data.unlock_data.v2_upgrade_token.is_some() && !headers.user.is_v2(); + let upgrade_token = match &data.unlock_data.v2_upgrade_token { + Some(token) if is_upgrade => Some(serde_json::to_string(token)?), + _ => None, + }; + + // An upgrade can't re-wrap the recovery keys without a trust prompt, so they're kept and get the token + // Ref: + for membership in &existing_memberships { + let has_key = data + .unlock_data + .organization_account_recovery_unlock_data + .iter() + .find(|rp| rp.organization_id == membership.org_uuid) + .and_then(|rp| rp.reset_password_key.as_deref()) + .is_some_and(|key| !key.trim().is_empty()); + if is_upgrade && has_key { + err!("Account recovery keys cannot be rotated during a V1 to V2 upgrade rotation.") + } + if !is_upgrade && !has_key { + err!("Account recovery keys cannot be null or empty during rotation.") + } + } + + // Ref: + for emergency_access in &existing_emergency_access { + if data + .unlock_data + .emergency_access_unlock_data + .iter() + .find(|ea| ea.id == emergency_access.uuid) + .is_some_and(|ea| ea.key_encrypted.is_none()) + { + err!("Emergency access keys cannot be set to null during rotation.") + } + } + + // Saving's own checks, so that they can't fail halfway + for cipher_data in data.account_data.ciphers.iter().filter(|c| c.organization_id.is_none()) { + if let Some(cipher) = existing_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) { + validate_rotated_cipher(cipher, cipher_data, &headers, &conn).await?; + } + } + + // TODO: Ideally we'd do everything after this point in a single transaction. + + // Update folder data, ignoring ids that aren't the user's, like upstream for folder_data in data.account_data.folders { // Skip `null` folder id entries. // See: https://github.com/bitwarden/clients/issues/8453 - if let Some(folder_id) = folder_data.id { - let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else { - err!("Folder doesn't exist") - }; - + if let Some(saved_folder) = folder_data.id.and_then(|id| existing_folders.iter_mut().find(|f| f.uuid == id)) { saved_folder.name = folder_data.name; saved_folder.save(&conn).await?; } } - // Update emergency access data - for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data { - let Some(saved_emergency_access) = - existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id) - else { - err!("Emergency access doesn't exist or is not owned by the user") - }; - - saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted); - saved_emergency_access.save(&conn).await?; + // Like upstream, the unlock data below comes from the first entry for each, as checked above + for saved_emergency_access in &mut existing_emergency_access { + if let Some(emergency_access_data) = + data.unlock_data.emergency_access_unlock_data.iter().find(|ea| ea.id == saved_emergency_access.uuid) + { + saved_emergency_access.key_encrypted.clone_from(&emergency_access_data.key_encrypted); + saved_emergency_access.save(&conn).await?; + } } - // Update reset password data - for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data { - let Some(membership) = - existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id) - else { - err!("Reset password doesn't exist") - }; - - membership.reset_password_key = Some(reset_password_data.reset_password_key); - membership.save(&conn).await?; + for membership in &mut existing_memberships { + if let Some(reset_password_data) = data + .unlock_data + .organization_account_recovery_unlock_data + .iter() + .find(|rp| rp.organization_id == membership.org_uuid) + { + if !is_upgrade { + membership.reset_password_key.clone_from(&reset_password_data.reset_password_key); + } + membership.v2_upgrade_token.clone_from(&upgrade_token); + membership.save(&conn).await?; + } } // Update send data for send_data in data.account_data.sends { - let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) else { - err!("Send doesn't exist") - }; - - // Like upstream, only the key changes on rotation - send.akey = send_data.key; - send.save(&conn).await?; + if let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) { + // Like upstream, only the key changes on rotation + send.akey = send_data.key; + send.save(&conn).await?; + } } // Update cipher data for cipher_data in data.account_data.ciphers { - if cipher_data.organization_id.is_none() { - let Some(saved_cipher) = + if cipher_data.organization_id.is_none() + && let Some(saved_cipher) = cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id)) - else { - err!("Cipher doesn't exist") - }; - - // Prevent triggering cipher updates via WebSockets by settings UpdateType::None - // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. - // We force the users to logout after the user has been saved to try and prevent these issues. - update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; + { + // No cipher pushes: the other sessions still hold the old user key + rotate_cipher_data(saved_cipher, cipher_data, &headers, &conn, &nt).await?; } } // Update user data let mut user = headers.user; + user.v2_upgrade_token = upgrade_token; - user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key); - user.set_password( - &data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash, - Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key), - true, - None, - &conn, - ) - .await?; + data.account_keys.apply(&mut user)?; + // The old id names a key that no longer exists, so it is replaced even when the new key has none. + user.key_id = data.new_user_key_id; - let save_result = user.save(&conn).await; + user.akey = data.wrapped_user_key; + if let Some((new_password_hash, new_password_hint)) = data.new_password { + // A password change ends every session, as `/accounts/password` does + user.password_hint = new_password_hint; + user.set_password(&new_password_hash, None, true, None, &conn).await?; + } else if !is_upgrade { + // An upgrade keeps the sessions alive, see `is_upgrade` above + user.reset_security_stamp_after_key_rotation(&conn).await?; + } + + // The key pair first, so that a v2 account is never saved without one. The data above is already re-encrypted by + // now, and for an account that was already v2 so is the signing key saved here, so a failure in either save + // leaves them under a key that isn't stored (see the TODO before the first write). + data.account_keys.save_signature_key_pair(&user.uuid, &conn).await?; + user.save(&conn).await?; // Prevent logging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. - nt.send_logout(&user, Some(&headers.device), &conn).await; + // On an upgrade, the reason lets clients sync instead + let reason = is_upgrade.then_some(LogOutReason::KeyRotation); + nt.send_logout_with_reason(&user, Some(&headers.device), reason, &conn).await; - save_result + Ok(()) +} + +/// Rotates the keys without changing the master password; like upstream, the session is the only proof. +/// +/// Ref: +#[post("/accounts/key-management/rotate-user-keys", data = "")] +async fn post_rotate_user_keys( + data: Json, + headers: Headers, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let data: RotateUserKeysData = data.into_inner(); + validate_rotation_request(&data.account_data, &data.unlock_data)?; + + // Ref: + let wrapped_user_key = match UnlockMethod::from_i32(data.unlock_method_data.unlock_method) { + Some(UnlockMethod::MasterPassword) => { + let (Some(unlock_data), None) = ( + data.unlock_method_data.master_password_unlock_data, + data.unlock_method_data.key_connector_key_wrapped_user_key, + ) else { + err!( + "Invalid MasterPassword unlock method request, MasterPasswordUnlockData must be provided and KeyConnectorKeyWrappedUserKey must be null" + ) + }; + required(Some(unlock_data.master_key_wrapped_user_key.clone()), "MasterKeyWrappedUserKey")?; + required(Some(unlock_data.salt.clone()), "Salt")?; + validate_max_length(&unlock_data.salt, 256, "Salt")?; + + // Ref: + if headers.user.password_hash.is_empty() || headers.user.akey.is_empty() { + err!("User is in an invalid state for master password key rotation.") + } + if unlock_data.salt != headers.user.master_password_salt() { + err!("Invalid master password salt.") + } + if !unlock_data.kdf.is_unchanged_for(&headers.user) { + err!("Invalid KDF settings.") + } + validate_contained_key_id(unlock_data.contained_key_id.as_ref(), data.new_user_key_id.as_ref())?; + unlock_data.master_key_wrapped_user_key + } + Some(UnlockMethod::Tde) => err!("Trusted device encryption is not supported"), + Some(UnlockMethod::KeyConnector) => err!("Key connector is not supported"), + None => err!("Unrecognized unlock method"), + }; + + rotate_account( + RotateData { + account_keys: data.wrapped_account_cryptographic_state.validate()?, + account_data: data.account_data, + unlock_data: data.unlock_data, + old_user_key_id: data.old_user_key_id, + new_user_key_id: data.new_user_key_id, + wrapped_user_key, + new_password: None, + }, + headers, + conn, + nt, + ) + .await } #[derive(Deserialize)] @@ -1073,8 +1870,9 @@ 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_code!("Unexpected data", Status::UnprocessableEntity.code); + err!("User key id is already set.") } user.key_id = Some(data.into_inner().user_key_id); @@ -1326,7 +2124,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); + push_logout_to_relay(&user, None, None); } Ok(()) } @@ -1354,7 +2152,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); + push_logout_to_relay(&user, None, None); } Ok(()) } @@ -1958,10 +2756,10 @@ mod tests { ) .unwrap(); - assert!(!data.unprocessable()); - assert_eq!(data.hash(), "hash"); - assert_eq!(data.key(), "key"); - assert_eq!(data.kdf().kdf_iterations, 600_000); + 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.keys.is_some()); assert_eq!(data.email_verification_token.as_deref(), Some("token")); } @@ -1983,10 +2781,10 @@ mod tests { ) .unwrap(); - assert!(!data.unprocessable()); - assert_eq!(data.hash(), "hash"); - assert_eq!(data.key(), "key"); - assert_eq!(data.kdf().kdf_iterations, 600_000); + 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.keys.is_some()); } } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 97f5fd49..749e8910 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -21,9 +21,10 @@ use crate::{ db::{ DbConn, DbPool, models::{ - 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, + Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, 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}, @@ -54,6 +55,7 @@ pub fn routes() -> Vec { post_ciphers_create, post_ciphers_import, get_attachment, + get_attachment_admin, post_attachment_v2, post_attachment_v2_data, post_attachment, // legacy @@ -189,12 +191,22 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option JsonResult { #[get("/ciphers/")] async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { - err!("Cipher is not owned by user") - } - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + get_cipher_impl(cipher_id, &headers, CipherAccessScope::User, &conn).await } #[get("/ciphers//admin")] async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - // TODO: Implement this correctly - get_cipher(cipher_id, headers, conn).await + get_cipher_impl(cipher_id, &headers, CipherAccessScope::OrganizationAdmin, &conn).await } #[get("/ciphers//details")] @@ -255,6 +255,38 @@ async fn get_cipher_details(cipher_id: CipherId, headers: Headers, conn: DbConn) get_cipher(cipher_id, headers, conn).await } +async fn get_cipher_impl( + cipher_id: CipherId, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, +) -> JsonResult { + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, conn).await else { + err!("Cipher doesn't exist") + }; + + if !cipher.is_accessible_to_user(&headers.user.uuid, scope, conn).await { + err!("Cipher is not owned by user") + } + + Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?)) +} + +/// The cipher, as answered to a route authorized with `scope`. +async fn cipher_json_for_scope( + cipher: &Cipher, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, +) -> Result { + match scope { + CipherAccessScope::User => { + cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await + } + CipherAccessScope::OrganizationAdmin => cipher.to_json_org_admin(&headers.host, &headers.user.uuid, conn).await, + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CipherData { @@ -269,7 +301,8 @@ pub struct CipherData { key: Option, - pub encrypted_for: UserId, // Added in web-v2025.6.0 + // Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent + pub encrypted_for: Option, // Added in web-v2025.8.1, Optional for compat pub encrypted_by_key_id: Option, @@ -284,7 +317,8 @@ pub struct CipherData { Passport = 8 */ pub r#type: i32, - pub name: String, + // Absent on a blob-encrypted cipher, whose name is sealed inside `data` + pub name: Option, pub notes: Option, fields: Option, @@ -298,6 +332,9 @@ 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, @@ -319,6 +356,98 @@ 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 { @@ -337,7 +466,8 @@ pub struct Attachments2Data { /// Called when an org admin clones an org cipher. #[post("/ciphers/admin", data = "")] async fn post_ciphers_admin(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - post_ciphers_create(data, headers, conn, nt).await + // The same as /ciphers/create, only answered like the other administrative routes + post_ciphers_create_impl(data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } /// Called when creating a new org-owned cipher, or cloning a cipher (whether @@ -349,21 +479,40 @@ async fn post_ciphers_create( headers: Headers, conn: DbConn, nt: Notify<'_>, +) -> JsonResult { + post_ciphers_create_impl(data, headers, CipherAccessScope::User, conn, nt).await +} + +async fn post_ciphers_create_impl( + data: Json, + headers: Headers, + response_scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); - if data.cipher.encrypted_for != headers.user.uuid { - err_code!("Invalid user cipher", Status::UnprocessableEntity.code); - } + validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?; // 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, data.cipher.name.clone()); + if response_scope == CipherAccessScope::OrganizationAdmin { + let Some(org_id) = data.cipher.organization_id.as_ref() else { + err!("Cipher is not owned by an organization") + }; + if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) + .await + .is_some_and(|member| member.can_edit_any_collection()) + { + err!("Cipher is not write accessible") + } + } + + let mut cipher = Cipher::new(data.cipher.r#type); cipher.user_uuid = Some(headers.user.uuid.clone()); - cipher.save(&conn).await?; // When cloning a cipher, the Bitwarden clients seem to set this field // based on the cipher being cloned (when creating a new cipher, it's set @@ -373,11 +522,7 @@ async fn post_ciphers_create( // or otherwise), we can just ignore this field entirely. data.cipher.last_known_revision_date = None; - let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await; - if res.is_err() { - cipher.delete(&conn).await?; - } - res + share_cipher_by_uuid(&cipher.uuid.clone(), Some(cipher), data, &headers, response_scope, &conn, &nt, None).await } /// Called when creating a new user-owned cipher. @@ -385,16 +530,7 @@ 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(); - 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); - } + validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?; // 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`, @@ -402,8 +538,8 @@ 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, data.name.clone()); - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; + let mut cipher = Cipher::new(data.r#type); + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate, true).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } @@ -426,6 +562,48 @@ 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(()) +} + +#[expect( + clippy::too_many_arguments, + reason = "Cipher updates need separate access, notification and transaction controls" +)] pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, @@ -434,6 +612,46 @@ pub async fn update_cipher_from_data( conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, + emit_side_effects: bool, +) -> EmptyResult { + enforce_personal_ownership_policy(Some(&data), headers, conn).await?; + save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut, emit_side_effects).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, false).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 +} + +#[expect( + clippy::too_many_arguments, + reason = "Cipher updates need separate access, notification and transaction controls" +)] +async fn save_cipher_data( + cipher: &mut Cipher, + data: CipherData, + headers: &Headers, + shared_to_collections: Option>, + conn: &DbConn, + nt: &Notify<'_>, + ut: UpdateType, + emit_side_effects: bool, ) -> EmptyResult { // Cleanup cipher data, like removing the 'Response' key. // This key is somewhere generated during Javascript so no way for us this fix this. @@ -449,34 +667,15 @@ pub async fn update_cipher_from_data( json_data } - 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(_) => (), - } - } + 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?; 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(); @@ -488,8 +687,9 @@ pub async fn update_cipher_from_data( // access to them, so we can move the cipher into the organization on that basis. // Write access to the cipher itself only counts when it already belongs to this organization. if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) - || member.has_full_access() - || (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await) + || member.can_edit_any_collection() + || (!transfer_cipher + && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await) { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. @@ -507,12 +707,6 @@ pub async fn update_cipher_from_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 { @@ -551,26 +745,36 @@ pub async fn update_cipher_from_data( _ => err!("Invalid type"), }; - let type_data = if let Some(mut data) = type_data_opt { + 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") + }; // Remove the 'Response' key from the base object. - if let Some(data_obj) = data.as_object_mut() { + if let Some(data_obj) = type_data.as_object_mut() { data_obj.remove("response"); } // Remove the 'Response' key from every Uri. - if data["uris"].is_array() { - data["uris"] = clean_cipher_data(data["uris"].clone()); + if type_data["uris"].is_array() { + type_data["uris"] = clean_cipher_data(type_data["uris"].clone()); } - data - } else { - err!("Data missing") - }; + + // `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(); + } 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?; @@ -585,7 +789,7 @@ pub async fn update_cipher_from_data( None => cipher.unarchive(&headers.user.uuid, conn).await?, } - if ut != UpdateType::None { + if ut != UpdateType::None && emit_side_effects { // Only log events for organizational ciphers if let Some(org_id) = &cipher.organization_uuid { let event_type = match (&ut, transfer_cipher) { @@ -670,8 +874,8 @@ 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, cipher_data.name.clone()); - update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; + let mut cipher = Cipher::new(cipher_data.r#type); + update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true).await?; } let mut user = headers.user; @@ -695,7 +899,7 @@ async fn put_cipher_admin( err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code); } - put_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } #[post("/ciphers//admin", data = "")] @@ -727,6 +931,17 @@ async fn put_cipher( headers: Headers, conn: DbConn, nt: Notify<'_>, +) -> JsonResult { + put_cipher_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await +} + +async fn put_cipher_impl( + cipher_id: CipherId, + data: Json, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let data: CipherData = data.into_inner(); @@ -739,18 +954,21 @@ async fn put_cipher( // cipher itself, so the user shouldn't need write access to change these. // Interestingly, upstream Bitwarden doesn't properly handle this either. - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { 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") } - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate, true).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) } #[post("/ciphers//partial", data = "")] @@ -777,7 +995,7 @@ async fn put_cipher_partial( err!("Cipher does not exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { err!("Cipher does not exist", "Cipher is not accessible for the current user") } @@ -848,65 +1066,7 @@ async fn post_collections_update( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let data: CollectionsAdminData = data.into_inner(); - - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { - err!("Collection cannot be changed") - } - - let Some(ref org_uuid) = cipher.organization_uuid else { - err!("Cipher is not owned by an organization") - }; - - let posted_collections = HashSet::::from_iter(data.collection_ids); - let current_collections = - HashSet::::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await); - - for collection in posted_collections.symmetric_difference(¤t_collections) { - match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { - None => err!("Invalid collection ID provided"), - Some(collection) => { - if collection.is_writable_by_user(&headers.user.uuid, &conn).await { - if posted_collections.contains(&collection.uuid) { - // Add to collection - CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; - } else { - // Remove from collection - CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; - } - } else { - err!("No rights to modify the collection") - } - } - } - } - - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(&conn).await, - &headers.device, - Some(Vec::from_iter(posted_collections)), - &conn, - ) - .await; - - log_event( - EventType::CipherUpdatedCollections, - &cipher.uuid, - org_uuid, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + update_collections_by_scope(cipher_id, data.into_inner(), headers, CipherAccessScope::User, conn, nt).await } #[put("/ciphers//collections-admin", data = "")] @@ -928,65 +1088,94 @@ async fn post_collections_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let data: CollectionsAdminData = data.into_inner(); + update_collections_by_scope(cipher_id, data.into_inner(), headers, CipherAccessScope::OrganizationAdmin, conn, nt) + .await +} - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { - err!("Collection cannot be changed") - } - - let Some(ref org_uuid) = cipher.organization_uuid else { - err!("Cipher is not owned by an organization") - }; - - let posted_collections = HashSet::::from_iter(data.collection_ids); - let current_collections = - HashSet::::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await); - - for collection in posted_collections.symmetric_difference(¤t_collections) { - match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { - None => err!("Invalid collection ID provided"), - Some(collection) => { - if collection.is_writable_by_user(&headers.user.uuid, &conn).await { - if posted_collections.contains(&collection.uuid) { - // Add to collection - CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; - } else { - // Remove from collection - CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; - } - } else { - err!("No rights to modify the collection") +async fn update_collections_by_scope( + cipher_id: CipherId, + data: CollectionsAdminData, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + let (cipher, posted_collections, org_uuid, affected_users) = conn + .transaction(async || { + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, scope, &conn).await { + err!("Collection cannot be changed") + } + let Some(org_uuid) = cipher.organization_uuid.clone() else { + err!("Cipher is not owned by an organization") + }; + let posted = HashSet::::from_iter(data.collection_ids); + let current = HashSet::::from_iter(match scope { + CipherAccessScope::User => cipher.get_collections(headers.user.uuid.clone(), &conn).await, + CipherAccessScope::OrganizationAdmin => { + cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await + } + }); + // The admin route accepts Edit Any for all collections of this cipher's organization. + // The personal route keeps the direct/group write check, even for that same member. + let admin_edit_any = scope == CipherAccessScope::OrganizationAdmin + && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_uuid, &conn) + .await + .is_some_and(|member| member.can_edit_any_collection()); + for id in &posted { + if Collection::find_by_uuid_and_org(id, &org_uuid, &conn).await.is_none() { + err!("Invalid collection ID provided") } } - } - } + let mut changes = Vec::new(); + for id in posted.symmetric_difference(¤t) { + let Some(collection) = Collection::find_by_uuid_and_org(id, &org_uuid, &conn).await else { + err!("Invalid collection ID provided") + }; + if !admin_edit_any && !collection.is_writable_by_user(&headers.user.uuid, &conn).await { + err!("No rights to modify the collection") + } + changes.push((collection.uuid, posted.contains(id))); + } + // Every ID and permission has been checked. A database error rolls back links and revisions. + for (id, add) in changes { + if add { + CollectionCipher::save(&cipher.uuid, &id, &conn).await?; + } else { + CollectionCipher::delete(&cipher.uuid, &id, &conn).await?; + } + } + let affected_users = cipher.update_users_revision(&conn).await; + Ok((cipher, posted, org_uuid, affected_users)) + }) + .await?; nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - &cipher.update_users_revision(&conn).await, + &affected_users, &headers.device, Some(Vec::from_iter(posted_collections)), &conn, ) .await; - log_event( EventType::CipherUpdatedCollections, &cipher.uuid, - org_uuid, + &org_uuid, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?)) + let sync_type = match scope { + CipherAccessScope::User => CipherSyncType::User, + CipherAccessScope::OrganizationAdmin => CipherSyncType::Organization, + }; + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, sync_type, &conn).await?)) } #[derive(Deserialize)] @@ -1019,15 +1208,22 @@ 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 + share_cipher_by_uuid(&cipher_id, None, data, &headers, CipherAccessScope::User, &conn, &nt, None).await } #[derive(Deserialize)] @@ -1085,21 +1281,35 @@ 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 { - if cipher.id.is_none() { - err!("Request missing ids field") - } - if cipher.organization_id.is_none() { - err!("Cipher OrganizationId is required.") - } + 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?; } let mut responses = Vec::with_capacity(data.ciphers.len()); @@ -1110,14 +1320,23 @@ async fn put_cipher_share_selected( }; if let Some(id) = shared_cipher_data.cipher.id.take() { - let response = - share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?; + let response = share_cipher_by_uuid( + &id, + None, + shared_cipher_data, + &headers, + CipherAccessScope::User, + &conn, + &nt, + Some(UpdateType::None), + ) + .await?; // Like upstream's VisibleToClient, withhold a cipher if sharing it // removed the user's access to it. Only then include its encrypted // mini response; the full single-share response stays internal. if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await - && shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await + && shared_cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { responses.push(cipher_mini_from_json(response.into_inner())); } @@ -1136,49 +1355,18 @@ async fn put_cipher_share_selected( }))) } +#[expect(clippy::too_many_arguments, reason = "New and existing ciphers share one atomic validation path")] async fn share_cipher_by_uuid( cipher_id: &CipherId, + new_cipher: Option, data: ShareCipherData, headers: &Headers, + // Only the admin creation route may use organization-wide collection authority. + response_scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, override_ut: Option, ) -> JsonResult { - let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { - if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { - cipher - } else { - err!("Cipher is not write accessible") - } - } else { - err!("Cipher doesn't exist") - }; - - // `update_cipher_from_data()` rejects this too, but only after the collections below were - // already linked. There are no transactions, so that would leave the cipher linked to a - // collection of another organization. - if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id { - err!("Organization mismatch. Please resync the client before updating the cipher") - } - - let mut shared_to_collections = vec![]; - - if let Some(organization_id) = &data.cipher.organization_id { - for col_id in &data.collection_ids { - match Collection::find_by_uuid_and_org(col_id, organization_id, conn).await { - None => err!("Invalid collection ID provided"), - Some(collection) => { - if collection.is_writable_by_user(&headers.user.uuid, conn).await { - CollectionCipher::save(&cipher.uuid, &collection.uuid, conn).await?; - shared_to_collections.push(collection.uuid); - } else { - err!("No rights to modify the collection") - } - } - } - } - } - // 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 { @@ -1188,10 +1376,81 @@ async fn share_cipher_by_uuid( } else { UpdateType::SyncCipherCreate }; + let (cipher, shared_to_collections, transfer_cipher, affected_users) = conn + .transaction(async || { + let is_new = new_cipher.is_some(); + let mut cipher = if let Some(cipher) = new_cipher { + cipher + } else if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { + cipher + } else { + err!("Cipher doesn't exist") + }; + if !cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { + err!("Cipher is not write accessible") + } + if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id { + err!("Organization mismatch. Please resync the client before updating the cipher") + } + let transfer_cipher = cipher.organization_uuid.is_none() && data.cipher.organization_id.is_some(); + let mut shared_to_collections = Vec::new(); + if let Some(organization_id) = &data.cipher.organization_id { + let admin_edit_any = response_scope == CipherAccessScope::OrganizationAdmin + && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, organization_id, conn) + .await + .is_some_and(|member| member.can_edit_any_collection()); + if response_scope == CipherAccessScope::OrganizationAdmin && !admin_edit_any { + err!("Cipher is not write accessible") + } + for col_id in HashSet::::from_iter(data.collection_ids) { + let Some(collection) = Collection::find_by_uuid_and_org(&col_id, organization_id, conn).await + else { + err!("Invalid collection ID provided") + }; + if !admin_edit_any && !collection.is_writable_by_user(&headers.user.uuid, conn).await { + err!("No rights to modify the collection") + } + shared_to_collections.push(collection.uuid); + } + } + if is_new { + cipher.save(conn).await?; + } + // Keep the cipher, collection links and affected revisions in one transaction. + // The helper still validates stale writes, ownership, folders and cipher data. + update_cipher_from_data( + &mut cipher, + data.cipher, + headers, + Some(shared_to_collections.clone()), + conn, + nt, + ut, + false, + ) + .await?; + for col_id in &shared_to_collections { + CollectionCipher::save(&cipher.uuid, col_id, conn).await?; + } + // Bulk sharing skips per-cipher pushes, but new group-derived recipients still need a revision update. + let affected_users = cipher.update_users_revision(conn).await; + Ok((cipher, shared_to_collections, transfer_cipher, affected_users)) + }) + .await?; - 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?)) + if ut != UpdateType::None { + if let Some(org_id) = &cipher.organization_uuid { + let event_type = match (&ut, transfer_cipher) { + (UpdateType::SyncCipherCreate, true) => EventType::CipherCreated, + (UpdateType::SyncCipherUpdate, true) => EventType::CipherShared, + (_, _) => EventType::CipherUpdated, + }; + log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) + .await; + } + nt.send_cipher_update(ut, &cipher, &affected_users, &headers.device, Some(shared_to_collections), conn).await; + } + Ok(Json(cipher_json_for_scope(&cipher, headers, response_scope, conn).await?)) } /// v2 API for downloading an attachment. This just redirects the client to @@ -1206,12 +1465,44 @@ async fn get_attachment( attachment_id: AttachmentId, headers: Headers, conn: DbConn, +) -> JsonResult { + get_attachment_data(cipher_id, attachment_id, headers, CipherAccessScope::User, conn).await +} + +#[get("/ciphers//attachment//admin")] +async fn get_attachment_admin( + cipher_id: CipherId, + attachment_id: AttachmentId, + headers: Headers, + conn: DbConn, +) -> JsonResult { + get_attachment_data(cipher_id, attachment_id, headers, CipherAccessScope::OrganizationAdmin, conn).await +} + +async fn get_attachment_data( + cipher_id: CipherId, + attachment_id: AttachmentId, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, ) -> JsonResult { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if scope == CipherAccessScope::OrganizationAdmin { + // NotFound as in Bitwarden, the web vault then falls back to the attachment's own URL + let Some(org_id) = cipher.organization_uuid.as_ref() else { + err_code!("Cipher is not owned by an organization", Status::NotFound.code) + }; + if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) + .await + .is_some_and(|member| member.can_edit_any_collection()) + || !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await + { + err_code!("Cipher is not accessible", Status::NotFound.code) + } + } else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not accessible") } @@ -1282,15 +1573,19 @@ async fn post_attachment_v2( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { + let data: AttachmentRequestData = data.into_inner(); + + // `adminRequest` picks the check, the administrative one still needs a member who may edit any collection + let scope = CipherAccessScope::requested(data.admin_request); + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not write accessible") } - let data: AttachmentRequestData = data.into_inner(); let file_size = data.file_size.into_i64()?; if file_size < 0 { @@ -1314,9 +1609,9 @@ async fn post_attachment_v2( notify_attachment_created(&cipher, &headers, &conn, &nt).await; let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); - let response_key = match data.admin_request { - Some(b) if b => "cipherMiniResponse", - _ => "cipherResponse", + let response_key = match scope { + CipherAccessScope::OrganizationAdmin => "cipherMiniResponse", + CipherAccessScope::User => "cipherResponse", }; Ok(Json(json!({ // AttachmentUploadDataResponseModel @@ -1324,7 +1619,7 @@ async fn post_attachment_v2( "attachmentId": attachment_id, "url": url, "fileUploadType": FileUploadType::Direct as i32, - response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?, + response_key: cipher_json_for_scope(&cipher, &headers, scope, &conn).await?, }))) } @@ -1347,6 +1642,7 @@ async fn save_attachment( cipher_id: CipherId, data: Form>, headers: &Headers, + scope: CipherAccessScope, conn: DbConn, nt: Notify<'_>, ) -> Result<(Cipher, DbConn), crate::error::Error> { @@ -1363,7 +1659,7 @@ async fn save_attachment( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not write accessible") } @@ -1484,11 +1780,26 @@ async fn post_attachment_v2_data( None => err!("Attachment doesn't exist"), }; - save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + // This request does not tell whether it is administrative, the member's own membership decides + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + let scope = cipher_scope_for_member(&cipher, &headers.user.uuid, &conn).await; + + save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?; Ok(()) } +/// The access for a route on `cipher` which does not tell which one it is. +async fn cipher_scope_for_member(cipher: &Cipher, user_id: &UserId, conn: &DbConn) -> CipherAccessScope { + let Some(org_id) = cipher.organization_uuid.as_ref() else { + return CipherAccessScope::User; + }; + + CipherAccessScope::for_member(Membership::find_confirmed_by_user_and_org(user_id, org_id, conn).await.as_ref()) +} + /// Legacy API for creating an attachment associated with a cipher. #[post("/ciphers//attachment", format = "multipart/form-data", data = "")] async fn post_attachment( @@ -1498,13 +1809,7 @@ async fn post_attachment( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - // Setting this as None signifies to save_attachment() that it should create - // the attachment database record as well as saving the data to disk. - let attachment = None; - - let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + post_attachment_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await } #[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] @@ -1515,7 +1820,24 @@ async fn post_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_attachment(cipher_id, data, headers, conn, nt).await + post_attachment_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await +} + +async fn post_attachment_impl( + cipher_id: CipherId, + data: Form>, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + // Setting this as None signifies to save_attachment() that it should create + // the attachment database record as well as saving the data to disk. + let attachment = None; + + let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?; + + Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) } #[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] @@ -1527,7 +1849,7 @@ async fn post_attachment_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?; + delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await?; post_attachment(cipher_id, data, headers, conn, nt).await } @@ -1539,7 +1861,15 @@ async fn delete_attachment_post_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_attachment(cipher_id, attachment_id, headers, conn, nt).await + delete_cipher_attachment_by_id( + &cipher_id, + &attachment_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &nt, + ) + .await } #[post("/ciphers//attachment//delete")] @@ -1561,7 +1891,7 @@ async fn delete_attachment( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await + delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await } #[delete("/ciphers//attachment//admin")] @@ -1572,42 +1902,77 @@ async fn delete_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await + delete_cipher_attachment_by_id( + &cipher_id, + &attachment_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &nt, + ) + .await } #[post("/ciphers//delete")] async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) + .await // permanent delete } #[post("/ciphers//delete-admin")] async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::HardSingle, + &nt, + ) + .await // permanent delete } #[put("/ciphers//delete")] async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::SoftSingle, &nt) + .await // soft delete } #[put("/ciphers//delete-admin")] async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::SoftSingle, + &nt, + ) + .await // soft delete } #[delete("/ciphers/")] async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) + .await // permanent delete } #[delete("/ciphers//admin")] async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::HardSingle, + &nt, + ) + .await // permanent delete } @@ -1618,7 +1983,7 @@ async fn delete_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -1629,7 +1994,7 @@ async fn delete_cipher_selected_post( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -1640,7 +2005,7 @@ async fn delete_cipher_selected_put( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::SoftMulti, nt).await // soft delete } @@ -1651,7 +2016,15 @@ async fn delete_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::HardMulti, + nt, + ) + .await // permanent delete } @@ -1662,7 +2035,15 @@ async fn delete_cipher_selected_post_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::HardMulti, + nt, + ) + .await // permanent delete } @@ -1673,18 +2054,26 @@ async fn delete_cipher_selected_put_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::SoftMulti, + nt, + ) + .await // soft delete } #[put("/ciphers//restore")] async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await + restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::User, &conn, &nt).await } #[put("/ciphers//restore-admin")] async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await + restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[put("/ciphers/restore-admin", data = "")] @@ -1694,7 +2083,7 @@ async fn restore_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, &conn, &nt).await + restore_multiple_ciphers(data, &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[put("/ciphers/restore", data = "")] @@ -1704,7 +2093,7 @@ async fn restore_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, &conn, &nt).await + restore_multiple_ciphers(data, &headers, CipherAccessScope::User, &conn, &nt).await } #[derive(Deserialize)] @@ -1905,6 +2294,7 @@ pub enum CipherDeleteOptions { async fn delete_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, delete_options: &CipherDeleteOptions, nt: &Notify<'_>, @@ -1913,7 +2303,7 @@ async fn delete_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher can't be deleted by user") } @@ -1971,6 +2361,7 @@ struct CipherIdsData { async fn delete_multiple_ciphers( data: Json, headers: Headers, + scope: CipherAccessScope, conn: DbConn, delete_options: CipherDeleteOptions, nt: Notify<'_>, @@ -1978,7 +2369,7 @@ async fn delete_multiple_ciphers( let data = data.into_inner(); for cipher_id in data.ids { - if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await { + if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, scope, &conn, &delete_options, &nt).await { return error; } } @@ -1993,6 +2384,7 @@ async fn restore_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, multi_restore: bool, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2000,7 +2392,7 @@ async fn restore_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher can't be restored by user") } @@ -2032,12 +2424,13 @@ async fn restore_cipher_by_uuid( .await; } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) + Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?)) } async fn restore_multiple_ciphers( data: Json, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2045,7 +2438,7 @@ async fn restore_multiple_ciphers( let mut ciphers: Vec = Vec::new(); for cipher_id in data.ids { - match restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await { + match restore_cipher_by_uuid(&cipher_id, headers, true, scope, conn, nt).await { Ok(json) => ciphers.push(json.into_inner()), err => return err, } @@ -2065,6 +2458,7 @@ async fn delete_cipher_attachment_by_id( cipher_id: &CipherId, attachment_id: &AttachmentId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2080,7 +2474,7 @@ async fn delete_cipher_attachment_by_id( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher cannot be deleted by user") } @@ -2108,7 +2502,7 @@ async fn delete_cipher_attachment_by_id( ) .await; } - let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?; + let cipher_json = cipher_json_for_scope(&cipher, headers, scope, conn).await?; Ok(Json(json!({"cipher":cipher_json}))) } @@ -2123,7 +2517,7 @@ async fn archive_cipher( err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { err!("Cipher is not accessible for the current user") } @@ -2155,7 +2549,7 @@ async fn unarchive_cipher( err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { err!("Cipher is not accessible for the current user") } @@ -2347,3 +2741,146 @@ impl CipherSyncData { } } } + +#[cfg(all(test, sqlite))] +mod custom_role_tests { + use super::*; + use crate::{ + api::notifications::{WS_USERS, WebSocketUsers}, + auth::ClientIp, + db::{ + models::{CustomPermission, Device, User}, + test_db, + }, + }; + use chrono::Datelike; + use diesel::connection::SimpleConnection; + use std::sync::Arc; + + #[test] + fn bulk_share_preserves_vault_scope_collection_ids_and_user_revisions() { + test_db::run(async |db| { + let conn = db.conn(); + let (org, user, mut member) = test_db::custom_member(&conn, CustomPermission::EditAnyCollection).await; + let (reader, _) = test_db::member(&conn, &org.uuid, MembershipType::User, &[]).await; + let collection = test_db::collection(&conn, &org.uuid).await; + let hidden = test_db::collection(&conn, &org.uuid).await; + CollectionUser::save(&reader.uuid, &collection.uuid, false, false, false, &conn).await.unwrap(); + let mut ids = Vec::new(); + for _ in 0..2 { + let mut cipher = Cipher::new(2); + cipher.name = "Personal".into(); + cipher.user_uuid = Some(user.uuid.clone()); + cipher.save(&conn).await.unwrap(); + ids.push(cipher.uuid); + } + let rocket = rocket::build().manage(Arc::clone(&WS_USERS)); + let notify = rocket.state::>().unwrap(); + for assigned in [false, true] { + if assigned { + CollectionUser::save(&user.uuid, &collection.uuid, false, false, true, &conn).await.unwrap(); + } + conn.run(|db| db.batch_execute("UPDATE users SET updated_at='2000-01-01 00:00:00'")).await.unwrap(); + let ciphers = ids + .iter() + .map(|id| { + serde_json::from_value(json!({ + "id": id, "name": "Shared", "type": 2, "organizationId": org.uuid, + "encryptedFor": user.uuid, "secureNote": {"type": 0} + })) + .unwrap() + }) + .collect(); + let headers = Headers { + host: "http://localhost".into(), + device: Device::new("test".to_owned().into(), user.uuid.clone(), "test".into(), 1), + user: User::find_by_uuid(&user.uuid, &conn).await.unwrap(), + ip: ClientIp { + ip: "127.0.0.1".parse().unwrap(), + }, + }; + let result = put_cipher_share_selected( + Json(ShareSelectedCipherData { + ciphers, + collection_ids: vec![collection.uuid.clone()], + }), + headers, + db.conn(), + notify.into(), + ) + .await; + assert_eq!(result.is_ok(), assigned); + if assigned { + let response = result.unwrap().into_inner(); + assert_eq!(response["object"], "list"); + let ciphers = response["data"].as_array().unwrap(); + assert_eq!(ciphers.len(), ids.len()); + for cipher in ciphers { + assert_eq!(cipher["object"], "cipherMini"); + assert_eq!(cipher["organizationId"], json!(org.uuid)); + assert!(cipher.get("collectionIds").is_none()); + assert!(cipher.get("edit").is_none()); + } + } + for id in &ids { + let cipher = Cipher::find_by_uuid(id, &conn).await.unwrap(); + assert_eq!(cipher.organization_uuid.as_ref(), assigned.then_some(&org.uuid)); + assert_eq!(cipher.user_uuid.as_ref(), (!assigned).then_some(&user.uuid)); + } + let revision = User::find_by_uuid(&reader.uuid, &conn).await.unwrap().updated_at; + assert_eq!(revision.year() > 2000, assigned); + } + let mut cipher = Cipher::find_by_uuid(&ids[0], &conn).await.unwrap(); + CollectionCipher::save(&cipher.uuid, &hidden.uuid, &conn).await.unwrap(); + let personal = + cipher.to_json("http://localhost", &user.uuid, None, CipherSyncType::User, &conn).await.unwrap(); + let admin = cipher.to_json_org_admin("http://localhost", &member.user_uuid, &conn).await.unwrap(); + assert_eq!(personal["collectionIds"], json!([collection.uuid])); + let admin_ids = admin["collectionIds"].as_array().unwrap(); + assert_eq!(admin_ids.len(), 1); + assert!(admin_ids.contains(&json!(collection.uuid))); + // Exercise PR2's SQL authorization independently of Custom permission support added in PR3. + let sql = format!("DELETE FROM users_collections WHERE user_uuid='{}'", user.uuid); + conn.run(move |c| c.batch_execute(&sql)).await.unwrap(); + let foreign = test_db::organization(&conn).await; + let admin_scope = CipherAccessScope::requested(Some(true)); + assert_eq!(admin_scope, CipherAccessScope::OrganizationAdmin); + assert_eq!(CipherAccessScope::for_member(None), CipherAccessScope::User); + for request in [None, Some(false)] { + assert_eq!(CipherAccessScope::requested(request), CipherAccessScope::User); + } + for (role, status, all, allowed) in [ + (MembershipType::User, 2, false, false), + (MembershipType::Custom, 2, false, false), + (MembershipType::Manager, 2, false, false), + (MembershipType::Manager, 2, true, true), + (MembershipType::Owner, 2, false, true), + (MembershipType::Admin, 2, false, true), + (MembershipType::Admin, 1, false, false), + (MembershipType::Admin, -126, false, false), + ] { + member.set_type(role); + member.status = status; + member.access_all = all; + member.save(&conn).await.unwrap(); + assert_eq!( + CipherAccessScope::for_member(Some(&member)), + if allowed { + admin_scope + } else { + CipherAccessScope::User + } + ); + for org_id in [&org.uuid, &foreign.uuid] { + cipher.organization_uuid = Some(org_id.clone()); + for scope in [CipherAccessScope::User, admin_scope] { + assert_eq!( + cipher.is_write_accessible_to_user(&user.uuid, scope, &conn).await, + allowed && org_id == &org.uuid + ); + } + } + } + }); + } +} diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 3b758bb2..a256d854 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -10,7 +10,9 @@ use crate::{ auth::{AdminHeaders, Headers}, db::{ DbConn, DbPool, - models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, + models::{ + Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId, + }, }, error::MapResult, util::parse_date, @@ -220,7 +222,7 @@ async fn post_events_collect(data: Json>, headers: Headers, // user can actually access it instead of trusting the provided cipher uuid. if let Some(cipher_uuid) = &event.cipher_id && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await - && cipher.is_accessible_to_user(&headers.user.uuid, &conn).await + && cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await && let Some(org_id) = cipher.organization_uuid { log_event_impl( diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 607db45b..2460ae93 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -8,17 +8,18 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, - CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, - Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, - OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled, + Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, + Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, + Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, + custom_roles_enabled, }, }, mail, @@ -47,6 +48,7 @@ pub fn routes() -> Vec { bulk_delete_organization_collections, post_bulk_collections, get_org_details, + get_assigned_org_details, get_org_domain_sso_verified, get_members, send_invite, @@ -841,7 +843,7 @@ async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: Db err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code); } - if !headers.membership.has_full_access() { + if !headers.membership.can_edit_any_collection() { err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } @@ -859,7 +861,12 @@ async fn get_org_details_impl( conn: &DbConn, ) -> Result { let ciphers = Cipher::find_by_org(org_id, conn).await; - let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; + let mut cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; + // Every cipher of the organization is listed, so list every collection of them too + cipher_sync_data.cipher_collections = HashMap::new(); + for (cipher_id, collection_id) in Cipher::get_collections_with_cipher_by_organization(org_id, conn).await { + cipher_sync_data.cipher_collections.entry(cipher_id).or_default().push(collection_id); + } let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { @@ -868,6 +875,39 @@ async fn get_org_details_impl( Ok(json!(ciphers_json)) } +// The Admin Console lists these instead of every cipher of the organization for a member who may not edit every +// cipher, like a Custom member with Delete any collection only. As in Bitwarden, any confirmed member may list them. +#[get("/ciphers/organization-details/assigned?")] +async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { + let Some(member) = + Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await + else { + err_code!("Resource not found.", "User is not a confirmed member of the organization", Status::NotFound.code); + }; + + let (assigned, unassigned) = Cipher::find_assigned_to_member(&member, &conn).await; + let mut ciphers_json = Vec::with_capacity(assigned.len() + unassigned.len()); + // The ciphers of the member's own vault, as they are there + let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; + for cipher in assigned { + ciphers_json.push( + cipher + .to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn) + .await?, + ); + } + // The ciphers in no collection, as the member may use them in the Admin Console + for cipher in unassigned { + ciphers_json.push(cipher.to_json_org_admin(&headers.host, &headers.user.uuid, &conn).await?); + } + + Ok(Json(json!({ + "data": ciphers_json, + "object": "list", + "continuationToken": null, + }))) +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user // So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere // In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` @@ -1866,7 +1906,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, cipher_data.name.clone()); + let mut cipher = Cipher::new(cipher_data.r#type); update_cipher_from_data( &mut cipher, cipher_data, @@ -1875,6 +1915,7 @@ async fn post_org_import( &conn, &nt, UpdateType::None, + true, ) .await .ok(); @@ -1908,29 +1949,37 @@ struct BulkCollectionsData { async fn post_bulk_collections(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: BulkCollectionsData = data.into_inner(); - if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { + let Some(member) = + Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await + else { err!("You need to be a Member of the Organization to call this endpoint") - } + }; + // A member who may edit any collection may use every collection of the organization + let can_edit_any_collection = member.can_edit_any_collection(); // Get all the collection available to the user in one query // Also filter based upon the provided collections - let user_collections: HashMap = - Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn) - .await - .into_iter() - .filter_map(|c| { - if data.collection_ids.contains(&c.uuid) { - Some((c.uuid.clone(), c)) - } else { - None - } - }) - .collect(); + let collections = if can_edit_any_collection { + Collection::find_by_organization(&data.organization_id, &conn).await + } else { + Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn).await + }; + let user_collections: HashMap = collections + .into_iter() + .filter_map(|c| { + if data.collection_ids.contains(&c.uuid) { + Some((c.uuid.clone(), c)) + } else { + None + } + }) + .collect(); // Verify if all the collections requested exists and are writable for the user, else abort for collection_uuid in &data.collection_ids { match user_collections.get(collection_uuid) { - Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (), + Some(collection) + if can_edit_any_collection || collection.is_writable_by_user(&headers.user.uuid, &conn).await => {} _ => err_code!("Resource not found", "User does not have access to a collection", 404), } } @@ -1939,7 +1988,7 @@ async fn post_bulk_collections(data: Json, headers: Headers // Only act on existing cipher uuid's // Do not abort the operation just ignore it, it could be a cipher was just deleted for example if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await - && cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await + && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await { // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. @@ -2869,8 +2918,26 @@ 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: OrgMemberHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.membership.org_uuid { +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 { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { @@ -2883,11 +2950,30 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH }))) } +/// 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: OrgMemberHeaders, conn: DbConn) -> JsonResult { - get_organization_public_key(org_id, headers, conn).await +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 } // Will allow to reset 2FA too diff --git a/src/api/identity.rs b/src/api/identity.rs index 4608ed77..d7ae5068 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -29,7 +29,7 @@ use crate::{ db::{ DbConn, models::{ - AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, + AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError, OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, }, @@ -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).await + password_login(data, &mut user_id, &conn, &client_header).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).await + api_key_login(data, &mut user_id, &conn, &client_header).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).await + sso_login(data, &mut user_id, &conn, &client_header).await } "authorization_code" => err!("SSO sign-in is not available"), "send_access" => { @@ -179,7 +179,13 @@ 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) -> JsonResult { +async fn sso_login( + data: ConnectData, + user_id: &mut Option, + conn: &DbConn, + client_header: &ClientHeaders, +) -> JsonResult { + let ip = &client_header.ip; AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login @@ -317,6 +323,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon } let mut user = User::new(&user_infos.email, user_infos.user_name.clone()); + // Before saving, so that a client that can't log in doesn't create the account + check_client_version(&user, client_header)?; user.verified_at = Some(now); user.save(conn).await?; @@ -325,6 +333,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon (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), @@ -334,6 +344,9 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon ) } Some((mut user, sso_user)) => { + *user_id = Some(user.uuid.clone()); + check_client_version(&user, client_header)?; + let mut device = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; @@ -368,7 +381,13 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon 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) -> JsonResult { +async fn password_login( + data: ConnectData, + user_id: &mut Option, + conn: &DbConn, + client_header: &ClientHeaders, +) -> JsonResult { + let ip = &client_header.ip; // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -438,6 +457,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & ) } + check_client_version(&user, client_header)?; + // 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?; @@ -499,6 +520,66 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } +/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account +/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the +/// credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2. +/// +/// Ref: +/// Ref: +fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult { + let Some((error, description)) = client_version_error( + user.has_v2_encryption(), + client_header.client_version.as_ref(), + client_header.device_type, + ) else { + return Ok(()); + }; + Err(crate::error::Error::from(( + // Escaped: on a first SSO login the email comes from the provider and isn't validated yet + format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()), + 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>, + device_type: i32, +) -> Option<(&'static str, &'static str)> { + const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1); + const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2); + + let Some(version) = client_version else { + return 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\"", + )); + }; + if !is_v2 { + return None; + } + // Upstream's desktop device types + // Ref: + let is_desktop = matches!( + DeviceType::from_i32(device_type), + DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp + ); + let min_version = if is_desktop { + &MIN_V2_DESKTOP_CLIENT + } else { + &MIN_V2_CLIENT + }; + (version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden")) +} + async fn authenticated_response( user: &User, device: &mut Device, @@ -546,24 +627,13 @@ async fn authenticated_response( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.email + "Salt": user.master_password_salt() }) } else { Value::Null }; - 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 account_keys = user.account_keys_json(conn).await; let mut result = json!({ "access_token": auth_tokens.access_token(), @@ -598,7 +668,13 @@ async fn authenticated_response( Ok(Json(result)) } -async fn api_key_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { +async fn api_key_login( + data: ConnectData, + user_id: &mut Option, + conn: &DbConn, + client_header: &ClientHeaders, +) -> JsonResult { + let ip = &client_header.ip; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; @@ -610,7 +686,7 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option, conn: &D 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).await + user_api_key_login(data, user_id, conn, client_header).await } Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), @@ -621,8 +697,9 @@ async fn user_api_key_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - ip: &ClientIp, + client_header: &ClientHeaders, ) -> JsonResult { + let ip = &client_header.ip; // Get the user via the client_id let client_id = data.client_id.as_ref().unwrap(); let Some(client_user_id) = client_id.strip_prefix("user.") else { @@ -659,6 +736,8 @@ async fn user_api_key_login( ) } + check_client_version(&user, client_header)?; + let mut device = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && device.is_new() { @@ -703,24 +782,13 @@ async fn user_api_key_login( // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, - "Salt": user.email + "Salt": user.master_password_salt() }) } else { Value::Null }; - 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 account_keys = user.account_keys_json(conn).await; // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. @@ -1351,3 +1419,33 @@ 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(); + let web = DeviceType::ChromeBrowser as i32; + let desktop = DeviceType::WindowsDesktop as i32; + // Every login needs the header + assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing")); + assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing")); + // V1 accounts log in from any version + assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None); + assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None); + // V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app + assert_eq!( + client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0), + Some("invalid_client_version") + ); + assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None); + assert_eq!( + client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0), + Some("invalid_client_version") + ); + assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None); + assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 450c2113..42c6d53d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -23,7 +23,7 @@ pub use crate::api::{ icons::routes as icons_routes, identity::routes as identity_routes, notifications::routes as notifications_routes, - notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, + notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, push::{ push_cipher_update, push_folder_update, push_logout, push_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 3bce7219..304ce447 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -412,23 +412,35 @@ impl WebSocketUsers { } pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) { + self.send_logout_with_reason(user, acting_device, None, conn).await; + } + + /// A logout that clients may handle differently depending on `reason` + pub async fn send_logout_with_reason( + &self, + user: &User, + acting_device: Option<&Device>, + reason: Option, + conn: &DbConn, + ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let acting_device_id = acting_device.map(|d| d.uuid.clone()); - let data = create_update( - vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], - UpdateType::LogOut, - acting_device_id, - ); + let mut payload = + vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))]; + if let Some(reason) = reason { + payload.push(("Reason".into(), (reason as i32).into())); + } + let data = create_update(payload, UpdateType::LogOut, acting_device_id); if CONFIG.enable_websocket() { self.send_update(&user.uuid, &data).await; } if CONFIG.push_enabled() { - push_logout(user, acting_device, conn).await; + push_logout(user, acting_device, reason, conn).await; } } @@ -692,6 +704,14 @@ 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 4da790e4..dab31c22 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -12,7 +12,7 @@ use tokio::sync::RwLock; use crate::{ CONFIG, - api::{ApiResult, EmptyResult, UpdateType}, + api::{ApiResult, EmptyResult, LogOutReason, UpdateType}, db::{ DbConn, models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, @@ -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>, conn: &DbConn) { +pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { - push_logout_to_relay(user, acting_device); + push_logout_to_relay(user, acting_device, reason); } } // 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>) { +pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: Option) { tokio::task::spawn(send_to_push_relay(json!({ "userId": user.uuid, "organizationId": (), @@ -209,7 +209,8 @@ pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) { "type": UpdateType::LogOut as i32, "payload": { "userId": user.uuid, - "date": format_date(&user.updated_at) + "date": format_date(&user.updated_at), + "reason": reason.map(|r| r as i32), }, "clientType": null, "installationId": null diff --git a/src/auth.rs b/src/auth.rs index cedbb130..ff858c0e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -34,7 +34,7 @@ use crate::{ models::{ AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, - SendFileId, SendId, User, UserId, UserStampException, + SendFileId, SendId, User, UserId, }, }, error::Error, @@ -632,6 +632,9 @@ 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] @@ -648,10 +651,13 @@ 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, }) } } @@ -707,31 +713,12 @@ impl<'r> FromRequest<'r> for Headers { }; if user.security_stamp != claims.sstamp { - if let Some(stamp_exception) = - user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::(s).ok()) - { - let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { - err_handler!("Error getting current route for stamp exception") - }; + let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { + err_handler!("Error getting current route for stamp exception") + }; - // Check if the stamp exception has expired first. - // Then, check if the current route matches any of the allowed routes. - // After that check the stamp in exception matches the one in the claims. - if Utc::now().timestamp() > stamp_exception.expire { - // If the stamp exception has been expired remove it from the database. - // This prevents checking this stamp exception for new requests. - let mut user = user; - user.reset_stamp_exception(); - if let Err(e) = user.save(&conn).await { - error!("Error updating user: {e:#?}"); - } - err_handler!("Stamp exception is expired") - } else if !stamp_exception.routes.contains(¤t_route.to_owned()) { - err_handler!("Invalid security stamp: Current route and exception route do not match") - } else if stamp_exception.security_stamp != claims.sstamp { - err_handler!("Invalid security stamp for matched stamp exception") - } - } else { + // Expired exceptions are only dropped on reset; writing them back here could undo a newer reset + if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) { err_handler!("Invalid security stamp") } } diff --git a/src/config.rs b/src/config.rs index a565a93e..3f04b8d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1516,6 +1516,8 @@ 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/mod.rs b/src/db/mod.rs index 62b40965..3caa6cde 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,7 +1,11 @@ mod query_logger; use std::{ - sync::{Arc, OnceLock}, + future::Future, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; @@ -123,6 +127,7 @@ pub static ACTIVE_DB_TYPE: OnceLock = OnceLock::new(); pub struct DbConn { conn: Arc>>>, permit: Option, + transaction_open: AtomicBool, } #[derive(Debug)] @@ -150,6 +155,7 @@ impl Drop for DbConn { fn drop(&mut self) { let conn = Arc::clone(&self.conn); let permit = self.permit.take(); + let transaction_open = self.transaction_open.load(Ordering::Acquire); // Since connection can't be on the stack in an async fn during an // await, we have to spawn a new blocking-safe thread... @@ -157,7 +163,13 @@ impl Drop for DbConn { // And then re-enter the runtime to wait on the async mutex, but in a blocking fashion. let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned()); - if let Some(conn) = conn.take() { + if let Some(mut conn) = conn.take() { + if transaction_open { + // A cancelled request must not return an open transaction to the pool. + if let Err(error) = conn.batch_execute("ROLLBACK") { + error!("Failed to roll back a cancelled database transaction: {error}"); + } + } drop(conn); } @@ -248,6 +260,7 @@ impl DbPool { Ok(DbConn { conn: Arc::new(Mutex::new(Some(c))), permit: Some(permit), + transaction_open: AtomicBool::new(false), }) } } @@ -320,6 +333,29 @@ impl DbConnType { } impl DbConn { + /// Run asynchronous model calls on this request's pooled connection in one transaction. + /// Send external notifications only after this returns successfully. + pub async fn transaction(&self, operation: F) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?; + self.transaction_open.store(true, Ordering::Release); + let result = operation().await; + if result.is_ok() { + let committed = self.run(|conn| conn.batch_execute("COMMIT")).await; + let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok(); + if closed { + self.transaction_open.store(false, Ordering::Release); + } + committed.map_res("Error committing database transaction")?; + } else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() { + self.transaction_open.store(false, Ordering::Release); + } + result + } + pub async fn run(&self, f: F) -> R where F: FnOnce(&mut DbConnInner) -> R + Send, @@ -538,9 +574,61 @@ mod postgresql_migrations { /// A throwaway SQLite database with the real schema, for tests that need to run queries. #[cfg(all(test, sqlite))] pub mod test_db { - use super::sqlite_migrations::MIGRATIONS; - use diesel::connection::SimpleConnection; + use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS}; + use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection}; use diesel_migrations::MigrationHarness; + use std::sync::{Arc, atomic::AtomicBool}; + use tokio::sync::Mutex; + + pub struct TestDb { + pool: Pool, + // Keep the named in-memory database alive across pooled connections. + _keeper: SqliteConnection, + } + + impl TestDb { + fn new() -> Self { + let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid()); + let mut keeper = SqliteConnection::establish(&url).unwrap(); + keeper.run_pending_migrations(MIGRATIONS).unwrap(); + let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap(); + Self { + pool, + _keeper: keeper, + } + } + + pub fn conn(&self) -> DbConn { + DbConn { + conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))), + permit: None, + transaction_open: AtomicBool::new(false), + } + } + } + + // CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized. + pub fn run(test: impl AsyncFnOnce(&TestDb)) { + if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", std::thread::current().name().unwrap(), "--nocapture"]) + .env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1") + .env("DATABASE_URL", "sqlite://:memory:") + .output() + .unwrap(); + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + // DbConn::run uses block_in_place, requiring a multi-threaded runtime. + tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async { + test(&TestDb::new()).await; + }); + } #[test] fn custom_role_migration_preserves_memberships_and_guards_downgrade() { @@ -604,4 +692,70 @@ pub mod test_db { assert_eq!(grants(&mut conn), 0); assert_eq!(rows(&mut conn), before); } + + use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User}; + + pub async fn organization(conn: &DbConn) -> Organization { + let org = Organization::new("Org".into(), "billing@example.com", None, None); + org.save(conn).await.unwrap(); + org + } + + pub async fn member( + conn: &DbConn, + org: &OrganizationId, + role: MembershipType, + permissions: &[CustomPermission], + ) -> (User, Membership) { + let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None); + user.password_hash = b"existing-account".to_vec(); + user.save(conn).await.unwrap(); + let mut member = Membership::new(user.uuid.clone(), org.clone(), None); + member.set_type(role); + member.status = 2; + member.set_custom_permissions(&permissions.iter().copied().collect()); + member.save(conn).await.unwrap(); + (user, member) + } + + pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) { + let org = organization(conn).await; + let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await; + (org, user, member) + } + + pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection { + let collection = Collection::new(org.clone(), "Collection".into(), None); + collection.save(conn).await.unwrap(); + collection + } + + #[test] + fn transactions_rollback_before_connection_reuse_and_commit_success() { + use crate::error::MapResult; + use diesel::RunQueryDsl; + run(async |db| { + let conn = db.conn(); + conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap(); + for (sql, expected) in [ + ("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64), + ("INSERT INTO probe VALUES (1)", 1), + ] { + let result: Result<(), super::Error> = conn + .transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed")) + .await; + assert_eq!(result.is_ok(), expected == 1); + let count: i64 = conn + .run(|c| { + diesel::select(diesel::dsl::sql::("(SELECT COUNT(*) FROM probe)")) + .get_result(c) + }) + .await + .unwrap(); + assert_eq!(count, expected); + // The same live connection must accept another transaction, before DbConn::drop can help. + conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap(); + } + }); + } } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index db8e9bf4..e940511c 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashSet}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; @@ -19,7 +19,7 @@ use crate::{ }, }, error::MapResult, - util::LowerCase, + util::{LowerCase, convert_json_key_lcase_first}, }; use macros::UuidFromParam; @@ -63,14 +63,68 @@ 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, } +/// Which access a cipher operation is authorized with. +/// +/// The administrative cipher routes (`/ciphers//admin` and friends) are served by the same handlers as the +/// regular vault routes, so the handlers state which one applies. They only differ for a Custom member with Edit +/// any collection, who reaches every cipher of the organization on the administrative routes, but keeps their +/// own vault limited to the collections they are assigned to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CipherAccessScope { + User, + OrganizationAdmin, +} + +impl CipherAccessScope { + /// Whether `member` reaches every cipher of their organization with this access. + fn reaches_all_ciphers(self, member: &Membership) -> bool { + match self { + CipherAccessScope::User => member.has_full_access(), + CipherAccessScope::OrganizationAdmin => member.can_edit_any_collection(), + } + } + + /// The access the v2 attachment upload asks for with `adminRequest`. It only picks which check runs, the + /// administrative one still needs a member who may edit any collection. + pub fn requested(admin_request: Option) -> Self { + if admin_request == Some(true) { + CipherAccessScope::OrganizationAdmin + } else { + CipherAccessScope::User + } + } + + /// The access for a route that does not tell which one it is, from the member's own membership. + pub fn for_member(member: Option<&Membership>) -> Self { + if member.is_some_and(Membership::can_edit_any_collection) { + CipherAccessScope::OrganizationAdmin + } else { + CipherAccessScope::User + } + } +} + /// Local methods impl Cipher { - pub fn new(atype: i32, name: String) -> Self { + /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data` + pub fn new(atype: i32) -> Self { let now = Utc::now().naive_utc(); Self { @@ -84,7 +138,7 @@ impl Cipher { key: None, atype, - name, + name: String::new(), notes: None, fields: None, @@ -110,6 +164,16 @@ 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 { @@ -151,9 +215,36 @@ impl Cipher { cipher_sync_data: Option<&CipherSyncData>, sync_type: CipherSyncType, conn: &DbConn, + ) -> Result { + self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await + } + + /// [`Cipher::to_json`] for an administrative route, telling what the member may do with the cipher there. + pub async fn to_json_org_admin( + &self, + host: &str, + user_uuid: &UserId, + conn: &DbConn, + ) -> Result { + self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn) + .await + } + + async fn to_json_scoped( + &self, + host: &str, + user_uuid: &UserId, + cipher_sync_data: Option<&CipherSyncData>, + sync_type: CipherSyncType, + scope: CipherAccessScope, + conn: &DbConn, ) -> 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) @@ -179,7 +270,7 @@ impl Cipher { // We don't need these values at all for Organizational syncs // Skip any other database calls if this is the case and just return false. let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { - if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { + if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await { (ro, hp, mn) } else { error!("Cipher ownership assertion failure"); @@ -250,63 +341,9 @@ impl Cipher { }) .unwrap_or_default(); - // 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; - } + // 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); 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) { @@ -403,10 +440,84 @@ impl Cipher { _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), }; - json_object[key] = type_data_json; + 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); + } 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 { @@ -562,20 +673,21 @@ impl Cipher { self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid } - /// Returns whether this cipher is owned by an org in which the user has full access. + /// Returns whether this cipher is owned by an org in which the user reaches every cipher with `scope`. async fn is_in_full_access_org( &self, user_uuid: &UserId, + scope: CipherAccessScope, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> bool { if let Some(ref org_uuid) = self.organization_uuid { if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { - return cached_member.has_full_access(); + return scope.reaches_all_ciphers(cached_member); } } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { - return member.has_full_access(); + return scope.reaches_all_ciphers(&member); } } false @@ -608,6 +720,7 @@ impl Cipher { pub async fn get_access_restrictions( &self, user_uuid: &UserId, + scope: CipherAccessScope, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> Option<(bool, bool, bool)> { @@ -615,7 +728,7 @@ impl Cipher { // a collection that the user has full access to. If so, there are no // access restrictions. if self.is_owned_by_user(user_uuid) - || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await + || self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await { return Some((false, false, true)); @@ -738,8 +851,13 @@ impl Cipher { .await } - pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - match self.get_access_restrictions(user_uuid, None, conn).await { + pub async fn is_write_accessible_to_user( + &self, + user_uuid: &UserId, + scope: CipherAccessScope, + conn: &DbConn, + ) -> bool { + match self.get_access_restrictions(user_uuid, scope, None, conn).await { Some((read_only, _hide_passwords, manage)) => !read_only || manage, None => false, } @@ -747,15 +865,20 @@ impl Cipher { // used for checking if collection can be edited (only if user has access to a collection they // can write to and also passwords are not hidden to prevent privilege escalation) - pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - match self.get_access_restrictions(user_uuid, None, conn).await { + pub async fn is_in_editable_collection_by_user( + &self, + user_uuid: &UserId, + scope: CipherAccessScope, + conn: &DbConn, + ) -> bool { + match self.get_access_restrictions(user_uuid, scope, None, conn).await { Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, None => false, } } - pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - self.get_access_restrictions(user_uuid, None, conn).await.is_some() + pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool { + self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some() } // Returns whether this cipher is a favorite of the specified user. @@ -1193,6 +1316,50 @@ impl Cipher { }) .await } + + /// Every collection of every cipher of the organization, for the members who may see all of them. + pub async fn get_collections_with_cipher_by_organization( + org_uuid: &OrganizationId, + conn: &DbConn, + ) -> Vec<(CipherId, CollectionId)> { + conn.run(move |conn| { + ciphers_collections::table + .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) + .filter(collections::org_uuid.eq(org_uuid)) + .select(ciphers_collections::all_columns) + .load::<(CipherId, CollectionId)>(conn) + .unwrap_or_default() + }) + .await + } + + /// The ciphers of the organization of `member` the Admin Console lists when they may not edit every cipher, like + /// Bitwarden does: the ones in the member's own vault, then, for a member who may edit any collection, the ones in + /// no collection at all. + pub async fn find_assigned_to_member(member: &Membership, conn: &DbConn) -> (Vec, Vec) { + let assigned: Vec = Self::find_by_user_visible(&member.user_uuid, conn) + .await + .into_iter() + .filter(|cipher| cipher.organization_uuid.as_ref() == Some(&member.org_uuid)) + .collect(); + if !member.can_edit_any_collection() { + return (assigned, Vec::new()); + } + + let listed: HashSet = assigned.iter().map(|cipher| cipher.uuid.clone()).collect(); + let in_collections: HashSet = + Self::get_collections_with_cipher_by_organization(&member.org_uuid, conn) + .await + .into_iter() + .map(|(cipher, _)| cipher) + .collect(); + let unassigned = Self::find_by_org(&member.org_uuid, conn) + .await + .into_iter() + .filter(|cipher| !in_collections.contains(&cipher.uuid) && !listed.contains(&cipher.uuid)) + .collect(); + (assigned, unassigned) + } } #[derive( diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 8f62bf0b..b41d36ae 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -17,11 +17,12 @@ 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}; +pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType, is_data_blob_encrypted}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::emergency_access::{ @@ -41,4 +42,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; -pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException}; +pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType}; +pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index b7fcdfdf..f1e03354 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -69,6 +69,9 @@ 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)] @@ -407,6 +410,8 @@ impl Membership { access_event_logs: false, access_import_export: false, access_reports: false, + + v2_upgrade_token: None, } } @@ -483,6 +488,35 @@ impl Membership { ) } + fn custom_permission(&self, permission: CustomPermission) -> bool { + match permission { + CustomPermission::ManageUsers => self.manage_users, + CustomPermission::ManageGroups => self.manage_groups, + CustomPermission::ManagePolicies => self.manage_policies, + CustomPermission::CreateNewCollections => self.create_new_collections, + CustomPermission::EditAnyCollection => self.edit_any_collection, + CustomPermission::DeleteAnyCollection => self.delete_any_collection, + CustomPermission::AccessEventLogs => self.access_event_logs, + CustomPermission::AccessImportExport => self.access_import_export, + CustomPermission::AccessReports => self.access_reports, + } + } + + /// Whether the role of this member grants `permission`. Only a Custom role grants any, only while Custom roles + /// are enabled, and only the permissions this version enforces. + fn grants_custom_permission(&self, permission: CustomPermission) -> bool { + custom_roles_enabled() + && permission.is_supported() + && self.has_type(MembershipType::Custom) + && self.custom_permission(permission) + } + + /// Whether this member may act with `permission`, granted by their Custom role. Like the authority of any + /// other role, it needs a confirmed membership. + pub fn has_custom_permission(&self, permission: CustomPermission) -> bool { + self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission) + } + pub fn restore(&mut self) -> bool { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; @@ -1001,6 +1035,13 @@ impl Membership { (self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed) } + /// Whether this member may edit every collection and the items in them in the Admin Console: every member with + /// full access, and a Custom member with Edit any collection. Unlike full access, the permission does not add + /// anything to the member's own vault. + pub fn can_edit_any_collection(&self) -> bool { + self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection) + } + pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { conn.run(move |conn| { users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::(conn).ok() diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 30541428..49ea67b7 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -6,6 +6,7 @@ use serde_json::Value; use crate::{ CONFIG, api::EmptyResult, + auth::DEFAULT_ACCESS_VALIDITY, crypto, db::{ DbConn, @@ -20,7 +21,7 @@ use macros::UuidFromParam; use super::{ AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, - TwoFactorIncomplete, + TwoFactorIncomplete, UserSignatureKeyPair, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] @@ -72,6 +73,13 @@ 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)] @@ -100,13 +108,28 @@ enum UserStatus { _Disabled = 2, } +/// A previous security stamp that is still accepted, until `expire`. #[derive(Serialize, Deserialize)] pub struct UserStampException { - pub routes: Vec, + /// The routes the stamp is still accepted on, or `None` for any route. + pub routes: Option>, pub security_stamp: String, pub expire: i64, } +impl UserStampException { + pub fn is_expired(&self) -> bool { + Utc::now().timestamp() > self.expire + } + + /// Whether this exception lets a token carrying `security_stamp` through on `route`. + pub fn allows(&self, security_stamp: &str, route: &str) -> bool { + !self.is_expired() + && self.security_stamp == security_stamp + && self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route)) + } +} + /// Local methods impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; @@ -159,9 +182,31 @@ 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(), @@ -171,6 +216,7 @@ impl User { ) } + /// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email pub fn master_password_salt(&self) -> String { self.email.trim().to_lowercase() } @@ -225,10 +271,33 @@ impl User { pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult { self.security_stamp = get_uuid(); + // Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace + let mut exceptions = self.stamp_exceptions(); + exceptions.retain(|e| e.routes.is_some() && !e.is_expired()); + self.set_stamp_exceptions(&exceptions); Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?; Ok(()) } + /// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh. + pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult { + let previous_stamp = self.security_stamp.clone(); + let mut grace: Vec = + self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect(); + + self.reset_security_stamp(conn).await?; + + grace.push(UserStampException { + routes: None, + security_stamp: previous_stamp, + expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(), + }); + let mut exceptions = self.stamp_exceptions(); + exceptions.extend(grace); + self.set_stamp_exceptions(&exceptions); + Ok(()) + } + /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// /// # Arguments @@ -237,17 +306,30 @@ impl User { /// After these 2 minutes this stamp will expire. /// pub fn set_stamp_exception(&mut self, route_exception: Vec) { - let stamp_exception = UserStampException { - routes: route_exception, + self.set_stamp_exceptions(&[UserStampException { + routes: Some(route_exception), security_stamp: self.security_stamp.clone(), expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(), - }; - self.stamp_exception = Some(serde_json::to_string(&stamp_exception).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; + /// 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()) + }; } pub fn display_name(&self) -> &str { @@ -266,18 +348,37 @@ 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 fn account_keys_json(&self) -> Value { + pub async fn account_keys_json(&self, conn: &DbConn) -> 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": null, + "signedPublicKey": signed_public_key, "object": "publicKeyEncryptionKeyPair", }, - "securityState": null, - "signatureKeyPair": null, + "securityState": security_state, + "signatureKeyPair": signature_key_pair, "object": "privateKeys" }) } else { @@ -285,6 +386,24 @@ 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 { @@ -305,7 +424,7 @@ impl User { UserStatus::Enabled }; - let account_keys = self.account_keys_json(); + let account_keys = self.account_keys_json(conn).await; json!({ "_status": status as i32, @@ -382,6 +501,7 @@ 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| { @@ -553,25 +673,26 @@ impl Invitation { pub struct UserId(String); #[derive( - Clone, - Debug, - DieselNewType, - FromForm, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - AsRef, - Deref, - Display, - From, - UuidFromParam, + Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, 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: @@ -625,3 +746,56 @@ 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() { + // User::new loads CONFIG; isolate it from the DNS bootstrap test and filesystem configuration. + if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", std::thread::current().name().unwrap(), "--nocapture"]) + .env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1") + .env("DATABASE_URL", "sqlite://:memory:") + .output() + .unwrap(); + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + 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 new file mode 100644 index 00000000..72509761 --- /dev/null +++ b/src/db/models/user_signature_key_pair.rs @@ -0,0 +1,132 @@ +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 e1b9a78d..98792674 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -219,6 +219,22 @@ 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, } } @@ -253,6 +269,7 @@ table! { access_event_logs -> Bool, access_import_export -> Bool, access_reports -> Bool, + v2_upgrade_token -> Nullable, } } @@ -393,6 +410,7 @@ 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, @@ -419,4 +437,5 @@ 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 369e255b..696c3360 100644 --- a/src/util.rs +++ b/src/util.rs @@ -899,6 +899,12 @@ 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