Skip to main content

bitwarden_exporters/
json.rs

1use chrono::{DateTime, SecondsFormat, Utc};
2use serde::Serializer;
3use thiserror::Error;
4use uuid::Uuid;
5
6use crate::{
7    Card, Cipher, CipherType, Fido2Credential, Field, Folder, Identity, Login, LoginUri,
8    PasswordHistory, SecureNote, SshKey,
9};
10
11/// Serialize a `DateTime<Utc>` with millisecond precision to match the web exporter, which uses
12/// JavaScript's `Date.toISOString()` format.
13fn rfc3339_millis_serialize<S: Serializer>(date: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
14    s.serialize_str(&date.to_rfc3339_opts(SecondsFormat::Millis, true))
15}
16
17/// Serialize an optional `DateTime<Utc>` with millisecond precision when present.
18fn rfc3339_millis_serialize_opt<S: Serializer>(
19    date: &Option<DateTime<Utc>>,
20    s: S,
21) -> Result<S::Ok, S::Error> {
22    match date {
23        Some(d) => s.serialize_str(&d.to_rfc3339_opts(SecondsFormat::Millis, true)),
24        None => s.serialize_none(),
25    }
26}
27
28#[derive(Error, Debug)]
29pub enum JsonError {
30    #[error("JSON error: {0}")]
31    Serde(#[from] serde_json::Error),
32}
33
34pub(crate) fn export_json(folders: Vec<Folder>, ciphers: Vec<Cipher>) -> Result<String, JsonError> {
35    let export = JsonExport {
36        encrypted: false,
37        folders: folders.into_iter().map(|f| f.into()).collect(),
38        items: ciphers
39            .into_iter()
40            .filter(|c| {
41                !matches!(
42                    c.r#type,
43                    CipherType::BankAccount | CipherType::Passport | CipherType::DriversLicense
44                )
45            })
46            .map(|c| c.into())
47            .collect(),
48    };
49
50    Ok(serde_json::to_string_pretty(&export)?)
51}
52
53/// JSON export format. These are intentionally decoupled from the internal data structures to
54/// ensure internal changes are not reflected in the public exports.
55///
56/// Be careful about changing these structs to maintain compatibility with old exporters/importers.
57#[derive(serde::Serialize)]
58#[serde(rename_all = "camelCase")]
59struct JsonExport {
60    encrypted: bool,
61    folders: Vec<JsonFolder>,
62    items: Vec<JsonCipher>,
63}
64
65#[derive(serde::Serialize)]
66#[serde(rename_all = "camelCase")]
67struct JsonFolder {
68    id: Uuid,
69    name: String,
70}
71
72impl From<Folder> for JsonFolder {
73    fn from(folder: Folder) -> Self {
74        JsonFolder {
75            id: folder.id,
76            name: folder.name,
77        }
78    }
79}
80
81#[derive(serde::Serialize)]
82#[serde(rename_all = "camelCase")]
83struct JsonCipher {
84    id: Uuid,
85    folder_id: Option<Uuid>,
86    // Organizational IDs which are always empty in personal exports
87    organization_id: Option<Uuid>,
88    collection_ids: Option<Vec<Uuid>>,
89
90    name: String,
91    notes: Option<String>,
92
93    r#type: u8,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    login: Option<JsonLogin>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    identity: Option<JsonIdentity>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    card: Option<JsonCard>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    secure_note: Option<JsonSecureNote>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    ssh_key: Option<JsonSshKey>,
104
105    favorite: bool,
106    reprompt: u8,
107
108    #[serde(skip_serializing_if = "Vec::is_empty")]
109    fields: Vec<JsonField>,
110    password_history: Option<Vec<JsonPasswordHistory>>,
111
112    #[serde(serialize_with = "rfc3339_millis_serialize")]
113    revision_date: DateTime<Utc>,
114    #[serde(serialize_with = "rfc3339_millis_serialize")]
115    creation_date: DateTime<Utc>,
116    #[serde(serialize_with = "rfc3339_millis_serialize_opt")]
117    deleted_date: Option<DateTime<Utc>>,
118}
119
120#[derive(serde::Serialize)]
121#[serde(rename_all = "camelCase")]
122struct JsonLogin {
123    username: Option<String>,
124    password: Option<String>,
125    uris: Vec<JsonLoginUri>,
126    totp: Option<String>,
127    fido2_credentials: Vec<JsonFido2Credential>,
128}
129
130impl From<Login> for JsonLogin {
131    fn from(login: Login) -> Self {
132        JsonLogin {
133            username: login.username,
134            password: login.password,
135            uris: login.login_uris.into_iter().map(|u| u.into()).collect(),
136            totp: login.totp,
137            fido2_credentials: login
138                .fido2_credentials
139                .unwrap_or_default()
140                .into_iter()
141                .map(|c| c.into())
142                .collect(),
143        }
144    }
145}
146
147#[derive(serde::Serialize)]
148#[serde(rename_all = "camelCase")]
149struct JsonFido2Credential {
150    credential_id: String,
151    key_type: String,
152    key_algorithm: String,
153    key_curve: String,
154    key_value: String,
155    rp_id: String,
156    user_handle: Option<String>,
157    user_name: Option<String>,
158    // Serialized as a string for parity with the web exporter.
159    counter: String,
160    rp_name: Option<String>,
161    user_display_name: Option<String>,
162    // Serialized as a string for parity with the web exporter.
163    discoverable: String,
164    #[serde(serialize_with = "rfc3339_millis_serialize")]
165    creation_date: DateTime<Utc>,
166}
167
168impl From<Fido2Credential> for JsonFido2Credential {
169    fn from(credential: Fido2Credential) -> Self {
170        JsonFido2Credential {
171            credential_id: credential.credential_id,
172            key_type: credential.key_type,
173            key_algorithm: credential.key_algorithm,
174            key_curve: credential.key_curve,
175            key_value: credential.key_value,
176            rp_id: credential.rp_id,
177            user_handle: credential.user_handle,
178            user_name: credential.user_name,
179            counter: credential.counter.to_string(),
180            rp_name: credential.rp_name,
181            user_display_name: credential.user_display_name,
182            discoverable: credential.discoverable,
183            creation_date: credential.creation_date,
184        }
185    }
186}
187
188#[derive(serde::Serialize)]
189#[serde(rename_all = "camelCase")]
190struct JsonPasswordHistory {
191    password: String,
192    #[serde(serialize_with = "rfc3339_millis_serialize")]
193    last_used_date: DateTime<Utc>,
194}
195
196impl From<PasswordHistory> for JsonPasswordHistory {
197    fn from(history: PasswordHistory) -> Self {
198        JsonPasswordHistory {
199            password: history.password,
200            last_used_date: history.last_used_date,
201        }
202    }
203}
204
205#[derive(serde::Serialize)]
206#[serde(rename_all = "camelCase")]
207struct JsonLoginUri {
208    uri: Option<String>,
209    r#match: Option<u8>,
210}
211
212impl From<LoginUri> for JsonLoginUri {
213    fn from(login_uri: LoginUri) -> Self {
214        JsonLoginUri {
215            uri: login_uri.uri,
216            r#match: login_uri.r#match,
217        }
218    }
219}
220
221#[derive(serde::Serialize)]
222#[serde(rename_all = "camelCase")]
223struct JsonSecureNote {
224    r#type: u8,
225}
226
227impl From<SecureNote> for JsonSecureNote {
228    fn from(note: SecureNote) -> Self {
229        JsonSecureNote {
230            r#type: note.r#type as u8,
231        }
232    }
233}
234
235#[derive(serde::Serialize)]
236#[serde(rename_all = "camelCase")]
237struct JsonCard {
238    cardholder_name: Option<String>,
239    exp_month: Option<String>,
240    exp_year: Option<String>,
241    code: Option<String>,
242    brand: Option<String>,
243    number: Option<String>,
244}
245
246impl From<Card> for JsonCard {
247    fn from(card: Card) -> Self {
248        JsonCard {
249            cardholder_name: card.cardholder_name,
250            exp_month: card.exp_month,
251            exp_year: card.exp_year,
252            code: card.code,
253            brand: card.brand,
254            number: card.number,
255        }
256    }
257}
258
259#[derive(serde::Serialize)]
260#[serde(rename_all = "camelCase")]
261struct JsonIdentity {
262    title: Option<String>,
263    first_name: Option<String>,
264    middle_name: Option<String>,
265    last_name: Option<String>,
266    address1: Option<String>,
267    address2: Option<String>,
268    address3: Option<String>,
269    city: Option<String>,
270    state: Option<String>,
271    postal_code: Option<String>,
272    country: Option<String>,
273    company: Option<String>,
274    email: Option<String>,
275    phone: Option<String>,
276    ssn: Option<String>,
277    username: Option<String>,
278    passport_number: Option<String>,
279    license_number: Option<String>,
280}
281
282impl From<Identity> for JsonIdentity {
283    fn from(identity: Identity) -> Self {
284        JsonIdentity {
285            title: identity.title,
286            first_name: identity.first_name,
287            middle_name: identity.middle_name,
288            last_name: identity.last_name,
289            address1: identity.address1,
290            address2: identity.address2,
291            address3: identity.address3,
292            city: identity.city,
293            state: identity.state,
294            postal_code: identity.postal_code,
295            country: identity.country,
296            company: identity.company,
297            email: identity.email,
298            phone: identity.phone,
299            ssn: identity.ssn,
300            username: identity.username,
301            passport_number: identity.passport_number,
302            license_number: identity.license_number,
303        }
304    }
305}
306
307#[derive(serde::Serialize)]
308#[serde(rename_all = "camelCase")]
309struct JsonSshKey {
310    private_key: String,
311    public_key: String,
312    key_fingerprint: String,
313}
314
315impl From<SshKey> for JsonSshKey {
316    fn from(ssh_key: SshKey) -> Self {
317        JsonSshKey {
318            private_key: ssh_key.private_key,
319            public_key: ssh_key.public_key,
320            key_fingerprint: ssh_key.fingerprint,
321        }
322    }
323}
324
325#[derive(serde::Serialize)]
326#[serde(rename_all = "camelCase")]
327struct JsonField {
328    name: Option<String>,
329    value: Option<String>,
330    r#type: u8,
331    linked_id: Option<u32>,
332}
333
334impl From<Field> for JsonField {
335    fn from(field: Field) -> Self {
336        JsonField {
337            name: field.name,
338            value: field.value,
339            r#type: field.r#type,
340            linked_id: field.linked_id,
341        }
342    }
343}
344
345impl From<Cipher> for JsonCipher {
346    fn from(cipher: Cipher) -> Self {
347        let r#type = match cipher.r#type {
348            CipherType::Login(_) => 1,
349            CipherType::SecureNote(_) => 2,
350            CipherType::Card(_) => 3,
351            CipherType::Identity(_) => 4,
352            CipherType::SshKey(_) => 5,
353            // BankAccount/Passport/DriversLicense ciphers should be filtered out before reaching
354            // this point
355            CipherType::BankAccount | CipherType::Passport | CipherType::DriversLicense => {
356                unreachable!(
357                    "This cipher type is not supported for export and should be filtered out"
358                )
359            }
360        };
361
362        let (login, secure_note, card, identity, ssh_key) = match cipher.r#type {
363            CipherType::Login(l) => (Some((*l).into()), None, None, None, None),
364            CipherType::SecureNote(s) => (None, Some((*s).into()), None, None, None),
365            CipherType::Card(c) => (None, None, Some((*c).into()), None, None),
366            CipherType::Identity(i) => (None, None, None, Some((*i).into()), None),
367            CipherType::SshKey(ssh) => (None, None, None, None, Some((*ssh).into())),
368            CipherType::BankAccount | CipherType::Passport | CipherType::DriversLicense => {
369                unreachable!(
370                    "This cipher type is not supported for export and should be filtered out"
371                )
372            }
373        };
374
375        JsonCipher {
376            id: cipher.id,
377            folder_id: cipher.folder_id,
378            organization_id: None,
379            collection_ids: None,
380            name: cipher.name,
381            notes: cipher.notes,
382            r#type,
383            login,
384            identity,
385            card,
386            secure_note,
387            ssh_key,
388            favorite: cipher.favorite,
389            reprompt: cipher.reprompt,
390            fields: cipher.fields.into_iter().map(|f| f.into()).collect(),
391            password_history: cipher
392                .password_history
393                .map(|h| h.into_iter().map(|p| p.into()).collect()),
394            revision_date: cipher.revision_date,
395            creation_date: cipher.creation_date,
396            deleted_date: cipher.deleted_date,
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use std::{fs, io::Read, path::PathBuf};
404
405    use super::*;
406    use crate::{Cipher, Fido2Credential, Field, LoginUri, PasswordHistory, SecureNoteType};
407
408    #[test]
409    fn test_convert_login() {
410        let cipher = Cipher {
411            id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(),
412            folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()),
413
414            name: "Bitwarden".to_string(),
415            notes: Some("My note".to_string()),
416
417            r#type: CipherType::Login(Box::new(Login {
418                username: Some("[email protected]".to_string()),
419                password: Some("asdfasdfasdf".to_string()),
420                login_uris: vec![LoginUri {
421                    uri: Some("https://vault.bitwarden.com".to_string()),
422                    r#match: None,
423                }],
424                totp: Some("ABC".to_string()),
425                fido2_credentials: None,
426            })),
427
428            favorite: true,
429            reprompt: 0,
430
431            fields: vec![
432                Field {
433                    name: Some("Text".to_string()),
434                    value: Some("A".to_string()),
435                    r#type: 0,
436                    linked_id: None,
437                },
438                Field {
439                    name: Some("Hidden".to_string()),
440                    value: Some("B".to_string()),
441                    r#type: 1,
442                    linked_id: None,
443                },
444                Field {
445                    name: Some("Boolean (true)".to_string()),
446                    value: Some("true".to_string()),
447                    r#type: 2,
448                    linked_id: None,
449                },
450                Field {
451                    name: Some("Boolean (false)".to_string()),
452                    value: Some("false".to_string()),
453                    r#type: 2,
454                    linked_id: None,
455                },
456                Field {
457                    name: Some("Linked".to_string()),
458                    value: None,
459                    r#type: 3,
460                    linked_id: Some(101),
461                },
462            ],
463
464            password_history: None,
465            revision_date: "2024-01-30T14:09:33.753Z".parse().unwrap(),
466            creation_date: "2024-01-30T11:23:54.416Z".parse().unwrap(),
467            deleted_date: None,
468        };
469
470        let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap();
471
472        let expected = r#"{
473            "passwordHistory": null,
474            "revisionDate": "2024-01-30T14:09:33.753Z",
475            "creationDate": "2024-01-30T11:23:54.416Z",
476            "deletedDate": null,
477            "id": "25c8c414-b446-48e9-a1bd-b10700bbd740",
478            "organizationId": null,
479            "folderId": "942e2984-1b9a-453b-b039-b107012713b9",
480            "type": 1,
481            "reprompt": 0,
482            "name": "Bitwarden",
483            "notes": "My note",
484            "favorite": true,
485            "fields": [
486              {
487                "name": "Text",
488                "value": "A",
489                "type": 0,
490                "linkedId": null
491              },
492              {
493                "name": "Hidden",
494                "value": "B",
495                "type": 1,
496                "linkedId": null
497              },
498              {
499                "name": "Boolean (true)",
500                "value": "true",
501                "type": 2,
502                "linkedId": null
503              },
504              {
505                "name": "Boolean (false)",
506                "value": "false",
507                "type": 2,
508                "linkedId": null
509              },
510              {
511                "name": "Linked",
512                "value": null,
513                "type": 3,
514                "linkedId": 101
515              }
516            ],
517            "login": {
518              "fido2Credentials": [],
519              "uris": [
520                {
521                  "match": null,
522                  "uri": "https://vault.bitwarden.com"
523                }
524              ],
525              "username": "[email protected]",
526              "password": "asdfasdfasdf",
527              "totp": "ABC"
528            },
529            "collectionIds": null
530          }"#;
531
532        assert_eq!(
533            json.parse::<serde_json::Value>().unwrap(),
534            expected.parse::<serde_json::Value>().unwrap()
535        )
536    }
537
538    #[test]
539    fn test_convert_secure_note() {
540        let cipher = Cipher {
541            id: "23f0f877-42b1-4820-a850-b10700bc41eb".parse().unwrap(),
542            folder_id: None,
543
544            name: "My secure note".to_string(),
545            notes: Some("Very secure!".to_string()),
546
547            r#type: CipherType::SecureNote(Box::new(SecureNote {
548                r#type: SecureNoteType::Generic,
549            })),
550
551            favorite: false,
552            reprompt: 0,
553
554            fields: vec![],
555
556            password_history: None,
557            revision_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
558            creation_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
559            deleted_date: None,
560        };
561
562        let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap();
563
564        let expected = r#"{
565            "passwordHistory": null,
566            "revisionDate": "2024-01-30T11:25:25.466Z",
567            "creationDate": "2024-01-30T11:25:25.466Z",
568            "deletedDate": null,
569            "id": "23f0f877-42b1-4820-a850-b10700bc41eb",
570            "organizationId": null,
571            "folderId": null,
572            "type": 2,
573            "reprompt": 0,
574            "name": "My secure note",
575            "notes": "Very secure!",
576            "favorite": false,
577            "secureNote": {
578              "type": 0
579            },
580            "collectionIds": null
581        }"#;
582
583        assert_eq!(
584            json.parse::<serde_json::Value>().unwrap(),
585            expected.parse::<serde_json::Value>().unwrap()
586        )
587    }
588
589    #[test]
590    fn test_convert_card() {
591        let cipher = Cipher {
592            id: "3ed8de45-48ee-4e26-a2dc-b10701276c53".parse().unwrap(),
593            folder_id: None,
594
595            name: "My card".to_string(),
596            notes: None,
597
598            r#type: CipherType::Card(Box::new(Card {
599                cardholder_name: Some("John Doe".to_string()),
600                exp_month: Some("1".to_string()),
601                exp_year: Some("2032".to_string()),
602                code: Some("123".to_string()),
603                brand: Some("Visa".to_string()),
604                number: Some("4111111111111111".to_string()),
605            })),
606
607            favorite: false,
608            reprompt: 0,
609
610            fields: vec![],
611
612            password_history: None,
613            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
614            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
615            deleted_date: None,
616        };
617
618        let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap();
619
620        let expected = r#"{
621            "passwordHistory": null,
622            "revisionDate": "2024-01-30T17:55:36.150Z",
623            "creationDate": "2024-01-30T17:55:36.150Z",
624            "deletedDate": null,
625            "id": "3ed8de45-48ee-4e26-a2dc-b10701276c53",
626            "organizationId": null,
627            "folderId": null,
628            "type": 3,
629            "reprompt": 0,
630            "name": "My card",
631            "notes": null,
632            "favorite": false,
633            "card": {
634                "cardholderName": "John Doe",
635                "brand": "Visa",
636                "number": "4111111111111111",
637                "expMonth": "1",
638                "expYear": "2032",
639                "code": "123"
640            },
641            "collectionIds": null
642        }"#;
643
644        assert_eq!(
645            json.parse::<serde_json::Value>().unwrap(),
646            expected.parse::<serde_json::Value>().unwrap()
647        )
648    }
649
650    #[test]
651    fn test_convert_identity() {
652        let cipher = Cipher {
653            id: "41cc3bc1-c3d9-4637-876c-b10701273712".parse().unwrap(),
654            folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()),
655
656            name: "My identity".to_string(),
657            notes: None,
658
659            r#type: CipherType::Identity(Box::new(Identity {
660                title: Some("Mr".to_string()),
661                first_name: Some("John".to_string()),
662                middle_name: None,
663                last_name: Some("Doe".to_string()),
664                address1: None,
665                address2: None,
666                address3: None,
667                city: None,
668                state: None,
669                postal_code: None,
670                country: None,
671                company: Some("Bitwarden".to_string()),
672                email: None,
673                phone: None,
674                ssn: None,
675                username: Some("JDoe".to_string()),
676                passport_number: None,
677                license_number: None,
678            })),
679
680            favorite: false,
681            reprompt: 0,
682
683            fields: vec![],
684
685            password_history: None,
686            revision_date: "2024-01-30T17:54:50.706Z".parse().unwrap(),
687            creation_date: "2024-01-30T17:54:50.706Z".parse().unwrap(),
688            deleted_date: None,
689        };
690
691        let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap();
692
693        let expected = r#"{
694            "passwordHistory": null,
695            "revisionDate": "2024-01-30T17:54:50.706Z",
696            "creationDate": "2024-01-30T17:54:50.706Z",
697            "deletedDate": null,
698            "id": "41cc3bc1-c3d9-4637-876c-b10701273712",
699            "organizationId": null,
700            "folderId": "942e2984-1b9a-453b-b039-b107012713b9",
701            "type": 4,
702            "reprompt": 0,
703            "name": "My identity",
704            "notes": null,
705            "favorite": false,
706            "identity": {
707                "title": "Mr",
708                "firstName": "John",
709                "middleName": null,
710                "lastName": "Doe",
711                "address1": null,
712                "address2": null,
713                "address3": null,
714                "city": null,
715                "state": null,
716                "postalCode": null,
717                "country": null,
718                "company": "Bitwarden",
719                "email": null,
720                "phone": null,
721                "ssn": null,
722                "username": "JDoe",
723                "passportNumber": null,
724                "licenseNumber": null
725            },
726            "collectionIds": null
727        }"#;
728
729        assert_eq!(
730            json.parse::<serde_json::Value>().unwrap(),
731            expected.parse::<serde_json::Value>().unwrap()
732        )
733    }
734
735    #[test]
736    fn test_convert_ssh_key() {
737        let cipher = Cipher {
738            id: "23f0f877-42b1-4820-a850-b10700bc41eb".parse().unwrap(),
739            folder_id: None,
740
741            name: "My ssh key".to_string(),
742            notes: None,
743
744            r#type: CipherType::SshKey(Box::new(SshKey {
745                private_key: "private".to_string(),
746                public_key: "public".to_string(),
747                fingerprint: "fingerprint".to_string(),
748            })),
749
750            favorite: false,
751            reprompt: 0,
752
753            fields: vec![],
754
755            password_history: None,
756            revision_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
757            creation_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
758            deleted_date: None,
759        };
760
761        let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap();
762
763        let expected = r#"{
764            "passwordHistory": null,
765            "revisionDate": "2024-01-30T11:25:25.466Z",
766            "creationDate": "2024-01-30T11:25:25.466Z",
767            "deletedDate": null,
768            "id": "23f0f877-42b1-4820-a850-b10700bc41eb",
769            "organizationId": null,
770            "folderId": null,
771            "type": 5,
772            "reprompt": 0,
773            "name": "My ssh key",
774            "notes": null,
775            "sshKey": {
776              "privateKey": "private",
777              "publicKey": "public",
778              "keyFingerprint": "fingerprint"
779            },
780            "favorite": false,
781            "collectionIds": null
782        }"#;
783
784        assert_eq!(
785            json.parse::<serde_json::Value>().unwrap(),
786            expected.parse::<serde_json::Value>().unwrap()
787        )
788    }
789
790    #[test]
791    pub fn test_export() {
792        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
793        d.push("resources");
794        d.push("json_export.json");
795
796        let mut file = fs::File::open(d).unwrap();
797
798        let mut expected = String::new();
799        file.read_to_string(&mut expected).unwrap();
800
801        let export = export_json(
802            vec![Folder {
803                id: "942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap(),
804                name: "Important".to_string(),
805            }],
806            vec![
807                Cipher {
808                    id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(),
809                    folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()),
810
811                    name: "Bitwarden".to_string(),
812                    notes: Some("My note".to_string()),
813
814                    r#type: CipherType::Login(Box::new(Login {
815                        username: Some("[email protected]".to_string()),
816                        password: Some("asdfasdfasdf".to_string()),
817                        login_uris: vec![LoginUri {
818                            uri: Some("https://vault.bitwarden.com".to_string()),
819                            r#match: None,
820                        }],
821                        totp: Some("ABC".to_string()),
822                        fido2_credentials: None,
823                    })),
824
825                    favorite: true,
826                    reprompt: 0,
827
828                    fields: vec![
829                        Field {
830                            name: Some("Text".to_string()),
831                            value: Some("A".to_string()),
832                            r#type: 0,
833                            linked_id: None,
834                        },
835                        Field {
836                            name: Some("Hidden".to_string()),
837                            value: Some("B".to_string()),
838                            r#type: 1,
839                            linked_id: None,
840                        },
841                        Field {
842                            name: Some("Boolean (true)".to_string()),
843                            value: Some("true".to_string()),
844                            r#type: 2,
845                            linked_id: None,
846                        },
847                        Field {
848                            name: Some("Boolean (false)".to_string()),
849                            value: Some("false".to_string()),
850                            r#type: 2,
851                            linked_id: None,
852                        },
853                        Field {
854                            name: Some("Linked".to_string()),
855                            value: None,
856                            r#type: 3,
857                            linked_id: Some(101),
858                        },
859                    ],
860
861                    password_history: None,
862                    revision_date: "2024-01-30T14:09:33.753Z".parse().unwrap(),
863                    creation_date: "2024-01-30T11:23:54.416Z".parse().unwrap(),
864                    deleted_date: None,
865                },
866                Cipher {
867                    id: "23f0f877-42b1-4820-a850-b10700bc41eb".parse().unwrap(),
868                    folder_id: None,
869
870                    name: "My secure note".to_string(),
871                    notes: Some("Very secure!".to_string()),
872
873                    r#type: CipherType::SecureNote(Box::new(SecureNote {
874                        r#type: SecureNoteType::Generic,
875                    })),
876
877                    favorite: false,
878                    reprompt: 0,
879
880                    fields: vec![],
881
882                    password_history: None,
883                    revision_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
884                    creation_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
885                    deleted_date: None,
886                },
887                Cipher {
888                    id: "3ed8de45-48ee-4e26-a2dc-b10701276c53".parse().unwrap(),
889                    folder_id: None,
890
891                    name: "My card".to_string(),
892                    notes: None,
893
894                    r#type: CipherType::Card(Box::new(Card {
895                        cardholder_name: Some("John Doe".to_string()),
896                        exp_month: Some("1".to_string()),
897                        exp_year: Some("2032".to_string()),
898                        code: Some("123".to_string()),
899                        brand: Some("Visa".to_string()),
900                        number: Some("4111111111111111".to_string()),
901                    })),
902
903                    favorite: false,
904                    reprompt: 0,
905
906                    fields: vec![],
907
908                    password_history: None,
909                    revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
910                    creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
911                    deleted_date: None,
912                },
913                Cipher {
914                    id: "41cc3bc1-c3d9-4637-876c-b10701273712".parse().unwrap(),
915                    folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()),
916
917                    name: "My identity".to_string(),
918                    notes: None,
919
920                    r#type: CipherType::Identity(Box::new(Identity {
921                        title: Some("Mr".to_string()),
922                        first_name: Some("John".to_string()),
923                        middle_name: None,
924                        last_name: Some("Doe".to_string()),
925                        address1: None,
926                        address2: None,
927                        address3: None,
928                        city: None,
929                        state: None,
930                        postal_code: None,
931                        country: None,
932                        company: Some("Bitwarden".to_string()),
933                        email: None,
934                        phone: None,
935                        ssn: None,
936                        username: Some("JDoe".to_string()),
937                        passport_number: None,
938                        license_number: None,
939                    })),
940
941                    favorite: false,
942                    reprompt: 0,
943
944                    fields: vec![],
945
946                    password_history: None,
947                    revision_date: "2024-01-30T17:54:50.706Z".parse().unwrap(),
948                    creation_date: "2024-01-30T17:54:50.706Z".parse().unwrap(),
949                    deleted_date: None,
950                },
951                Cipher {
952                    id: "646594a9-a9cb-4082-9d57-0024c3fbcaa9".parse().unwrap(),
953                    folder_id: None,
954
955                    name: "My ssh key".to_string(),
956                    notes: None,
957
958                    r#type: CipherType::SshKey(Box::new(SshKey {
959                        private_key: "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBinNE5chMtCHh3BV0H1+CpPlEQBwR5cD+Xb9i8MaHGiwAAAKAy48fwMuPH\n8AAAAAtzc2gtZWQyNTUxOQAAACBinNE5chMtCHh3BV0H1+CpPlEQBwR5cD+Xb9i8MaHGiw\nAAAEAYUCIdfLI14K3XIy9V0FDZLQoZ9gcjOnvFjb4uA335HmKc0TlyEy0IeHcFXQfX4Kk+\nURAHBHlwP5dv2LwxocaLAAAAHHF1ZXh0ZW5ATWFjQm9vay1Qcm8tMTYubG9jYWwB\n-----END OPENSSH PRIVATE KEY-----".to_string(),
960                        public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGKc0TlyEy0IeHcFXQfX4Kk+URAHBHlwP5dv2LwxocaL".to_string(),
961                        fingerprint: "SHA256:1JjFjvPRkj1Gbf2qRP1dgHiIzEuNAEvp+92x99jw3K0".to_string(),
962                    })),
963
964                    favorite: false,
965                    reprompt: 0,
966
967                    fields: vec![],
968
969                    password_history: None,
970                    revision_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
971                    creation_date: "2024-01-30T11:25:25.466Z".parse().unwrap(),
972                    deleted_date: None,
973                }
974            ],
975        )
976        .unwrap();
977
978        assert_eq!(
979            export.parse::<serde_json::Value>().unwrap(),
980            expected.parse::<serde_json::Value>().unwrap()
981        )
982    }
983
984    /// Verifies that populated `fido2_credentials` flow through to the JSON export. The web
985    /// exporter includes these credentials and we must do the same so iOS/Android JSON exports
986    /// don't silently drop passkeys.
987    #[test]
988    fn test_login_with_fido2_credentials() {
989        let cipher = Cipher {
990            id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(),
991            folder_id: None,
992            name: "Bitwarden".to_string(),
993            notes: None,
994            r#type: CipherType::Login(Box::new(Login {
995                username: None,
996                password: None,
997                login_uris: vec![],
998                totp: None,
999                fido2_credentials: Some(vec![Fido2Credential {
1000                    credential_id: "e8d88789-e916-e196-3cbd-81dafae71bbc".to_string(),
1001                    key_type: "public-key".to_string(),
1002                    key_algorithm: "ECDSA".to_string(),
1003                    key_curve: "P-256".to_string(),
1004                    key_value: "AAECAwQFBg".to_string(),
1005                    rp_id: "bitwarden.com".to_string(),
1006                    user_handle: Some("AAECAwQFBg".to_string()),
1007                    user_name: Some("[email protected]".to_string()),
1008                    counter: 0,
1009                    rp_name: Some("Bitwarden".to_string()),
1010                    user_display_name: Some("User".to_string()),
1011                    discoverable: "true".to_string(),
1012                    creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
1013                }]),
1014            })),
1015            favorite: false,
1016            reprompt: 0,
1017            fields: vec![],
1018            password_history: None,
1019            revision_date: "2024-01-30T14:09:33.753Z".parse().unwrap(),
1020            creation_date: "2024-01-30T11:23:54.416Z".parse().unwrap(),
1021            deleted_date: None,
1022        };
1023
1024        let json = serde_json::to_value(JsonCipher::from(cipher)).unwrap();
1025        let creds = &json["login"]["fido2Credentials"];
1026
1027        assert_eq!(creds.as_array().unwrap().len(), 1);
1028        assert_eq!(
1029            creds[0]["credentialId"],
1030            "e8d88789-e916-e196-3cbd-81dafae71bbc"
1031        );
1032        assert_eq!(creds[0]["keyType"], "public-key");
1033        // Counter and discoverable are serialized as strings to match the web exporter.
1034        assert_eq!(creds[0]["counter"], "0");
1035        assert_eq!(creds[0]["discoverable"], "true");
1036        // Dates use millisecond precision to match JavaScript's toISOString().
1037        assert_eq!(creds[0]["creationDate"], "2024-06-07T14:12:36.150Z");
1038    }
1039
1040    /// Verifies that populated `password_history` is included in the JSON export. The web
1041    /// exporter emits these entries and the SDK previously hardcoded them to null.
1042    #[test]
1043    fn test_cipher_with_password_history() {
1044        let cipher = Cipher {
1045            id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(),
1046            folder_id: None,
1047            name: "Bitwarden".to_string(),
1048            notes: None,
1049            r#type: CipherType::Login(Box::new(Login {
1050                username: None,
1051                password: None,
1052                login_uris: vec![],
1053                totp: None,
1054                fido2_credentials: None,
1055            })),
1056            favorite: false,
1057            reprompt: 0,
1058            fields: vec![],
1059            password_history: Some(vec![PasswordHistory {
1060                password: "old-password".to_string(),
1061                last_used_date: "2024-01-30T14:09:33.753Z".parse().unwrap(),
1062            }]),
1063            revision_date: "2024-01-30T14:09:33.753Z".parse().unwrap(),
1064            creation_date: "2024-01-30T11:23:54.416Z".parse().unwrap(),
1065            deleted_date: None,
1066        };
1067
1068        let json = serde_json::to_value(JsonCipher::from(cipher)).unwrap();
1069        let history = &json["passwordHistory"];
1070
1071        assert_eq!(history.as_array().unwrap().len(), 1);
1072        assert_eq!(history[0]["password"], "old-password");
1073        assert_eq!(history[0]["lastUsedDate"], "2024-01-30T14:09:33.753Z");
1074    }
1075
1076    /// Verifies that sub-millisecond timestamp precision is truncated to milliseconds in the
1077    /// export, matching JavaScript's `Date.toISOString()` output used by the web exporter.
1078    #[test]
1079    fn test_dates_use_millisecond_precision() {
1080        let cipher = Cipher {
1081            id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(),
1082            folder_id: None,
1083            name: "Bitwarden".to_string(),
1084            notes: None,
1085            r#type: CipherType::SecureNote(Box::new(SecureNote {
1086                r#type: SecureNoteType::Generic,
1087            })),
1088            favorite: false,
1089            reprompt: 0,
1090            fields: vec![],
1091            password_history: None,
1092            // Microsecond precision in the source value should be truncated to ms in the output.
1093            revision_date: "2024-01-30T14:09:33.753456Z".parse().unwrap(),
1094            creation_date: "2024-01-30T11:23:54.416789123Z".parse().unwrap(),
1095            deleted_date: Some("2024-02-01T00:00:00.000000Z".parse().unwrap()),
1096        };
1097
1098        let json = serde_json::to_value(JsonCipher::from(cipher)).unwrap();
1099
1100        assert_eq!(json["revisionDate"], "2024-01-30T14:09:33.753Z");
1101        assert_eq!(json["creationDate"], "2024-01-30T11:23:54.416Z");
1102        assert_eq!(json["deletedDate"], "2024-02-01T00:00:00.000Z");
1103    }
1104}