passkey-client 0.6.0

Webauthn client in Rust.
Documentation
use std::collections::HashMap;

use passkey_authenticator::{CredentialStore, UserValidationMethod};
use passkey_crypto::{CryptoBackend, hash::Sha256Backend};
use passkey_types::{
    Bytes,
    ctap2::{
        extensions::{AuthenticatorPrfInputs, AuthenticatorPrfValues},
        get_assertion, get_info, make_credential,
    },
    webauthn::{
        AuthenticationExtensionsClientInputs, AuthenticationExtensionsPrfInputs,
        AuthenticationExtensionsPrfValues, PublicKeyCredentialDescriptor,
        PublicKeyCredentialRequestOptions,
    },
};

use crate::{Client, WebauthnError};

type Result<T> = ::std::result::Result<T, WebauthnError>;

impl<S, U, C, P, F> Client<S, U, C, P, F>
where
    S: CredentialStore + Sync,
    U: UserValidationMethod + Sync,
    C: CryptoBackend,
    P: public_suffix::EffectiveTLDProvider + Sync + 'static,
{
    pub(super) fn registration_prf_to_ctap2_input(
        request: Option<&AuthenticationExtensionsClientInputs>,
        supported_extensions: &[get_info::Extension],
    ) -> Result<Option<make_credential::ExtensionInputs>> {
        let maybe_prf = Self::make_ctap_extension(
            request.and_then(|r| r.prf.as_ref()),
            supported_extensions,
            true,
        )?;

        if maybe_prf.is_none() {
            // Then try prfAlreadyHashed
            Self::make_ctap_extension(
                request.and_then(|r| r.prf_already_hashed.as_ref()),
                supported_extensions,
                false,
            )
        } else {
            Ok(maybe_prf)
        }
    }

    fn validate_no_eval_by_cred(
        prf_input: Option<&AuthenticationExtensionsPrfInputs>,
    ) -> Result<Option<&AuthenticationExtensionsPrfInputs>> {
        Ok(match prf_input {
            Some(prf) if prf.eval_by_credential.is_some() => {
                return Err(WebauthnError::NotSupportedError);
            }
            Some(prf) => Some(prf),
            None => None,
        })
    }

    fn convert_eval_to_ctap(
        eval: &AuthenticationExtensionsPrfValues,
        should_hash: bool,
    ) -> Result<AuthenticatorPrfValues> {
        let (first, second) = if should_hash {
            let salt1 = Self::make_salt(&eval.first);
            let salt2 = eval.second.as_ref().map(Self::make_salt);
            (salt1, salt2)
        } else {
            let salt1 = eval
                .first
                .as_slice()
                .try_into()
                .map_err(|_| WebauthnError::ValidationError{
                    context: "PRF eval.first does not have the expected raw HMAC-secret salt length",
                })?;
            let salt2 = eval
                .second
                .as_ref()
                .map(|b| {
                    b.as_slice()
                        .try_into()
                        .map_err(|_| WebauthnError::ValidationError{
                            context: "PRF eval.second does not have the expected raw HMAC-secret salt length",
                        })
                })
                .transpose()?;
            (salt1, salt2)
        };

        Ok(AuthenticatorPrfValues { first, second })
    }

    fn make_ctap_extension(
        prf: Option<&AuthenticationExtensionsPrfInputs>,
        supported_extensions: &[get_info::Extension],
        should_hash: bool,
    ) -> Result<Option<make_credential::ExtensionInputs>> {
        // Check if PRF extension input is provided and process it.
        //
        // Should return a "NotSupportedError" if `evalByCredential` is present
        // in this registration request.
        let prf = Self::validate_no_eval_by_cred(prf)?;

        // Only request hmac-secret extension input if it's enabled on the authenticator and prf is requested.
        let hmac_secret = prf.and_then(|_| {
            supported_extensions
                .contains(&get_info::Extension::HmacSecret)
                .then_some(true)
        });

        let prf = prf
            .filter(|_| supported_extensions.contains(&get_info::Extension::Prf))
            .map(|prf| {
                // Only create prf extension input if it's enabled on the authenticator.
                prf.eval
                    .as_ref()
                    .map(|values| Self::convert_eval_to_ctap(values, should_hash))
                    .transpose()
                    .map(|eval| AuthenticatorPrfInputs {
                        eval,
                        eval_by_credential: None,
                    })
            })
            .transpose()?;

        // If any of the input fields are Some, only then should this pass
        // a Some(ExtensionInputs) to authenticator. Otherwise, it should
        // forward a None.
        Ok(make_credential::ExtensionInputs {
            hmac_secret,
            hmac_secret_mc: None,
            prf,
        }
        .zip_contents())
    }

    pub(super) fn auth_prf_to_ctap2_input(
        request: &PublicKeyCredentialRequestOptions,
        supported_extensions: &[get_info::Extension],
    ) -> Result<Option<get_assertion::ExtensionInputs>> {
        let maybe_prf = Self::get_ctap_extension(
            request.allow_credentials.as_deref(),
            request.extensions.as_ref().and_then(|ext| ext.prf.as_ref()),
            supported_extensions,
            true,
        )?;

        if maybe_prf.is_none() {
            // Then try prfAlreadyHashed
            Self::get_ctap_extension(
                request.allow_credentials.as_deref(),
                request
                    .extensions
                    .as_ref()
                    .and_then(|ext| ext.prf_already_hashed.as_ref()),
                supported_extensions,
                false,
            )
        } else {
            Ok(maybe_prf)
        }
    }

    fn get_ctap_extension(
        allow_credentials: Option<&[PublicKeyCredentialDescriptor]>,
        prf_input: Option<&AuthenticationExtensionsPrfInputs>,
        supported_extensions: &[get_info::Extension],
        should_hash: bool,
    ) -> Result<Option<get_assertion::ExtensionInputs>> {
        // Check if the authenticator supports prf before continuing
        if !supported_extensions.contains(&get_info::Extension::Prf) {
            return Ok(None);
        }
        // Check if PRF extension input is provided and process it.
        let eval_by_credential = prf_input
            .as_ref()
            .and_then(|prf| prf.eval_by_credential.as_ref());

        // If evalByCredential is not empty but allowCredentials is empty,
        // return a DOMException whose name is “NotSupportedError”.
        if eval_by_credential.is_some_and(|record| !record.is_empty())
            && (allow_credentials.is_none()
                || allow_credentials
                    .as_ref()
                    .is_some_and(|allow| allow.is_empty()))
        {
            return Err(WebauthnError::NotSupportedError);
        }

        // Pre-compute the parsed values of the base64url-encoded key s.t. we
        // can speed up our logic later on instead of having the re-compute
        // these values there again.
        // TODO: consolidate with authenticator logic
        let precomputed_eval_cred = eval_by_credential
            .map(|record| {
                record
                    .iter()
                    .map(|(key, val)| {
                        Bytes::try_from(key.as_str())
                            .map(|k| (k, val))
                            .map_err(|_| WebauthnError::SyntaxError)
                    })
                    .collect::<Result<Vec<_>>>()
            })
            .transpose()?;

        // If any key in evalByCredential is the empty string, or is not a valid
        // base64url encoding, or does not equal the id of some element of
        // allowCredentials after performing base64url decoding, then return a
        // DOMException whose name is “SyntaxError”.
        if let Some(record) = precomputed_eval_cred.as_ref() {
            if record.iter().any(|(k_bytes, _)| {
                k_bytes.is_empty()
                    || allow_credentials
                        .as_ref()
                        .is_some_and(|allow| !allow.iter().any(|cred| cred.id == *k_bytes))
            }) {
                return Err(WebauthnError::SyntaxError);
            }
        }

        let new_eval_by_cred = precomputed_eval_cred
            .map(|map| {
                map.into_iter()
                    .map(|(k, values)| {
                        Self::convert_eval_to_ctap(values, should_hash).map(|v| (k, v))
                    })
                    .collect::<Result<HashMap<_, _>>>()
            })
            .transpose()?;

        let eval = prf_input
            .and_then(|prf| {
                prf.eval
                    .as_ref()
                    .map(|prf_values| Self::convert_eval_to_ctap(prf_values, should_hash))
            })
            .transpose()?;

        let prf = prf_input.map(|_| AuthenticatorPrfInputs {
            eval,
            eval_by_credential: new_eval_by_cred,
        });

        let extension_inputs = get_assertion::ExtensionInputs {
            hmac_secret: None,
            prf,
        }
        .zip_contents();

        Ok(extension_inputs)
    }

    // Build the value that's used as salt by the CTAP2 hmac-secret extension.
    fn make_salt(prf_value: &Bytes) -> [u8; 32] {
        C::Sha256::sha256(
            &b"WebAuthn PRF"
                .iter()
                .chain(std::iter::once(&0x0))
                .chain(prf_value)
                .cloned()
                .collect::<Vec<_>>(),
        )
    }
}