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#[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
31pub(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![], 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 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 CipherType::SecureNote(_) => vec![],
108 CipherType::SshKey(ssh) => (*ssh).try_into().unwrap_or_default(),
109 }
110 }
111}
112
113impl 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
139fn convert_totp(totp: Totp) -> TotpCredential {
141 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); 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 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 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 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 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"); }
375 _ => panic!("Expected String field for Linked"),
376 }
377 }
378 _ => panic!("Expected Credential::CustomFields"),
379 }
380 }
381}