Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support prf already hashed extension (PRF#6) #38

Merged
merged 1 commit into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading