bitwarden_fido/
types.rs

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