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