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