Skip to main content

bitwarden_exporters/cxf/
export.rs

1use bitwarden_vault::{Totp, TotpAlgorithm};
2use credential_exchange_format::{
3    Account as CxfAccount, Credential, CustomFieldsCredential, EditableFieldValue, Item,
4    NoteCredential, OTPHashAlgorithm, TotpCredential,
5};
6use uuid::Uuid;
7#[cfg(feature = "wasm")]
8use {tsify::Tsify, wasm_bindgen::prelude::*};
9
10use crate::{
11    Cipher, CipherType, Login,
12    cxf::{CxfError, editable_field::field_to_editable_field_value},
13};
14
15/// Temporary struct to hold metadata related to current account
16///
17/// Eventually the SDK itself should have this state and we get rid of this struct.
18#[derive(Debug)]
19#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
20#[cfg_attr(
21    feature = "wasm",
22    derive(serde::Serialize, serde::Deserialize, Tsify),
23    tsify(into_wasm_abi, from_wasm_abi)
24)]
25pub struct Account {
26    id: Uuid,
27    email: String,
28    name: Option<String>,
29}
30
31/// Builds a Credential Exchange Format (CXF) payload
32pub(crate) fn build_cxf(account: Account, ciphers: Vec<Cipher>) -> Result<String, CxfError> {
33    let items: Vec<Item> = ciphers
34        .into_iter()
35        .filter(|c| {
36            !matches!(
37                c.r#type,
38                CipherType::BankAccount | CipherType::Passport | CipherType::DriversLicense
39            )
40        })
41        .flat_map(|cipher| cipher.try_into())
42        .collect();
43
44    let account = CxfAccount {
45        id: account.id.as_bytes().as_slice().into(),
46        username: "".to_owned(),
47        email: account.email,
48        full_name: account.name,
49        collections: vec![], // TODO: Add support for folders
50        items,
51        extensions: None,
52    };
53
54    Ok(serde_json::to_string(&account)?)
55}
56
57impl TryFrom<Cipher> for Item {
58    type Error = CxfError;
59
60    fn try_from(value: Cipher) -> Result<Self, Self::Error> {
61        let mut credentials: Vec<Credential> = value.r#type.clone().into();
62
63        if let Some(note) = value.notes {
64            credentials.push(Credential::Note(Box::new(NoteCredential {
65                content: note.into(),
66            })));
67        }
68
69        // Convert Bitwarden custom fields to CustomFieldsCredential
70        if !value.fields.is_empty() {
71            let custom_fields: Vec<EditableFieldValue> = value
72                .fields
73                .into_iter()
74                .filter_map(field_to_editable_field_value)
75                .collect();
76
77            if !custom_fields.is_empty() {
78                credentials.push(Credential::CustomFields(Box::new(CustomFieldsCredential {
79                    id: None,
80                    label: None,
81                    fields: custom_fields,
82                    extensions: vec![],
83                })));
84            }
85        }
86
87        Ok(Self {
88            id: value.id.as_bytes().as_slice().into(),
89            creation_at: Some(value.creation_date.timestamp() as u64),
90            modified_at: Some(value.revision_date.timestamp() as u64),
91            title: value.name,
92            subtitle: None,
93            favorite: Some(value.favorite),
94            credentials,
95            tags: None,
96            extensions: None,
97            scope: match value.r#type {
98                CipherType::Login(login) => Some((*login).into()),
99                _ => None,
100            },
101        })
102    }
103}
104
105impl From<CipherType> for Vec<Credential> {
106    fn from(value: CipherType) -> Self {
107        match value {
108            CipherType::Login(login) => (*login).into(),
109            CipherType::Card(card) => (*card).into(),
110            CipherType::Identity(identity) => (*identity).into(),
111            // Secure Notes only contains a note field which is handled by `TryFrom<Cipher> for
112            // Item`.
113            CipherType::SecureNote(_) => vec![],
114            CipherType::SshKey(ssh) => (*ssh).try_into().unwrap_or_default(),
115            CipherType::BankAccount => vec![],
116            CipherType::Passport => vec![],
117            CipherType::DriversLicense => vec![],
118        }
119    }
120}
121
122/// Convert a `Login` struct into the appropriate `Credential`s.
123impl From<Login> for Vec<Credential> {
124    fn from(login: Login) -> Self {
125        let mut credentials = vec![];
126
127        if login.username.is_some() || login.password.is_some() || !login.login_uris.is_empty() {
128            credentials.push(Credential::BasicAuth(Box::new(login.clone().into())));
129        }
130
131        if let Some(totp) = login.totp.and_then(|t| t.parse::<Totp>().ok()) {
132            credentials.push(Credential::Totp(Box::new(convert_totp(totp))));
133        }
134
135        if let Some(fido2_credentials) = login.fido2_credentials {
136            credentials.extend(
137                fido2_credentials
138                    .into_iter()
139                    .filter_map(|fido2_credential| fido2_credential.try_into().ok())
140                    .map(|c| Credential::Passkey(Box::new(c))),
141            );
142        }
143
144        credentials
145    }
146}
147
148/// Convert a `Totp` struct into a `TotpCredential` struct
149fn convert_totp(totp: Totp) -> TotpCredential {
150    // TODO(PM-15389): Properly set username/issuer.
151    TotpCredential {
152        secret: totp.secret.into(),
153        period: totp.period as u8,
154        digits: totp.digits as u8,
155        username: totp.account,
156        algorithm: match totp.algorithm {
157            TotpAlgorithm::Sha1 => OTPHashAlgorithm::Sha1,
158            TotpAlgorithm::Sha256 => OTPHashAlgorithm::Sha256,
159            TotpAlgorithm::Sha512 => OTPHashAlgorithm::Sha512,
160            TotpAlgorithm::Steam => OTPHashAlgorithm::Unknown("steam".to_string()),
161        },
162        issuer: totp.issuer,
163    }
164}
165
166#[cfg(test)]
167mod tests {
168
169    use super::*;
170    use crate::{Fido2Credential, Field, LoginUri};
171
172    #[test]
173    fn test_convert_totp() {
174        let totp = Totp {
175            account: Some("[email protected]".to_string()),
176            algorithm: TotpAlgorithm::Sha1,
177            digits: 4,
178            issuer: Some("test-issuer".to_string()),
179            period: 60,
180            secret: "secret".as_bytes().to_vec(),
181        };
182
183        let credential = convert_totp(totp);
184        assert_eq!(String::from(credential.secret), "ONSWG4TFOQ");
185        assert_eq!(credential.period, 60);
186        assert_eq!(credential.digits, 4);
187        assert_eq!(credential.username.unwrap(), "[email protected]");
188        assert_eq!(credential.algorithm, OTPHashAlgorithm::Sha1);
189        assert_eq!(credential.issuer, Some("test-issuer".to_string()));
190    }
191
192    #[test]
193    fn test_login_to_item() {
194        let cipher = Cipher {
195            id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(),
196            folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()),
197
198            name: "Bitwarden".to_string(),
199            notes: Some("My note".to_string()),
200
201            r#type: CipherType::Login(Box::new(Login {
202                username: Some("[email protected]".to_string()),
203                password: Some("asdfasdfasdf".to_string()),
204                login_uris: vec![LoginUri {
205                    uri: Some("https://vault.bitwarden.com".to_string()),
206                    r#match: None,
207                }],
208                totp: Some("JBSWY3DPEHPK3PXP".to_string()),
209                fido2_credentials: Some(vec![Fido2Credential {
210                    credential_id: "e8d88789-e916-e196-3cbd-81dafae71bbc".to_string(),
211                    key_type: "public-key".to_string(),
212                    key_algorithm: "ECDSA".to_string(),
213                    key_curve: "P-256".to_string(),
214                    key_value: "AAECAwQFBg".to_string(),
215                    rp_id: "123".to_string(),
216                    user_handle: Some("AAECAwQFBg".to_string()),
217                    user_name: None,
218                    counter: 0,
219                    rp_name: None,
220                    user_display_name: None,
221                    discoverable: "true".to_string(),
222                    creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
223                }]),
224            })),
225
226            favorite: true,
227            reprompt: 0,
228
229            fields: vec![
230                Field {
231                    name: Some("Text".to_string()),
232                    value: Some("A".to_string()),
233                    r#type: 0,
234                    linked_id: None,
235                },
236                Field {
237                    name: Some("Hidden".to_string()),
238                    value: Some("B".to_string()),
239                    r#type: 1,
240                    linked_id: None,
241                },
242                Field {
243                    name: Some("Boolean (true)".to_string()),
244                    value: Some("true".to_string()),
245                    r#type: 2,
246                    linked_id: None,
247                },
248                Field {
249                    name: Some("Boolean (false)".to_string()),
250                    value: Some("false".to_string()),
251                    r#type: 2,
252                    linked_id: None,
253                },
254                Field {
255                    name: Some("Linked".to_string()),
256                    value: None,
257                    r#type: 3,
258                    linked_id: Some(101),
259                },
260            ],
261
262            revision_date: "2024-01-30T14:09:33.753Z".parse().unwrap(),
263            creation_date: "2024-01-30T11:23:54.416Z".parse().unwrap(),
264            deleted_date: None,
265        };
266
267        let item: Item = cipher.try_into().unwrap();
268
269        assert_eq!(item.id.to_string(), "JcjEFLRGSOmhvbEHALvXQA");
270        assert_eq!(item.creation_at, Some(1706613834));
271        assert_eq!(item.modified_at, Some(1706623773));
272        assert_eq!(item.title, "Bitwarden");
273        assert_eq!(item.subtitle, None);
274        assert_eq!(item.tags, None);
275        assert_eq!(
276            item.scope.unwrap().urls,
277            vec!["https://vault.bitwarden.com".to_string()]
278        );
279        assert!(item.extensions.is_none());
280
281        assert_eq!(item.credentials.len(), 5);
282
283        let credential = &item.credentials[0];
284
285        match credential {
286            Credential::BasicAuth(basic_auth) => {
287                let username = basic_auth.username.as_ref().unwrap();
288                assert_eq!(username.value.0, "[email protected]");
289                assert!(username.label.is_none());
290
291                let password = basic_auth.password.as_ref().unwrap();
292                assert_eq!(password.value.0, "asdfasdfasdf");
293                assert!(password.label.is_none());
294            }
295            _ => panic!("Expected Credential::BasicAuth"),
296        }
297
298        let credential = &item.credentials[1];
299
300        match credential {
301            Credential::Totp(totp) => {
302                assert_eq!(String::from(totp.secret.clone()), "JBSWY3DPEHPK3PXP");
303                assert_eq!(totp.period, 30);
304                assert_eq!(totp.digits, 6);
305                assert_eq!(totp.username, None);
306                assert_eq!(totp.algorithm, OTPHashAlgorithm::Sha1);
307                assert!(totp.issuer.is_none());
308            }
309            _ => panic!("Expected Credential::Passkey"),
310        }
311
312        let credential = &item.credentials[2];
313
314        match credential {
315            Credential::Passkey(passkey) => {
316                assert_eq!(passkey.credential_id.to_string(), "6NiHiekW4ZY8vYHa-ucbvA");
317                assert_eq!(passkey.rp_id, "123");
318                assert_eq!(passkey.username, "");
319                assert_eq!(passkey.user_display_name, "");
320                assert_eq!(String::from(passkey.user_handle.clone()), "AAECAwQFBg");
321                assert_eq!(String::from(passkey.key.clone()), "AAECAwQFBg");
322                assert!(passkey.fido2_extensions.is_none());
323            }
324            _ => panic!("Expected Credential::Passkey"),
325        }
326
327        let credential = &item.credentials[3];
328
329        match credential {
330            Credential::Note(n) => {
331                assert_eq!(n.content.value.0, "My note");
332            }
333            _ => panic!("Expected Credential::Note"),
334        }
335
336        let credential = &item.credentials[4];
337
338        match credential {
339            Credential::CustomFields(custom_fields) => {
340                assert_eq!(custom_fields.fields.len(), 5); // Text, Hidden, Boolean true, Boolean false, Linked
341
342                // Check Text field
343                match &custom_fields.fields[0] {
344                    EditableFieldValue::String(field) => {
345                        assert_eq!(field.label.as_ref().unwrap(), "Text");
346                        assert_eq!(field.value.0, "A");
347                    }
348                    _ => panic!("Expected String field"),
349                }
350
351                // Check Hidden field
352                match &custom_fields.fields[1] {
353                    EditableFieldValue::ConcealedString(field) => {
354                        assert_eq!(field.label.as_ref().unwrap(), "Hidden");
355                        assert_eq!(field.value.0, "B");
356                    }
357                    _ => panic!("Expected ConcealedString field"),
358                }
359
360                // Check Boolean true field
361                match &custom_fields.fields[2] {
362                    EditableFieldValue::Boolean(field) => {
363                        assert_eq!(field.label.as_ref().unwrap(), "Boolean (true)");
364                        assert!(field.value.0);
365                    }
366                    _ => panic!("Expected Boolean field"),
367                }
368
369                // Check Boolean false field
370                match &custom_fields.fields[3] {
371                    EditableFieldValue::Boolean(field) => {
372                        assert_eq!(field.label.as_ref().unwrap(), "Boolean (false)");
373                        assert!(!field.value.0);
374                    }
375                    _ => panic!("Expected Boolean field"),
376                }
377
378                // Check Linked field
379                match &custom_fields.fields[4] {
380                    EditableFieldValue::String(field) => {
381                        assert_eq!(field.label.as_ref().unwrap(), "Linked");
382                        assert_eq!(field.value.0, "101"); // linked_id as string
383                    }
384                    _ => panic!("Expected String field for Linked"),
385                }
386            }
387            _ => panic!("Expected Credential::CustomFields"),
388        }
389    }
390}