Skip to content

Commit 6fae120

Browse files
feat(webauthn): PRF with hybrid authenticators via the prf extension (#267)
Closes #266. Closes #168. WebAuthn L3 defines prf as a client extension. Security keys do not implement it directly. The client maps the PRF inputs onto the CTAP hmac-secret extension, encrypting the salts with a shared secret negotiated over the PIN/UV protocol, and the outputs come back encrypted inside the signed authenticator data. This is the path libwebauthn already implements. Phone authenticators reached over hybrid (caBLE) take a different approach. They advertise a prf extension in getInfo and consume the WebAuthn-shaped prf input directly at the CTAP level, with the salts pre-hashed (WebAuthn L3 §10.1.4) and sent in the clear, since the hybrid tunnel already provides an encrypted and authenticated channel. There is no shared secret involved, which matters because these authenticators expose no PIN/UV auth protocol at all. Outputs come back in unsignedExtensionOutputs (CTAP 2.3 §6.1 and §6.2) rather than in the signed authenticator data. This CTAP-level prf extension is not part of the published extension registry, but it is what Chromium implements and what Google Password Manager phones expect, and WebAuthn L3 explicitly allows PRF to be implemented by means other than hmac-secret. This change makes the two mechanisms co-exist behind the same WebAuthn-level prf extension. When the authenticator advertises prf in getInfo, the input is sent as a CTAP prf extension and results are read from unsignedExtensionOutputs. Otherwise the existing hmac-secret mapping is used, unchanged. Callers see the same client extension output either way. Also adds a webauthn_prf_cable example covering registration and assertion. Verified end to end against a Google Password Manager phone over caBLE: registration returns enabled true together with create-time results, and assertion returns both PRF outputs. Stacked on #260.
1 parent 1dfa002 commit 6fae120

8 files changed

Lines changed: 1159 additions & 40 deletions

File tree

libwebauthn/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ path = "examples/features/webauthn_preflight_hid.rs"
175175
name = "webauthn_prf_hid"
176176
path = "examples/features/webauthn_prf_hid.rs"
177177

178+
[[example]]
179+
name = "webauthn_prf_cable"
180+
path = "examples/features/webauthn_prf_cable.rs"
181+
178182
[[example]]
179183
name = "webauthn_related_origins_hid"
180184
path = "examples/features/webauthn_related_origins_hid.rs"
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//! PRF extension over caBLE/hybrid, against a phone authenticator that
2+
//! advertises the `prf` extension in getInfo.
3+
//!
4+
//! cargo run --example webauthn_prf_cable -- create
5+
//! cargo run --example webauthn_prf_cable -- get [credential-id]
6+
//!
7+
//! `create` registers a discoverable credential with PRF enabled and an eval
8+
//! at creation, then prints the credential ID. `get` asserts with PRF eval
9+
//! salts. When a credential ID is given, it is added to the allow list with an
10+
//! evalByCredential entry.
11+
12+
use std::collections::HashMap;
13+
use std::error::Error;
14+
use std::time::Duration;
15+
16+
use libwebauthn::ops::webauthn::{
17+
GetAssertionRequest, GetAssertionRequestExtensions, JsonFormat, MakeCredentialPrfInput,
18+
MakeCredentialRequest, MakeCredentialsRequestExtensions, PrfInput, PrfInputValue,
19+
ResidentKeyRequirement, UserVerificationRequirement, WebAuthnIDLResponse as _,
20+
};
21+
use libwebauthn::proto::ctap2::{
22+
Ctap2CredentialType, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity,
23+
Ctap2PublicKeyCredentialType, Ctap2PublicKeyCredentialUserEntity,
24+
};
25+
use libwebauthn::transport::cable::channel::CableChannel;
26+
use libwebauthn::transport::cable::is_available;
27+
use libwebauthn::transport::cable::qr_code_device::{
28+
CableQrCodeDevice, CableTransports, QrCodeOperationHint,
29+
};
30+
use libwebauthn::transport::{Channel as _, ChannelSettings, Device};
31+
use libwebauthn::webauthn::WebAuthn;
32+
use qrcode::render::unicode;
33+
use qrcode::QrCode;
34+
use serde_bytes::ByteBuf;
35+
36+
#[path = "../common/mod.rs"]
37+
mod common;
38+
39+
const TIMEOUT: Duration = Duration::from_secs(120);
40+
const RP_ID: &str = "example.org";
41+
const ORIGIN: &str = "https://example.org";
42+
43+
// Deterministic PRF inputs, so results can be cross-checked against another
44+
// client holding the same credential.
45+
const CREATE_EVAL_FIRST: &[u8] = b"example-prf-create-first";
46+
const CREATE_EVAL_SECOND: &[u8] = b"example-prf-create-second";
47+
const GET_EVAL_FIRST: &[u8] = b"example-prf-get-first";
48+
const GET_EVAL_SECOND: &[u8] = b"example-prf-get-second";
49+
const BY_CRED_FIRST: &[u8] = b"example-prf-bycred-first";
50+
const BY_CRED_SECOND: &[u8] = b"example-prf-bycred-second";
51+
52+
#[tokio::main]
53+
pub async fn main() -> Result<(), Box<dyn Error>> {
54+
common::setup_logging();
55+
56+
let args: Vec<String> = std::env::args().collect();
57+
let mode = args.get(1).map(String::as_str);
58+
59+
if !is_available().await {
60+
eprintln!("No Bluetooth adapter found. Cable/Hybrid transport is unavailable.");
61+
return Err("Cable transport not available".into());
62+
}
63+
64+
match mode {
65+
Some("create") => create().await,
66+
Some("get") => get(args.get(2).map(String::as_str)).await,
67+
_ => {
68+
eprintln!("Usage: webauthn_prf_cable <create | get [credential-id-base64url]>");
69+
Err("missing or unknown subcommand".into())
70+
}
71+
}
72+
}
73+
74+
async fn connect(
75+
hint: QrCodeOperationHint,
76+
) -> Result<(CableQrCodeDevice, CableChannel), Box<dyn Error>> {
77+
let mut device: CableQrCodeDevice =
78+
CableQrCodeDevice::new_transient(hint, CableTransports::CloudAssistedOrLocal)?;
79+
80+
println!("Created QR code, awaiting advertisement.");
81+
let qr_code = QrCode::new(device.qr_code.to_string()).unwrap();
82+
let image = qr_code
83+
.render::<unicode::Dense1x2>()
84+
.dark_color(unicode::Dense1x2::Light)
85+
.light_color(unicode::Dense1x2::Dark)
86+
.build();
87+
println!("{}", image);
88+
89+
let channel = device.channel(ChannelSettings::default()).await?;
90+
println!("Channel established {:?}", channel);
91+
92+
let state_recv = channel.get_ux_update_receiver();
93+
tokio::spawn(common::handle_cable_updates(state_recv));
94+
Ok((device, channel))
95+
}
96+
97+
async fn create() -> Result<(), Box<dyn Error>> {
98+
let (_device, mut channel) = connect(QrCodeOperationHint::MakeCredential).await?;
99+
100+
let extensions = MakeCredentialsRequestExtensions {
101+
prf: Some(MakeCredentialPrfInput {
102+
eval: Some(PrfInputValue {
103+
first: CREATE_EVAL_FIRST.to_vec(),
104+
second: Some(CREATE_EVAL_SECOND.to_vec()),
105+
}),
106+
}),
107+
..Default::default()
108+
};
109+
110+
let request = MakeCredentialRequest {
111+
challenge: vec![0x11; 32],
112+
origin: ORIGIN.to_owned(),
113+
top_origin: None,
114+
relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP_ID, "Example Relying Party"),
115+
user: Ctap2PublicKeyCredentialUserEntity::new(&[0x42; 16], "alice", "Alice"),
116+
resident_key: Some(ResidentKeyRequirement::Required),
117+
user_verification: UserVerificationRequirement::Preferred,
118+
algorithms: vec![Ctap2CredentialType::default()],
119+
exclude: None,
120+
extensions: Some(extensions),
121+
timeout: TIMEOUT,
122+
};
123+
124+
let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
125+
126+
let response_json = response
127+
.to_json_string(&request, JsonFormat::Prettified)
128+
.expect("Failed to serialize MakeCredential response");
129+
println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
130+
131+
let credential: Ctap2PublicKeyCredentialDescriptor =
132+
(&response.authenticator_data).try_into().unwrap();
133+
println!(
134+
"\nCredential ID (base64url): {}",
135+
base64_url::encode(&credential.id)
136+
);
137+
println!("Next: run the `get` leg, e.g.");
138+
println!(
139+
"cargo run --example webauthn_prf_cable -- get {}",
140+
base64_url::encode(&credential.id)
141+
);
142+
Ok(())
143+
}
144+
145+
async fn get(credential_id: Option<&str>) -> Result<(), Box<dyn Error>> {
146+
let (_device, mut channel) = connect(QrCodeOperationHint::GetAssertionRequest).await?;
147+
148+
let mut eval_by_credential = HashMap::new();
149+
let allow = match credential_id {
150+
Some(encoded) => {
151+
eval_by_credential.insert(
152+
encoded.to_owned(),
153+
PrfInputValue {
154+
first: BY_CRED_FIRST.to_vec(),
155+
second: Some(BY_CRED_SECOND.to_vec()),
156+
},
157+
);
158+
vec![Ctap2PublicKeyCredentialDescriptor {
159+
r#type: Ctap2PublicKeyCredentialType::PublicKey,
160+
id: ByteBuf::from(
161+
base64_url::decode(encoded)
162+
.map_err(|e| format!("invalid credential id: {e}"))?,
163+
),
164+
transports: None,
165+
}]
166+
}
167+
None => vec![],
168+
};
169+
170+
let request = GetAssertionRequest {
171+
relying_party_id: RP_ID.to_owned(),
172+
challenge: vec![0x22; 32],
173+
origin: ORIGIN.to_owned(),
174+
top_origin: None,
175+
allow,
176+
user_verification: UserVerificationRequirement::Preferred,
177+
extensions: Some(GetAssertionRequestExtensions {
178+
prf: Some(PrfInput {
179+
eval: Some(PrfInputValue {
180+
first: GET_EVAL_FIRST.to_vec(),
181+
second: Some(GET_EVAL_SECOND.to_vec()),
182+
}),
183+
eval_by_credential,
184+
}),
185+
..Default::default()
186+
}),
187+
timeout: TIMEOUT,
188+
};
189+
190+
let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap();
191+
192+
for (num, assertion) in response.assertions.iter().enumerate() {
193+
let assertion_json = assertion
194+
.to_json_string(&request, JsonFormat::Prettified)
195+
.expect("Failed to serialize GetAssertion response");
196+
println!("Assertion {num} (JSON):\n{assertion_json}");
197+
}
198+
Ok(())
199+
}

libwebauthn/src/ops/u2f.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ impl UpgradableResponse<MakeCredentialResponse, MakeCredentialRequest> for Regis
168168
attestation_statement,
169169
enterprise_attestation: None,
170170
large_blob_key: None,
171+
unsigned_extension_outputs: None,
171172
};
172173
Ok(resp.into_make_credential_output(request, None, None))
173174
}

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::BTreeMap;
12
use std::time::Duration;
23

34
use async_trait::async_trait;
@@ -25,10 +26,11 @@ use crate::{
2526
proto::{
2627
ctap1::{Ctap1RegisteredKey, Ctap1Version},
2728
ctap2::{
28-
cbor, cose, Ctap2AttestationStatement, Ctap2COSEAlgorithmIdentifier,
29-
Ctap2CredentialType, Ctap2GetInfoResponse, Ctap2MakeCredentialsResponseExtensions,
30-
Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity,
31-
Ctap2PublicKeyCredentialUserEntity,
29+
cbor, cbor::Value, cose, parse_unsigned_prf, Ctap2AttestationStatement,
30+
Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, Ctap2GetInfoResponse,
31+
Ctap2MakeCredentialsResponseExtensions, Ctap2PublicKeyCredentialDescriptor,
32+
Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity,
33+
UnsignedPrfOutput,
3234
},
3335
},
3436
transport::AuthTokenData,
@@ -203,35 +205,44 @@ impl MakeCredentialsResponseUnsignedExtensions {
203205

204206
pub fn from_signed_extensions(
205207
signed_extensions: &Option<Ctap2MakeCredentialsResponseExtensions>,
208+
unsigned_outputs: Option<&BTreeMap<Value, Value>>,
206209
request: &MakeCredentialRequest,
207210
info: Option<&Ctap2GetInfoResponse>,
208211
auth_data: Option<&AuthTokenData>,
209212
) -> MakeCredentialsResponseUnsignedExtensions {
210213
let mut hmac_create_secret = None;
211214
let mut prf = None;
212-
if let Some(signed_extensions) = signed_extensions {
213-
if let Some(incoming_ext) = &request.extensions {
214-
if incoming_ext.hmac_create_secret.is_some() {
215-
hmac_create_secret = signed_extensions.hmac_secret;
216-
}
217-
if incoming_ext.prf.is_some() {
218-
let results = signed_extensions
219-
.hmac_secret_mc
220-
.as_ref()
221-
.zip(auth_data)
222-
.and_then(|(out, auth)| {
223-
let uv_proto = auth.protocol_version.create_protocol_object();
224-
out.decrypt_output(&auth.shared_secret, uv_proto.as_ref())
225-
})
226-
.map(|decrypted| PrfOutputValue {
227-
first: decrypted.output1,
228-
second: decrypted.output2,
229-
});
230-
prf = Some(MakeCredentialPrfOutput {
231-
enabled: signed_extensions.hmac_secret,
232-
results,
215+
// Native `prf` outputs arrive in unsignedExtensionOutputs, not authData.
216+
let unsigned_prf = unsigned_outputs.and_then(parse_unsigned_prf);
217+
if let Some(incoming_ext) = &request.extensions {
218+
if incoming_ext.hmac_create_secret.is_some() {
219+
hmac_create_secret = signed_extensions.as_ref().and_then(|s| s.hmac_secret);
220+
}
221+
if incoming_ext.prf.is_some() && (signed_extensions.is_some() || unsigned_prf.is_some())
222+
{
223+
let decrypted_results = signed_extensions
224+
.as_ref()
225+
.and_then(|s| s.hmac_secret_mc.as_ref())
226+
.zip(auth_data)
227+
.and_then(|(out, auth)| {
228+
let uv_proto = auth.protocol_version.create_protocol_object();
229+
out.decrypt_output(&auth.shared_secret, uv_proto.as_ref())
230+
})
231+
.map(|decrypted| PrfOutputValue {
232+
first: decrypted.output1,
233+
second: decrypted.output2,
233234
});
234-
}
235+
let UnsignedPrfOutput {
236+
enabled: unsigned_enabled,
237+
results: unsigned_results,
238+
} = unsigned_prf.unwrap_or_default();
239+
prf = Some(MakeCredentialPrfOutput {
240+
enabled: signed_extensions
241+
.as_ref()
242+
.and_then(|s| s.hmac_secret)
243+
.or(unsigned_enabled),
244+
results: decrypted_results.or(unsigned_results),
245+
});
235246
}
236247
}
237248

@@ -1448,6 +1459,32 @@ mod tests {
14481459
);
14491460
}
14501461

1462+
#[test]
1463+
fn prf_output_serialized_into_client_extension_results() {
1464+
let mut response = create_test_response();
1465+
response.unsigned_extensions_output = MakeCredentialsResponseUnsignedExtensions {
1466+
prf: Some(MakeCredentialPrfOutput {
1467+
enabled: Some(true),
1468+
results: Some(PrfOutputValue {
1469+
first: [0xAB; 32],
1470+
second: Some([0xCD; 32]),
1471+
}),
1472+
}),
1473+
..Default::default()
1474+
};
1475+
1476+
let results = serde_json::to_value(response.build_client_extension_results()).unwrap();
1477+
assert_eq!(results["prf"]["enabled"], serde_json::json!(true));
1478+
assert_eq!(
1479+
results["prf"]["results"]["first"],
1480+
serde_json::json!(base64_url::encode(&[0xAB; 32]))
1481+
);
1482+
assert_eq!(
1483+
results["prf"]["results"]["second"],
1484+
serde_json::json!(base64_url::encode(&[0xCD; 32]))
1485+
);
1486+
}
1487+
14511488
#[test]
14521489
fn test_response_to_idl_model() {
14531490
let response = create_test_response();

libwebauthn/src/proto/ctap2/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub use model::{
2020
Ctap2UserVerificationOperation, FidoU2fAttestationStmt,
2121
};
2222

23+
pub(crate) use model::{parse_unsigned_prf, UnsignedPrfOutput};
2324
pub use model::{
2425
Ctap2AuthenticatorConfigCommand, Ctap2AuthenticatorConfigParams,
2526
Ctap2AuthenticatorConfigRequest,
@@ -34,10 +35,12 @@ pub use model::{
3435
};
3536
pub use model::{
3637
Ctap2GetAssertionRequest, Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions,
38+
Ctap2PrfGetAssertionInput, Ctap2PrfSalts,
3739
};
3840
pub use model::{Ctap2LargeBlobsRequest, Ctap2LargeBlobsResponse};
3941
pub use model::{
40-
Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, Ctap2MakeCredentialsResponseExtensions,
42+
Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse,
43+
Ctap2MakeCredentialsResponseExtensions, Ctap2PrfMakeCredentialInput,
4144
};
4245
pub mod preflight;
4346
pub use protocol::Ctap2;

libwebauthn/src/proto/ctap2/model.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ pub use client_pin::{
3232
mod make_credential;
3333
pub use make_credential::{
3434
Ctap2MakeCredentialOptions, Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse,
35-
Ctap2MakeCredentialsResponseExtensions,
35+
Ctap2MakeCredentialsResponseExtensions, Ctap2PrfMakeCredentialInput,
3636
};
3737
mod get_assertion;
38+
pub(crate) use get_assertion::{parse_unsigned_prf, UnsignedPrfOutput};
3839
pub use get_assertion::{
3940
Ctap2AttestationStatement, Ctap2GetAssertionOptions, Ctap2GetAssertionRequest,
40-
Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions, FidoU2fAttestationStmt,
41+
Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions, Ctap2PrfGetAssertionInput,
42+
Ctap2PrfSalts, FidoU2fAttestationStmt,
4143
};
4244
mod credential_management;
4345
pub use credential_management::{

0 commit comments

Comments
 (0)