Skip to content

Commit 2044a11

Browse files
committed
refactor(vault)!: replace attachmentview with attachmentfullview
1 parent 7da83fc commit 2044a11

9 files changed

Lines changed: 163 additions & 201 deletions

File tree

crates/bitwarden-uniffi/src/vault/attachments.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::Path;
22

3-
use bitwarden_vault::{Attachment, AttachmentEncryptResult, AttachmentView, Cipher};
3+
use bitwarden_vault::{Attachment, AttachmentEncryptResult, AttachmentFullView, Cipher};
44

55
use crate::Result;
66

@@ -13,7 +13,7 @@ impl AttachmentsClient {
1313
pub fn encrypt_buffer(
1414
&self,
1515
cipher: Cipher,
16-
attachment: AttachmentView,
16+
attachment: AttachmentFullView,
1717
buffer: Vec<u8>,
1818
) -> Result<AttachmentEncryptResult> {
1919
Ok(self.0.encrypt_buffer(cipher, attachment, &buffer)?)
@@ -23,7 +23,7 @@ impl AttachmentsClient {
2323
pub fn encrypt_file(
2424
&self,
2525
cipher: Cipher,
26-
attachment: AttachmentView,
26+
attachment: AttachmentFullView,
2727
decrypted_file_path: String,
2828
encrypted_file_path: String,
2929
) -> Result<Attachment> {
@@ -38,7 +38,7 @@ impl AttachmentsClient {
3838
pub fn decrypt_buffer(
3939
&self,
4040
cipher: Cipher,
41-
attachment: AttachmentView,
41+
attachment: AttachmentFullView,
4242
buffer: Vec<u8>,
4343
) -> Result<Vec<u8>> {
4444
Ok(self.0.decrypt_buffer(cipher, attachment, &buffer)?)
@@ -48,7 +48,7 @@ impl AttachmentsClient {
4848
pub fn decrypt_file(
4949
&self,
5050
cipher: Cipher,
51-
attachment: AttachmentView,
51+
attachment: AttachmentFullView,
5252
encrypted_file_path: String,
5353
decrypted_file_path: String,
5454
) -> Result<()> {

crates/bitwarden-vault/src/cipher/attachment.rs

Lines changed: 79 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -64,57 +64,29 @@ impl Attachment {
6464
}
6565
}
6666

67+
/// The full decrypted view of an attachment, including its cryptographic key.
68+
///
69+
/// Eventually this will be made SDK-internal and a separate `AttachmentView` (without the key) will
70+
/// be exposed to clients. That requires all attachment operations to already live in the SDK, so
71+
/// clients never need to handle the key themselves.
6772
#[allow(missing_docs)]
6873
#[derive(Serialize, Deserialize, Debug, Clone)]
6974
#[serde(rename_all = "camelCase", deny_unknown_fields)]
7075
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
7176
#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
72-
pub struct AttachmentView {
77+
pub struct AttachmentFullView {
7378
pub id: Option<String>,
7479
pub url: Option<String>,
7580
pub size: Option<String>,
7681
pub size_name: Option<String>,
7782
pub file_name: Option<String>,
78-
pub key: Option<EncString>,
79-
/// The decrypted attachmentkey in base64 format.
80-
///
81-
/// **TEMPORARY FIELD**: This field is a temporary workaround to provide
82-
/// decrypted attachment keys to the TypeScript client during the migration
83-
/// process. It will be removed once the encryption/decryption logic is
84-
/// fully migrated to the SDK.
85-
///
86-
/// **Ticket**: <https://bitwarden.atlassian.net/browse/PM-23005>
83+
/// The decrypted per-attachment key that encrypts the attachment contents.
8784
///
88-
/// Do not rely on this field for long-term use.
89-
#[cfg(feature = "wasm")]
90-
pub decrypted_key: Option<String>,
91-
}
92-
93-
impl AttachmentView {
94-
pub(crate) fn reencrypt_key(
95-
&mut self,
96-
ctx: &mut KeyStoreContext<KeySlotIds>,
97-
old_key: SymmetricKeySlotId,
98-
new_key: SymmetricKeySlotId,
99-
) -> Result<(), CryptoError> {
100-
if let Some(attachment_key) = &mut self.key {
101-
let tmp_attachment_key_id = ctx.unwrap_symmetric_key(old_key, attachment_key)?;
102-
*attachment_key = ctx.wrap_symmetric_key(new_key, tmp_attachment_key_id)?;
103-
}
104-
Ok(())
105-
}
106-
107-
pub(crate) fn reencrypt_keys(
108-
attachment_views: &mut Vec<AttachmentView>,
109-
ctx: &mut KeyStoreContext<KeySlotIds>,
110-
old_key: SymmetricKeySlotId,
111-
new_key: SymmetricKeySlotId,
112-
) -> Result<(), CryptoError> {
113-
for attachment in attachment_views {
114-
attachment.reencrypt_key(ctx, old_key, new_key)?;
115-
}
116-
Ok(())
117-
}
85+
/// This is the decrypted key material, consistent with the [`CompositeEncryptable`] contract
86+
/// that a `*View` holds plaintext. On encryption it is wrapped with the cipher key to produce
87+
/// [`Attachment::key`]; `None` marks a legacy v1 attachment whose contents are encrypted
88+
/// directly with the user/organization key.
89+
pub key: Option<SymmetricCryptoKey>,
11890
}
11991

12092
#[allow(missing_docs)]
@@ -129,7 +101,7 @@ pub struct AttachmentEncryptResult {
129101
#[allow(missing_docs)]
130102
pub struct AttachmentFile {
131103
pub cipher: Cipher,
132-
pub attachment: AttachmentView,
104+
pub attachment: AttachmentFullView,
133105

134106
/// There are three different ways attachments are encrypted.
135107
/// 1. UserKey / OrgKey (Contents) - Legacy
@@ -141,7 +113,7 @@ pub struct AttachmentFile {
141113
#[allow(missing_docs)]
142114
pub struct AttachmentFileView<'a> {
143115
pub cipher: Cipher,
144-
pub attachment: AttachmentView,
116+
pub attachment: AttachmentFullView,
145117
pub contents: &'a [u8],
146118
}
147119

@@ -168,12 +140,16 @@ impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, AttachmentEncryptResul
168140

169141
let mut attachment = self.attachment.clone();
170142

171-
// Because this is a new attachment, we have to generate a key for it, encrypt the contents
172-
// with it, and then encrypt the key with the cipher key
143+
// Because this is a new attachment, we have to generate a key for it and encrypt the
144+
// contents with it. The decrypted key is stored on the view; wrapping it with the cipher
145+
// key is handled by `AttachmentFullView::encrypt_composite` below.
173146
let attachment_key = ctx.generate_symmetric_key();
174147
let encrypted_contents =
175148
OctetStreamBytes::from(self.contents).encrypt(ctx, attachment_key)?;
176-
attachment.key = Some(ctx.wrap_symmetric_key(ciphers_key, attachment_key)?);
149+
#[allow(deprecated)]
150+
{
151+
attachment.key = Some(ctx.dangerous_get_symmetric_key(attachment_key)?.clone());
152+
}
177153

178154
let contents = encrypted_contents.to_buffer()?;
179155

@@ -204,30 +180,11 @@ impl Decryptable<KeySlotIds, SymmetricKeySlotId, Vec<u8>> for AttachmentFile {
204180
ctx: &mut KeyStoreContext<KeySlotIds>,
205181
key: SymmetricKeySlotId,
206182
) -> Result<Vec<u8>, CryptoError> {
207-
let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key).map_err(|e| {
208-
tracing::warn!(
209-
attachment_id = ?self.attachment.id,
210-
cipher_id = ?self.cipher.id,
211-
has_cipher_key = self.cipher.key.is_some(),
212-
error = %e,
213-
"Failed to decrypt cipher key for attachment"
214-
);
215-
e
216-
})?;
217-
218-
// Version 2 or 3, `AttachmentKey` or `CipherKey(AttachmentKey)`
183+
// Version 2 or 3, `AttachmentKey` or `CipherKey(AttachmentKey)`. The view already carries
184+
// the decrypted content key (unwrapped when the attachment was decrypted), so register it
185+
// in the context and decrypt with it directly - the cipher key is not needed here.
219186
if let Some(attachment_key) = &self.attachment.key {
220-
let content_key = ctx
221-
.unwrap_symmetric_key(ciphers_key, attachment_key)
222-
.map_err(|e| {
223-
tracing::warn!(
224-
attachment_id = ?self.attachment.id,
225-
cipher_id = ?self.cipher.id,
226-
error = %e,
227-
"Failed to unwrap attachment key (v2/v3)"
228-
);
229-
e
230-
})?;
187+
let content_key = ctx.add_local_symmetric_key(attachment_key.clone());
231188
self.contents.decrypt(ctx, content_key).map_err(|e| {
232189
tracing::warn!(
233190
attachment_id = ?self.attachment.id,
@@ -252,59 +209,62 @@ impl Decryptable<KeySlotIds, SymmetricKeySlotId, Vec<u8>> for AttachmentFile {
252209
}
253210
}
254211

255-
// ⚠️ CONTRACT VIOLATION of `bitwarden_crypto::CompositeEncryptable`: `AttachmentView` retains
256-
// key-bound ciphertext (`key`, the attachment content key wrapped under the cipher key) and copies
257-
// it through unchanged (`key: self.key.clone()` below) instead of re-wrapping it under `key`. As a
258-
// result decrypt(K) -> encrypt(K1) -> decrypt(K1) does NOT round-trip.
259-
impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Attachment> for AttachmentView {
212+
impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Attachment> for AttachmentFullView {
260213
fn encrypt_composite(
261214
&self,
262215
ctx: &mut KeyStoreContext<KeySlotIds>,
263216
key: SymmetricKeySlotId,
264217
) -> Result<Attachment, CryptoError> {
218+
// Wrap the decrypted attachment key with the cipher key to produce the stored, wrapped
219+
// key. `None` (legacy v1) stays `None`.
220+
let wrapped_key = self
221+
.key
222+
.as_ref()
223+
.map(|attachment_key| {
224+
let key_id = ctx.add_local_symmetric_key(attachment_key.clone());
225+
ctx.wrap_symmetric_key(key, key_id)
226+
})
227+
.transpose()?;
228+
265229
Ok(Attachment {
266230
id: self.id.clone(),
267231
url: self.url.clone(),
268232
size: self.size.clone(),
269233
size_name: self.size_name.clone(),
270234
file_name: self.file_name.encrypt(ctx, key)?,
271-
// ⚠️ pass-through of wrapped key-bound ciphertext — see the contract-violation note
272-
// above.
273-
key: self.key.clone(),
235+
key: wrapped_key,
274236
})
275237
}
276238
}
277239

278-
impl Decryptable<KeySlotIds, SymmetricKeySlotId, AttachmentView> for Attachment {
240+
impl Decryptable<KeySlotIds, SymmetricKeySlotId, AttachmentFullView> for Attachment {
279241
fn decrypt(
280242
&self,
281243
ctx: &mut KeyStoreContext<KeySlotIds>,
282244
key: SymmetricKeySlotId,
283-
) -> Result<AttachmentView, CryptoError> {
245+
) -> Result<AttachmentFullView, CryptoError> {
284246
// Decrypt the file name or return an error if decryption fails
285247
let file_name = self.file_name.decrypt(ctx, key)?;
286248

287-
#[cfg(feature = "wasm")]
288-
let decrypted_key = if let Some(attachment_key) = &self.key {
289-
let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
290-
291-
#[allow(deprecated)]
292-
let actual_key = ctx.dangerous_get_symmetric_key(content_key_id)?;
293-
294-
Some(actual_key.to_base64())
295-
} else {
296-
None
297-
};
249+
// Unwrap the stored, wrapped attachment key into its decrypted form for the view. `None`
250+
// (legacy v1) stays `None`.
251+
let attachment_key = self
252+
.key
253+
.as_ref()
254+
.map(|wrapped_key| {
255+
let content_key_id = ctx.unwrap_symmetric_key(key, wrapped_key)?;
256+
#[allow(deprecated)]
257+
ctx.dangerous_get_symmetric_key(content_key_id).cloned()
258+
})
259+
.transpose()?;
298260

299-
Ok(AttachmentView {
261+
Ok(AttachmentFullView {
300262
id: self.id.clone(),
301263
url: self.url.clone(),
302264
size: self.size.clone(),
303265
size_name: self.size_name.clone(),
304266
file_name,
305-
key: self.key.clone(),
306-
#[cfg(feature = "wasm")]
307-
decrypted_key: decrypted_key.map(|k| k.to_string()),
267+
key: attachment_key,
308268
})
309269
}
310270
}
@@ -316,7 +276,7 @@ pub(crate) fn decrypt_attachments_with_failures(
316276
attachments: &[Attachment],
317277
ctx: &mut KeyStoreContext<KeySlotIds>,
318278
key: SymmetricKeySlotId,
319-
) -> (Vec<AttachmentView>, Vec<AttachmentView>) {
279+
) -> (Vec<AttachmentFullView>, Vec<AttachmentFullView>) {
320280
let mut successes = Vec::new();
321281
let mut failures = Vec::new();
322282

@@ -325,15 +285,14 @@ pub(crate) fn decrypt_attachments_with_failures(
325285
Ok(decrypted) => successes.push(decrypted),
326286
Err(e) => {
327287
tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
328-
failures.push(AttachmentView {
288+
failures.push(AttachmentFullView {
329289
id: attachment.id.clone(),
330290
url: attachment.url.clone(),
331291
size: attachment.size.clone(),
332292
size_name: attachment.size_name.clone(),
333293
file_name: None,
334-
key: attachment.key.clone(),
335-
#[cfg(feature = "wasm")]
336-
decrypted_key: None,
294+
// The attachment failed to decrypt, so its decrypted key is unavailable.
295+
key: None,
337296
});
338297
}
339298
}
@@ -361,12 +320,12 @@ impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment
361320

362321
#[cfg(test)]
363322
mod tests {
364-
use bitwarden_core::key_management::create_test_crypto_with_user_key;
323+
use bitwarden_core::key_management::{SymmetricKeySlotId, create_test_crypto_with_user_key};
365324
use bitwarden_crypto::{EncString, SymmetricCryptoKey};
366325
use bitwarden_encoding::B64;
367326

368327
use crate::{
369-
AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
328+
AttachmentFile, AttachmentFileView, AttachmentFullView, Cipher,
370329
cipher::cipher::{CipherRepromptType, CipherType},
371330
};
372331

@@ -387,15 +346,13 @@ mod tests {
387346
let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
388347
let key_store = create_test_crypto_with_user_key(user_key);
389348

390-
let attachment = AttachmentView {
349+
let attachment = AttachmentFullView {
391350
id: None,
392351
url: None,
393352
size: Some("100".into()),
394353
size_name: Some("100 Bytes".into()),
395354
file_name: Some("Test.txt".into()),
396355
key: None,
397-
#[cfg(feature = "wasm")]
398-
decrypted_key: None,
399356
};
400357

401358
let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
@@ -450,15 +407,29 @@ mod tests {
450407
let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
451408
let key_store = create_test_crypto_with_user_key(user_key);
452409

453-
let attachment = AttachmentView {
410+
// The view carries the decrypted content key. Unwrap the stored key (wrapped by the cipher
411+
// key, which is itself wrapped by the user key) to obtain it.
412+
let attachment_key = {
413+
let mut ctx = key_store.context();
414+
let cipher_key_enc: EncString = "2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap();
415+
let cipher_key = ctx
416+
.unwrap_symmetric_key(SymmetricKeySlotId::User, &cipher_key_enc)
417+
.unwrap();
418+
let wrapped_key: EncString = "2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap();
419+
let content_key_id = ctx.unwrap_symmetric_key(cipher_key, &wrapped_key).unwrap();
420+
#[allow(deprecated)]
421+
ctx.dangerous_get_symmetric_key(content_key_id)
422+
.unwrap()
423+
.clone()
424+
};
425+
426+
let attachment = AttachmentFullView {
454427
id: None,
455428
url: None,
456429
size: Some("161".into()),
457430
size_name: Some("161 Bytes".into()),
458431
file_name: Some("Test.txt".into()),
459-
key: Some("2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap()),
460-
#[cfg(feature = "wasm")]
461-
decrypted_key: None,
432+
key: Some(attachment_key),
462433
};
463434

464435
let cipher = Cipher {
@@ -514,15 +485,13 @@ mod tests {
514485
let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
515486
let key_store = create_test_crypto_with_user_key(user_key);
516487

517-
let attachment = AttachmentView {
488+
let attachment = AttachmentFullView {
518489
id: None,
519490
url: None,
520491
size: Some("161".into()),
521492
size_name: Some("161 Bytes".into()),
522493
file_name: Some("Test.txt".into()),
523494
key: None,
524-
#[cfg(feature = "wasm")]
525-
decrypted_key: None,
526495
};
527496

528497
let cipher = Cipher {

0 commit comments

Comments
 (0)