passkey_client/
lib.rs

1//! # Passkey Client
2//!
3//! [![github]](https://github.com/1Password/passkey-rs/tree/main/passkey-client)
4//! [![version]](https://crates.io/crates/passkey-client)
5//! [![documentation]](https://docs.rs/passkey-client/)
6//!
7//! This crate defines a [`Client`] type along with a basic implementation of the [Webauthn]
8//! specification. The [`Client`] uses an [`Authenticator`] to perform the actual cryptographic
9//! operations, while the Client itself marshals data to and from the structs received from the Relying Party.
10//!
11//! This crate does not provide any code to perform networking requests to and from Relying Parties.
12//!
13//! [github]: https://img.shields.io/badge/GitHub-1Password%2Fpasskey--rs%2Fpasskey--client-informational?logo=github&style=flat
14//! [version]: https://img.shields.io/crates/v/passkey-client?logo=rust&style=flat
15//! [documentation]: https://img.shields.io/docsrs/passkey-client/latest?logo=docs.rs&style=flat
16//! [Webauthn]: https://w3c.github.io/webauthn/
17mod client_data;
18pub use client_data::*;
19
20use std::{borrow::Cow, fmt::Display, ops::ControlFlow};
21
22use ciborium::{cbor, value::Value};
23use coset::{iana::EnumI64, Algorithm};
24use passkey_authenticator::{Authenticator, CredentialStore, UserValidationMethod};
25use passkey_types::{
26    crypto::sha256,
27    ctap2, encoding,
28    webauthn::{
29        self, AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
30    },
31    Passkey,
32};
33use serde::Serialize;
34use typeshare::typeshare;
35use url::Url;
36
37mod extensions;
38
39#[cfg(feature = "android-asset-validation")]
40mod android;
41
42#[cfg(feature = "android-asset-validation")]
43pub use self::android::{valid_fingerprint, UnverifiedAssetLink, ValidationError};
44
45#[cfg(test)]
46mod tests;
47
48#[typeshare]
49#[derive(Debug, serde::Serialize, PartialEq, Eq)]
50#[serde(tag = "type", content = "content")]
51/// Errors produced by Webauthn Operations.
52pub enum WebauthnError {
53    /// A credential ID can be a maximum of 1023 bytes.
54    CredentialIdTooLong,
55    /// The request origin was missing a proper domain part.
56    OriginMissingDomain,
57    /// The request origin is not a sub-domain of the RP ID.
58    OriginRpMissmatch,
59    /// The origin of the request does not use HTTPS.
60    UnprotectedOrigin,
61    /// Origin was set to localhost but allows_insecure_localhost was not set.
62    InsecureLocalhostNotAllowed,
63    /// No credential was found
64    CredentialNotFound,
65    /// The RP ID is invalid.
66    InvalidRpId,
67    /// Internal authenticator error whose value represents a `ctap2::StatusCode`
68    AuthenticatorError(u8),
69    /// The operation is not supported.
70    NotSupportedError,
71    /// The string did not match the expected pattern.
72    SyntaxError,
73    /// The input failed validation
74    ValidationError,
75}
76
77impl WebauthnError {
78    /// Was the error a vendor error?
79    pub fn is_vendor_error(&self) -> bool {
80        matches!(self, WebauthnError::AuthenticatorError(ctap_error) if ctap2::VendorError::try_from(*ctap_error).is_ok())
81    }
82}
83
84impl From<ctap2::StatusCode> for WebauthnError {
85    fn from(value: ctap2::StatusCode) -> Self {
86        match value {
87            ctap2::StatusCode::Ctap1(u2f) => WebauthnError::AuthenticatorError(u2f.into()),
88            ctap2::StatusCode::Ctap2(ctap2::Ctap2Code::Known(ctap2::Ctap2Error::NoCredentials)) => {
89                WebauthnError::CredentialNotFound
90            }
91            ctap2::StatusCode::Ctap2(ctap2code) => {
92                WebauthnError::AuthenticatorError(ctap2code.into())
93            }
94        }
95    }
96}
97
98/// Returns a decoded [String] if the domain name is punycode otherwise
99/// the original string reference [str] is returned.
100fn decode_host(host: &str) -> Option<Cow<str>> {
101    if host.split('.').any(|s| s.starts_with("xn--")) {
102        let (decoded, result) = idna::domain_to_unicode(host);
103        result.ok().map(|_| Cow::from(decoded))
104    } else {
105        Some(Cow::from(host))
106    }
107}
108
109/// The origin of a WebAuthn request.
110pub enum Origin<'a> {
111    /// A Url, meant for a request in the web browser.
112    Web(Cow<'a, Url>),
113    /// An android digital asset fingerprint.
114    /// Meant for a request coming from an android application.
115    #[cfg(feature = "android-asset-validation")]
116    Android(UnverifiedAssetLink<'a>),
117}
118
119impl From<Url> for Origin<'_> {
120    fn from(value: Url) -> Self {
121        Origin::Web(Cow::Owned(value))
122    }
123}
124
125impl<'a> From<&'a Url> for Origin<'a> {
126    fn from(value: &'a Url) -> Self {
127        Origin::Web(Cow::Borrowed(value))
128    }
129}
130
131impl Display for Origin<'_> {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        match self {
134            Origin::Web(url) => write!(f, "{}", url.as_str().trim_end_matches('/')),
135            #[cfg(feature = "android-asset-validation")]
136            Origin::Android(target_link) => {
137                write!(
138                    f,
139                    "android:apk-key-hash:{}",
140                    encoding::base64url(target_link.sha256_cert_fingerprint())
141                )
142            }
143        }
144    }
145}
146
147/// A `Client` represents a Webauthn client. Users of this struct should supply a
148/// [`CredentialStore`], a [`UserValidationMethod`] and, optionally, an implementation of
149/// [`public_suffix::EffectiveTLDProvider`].
150///
151/// The `tld_provider` is used to verify effective Top-Level Domains for request origins presented
152/// to the client. Most applications can use the `new()` function, which creates a `Client` with a
153/// default provider implementation. Use `new_with_custom_tld_provider()` to provide a custom
154/// `EffectiveTLDProvider` if your application needs to interpret eTLDs differently from the Mozilla
155/// Public Suffix List.
156pub struct Client<S, U, P>
157where
158    S: CredentialStore + Sync,
159    U: UserValidationMethod + Sync,
160    P: public_suffix::EffectiveTLDProvider + Sync + 'static,
161{
162    authenticator: Authenticator<S, U>,
163    rp_id_verifier: RpIdVerifier<P>,
164}
165
166impl<S, U> Client<S, U, public_suffix::PublicSuffixList>
167where
168    S: CredentialStore + Sync,
169    U: UserValidationMethod + Sync,
170    Passkey: TryFrom<<S as CredentialStore>::PasskeyItem>,
171{
172    /// Create a `Client` with a given `Authenticator` that uses the default
173    /// TLD verifier provided by `[public_suffix]`.
174    pub fn new(authenticator: Authenticator<S, U>) -> Self {
175        Self {
176            authenticator,
177            rp_id_verifier: RpIdVerifier::new(public_suffix::DEFAULT_PROVIDER),
178        }
179    }
180}
181
182impl<S, U, P> Client<S, U, P>
183where
184    S: CredentialStore + Sync,
185    U: UserValidationMethod<PasskeyItem = <S as CredentialStore>::PasskeyItem> + Sync,
186    P: public_suffix::EffectiveTLDProvider + Sync + 'static,
187{
188    /// Create a `Client` with a given `Authenticator` and a custom TLD provider
189    /// that implements `[public_suffix::EffectiveTLDProvider]`.
190    pub fn new_with_custom_tld_provider(
191        authenticator: Authenticator<S, U>,
192        custom_provider: P,
193    ) -> Self {
194        Self {
195            authenticator,
196            rp_id_verifier: RpIdVerifier::new(custom_provider),
197        }
198    }
199
200    /// Allows the internal [RpIdVerifier] to pass through localhost requests.
201    pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
202        self.rp_id_verifier = self.rp_id_verifier.allows_insecure_localhost(is_allowed);
203        self
204    }
205
206    /// Read access to the Client's `Authenticator`.
207    pub fn authenticator(&self) -> &Authenticator<S, U> {
208        &self.authenticator
209    }
210
211    /// Write access to the Client's `Authenticator`.
212    pub fn authenticator_mut(&mut self) -> &mut Authenticator<S, U> {
213        &mut self.authenticator
214    }
215
216    /// Register a webauthn `request` from the given `origin`.
217    ///
218    /// Returns either a [`webauthn::CreatedPublicKeyCredential`] on success or some [`WebauthnError`]
219    pub async fn register<D: ClientData<E>, E: Serialize + Clone>(
220        &mut self,
221        origin: impl Into<Origin<'_>>,
222        request: webauthn::CredentialCreationOptions,
223        client_data: D,
224    ) -> Result<webauthn::CreatedPublicKeyCredential, WebauthnError> {
225        let origin = origin.into();
226
227        // extract inner value of request as there is nothing else of value directly in CredentialCreationOptions
228        let request = request.public_key;
229        let auth_info = self.authenticator.get_info().await;
230
231        let pub_key_cred_params = if request.pub_key_cred_params.is_empty() {
232            webauthn::PublicKeyCredentialParameters::default_algorithms()
233        } else {
234            request.pub_key_cred_params
235        };
236        // TODO: Handle given timeout here, If the value is not within what we consider a reasonable range
237        // override to our default
238        // let timeout = request
239        //     .timeout
240        //     .map(|t| t.clamp(MIN_TIMEOUT, MAX_TIMEOUT))
241        //     .unwrap_or(MAX_TIMEOUT);
242
243        let rp_id = self
244            .rp_id_verifier
245            .assert_domain(&origin, request.rp.id.as_deref())?;
246
247        let collected_client_data = webauthn::CollectedClientData::<E> {
248            ty: webauthn::ClientDataType::Create,
249            challenge: encoding::base64url(&request.challenge),
250            origin: origin.to_string(),
251            cross_origin: None,
252            extra_data: client_data.extra_client_data(),
253            unknown_keys: Default::default(),
254        };
255
256        // SAFETY: it is a developer error if serializing this struct fails.
257        let client_data_json = serde_json::to_string(&collected_client_data).unwrap();
258        let client_data_json_hash = client_data
259            .client_data_hash()
260            .unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec());
261
262        let extension_request = request.extensions.and_then(|e| e.zip_contents());
263
264        let ctap_extensions = self.registration_extension_ctap2_input(
265            extension_request.as_ref(),
266            auth_info.extensions.as_deref().unwrap_or_default(),
267        )?;
268
269        let rk = self.map_rk(&request.authenticator_selection, &auth_info);
270        let uv = request.authenticator_selection.map(|s| s.user_verification)
271            != Some(UserVerificationRequirement::Discouraged);
272
273        let ctap2_response = self
274            .authenticator
275            .make_credential(ctap2::make_credential::Request {
276                client_data_hash: client_data_json_hash.into(),
277                rp: ctap2::make_credential::PublicKeyCredentialRpEntity {
278                    id: rp_id.to_owned(),
279                    name: Some(request.rp.name),
280                },
281                user: request.user,
282                pub_key_cred_params,
283                exclude_list: request.exclude_credentials,
284                extensions: ctap_extensions,
285                options: ctap2::make_credential::Options { rk, up: true, uv },
286                pin_auth: None,
287                pin_protocol: None,
288            })
289            .await
290            .map_err(|sc| WebauthnError::AuthenticatorError(sc.into()))?;
291
292        let mut attestation_object = Vec::with_capacity(128);
293        // SAFETY: The Results here are from serializing all the internals of `cbor!` into `ciborium::Value`
294        // then serializing said value to bytes. The unwraps here are safe because it would otherwise be
295        // programmer error.
296        // TODO: Create strong attestation type definitions, part of CTAP2
297        let attestation_object_value = cbor!({
298               // TODO: Follow preference and/or implement AnonCA https://w3c.github.io/webauthn/#anonymization-ca
299               "fmt" => "none",
300                "attStmt" => {},
301                // Explicitly define these fields as bytes since specialization is still fairly far
302               "authData" => Value::Bytes(ctap2_response.auth_data.to_vec()),
303        })
304        .unwrap();
305        ciborium::ser::into_writer(&attestation_object_value, &mut attestation_object).unwrap();
306
307        // SAFETY: this unwrap is safe because the ctap2_response was just created in make_credential()
308        // above, which currently sets auth_data.attested_credential_data unconditionally.
309        // If this fails, it's a programmer error in that the postconditions of make_credential will
310        // have changed.
311        let credential_id = ctap2_response
312            .auth_data
313            .attested_credential_data
314            .as_ref()
315            .unwrap();
316        let alg = match credential_id.key.alg.as_ref().unwrap() {
317            Algorithm::PrivateUse(val) => *val,
318            Algorithm::Assigned(alg) => alg.to_i64(),
319            Algorithm::Text(_) => {
320                unreachable!()
321            }
322        };
323        let public_key = Some(
324            passkey_authenticator::public_key_der_from_cose_key(&credential_id.key)
325                .map_err(|e| WebauthnError::AuthenticatorError(e.into()))?,
326        );
327
328        let store_info = self.authenticator.store().get_info().await;
329        let client_extension_results = self.registration_extension_outputs(
330            extension_request.as_ref(),
331            store_info,
332            rk,
333            ctap2_response.unsigned_extension_outputs,
334        );
335
336        let response = webauthn::CreatedPublicKeyCredential {
337            id: encoding::base64url(credential_id.credential_id()),
338            raw_id: credential_id.credential_id().to_vec().into(),
339            ty: webauthn::PublicKeyCredentialType::PublicKey,
340            response: webauthn::AuthenticatorAttestationResponse {
341                client_data_json: Vec::from(client_data_json).into(),
342                authenticator_data: ctap2_response.auth_data.to_vec().into(),
343                public_key,
344                public_key_algorithm: alg,
345                attestation_object: attestation_object.into(),
346                transports: auth_info.transports,
347            },
348            authenticator_attachment: Some(self.authenticator().attachment_type()),
349            client_extension_results,
350        };
351
352        Ok(response)
353    }
354
355    /// Authenticate a Webauthn request.
356    ///
357    /// Returns either an [`webauthn::AuthenticatedPublicKeyCredential`] on success or some [`WebauthnError`].
358    pub async fn authenticate<D: ClientData<E>, E: Serialize + Clone>(
359        &mut self,
360        origin: impl Into<Origin<'_>>,
361        request: webauthn::CredentialRequestOptions,
362        client_data: D,
363    ) -> Result<webauthn::AuthenticatedPublicKeyCredential, WebauthnError> {
364        let origin = origin.into();
365
366        // extract inner value of request as there is nothing else of value directly in CredentialRequestOptions
367        let request = request.public_key;
368        let auth_info = self.authenticator().get_info().await;
369
370        // TODO: Handle given timeout here, If the value is not within what we consider a reasonable range
371        // override to our default
372        // let timeout = request
373        //     .timeout
374        //     .map(|t| t.clamp(MIN_TIMEOUT, MAX_TIMEOUT))
375        //     .unwrap_or(MAX_TIMEOUT);
376
377        let rp_id = self
378            .rp_id_verifier
379            .assert_domain(&origin, request.rp_id.as_deref())?;
380
381        let collected_client_data = webauthn::CollectedClientData::<E> {
382            ty: webauthn::ClientDataType::Get,
383            challenge: encoding::base64url(&request.challenge),
384            origin: origin.to_string(),
385            cross_origin: None, //Some(false),
386            extra_data: client_data.extra_client_data(),
387            unknown_keys: Default::default(),
388        };
389
390        // SAFETY: it is a developer error if serializing this struct fails.
391        let client_data_json = serde_json::to_string(&collected_client_data).unwrap();
392        let client_data_json_hash = client_data
393            .client_data_hash()
394            .unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec());
395
396        let ctap_extensions = self.auth_extension_ctap2_input(
397            &request,
398            auth_info.extensions.unwrap_or_default().as_slice(),
399        )?;
400        let rk = false;
401        let uv = request.user_verification != UserVerificationRequirement::Discouraged;
402
403        let ctap2_response = self
404            .authenticator
405            .get_assertion(ctap2::get_assertion::Request {
406                rp_id: rp_id.to_owned(),
407                client_data_hash: client_data_json_hash.into(),
408                allow_list: request.allow_credentials,
409                extensions: ctap_extensions,
410                options: ctap2::get_assertion::Options { rk, up: true, uv },
411                pin_auth: None,
412                pin_protocol: None,
413            })
414            .await
415            .map_err(Into::<WebauthnError>::into)?;
416
417        let client_extension_results =
418            self.auth_extension_outputs(ctap2_response.unsigned_extension_outputs);
419
420        // SAFETY: This unwrap is safe because ctap2_response was created immedately
421        // above and the postcondition of that function is that response.credential
422        // will yield a credential. If none was found, we will have already returned
423        // a WebauthnError::CredentialNotFound error from map_err in that line.
424        let credential_id_bytes = ctap2_response.credential.unwrap().id;
425        Ok(webauthn::AuthenticatedPublicKeyCredential {
426            id: encoding::base64url(&credential_id_bytes),
427            raw_id: credential_id_bytes.to_vec().into(),
428            ty: webauthn::PublicKeyCredentialType::PublicKey,
429            response: webauthn::AuthenticatorAssertionResponse {
430                client_data_json: Vec::from(client_data_json).into(),
431                authenticator_data: ctap2_response.auth_data.to_vec().into(),
432                signature: ctap2_response.signature,
433                user_handle: ctap2_response.user.map(|user| user.id),
434                attestation_object: None,
435            },
436            authenticator_attachment: Some(self.authenticator().attachment_type()),
437            client_extension_results,
438        })
439    }
440
441    fn map_rk(
442        &self,
443        criteria: &Option<AuthenticatorSelectionCriteria>,
444        auth_info: &ctap2::get_info::Response,
445    ) -> bool {
446        let supports_rk = auth_info.options.as_ref().is_some_and(|o| o.rk);
447
448        match criteria.as_ref().unwrap_or(&Default::default()) {
449            // > If pkOptions.authenticatorSelection.residentKey:
450            // > is present and set to required
451            AuthenticatorSelectionCriteria {
452                resident_key: Some(ResidentKeyRequirement::Required),
453                ..
454            // > Let requireResidentKey be true.
455            } => true,
456
457            // > is present and set to preferred
458            AuthenticatorSelectionCriteria {
459                resident_key: Some(ResidentKeyRequirement::Preferred),
460                ..
461            // >  And the authenticator is capable of client-side credential storage modality
462            //    > Let requireResidentKey be true.
463            // >  And the authenticator is not capable of client-side credential storage modality, or if the client cannot determine authenticator capability,
464            //    > Let requireResidentKey be false.
465            } => supports_rk,
466
467            // > is present and set to discouraged
468            AuthenticatorSelectionCriteria {
469                resident_key: Some(ResidentKeyRequirement::Discouraged),
470                ..
471            // > Let requireResidentKey be false.
472            } => false,
473
474            // > If pkOptions.authenticatorSelection.residentKey is not present
475            AuthenticatorSelectionCriteria {
476                resident_key: None,
477                require_resident_key,
478                ..
479            // > Let requireResidentKey be the value of pkOptions.authenticatorSelection.requireResidentKey.
480            } => *require_resident_key,
481        }
482    }
483}
484
485/// Wrapper struct for verifying that a given RpId matches the request's origin.
486///
487/// While most cases should not use this type directly and instead use [`Client`], there are some
488/// cases that warrant the need for checking an RpId in the same way that the client does, but without
489/// the rest of pieces that the client needs.
490pub struct RpIdVerifier<P> {
491    tld_provider: Box<P>,
492    allows_insecure_localhost: bool,
493}
494
495impl<P> RpIdVerifier<P>
496where
497    P: public_suffix::EffectiveTLDProvider + Sync + 'static,
498{
499    /// Create a new Verifier with a given TLD provider. Most cases should just use
500    /// [`public_suffix::DEFAULT_PROVIDER`].
501    pub fn new(tld_provider: P) -> Self {
502        Self {
503            tld_provider: Box::new(tld_provider),
504            allows_insecure_localhost: false,
505        }
506    }
507
508    /// Allows [`RpIdVerifier::assert_domain`] to pass through requests from `localhost`
509    pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
510        self.allows_insecure_localhost = is_allowed;
511        self
512    }
513
514    /// Parse the given Relying Party Id and verify it against the origin url of the request.
515    ///
516    /// This follows the steps defined in: <https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to>
517    ///
518    /// Returns the effective domain on success or some [`WebauthnError`]
519    pub fn assert_domain<'a>(
520        &self,
521        origin: &'a Origin,
522        rp_id: Option<&'a str>,
523    ) -> Result<&'a str, WebauthnError> {
524        match origin {
525            Origin::Web(url) => self.assert_web_rp_id(url, rp_id),
526            #[cfg(feature = "android-asset-validation")]
527            Origin::Android(unverified) => self.assert_android_rp_id(unverified, rp_id),
528        }
529    }
530
531    fn assert_web_rp_id<'a>(
532        &self,
533        origin: &'a Url,
534        rp_id: Option<&'a str>,
535    ) -> Result<&'a str, WebauthnError> {
536        let mut effective_domain = origin.domain().ok_or(WebauthnError::OriginMissingDomain)?;
537
538        if let Some(rp_id) = rp_id {
539            if !effective_domain.ends_with(rp_id) {
540                return Err(WebauthnError::OriginRpMissmatch);
541            }
542
543            effective_domain = rp_id;
544        }
545
546        // Guard against local host and assert rp_id is not part of the public suffix list
547        if let ControlFlow::Break(res) = self.assert_valid_rp_id(effective_domain) {
548            return res;
549        }
550
551        // Make sure origin uses https://
552        if !(origin.scheme().eq_ignore_ascii_case("https")) {
553            return Err(WebauthnError::UnprotectedOrigin);
554        }
555
556        Ok(effective_domain)
557    }
558
559    fn assert_valid_rp_id<'a>(
560        &self,
561        rp_id: &'a str,
562    ) -> ControlFlow<Result<&'a str, WebauthnError>, ()> {
563        // guard against localhost effective domain, return early
564        if rp_id == "localhost" {
565            return if self.allows_insecure_localhost {
566                ControlFlow::Break(Ok(rp_id))
567            } else {
568                ControlFlow::Break(Err(WebauthnError::InsecureLocalhostNotAllowed))
569            };
570        }
571
572        // assert rp_id is not part of the public suffix list and is a registerable domain.
573        if decode_host(rp_id)
574            .as_ref()
575            .and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
576            .is_none()
577        {
578            return ControlFlow::Break(Err(WebauthnError::InvalidRpId));
579        }
580
581        ControlFlow::Continue(())
582    }
583
584    /// Parse a given Relying Party ID and assert that it is valid to act as such.
585    ///
586    /// This method is only to assert that an RP ID passes the required checks.
587    /// In order to ensure that a request's origin is in accordance with it's claimed RP ID,
588    /// [`Self::assert_domain`] should be used.
589    ///
590    /// There are several checks that an RP ID must pass:
591    /// 1. An RP ID set to `localhost` is only allowed when explicitly enabled with [`Self::allows_insecure_localhost`].
592    /// 1. An RP ID must not be part of the [public suffix list],
593    ///    since that would allow it to act as a credential for unrelated services by other entities.
594    pub fn is_valid_rp_id(&self, rp_id: &str) -> bool {
595        match self.assert_valid_rp_id(rp_id) {
596            ControlFlow::Continue(_) | ControlFlow::Break(Ok(_)) => true,
597            ControlFlow::Break(Err(_)) => false,
598        }
599    }
600
601    #[cfg(feature = "android-asset-validation")]
602    fn assert_android_rp_id<'a>(
603        &self,
604        target_link: &'a UnverifiedAssetLink,
605        rp_id: Option<&'a str>,
606    ) -> Result<&'a str, WebauthnError> {
607        let mut effective_rp_id = target_link.host();
608
609        if let Some(rp_id) = rp_id {
610            // subset from assert_web_rp_id
611            if !effective_rp_id.ends_with(rp_id) {
612                return Err(WebauthnError::OriginRpMissmatch);
613            }
614            effective_rp_id = rp_id;
615        }
616
617        if decode_host(effective_rp_id)
618            .as_ref()
619            .and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
620            .is_none()
621        {
622            return Err(WebauthnError::InvalidRpId);
623        }
624
625        // TODO: Find an ergonomic and caching friendly way to fetch the remote
626        // assetlinks and validate them here.
627        // https://github.com/1Password/passkey-rs/issues/13
628
629        Ok(effective_rp_id)
630    }
631}
632
633#[cfg(test)]
634mod test {
635    use passkey_authenticator::{Authenticator, MemoryStore, MockUserValidationMethod};
636    use passkey_types::{
637        ctap2,
638        webauthn::{
639            AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
640        },
641    };
642
643    use crate::Client;
644
645    #[test]
646    fn map_rk_maps_criteria_to_rk_bool() {
647        #[derive(Debug)]
648        struct TestCase {
649            resident_key: Option<ResidentKeyRequirement>,
650            require_resident_key: bool,
651            expected_rk: bool,
652        }
653
654        let test_cases = vec![
655            // require_resident_key fallbacks
656            TestCase {
657                resident_key: None,
658                require_resident_key: false,
659                expected_rk: false,
660            },
661            TestCase {
662                resident_key: None,
663                require_resident_key: true,
664                expected_rk: true,
665            },
666            // resident_key values
667            TestCase {
668                resident_key: Some(ResidentKeyRequirement::Discouraged),
669                require_resident_key: false,
670                expected_rk: false,
671            },
672            TestCase {
673                resident_key: Some(ResidentKeyRequirement::Preferred),
674                require_resident_key: false,
675                expected_rk: true,
676            },
677            TestCase {
678                resident_key: Some(ResidentKeyRequirement::Required),
679                require_resident_key: false,
680                expected_rk: true,
681            },
682            // resident_key overrides require_resident_key
683            TestCase {
684                resident_key: Some(ResidentKeyRequirement::Discouraged),
685                require_resident_key: true,
686                expected_rk: false,
687            },
688        ];
689
690        for test_case in test_cases {
691            let criteria = AuthenticatorSelectionCriteria {
692                resident_key: test_case.resident_key,
693                require_resident_key: test_case.require_resident_key,
694                user_verification: UserVerificationRequirement::Discouraged,
695                authenticator_attachment: None,
696            };
697            let auth_info = ctap2::get_info::Response {
698                versions: vec![],
699                extensions: None,
700                aaguid: ctap2::Aaguid::new_empty(),
701                options: Some(ctap2::get_info::Options {
702                    rk: true,
703                    uv: Some(true),
704                    up: true,
705                    plat: true,
706                    client_pin: None,
707                }),
708                max_msg_size: None,
709                pin_protocols: None,
710                transports: None,
711            };
712            let client = Client::new(Authenticator::new(
713                ctap2::Aaguid::new_empty(),
714                MemoryStore::new(),
715                MockUserValidationMethod::verified_user(0),
716            ));
717
718            let result = client.map_rk(&Some(criteria), &auth_info);
719
720            assert_eq!(result, test_case.expected_rk, "{:?}", test_case);
721        }
722    }
723}