From 46f4ec83dfdbcd651ab5c8031bf613ddb170fd8e 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 --- .../down.sql | 0 .../up.sql | 3 + .../down.sql | 0 .../up.sql | 1 + .../down.sql | 0 .../up.sql | 1 + src/api/core/accounts.rs | 20 +- src/api/core/ciphers.rs | 295 +++++++++++++----- src/api/core/organizations.rs | 2 +- src/db/models/cipher.rs | 164 ++++++---- src/db/models/mod.rs | 2 +- 11 files changed, 342 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..f18eb816 --- /dev/null +++ b/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql @@ -0,0 +1 @@ +-- TEXT has no size limit here, only MySQL needed the change diff --git a/migrations/sqlite/2026-10-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 dd2e5801..0e8e8b8b 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 46766896..a5198577 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 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,79 @@ 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) + } + + /// 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 +435,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 +466,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 +474,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 +498,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 '{dt}': {err}"), + 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 +561,14 @@ 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 '{dt}': {err}"), - 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)?; + 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 +599,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 +637,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?; @@ -667,7 +763,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); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } @@ -735,6 +831,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())?; + 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?)) @@ -993,9 +1092,7 @@ async fn post_cipher_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let data: ShareCipherData = data.into_inner(); - - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await + put_cipher_share(cipher_id, data, headers, conn, nt).await } #[put("/ciphers//share", data = "")] @@ -1008,6 +1105,21 @@ 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 } @@ -1027,18 +1139,33 @@ async fn put_cipher_share_selected( ) -> EmptyResult { let mut 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") - } + cipher.validate_content(cipher.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?; } while let Some(cipher) = data.ciphers.pop() { @@ -1085,6 +1212,20 @@ 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 + data.cipher.validate_content(data.cipher.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 { @@ -1103,16 +1244,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 6fccc850..3d850d93 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1831,7 +1831,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 f39b0bfc..f316d979 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,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 +172,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 +272,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 +371,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 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};