From 3ac3709d17852dce2958eb4b06eda5353710a39a 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] Support blob-encrypted ciphers and match upstream's key id checks --- src/api/core/accounts.rs | 22 ++- src/api/core/ciphers.rs | 143 ++++++++++++++---- src/api/core/organizations.rs | 2 +- src/db/models/cipher.rs | 271 +++++++++++++++++++--------------- src/db/models/mod.rs | 2 +- src/db/models/user.rs | 13 +- 6 files changed, 303 insertions(+), 150 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 8cc5e55b..4952e302 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -628,6 +628,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) @@ -713,6 +715,21 @@ struct UnlockData { salt: String, kdf: KDFData, master_key_wrapped_user_key: String, + contained_key_id: Option, +} + +/// A password or KDF change re-wraps the same user key, so a key id sent with it has to be the +/// current one. Either may be missing: from a client that predates key ids, or a user whose key id +/// isn't known yet. There is nothing to compare in those cases. +/// +/// 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)] @@ -739,6 +756,8 @@ async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: err!("Invalid master password salt") } + validate_key_id_unchanged(&headers.user, &data.unlock_data)?; + let mut user = headers.user; set_kdf_data(&mut user, &data.unlock_data.kdf)?; @@ -1036,8 +1055,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 a8c6aea0..d16a8a41 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -23,7 +23,8 @@ use crate::{ models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, - Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + 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 notes: Option, fields: Option, @@ -298,6 +304,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 +328,70 @@ 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) + } +} + +/// A user-owned cipher must be encrypted with the user's current key. Organization ciphers use the +/// organization key, which has no id yet, and either id may be missing: from a client that predates +/// the field, or a user whose key id isn't known yet. There is nothing to compare in those cases. +/// +/// Ref: upstream's `CiphersController.ValidateCipherEncryptedByUser` +fn validate_encrypted_by_user_key(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult { + 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 the content is a single blob rather than the per-type fields. This parses `data`, so + /// callers that need the answer more than once should keep it. + pub fn is_blob(&self) -> bool { + self.data.as_deref().is_some_and(is_data_blob_encrypted) + } + + /// Checks the content the way upstream's model validation does, before anything is saved. + /// `is_blob` is [`Self::is_blob`]. + /// + /// Ref: + 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 { @@ -355,13 +428,14 @@ async fn post_ciphers_create( if data.cipher.encrypted_for != headers.user.uuid { err_code!("Invalid user cipher", Status::UnprocessableEntity.code); } + validate_encrypted_by_user_key(&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, String::new()); cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.save(&conn).await?; @@ -389,12 +463,7 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt 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_key(&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 +471,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, String::new()); 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?)) @@ -451,6 +520,9 @@ pub async fn update_cipher_from_data( enforce_personal_ownership_policy(Some(&data), headers, conn).await?; + let is_blob = data.is_blob(); + data.validate_content(is_blob)?; + // 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 @@ -550,24 +622,35 @@ 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 all of the content, the name included, so nothing is kept outside it. The name + // column can't be null, so it's left empty; `to_json` reports it as null, as upstream does. + // 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. - data.as_object_mut().unwrap().remove("response"); + type_data.as_object_mut().unwrap().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?; @@ -664,7 +747,7 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned()); cipher_data.folder_id = folder_id; - let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); + let mut cipher = Cipher::new(cipher_data.r#type, String::new()); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } @@ -732,6 +815,8 @@ async fn put_cipher( err!("Cipher is not write accessible") } + validate_encrypted_by_user_key(&data, &headers.user, cipher.organization_uuid.is_some())?; + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).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 046be793..8b9d9f8e 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1891,7 +1891,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, String::new()); update_cipher_from_data( &mut cipher, cipher_data, diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 4a8ba1c1..436fde76 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -63,6 +63,22 @@ pub struct Cipher { pub reprompt: Option, } +/// Whether `data` is a sealed cipher blob rather than the legacy per-type JSON. +/// +/// Ciphers of v2 accounts are encrypted as a single blob that carries everything, name included, +/// and is opaque to us. It is recognized the same way upstream does, by a top-level +/// `format_version` key that the legacy JSON never has. +/// +/// 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, @@ -110,6 +126,11 @@ impl Cipher { .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); } + if let Err(e) = cipher.validate_content(cipher.is_blob()) { + validation_errors + .insert(format!("Ciphers[{index}].{}", e.field), 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 +175,11 @@ impl Cipher { ) -> Result { use crate::util::{format_date, validate_and_format_date}; + // Parsed once here, since `data` can be large and this runs for every cipher in a sync. + // `LowerCase` only lowercases the first letter of each key, so it keeps `format_version`. + let type_data = serde_json::from_str::>(&self.data).map(|d| d.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) @@ -189,124 +215,62 @@ impl Cipher { (false, false, false) }; - let fields_json: Vec<_> = self - .fields - .as_ref() - .and_then(|s| { - serde_json::from_str::>>(s) - .inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid)) - .ok() - }) - .map(|d| { - d.into_iter() - .map(|mut f| { - // Check if the `type` key is a number, strings break some clients - // The fallback type is the hidden type `1`. this should prevent accidental data disclosure - // If not try to convert the string value to a number and fallback to `1` - // If it is both not a number and not a string, fallback to `1` - match f.data.get("type") { - Some(t) if t.is_number() => {} - Some(t) if t.is_string() => { - let type_num = &t.as_str().unwrap_or("1").parse::().unwrap_or(1); - f.data["type"] = json!(type_num); - } - _ => { - f.data["type"] = json!(1); + // Like upstream, a cipher that was stored without fields or password history reports them as + // null rather than as an empty list; clients keep the two apart. + let fields_json: Option> = self.fields.as_ref().map(|s| { + serde_json::from_str::>>(s) + .inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid)) + .ok() + .map(|d| { + d.into_iter() + .map(|mut f| { + // Check if the `type` key is a number, strings break some clients + // The fallback type is the hidden type `1`. this should prevent accidental data disclosure + // If not try to convert the string value to a number and fallback to `1` + // If it is both not a number and not a string, fallback to `1` + match f.data.get("type") { + Some(t) if t.is_number() => {} + Some(t) if t.is_string() => { + let type_num = &t.as_str().unwrap_or("1").parse::().unwrap_or(1); + f.data["type"] = json!(type_num); + } + _ => { + f.data["type"] = json!(1); + } } - } - f.data - }) - .collect() - }) - .unwrap_or_default(); - - let password_history_json: Vec<_> = self - .password_history - .as_ref() - .and_then(|s| { - serde_json::from_str::>>(s) - .inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid)) - .ok() - }) - .map(|d| { - // Check every password history item if they are valid and return it. - // If a password field has the type `null` skip it, it breaks newer Bitwarden clients - // A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used - d.into_iter() - .filter_map(|d| match d.data.get("password") { - Some(p) if p.is_string() => Some(d.data), - _ => None, - }) - .map(|mut d| { - let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) { - validate_and_format_date(l) - } else { - "1970-01-01T00:00:00.000000Z".to_owned() - }; - d["lastUsedDate"] = json!(lud); - d - }) - .collect() - }) - .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}); - } - } - } + f.data + }) + .collect() + }) + .unwrap_or_default() + }); - // Fix invalid SSH Entries - // This breaks at least the native mobile client if invalid - // The only way to fix this is by setting type_data_json to `null` - // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it - if self.atype == 5 - && (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty) - || type_data_json["privateKey"].as_str().is_none_or(str::is_empty) - || type_data_json["publicKey"].as_str().is_none_or(str::is_empty)) - { - warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); - type_data_json = Value::Null; - } + let password_history_json: Option> = self.password_history.as_ref().map(|s| { + serde_json::from_str::>>(s) + .inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid)) + .ok() + .map(|d| { + // Check every password history item if they are valid and return it. + // If a password field has the type `null` skip it, it breaks newer Bitwarden clients + // A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used + d.into_iter() + .filter_map(|d| match d.data.get("password") { + Some(p) if p.is_string() => Some(d.data), + _ => None, + }) + .map(|mut d| { + let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) { + validate_and_format_date(l) + } else { + "1970-01-01T00:00:00.000000Z".to_owned() + }; + d["lastUsedDate"] = json!(lud); + d + }) + .collect() + }) + .unwrap_or_default() + }); 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 +367,83 @@ impl Cipher { _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), }; - json_object[key] = type_data_json; + if is_blob_encrypted { + // The blob holds all of the content, so it is sent back as-is and the structured fields + // stay null, as upstream does. Only the name needs clearing, the others are never stored. + 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 (`login`, `card`, …) of a legacy cipher, from `type_data` as parsed from + /// `self.data`, with fixups for values that are 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 = type_data + .inspect_err(|_| warn!("Error parsing data field for {}", self.uuid)) + .unwrap_or_else(|_| 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 0e4073a5..8cb141e5 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::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3412b142..cc5324e5 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -555,7 +555,18 @@ pub struct UserId(String); )] #[deref(forward)] #[from(forward)] -pub struct KeyId(String); +pub struct KeyId(#[serde(deserialize_with = "deserialize_key_id")] String); + +/// Rejects a key id from a request that isn't 16 bytes as lowercase hex, as upstream's `[KeyId]` does. +/// +/// Ref: +fn deserialize_key_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let key_id = ::deserialize(deserializer)?; + if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return Err(serde::de::Error::custom("Key id must be a 32 character lowercase hex-encoded string.")); + } + Ok(key_id) +} impl SsoUser { pub async fn save(&self, conn: &DbConn) -> EmptyResult {