Skip to main content

bitwarden_vault/cipher/blob/
v1.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::cipher::{
5    field::FieldType, linked_id::LinkedIdType, login::UriMatchType, secure_note::SecureNoteType,
6};
7
8#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
9#[serde(rename_all = "camelCase")]
10pub(crate) struct CipherBlobV1 {
11    pub name: String,
12    pub notes: Option<String>,
13    pub type_data: CipherTypeDataV1,
14    #[serde(default, skip_serializing_if = "Vec::is_empty")]
15    pub fields: Vec<FieldDataV1>,
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub password_history: Vec<PasswordHistoryDataV1>,
18}
19
20impl bitwarden_crypto::safe::SealableData for CipherBlobV1 {}
21
22// IdentityDataV1 is significantly larger than other variants (432 bytes vs ~144 bytes).
23// Boxing could be considered if this becomes a performance concern, but since these types are
24// only constructed during deserialization from encrypted blobs, the stack size is acceptable.
25#[allow(clippy::large_enum_variant)]
26#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
27#[serde(tag = "type", rename_all = "camelCase")]
28pub(crate) enum CipherTypeDataV1 {
29    Login(LoginDataV1),
30    Card(CardDataV1),
31    Identity(IdentityDataV1),
32    SecureNote(SecureNoteDataV1),
33    SshKey(SshKeyDataV1),
34}
35
36#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
37#[serde(rename_all = "camelCase")]
38pub(crate) struct LoginDataV1 {
39    pub username: Option<String>,
40    pub password: Option<String>,
41    pub password_revision_date: Option<DateTime<Utc>>,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub uris: Vec<LoginUriDataV1>,
44    pub totp: Option<String>,
45    pub autofill_on_page_load: Option<bool>,
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub fido2_credentials: Vec<Fido2CredentialDataV1>,
48}
49
50#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
51#[serde(rename_all = "camelCase")]
52pub(crate) struct LoginUriDataV1 {
53    pub uri: Option<String>,
54    pub r#match: Option<UriMatchType>,
55}
56
57#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
58#[serde(rename_all = "camelCase")]
59pub(crate) struct Fido2CredentialDataV1 {
60    pub credential_id: String,
61    pub key_type: String,
62    pub key_algorithm: String,
63    pub key_curve: String,
64    pub key_value: String,
65    pub rp_id: String,
66    pub user_handle: Option<String>,
67    pub user_name: Option<String>,
68    pub counter: u64,
69    pub rp_name: Option<String>,
70    pub user_display_name: Option<String>,
71    pub discoverable: bool,
72    pub creation_date: DateTime<Utc>,
73}
74
75#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
76#[serde(rename_all = "camelCase")]
77pub(crate) struct CardDataV1 {
78    pub cardholder_name: Option<String>,
79    pub exp_month: Option<String>,
80    pub exp_year: Option<String>,
81    pub code: Option<String>,
82    pub brand: Option<String>,
83    pub number: Option<String>,
84}
85
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
87#[serde(rename_all = "camelCase")]
88pub(crate) struct IdentityDataV1 {
89    pub title: Option<String>,
90    pub first_name: Option<String>,
91    pub middle_name: Option<String>,
92    pub last_name: Option<String>,
93    pub address1: Option<String>,
94    pub address2: Option<String>,
95    pub address3: Option<String>,
96    pub city: Option<String>,
97    pub state: Option<String>,
98    pub postal_code: Option<String>,
99    pub country: Option<String>,
100    pub company: Option<String>,
101    pub email: Option<String>,
102    pub phone: Option<String>,
103    pub ssn: Option<String>,
104    pub username: Option<String>,
105    pub passport_number: Option<String>,
106    pub license_number: Option<String>,
107}
108
109#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
110#[serde(rename_all = "camelCase")]
111pub(crate) struct SecureNoteDataV1 {
112    #[serde(rename = "secureNoteType")]
113    pub r#type: SecureNoteType,
114}
115
116#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
117#[serde(rename_all = "camelCase")]
118pub(crate) struct SshKeyDataV1 {
119    pub private_key: String,
120    pub public_key: String,
121    pub fingerprint: String,
122}
123
124#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
125#[serde(rename_all = "camelCase")]
126pub(crate) struct FieldDataV1 {
127    pub name: Option<String>,
128    pub value: Option<String>,
129    pub r#type: FieldType,
130    pub linked_id: Option<LinkedIdType>,
131}
132
133#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
134#[serde(rename_all = "camelCase")]
135pub(crate) struct PasswordHistoryDataV1 {
136    pub password: String,
137    pub last_used_date: DateTime<Utc>,
138}
139
140#[cfg(test)]
141mod tests {
142    use bitwarden_core::key_management::KeyIds;
143    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey, safe::DataEnvelope};
144    use bitwarden_encoding::B64;
145    use chrono::TimeZone;
146
147    use super::*;
148    use crate::cipher::{
149        blob::CipherBlob, linked_id::LoginLinkedIdType, secure_note::SecureNoteType,
150    };
151
152    const TEST_VECTOR_SECURE_NOTE_CEK: &str =
153        "pQEEAlApYxU9rgfIc9v9sHuglhkKAzoAARFvBIEEIFggDKYmieFA7a0UoOMAt4BErOpk7VfwZI8Dk0Yc9FxFzWIB";
154    const TEST_VECTOR_SECURE_NOTE_ENVELOPE: &str = "g1hLpQE6AAERbwN4I2FwcGxpY2F0aW9uL3guYml0d2FyZGVuLmNib3ItcGFkZGVkBFApYxU9rgfIc9v9sHuglhkKOgABOIECOgABOIABoQVYGMihknYX7mrmC03/w0V7rTMij2+q237p21h2H2Sq0hnlzZ4ka8yMW1yFOOMSt5ZS3tNQxzT07qeeY10tAeczgizA2g5AvmIQJdXK+7KR5mP3zk5VaVfGjC9mZUUFXwIAXsxC16HUII7Z1Iwhpd+MrJDf+itZGFVd07ExaXkH5+MjfaXhJqXBSxAStCG5zLGsEg==";
155
156    const TEST_VECTOR_LOGIN_CEK: &str =
157        "pQEEAlClJ9tO9x8fN2JVe5N8uzAaAzoAARFvBIEEIFggZwJXkLK7A6Sy5Y9+dacJrzCg9bo4RMRxXaRGDWYfbTYB";
158    const TEST_VECTOR_LOGIN_ENVELOPE: &str = "g1hLpQE6AAERbwN4I2FwcGxpY2F0aW9uL3guYml0d2FyZGVuLmNib3ItcGFkZGVkBFClJ9tO9x8fN2JVe5N8uzAaOgABOIECOgABOIABoQVYGNYP1rgAT3D2T6q2lGTRjIPHR2IELUDWE1kC2erlZM4Dqyeew1VlDkdXnZIE+t2g4SJh8IFSHo9WuzmyY+qC+V9cuGW3QHt+sg7pfZ5kQBh40U9uxUcxOHdVF3jQhcAmgB9abShR68u41NMDwwLSafG8PLzsUfhhxpCG0+ZuOda3tFVM1y5TyiDPJBBoJECuYK/K/1RLNAWAy5AU98yI0RgdK0MHxpoOqdSC8dXXu6fGgON7XLQWkkceFWILp0o51/c7OvdJ3B1nCiCIZjwAyS96+oWOzLrsPaGkBjqedBCi5iwelzLOttXk6nrzE/FfC0PkeeSsPSr9UdXBbeuUSK7wKtir9Lx/gtJ4sMPFidtTNdXCcDT9RA7y21h+3+wHQgGSlOGRigXFgXWi5ajSPCs9zLn7ERoG4BZ4IHa6EMSJbGH1pANW+Ibg4aadBF7LzOi/BZx2oZ/6z54XfAfqb44FR3/XXdNFHosPH9IH7CkKbNvLTuGrOTk9S998qFDNkWeGthrDYclaYSFktSHULvmHhSPadRL4uM6254HTmSjTAG6FDhhqdJU3SvoifrvBeHqjEP7F5R+zjVQol+JUcs/ExwmlrxXETOTzGyC/++FflZyPIUnH9U7ZqXhiYGd0ZvcyrnrnieehYToWFdwFR9ho+6h9hB/SCjhGudP5CRPZDL9GkNAUS5+pAd4ZC19fDjWpwMnEbgPTuthXKl6YRHxxCV+xpc9jncVQt9zF31e0bSIP7kVcdmlgjXaV6Nmd3aZ+PqeJnj27gCxG2tZkMimdJmEgxkL/cfvNHENg43+rpnV2mCTrCAO/X+RDGKdi7SIzsLesPVXVHEZCM4UBv2v+S/vpDFC3ie09cz525PCgt/7Je68k8S5WsTwKVeL0+T/ysqDo7wJvFSToY2G/LOGnBYMUYcCLfJGQbD8g2xI3oC+go8kcyVDJv+936/MkAYSxnvZDrMr30zEZaNm5YBh8cRqKr4MxOjJBk1XWjwDNsSQrDw==";
159
160    const TEST_VECTOR_CARD_CEK: &str =
161        "pQEEAlBqeNyImi1f1pMtJVlvDuV1AzoAARFvBIEEIFggyyOmMlDBj/oRic4qPjXnnKXGf4QGYMq7KvztZO3it+cB";
162    const TEST_VECTOR_CARD_ENVELOPE: &str = "g1hLpQE6AAERbwN4I2FwcGxpY2F0aW9uL3guYml0d2FyZGVuLmNib3ItcGFkZGVkBFBqeNyImi1f1pMtJVlvDuV1OgABOIECOgABOIABoQVYGHZO47yxMBN7r8jgWNJ8c+1bfUld0uzdNli2xQgFm6X0chG+qNdkiROVAUxi75+cN4jiTOzt41pG7bXyo7U4D8R38zR+l7jePi39w3YV4tnmxIenwBPK/0qdO8pLKUMwi8PMIBqJ2UxanLqRhP6Ru2i43rpVZMgAmasGgzGG8hhJttii1CidG8ntNP/zRvRl38F/7bphlN1a08/FeycdIAfQUf7tgzoaj1JegSwEs1M7/+ONHlPtlkmovN/zJTP0ZHL7U7NAt/JBIWbeScbGP6E=";
163
164    const TEST_VECTOR_IDENTITY_CEK: &str =
165        "pQEEAlBKyRoZr3LVRJsXJQ1msUhQAzoAARFvBIEEIFggVf/R4MMZFsa5DDtjjTG/1GozhF9jNQACFt9KpMpA9D8B";
166    const TEST_VECTOR_IDENTITY_ENVELOPE: &str = "g1hLpQE6AAERbwN4I2FwcGxpY2F0aW9uL3guYml0d2FyZGVuLmNib3ItcGFkZGVkBFBKyRoZr3LVRJsXJQ1msUhQOgABOIECOgABOIABoQVYGNZ9Ckqt+ftCL1eTn3LHTP/bLQVSiT2nFFkBl2ON9MRyKHrBCGlRlKxcGgMhFQhf3LY7kiDTjvvUhbZr3/rbc/fA8+7HS2UYu/SMOnxF4fg5AlBDc2kwE5iPqwJAJU4fnMqlQm+0jBAE2MS7oppPwHYh8cxDE9pqEa7Ehz1XygUgmUWtEpgGo2Nia+mdCnltws2X+uLCeAf6x5Ioc+HvFUIzFiYEN4WQ+NLmaCNrES9Zw9TXQTSh0drdPqaW7SSMjpBLk1TRtKX4hnSqE+tlRcfG24hPf214jG8On9tw1cdMQbF9GeC2FidfX+snjrU5psmje2bCcExfnvL7pgPeTV/R6+Fct0Jx5pKFXdTQM3SM1Ms8I+sq22sSc9Bu++V9nXFlLIyvWF9H/9FMYXrUR6HfBzSMJk7BSSin4wk/BKTEE59uaW+MtT/sDsW76aRo29VUqymbd5aezHCNxM8CFYaRGEXqWmakwXOPkqZh6CRhT3IZ6MjMQw2GbmDG+qv/KcbJatlKT1ZE6LUos/zpErOf0AT16D1WkS+9QwIeTP5QLv6F291nlBR2xDPg9v/cuauw";
167
168    const TEST_VECTOR_SSH_KEY_CEK: &str =
169        "pQEEAlApE2RsnNwb3+3FyIr/kcfWAzoAARFvBIEEIFggDk3igU6wYnicl6jRSYILSaPlDWYCjnRUqMLdqfPkVKAB";
170    const TEST_VECTOR_SSH_KEY_ENVELOPE: &str = "g1hLpQE6AAERbwN4I2FwcGxpY2F0aW9uL3guYml0d2FyZGVuLmNib3ItcGFkZGVkBFApE2RsnNwb3+3FyIr/kcfWOgABOIECOgABOIABoQVYGHPwqnuSuDHdwTg3twT5B0b3AXKVK+cySVkBSzorjdnfAdt1aNM32x3BPUg4QMkR99SQum3yc4eIT5eqi2FZjHyvEVPMwxfcWqg26g8UTc3dsRW57RYRF4ajx4+MGcJj+wWTrI8jPmthhLAnEHT11eC2YjYIW1INWKGFJTKnTjwHw1LTVJvEzA9MAZRk2y2NC+qkkdDM3wKmhl4PqoEPmt/x6qBjlR5+rlA4rUqkm9ja+NqqEbz8McGXBw8QWOh99/xE1PorFk7S+o9LW1Kcv1/GL+1wv6X7tTo1dYVYa2uCo9Hp9C8D5zXz/iVLm9w98NQFZQlteO8yibEOp+F/VNpgpsmZjOQzJ6wf0hKabFF2eXIUJ2RT1vJT+zUdcfc+TMkypaBbJEagmAiEBnZFcxVEhQ3tn1ZyJFRUcMzm91azIHQMmQ9cS6h/SqTGFF3z+q0H4+8w2S+yl+D5/OVWQHKcSOFvsPA=";
171
172    fn test_blob_secure_note() -> CipherBlobV1 {
173        CipherBlobV1 {
174            name: "Test Secure Note".to_string(),
175            notes: Some("Some notes".to_string()),
176            type_data: CipherTypeDataV1::SecureNote(SecureNoteDataV1 {
177                r#type: SecureNoteType::Generic,
178            }),
179            fields: Vec::new(),
180            password_history: Vec::new(),
181        }
182    }
183
184    fn test_blob_login() -> CipherBlobV1 {
185        CipherBlobV1 {
186            name: "Test Login".to_string(),
187            notes: Some("Login notes".to_string()),
188            type_data: CipherTypeDataV1::Login(LoginDataV1 {
189                username: Some("[email protected]".to_string()),
190                password: Some("p@ssw0rd123".to_string()),
191                password_revision_date: Some(Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap()),
192                uris: vec![LoginUriDataV1 {
193                    uri: Some("https://example.com/login".to_string()),
194                    r#match: Some(UriMatchType::Domain),
195                }],
196                totp: Some("otpauth://totp/test?secret=JBSWY3DPEHPK3PXP".to_string()),
197                autofill_on_page_load: Some(true),
198                fido2_credentials: vec![Fido2CredentialDataV1 {
199                    credential_id: "credential-id-123".to_string(),
200                    key_type: "public-key".to_string(),
201                    key_algorithm: "ECDSA".to_string(),
202                    key_curve: "P-256".to_string(),
203                    key_value: "key-value-base64".to_string(),
204                    rp_id: "example.com".to_string(),
205                    user_handle: Some("user-handle-456".to_string()),
206                    user_name: Some("testuser".to_string()),
207                    counter: 42,
208                    rp_name: Some("Example".to_string()),
209                    user_display_name: Some("Test User".to_string()),
210                    discoverable: true,
211                    creation_date: Utc.with_ymd_and_hms(2024, 6, 1, 10, 30, 0).unwrap(),
212                }],
213            }),
214            fields: vec![FieldDataV1 {
215                name: Some("Custom Field".to_string()),
216                value: Some("custom-value".to_string()),
217                r#type: FieldType::Linked,
218                linked_id: Some(LinkedIdType::Login(LoginLinkedIdType::Username)),
219            }],
220            password_history: vec![PasswordHistoryDataV1 {
221                password: "old-password-1".to_string(),
222                last_used_date: Utc.with_ymd_and_hms(2023, 12, 1, 8, 0, 0).unwrap(),
223            }],
224        }
225    }
226
227    fn test_blob_card() -> CipherBlobV1 {
228        CipherBlobV1 {
229            name: "Test Card".to_string(),
230            notes: Some("Card notes".to_string()),
231            type_data: CipherTypeDataV1::Card(CardDataV1 {
232                cardholder_name: Some("John Doe".to_string()),
233                exp_month: Some("12".to_string()),
234                exp_year: Some("2028".to_string()),
235                code: Some("123".to_string()),
236                brand: Some("Visa".to_string()),
237                number: Some("4111111111111111".to_string()),
238            }),
239            fields: Vec::new(),
240            password_history: Vec::new(),
241        }
242    }
243
244    fn test_blob_identity() -> CipherBlobV1 {
245        CipherBlobV1 {
246            name: "Test Identity".to_string(),
247            notes: Some("Identity notes".to_string()),
248            type_data: CipherTypeDataV1::Identity(IdentityDataV1 {
249                title: Some("Mr".to_string()),
250                first_name: Some("John".to_string()),
251                middle_name: Some("Michael".to_string()),
252                last_name: Some("Doe".to_string()),
253                address1: Some("123 Main St".to_string()),
254                address2: Some("Apt 4B".to_string()),
255                address3: Some("Building C".to_string()),
256                city: Some("New York".to_string()),
257                state: Some("NY".to_string()),
258                postal_code: Some("10001".to_string()),
259                country: Some("US".to_string()),
260                company: Some("Acme Corp".to_string()),
261                email: Some("[email protected]".to_string()),
262                phone: Some("555-0123".to_string()),
263                ssn: Some("123-45-6789".to_string()),
264                username: Some("johndoe".to_string()),
265                passport_number: Some("P12345678".to_string()),
266                license_number: Some("DL-987654".to_string()),
267            }),
268            fields: Vec::new(),
269            password_history: Vec::new(),
270        }
271    }
272
273    fn test_blob_ssh_key() -> CipherBlobV1 {
274        CipherBlobV1 {
275            name: "Test SSH Key".to_string(),
276            notes: Some("SSH key notes".to_string()),
277            type_data: CipherTypeDataV1::SshKey(SshKeyDataV1 {
278                private_key: "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEA\n-----END OPENSSH PRIVATE KEY-----".to_string(),
279                public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI [email protected]".to_string(),
280                fingerprint: "SHA256:abcdefghijklmnopqrstuvwxyz012345678901234567".to_string(),
281            }),
282            fields: Vec::new(),
283            password_history: Vec::new(),
284        }
285    }
286
287    #[test]
288    #[ignore]
289    fn generate_test_vectors() {
290        let blobs: Vec<(&str, CipherBlobV1)> = vec![
291            ("LOGIN", test_blob_login()),
292            ("CARD", test_blob_card()),
293            ("IDENTITY", test_blob_identity()),
294            ("SECURE_NOTE", test_blob_secure_note()),
295            ("SSH_KEY", test_blob_ssh_key()),
296        ];
297
298        for (name, blob) in blobs {
299            let data: CipherBlob = blob.into();
300            let store: KeyStore<KeyIds> = KeyStore::default();
301            let mut ctx = store.context_mut();
302            let (envelope, cek_id) = DataEnvelope::seal(data, &mut ctx).unwrap();
303
304            #[allow(deprecated)]
305            let cek = ctx.dangerous_get_symmetric_key(cek_id).unwrap();
306            println!(
307                "const TEST_VECTOR_{}_CEK: &str = \"{}\";",
308                name,
309                cek.to_base64()
310            );
311            println!(
312                "const TEST_VECTOR_{}_ENVELOPE: &str = \"{}\";",
313                name,
314                String::from(envelope)
315            );
316            println!();
317        }
318    }
319
320    fn verify_test_vector(cek_str: &str, envelope_str: &str, expected: CipherBlobV1) {
321        let cek = SymmetricCryptoKey::try_from(B64::try_from(cek_str).unwrap()).unwrap();
322
323        let store: KeyStore<KeyIds> = KeyStore::default();
324        let mut ctx = store.context_mut();
325        let cek_id = ctx.add_local_symmetric_key(cek);
326
327        let envelope: DataEnvelope = envelope_str.parse().unwrap();
328        let unsealed: CipherBlob = envelope
329            .unseal(cek_id, &mut ctx)
330            .expect("CipherBlobV1 has changed in a backwards-incompatible way. Existing encrypted data must remain deserializable. If a new format is needed, create a new version instead of modifying V1.");
331        assert_eq!(unsealed, expected.into());
332    }
333
334    #[test]
335    fn test_recorded_secure_note_test_vector() {
336        verify_test_vector(
337            TEST_VECTOR_SECURE_NOTE_CEK,
338            TEST_VECTOR_SECURE_NOTE_ENVELOPE,
339            test_blob_secure_note(),
340        );
341    }
342
343    #[test]
344    fn test_recorded_login_test_vector() {
345        verify_test_vector(
346            TEST_VECTOR_LOGIN_CEK,
347            TEST_VECTOR_LOGIN_ENVELOPE,
348            test_blob_login(),
349        );
350    }
351
352    #[test]
353    fn test_recorded_card_test_vector() {
354        verify_test_vector(
355            TEST_VECTOR_CARD_CEK,
356            TEST_VECTOR_CARD_ENVELOPE,
357            test_blob_card(),
358        );
359    }
360
361    #[test]
362    fn test_recorded_identity_test_vector() {
363        verify_test_vector(
364            TEST_VECTOR_IDENTITY_CEK,
365            TEST_VECTOR_IDENTITY_ENVELOPE,
366            test_blob_identity(),
367        );
368    }
369
370    #[test]
371    fn test_recorded_ssh_key_test_vector() {
372        verify_test_vector(
373            TEST_VECTOR_SSH_KEY_CEK,
374            TEST_VECTOR_SSH_KEY_ENVELOPE,
375            test_blob_ssh_key(),
376        );
377    }
378}