From 274ee69db3385314c8ff44d38e0eecef522c07f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Thu, 24 Sep 2026 00:53:31 +0200 Subject: [PATCH] Implement V2 registration support --- .typos.toml | 5 +- Cargo.toml | 2 +- .../2026-10-09-120000_user_crypto_v2/down.sql | 5 + .../2026-10-09-120000_user_crypto_v2/up.sql | 14 + .../2026-10-09-120000_user_crypto_v2/down.sql | 5 + .../2026-10-09-120000_user_crypto_v2/up.sql | 13 + .../2026-10-09-120000_user_crypto_v2/down.sql | 5 + .../2026-10-09-120000_user_crypto_v2/up.sql | 13 + src/api/core/accounts.rs | 456 +++++++++++++++--- src/api/core/organizations.rs | 47 +- src/api/identity.rs | 26 +- src/config.rs | 2 + src/db/models/mod.rs | 2 + src/db/models/user.rs | 58 ++- src/db/models/user_signature_key_pair.rs | 132 +++++ src/db/schema.rs | 17 + src/util.rs | 6 + 17 files changed, 705 insertions(+), 103 deletions(-) create mode 100644 migrations/mysql/2026-10-09-120000_user_crypto_v2/down.sql create mode 100644 migrations/mysql/2026-10-09-120000_user_crypto_v2/up.sql create mode 100644 migrations/postgresql/2026-10-09-120000_user_crypto_v2/down.sql create mode 100644 migrations/postgresql/2026-10-09-120000_user_crypto_v2/up.sql create mode 100644 migrations/sqlite/2026-10-09-120000_user_crypto_v2/down.sql create mode 100644 migrations/sqlite/2026-10-09-120000_user_crypto_v2/up.sql create mode 100644 src/db/models/user_signature_key_pair.rs 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 764fbd53..a2c0e51d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } +diesel = { version = "2.3.13", 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-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/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/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/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0e8e8b8b..9f6625ac 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -22,8 +22,8 @@ use crate::{ models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, - MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, - UserKdfType, + MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, + User, UserId, UserKdfType, UserSignatureKeyPair, }, }, mail, @@ -42,6 +42,7 @@ pub fn routes() -> Vec { post_profile, put_avatar, get_public_keys, + get_account_public_keys, get_keys, post_keys, post_password, @@ -113,6 +114,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 +128,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 +167,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 +212,198 @@ 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 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, which then needs saving before [`Self::save_signature_key_pair`]. + 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 +424,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 +478,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,10 +547,11 @@ 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 { Some(user) => { @@ -393,9 +598,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 +608,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 { @@ -428,6 +634,7 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { } user.save(&conn).await?; + account_keys.save_signature_key_pair(&user.uuid, &conn).await?; // accept any open emergency access invitations if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() { @@ -441,25 +648,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 +730,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 @@ -497,6 +763,10 @@ async fn post_set_password(data: Json, headers: Headers, conn: user.save(&conn).await?; + if let Some(account_keys) = account_keys { + account_keys.save_signature_key_pair(&user.uuid, &conn).await?; + } + Ok(Json(json!({ "object": "set-password", }))) @@ -577,8 +847,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 +871,66 @@ 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)?; user.save(&conn).await?; + account_keys.save_signature_key_pair(&user.uuid, &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" }))) } @@ -965,6 +1283,14 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: err!("Invalid password") } + // Rotating v2 keys, or upgrading to them, would leave an account that can't be unlocked here + if headers.user.is_v2() { + err!("Key rotation is not supported for v2 accounts") + } + if !data.account_keys.user_key_encrypted_account_private_key.starts_with("2.") { + err!("The provided account private key was not wrapped with AES-256-CBC-HMAC") + } + // 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. @@ -1950,10 +2276,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")); } @@ -1975,10 +2301,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/organizations.rs b/src/api/core/organizations.rs index 3d850d93..8e6109c3 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -8,7 +8,7 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - 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}, @@ -2771,8 +2771,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 { @@ -2785,11 +2803,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 b61258cf..09fdf896 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -551,18 +551,7 @@ async fn authenticated_response( 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(), @@ -708,18 +697,7 @@ async fn user_api_key_login( 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. diff --git a/src/config.rs b/src/config.rs index a56748f4..4599efd7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1435,6 +1435,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/models/mod.rs b/src/db/models/mod.rs index 8cb141e5..8521557c 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -17,6 +17,7 @@ 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}; @@ -40,3 +41,4 @@ 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_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair}; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 6610c945..e7b639c6 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -20,6 +20,7 @@ use macros::UuidFromParam; use super::{ Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, + UserSignatureKeyPair, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] @@ -71,6 +72,11 @@ 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, } #[derive(Identifiable, Queryable, Insertable)] @@ -158,9 +164,17 @@ 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, } } + pub fn is_v2(&self) -> bool { + self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some() + } + pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash( password.as_bytes(), @@ -265,18 +279,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 { @@ -284,6 +317,20 @@ 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 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 { @@ -304,7 +351,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, @@ -380,6 +427,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| { 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 ae1e3216..70580596 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -219,6 +219,21 @@ table! { avatar_color -> Nullable, external_id -> Nullable, key_id -> Nullable, + signed_public_key -> Nullable, + security_state -> Nullable, + security_version -> 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, } } @@ -384,6 +399,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, @@ -410,4 +426,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 9d319542..0939f6e8 100644 --- a/src/util.rs +++ b/src/util.rs @@ -839,6 +839,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