Skip to content

Commit

Permalink
Add prfAlreadyHashed as an unofficial extension
Browse files Browse the repository at this point in the history
  • Loading branch information
Vogeltak authored and Progdrasil committed Jul 11, 2024
1 parent 105d90f commit a023773
Show file tree
Hide file tree
Showing 4 changed files with 274 additions and 11 deletions.
68 changes: 61 additions & 7 deletions passkey-client/src/extensions/prf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,22 @@ 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)
let maybe_prf = make_ctap_extension(
request.and_then(|r| r.prf.as_ref()),
supported_extensions,
true,
)?;

if maybe_prf.is_none() {
// Then try prfAlreadyHashed
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(
Expand All @@ -39,11 +54,28 @@ fn validate_no_eval_by_cred(

fn convert_eval_to_ctap(
eval: &AuthenticationExtensionsPrfValues,
should_hash: bool,
) -> Result<AuthenticatorPrfValues> {
let (first, second) = {
let (first, second) = if should_hash {
let salt1 = make_salt(&eval.first);
let salt2 = eval.second.as_ref().map(make_salt);
(salt1, salt2)
} else {
let salt1 = eval
.first
.as_slice()
.try_into()
.map_err(|_| WebauthnError::ValidationError)?;
let salt2 = eval
.second
.as_ref()
.map(|b| {
b.as_slice()
.try_into()
.map_err(|_| WebauthnError::ValidationError)
})
.transpose()?;
(salt1, salt2)
};

Ok(AuthenticatorPrfValues { first, second })
Expand All @@ -52,6 +84,7 @@ fn convert_eval_to_ctap(
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.
//
Expand All @@ -72,7 +105,7 @@ fn make_ctap_extension(
// Only create prf extension input if it's enabled on the authenticator.
prf.eval
.as_ref()
.map(convert_eval_to_ctap)
.map(|values| convert_eval_to_ctap(values, should_hash))
.transpose()
.map(|eval| AuthenticatorPrfInputs {
eval,
Expand All @@ -96,17 +129,34 @@ pub(super) fn auth_prf_to_ctap2_input(
request: &PublicKeyCredentialRequestOptions,
supported_extensions: &[get_info::Extension],
) -> Result<Option<get_assertion::ExtensionInputs>> {
get_ctap_extension(
let maybe_prf = 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
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) {
Expand Down Expand Up @@ -163,13 +213,17 @@ fn get_ctap_extension(
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)))
.map(|(k, values)| 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(convert_eval_to_ctap))
.and_then(|prf| {
prf.eval
.as_ref()
.map(|prf_values| convert_eval_to_ctap(prf_values, should_hash))
})
.transpose()?;

let prf = prf_input.map(|_| AuthenticatorPrfInputs {
Expand Down
2 changes: 2 additions & 0 deletions passkey-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ pub enum WebauthnError {
NotSupportedError,
/// The string did not match the expected pattern.
SyntaxError,
/// The input failed validation
ValidationError,
}

impl WebauthnError {
Expand Down
191 changes: 190 additions & 1 deletion passkey-client/src/tests/ext_prf.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use std::collections::HashMap;

use passkey_authenticator::extensions::HmacSecretConfig;
use passkey_types::ctap2::{AuthenticatorData, Flags};
use passkey_types::{
crypto::hmac_sha256,
ctap2::{AuthenticatorData, Flags},
};

use super::*;

Expand Down Expand Up @@ -800,3 +803,189 @@ async fn two_eval_by_credential_entries() {
assert_eq!(treatment_prf_res.first, control_prf_res.first);
assert_eq!(treatment_prf_res.second, control_prf_res.second);
}

#[tokio::test]
async fn prf_already_hashed_does_not_hash_again() {
let salt = [2; 32];

let hashed_salt = sha256(&[b"WebAuthn PRF".as_slice(), &[0x00], salt.as_slice()].concat());

let origin = Url::parse("https://future.1password.com").unwrap();

let auth = Authenticator::new(ctap2::Aaguid::new_empty(), None, uv_mock_with_creation(2))
.hmac_secret(HmacSecretConfig::new_without_uv().enable_on_make_credential());
let mut client = Client::new(auth);
let create_request = webauthn::CredentialCreationOptions {
public_key: webauthn::PublicKeyCredentialCreationOptions {
extensions: Some(webauthn::AuthenticationExtensionsClientInputs {
prf_already_hashed: Some(webauthn::AuthenticationExtensionsPrfInputs {
eval: Some(webauthn::AuthenticationExtensionsPrfValues {
first: hashed_salt.as_slice().into(),
second: None,
}),
eval_by_credential: None,
}),
..Default::default()
}),
..good_credential_creation_options()
},
};
let created = client
.register(&origin, create_request, None)
.await
.expect("could not register a new passkey with PRF already hashed");

let passkey = client
.authenticator
.store()
.clone()
.expect("no passkey was stored after its creation");

let hmac_secret = passkey
.extensions
.hmac_secret
.as_ref()
.expect("no HMAC secret was created with PRF already hashed")
.cred_with_uv
.clone();

let expected_output = hmac_sha256(&hmac_secret, &hashed_salt);

let prf_results = created
.client_extension_results
.prf
.expect("no PRF was returned")
.results
.expect("no results were returned with make credential support");
assert_eq!(prf_results.first.as_slice(), expected_output.as_slice());

let request = webauthn::CredentialRequestOptions {
public_key: webauthn::PublicKeyCredentialRequestOptions {
allow_credentials: None,
extensions: Some(webauthn::AuthenticationExtensionsClientInputs {
prf_already_hashed: Some(webauthn::AuthenticationExtensionsPrfInputs {
eval: Some(webauthn::AuthenticationExtensionsPrfValues {
first: hashed_salt.as_slice().into(),
second: None,
}),
eval_by_credential: None,
}),
..Default::default()
}),
..good_credential_request_options(vec![])
},
};

let response = client
.authenticate(&origin, request, None)
.await
.expect("could not authenticate with PRF already hashed");

let prf = response
.client_extension_results
.prf
.expect("no PRF output was provided");

let prf_results = prf
.results
.expect("no PRF results were included in the output");

assert_eq!(prf_results.first.as_slice(), expected_output.as_slice());
}

#[tokio::test]
async fn prf_takes_precedence_over_prf_already_hashed() {
let salt = [2; 32];

let hashed_salt = sha256(&[b"WebAuthn PRF".as_slice(), &[0x00], salt.as_slice()].concat());

let origin = Url::parse("https://future.1password.com").unwrap();

let auth = Authenticator::new(ctap2::Aaguid::new_empty(), None, uv_mock_with_creation(2))
.hmac_secret(HmacSecretConfig::new_without_uv().enable_on_make_credential());
let mut client = Client::new(auth);
let create_request = webauthn::CredentialCreationOptions {
public_key: webauthn::PublicKeyCredentialCreationOptions {
extensions: Some(webauthn::AuthenticationExtensionsClientInputs {
prf_already_hashed: Some(webauthn::AuthenticationExtensionsPrfInputs {
eval: Some(webauthn::AuthenticationExtensionsPrfValues {
first: hashed_salt.as_slice().into(),
second: None,
}),
eval_by_credential: None,
}),
..Default::default()
}),
..good_credential_creation_options()
},
};
let created = client
.register(&origin, create_request, None)
.await
.expect("could not register a new passkey with PRF already hashed");

let passkey = client
.authenticator
.store()
.clone()
.expect("no passkey was stored after its creation");

let hmac_secret = passkey
.extensions
.hmac_secret
.as_ref()
.expect("no HMAC secret was created with PRF already hashed")
.cred_with_uv
.clone();

let expected_output = hmac_sha256(&hmac_secret, &hashed_salt);

let prf_results = created
.client_extension_results
.prf
.expect("no PRF was returned")
.results
.expect("no results were returned with make credential support");
assert_eq!(prf_results.first.as_slice(), expected_output.as_slice());

let request = webauthn::CredentialRequestOptions {
public_key: webauthn::PublicKeyCredentialRequestOptions {
allow_credentials: None,
extensions: Some(webauthn::AuthenticationExtensionsClientInputs {
prf: Some(webauthn::AuthenticationExtensionsPrfInputs {
eval: Some(webauthn::AuthenticationExtensionsPrfValues {
first: salt.as_slice().into(),
second: None,
}),
eval_by_credential: None,
}),
prf_already_hashed: Some(webauthn::AuthenticationExtensionsPrfInputs {
eval: Some(webauthn::AuthenticationExtensionsPrfValues {
// Input nonsense here so if it is selected it fails
first: [3; 32].as_slice().into(),
second: None,
}),
eval_by_credential: None,
}),
..Default::default()
}),
..good_credential_request_options(vec![])
},
};

let response = client
.authenticate(&origin, request, None)
.await
.expect("could not authenticate with PRF already hashed");

let prf = response
.client_extension_results
.prf
.expect("no PRF output was provided");

let prf_results = prf
.results
.expect("no PRF results were included in the output");

assert_eq!(prf_results.first.as_slice(), expected_output.as_slice());
}
24 changes: 21 additions & 3 deletions passkey-types/src/webauthn/extensions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,37 @@ pub struct AuthenticationExtensionsClientInputs {
/// See [`AuthenticationExtensionsPrfInputs`] for more information.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prf: Option<AuthenticationExtensionsPrfInputs>,

/// Inputs for the pseudo-random function extension where the inputs are already hashed
/// by another client following the `sha256("WebAuthn PRF" || salt)` format.
///
/// This is not an official extension, rather a field that occurs in some cases on Android
/// as well as the field that MUST be used when mapping from Apple's Authentication Services
/// [`ASAuthorizationPublicKeyCredentialPRFAssertionInput`].
///
/// This field SHOULD NOT be present alongside the [`Self::prf`] field as that field will take precedence.
///
/// [`ASAuthorizationPublicKeyCredentialPRFAssertionInput`]: https://developer.apple.com/documentation/authenticationservices/asauthorizationpublickeycredentialprfassertioninput-swift.struct
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prf_already_hashed: Option<AuthenticationExtensionsPrfInputs>,
}

impl AuthenticationExtensionsClientInputs {
/// Validates that there is at least one extension field that is `Some`
/// and that they are in turn not empty. If all fields are `None`
/// then this returns `None` as well.
pub fn zip_contents(self) -> Option<Self> {
let Self { cred_props, prf } = &self;

let Self {
cred_props,
prf,
prf_already_hashed,
} = &self;
let has_cred_props = cred_props.is_some();

let has_prf = prf.is_some();
let has_prf_already_hashed = prf_already_hashed.is_some();

(has_cred_props || has_prf).then_some(self)
(has_cred_props || has_prf || has_prf_already_hashed).then_some(self)
}
}

Expand Down

0 comments on commit a023773

Please sign in to comment.