Skip to content

Commit

Permalink
Merge pull request #35 from 1Password/handle-prf-input-output-for-client
Browse files Browse the repository at this point in the history
Handle prf input output for client (PRF#3)
  • Loading branch information
Progdrasil authored Jul 25, 2024
2 parents dd56443 + c8413e3 commit 8f23bc8
Show file tree
Hide file tree
Showing 4 changed files with 297 additions and 26 deletions.
52 changes: 40 additions & 12 deletions passkey-client/src/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@
//!
//! 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, DiscoverabilitySupport, StoreInfo, 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 @@ -34,8 +38,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 All @@ -44,6 +49,7 @@ where
request: Option<&AuthenticationExtensionsClientInputs>,
store_info: StoreInfo,
rk: bool,
authenticator_response: Option<make_credential::UnsignedExtensionOutputs>,
) -> AuthenticationExtensionsClientOutputs {
let cred_props_requested = request.and_then(|ext| ext.cred_props) == Some(true);
let cred_props = if cred_props_requested {
Expand All @@ -61,18 +67,40 @@ where
None
};

AuthenticationExtensionsClientOutputs {
cred_props,
prf: None,
}
// Handling the prf extension outputs.
let prf = authenticator_response
.and_then(|ext_out| ext_out.prf)
.map(Into::into);

AuthenticationExtensionsClientOutputs { cred_props, prf }
}

/// Create the extension inputs to be passed to an authenticator over CTAP2
/// 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)
}

/// Build the extension outputs for the WebAuthn client in an authentication request.
pub(super) fn auth_extension_outputs(
&self,
authenticator_response: Option<get_assertion::UnsignedExtensionOutputs>,
) -> AuthenticationExtensionsClientOutputs {
// Handling the prf extension output.
// NOTE: currently very simple because prf is the only
// extension that we support for ctap2. When adding new extensions,
// take care to properly unpack all outputs to Options.
let prf = authenticator_response
.and_then(|ext_out| ext_out.prf)
.map(Into::into);

AuthenticationExtensionsClientOutputs {
prf,
..Default::default()
}
}
}
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<_>>(),
)
}
31 changes: 24 additions & 7 deletions passkey-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ use passkey_types::{
crypto::sha256,
ctap2, encoding,
webauthn::{
self, AuthenticationExtensionsClientOutputs, AuthenticatorSelectionCriteria,
ResidentKeyRequirement, UserVerificationRequirement,
self, AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement,
},
Passkey,
};
Expand Down Expand Up @@ -69,6 +68,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 @@ -260,7 +263,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 @@ -322,8 +328,12 @@ where
);

let store_info = self.authenticator.store().get_info().await;
let client_extension_results =
self.registration_extension_outputs(extension_request.as_ref(), store_info, rk);
let client_extension_results = self.registration_extension_outputs(
extension_request.as_ref(),
store_info,
rk,
ctap2_response.unsigned_extension_outputs,
);

let response = webauthn::CreatedPublicKeyCredential {
id: encoding::base64url(credential_id.credential_id()),
Expand Down Expand Up @@ -359,6 +369,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().await;

// TODO: Handle given timeout here, If the value is not within what we consider a reasonable range
// override to our default
Expand Down Expand Up @@ -386,7 +397,10 @@ where
.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 rk = false;
let uv = request.user_verification != UserVerificationRequirement::Discouraged;

Expand All @@ -404,6 +418,9 @@ where
.await
.map_err(Into::<WebauthnError>::into)?;

let client_extension_results =
self.auth_extension_outputs(ctap2_response.unsigned_extension_outputs);

// SAFETY: This unwrap is safe because ctap2_response was created immedately
// above and the postcondition of that function is that response.credential
// will yield a credential. If none was found, we will have already returned
Expand All @@ -421,7 +438,7 @@ where
attestation_object: None,
},
authenticator_attachment: Some(self.authenticator().attachment_type()),
client_extension_results: AuthenticationExtensionsClientOutputs::default(),
client_extension_results,
})
}

Expand Down
Loading

0 comments on commit 8f23bc8

Please sign in to comment.