From d518f6f01e4d21f3563875450b71743d6b537ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sat, 1 Aug 2026 03:39:45 +0200 Subject: [PATCH] Implement V2 registration support --- .typos.toml | 1 + Cargo.toml | 2 +- .../2026-08-01-120000_user_crypto_v2/down.sql | 6 + .../2026-08-01-120000_user_crypto_v2/up.sql | 17 + .../2026-08-01-120000_user_crypto_v2/down.sql | 6 + .../2026-08-01-120000_user_crypto_v2/up.sql | 16 + .../2026-08-01-120000_user_crypto_v2/down.sql | 6 + .../2026-08-01-120000_user_crypto_v2/up.sql | 16 + src/api/core/accounts.rs | 327 +++++++++++++++--- src/api/core/ciphers.rs | 1 + src/api/identity.rs | 26 +- src/db/models/mod.rs | 2 + src/db/models/user.rs | 91 ++++- src/db/models/user_signature_key_pair.rs | 169 +++++++++ src/db/schema.rs | 18 + 15 files changed, 614 insertions(+), 90 deletions(-) create mode 100644 migrations/mysql/2026-08-01-120000_user_crypto_v2/down.sql create mode 100644 migrations/mysql/2026-08-01-120000_user_crypto_v2/up.sql create mode 100644 migrations/postgresql/2026-08-01-120000_user_crypto_v2/down.sql create mode 100644 migrations/postgresql/2026-08-01-120000_user_crypto_v2/up.sql create mode 100644 migrations/sqlite/2026-08-01-120000_user_crypto_v2/down.sql create mode 100644 migrations/sqlite/2026-08-01-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..7034a849 100644 --- a/.typos.toml +++ b/.typos.toml @@ -15,6 +15,7 @@ extend-ignore-re = [ "(?i)helo_name", "Server name sent during.+HELO", # COSE Is short for CBOR Object Signing and Encryption, ignore these specific items + "COSE", "COSEKey", "COSEAlgorithm", # Ignore this specific string as it's valid diff --git a/Cargo.toml b/Cargo.toml index db685864..8bc72255 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,7 +106,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] } +diesel = { version = "2.3.11", 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-08-01-120000_user_crypto_v2/down.sql b/migrations/mysql/2026-08-01-120000_user_crypto_v2/down.sql new file mode 100644 index 00000000..1649ec86 --- /dev/null +++ b/migrations/mysql/2026-08-01-120000_user_crypto_v2/down.sql @@ -0,0 +1,6 @@ +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; +ALTER TABLE users DROP COLUMN v2_upgrade_token; diff --git a/migrations/mysql/2026-08-01-120000_user_crypto_v2/up.sql b/migrations/mysql/2026-08-01-120000_user_crypto_v2/up.sql new file mode 100644 index 00000000..8506c6ad --- /dev/null +++ b/migrations/mysql/2026-08-01-120000_user_crypto_v2/up.sql @@ -0,0 +1,17 @@ +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; +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; + +DROP TABLE IF EXISTS user_signature_key_pairs; + +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-08-01-120000_user_crypto_v2/down.sql b/migrations/postgresql/2026-08-01-120000_user_crypto_v2/down.sql new file mode 100644 index 00000000..1649ec86 --- /dev/null +++ b/migrations/postgresql/2026-08-01-120000_user_crypto_v2/down.sql @@ -0,0 +1,6 @@ +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; +ALTER TABLE users DROP COLUMN v2_upgrade_token; diff --git a/migrations/postgresql/2026-08-01-120000_user_crypto_v2/up.sql b/migrations/postgresql/2026-08-01-120000_user_crypto_v2/up.sql new file mode 100644 index 00000000..72fa6dc0 --- /dev/null +++ b/migrations/postgresql/2026-08-01-120000_user_crypto_v2/up.sql @@ -0,0 +1,16 @@ +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; +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; + +DROP TABLE IF EXISTS user_signature_key_pairs; + +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-08-01-120000_user_crypto_v2/down.sql b/migrations/sqlite/2026-08-01-120000_user_crypto_v2/down.sql new file mode 100644 index 00000000..1649ec86 --- /dev/null +++ b/migrations/sqlite/2026-08-01-120000_user_crypto_v2/down.sql @@ -0,0 +1,6 @@ +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; +ALTER TABLE users DROP COLUMN v2_upgrade_token; diff --git a/migrations/sqlite/2026-08-01-120000_user_crypto_v2/up.sql b/migrations/sqlite/2026-08-01-120000_user_crypto_v2/up.sql new file mode 100644 index 00000000..be3bfcdd --- /dev/null +++ b/migrations/sqlite/2026-08-01-120000_user_crypto_v2/up.sql @@ -0,0 +1,16 @@ +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; +ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; + +DROP TABLE IF EXISTS user_signature_key_pairs; + +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 0cb4d3c0..0f77d2d7 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -22,7 +22,8 @@ use crate::{ models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, - OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, + OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, SignatureAlgorithm, User, UserId, + UserKdfType, UserSignatureKeyPair, }, }, mail, @@ -41,6 +42,8 @@ pub fn routes() -> Vec { post_profile, put_avatar, get_public_keys, + get_account_public_keys, + get_keys, post_keys, post_password, post_set_password, @@ -102,6 +105,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, name: Option, @@ -116,36 +122,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)] @@ -183,6 +159,34 @@ 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() + } + + // 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)] @@ -192,6 +196,167 @@ struct KeysData { public_key: String, } +/// The `accountKeys` payload, which replaces the flat `keys`/`userAsymmetricKeys` object. +/// +/// It carries either a "v1" state (just the encryption key pair) or a "v2" one, which adds a +/// signature key pair, a signed public key, and a signed security state. The two deprecated +/// top-level fields are still sent by the SDK alongside the nested ones and are only used as a +/// fallback for clients that don't send `publicKeyEncryptionKeyPair` yet. +/// +/// Ref: +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountKeysData { + 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, +} + +pub 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 { + /// Checks that the payload describes a complete account cryptographic state. + /// + /// The v2 fields have to be all present or all absent: a client that receives a COSE-wrapped + /// private key without the matching signature key pair and security state refuses to unlock the + /// vault, so storing half a state would produce an account nobody can log into. + pub fn validate(self) -> ApiResult { + let (private_key, public_key, signed_public_key) = if let Some(key_pair) = self.public_key_encryption_key_pair { + (key_pair.wrapped_private_key, key_pair.public_key, key_pair.signed_public_key) + // Older clients only send the deprecated top-level fields, which are always v1. + } else if let (Some(private_key), Some(public_key)) = + (self.user_key_encrypted_account_private_key, self.account_public_key) + { + (private_key, public_key, None) + } else { + err!("The account keys are missing an encryption key pair") + }; + + let v2 = match (signed_public_key, self.signature_key_pair, self.security_state) { + (Some(signed_public_key), Some(signature_key_pair), Some(security_state)) => { + let Some(signature_algorithm) = SignatureAlgorithm::from_str(&signature_key_pair.signature_algorithm) + else { + err!(format!("Unsupported signature algorithm: {}", signature_key_pair.signature_algorithm)) + }; + + 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 { + /// Writes the parts of the state that live on the user itself. The user still needs saving, and + /// [`Self::save_signature_key_pair`] still needs calling once it has been. + /// + /// Rejects downgrading an account from v2 back to v1. + pub fn apply(&self, user: &mut User) -> EmptyResult { + if user.is_v2() && self.v2.is_none() { + err!("Cannot downgrade an account from v2 to v1 encryption") + } + + 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(()) + } + + /// Persists the signature key pair. Separate from [`Self::apply`] because the row has a foreign + /// key to the user, so it can only be written once the user exists. + pub 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_active_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 { @@ -216,11 +381,12 @@ pub struct MasterPasswordUnlock { #[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, } @@ -263,7 +429,7 @@ pub async fn register(data: Json, email_verification: bool, conn: let mut pending_emergency_access = None; - if data.unprocessable() { + if data.compat.unprocessable(&data.email) { err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); } @@ -386,9 +552,9 @@ pub async fn register(data: Json, email_verification: bool, conn: // 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 @@ -396,9 +562,13 @@ pub async fn register(data: Json, email_verification: bool, conn: user.name = name; } - if let Some(keys) = data.keys { - user.private_key = Some(keys.encrypted_private_key); - user.public_key = Some(keys.public_key); + let account_keys = match (data.account_keys, data.keys) { + (Some(account_keys), _) => Some(account_keys.validate()?), + (None, Some(keys)) => Some(keys.into()), + (None, None) => None, + }; + if let Some(ref account_keys) = account_keys { + account_keys.apply(&mut user)?; } if email_verified { @@ -422,6 +592,10 @@ pub async fn register(data: Json, email_verification: bool, conn: user.save(&conn).await?; + if let Some(account_keys) = account_keys { + account_keys.save_signature_key_pair(&user.uuid, &conn).await?; + } + // accept any open emergency access invitations if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() { for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &conn).await { @@ -444,16 +618,26 @@ async fn post_set_password(data: Json, headers: Headers, conn: 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)?; + let account_keys = match (data.account_keys, data.keys) { + (Some(account_keys), _) => Some(account_keys.validate()?), + (None, Some(keys)) => Some(keys.into()), + (None, None) => None, + }; + + 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, @@ -461,9 +645,8 @@ 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)?; } if let Some(identifier) = data.org_identifier @@ -492,6 +675,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", "captchaBypassToken": "", @@ -573,20 +760,60 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js }))) } +#[get("/users//keys")] +async fn get_account_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult { + let user = match User::find_by_uuid(&user_id, &conn).await { + Some(user) if user.public_key.is_some() => user, + Some(_) => err_code!("User has no public_key", Status::NotFound.code), + None => err_code!("User doesn't exist", Status::NotFound.code), + }; + + Ok(Json(user.public_keys_json(&conn).await)) +} + +#[get("/accounts/keys")] +async fn get_keys(headers: Headers, conn: DbConn) -> JsonResult { + let user = headers.user; + + Ok(Json(json!({ + "key": user.akey, + "privateKey": user.private_key, + "publicKey": user.public_key, + "accountKeys": user.account_keys_json(&conn).await, + "object": "keys" + }))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PostKeysData { + #[serde(flatten)] + keys: Option, + account_keys: 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; - user.private_key = Some(data.encrypted_private_key); - user.public_key = Some(data.public_key); + // `accountKeys` supersedes the flat `keys` object when both are sent. + let account_keys = match (data.account_keys, data.keys) { + (Some(account_keys), _) => account_keys.validate()?, + (None, Some(keys)) => keys.into(), + (None, None) => err!("No account keys provided"), + }; + 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, "privateKey": user.private_key, "publicKey": user.public_key, + "accountKeys": user.account_keys_json(&conn).await, "object":"keys" }))) } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 0cdae612..0b834e49 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -198,6 +198,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option, pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. + + // "v2" account cryptographic state. Either all of these are set (together with a row in + // `user_signature_key_pairs`) or none of them are; see `User::is_v2`. + pub signed_public_key: Option, + pub security_state: Option, + pub security_version: Option, + /// JSON `{"wrappedUserKey1": ..., "wrappedUserKey2": ...}`, letting clients that still hold the + /// v1 user key obtain the v2 one after another client performed the upgrade. Opaque to us. + pub v2_upgrade_token: Option, } #[derive(Identifiable, Queryable, Insertable)] @@ -154,9 +164,18 @@ impl User { avatar_color: None, external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. + + 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() + } + pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash( password.as_bytes(), @@ -253,6 +272,61 @@ impl User { /// Database methods impl User { + async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option { + if !self.is_v2() { + return None; + } + UserSignatureKeyPair::find_active_by_user(&self.uuid, conn).await + } + + pub async fn account_keys_json(&self, conn: &DbConn) -> Value { + if self.private_key.is_none() { + return Value::Null; + } + + let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await { + Some(key_pair) => ( + json!(self.signed_public_key), + key_pair.to_json(), + json!({ + "securityState": self.security_state, + "securityVersion": self.security_version, + }), + ), + None => (Value::Null, Value::Null, Value::Null), + }; + + json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": self.private_key, + "publicKey": self.public_key, + "signedPublicKey": signed_public_key, + "object": "publicKeyEncryptionKeyPair", + }, + "signatureKeyPair": signature_key_pair, + "securityState": security_state, + "object": "privateKeys" + }) + } + + 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) -> Value { + self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok()).unwrap_or(Value::Null) + } + 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 { @@ -268,21 +342,7 @@ impl User { UserStatus::Enabled }; - let account_keys = if self.private_key.is_some() { - json!({ - "publicKeyEncryptionKeyPair": { - "wrappedPrivateKey": self.private_key, - "publicKey": self.public_key, - "signedPublicKey": null, - "object": "publicKeyEncryptionKeyPair", - }, - "securityState": null, - "signatureKeyPair": null, - "object": "privateKeys" - }) - } else { - Value::Null - }; + let account_keys = self.account_keys_json(conn).await; json!({ "_status": status as i32, @@ -357,6 +417,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..b7e58f3a --- /dev/null +++ b/src/db/models/user_signature_key_pair.rs @@ -0,0 +1,169 @@ +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 macros::UuidFromParam; + +use super::UserId; + +/// A user's signature key pair, part of the "v2" account cryptographic state. +/// +/// Upstream keeps this in its own table rather than as columns on the user, with a unique index on +/// the user id. The stated intent is to eventually keep superseded key pairs around (an `active` +/// flag was sketched but not shipped), which is why this is modelled as a row with its own identity +/// instead of a set of user attributes. +/// +/// 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, +} + +/// https://github.com/bitwarden/server/blob/main/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignatureAlgorithm { + Ed25519 = 0, + MlDsa44 = 1, +} + +impl SignatureAlgorithm { + pub fn from_str(algorithm: &str) -> Option { + match algorithm { + "ed25519" => Some(Self::Ed25519), + "mldsa44" => Some(Self::MlDsa44), + _ => None, + } + } + + pub fn from_i32(algorithm: i32) -> Option { + match algorithm { + 0 => Some(Self::Ed25519), + 1 => Some(Self::MlDsa44), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Ed25519 => "ed25519", + Self::MlDsa44 => "mldsa44", + } + } +} + +/// 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 key pair currently in use by the user. There is at most one today, enforced by a unique + /// index on `user_uuid`. + pub async fn find_active_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, + FromForm, + Hash, + PartialEq, + Eq, + Serialize, + Deserialize, + UuidFromParam, +)] +pub struct UserSignatureKeyPairId(String); diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..8f48a86e 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -217,6 +217,22 @@ table! { api_key -> Nullable, avatar_color -> Nullable, external_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, } } @@ -382,6 +398,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, @@ -408,4 +425,5 @@ allow_tables_to_appear_in_same_query!( collections_groups, event, auth_requests, + user_signature_key_pairs, );