From c218e0200caab85701ca3e24af610fca206c90c7 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 1/2] Support blob-encrypted ciphers and match upstream's key id checks --- .../down.sql | 0 .../up.sql | 3 + .../down.sql | 0 .../up.sql | 3 + .../down.sql | 0 .../up.sql | 1 + src/api/core/accounts.rs | 20 +- src/api/core/ciphers.rs | 310 +++++++++++++----- src/api/core/organizations.rs | 2 +- src/db/models/cipher.rs | 169 ++++++---- src/db/models/mod.rs | 2 +- 11 files changed, 364 insertions(+), 146 deletions(-) create mode 100644 migrations/mysql/2026-10-08-120000_cipher_data_longtext/down.sql create mode 100644 migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql create mode 100644 migrations/postgresql/2026-10-08-120000_cipher_data_longtext/down.sql create mode 100644 migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql create mode 100644 migrations/sqlite/2026-10-08-120000_cipher_data_longtext/down.sql create mode 100644 migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql 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/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/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/src/api/core/accounts.rs b/src/api/core/accounts.rs index 69918212..f0967efd 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -651,6 +651,8 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon 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 +755,19 @@ 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(()) } #[derive(Deserialize)] @@ -773,6 +788,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)?; @@ -1073,8 +1090,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); diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 97f5fd49..9303f91c 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -24,6 +24,7 @@ use crate::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId, + is_data_blob_encrypted, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -189,12 +190,19 @@ async fn sync(data: SyncData, headers: Headers, client_version: 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 +290,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 +305,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 +329,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 { @@ -352,16 +454,14 @@ async fn post_ciphers_create( ) -> 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()); + let mut cipher = Cipher::new(data.cipher.r#type); cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.save(&conn).await?; @@ -385,16 +485,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,7 +493,7 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt // needed when creating a new cipher, so just ignore it unconditionally. data.last_known_revision_date = None; - let mut cipher = Cipher::new(data.r#type, data.name.clone()); + let mut cipher = Cipher::new(data.r#type); update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) @@ -426,6 +517,44 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } +/// The checks of saving a cipher, for callers that run them before writing anything else +async fn validate_cipher_update( + cipher: &Cipher, + data: &CipherData, + headers: &Headers, + conn: &DbConn, + ut: UpdateType, +) -> EmptyResult { + // Check that the client isn't updating an existing cipher with stale data. + // And only perform this check when not importing ciphers, else the date/time check will fail. + if ut != UpdateType::None + && let Some(dt) = &data.last_known_revision_date + { + match NaiveDateTime::parse_from_str(dt, "%+") { + // ISO 8601 format + Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()), + Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { + err!("The client copy of this cipher is out of date. Resync the client and try again.") + } + Ok(_) => (), + } + } + + if let Some(note) = &data.notes { + let max_note_size = CONFIG._max_note_size(); + if note.len() > max_note_size { + err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters.")) + } + } + + if let Some(folder_id) = &data.folder_id + && Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() + { + err!("Invalid folder", "Folder does not exist or belongs to another user"); + } + Ok(()) +} + pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, @@ -451,32 +580,15 @@ pub async fn update_cipher_from_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(); @@ -507,12 +619,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 +657,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?; @@ -670,7 +786,7 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC // Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58 cipher_data.organization_id = None; - let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); + let mut cipher = Cipher::new(cipher_data.r#type); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } @@ -743,6 +859,9 @@ async fn put_cipher( err!("Cipher is not write accessible") } + // Against the cipher we hold rather than the organization the client claims, like upstream + validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?; + // Moving a cipher into an organization is only possible through the share endpoints if cipher.organization_uuid != data.organization_id { err!("Organization mismatch. Please resync the client before updating the cipher") @@ -1019,13 +1138,20 @@ 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 } @@ -1085,21 +1211,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()); @@ -1161,6 +1301,22 @@ async fn share_cipher_by_uuid( err!("Organization mismatch. Please resync the client before updating the cipher") } + // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. + // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item + let ut = if let Some(ut) = override_ut { + ut + } else if data.cipher.last_known_revision_date.is_some() { + UpdateType::SyncCipherUpdate + } else { + UpdateType::SyncCipherCreate + }; + + // For the same reason, the other checks of saving + let is_blob = data.cipher.is_blob(); + data.cipher.validate_content(is_blob)?; + data.cipher.validate_type_data(is_blob)?; + validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?; + let mut shared_to_collections = vec![]; if let Some(organization_id) = &data.cipher.organization_id { @@ -1179,16 +1335,6 @@ async fn share_cipher_by_uuid( } } - // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. - // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item - let ut = if let Some(ut) = override_ut { - ut - } else if data.cipher.last_known_revision_date.is_some() { - UpdateType::SyncCipherUpdate - } else { - UpdateType::SyncCipherCreate - }; - update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 607db45b..38a0ea96 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1866,7 +1866,7 @@ async fn post_org_import( cipher_data.folder_id = None; // Replace the client-provided, unvalidated organizationId with the real target org cipher_data.organization_id = Some(org_id.clone()); - let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); + let mut cipher = Cipher::new(cipher_data.r#type); update_cipher_from_data( &mut cipher, cipher_data, diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index db8e9bf4..f87d61d8 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -19,7 +19,7 @@ use crate::{ }, }, error::MapResult, - util::LowerCase, + util::{LowerCase, convert_json_key_lcase_first}, }; use macros::UuidFromParam; @@ -63,6 +63,18 @@ 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, @@ -70,7 +82,8 @@ pub enum RepromptType { /// 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 +97,7 @@ impl Cipher { key: None, atype, - name, + name: String::new(), notes: None, fields: None, @@ -110,6 +123,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 { @@ -154,6 +177,10 @@ impl Cipher { ) -> Result { use crate::util::{format_date, validate_and_format_date}; + // Parsed once, since `data` can be large and this runs for every cipher in a sync + let type_data = serde_json::from_str::(&self.data); + let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value); + let mut attachments_json: Value = Value::Null; if let Some(cipher_sync_data) = cipher_sync_data { if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) @@ -250,63 +277,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 +376,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 { diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 8f62bf0b..7f97cbff 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -21,7 +21,7 @@ mod user; 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, 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::{ From de40d7de294f930572131e862336d108172db8c7 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 2/2] 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 | 130 +++-- src/auth.rs | 6 + src/config.rs | 2 + src/db/models/mod.rs | 2 + src/db/models/user.rs | 59 ++- src/db/models/user_signature_key_pair.rs | 132 +++++ src/db/schema.rs | 17 + src/util.rs | 6 + 18 files changed, 808 insertions(+), 111 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 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-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 f0967efd..b1bcabc5 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. @@ -1976,10 +2302,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")); } @@ -2001,10 +2327,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 38a0ea96..bca0745f 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}, @@ -2869,8 +2869,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 +2901,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..5bfd974d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -77,14 +77,14 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - password_login(data, &mut user_id, &conn, &client_header.ip).await + password_login(data, &mut user_id, &conn, &client_header.ip, client_header.client_version.as_ref()).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.ip, client_header.client_version.as_ref()).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.ip, client_header.client_version.as_ref()).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, + ip: &ClientIp, + client_version: Option<&semver::Version>, +) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login @@ -325,6 +331,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 +342,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_version, ip)?; + let mut device = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; @@ -368,7 +379,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, + ip: &ClientIp, + client_version: Option<&semver::Version>, +) -> JsonResult { // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; @@ -438,6 +455,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & ) } + check_client_version(&user, client_version, ip)?; + // Change the KDF Iterations (only when not logging in with an auth request) if data.auth_request.is_none() { kdf_upgrade(&mut user, password, conn).await?; @@ -499,6 +518,47 @@ 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`, a V2 account logs in only from a client that sends its version and is at +/// least 2025.11.0 (upstream also refuses a missing header for V1 accounts). Called right after the credentials are +/// checked, like upstream, so that it doesn't tell anyone else which accounts are V2. +/// +/// Ref: +fn check_client_version(user: &User, client_version: Option<&semver::Version>, ip: &ClientIp) -> EmptyResult { + let Some((error, description)) = client_version_error(user.is_v2(), client_version) else { + return Ok(()); + }; + Err(crate::error::Error::from(( + format!("IP: {}. Username: {}. {error}", ip.ip, user.email), + json!({ + "error": error, + "error_description": description, + "ErrorModel": { "Message": description, "Object": "error" }, + }), + )) + .with_event(crate::error::ErrorEvent { + event: EventType::UserFailedLogIn, + })) +} + +/// The error id and message of [`check_client_version`], `None` when the client may log in. +fn client_version_error(is_v2: bool, client_version: Option<&semver::Version>) -> Option<(&'static str, &'static str)> { + const MIN_V2_CLIENT: semver::Version = semver::Version::new(2025, 11, 0); + + if !is_v2 { + return None; + } + match client_version { + None => Some(( + "version_header_missing", + "No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"", + )), + Some(version) if *version < MIN_V2_CLIENT => { + Some(("invalid_client_version", "Please update your app to continue using Bitwarden")) + } + Some(_) => None, + } +} + async fn authenticated_response( user: &User, device: &mut Device, @@ -552,18 +612,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(), @@ -598,7 +647,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, + ip: &ClientIp, + client_version: Option<&semver::Version>, +) -> JsonResult { // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; @@ -610,7 +665,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, ip, client_version).await } Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), @@ -622,6 +677,7 @@ async fn user_api_key_login( user_id: &mut Option, conn: &DbConn, ip: &ClientIp, + client_version: Option<&semver::Version>, ) -> JsonResult { // Get the user via the client_id let client_id = data.client_id.as_ref().unwrap(); @@ -659,6 +715,8 @@ async fn user_api_key_login( ) } + check_client_version(&user, client_version, ip)?; + let mut device = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && device.is_new() { @@ -709,18 +767,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. @@ -1351,3 +1398,24 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, Ok(Redirect::temporary(String::from(auth_url))) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_version_error() { + let version = |v| semver::Version::parse(v).unwrap(); + // V1 accounts aren't checked, with or without a version + assert_eq!(client_version_error(false, None), None); + assert_eq!(client_version_error(false, Some(&version("2024.1.0"))), None); + // V2 accounts need the header and 2025.11.0 or newer + assert_eq!(client_version_error(true, None).map(|e| e.0), Some("version_header_missing")); + assert_eq!( + client_version_error(true, Some(&version("2025.10.3"))).map(|e| e.0), + Some("invalid_client_version") + ); + assert_eq!(client_version_error(true, Some(&version("2025.11.0"))), None); + assert_eq!(client_version_error(true, Some(&version("2026.10.1"))), None); + } +} diff --git a/src/auth.rs b/src/auth.rs index cedbb130..98ff929c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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, }) } } diff --git a/src/config.rs b/src/config.rs index 1d75d342..3fb8ac9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1512,6 +1512,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 7f97cbff..952e240b 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}; @@ -42,3 +43,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 30541428..dfdeb6ac 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -20,7 +20,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 +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)] @@ -159,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(), @@ -266,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 { @@ -285,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 { @@ -305,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, @@ -382,6 +428,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 e1b9a78d..546d7629 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, } } @@ -393,6 +408,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 +435,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