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