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