bitwarden_fido/
types.rs

1use std::borrow::Cow;
2
3use bitwarden_core::key_management::KeyIds;
4use bitwarden_crypto::{CryptoError, KeyStoreContext};
5use bitwarden_encoding::{B64Url, NotB64UrlEncodedError};
6use bitwarden_vault::{CipherListView, CipherListViewType, CipherView, LoginListView};
7use passkey::types::webauthn::UserVerificationRequirement;
8use reqwest::Url;
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use super::{
13    InvalidGuidError, SelectedCredential, UnknownEnumError, Verification,
14    get_enum_from_string_name, string_to_guid_bytes,
15};
16
17#[allow(missing_docs)]
18#[derive(Serialize, Deserialize, Debug, Clone)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
21pub struct Fido2CredentialAutofillView {
22    pub credential_id: Vec<u8>,
23    pub cipher_id: uuid::Uuid,
24    pub rp_id: String,
25    pub user_name_for_ui: Option<String>,
26    pub user_handle: Vec<u8>,
27    /// Indicates if this credential uses a signature counter (legacy passkeys).
28    /// When true, mobile clients must sync before authentication to ensure
29    /// counter values are current. Modern passkeys (counter = 0) can work offline.
30    pub has_counter: bool,
31}
32
33trait NoneWhitespace {
34    /// Convert only whitespace to None
35    fn none_whitespace(&self) -> Option<String>;
36}
37
38impl NoneWhitespace for String {
39    fn none_whitespace(&self) -> Option<String> {
40        match self.trim() {
41            "" => None,
42            s => Some(s.to_owned()),
43        }
44    }
45}
46
47impl NoneWhitespace for Option<String> {
48    fn none_whitespace(&self) -> Option<String> {
49        self.as_ref().and_then(|s| s.none_whitespace())
50    }
51}
52
53#[allow(missing_docs)]
54#[derive(Debug, Error)]
55pub enum Fido2CredentialAutofillViewError {
56    #[error("Autofill credentials can only be created from existing ciphers that have a cipher id")]
57    MissingCipherId,
58
59    #[error(transparent)]
60    InvalidGuid(#[from] InvalidGuidError),
61
62    #[error(transparent)]
63    Crypto(#[from] CryptoError),
64
65    #[error(transparent)]
66    Base64Decode(#[from] NotB64UrlEncodedError),
67}
68
69impl Fido2CredentialAutofillView {
70    #[allow(missing_docs)]
71    pub fn from_cipher_view(
72        cipher: &CipherView,
73        ctx: &mut KeyStoreContext<KeyIds>,
74    ) -> Result<Vec<Fido2CredentialAutofillView>, Fido2CredentialAutofillViewError> {
75        let credentials = cipher.decrypt_fido2_credentials(ctx)?;
76
77        credentials
78            .iter()
79            .filter_map(|c| -> Option<Result<_, Fido2CredentialAutofillViewError>> {
80                c.user_handle
81                    .as_ref()
82                    .map(|u| B64Url::try_from(u.as_str()))
83                    .map(|user_handle| {
84                        Ok(Fido2CredentialAutofillView {
85                            credential_id: string_to_guid_bytes(&c.credential_id)?,
86                            cipher_id: cipher
87                                .id
88                                .ok_or(Fido2CredentialAutofillViewError::MissingCipherId)?
89                                .into(),
90                            rp_id: c.rp_id.clone(),
91                            user_handle: user_handle?.into_bytes(),
92                            user_name_for_ui: c
93                                .user_name
94                                .none_whitespace()
95                                .or(c.user_display_name.none_whitespace())
96                                .or(cipher
97                                    .login
98                                    .as_ref()
99                                    .and_then(|l| l.username.none_whitespace()))
100                                .or(cipher.name.none_whitespace()),
101                            has_counter: Self::has_signature_counter(&c.counter),
102                        })
103                    })
104            })
105            .collect()
106    }
107
108    #[allow(missing_docs)]
109    pub fn from_cipher_list_view(
110        cipher: &CipherListView,
111    ) -> Result<Vec<Fido2CredentialAutofillView>, Fido2CredentialAutofillViewError> {
112        match &cipher.r#type {
113            CipherListViewType::Login(LoginListView {
114                fido2_credentials: Some(fido2_credentials),
115                username,
116                ..
117            }) => fido2_credentials
118                .iter()
119                .filter_map(|c| -> Option<Result<_, Fido2CredentialAutofillViewError>> {
120                    c.user_handle
121                        .as_ref()
122                        .map(|u| B64Url::try_from(u.as_str()))
123                        .map(|user_handle| {
124                            Ok(Fido2CredentialAutofillView {
125                                credential_id: string_to_guid_bytes(&c.credential_id)?,
126                                cipher_id: cipher
127                                    .id
128                                    .ok_or(Fido2CredentialAutofillViewError::MissingCipherId)?
129                                    .into(),
130                                rp_id: c.rp_id.clone(),
131                                user_handle: user_handle?.into_bytes(),
132                                user_name_for_ui: c
133                                    .user_name
134                                    .none_whitespace()
135                                    .or(c.user_display_name.none_whitespace())
136                                    .or(username.none_whitespace())
137                                    .or(cipher.name.none_whitespace()),
138                                has_counter: Self::has_signature_counter(&c.counter),
139                            })
140                        })
141                })
142                .collect(),
143            _ => Ok(vec![]),
144        }
145    }
146
147    fn has_signature_counter(str: &String) -> bool {
148        str.none_whitespace()
149            .is_some_and(|counter_str| counter_str.parse::<u64>().is_ok_and(|counter| counter > 0))
150    }
151}
152
153#[allow(missing_docs)]
154#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
155pub struct PublicKeyCredentialRpEntity {
156    pub id: String,
157    pub name: Option<String>,
158}
159
160#[allow(missing_docs)]
161#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
162pub struct PublicKeyCredentialUserEntity {
163    pub id: Vec<u8>,
164    pub display_name: String,
165    pub name: String,
166}
167
168#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
169pub struct PublicKeyCredentialParameters {
170    pub ty: String,
171    pub alg: i64,
172}
173
174#[derive(Debug, Error)]
175pub enum PublicKeyCredentialParametersError {
176    #[error("Invalid algorithm")]
177    InvalidAlgorithm,
178
179    #[error("Unknown type")]
180    UnknownEnum(#[from] UnknownEnumError),
181}
182
183impl TryFrom<PublicKeyCredentialParameters>
184    for passkey::types::webauthn::PublicKeyCredentialParameters
185{
186    type Error = PublicKeyCredentialParametersError;
187
188    fn try_from(value: PublicKeyCredentialParameters) -> Result<Self, Self::Error> {
189        use coset::iana::EnumI64;
190        Ok(Self {
191            ty: get_enum_from_string_name(&value.ty)?,
192            alg: coset::iana::Algorithm::from_i64(value.alg)
193                .ok_or(PublicKeyCredentialParametersError::InvalidAlgorithm)?,
194        })
195    }
196}
197
198#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
199pub struct PublicKeyCredentialDescriptor {
200    pub ty: String,
201    pub id: Vec<u8>,
202    pub transports: Option<Vec<String>>,
203}
204
205impl TryFrom<PublicKeyCredentialDescriptor>
206    for passkey::types::webauthn::PublicKeyCredentialDescriptor
207{
208    type Error = UnknownEnumError;
209
210    fn try_from(value: PublicKeyCredentialDescriptor) -> Result<Self, Self::Error> {
211        Ok(Self {
212            ty: get_enum_from_string_name(&value.ty)?,
213            id: value.id.into(),
214            transports: value
215                .transports
216                .map(|tt| {
217                    tt.into_iter()
218                        .map(|t| get_enum_from_string_name(&t))
219                        .collect::<Result<Vec<_>, Self::Error>>()
220                })
221                .transpose()?,
222        })
223    }
224}
225
226pub type Extensions = Option<String>;
227
228#[allow(missing_docs)]
229#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
230pub struct MakeCredentialRequest {
231    pub client_data_hash: Vec<u8>,
232    pub rp: PublicKeyCredentialRpEntity,
233    pub user: PublicKeyCredentialUserEntity,
234    pub pub_key_cred_params: Vec<PublicKeyCredentialParameters>,
235    pub exclude_list: Option<Vec<PublicKeyCredentialDescriptor>>,
236    pub options: Options,
237    pub extensions: Extensions,
238}
239
240#[allow(missing_docs)]
241#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
242pub struct MakeCredentialResult {
243    pub authenticator_data: Vec<u8>,
244    pub attestation_object: Vec<u8>,
245    pub credential_id: Vec<u8>,
246}
247
248#[allow(missing_docs)]
249#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
250pub struct GetAssertionRequest {
251    pub rp_id: String,
252    pub client_data_hash: Vec<u8>,
253    pub allow_list: Option<Vec<PublicKeyCredentialDescriptor>>,
254    pub options: Options,
255    pub extensions: Extensions,
256}
257
258#[allow(missing_docs)]
259#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
260pub struct Options {
261    pub rk: bool,
262    pub uv: UV,
263}
264
265impl From<super::CheckUserOptions> for Options {
266    fn from(value: super::CheckUserOptions) -> Self {
267        Self {
268            rk: value.require_presence,
269            uv: value.require_verification.into(),
270        }
271    }
272}
273
274impl From<Options> for super::CheckUserOptions {
275    fn from(value: Options) -> Self {
276        Self {
277            require_presence: value.rk,
278            require_verification: value.uv.into(),
279        }
280    }
281}
282
283#[derive(Eq, PartialEq, Clone, Copy)]
284#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
285pub enum UV {
286    Discouraged,
287    Preferred,
288    Required,
289}
290
291impl From<UV> for Verification {
292    fn from(value: UV) -> Self {
293        match value {
294            UV::Discouraged => Verification::Discouraged,
295            UV::Preferred => Verification::Preferred,
296            UV::Required => Verification::Required,
297        }
298    }
299}
300
301impl From<Verification> for UV {
302    fn from(value: Verification) -> Self {
303        match value {
304            Verification::Discouraged => UV::Discouraged,
305            Verification::Preferred => UV::Preferred,
306            Verification::Required => UV::Required,
307        }
308    }
309}
310
311impl From<UserVerificationRequirement> for UV {
312    fn from(value: UserVerificationRequirement) -> Self {
313        match value {
314            UserVerificationRequirement::Discouraged => UV::Discouraged,
315            UserVerificationRequirement::Preferred => UV::Preferred,
316            UserVerificationRequirement::Required => UV::Required,
317        }
318    }
319}
320
321#[allow(missing_docs)]
322#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
323pub struct GetAssertionResult {
324    pub credential_id: Vec<u8>,
325    pub authenticator_data: Vec<u8>,
326    pub signature: Vec<u8>,
327    pub user_handle: Vec<u8>,
328
329    pub selected_credential: SelectedCredential,
330}
331
332#[allow(missing_docs)]
333#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
334pub enum ClientData {
335    DefaultWithExtraData { android_package_name: String },
336    DefaultWithCustomHash { hash: Vec<u8> },
337}
338
339#[derive(Serialize, Clone)]
340#[serde(rename_all = "camelCase")]
341pub(super) struct AndroidClientData {
342    android_package_name: String,
343}
344
345impl passkey::client::ClientData<Option<AndroidClientData>> for ClientData {
346    fn extra_client_data(&self) -> Option<AndroidClientData> {
347        match self {
348            ClientData::DefaultWithExtraData {
349                android_package_name,
350            } => Some(AndroidClientData {
351                android_package_name: android_package_name.clone(),
352            }),
353            ClientData::DefaultWithCustomHash { .. } => None,
354        }
355    }
356
357    fn client_data_hash(&self) -> Option<Vec<u8>> {
358        match self {
359            ClientData::DefaultWithExtraData { .. } => None,
360            ClientData::DefaultWithCustomHash { hash } => Some(hash.clone()),
361        }
362    }
363}
364
365#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
366pub struct ClientExtensionResults {
367    pub cred_props: Option<CredPropsResult>,
368}
369
370#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
371pub struct CredPropsResult {
372    pub rk: Option<bool>,
373    pub authenticator_display_name: Option<String>,
374}
375
376impl From<passkey::types::webauthn::CredentialPropertiesOutput> for CredPropsResult {
377    fn from(value: passkey::types::webauthn::CredentialPropertiesOutput) -> Self {
378        Self {
379            rk: value.discoverable,
380            authenticator_display_name: value.authenticator_display_name,
381        }
382    }
383}
384
385#[allow(missing_docs)]
386#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
387pub struct PublicKeyCredentialAuthenticatorAttestationResponse {
388    pub id: String,
389    pub raw_id: Vec<u8>,
390    pub ty: String,
391    pub authenticator_attachment: Option<String>,
392    pub client_extension_results: ClientExtensionResults,
393    pub response: AuthenticatorAttestationResponse,
394    pub selected_credential: SelectedCredential,
395}
396
397#[allow(missing_docs)]
398#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
399pub struct AuthenticatorAttestationResponse {
400    pub client_data_json: Vec<u8>,
401    pub authenticator_data: Vec<u8>,
402    pub public_key: Option<Vec<u8>>,
403    pub public_key_algorithm: i64,
404    pub attestation_object: Vec<u8>,
405    pub transports: Option<Vec<String>>,
406}
407
408#[allow(missing_docs)]
409#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
410pub struct PublicKeyCredentialAuthenticatorAssertionResponse {
411    pub id: String,
412    pub raw_id: Vec<u8>,
413    pub ty: String,
414    pub authenticator_attachment: Option<String>,
415    pub client_extension_results: ClientExtensionResults,
416    pub response: AuthenticatorAssertionResponse,
417    pub selected_credential: SelectedCredential,
418}
419
420#[allow(missing_docs)]
421#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
422pub struct AuthenticatorAssertionResponse {
423    pub client_data_json: Vec<u8>,
424    pub authenticator_data: Vec<u8>,
425    pub signature: Vec<u8>,
426    pub user_handle: Vec<u8>,
427}
428
429#[derive(Debug, Error)]
430#[error("Invalid origin: {0}")]
431pub struct InvalidOriginError(String);
432
433#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
434/// An Unverified asset link.
435pub struct UnverifiedAssetLink {
436    /// Application package name.
437    package_name: String,
438    /// Fingerprint to compare.
439    sha256_cert_fingerprint: String,
440    /// Host to lookup the well known asset link.
441    host: String,
442    /// When sourced from the application statement list or parsed from host for passkeys.
443    /// Will be generated from `host` if not provided.
444    asset_link_url: Option<String>,
445}
446
447#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
448/// The origin of a WebAuthn request.
449pub enum Origin {
450    /// A Url, meant for a request in the web browser.
451    Web(String),
452    /// An android digital asset fingerprint.
453    /// Meant for a request coming from an android application.
454    Android(UnverifiedAssetLink),
455}
456
457impl TryFrom<Origin> for passkey::client::Origin<'_> {
458    type Error = InvalidOriginError;
459
460    fn try_from(value: Origin) -> Result<Self, Self::Error> {
461        Ok(match value {
462            Origin::Web(url) => {
463                let url = Url::parse(&url).map_err(|e| InvalidOriginError(format!("{e}")))?;
464                passkey::client::Origin::Web(Cow::Owned(url))
465            }
466            Origin::Android(link) => passkey::client::Origin::Android(link.try_into()?),
467        })
468    }
469}
470
471impl TryFrom<UnverifiedAssetLink> for passkey::client::UnverifiedAssetLink<'_> {
472    type Error = InvalidOriginError;
473
474    fn try_from(value: UnverifiedAssetLink) -> Result<Self, Self::Error> {
475        let asset_link_url = match value.asset_link_url {
476            Some(url) => Some(Url::parse(&url).map_err(|e| InvalidOriginError(format!("{e}")))?),
477            None => None,
478        };
479
480        passkey::client::UnverifiedAssetLink::new(
481            Cow::from(value.package_name),
482            value.sha256_cert_fingerprint.as_str(),
483            Cow::from(value.host),
484            asset_link_url,
485        )
486        .map_err(|e| InvalidOriginError(format!("{e:?}")))
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use serde::{Deserialize, Serialize};
493
494    use super::AndroidClientData;
495
496    // This is a stripped down of the passkey-rs implementation, to test the
497    // serialization of the `ClientData` enum, and to make sure that () and None
498    // are serialized the same way when going through #[serde(flatten)].
499    #[derive(Serialize, Deserialize)]
500    #[serde(rename_all = "camelCase")]
501    pub struct CollectedClientData<E = ()>
502    where
503        E: Serialize,
504    {
505        pub origin: String,
506
507        #[serde(flatten)]
508        pub extra_data: E,
509    }
510
511    #[test]
512    fn test_serialize_unit_data() {
513        let data = CollectedClientData {
514            origin: "https://example.com".to_owned(),
515            extra_data: (),
516        };
517
518        let serialized = serde_json::to_string(&data).unwrap();
519        assert_eq!(serialized, r#"{"origin":"https://example.com"}"#);
520    }
521
522    #[test]
523    fn test_serialize_none_data() {
524        let data = CollectedClientData {
525            origin: "https://example.com".to_owned(),
526            extra_data: Option::<AndroidClientData>::None,
527        };
528
529        let serialized = serde_json::to_string(&data).unwrap();
530        assert_eq!(serialized, r#"{"origin":"https://example.com"}"#);
531    }
532
533    #[test]
534    fn test_serialize_android_data() {
535        let data = CollectedClientData {
536            origin: "https://example.com".to_owned(),
537            extra_data: Some(AndroidClientData {
538                android_package_name: "com.example.app".to_owned(),
539            }),
540        };
541
542        let serialized = serde_json::to_string(&data).unwrap();
543        assert_eq!(
544            serialized,
545            r#"{"origin":"https://example.com","androidPackageName":"com.example.app"}"#
546        );
547    }
548}