1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
7use bitwarden_core::MissingFieldError;
8use bitwarden_fido::{string_to_guid_bytes, InvalidGuid};
9use bitwarden_vault::FieldType;
10use chrono::{DateTime, Utc};
11use credential_exchange_format::{
12 AndroidAppIdCredential, BasicAuthCredential, CredentialScope, PasskeyCredential,
13};
14use thiserror::Error;
15
16use crate::{Fido2Credential, Field, Login, LoginUri};
17
18const ANDROID_APP_SCHEME: &str = "androidapp://";
20
21pub(super) fn to_login(
22 creation_date: DateTime<Utc>,
23 basic_auth: Option<&BasicAuthCredential>,
24 passkey: Option<&PasskeyCredential>,
25 scope: Option<&CredentialScope>,
26) -> Login {
27 Login {
28 username: basic_auth.and_then(|v| v.username.clone().map(|v| v.into())),
29 password: basic_auth.and_then(|v| v.password.clone().map(|u| u.into())),
30 login_uris: scope.map(to_uris).unwrap_or_default(),
31 totp: None,
32 fido2_credentials: passkey.map(|p| {
33 vec![Fido2Credential {
34 credential_id: format!("b64.{}", p.credential_id),
35 key_type: "public-key".to_string(),
36 key_algorithm: "ECDSA".to_string(),
37 key_curve: "P-256".to_string(),
38 key_value: URL_SAFE_NO_PAD.encode(&p.key),
39 rp_id: p.rp_id.clone(),
40 user_handle: Some(p.user_handle.to_string()),
41 user_name: Some(p.username.clone()),
42 counter: 0,
43 rp_name: Some(p.rp_id.clone()),
44 user_display_name: Some(p.user_display_name.clone()),
45 discoverable: "true".to_string(),
46 creation_date,
47 }]
48 }),
49 }
50}
51
52fn to_uris(scope: &CredentialScope) -> Vec<LoginUri> {
56 let urls = scope.urls.iter().map(|u| LoginUri {
57 uri: Some(u.clone()),
58 r#match: None,
59 });
60
61 let android_apps = scope.android_apps.iter().map(|a| LoginUri {
62 uri: Some(format!("{ANDROID_APP_SCHEME}{}", a.bundle_id)),
63 r#match: None,
64 });
65
66 urls.chain(android_apps).collect()
67}
68
69pub(crate) fn to_fields(scope: &CredentialScope) -> Vec<Field> {
73 let urls = scope.urls.iter().enumerate().map(|(i, u)| Field {
74 name: Some(format!("Url {}", i + 1)),
75 value: Some(u.clone()),
76 r#type: FieldType::Text as u8,
77 linked_id: None,
78 });
79
80 let android_apps = scope.android_apps.iter().enumerate().map(|(i, a)| Field {
81 name: Some(format!("Android App {}", i + 1)),
82 value: Some(a.bundle_id.clone()),
83 r#type: FieldType::Text as u8,
84 linked_id: None,
85 });
86
87 urls.chain(android_apps).collect()
88}
89
90impl From<Login> for BasicAuthCredential {
91 fn from(login: Login) -> Self {
92 BasicAuthCredential {
93 username: login.username.map(|v| v.into()),
94 password: login.password.map(|v| v.into()),
95 }
96 }
97}
98
99impl From<Login> for CredentialScope {
100 fn from(login: Login) -> Self {
101 let (android_uris, urls): (Vec<_>, Vec<_>) = login
102 .login_uris
103 .into_iter()
104 .filter_map(|u| u.uri)
105 .partition(|uri| uri.starts_with(ANDROID_APP_SCHEME));
106
107 let android_apps = android_uris
108 .into_iter()
109 .map(|uri| {
110 let rest = uri.trim_start_matches(ANDROID_APP_SCHEME);
111 AndroidAppIdCredential {
112 bundle_id: rest.to_string(),
113 certificate: None,
114 name: None,
115 }
116 })
117 .collect();
118
119 CredentialScope { urls, android_apps }
120 }
121}
122
123#[derive(Error, Debug)]
124pub enum PasskeyError {
125 #[error("Counter is not zero")]
126 CounterNotZero,
127 #[error(transparent)]
128 InvalidGuid(InvalidGuid),
129 #[error(transparent)]
130 MissingField(MissingFieldError),
131 #[error(transparent)]
132 InvalidBase64(#[from] base64::DecodeError),
133}
134
135impl TryFrom<Fido2Credential> for PasskeyCredential {
136 type Error = PasskeyError;
137
138 fn try_from(value: Fido2Credential) -> Result<Self, Self::Error> {
139 if value.counter > 0 {
140 return Err(PasskeyError::CounterNotZero);
141 }
142
143 Ok(PasskeyCredential {
144 credential_id: string_to_guid_bytes(&value.credential_id)
145 .map_err(PasskeyError::InvalidGuid)?
146 .into(),
147 rp_id: value.rp_id,
148 username: value.user_name.unwrap_or_default(),
149 user_display_name: value.user_display_name.unwrap_or_default(),
150 user_handle: value
151 .user_handle
152 .map(|v| URL_SAFE_NO_PAD.decode(v))
153 .transpose()?
154 .map(|v| v.into())
155 .ok_or(PasskeyError::MissingField(MissingFieldError("user_handle")))?,
156 key: URL_SAFE_NO_PAD.decode(value.key_value)?.into(),
157 fido2_extensions: None,
158 })
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::LoginUri;
166
167 #[test]
168 fn test_basic_auth() {
169 let login = Login {
170 username: Some("[email protected]".to_string()),
171 password: Some("asdfasdfasdf".to_string()),
172 login_uris: vec![LoginUri {
173 uri: Some("https://vault.bitwarden.com".to_string()),
174 r#match: None,
175 }],
176 totp: None,
177 fido2_credentials: None,
178 };
179
180 let basic_auth: BasicAuthCredential = login.into();
181
182 let username = basic_auth.username.as_ref().unwrap();
183 assert_eq!(username.value.0, "[email protected]");
184 assert!(username.label.is_none());
185
186 let password = basic_auth.password.as_ref().unwrap();
187 assert_eq!(password.value.0, "asdfasdfasdf");
188 assert!(password.label.is_none());
189 }
190
191 #[test]
192 fn test_credential_scope() {
193 let login = Login {
194 username: None,
195 password: None,
196 login_uris: vec![LoginUri {
197 uri: Some("https://vault.bitwarden.com".to_string()),
198 r#match: None,
199 }],
200 totp: None,
201 fido2_credentials: None,
202 };
203
204 let scope: CredentialScope = login.into();
205
206 assert_eq!(scope.urls, vec!["https://vault.bitwarden.com".to_string()]);
207 }
208
209 #[test]
210 fn test_passkey() {
211 let credential = Fido2Credential {
212 credential_id: "e8d88789-e916-e196-3cbd-81dafae71bbc".to_string(),
213 key_type: "public-key".to_string(),
214 key_algorithm: "ECDSA".to_string(),
215 key_curve: "P-256".to_string(),
216 key_value: "AAECAwQFBg".to_string(),
217 rp_id: "123".to_string(),
218 user_handle: Some("AAECAwQFBg".to_string()),
219 user_name: None,
220 counter: 0,
221 rp_name: None,
222 user_display_name: None,
223 discoverable: "true".to_string(),
224 creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
225 };
226
227 let passkey: PasskeyCredential = credential.try_into().unwrap();
228
229 assert_eq!(passkey.credential_id.to_string(), "6NiHiekW4ZY8vYHa-ucbvA");
230 assert_eq!(passkey.rp_id, "123");
231 assert_eq!(passkey.username, "");
232 assert_eq!(passkey.user_display_name, "");
233 assert_eq!(String::from(passkey.user_handle.clone()), "AAECAwQFBg");
234 assert_eq!(String::from(passkey.key.clone()), "AAECAwQFBg");
235 assert!(passkey.fido2_extensions.is_none());
236 }
237
238 #[test]
239 fn test_to_uris_with_urls_only() {
240 let scope = CredentialScope {
241 urls: vec![
242 "https://vault.bitwarden.com".to_string(),
243 "https://bitwarden.com".to_string(),
244 ],
245 android_apps: vec![],
246 };
247
248 let uris = to_uris(&scope);
249
250 assert_eq!(
251 uris,
252 vec![
253 LoginUri {
254 uri: Some("https://vault.bitwarden.com".to_string()),
255 r#match: None
256 },
257 LoginUri {
258 uri: Some("https://bitwarden.com".to_string()),
259 r#match: None
260 },
261 ]
262 );
263 }
264
265 #[test]
266 fn test_to_uris_with_android_apps_only() {
267 let scope = CredentialScope {
268 urls: vec![],
269 android_apps: vec![
270 credential_exchange_format::AndroidAppIdCredential {
271 bundle_id: "com.bitwarden.app".to_string(),
272 certificate: None,
273 name: None,
274 },
275 credential_exchange_format::AndroidAppIdCredential {
276 bundle_id: "com.example.app".to_string(),
277 certificate: None,
278 name: None,
279 },
280 ],
281 };
282
283 let uris = to_uris(&scope);
284
285 assert_eq!(
286 uris,
287 vec![
288 LoginUri {
289 uri: Some("androidapp://com.bitwarden.app".to_string()),
290 r#match: None
291 },
292 LoginUri {
293 uri: Some("androidapp://com.example.app".to_string()),
294 r#match: None
295 },
296 ]
297 );
298 }
299
300 #[test]
301 fn test_to_uris_with_mixed_urls_and_android_apps() {
302 let scope = CredentialScope {
303 urls: vec![
304 "https://vault.bitwarden.com".to_string(),
305 "https://bitwarden.com".to_string(),
306 ],
307 android_apps: vec![
308 credential_exchange_format::AndroidAppIdCredential {
309 bundle_id: "com.bitwarden.app".to_string(),
310 certificate: None,
311 name: None,
312 },
313 credential_exchange_format::AndroidAppIdCredential {
314 bundle_id: "com.example.app".to_string(),
315 certificate: None,
316 name: None,
317 },
318 ],
319 };
320
321 let uris = to_uris(&scope);
322
323 assert_eq!(
324 uris,
325 vec![
326 LoginUri {
327 uri: Some("https://vault.bitwarden.com".to_string()),
328 r#match: None
329 },
330 LoginUri {
331 uri: Some("https://bitwarden.com".to_string()),
332 r#match: None
333 },
334 LoginUri {
335 uri: Some("androidapp://com.bitwarden.app".to_string()),
336 r#match: None
337 },
338 LoginUri {
339 uri: Some("androidapp://com.example.app".to_string()),
340 r#match: None
341 },
342 ]
343 );
344 }
345
346 #[test]
347 fn test_to_uris_with_empty_scope() {
348 let scope = CredentialScope {
349 urls: vec![],
350 android_apps: vec![],
351 };
352
353 let uris = to_uris(&scope);
354
355 assert!(uris.is_empty());
356 }
357
358 #[test]
359 fn test_credential_scope_with_android_apps_only() {
360 let login = Login {
361 username: None,
362 password: None,
363 login_uris: vec![
364 LoginUri {
365 uri: Some("androidapp://com.bitwarden.app".to_string()),
366 r#match: None,
367 },
368 LoginUri {
369 uri: Some("androidapp://com.example.app".to_string()),
370 r#match: None,
371 },
372 ],
373 totp: None,
374 fido2_credentials: None,
375 };
376
377 let scope: CredentialScope = login.into();
378 assert!(scope.urls.is_empty());
379 assert_eq!(scope.android_apps.len(), 2);
380 assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
381 assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
382 }
383
384 #[test]
385 fn test_credential_scope_with_mixed_urls_and_android_apps() {
386 let login = Login {
387 username: None,
388 password: None,
389 login_uris: vec![
390 LoginUri {
391 uri: Some("https://vault.bitwarden.com".to_string()),
392 r#match: None,
393 },
394 LoginUri {
395 uri: Some("androidapp://com.bitwarden.app".to_string()),
396 r#match: None,
397 },
398 LoginUri {
399 uri: Some("https://bitwarden.com".to_string()),
400 r#match: None,
401 },
402 LoginUri {
403 uri: Some("androidapp://com.example.app".to_string()),
404 r#match: None,
405 },
406 ],
407 totp: None,
408 fido2_credentials: None,
409 };
410
411 let scope: CredentialScope = login.into();
412 assert_eq!(
413 scope.urls,
414 vec![
415 "https://vault.bitwarden.com".to_string(),
416 "https://bitwarden.com".to_string(),
417 ]
418 );
419 assert_eq!(scope.android_apps.len(), 2);
420 assert_eq!(scope.android_apps[0].bundle_id, "com.bitwarden.app");
421 assert_eq!(scope.android_apps[1].bundle_id, "com.example.app");
422 }
423
424 #[test]
425 fn test_to_fields() {
426 let scope = CredentialScope {
427 urls: vec![
428 "https://vault.bitwarden.com".to_string(),
429 "https://bitwarden.com".to_string(),
430 ],
431 android_apps: vec![
432 credential_exchange_format::AndroidAppIdCredential {
433 bundle_id: "com.bitwarden.app".to_string(),
434 certificate: None,
435 name: None,
436 },
437 credential_exchange_format::AndroidAppIdCredential {
438 bundle_id: "com.example.app".to_string(),
439 certificate: None,
440 name: None,
441 },
442 ],
443 };
444
445 let fields = to_fields(&scope);
446 assert_eq!(
447 fields,
448 vec![
449 Field {
450 name: Some("Url 1".to_string()),
451 value: Some("https://vault.bitwarden.com".to_string()),
452 r#type: FieldType::Text as u8,
453 linked_id: None,
454 },
455 Field {
456 name: Some("Url 2".to_string()),
457 value: Some("https://bitwarden.com".to_string()),
458 r#type: FieldType::Text as u8,
459 linked_id: None,
460 },
461 Field {
462 name: Some("Android App 1".to_string()),
463 value: Some("com.bitwarden.app".to_string()),
464 r#type: FieldType::Text as u8,
465 linked_id: None,
466 },
467 Field {
468 name: Some("Android App 2".to_string()),
469 value: Some("com.example.app".to_string()),
470 r#type: FieldType::Text as u8,
471 linked_id: None,
472 },
473 ]
474 );
475 }
476}