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