bitwarden_exporters/cxf/
export.rs

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