Skip to content

Commit

Permalink
Add support for WebAuthn PRF extension (#337)
Browse files Browse the repository at this point in the history
* Add support for WebAuthn PRF extension

Original context: https://bugzilla.mozilla.org/show_bug.cgi?id=1863819

* Send correct PIN protocol ID in hmac-secret

Before this change, OpenSK (tag 2.1, commit
893faa5113f47457337ddb826b1a58870f00bc78) returns CTAP2_ERR_INVALID_PARAMETER in
response to attempts to use the WebAuthn PRF extension.

Original context: https://bugzilla.mozilla.org/show_bug.cgi?id=1863819

* Extract function HmacSecretResponse::decrypt_secrets

* Clarify and correct hmac-secret and PRF client outputs in makeCredential

* Delete unnecessary impl Default

* Rename HmacSecretFromHmacSecretOrPrf to HmacCreateSecretOrPrf

* Use HmacGetSecretOrPrf data model in getAssertion too

* Add examples/prf.rs

* Construct channels outside loop

* Remove unused loop

* Add tests for HmacSecretResponse::decrypt_secrets

* Extract function AuthenticationExtensionsPRFInputs::eval_to_salt

* Extract AuthenticationExtensionsPRFInputs::select_eval and ::select_credential

* Add doc comment to AuthenticationExtensionsPRFInputs::calculate

* Fix clippy lint

* Return empty prf output if no eval or evalByCredential entry matched

* Extract function HmacGetSecretOrPrf::calculate

* Add tests of calculating hmac-secret/PRF inputs

* Fix outdated error messages

* Separate hmac_secret tests that require a crypto backend

* Add debug output to error paths of HmacSecretResponse::decrypt_secrets

* Fix a typo and a cryptic comment

* Eliminate unnecessary sha256 function

* Simplify to Sha256::digest where possible

* Derive PartialEq always, not just in cfg(test)

* Document generation of hmac_secret test data

* Remove unnecessary comma

* Tweak imports per review

* Take PinUvAuthToken as reference in HmacSecretExtension::calculate

* Deduplicate decrypt_pin_token code in tests

* Extract function GetAssertion::process_hmac_secret_and_prf_extension

* Move allow_list assignment to top level scope

* Add tests of hmac-secret and prf processing in GetAssertion::finalize_result

* Fail hmac-secret salt calculation if input salts are too long

This is prescribed by the [CTAP spec][ctap]:

>**Client extension processing**
>1. [...]
>2. If present in a get():
>  1. Verify that salt1 is a 32-byte ArrayBuffer.
>  2. If salt2 is present, verify that it is a 32-byte ArrayBuffer.
>  [...]

[ctap]: https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#sctn-hmac-secret-extension

* Add tests of GetAssertion::process_hmac_secret_and_prf_extension

* Propagate WrongSaltLength as InvalidRelyingPartyInput in GetAssertion::process_hmac_secret_and_prf_extension

* Return PrfUnmatched instead of None when shared secret is not available

This is needed because the PRF extension should return an empty extension output
`prf: {}` when the extension is processed but no eligible authenticator is
found. Thus we need to differentiate these cases so that
`GetAssertion::finalize_result` can match on `PrfUnmatched` and generate the
empty output.

* Add debug logging when no shared secret is available

* Add debug logging when hmac-secret output decryption fails

* Add test of serializing uninitialized and unmatched PRF inputs

* Add missing test of serializing hmac-secret with PIN protocol 2
  • Loading branch information
emlun authored Jul 25, 2024
1 parent 86ca747 commit c3defd3
Show file tree
Hide file tree
Showing 11 changed files with 2,204 additions and 99 deletions.
8 changes: 2 additions & 6 deletions examples/ctap2_discoverable_creds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,7 @@ fn register_user(manager: &mut AuthenticatorService, username: &str, timeout_ms:
username,
r#""}"#
);
let mut challenge = Sha256::new();
challenge.update(challenge_str.as_bytes());
let chall_bytes = challenge.finalize().into();
let chall_bytes = Sha256::digest(challenge_str.as_bytes()).into();

let (status_tx, status_rx) = channel::<StatusUpdate>();
thread::spawn(move || loop {
Expand Down Expand Up @@ -331,9 +329,7 @@ fn main() {
}
});

let mut challenge = Sha256::new();
challenge.update(challenge_str.as_bytes());
let chall_bytes = challenge.finalize().into();
let chall_bytes = Sha256::digest(challenge_str.as_bytes()).into();
let ctap_args = SignArgs {
client_data_hash: chall_bytes,
origin,
Expand Down
307 changes: 307 additions & 0 deletions examples/prf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

use authenticator::{
authenticatorservice::{AuthenticatorService, RegisterArgs, SignArgs},
crypto::COSEAlgorithm,
ctap2::server::{
AuthenticationExtensionsClientInputs, AuthenticationExtensionsPRFInputs,
AuthenticationExtensionsPRFValues, HMACGetSecretInput, PublicKeyCredentialDescriptor,
PublicKeyCredentialParameters, PublicKeyCredentialUserEntity, RelyingParty,
ResidentKeyRequirement, Transport, UserVerificationRequirement,
},
statecallback::StateCallback,
Pin, StatusPinUv, StatusUpdate,
};
use getopts::Options;
use rand::{thread_rng, RngCore};
use std::sync::mpsc::{channel, RecvError};
use std::{env, thread};

fn print_usage(program: &str, opts: Options) {
let brief = format!("Usage: {program} [options]");
print!("{}", opts.usage(&brief));
}

fn main() {
env_logger::init();

let args: Vec<String> = env::args().collect();
let program = args[0].clone();

let rp_id = "example.com".to_string();

let mut opts = Options::new();
opts.optflag("h", "help", "print this help menu").optopt(
"t",
"timeout",
"timeout in seconds",
"SEC",
);
opts.optflag("h", "help", "print this help menu");
opts.optflag(
"",
"hmac-secret",
"Return hmac-secret outputs instead of prf outputs (i.e., do not prefix and hash the inputs)",
);
let matches = match opts.parse(&args[1..]) {
Ok(m) => m,
Err(f) => panic!("{}", f.to_string()),
};
if matches.opt_present("help") {
print_usage(&program, opts);
return;
}

let mut manager =
AuthenticatorService::new().expect("The auth service should initialize safely");
manager.add_u2f_usb_hid_platform_transports();

let timeout_ms = match matches.opt_get_default::<u64>("timeout", 25) {
Ok(timeout_s) => {
println!("Using {}s as the timeout", &timeout_s);
timeout_s * 1_000
}
Err(e) => {
println!("{e}");
print_usage(&program, opts);
return;
}
};

let (register_hmac_secret, sign_hmac_secret, register_prf, sign_prf) =
if matches.opt_present("hmac-secret") {
let register_hmac_secret = Some(true);
let sign_hmac_secret = Some(HMACGetSecretInput {
salt1: [0x07; 32],
salt2: Some([0x07; 32]),
});
(register_hmac_secret, sign_hmac_secret, None, None)
} else {
let register_prf = Some(AuthenticationExtensionsPRFInputs::default());
let sign_prf = Some(AuthenticationExtensionsPRFInputs {
eval: Some(AuthenticationExtensionsPRFValues {
first: vec![1, 2, 3, 4],
second: Some(vec![1, 2, 3, 4]),
}),
eval_by_credential: None,
});
(None, None, register_prf, sign_prf)
};

println!("Asking a security key to register now...");
let mut chall_bytes = [0u8; 32];
thread_rng().fill_bytes(&mut chall_bytes);

let (status_tx, status_rx) = channel::<StatusUpdate>();
thread::spawn(move || loop {
match status_rx.recv() {
Ok(StatusUpdate::InteractiveManagement(..)) => {
panic!("STATUS: This can't happen when doing non-interactive usage");
}
Ok(StatusUpdate::SelectDeviceNotice) => {
println!("STATUS: Please select a device by touching one of them.");
}
Ok(StatusUpdate::PresenceRequired) => {
println!("STATUS: waiting for user presence");
}
Ok(StatusUpdate::PinUvError(StatusPinUv::PinRequired(sender))) => {
let raw_pin =
rpassword::prompt_password_stderr("Enter PIN: ").expect("Failed to read PIN");
sender.send(Pin::new(&raw_pin)).expect("Failed to send PIN");
continue;
}
Ok(StatusUpdate::PinUvError(StatusPinUv::InvalidPin(sender, attempts))) => {
println!(
"Wrong PIN! {}",
attempts.map_or("Try again.".to_string(), |a| format!(
"You have {a} attempts left."
))
);
let raw_pin =
rpassword::prompt_password_stderr("Enter PIN: ").expect("Failed to read PIN");
sender.send(Pin::new(&raw_pin)).expect("Failed to send PIN");
continue;
}
Ok(StatusUpdate::PinUvError(StatusPinUv::PinAuthBlocked)) => {
panic!("Too many failed attempts in one row. Your device has been temporarily blocked. Please unplug it and plug in again.")
}
Ok(StatusUpdate::PinUvError(StatusPinUv::PinBlocked)) => {
panic!("Too many failed attempts. Your device has been blocked. Reset it.")
}
Ok(StatusUpdate::PinUvError(StatusPinUv::InvalidUv(attempts))) => {
println!(
"Wrong UV! {}",
attempts.map_or("Try again.".to_string(), |a| format!(
"You have {a} attempts left."
))
);
continue;
}
Ok(StatusUpdate::PinUvError(StatusPinUv::UvBlocked)) => {
println!("Too many failed UV-attempts.");
continue;
}
Ok(StatusUpdate::PinUvError(e)) => {
panic!("Unexpected error: {:?}", e)
}
Ok(StatusUpdate::SelectResultNotice(_, _)) => {
panic!("Unexpected select device notice")
}
Err(RecvError) => {
println!("STATUS: end");
return;
}
}
});

let user = PublicKeyCredentialUserEntity {
id: "user_id".as_bytes().to_vec(),
name: Some("A. User".to_string()),
display_name: None,
};
let relying_party = RelyingParty {
id: rp_id.clone(),
name: None,
};
let ctap_args = RegisterArgs {
client_data_hash: chall_bytes,
relying_party,
origin: format!("https://{rp_id}"),
user,
pub_cred_params: vec![
PublicKeyCredentialParameters {
alg: COSEAlgorithm::ES256,
},
PublicKeyCredentialParameters {
alg: COSEAlgorithm::RS256,
},
],
exclude_list: vec![],
user_verification_req: UserVerificationRequirement::Required,
resident_key_req: ResidentKeyRequirement::Discouraged,
extensions: AuthenticationExtensionsClientInputs {
hmac_create_secret: register_hmac_secret,
prf: register_prf,
..Default::default()
},
pin: None,
use_ctap1_fallback: false,
};

let attestation_object;
let (register_tx, register_rx) = channel();
let callback = StateCallback::new(Box::new(move |rv| {
register_tx.send(rv).unwrap();
}));

if let Err(e) = manager.register(timeout_ms, ctap_args, status_tx.clone(), callback) {
panic!("Couldn't register: {:?}", e);
};

let register_result = register_rx
.recv()
.expect("Problem receiving, unable to continue");
match register_result {
Ok(a) => {
println!("Ok!");
attestation_object = a;
}
Err(e) => panic!("Registration failed: {:?}", e),
};

println!("Register result: {:?}", &attestation_object);

println!();
println!("*********************************************************************");
println!("Asking a security key to sign now, with the data from the register...");
println!("*********************************************************************");

let allow_list;
if let Some(cred_data) = attestation_object.att_obj.auth_data.credential_data {
allow_list = vec![PublicKeyCredentialDescriptor {
id: cred_data.credential_id,
transports: vec![Transport::USB],
}];
} else {
allow_list = Vec::new();
}

let ctap_args = SignArgs {
client_data_hash: chall_bytes,
origin: format!("https://{rp_id}"),
relying_party_id: rp_id,
allow_list,
user_verification_req: UserVerificationRequirement::Required,
user_presence_req: true,
extensions: AuthenticationExtensionsClientInputs {
hmac_get_secret: sign_hmac_secret.clone(),
prf: sign_prf.clone(),
..Default::default()
},
pin: None,
use_ctap1_fallback: false,
};

let (sign_tx, sign_rx) = channel();
let callback = StateCallback::new(Box::new(move |rv| {
sign_tx.send(rv).unwrap();
}));

if let Err(e) = manager.sign(timeout_ms, ctap_args, status_tx, callback) {
panic!("Couldn't sign: {:?}", e);
}

let sign_result = sign_rx
.recv()
.expect("Problem receiving, unable to continue");

match sign_result {
Ok(assertion_object) => {
println!("Assertion Object: {assertion_object:?}");
println!("Done.");

if sign_hmac_secret.is_some() {
let hmac_secret_outputs = assertion_object
.extensions
.hmac_get_secret
.as_ref()
.expect("Expected hmac-secret output");

assert_eq!(
Some(hmac_secret_outputs.output1),
hmac_secret_outputs.output2,
"Expected hmac-secret outputs to be equal for equal input"
);

assert_eq!(
assertion_object.extensions.prf, None,
"Expected no PRF outputs when hmacGetSecret input was present"
);
}

if sign_prf.is_some() {
let prf_results = assertion_object
.extensions
.prf
.expect("Expected PRF output")
.results
.expect("Expected PRF output to contain results");

assert_eq!(
Some(prf_results.first),
prf_results.second,
"Expected PRF results to be equal for equal input"
);

assert_eq!(
assertion_object.extensions.hmac_get_secret, None,
"Expected no hmacGetSecret output when PRF input was present"
);
}
}

Err(e) => panic!("Signing failed: {:?}", e),
}
}
4 changes: 1 addition & 3 deletions examples/test_exclude_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ fn main() {
r#"{"challenge": "1vQ9mxionq0ngCnjD-wTsv1zUSrGRtFqG2xP09SbZ70","#,
r#" "version": "U2F_V2", "appId": "http://example.com"}"#
);
let mut challenge = Sha256::new();
challenge.update(challenge_str.as_bytes());
let chall_bytes = challenge.finalize().into();
let chall_bytes = Sha256::digest(challenge_str.as_bytes()).into();

let (status_tx, status_rx) = channel::<StatusUpdate>();
thread::spawn(move || loop {
Expand Down
19 changes: 18 additions & 1 deletion src/crypto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,23 @@ impl SharedSecret {
pub fn peer_input(&self) -> &COSEKey {
&self.inputs.peer
}

#[cfg(test)]
pub fn new_test(
pin_protocol: PinUvAuthProtocol,
key: Vec<u8>,
client_input: COSEKey,
peer_input: COSEKey,
) -> Self {
Self {
pin_protocol,
key,
inputs: PublicInputs {
client: client_input,
peer: peer_input,
},
}
}
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -1073,7 +1090,7 @@ impl Serialize for COSEKey {
}

/// Errors that can be returned from COSE functions.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum CryptoError {
// DecodingFailure,
LibraryFailure,
Expand Down
Loading

0 comments on commit c3defd3

Please sign in to comment.