Skip to main content

bitwarden_vault/cipher/
attachment.rs

1use bitwarden_api_api::models::CipherAttachmentModel;
2use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
3use bitwarden_crypto::{
4    CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
5    OctetStreamBytes, PrimitiveEncryptable, SymmetricCryptoKey,
6};
7use serde::{Deserialize, Serialize};
8#[cfg(feature = "wasm")]
9use tsify::Tsify;
10
11use super::Cipher;
12use crate::VaultParseError;
13
14/// Cryptographic material for a new attachment, shared by the upgrade and create paths.
15pub(crate) struct AttachmentMaterial {
16    /// Raw attachment key, used to encrypt the attachment contents.
17    pub(crate) key: SymmetricCryptoKey,
18    /// Attachment key wrapped with the cipher key, stored on the attachment record.
19    pub(crate) wrapped_key: EncString,
20    /// File name encrypted with the cipher key.
21    pub(crate) encrypted_file_name: EncString,
22}
23
24#[allow(missing_docs)]
25#[derive(Serialize, Deserialize, Debug, Clone)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
28#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
29pub struct Attachment {
30    pub id: Option<String>,
31    pub url: Option<String>,
32    pub size: Option<String>,
33    /// Readable size, ex: "4.2 KB" or "1.43 GB"
34    pub size_name: Option<String>,
35    pub file_name: Option<EncString>,
36    pub key: Option<EncString>,
37}
38
39impl From<Attachment> for CipherAttachmentModel {
40    fn from(attachment: Attachment) -> Self {
41        Self {
42            file_name: attachment.file_name.map(|f| f.to_string()),
43            key: attachment.key.map(|k| k.to_string()),
44        }
45    }
46}
47
48/// The encryption format an attachment uses, derived from whether it carries its own key.
49#[derive(Debug, PartialEq, Eq)]
50pub(crate) enum AttachmentEncryptionVersion {
51    /// Legacy v1: contents are encrypted directly with the cipher key; no per-attachment key.
52    LegacyNoKeyV1,
53    /// V2: a per-attachment key, wrapped by the cipher key, encrypts the contents.
54    AttachmentKeyV2,
55}
56
57impl Attachment {
58    /// Returns the [`AttachmentEncryptionVersion`] this attachment uses.
59    pub(crate) fn encryption_version(&self) -> AttachmentEncryptionVersion {
60        match self.key {
61            Some(_) => AttachmentEncryptionVersion::AttachmentKeyV2,
62            None => AttachmentEncryptionVersion::LegacyNoKeyV1,
63        }
64    }
65}
66
67#[allow(missing_docs)]
68#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
71#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
72pub struct AttachmentView {
73    pub id: Option<String>,
74    pub url: Option<String>,
75    pub size: Option<String>,
76    pub size_name: Option<String>,
77    pub file_name: Option<String>,
78    #[cfg_attr(feature = "wasm", tsify(type = "SymmetricKey | undefined"))]
79    pub key: Option<SymmetricCryptoKey>,
80}
81
82#[allow(missing_docs)]
83#[derive(Serialize, Deserialize, Debug)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
86pub struct AttachmentEncryptResult {
87    pub attachment: Attachment,
88    pub contents: Vec<u8>,
89}
90
91#[allow(missing_docs)]
92pub struct AttachmentFile {
93    pub cipher: Cipher,
94    pub attachment: AttachmentView,
95
96    /// There are three different ways attachments are encrypted.
97    /// 1. UserKey / OrgKey (Contents) - Legacy
98    /// 2. AttachmentKey(Contents) - Pre CipherKey
99    /// 3. CipherKey(AttachmentKey(Contents)) - Current
100    pub contents: EncString,
101}
102
103#[allow(missing_docs)]
104pub struct AttachmentFileView<'a> {
105    pub cipher: Cipher,
106    pub attachment: AttachmentView,
107    pub contents: &'a [u8],
108}
109
110impl IdentifyKey<SymmetricKeySlotId> for AttachmentFileView<'_> {
111    fn key_identifier(&self) -> SymmetricKeySlotId {
112        self.cipher.key_identifier()
113    }
114}
115impl IdentifyKey<SymmetricKeySlotId> for AttachmentFile {
116    fn key_identifier(&self) -> SymmetricKeySlotId {
117        self.cipher.key_identifier()
118    }
119}
120
121impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, AttachmentEncryptResult>
122    for AttachmentFileView<'_>
123{
124    fn encrypt_composite(
125        &self,
126        ctx: &mut KeyStoreContext<KeySlotIds>,
127        key: SymmetricKeySlotId,
128    ) -> Result<AttachmentEncryptResult, CryptoError> {
129        let ciphers_key = Cipher::decrypt_cipher_key(ctx, key, &self.cipher.key)?;
130
131        let mut attachment = self.attachment.clone();
132
133        // Because this is a new attachment, we have to generate a key for it, encrypt the contents
134        // with it, and then encrypt the key with the cipher key
135        let attachment_key = ctx.generate_symmetric_key();
136        let encrypted_contents =
137            OctetStreamBytes::from(self.contents).encrypt(ctx, attachment_key)?;
138        #[allow(deprecated)]
139        let raw_attachment_key = ctx.dangerous_get_symmetric_key(attachment_key)?.clone();
140        attachment.key = Some(raw_attachment_key);
141
142        let contents = encrypted_contents.to_buffer()?;
143
144        // Once we have the encrypted contents, we can set the size of the attachment
145        attachment.size = Some(contents.len().to_string());
146        attachment.size_name = Some(size_name(contents.len()));
147
148        Ok(AttachmentEncryptResult {
149            attachment: attachment.encrypt_composite(ctx, ciphers_key)?,
150            contents,
151        })
152    }
153}
154
155fn size_name(size: usize) -> String {
156    let units = ["Bytes", "KB", "MB", "GB", "TB"];
157    let size = size as f64;
158    let unit = (size.ln() / 1024_f64.ln()).floor() as usize;
159    let size = size / 1024_f64.powi(unit as i32);
160
161    let size_round = (size * 10.0_f64).round() as usize as f64 / 10.0_f64;
162    format!("{} {}", size_round, units[unit])
163}
164
165impl Decryptable<KeySlotIds, SymmetricKeySlotId, Vec<u8>> for AttachmentFile {
166    fn decrypt(
167        &self,
168        ctx: &mut KeyStoreContext<KeySlotIds>,
169        key: SymmetricKeySlotId,
170    ) -> Result<Vec<u8>, CryptoError> {
171        // Version 2 or 3: attachment view already holds the raw attachment key
172        if let Some(attachment_key) = &self.attachment.key {
173            let content_key = ctx.add_local_symmetric_key(attachment_key.clone());
174            self.contents.decrypt(ctx, content_key).map_err(|e| {
175                tracing::warn!(
176                    attachment_id = ?self.attachment.id,
177                    cipher_id = ?self.cipher.id,
178                    error = %e,
179                    "Failed to decrypt attachment contents with attachment key (v2/v3)"
180                );
181                e
182            })
183        } else {
184            // Legacy attachment version 1, use user/org key
185            self.contents.decrypt(ctx, key).map_err(|e| {
186                tracing::warn!(
187                    attachment_id = ?self.attachment.id,
188                    cipher_id = ?self.cipher.id,
189                    error = %e,
190                    "Failed to decrypt attachment contents with user/org key (legacy v1)"
191                );
192                e
193            })
194        }
195    }
196}
197
198impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Attachment> for AttachmentView {
199    fn encrypt_composite(
200        &self,
201        ctx: &mut KeyStoreContext<KeySlotIds>,
202        key: SymmetricKeySlotId,
203    ) -> Result<Attachment, CryptoError> {
204        Ok(Attachment {
205            id: self.id.clone(),
206            url: self.url.clone(),
207            size: self.size.clone(),
208            size_name: self.size_name.clone(),
209            file_name: self.file_name.encrypt(ctx, key)?,
210            key: self
211                .key
212                .as_ref()
213                .map(|k| {
214                    let slot = ctx.add_local_symmetric_key(k.clone());
215                    ctx.wrap_symmetric_key(key, slot)
216                })
217                .transpose()?,
218        })
219    }
220}
221
222impl Decryptable<KeySlotIds, SymmetricKeySlotId, AttachmentView> for Attachment {
223    fn decrypt(
224        &self,
225        ctx: &mut KeyStoreContext<KeySlotIds>,
226        key: SymmetricKeySlotId,
227    ) -> Result<AttachmentView, CryptoError> {
228        let file_name = self.file_name.decrypt(ctx, key)?;
229
230        let decrypted_key = if let Some(attachment_key) = &self.key {
231            let content_key_id = ctx.unwrap_symmetric_key(key, attachment_key)?;
232            #[allow(deprecated)]
233            Some(ctx.dangerous_get_symmetric_key(content_key_id)?.clone())
234        } else {
235            None
236        };
237
238        Ok(AttachmentView {
239            id: self.id.clone(),
240            url: self.url.clone(),
241            size: self.size.clone(),
242            size_name: self.size_name.clone(),
243            file_name,
244            key: decrypted_key,
245        })
246    }
247}
248
249/// Decrypts a list of attachments, separating successful decryptions from failures.
250///
251/// Returns a tuple of (successful_attachments, failed_attachments).
252pub(crate) fn decrypt_attachments_with_failures(
253    attachments: &[Attachment],
254    ctx: &mut KeyStoreContext<KeySlotIds>,
255    key: SymmetricKeySlotId,
256) -> (Vec<AttachmentView>, Vec<AttachmentView>) {
257    let mut successes = Vec::new();
258    let mut failures = Vec::new();
259
260    for attachment in attachments {
261        match attachment.decrypt(ctx, key) {
262            Ok(decrypted) => successes.push(decrypted),
263            Err(e) => {
264                tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
265                let recovered_key = attachment.key.as_ref().and_then(|attachment_key| {
266                    let slot = ctx.unwrap_symmetric_key(key, attachment_key).ok()?;
267                    #[allow(deprecated)]
268                    Some(ctx.dangerous_get_symmetric_key(slot).ok()?.clone())
269                });
270                failures.push(AttachmentView {
271                    id: attachment.id.clone(),
272                    url: attachment.url.clone(),
273                    size: attachment.size.clone(),
274                    size_name: attachment.size_name.clone(),
275                    file_name: None,
276                    key: recovered_key,
277                });
278            }
279        }
280    }
281
282    (successes, failures)
283}
284
285impl TryFrom<bitwarden_api_api::models::AttachmentResponseModel> for Attachment {
286    type Error = VaultParseError;
287
288    fn try_from(
289        attachment: bitwarden_api_api::models::AttachmentResponseModel,
290    ) -> Result<Self, Self::Error> {
291        Ok(Self {
292            id: attachment.id,
293            url: attachment.url,
294            size: attachment.size,
295            size_name: attachment.size_name,
296            file_name: EncString::try_from_optional(attachment.file_name)?,
297            key: EncString::try_from_optional(attachment.key)?,
298        })
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use bitwarden_core::key_management::create_test_crypto_with_user_key;
305    use bitwarden_crypto::{EncString, SymmetricCryptoKey};
306    use bitwarden_encoding::B64;
307
308    use crate::{
309        AttachmentFile, AttachmentFileView, AttachmentView, Cipher,
310        cipher::cipher::{CipherRepromptType, CipherType},
311    };
312
313    #[test]
314    fn test_size_name_conversions() {
315        assert_eq!(super::size_name(0), "0 Bytes");
316        assert_eq!(super::size_name(19), "19 Bytes");
317        assert_eq!(super::size_name(1024), "1 KB");
318        assert_eq!(super::size_name(1570), "1.5 KB");
319        assert_eq!(super::size_name(1024 * 1024), "1 MB");
320        assert_eq!(super::size_name(1024 * 18999), "18.6 MB");
321        assert_eq!(super::size_name(1024 * 1024 * 1024), "1 GB");
322        assert_eq!(super::size_name(1024 * 1024 * 1024 * 1024), "1 TB");
323    }
324
325    #[test]
326    fn test_encrypt_attachment() {
327        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
328        let key_store = create_test_crypto_with_user_key(user_key);
329
330        let attachment = AttachmentView {
331            id: None,
332            url: None,
333            size: Some("100".into()),
334            size_name: Some("100 Bytes".into()),
335            file_name: Some("Test.txt".into()),
336            key: None,
337        };
338
339        let contents = b"This is a test file that we will encrypt. It's 100 bytes long, the encrypted version will be longer!";
340
341        let attachment_file = AttachmentFileView {
342            cipher: Cipher {
343                partial_data: None,
344                id: None,
345                organization_id: None,
346                folder_id: None,
347                collection_ids: Vec::new(),
348                key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
349                name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
350                notes: None,
351                r#type: CipherType::Login,
352                login: None,
353                identity: None,
354                card: None,
355                secure_note: None,
356                ssh_key: None,
357                bank_account: None,
358                drivers_license: None,
359                passport: None,
360                favorite: false,
361                reprompt: CipherRepromptType::None,
362                organization_use_totp: false,
363                edit: true,
364                permissions: None,
365                view_password: true,
366                local_data: None,
367                attachments: None,
368                fields: None,
369                password_history: None,
370                creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
371                deleted_date: None,
372                revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
373                archived_date: None,
374                data: None,
375            },
376            attachment,
377            contents: contents.as_slice(),
378        };
379
380        let result = key_store.encrypt(attachment_file).unwrap();
381
382        assert_eq!(result.contents.len(), 161);
383        assert_eq!(result.attachment.size, Some("161".into()));
384        assert_eq!(result.attachment.size_name, Some("161 Bytes".into()));
385    }
386
387    #[test]
388    fn test_attachment_key() {
389        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
390        let key_store = create_test_crypto_with_user_key(user_key);
391
392        let attachment_key = {
393            let mut ctx = key_store.context();
394            let cipher_key_enc: EncString = "2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap();
395            let cipher_key_slot = Cipher::decrypt_cipher_key(
396                &mut ctx,
397                bitwarden_core::key_management::SymmetricKeySlotId::User,
398                &Some(cipher_key_enc),
399            )
400            .unwrap();
401            let att_key_enc: EncString = "2.r288/AOSPiaLFkW07EBGBw==|SAmnnCbOLFjX5lnURvoualOetQwuyPc54PAmHDTRrhT0gwO9ailna9U09q9bmBfI5XrjNNEsuXssgzNygRkezoVQvZQggZddOwHB6KQW5EQ=|erIMUJp8j+aTcmhdE50zEX+ipv/eR1sZ7EwULJm/6DY=".parse().unwrap();
402            let att_key_slot = ctx
403                .unwrap_symmetric_key(cipher_key_slot, &att_key_enc)
404                .unwrap();
405            #[allow(deprecated)]
406            ctx.dangerous_get_symmetric_key(att_key_slot)
407                .unwrap()
408                .clone()
409        };
410
411        let attachment = AttachmentView {
412            id: None,
413            url: None,
414            size: Some("161".into()),
415            size_name: Some("161 Bytes".into()),
416            file_name: Some("Test.txt".into()),
417            key: Some(attachment_key),
418        };
419
420        let cipher  = Cipher {
421            partial_data: None,
422            id: None,
423            organization_id: None,
424            folder_id: None,
425            collection_ids: Vec::new(),
426            key: Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap()),
427            name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
428            notes: None,
429            r#type: CipherType::Login,
430            login: None,
431            identity: None,
432            card: None,
433            secure_note: None,
434            ssh_key: None,
435            bank_account: None,
436            drivers_license: None,
437            passport: None,
438            favorite: false,
439            reprompt: CipherRepromptType::None,
440            organization_use_totp: false,
441            edit: true,
442            permissions: None,
443            view_password: true,
444            local_data: None,
445            attachments: None,
446            fields: None,
447            password_history: None,
448            creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
449            deleted_date: None,
450            revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
451            archived_date: None,
452            data: None,
453        };
454
455        let enc_file = B64::try_from("Ao00qr1xLsV+ZNQpYZ/UwEwOWo3hheKwCYcOGIbsorZ6JIG2vLWfWEXCVqP0hDuzRvmx8otApNZr8pJYLNwCe1aQ+ySHQYGkdubFjoMojulMbQ959Y4SJ6Its/EnVvpbDnxpXTDpbutDxyhxfq1P3lstL2G9rObJRrxiwdGlRGu1h94UA1fCCkIUQux5LcqUee6W4MyQmRnsUziH8gGzmtI=").unwrap();
456        let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
457
458        let dec = key_store
459            .decrypt(&AttachmentFile {
460                cipher,
461                attachment,
462                contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
463            })
464            .unwrap();
465
466        assert_eq!(dec, original.as_bytes());
467    }
468
469    #[test]
470    fn test_attachment_without_key() {
471        let user_key: SymmetricCryptoKey = "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==".to_string().try_into().unwrap();
472        let key_store = create_test_crypto_with_user_key(user_key);
473
474        let attachment = AttachmentView {
475            id: None,
476            url: None,
477            size: Some("161".into()),
478            size_name: Some("161 Bytes".into()),
479            file_name: Some("Test.txt".into()),
480            key: None,
481        };
482
483        let cipher  = Cipher {
484            partial_data: None,
485            id: None,
486            organization_id: None,
487            folder_id: None,
488            collection_ids: Vec::new(),
489            key: None,
490            name: Some("2.d24xECyEdMZ3MG9s6SrGNw==|XvJlTeu5KJ22M3jKosy6iw==|8xGiQty4X61cDMx6PVqkJfSQ0ZTdA/5L9TpG7QfovoM=".parse().unwrap()),
491            notes: None,
492            r#type: CipherType::Login,
493            login: None,
494            identity: None,
495            card: None,
496            secure_note: None,
497            ssh_key: None,
498            bank_account: None,
499            drivers_license: None,
500            passport: None,
501            favorite: false,
502            reprompt: CipherRepromptType::None,
503            organization_use_totp: false,
504            edit: true,
505            permissions: None,
506            view_password: true,
507            local_data: None,
508            attachments: None,
509            fields: None,
510            password_history: None,
511            creation_date: "2023-07-24T12:05:09.466666700Z".parse().unwrap(),
512            deleted_date: None,
513            revision_date: "2023-07-27T19:28:05.240Z".parse().unwrap(),
514            archived_date: None,
515            data: None,
516        };
517
518        let enc_file = B64::try_from("AsQLXOBHrJ8porroTUlPxeJOm9XID7LL9D2+KwYATXEpR1EFjLBpcCvMmnqcnYLXIEefe9TCeY4Us50ux43kRSpvdB7YkjxDKV0O1/y6tB7qC4vvv9J9+O/uDEnMx/9yXuEhAW/LA/TsU/WAgxkOM0uTvm8JdD9LUR1z9Ql7zOWycMVzkvGsk2KBNcqAdrotS5FlDftZOXyU8pWecNeyA/w=").unwrap();
519        let original = B64::try_from("rMweTemxOL9D0iWWfRxiY3enxiZ5IrwWD6ef2apGO6MvgdGhy2fpwmATmn7BpSj9lRumddLLXm7u8zSp6hnXt1hS71YDNh78LjGKGhGL4sbg8uNnpa/I6GK/83jzqGYN7+ESbg==").unwrap();
520
521        let dec = key_store
522            .decrypt(&AttachmentFile {
523                cipher,
524                attachment,
525                contents: EncString::from_buffer(enc_file.as_bytes()).unwrap(),
526            })
527            .unwrap();
528
529        assert_eq!(dec, original.as_bytes());
530    }
531}