Skip to main content

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, marker::PhantomData};
21
22use passkey_authenticator::{Authenticator, CredentialStore, UserValidationMethod};
23use passkey_crypto::{
24    CryptoBackend, PublicKeyT, SecretKeyT, coset::Algorithm, hash::Sha256Backend, iana::EnumI64,
25};
26use passkey_types::{
27    Bytes, Passkey, ctap2, encoding,
28    webauthn::{
29        self, AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
30    },
31};
32use serde::Serialize;
33#[cfg(feature = "typeshare")]
34use typeshare::typeshare;
35use url::Url;
36
37mod extensions;
38mod rp_id_verifier;
39
40#[cfg(all(feature = "linux", target_os = "linux"))]
41pub mod linux;
42#[cfg(all(feature = "windows", target_os = "windows"))]
43pub mod windows;
44
45pub use self::rp_id_verifier::{Fetcher, RelatedOriginResponse, RpIdVerifier};
46
47#[cfg(feature = "android-asset-validation")]
48pub use self::rp_id_verifier::android::{UnverifiedAssetLink, ValidationError, valid_fingerprint};
49
50#[cfg(test)]
51mod tests;
52
53#[cfg_attr(feature = "typeshare", typeshare)]
54#[derive(Debug, serde::Serialize, PartialEq, Eq)]
55#[serde(tag = "type", content = "content")]
56#[non_exhaustive]
57/// Errors produced by Webauthn Operations.
58pub enum WebauthnError {
59    /// A credential ID can be a maximum of 1023 bytes.
60    CredentialIdTooLong,
61    /// The request origin was missing a proper domain part.
62    OriginMissingDomain,
63    /// The request origin is not a sub-domain of the RP ID.
64    OriginRpMissmatch,
65    /// The origin of the request does not use HTTPS.
66    UnprotectedOrigin,
67    /// Origin was set to localhost but allows_insecure_localhost was not set.
68    InsecureLocalhostNotAllowed,
69    /// No credential was found
70    CredentialNotFound,
71    /// The RP ID is invalid.
72    InvalidRpId,
73    /// Internal authenticator error whose value represents a `ctap2::StatusCode`
74    AuthenticatorError(u8),
75    /// The operation is not supported.
76    NotSupportedError,
77    /// The string did not match the expected pattern.
78    SyntaxError,
79    /// The input failed validation
80    ValidationError {
81        /// Context for the validation error
82        context: &'static str,
83    },
84    /// The given RpId has possibly rolled out related origins
85    RequiresRelatedOriginsSupport,
86    /// An error when fetching remote resources
87    FetcherError,
88    /// A redirect that was not allowed occured
89    RedirectError,
90    /// Related Origins endpoint contains a number of labels exceeding the max limit
91    ExceedsMaxLabelLimit,
92    /// JSON serialization error
93    SerializationError,
94    /// The operation timed out
95    TimeoutError,
96}
97
98const MAX_CREDENTIAL_ID_LENGTH: usize = 1023;
99
100// Library sets tokio import as optional, timeouts rely on it
101// Not sure if tokio should still be optional but guarding for now to avoid breaking changes
102// https://www.w3.org/TR/webauthn-3/#recommended-range-and-default-for-a-webauthn-ceremony-timeout
103#[cfg(feature = "tokio")]
104const MIN_TIMEOUT: u32 = 300_000;
105#[cfg(feature = "tokio")]
106const MAX_TIMEOUT: u32 = 600_000;
107
108impl WebauthnError {
109    /// Was the error a vendor error?
110    pub fn is_vendor_error(&self) -> bool {
111        matches!(self, WebauthnError::AuthenticatorError(ctap_error) if ctap2::VendorError::try_from(*ctap_error).is_ok())
112    }
113}
114
115impl From<ctap2::StatusCode> for WebauthnError {
116    fn from(value: ctap2::StatusCode) -> Self {
117        match value {
118            ctap2::StatusCode::Known(ctap2::Ctap2Error::NoCredentials) => {
119                WebauthnError::CredentialNotFound
120            }
121            ctap2code => WebauthnError::AuthenticatorError(ctap2code.into()),
122        }
123    }
124}
125
126/// Build the `CollectedClientData`, JSON-serialize it, and derive the client-data
127/// hash that will be signed by the authenticator. Shared by both `Client` and
128/// `LinuxClient` because the client-data step of a WebAuthn ceremony is
129/// authenticator-independent.
130pub(crate) fn build_client_data<D, E, C>(
131    client_data: &D,
132    ty: webauthn::ClientDataType,
133    challenge: &[u8],
134    origin: &Origin<'_>,
135    _crypto: PhantomData<C>,
136) -> Result<(String, Vec<u8>), WebauthnError>
137where
138    D: ClientData<E>,
139    E: Serialize + Clone,
140    C: CryptoBackend,
141{
142    let collected_client_data = webauthn::CollectedClientData::<E> {
143        ty,
144        challenge: encoding::base64url(challenge),
145        origin: origin.to_string(),
146        cross_origin: None,
147        extra_data: client_data.extra_client_data(),
148        unknown_keys: Default::default(),
149    };
150    let client_data_json = serde_json::to_string(&collected_client_data)
151        .map_err(|_| WebauthnError::SerializationError)?;
152    let client_data_hash = client_data
153        .client_data_hash()
154        .filter(|v| !v.is_empty())
155        .unwrap_or_else(|| C::Sha256::sha256(client_data_json.as_bytes()).to_vec());
156    Ok((client_data_json, client_data_hash))
157}
158
159/// Resolve the WebAuthn resident-key requirement + authenticator capabilities
160/// into the CTAP2 `rk` option.
161pub(crate) fn map_rk(
162    criteria: Option<&AuthenticatorSelectionCriteria>,
163    auth_info: &ctap2::get_info::Response,
164) -> bool {
165    let supports_rk = auth_info.options.as_ref().is_some_and(|o| o.rk);
166
167    match criteria.unwrap_or(&Default::default()) {
168        // If pkOptions.authenticatorSelection.residentKey is present and set to required.
169        AuthenticatorSelectionCriteria {
170            resident_key: Some(ResidentKeyRequirement::Required),
171            ..
172        } => true,
173
174        // If pkOptions.authenticatorSelection.residentKey is present and set to preferred,
175        // require an rk only if the authenticator advertises support for it.
176        AuthenticatorSelectionCriteria {
177            resident_key: Some(ResidentKeyRequirement::Preferred),
178            ..
179        } => supports_rk,
180
181        // If pkOptions.authenticatorSelection.residentKey is present and set to discouraged.
182        AuthenticatorSelectionCriteria {
183            resident_key: Some(ResidentKeyRequirement::Discouraged),
184            ..
185        } => false,
186
187        // Fall back to the legacy `requireResidentKey` when `residentKey` is absent.
188        AuthenticatorSelectionCriteria {
189            resident_key: None,
190            require_resident_key,
191            ..
192        } => *require_resident_key,
193    }
194}
195
196/// Map [`UserVerificationRequirement`] to the CTAP2 `uv` option, deferring to
197/// the client's preference for `Preferred`.
198pub(crate) fn ctap_uv_option(
199    requirement: UserVerificationRequirement,
200    uv_when_preferred: bool,
201) -> bool {
202    match requirement {
203        UserVerificationRequirement::Discouraged => false,
204        UserVerificationRequirement::Required => true,
205        UserVerificationRequirement::Preferred => uv_when_preferred,
206    }
207}
208
209/// The origin of a WebAuthn request.
210pub enum Origin<'a> {
211    /// A Url, meant for a request in the web browser.
212    Web(Cow<'a, Url>),
213    /// An android digital asset fingerprint.
214    /// Meant for a request coming from an android application.
215    #[cfg(feature = "android-asset-validation")]
216    Android(UnverifiedAssetLink<'a>),
217}
218
219impl From<Url> for Origin<'_> {
220    fn from(value: Url) -> Self {
221        Origin::Web(Cow::Owned(value))
222    }
223}
224
225impl<'a> From<&'a Url> for Origin<'a> {
226    fn from(value: &'a Url) -> Self {
227        Origin::Web(Cow::Borrowed(value))
228    }
229}
230
231impl Display for Origin<'_> {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        match self {
234            Origin::Web(url) => write!(f, "{}", url.as_str().trim_end_matches('/')),
235            #[cfg(feature = "android-asset-validation")]
236            Origin::Android(target_link) => {
237                write!(
238                    f,
239                    "android:apk-key-hash:{}",
240                    encoding::base64url(target_link.sha256_cert_fingerprint())
241                )
242            }
243        }
244    }
245}
246
247/// A `Client` represents a Webauthn client. Users of this struct should supply a
248/// [`CredentialStore`], a [`UserValidationMethod`] and, optionally, an implementation of
249/// [`public_suffix::EffectiveTLDProvider`].
250///
251/// The `tld_provider` is used to verify effective Top-Level Domains for request origins presented
252/// to the client. Most applications can use the `new()` function, which creates a `Client` with a
253/// default provider implementation. Use `new_with_custom_tld_provider()` to provide a custom
254/// `EffectiveTLDProvider` if your application needs to interpret eTLDs differently from the Mozilla
255/// Public Suffix List.
256pub struct Client<S, U, C, P, F>
257where
258    S: CredentialStore + Sync,
259    U: UserValidationMethod + Sync,
260    C: CryptoBackend,
261    P: public_suffix::EffectiveTLDProvider + Sync + 'static,
262{
263    authenticator: Authenticator<S, U, C>,
264    rp_id_verifier: RpIdVerifier<P, F>,
265    /// When the RP sends [`UserVerificationRequirement::Preferred`], whether to set CTAP `uv` to true.
266    uv_when_preferred: bool,
267    crypto: PhantomData<C>,
268}
269
270impl<S, U, C> Client<S, U, C, public_suffix::PublicSuffixList, ()>
271where
272    S: CredentialStore + Sync,
273    U: UserValidationMethod + Sync,
274    C: CryptoBackend + Sync,
275    Passkey: TryFrom<<S as CredentialStore>::PasskeyItem>,
276{
277    /// Create a `Client` with a given `Authenticator` that uses the default
278    /// TLD verifier provided by `[public_suffix]`.
279    pub fn new(authenticator: Authenticator<S, U, C>) -> Self {
280        Self {
281            authenticator,
282            rp_id_verifier: RpIdVerifier::new(public_suffix::DEFAULT_PROVIDER, None),
283            uv_when_preferred: true,
284            crypto: PhantomData,
285        }
286    }
287}
288
289impl<S, U, C, P, F> Client<S, U, C, P, F>
290where
291    S: CredentialStore + Sync,
292    U: UserValidationMethod<PasskeyItem = <S as CredentialStore>::PasskeyItem> + Sync,
293    C: CryptoBackend,
294    P: public_suffix::EffectiveTLDProvider + Sync + 'static,
295    F: Fetcher + Sync,
296{
297    /// Create a `Client` with a given `Authenticator` and a custom TLD provider
298    /// that implements `[public_suffix::EffectiveTLDProvider]`.
299    pub fn new_with_custom_tld_provider(
300        authenticator: Authenticator<S, U, C>,
301        custom_provider: P,
302        fetcher: Option<F>,
303    ) -> Self {
304        Self {
305            authenticator,
306            rp_id_verifier: RpIdVerifier::new(custom_provider, fetcher),
307            uv_when_preferred: true,
308            crypto: PhantomData,
309        }
310    }
311
312    /// Allows the internal [RpIdVerifier] to pass through localhost requests.
313    pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
314        self.rp_id_verifier = self.rp_id_verifier.allows_insecure_localhost(is_allowed);
315        self
316    }
317
318    /// Read access to the Client's `Authenticator`.
319    pub fn authenticator(&self) -> &Authenticator<S, U, C> {
320        &self.authenticator
321    }
322
323    /// Write access to the Client's `Authenticator`.
324    pub fn authenticator_mut(&mut self) -> &mut Authenticator<S, U, C> {
325        &mut self.authenticator
326    }
327
328    /// Set the client's preferred user verification setting.
329    /// This setting is used when the RP requirement is [`UserVerificationRequirement::Preferred`] to determine if uv should be set to true.
330    pub fn user_verification_when_preferred(mut self, enabled: bool) -> Self {
331        self.uv_when_preferred = enabled;
332        self
333    }
334
335    /// Register a webauthn `request` from the given `origin`.
336    ///
337    /// Returns either a [`webauthn::CreatedPublicKeyCredential`] on success or some [`WebauthnError`]
338    pub async fn register<D: ClientData<E>, E: Serialize + Clone>(
339        &mut self,
340        origin: impl Into<Origin<'_>>,
341        request: webauthn::CredentialCreationOptions,
342        client_data: D,
343    ) -> Result<webauthn::CreatedPublicKeyCredential, WebauthnError> {
344        let origin = origin.into();
345
346        // extract inner value of request as there is nothing else of value directly in CredentialCreationOptions
347        let request = request.public_key;
348        let auth_info = self.authenticator.get_info().await;
349
350        let pub_key_cred_params = if request.pub_key_cred_params.is_empty() {
351            webauthn::PublicKeyCredentialParameters::default_algorithms()
352        } else {
353            request.pub_key_cred_params
354        };
355
356        // Build the timeout for the request, clamping to a reasonable range if the RP provided one,
357        // or using our defaults if not.
358        #[cfg(feature = "tokio")]
359        let timeout = std::time::Duration::from_millis(
360            request
361                .timeout
362                .map(|t| t.clamp(MIN_TIMEOUT, MAX_TIMEOUT))
363                .unwrap_or(MIN_TIMEOUT)
364                .into(),
365        ); // Treat MIN_TIMEOUT as default as per webauthn-3 spec
366
367        let rp_id = self
368            .rp_id_verifier
369            .assert_domain(&origin, request.rp.id.as_deref())
370            .await?;
371
372        let (client_data_json, client_data_json_hash) = build_client_data(
373            &client_data,
374            webauthn::ClientDataType::Create,
375            &request.challenge,
376            &origin,
377            PhantomData::<C>,
378        )?;
379
380        let extension_request = request.extensions.and_then(|e| e.zip_contents());
381
382        let ctap_extensions = self.registration_extension_ctap2_input(
383            extension_request.as_ref(),
384            auth_info.extensions.as_deref().unwrap_or_default(),
385        )?;
386
387        let rk = map_rk(request.authenticator_selection.as_ref(), &auth_info);
388        let uv_requirement = request
389            .authenticator_selection
390            .as_ref()
391            .map(|s| s.user_verification)
392            .unwrap_or_default();
393        let uv = ctap_uv_option(uv_requirement, self.uv_when_preferred);
394
395        let make_credential_request = ctap2::make_credential::Request {
396            client_data_hash: client_data_json_hash.into(),
397            rp: ctap2::make_credential::PublicKeyCredentialRpEntity {
398                id: rp_id.to_owned(),
399                name: Some(request.rp.name),
400            },
401            user: request.user,
402            pub_key_cred_params,
403            exclude_list: request.exclude_credentials,
404            extensions: ctap_extensions,
405            options: ctap2::make_credential::Options { rk, up: true, uv },
406            pin_auth: None,
407            pin_protocol: None,
408        };
409
410        #[cfg(feature = "tokio")]
411        let ctap2_response = tokio::time::timeout(
412            timeout,
413            self.authenticator.make_credential(make_credential_request),
414        )
415        .await
416        .map_err(|_| WebauthnError::TimeoutError)?
417        .map_err(|sc| WebauthnError::AuthenticatorError(sc.into()))?;
418
419        #[cfg(not(feature = "tokio"))]
420        let ctap2_response = self
421            .authenticator
422            .make_credential(make_credential_request)
423            .await
424            .map_err(|sc| WebauthnError::AuthenticatorError(sc.into()))?;
425
426        // SAFETY: this unwrap is safe because the ctap2_response was just created in make_credential()
427        // above, which currently sets auth_data.attested_credential_data unconditionally.
428        // If this fails, it's a programmer error in that the postconditions of make_credential will
429        // have changed.
430        let credential_id = ctap2_response
431            .auth_data
432            .attested_credential_data
433            .as_ref()
434            .unwrap();
435
436        // Validate returned credential ID length to comply with new credential registration ceremony spec
437        // https://w3c.github.io/webauthn/#sctn-registering-a-new-credential
438        if credential_id.credential_id().len() > MAX_CREDENTIAL_ID_LENGTH {
439            return Err(WebauthnError::CredentialIdTooLong);
440        }
441
442        let alg = match credential_id.key.alg.as_ref().unwrap() {
443            Algorithm::PrivateUse(val) => *val,
444            Algorithm::Assigned(alg) => alg.to_i64(),
445            Algorithm::Text(_) => {
446                unreachable!()
447            }
448        };
449        let public_key = Some(
450            <<C as CryptoBackend>::SecretKey as SecretKeyT>::PublicKey::der_from_cose_key(
451                &credential_id.key,
452            )
453            .map(Bytes::from)
454            .map_err(|e| WebauthnError::AuthenticatorError(ctap2::Ctap2Error::from(e).into()))?,
455        );
456
457        let attestation_object = ctap2_response.as_webauthn_bytes();
458        let store_info = self.authenticator.store().get_info().await;
459        let client_extension_results = self.registration_extension_outputs(
460            extension_request.as_ref(),
461            store_info,
462            rk,
463            ctap2_response.unsigned_extension_outputs,
464        );
465
466        let response = webauthn::CreatedPublicKeyCredential {
467            id: encoding::base64url(credential_id.credential_id()),
468            raw_id: credential_id.credential_id().to_vec().into(),
469            ty: webauthn::PublicKeyCredentialType::PublicKey,
470            response: webauthn::AuthenticatorAttestationResponse {
471                client_data_json: Vec::from(client_data_json).into(),
472                authenticator_data: ctap2_response.auth_data.to_vec().into(),
473                public_key,
474                public_key_algorithm: alg,
475                attestation_object,
476                transports: auth_info.transports,
477            },
478            authenticator_attachment: Some(self.authenticator().attachment_type()),
479            client_extension_results,
480        };
481
482        Ok(response)
483    }
484
485    /// Authenticate a Webauthn request.
486    ///
487    /// Returns either an [`webauthn::AuthenticatedPublicKeyCredential`] on success or some [`WebauthnError`].
488    pub async fn authenticate<D: ClientData<E>, E: Serialize + Clone>(
489        &mut self,
490        origin: impl Into<Origin<'_>>,
491        request: webauthn::CredentialRequestOptions,
492        client_data: D,
493    ) -> Result<webauthn::AuthenticatedPublicKeyCredential, WebauthnError> {
494        let origin = origin.into();
495
496        // extract inner value of request as there is nothing else of value directly in CredentialRequestOptions
497        let request = request.public_key;
498        let auth_info = self.authenticator().get_info().await;
499
500        #[cfg(feature = "tokio")]
501        let timeout = std::time::Duration::from_millis(
502            request
503                .timeout
504                .map(|t| t.clamp(MIN_TIMEOUT, MAX_TIMEOUT))
505                .unwrap_or(MIN_TIMEOUT)
506                .into(),
507        ); // Treat MIN_TIMEOUT as default as per webauthn-3 spec
508
509        let rp_id = self
510            .rp_id_verifier
511            .assert_domain(&origin, request.rp_id.as_deref())
512            .await?;
513
514        let (client_data_json, client_data_json_hash) = build_client_data(
515            &client_data,
516            webauthn::ClientDataType::Get,
517            &request.challenge,
518            &origin,
519            PhantomData::<C>,
520        )?;
521
522        let ctap_extensions = self.auth_extension_ctap2_input(
523            &request,
524            auth_info.extensions.unwrap_or_default().as_slice(),
525        )?;
526        let uv = ctap_uv_option(request.user_verification, self.uv_when_preferred);
527
528        let get_assertion_request = ctap2::get_assertion::Request {
529            rp_id: rp_id.to_owned(),
530            client_data_hash: client_data_json_hash.into(),
531            allow_list: request.allow_credentials,
532            extensions: ctap_extensions,
533            options: ctap2::get_assertion::Options { up: true, uv },
534            pin_auth: None,
535            pin_protocol: None,
536        };
537
538        #[cfg(feature = "tokio")]
539        let ctap2_response = tokio::time::timeout(
540            timeout,
541            self.authenticator.get_assertion(get_assertion_request),
542        )
543        .await
544        .map_err(|_| WebauthnError::TimeoutError)?
545        .map_err(Into::<WebauthnError>::into)?;
546
547        #[cfg(not(feature = "tokio"))]
548        let ctap2_response = self
549            .authenticator
550            .get_assertion(get_assertion_request)
551            .await
552            .map_err(Into::<WebauthnError>::into)?;
553
554        let client_extension_results =
555            self.auth_extension_outputs(ctap2_response.unsigned_extension_outputs);
556
557        // SAFETY: This unwrap is safe because ctap2_response was created immedately
558        // above and the postcondition of that function is that response.credential
559        // will yield a credential. If none was found, we will have already returned
560        // a WebauthnError::CredentialNotFound error from map_err in that line.
561        let credential_id_bytes = ctap2_response.credential.unwrap().id;
562        Ok(webauthn::AuthenticatedPublicKeyCredential {
563            id: encoding::base64url(&credential_id_bytes),
564            raw_id: credential_id_bytes.to_vec().into(),
565            ty: webauthn::PublicKeyCredentialType::PublicKey,
566            response: webauthn::AuthenticatorAssertionResponse {
567                client_data_json: Vec::from(client_data_json).into(),
568                authenticator_data: ctap2_response.auth_data.to_vec().into(),
569                signature: ctap2_response.signature,
570                user_handle: ctap2_response.user.map(|user| user.id),
571                attestation_object: None,
572            },
573            authenticator_attachment: Some(self.authenticator().attachment_type()),
574            client_extension_results,
575        })
576    }
577}