Skip to content

Commit

Permalink
Handle validation and mapping of PRF inputs to client
Browse files Browse the repository at this point in the history
  • Loading branch information
Vogeltak authored and Progdrasil committed Jul 11, 2024
1 parent a7a1ef9 commit cfa6d52
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 17 deletions.
22 changes: 14 additions & 8 deletions passkey-client/src/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@
//!
//! The currently supported extensions are:
//! * [`Credential Properties`][credprops]
//! * [`Pseudo-random function`][prf]
//!
//! [ctap2]: https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-errata-20220621.html#sctn-defined-extensions
//! [webauthn]: https://w3c.github.io/webauthn/#sctn-defined-extensions
//! [credprops]: https://w3c.github.io/webauthn/#sctn-authenticator-credential-properties-extension
//! [prf]: https://w3c.github.io/webauthn/#prf-extension
use passkey_authenticator::{CredentialStore, UserValidationMethod};
use passkey_types::{
ctap2::{get_assertion, make_credential},
ctap2::{get_assertion, get_info, make_credential},
webauthn::{
AuthenticationExtensionsClientInputs, AuthenticationExtensionsClientOutputs,
CredentialPropertiesOutput,
CredentialPropertiesOutput, PublicKeyCredentialRequestOptions,
},
Passkey,
};

use crate::Client;
use crate::{Client, WebauthnError};

mod prf;

impl<S, U, P> Client<S, U, P>
where
Expand All @@ -32,8 +36,9 @@ where
pub(super) fn registration_extension_ctap2_input(
&self,
request: Option<&AuthenticationExtensionsClientInputs>,
) -> Option<make_credential::ExtensionInputs> {
request.map(|_| make_credential::ExtensionInputs::default())
supported_extensions: &[get_info::Extension],
) -> Result<Option<make_credential::ExtensionInputs>, WebauthnError> {
prf::registration_prf_to_ctap2_input(request, supported_extensions)
}

/// Build the extension outputs for the WebAuthn client in a registration request.
Expand Down Expand Up @@ -61,8 +66,9 @@ where
/// during an authentication request.
pub(super) fn auth_extension_ctap2_input(
&self,
request: Option<&AuthenticationExtensionsClientInputs>,
) -> Option<get_assertion::ExtensionInputs> {
request.map(|_| get_assertion::ExtensionInputs::default())
request: &PublicKeyCredentialRequestOptions,
supported_extensions: &[get_info::Extension],
) -> Result<Option<get_assertion::ExtensionInputs>, WebauthnError> {
prf::auth_prf_to_ctap2_input(request, supported_extensions)
}
}
199 changes: 199 additions & 0 deletions passkey-client/src/extensions/prf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
use std::collections::HashMap;

use passkey_types::{
crypto::sha256,
ctap2::{
extensions::{AuthenticatorPrfInputs, AuthenticatorPrfValues},
get_assertion, get_info, make_credential,
},
webauthn::{
AuthenticationExtensionsClientInputs, AuthenticationExtensionsPrfInputs,
AuthenticationExtensionsPrfValues, PublicKeyCredentialDescriptor,
PublicKeyCredentialRequestOptions,
},
Bytes,
};

use crate::WebauthnError;

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

pub(super) fn registration_prf_to_ctap2_input(
request: Option<&AuthenticationExtensionsClientInputs>,
supported_extensions: &[get_info::Extension],
) -> Result<Option<make_credential::ExtensionInputs>> {
make_ctap_extension(request.and_then(|r| r.prf.as_ref()), supported_extensions)
}

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,
) -> Result<AuthenticatorPrfValues> {
let (first, second) = {
let salt1 = make_salt(&eval.first);
let salt2 = eval.second.as_ref().map(make_salt);
(salt1, salt2)
};

Ok(AuthenticatorPrfValues { first, second })
}

fn make_ctap_extension(
prf: Option<&AuthenticationExtensionsPrfInputs>,
supported_extensions: &[get_info::Extension],
) -> 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 = 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(convert_eval_to_ctap)
.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>> {
get_ctap_extension(
request.allow_credentials.as_deref(),
request.extensions.as_ref().and_then(|ext| ext.prf.as_ref()),
supported_extensions,
)
}

fn get_ctap_extension(
allow_credentials: Option<&[PublicKeyCredentialDescriptor]>,
prf_input: Option<&AuthenticationExtensionsPrfInputs>,
supported_extensions: &[get_info::Extension],
) -> 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)| convert_eval_to_ctap(values).map(|v| (k, v)))
.collect::<Result<HashMap<_, _>>>()
})
.transpose()?;

let eval = prf_input
.and_then(|prf| prf.eval.as_ref().map(convert_eval_to_ctap))
.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] {
sha256(
&b"WebAuthn PRF"
.iter()
.chain(std::iter::once(&0x0))
.chain(prf_value)
.cloned()
.collect::<Vec<_>>(),
)
}
15 changes: 13 additions & 2 deletions passkey-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ pub enum WebauthnError {
InvalidRpId,
/// Internal authenticator error whose value represents a `ctap2::StatusCode`
AuthenticatorError(u8),
/// The operation is not supported.
NotSupportedError,
/// The string did not match the expected pattern.
SyntaxError,
}

impl WebauthnError {
Expand Down Expand Up @@ -254,7 +258,10 @@ where

let extension_request = request.extensions.and_then(|e| e.zip_contents());

let ctap_extensions = self.registration_extension_ctap2_input(extension_request.as_ref());
let ctap_extensions = self.registration_extension_ctap2_input(
extension_request.as_ref(),
auth_info.extensions.as_deref().unwrap_or_default(),
)?;

let rk = self.map_rk(&request.authenticator_selection, &auth_info);
let uv = request.authenticator_selection.map(|s| s.user_verification)
Expand Down Expand Up @@ -352,6 +359,7 @@ where

// extract inner value of request as there is nothing else of value directly in CredentialRequestOptions
let request = request.public_key;
let auth_info = self.authenticator().get_info();

// TODO: Handle given timeout here, If the value is not within what we consider a reasonable range
// override to our default
Expand All @@ -377,7 +385,10 @@ where
let client_data_json_hash =
client_data_hash.unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec());

let ctap_extensions = self.auth_extension_ctap2_input(request.extensions.as_ref());
let ctap_extensions = self.auth_extension_ctap2_input(
&request,
auth_info.extensions.unwrap_or_default().as_slice(),
)?;

let ctap2_response = self
.authenticator
Expand Down
41 changes: 34 additions & 7 deletions passkey-types/src/ctap2/get_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ serde_workaround! {

/// List of supported extensions. (Optional)
#[serde(rename = 0x02, default, skip_serializing_if = Option::is_none)]
pub extensions: Option<Vec<Cow<'static, str>>>,
pub extensions: Option<Vec<Extension>>,

/// The claimed AAGUID. 16 bytes in length
#[serde(rename = 0x03)]
Expand Down Expand Up @@ -128,16 +128,37 @@ impl Default for Options {
}
}

/// CTAP extensions supported by the authenticator
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum Extension {
/// The authenticator supports the [`hmac-secret`] extension
///
/// [`hmac-secret`]: https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-errata-20220621.html#sctn-hmac-secret-extension
#[serde(rename = "hmac-secret")]
HmacSecret,
/// The authenticator supports the unsigned [`prf`] extension
///
/// [`prf`]: https://w3c.github.io/webauthn/#prf-extension
#[serde(rename = "prf")]
Prf,
/// The authenticator supports an extensions which is currently unsupported by this library.
#[serde(untagged)]
Unknown(String),
}

#[cfg(test)]
mod tests {
use ciborium::cbor;

use super::{Aaguid, AuthenticatorTransport, Options, Response};
use super::{Aaguid, AuthenticatorTransport, Extension, Options, Response};
#[test]
fn serialization_round_trip() {
let expected = Response {
versions: vec!["FIDO_2_0".into()],
extensions: None,
extensions: Some(vec![
Extension::HmacSecret,
Extension::Unknown("credProtect".into()),
]),
aaguid: Aaguid::new_empty(),
options: Some(Options {
rk: true,
Expand Down Expand Up @@ -166,7 +187,10 @@ mod tests {
let aaguid = Aaguid::new_empty();
let input = Response {
versions: vec!["FIDO_2_0".into()],
extensions: None,
extensions: Some(vec![
Extension::HmacSecret,
Extension::Unknown("credProtect".into()),
]),
aaguid,
options: Some(Options {
rk: true,
Expand All @@ -189,7 +213,7 @@ mod tests {

let expected = cbor!({
0x01 => vec!["FIDO_2_0"],
// extensions should be skiped
0x02 => vec!["hmac-secret", "credProtect"],
0x03 => ciborium::value::Value::Bytes([0;16].into()),
0x04 => {
"plat" => false,
Expand All @@ -211,7 +235,7 @@ mod tests {
fn unknown_gets_ignored() {
let input = cbor!({
0x01 => vec!["FIDO_2_0"],
// extensions should be skiped
0x02 => vec!["hmac-secret", "credProtect"],
0x03 => ciborium::value::Value::Bytes([0;16].into()),
0x04 => {
"plat" => false,
Expand All @@ -234,7 +258,10 @@ mod tests {

let expected = Response {
versions: vec!["FIDO_2_0".into()],
extensions: None,
extensions: Some(vec![
Extension::HmacSecret,
Extension::Unknown("credProtect".into()),
]),
aaguid: Aaguid::new_empty(),
options: Some(Options {
rk: true,
Expand Down

0 comments on commit cfa6d52

Please sign in to comment.