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 deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index 09abbaee..00000000 --- a/migrations/mysql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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 deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index aab2e86a..00000000 --- a/migrations/postgresql/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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 deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index f18eb816..00000000 --- a/migrations/sqlite/2026-10-08-120000_cipher_data_longtext/up.sql +++ /dev/null @@ -1 +0,0 @@ --- 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 f0967efd..69918212 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -651,8 +651,6 @@ 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) @@ -755,19 +753,6 @@ 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)] @@ -788,8 +773,6 @@ 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)?; @@ -1090,9 +1073,8 @@ 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!("User key id is already set.") + err_code!("Unexpected data", Status::UnprocessableEntity.code); } 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 9303f91c..97f5fd49 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -24,7 +24,6 @@ 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}, @@ -190,19 +189,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option, - // Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent - pub encrypted_for: Option, + pub encrypted_for: UserId, // Added in web-v2025.6.0 // Added in web-v2025.8.1, Optional for compat pub encrypted_by_key_id: Option, @@ -290,8 +284,7 @@ pub struct CipherData { Passport = 8 */ pub r#type: i32, - // Absent on a blob-encrypted cipher, whose name is sealed inside `data` - pub name: Option, + pub name: String, pub notes: Option, fields: Option, @@ -305,9 +298,6 @@ 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, @@ -329,98 +319,6 @@ 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 { @@ -454,14 +352,16 @@ async fn post_ciphers_create( ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); - validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?; + if data.cipher.encrypted_for != headers.user.uuid { + err_code!("Invalid user cipher", Status::UnprocessableEntity.code); + } // 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); + let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.save(&conn).await?; @@ -485,7 +385,16 @@ 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(); - validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?; + 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); + } // 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`, @@ -493,7 +402,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); + let mut cipher = Cipher::new(data.r#type, data.name.clone()); 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?)) @@ -517,44 +426,6 @@ 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, @@ -580,15 +451,32 @@ 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)?; - data.validate_type_data(is_blob)?; - validate_cipher_update(cipher, &data, headers, conn, ut).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(_) => (), + } + } 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(); @@ -619,6 +507,12 @@ 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 { @@ -657,36 +551,26 @@ pub async fn update_cipher_from_data( _ => err!("Invalid type"), }; - 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") - }; + let type_data = if let Some(mut data) = type_data_opt { // Remove the 'Response' key from the base object. - if let Some(data_obj) = type_data.as_object_mut() { + if let Some(data_obj) = data.as_object_mut() { data_obj.remove("response"); } // Remove the 'Response' key from every Uri. - if type_data["uris"].is_array() { - type_data["uris"] = clean_cipher_data(type_data["uris"].clone()); + if data["uris"].is_array() { + data["uris"] = clean_cipher_data(data["uris"].clone()); } - - // `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(); - } + data + } else { + err!("Data missing") + }; 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?; @@ -786,7 +670,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); + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } @@ -859,9 +743,6 @@ 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") @@ -1138,20 +1019,13 @@ 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 } @@ -1211,35 +1085,21 @@ 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 { - 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?; + if cipher.id.is_none() { + err!("Request missing ids field") + } + if cipher.organization_id.is_none() { + err!("Cipher OrganizationId is required.") + } } let mut responses = Vec::with_capacity(data.ciphers.len()); @@ -1301,22 +1161,6 @@ 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 { @@ -1335,6 +1179,16 @@ 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 38a0ea96..607db45b 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); + let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data( &mut cipher, cipher_data, diff --git a/src/config.rs b/src/config.rs index 1d75d342..a565a93e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -261,6 +261,10 @@ macro_rules! make_config { } async fn from_file() -> Result, Error> { + // The Fs operator would create the folder if it doesn't exist, which we never want + if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() { + return Ok(None); + } let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let config_bytes = match operator.read(&CONFIG_FILENAME).await { Ok(bytes) => bytes, diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index f87d61d8..db8e9bf4 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -19,7 +19,7 @@ use crate::{ }, }, error::MapResult, - util::{LowerCase, convert_json_key_lcase_first}, + util::LowerCase, }; use macros::UuidFromParam; @@ -63,18 +63,6 @@ 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, @@ -82,8 +70,7 @@ pub enum RepromptType { /// Local methods impl Cipher { - /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data` - pub fn new(atype: i32) -> Self { + pub fn new(atype: i32, name: String) -> Self { let now = Utc::now().naive_utc(); Self { @@ -97,7 +84,7 @@ impl Cipher { key: None, atype, - name: String::new(), + name, notes: None, fields: None, @@ -123,16 +110,6 @@ 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 { @@ -177,10 +154,6 @@ 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) @@ -277,9 +250,63 @@ impl Cipher { }) .unwrap_or_default(); - // 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); + // 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; + } 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) { @@ -376,84 +403,10 @@ impl Cipher { _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), }; - 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); - } + json_object[key] = type_data_json; 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 7f97cbff..8f62bf0b 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, is_data_blob_encrypted}; +pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::emergency_access::{