Skip to content

Commit c3defd3

Browse files
authored
Add support for WebAuthn PRF extension (#337)
* 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
1 parent 86ca747 commit c3defd3

File tree

11 files changed

+2204
-99
lines changed

11 files changed

+2204
-99
lines changed

examples/ctap2_discoverable_creds.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,7 @@ fn register_user(manager: &mut AuthenticatorService, username: &str, timeout_ms:
7474
username,
7575
r#""}"#
7676
);
77-
let mut challenge = Sha256::new();
78-
challenge.update(challenge_str.as_bytes());
79-
let chall_bytes = challenge.finalize().into();
77+
let chall_bytes = Sha256::digest(challenge_str.as_bytes()).into();
8078

8179
let (status_tx, status_rx) = channel::<StatusUpdate>();
8280
thread::spawn(move || loop {
@@ -331,9 +329,7 @@ fn main() {
331329
}
332330
});
333331

334-
let mut challenge = Sha256::new();
335-
challenge.update(challenge_str.as_bytes());
336-
let chall_bytes = challenge.finalize().into();
332+
let chall_bytes = Sha256::digest(challenge_str.as_bytes()).into();
337333
let ctap_args = SignArgs {
338334
client_data_hash: chall_bytes,
339335
origin,

examples/prf.rs

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
use authenticator::{
6+
authenticatorservice::{AuthenticatorService, RegisterArgs, SignArgs},
7+
crypto::COSEAlgorithm,
8+
ctap2::server::{
9+
AuthenticationExtensionsClientInputs, AuthenticationExtensionsPRFInputs,
10+
AuthenticationExtensionsPRFValues, HMACGetSecretInput, PublicKeyCredentialDescriptor,
11+
PublicKeyCredentialParameters, PublicKeyCredentialUserEntity, RelyingParty,
12+
ResidentKeyRequirement, Transport, UserVerificationRequirement,
13+
},
14+
statecallback::StateCallback,
15+
Pin, StatusPinUv, StatusUpdate,
16+
};
17+
use getopts::Options;
18+
use rand::{thread_rng, RngCore};
19+
use std::sync::mpsc::{channel, RecvError};
20+
use std::{env, thread};
21+
22+
fn print_usage(program: &str, opts: Options) {
23+
let brief = format!("Usage: {program} [options]");
24+
print!("{}", opts.usage(&brief));
25+
}
26+
27+
fn main() {
28+
env_logger::init();
29+
30+
let args: Vec<String> = env::args().collect();
31+
let program = args[0].clone();
32+
33+
let rp_id = "example.com".to_string();
34+
35+
let mut opts = Options::new();
36+
opts.optflag("h", "help", "print this help menu").optopt(
37+
"t",
38+
"timeout",
39+
"timeout in seconds",
40+
"SEC",
41+
);
42+
opts.optflag("h", "help", "print this help menu");
43+
opts.optflag(
44+
"",
45+
"hmac-secret",
46+
"Return hmac-secret outputs instead of prf outputs (i.e., do not prefix and hash the inputs)",
47+
);
48+
let matches = match opts.parse(&args[1..]) {
49+
Ok(m) => m,
50+
Err(f) => panic!("{}", f.to_string()),
51+
};
52+
if matches.opt_present("help") {
53+
print_usage(&program, opts);
54+
return;
55+
}
56+
57+
let mut manager =
58+
AuthenticatorService::new().expect("The auth service should initialize safely");
59+
manager.add_u2f_usb_hid_platform_transports();
60+
61+
let timeout_ms = match matches.opt_get_default::<u64>("timeout", 25) {
62+
Ok(timeout_s) => {
63+
println!("Using {}s as the timeout", &timeout_s);
64+
timeout_s * 1_000
65+
}
66+
Err(e) => {
67+
println!("{e}");
68+
print_usage(&program, opts);
69+
return;
70+
}
71+
};
72+
73+
let (register_hmac_secret, sign_hmac_secret, register_prf, sign_prf) =
74+
if matches.opt_present("hmac-secret") {
75+
let register_hmac_secret = Some(true);
76+
let sign_hmac_secret = Some(HMACGetSecretInput {
77+
salt1: [0x07; 32],
78+
salt2: Some([0x07; 32]),
79+
});
80+
(register_hmac_secret, sign_hmac_secret, None, None)
81+
} else {
82+
let register_prf = Some(AuthenticationExtensionsPRFInputs::default());
83+
let sign_prf = Some(AuthenticationExtensionsPRFInputs {
84+
eval: Some(AuthenticationExtensionsPRFValues {
85+
first: vec![1, 2, 3, 4],
86+
second: Some(vec![1, 2, 3, 4]),
87+
}),
88+
eval_by_credential: None,
89+
});
90+
(None, None, register_prf, sign_prf)
91+
};
92+
93+
println!("Asking a security key to register now...");
94+
let mut chall_bytes = [0u8; 32];
95+
thread_rng().fill_bytes(&mut chall_bytes);
96+
97+
let (status_tx, status_rx) = channel::<StatusUpdate>();
98+
thread::spawn(move || loop {
99+
match status_rx.recv() {
100+
Ok(StatusUpdate::InteractiveManagement(..)) => {
101+
panic!("STATUS: This can't happen when doing non-interactive usage");
102+
}
103+
Ok(StatusUpdate::SelectDeviceNotice) => {
104+
println!("STATUS: Please select a device by touching one of them.");
105+
}
106+
Ok(StatusUpdate::PresenceRequired) => {
107+
println!("STATUS: waiting for user presence");
108+
}
109+
Ok(StatusUpdate::PinUvError(StatusPinUv::PinRequired(sender))) => {
110+
let raw_pin =
111+
rpassword::prompt_password_stderr("Enter PIN: ").expect("Failed to read PIN");
112+
sender.send(Pin::new(&raw_pin)).expect("Failed to send PIN");
113+
continue;
114+
}
115+
Ok(StatusUpdate::PinUvError(StatusPinUv::InvalidPin(sender, attempts))) => {
116+
println!(
117+
"Wrong PIN! {}",
118+
attempts.map_or("Try again.".to_string(), |a| format!(
119+
"You have {a} attempts left."
120+
))
121+
);
122+
let raw_pin =
123+
rpassword::prompt_password_stderr("Enter PIN: ").expect("Failed to read PIN");
124+
sender.send(Pin::new(&raw_pin)).expect("Failed to send PIN");
125+
continue;
126+
}
127+
Ok(StatusUpdate::PinUvError(StatusPinUv::PinAuthBlocked)) => {
128+
panic!("Too many failed attempts in one row. Your device has been temporarily blocked. Please unplug it and plug in again.")
129+
}
130+
Ok(StatusUpdate::PinUvError(StatusPinUv::PinBlocked)) => {
131+
panic!("Too many failed attempts. Your device has been blocked. Reset it.")
132+
}
133+
Ok(StatusUpdate::PinUvError(StatusPinUv::InvalidUv(attempts))) => {
134+
println!(
135+
"Wrong UV! {}",
136+
attempts.map_or("Try again.".to_string(), |a| format!(
137+
"You have {a} attempts left."
138+
))
139+
);
140+
continue;
141+
}
142+
Ok(StatusUpdate::PinUvError(StatusPinUv::UvBlocked)) => {
143+
println!("Too many failed UV-attempts.");
144+
continue;
145+
}
146+
Ok(StatusUpdate::PinUvError(e)) => {
147+
panic!("Unexpected error: {:?}", e)
148+
}
149+
Ok(StatusUpdate::SelectResultNotice(_, _)) => {
150+
panic!("Unexpected select device notice")
151+
}
152+
Err(RecvError) => {
153+
println!("STATUS: end");
154+
return;
155+
}
156+
}
157+
});
158+
159+
let user = PublicKeyCredentialUserEntity {
160+
id: "user_id".as_bytes().to_vec(),
161+
name: Some("A. User".to_string()),
162+
display_name: None,
163+
};
164+
let relying_party = RelyingParty {
165+
id: rp_id.clone(),
166+
name: None,
167+
};
168+
let ctap_args = RegisterArgs {
169+
client_data_hash: chall_bytes,
170+
relying_party,
171+
origin: format!("https://{rp_id}"),
172+
user,
173+
pub_cred_params: vec![
174+
PublicKeyCredentialParameters {
175+
alg: COSEAlgorithm::ES256,
176+
},
177+
PublicKeyCredentialParameters {
178+
alg: COSEAlgorithm::RS256,
179+
},
180+
],
181+
exclude_list: vec![],
182+
user_verification_req: UserVerificationRequirement::Required,
183+
resident_key_req: ResidentKeyRequirement::Discouraged,
184+
extensions: AuthenticationExtensionsClientInputs {
185+
hmac_create_secret: register_hmac_secret,
186+
prf: register_prf,
187+
..Default::default()
188+
},
189+
pin: None,
190+
use_ctap1_fallback: false,
191+
};
192+
193+
let attestation_object;
194+
let (register_tx, register_rx) = channel();
195+
let callback = StateCallback::new(Box::new(move |rv| {
196+
register_tx.send(rv).unwrap();
197+
}));
198+
199+
if let Err(e) = manager.register(timeout_ms, ctap_args, status_tx.clone(), callback) {
200+
panic!("Couldn't register: {:?}", e);
201+
};
202+
203+
let register_result = register_rx
204+
.recv()
205+
.expect("Problem receiving, unable to continue");
206+
match register_result {
207+
Ok(a) => {
208+
println!("Ok!");
209+
attestation_object = a;
210+
}
211+
Err(e) => panic!("Registration failed: {:?}", e),
212+
};
213+
214+
println!("Register result: {:?}", &attestation_object);
215+
216+
println!();
217+
println!("*********************************************************************");
218+
println!("Asking a security key to sign now, with the data from the register...");
219+
println!("*********************************************************************");
220+
221+
let allow_list;
222+
if let Some(cred_data) = attestation_object.att_obj.auth_data.credential_data {
223+
allow_list = vec![PublicKeyCredentialDescriptor {
224+
id: cred_data.credential_id,
225+
transports: vec![Transport::USB],
226+
}];
227+
} else {
228+
allow_list = Vec::new();
229+
}
230+
231+
let ctap_args = SignArgs {
232+
client_data_hash: chall_bytes,
233+
origin: format!("https://{rp_id}"),
234+
relying_party_id: rp_id,
235+
allow_list,
236+
user_verification_req: UserVerificationRequirement::Required,
237+
user_presence_req: true,
238+
extensions: AuthenticationExtensionsClientInputs {
239+
hmac_get_secret: sign_hmac_secret.clone(),
240+
prf: sign_prf.clone(),
241+
..Default::default()
242+
},
243+
pin: None,
244+
use_ctap1_fallback: false,
245+
};
246+
247+
let (sign_tx, sign_rx) = channel();
248+
let callback = StateCallback::new(Box::new(move |rv| {
249+
sign_tx.send(rv).unwrap();
250+
}));
251+
252+
if let Err(e) = manager.sign(timeout_ms, ctap_args, status_tx, callback) {
253+
panic!("Couldn't sign: {:?}", e);
254+
}
255+
256+
let sign_result = sign_rx
257+
.recv()
258+
.expect("Problem receiving, unable to continue");
259+
260+
match sign_result {
261+
Ok(assertion_object) => {
262+
println!("Assertion Object: {assertion_object:?}");
263+
println!("Done.");
264+
265+
if sign_hmac_secret.is_some() {
266+
let hmac_secret_outputs = assertion_object
267+
.extensions
268+
.hmac_get_secret
269+
.as_ref()
270+
.expect("Expected hmac-secret output");
271+
272+
assert_eq!(
273+
Some(hmac_secret_outputs.output1),
274+
hmac_secret_outputs.output2,
275+
"Expected hmac-secret outputs to be equal for equal input"
276+
);
277+
278+
assert_eq!(
279+
assertion_object.extensions.prf, None,
280+
"Expected no PRF outputs when hmacGetSecret input was present"
281+
);
282+
}
283+
284+
if sign_prf.is_some() {
285+
let prf_results = assertion_object
286+
.extensions
287+
.prf
288+
.expect("Expected PRF output")
289+
.results
290+
.expect("Expected PRF output to contain results");
291+
292+
assert_eq!(
293+
Some(prf_results.first),
294+
prf_results.second,
295+
"Expected PRF results to be equal for equal input"
296+
);
297+
298+
assert_eq!(
299+
assertion_object.extensions.hmac_get_secret, None,
300+
"Expected no hmacGetSecret output when PRF input was present"
301+
);
302+
}
303+
}
304+
305+
Err(e) => panic!("Signing failed: {:?}", e),
306+
}
307+
}

examples/test_exclude_list.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,7 @@ fn main() {
7272
r#"{"challenge": "1vQ9mxionq0ngCnjD-wTsv1zUSrGRtFqG2xP09SbZ70","#,
7373
r#" "version": "U2F_V2", "appId": "http://example.com"}"#
7474
);
75-
let mut challenge = Sha256::new();
76-
challenge.update(challenge_str.as_bytes());
77-
let chall_bytes = challenge.finalize().into();
75+
let chall_bytes = Sha256::digest(challenge_str.as_bytes()).into();
7876

7977
let (status_tx, status_rx) = channel::<StatusUpdate>();
8078
thread::spawn(move || loop {

src/crypto/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,23 @@ impl SharedSecret {
341341
pub fn peer_input(&self) -> &COSEKey {
342342
&self.inputs.peer
343343
}
344+
345+
#[cfg(test)]
346+
pub fn new_test(
347+
pin_protocol: PinUvAuthProtocol,
348+
key: Vec<u8>,
349+
client_input: COSEKey,
350+
peer_input: COSEKey,
351+
) -> Self {
352+
Self {
353+
pin_protocol,
354+
key,
355+
inputs: PublicInputs {
356+
client: client_input,
357+
peer: peer_input,
358+
},
359+
}
360+
}
344361
}
345362

346363
#[derive(Clone, Debug)]
@@ -1073,7 +1090,7 @@ impl Serialize for COSEKey {
10731090
}
10741091

10751092
/// Errors that can be returned from COSE functions.
1076-
#[derive(Debug, Clone, Serialize)]
1093+
#[derive(Debug, Clone, PartialEq, Serialize)]
10771094
pub enum CryptoError {
10781095
// DecodingFailure,
10791096
LibraryFailure,

0 commit comments

Comments
 (0)