|
|
|
@ -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<ClientVer |
|
|
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
|
|
|
"masterKeyEncryptedUserKey": headers.user.akey, |
|
|
|
"masterKeyWrappedUserKey": headers.user.akey, |
|
|
|
"salt": headers.user.email |
|
|
|
"salt": headers.user.email, |
|
|
|
"containedKeyId": headers.user.key_id, |
|
|
|
}) |
|
|
|
} else { |
|
|
|
Value::Null |
|
|
|
}; |
|
|
|
|
|
|
|
// Upstream omits this when unset rather than sending null.
|
|
|
|
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock }); |
|
|
|
if let Some(key_id) = &headers.user.key_id { |
|
|
|
user_decryption["userKeyId"] = json!(key_id); |
|
|
|
} |
|
|
|
|
|
|
|
Ok(Json(json!({ |
|
|
|
"profile": user_json, |
|
|
|
"folders": folders_json, |
|
|
|
@ -204,10 +212,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer |
|
|
|
"ciphers": ciphers_json, |
|
|
|
"domains": domains_json, |
|
|
|
"sends": sends_json, |
|
|
|
"userDecryption": { |
|
|
|
"masterPasswordUnlock": master_password_unlock, |
|
|
|
"userKeyId": headers.user.key_id, |
|
|
|
}, |
|
|
|
"userDecryption": user_decryption, |
|
|
|
"object": "sync" |
|
|
|
}))) |
|
|
|
} |
|
|
|
@ -284,7 +289,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<String>, |
|
|
|
pub notes: Option<String>, |
|
|
|
fields: Option<Value>, |
|
|
|
|
|
|
|
@ -298,6 +304,9 @@ pub struct CipherData { |
|
|
|
drivers_license: Option<Value>, |
|
|
|
passport: Option<Value>, |
|
|
|
|
|
|
|
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
|
|
|
|
data: Option<String>, |
|
|
|
|
|
|
|
favorite: Option<bool>, |
|
|
|
reprompt: Option<i32>, |
|
|
|
|
|
|
|
@ -319,6 +328,70 @@ pub struct CipherData { |
|
|
|
archived_date: Option<String>, |
|
|
|
} |
|
|
|
|
|
|
|
/// 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<CipherValidationError> 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: <https://github.com/bitwarden/server/blob/main/src/Api/Vault/Models/Request/CipherRequestModel.cs>
|
|
|
|
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<CipherData>, 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<CipherData>, 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<ImportData>, 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?)) |
|
|
|
|