mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-23 12:38:09 +03:00
Support blob-encrypted ciphers and match upstream's key id checks
This commit is contained in:
6 files changed
+303
-150
No files matched your search
+114
-29
@@ -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
|
||||
@@ -551,26 +623,37 @@ 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.
|
||||
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 +750,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?;
|
||||
}
|
||||
|
||||
@@ -735,6 +818,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?))
|
||||
|
||||
Reference in new issue
Block a user