Skip to content
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
29 changes: 22 additions & 7 deletions libwebauthn/src/management/credential_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ where
self,
self.ctap2_credential_management(&req, timeout).await,
uv_auth_used,
timeout
timeout,
req
)
}?;
let metadata = Ctap2CredentialManagementMetadata::new(
Expand Down Expand Up @@ -106,7 +107,8 @@ where
self,
self.ctap2_credential_management(&req, timeout).await,
uv_auth_used,
timeout
timeout,
req
)
}?;
Ok((
Expand Down Expand Up @@ -134,7 +136,8 @@ where
self,
self.ctap2_credential_management(&req, timeout).await,
uv_auth_used,
timeout
timeout,
req
)
}?;
Ok(Ctap2RPData::new(
Expand Down Expand Up @@ -163,7 +166,8 @@ where
self,
self.ctap2_credential_management(&req, timeout).await,
uv_auth_used,
timeout
timeout,
req
)
}?;
let cred = Ctap2CredentialData::new(
Expand Down Expand Up @@ -196,7 +200,8 @@ where
self,
self.ctap2_credential_management(&req, timeout).await,
uv_auth_used,
timeout
timeout,
req
)
}?;
let cred = Ctap2CredentialData::new(
Expand Down Expand Up @@ -229,7 +234,8 @@ where
self,
self.ctap2_credential_management(&req, timeout).await,
uv_auth_used,
timeout
timeout,
req
)
}?;
Ok(())
Expand Down Expand Up @@ -262,7 +268,8 @@ where
self,
self.ctap2_credential_management(&req, timeout).await,
uv_auth_used,
timeout
timeout,
req
)
}?;
Ok(())
Expand Down Expand Up @@ -339,4 +346,12 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest {
fn wants_persistent_token(&self) -> bool {
self.use_persistent_token
}

fn note_persistent_token_rejected(&mut self) {
self.persistent_token_rejected = true;
}

fn persistent_token_rejected(&self) -> bool {
self.persistent_token_rejected
}
}
18 changes: 18 additions & 0 deletions libwebauthn/src/pin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use tracing::{error, instrument, warn};
use x509_parser::nom::AsBytes;

pub mod persistent_token;
use persistent_token::recognize_authenticator;

use crate::{
proto::{
Expand Down Expand Up @@ -525,6 +526,16 @@ pub(crate) mod internal {
return Err(Error::Platform(PlatformError::PinTooLong));
}

// A successful PIN set/change invalidates this authenticator's persistent token
// (resetPersistentPinUvAuthToken). Identify our record now, while the current
// token still matches encIdentifier, so we can evict it once the change succeeds.
let persistent_record_id = match self.persistent_token_store() {
Some(store) => recognize_authenticator(store.as_ref(), get_info_response)
.await
.map(|(id, _)| id),
None => None,
};

let Some(uv_proto) = select_uv_proto(
#[cfg(feature = "virt")]
self.get_forced_pin_protocol(),
Expand Down Expand Up @@ -610,6 +621,13 @@ pub(crate) mod internal {

// On success, this is an all-empty Ctap2ClientPinResponse
let _ = self.ctap2_client_pin(&req, timeout).await?;

// The PIN set/change cleared the persistent token; drop our now-stale record.
if let Some(id) = persistent_record_id {
if let Some(store) = self.persistent_token_store() {
store.delete(&id).await;
}
}
Ok(())
}
}
Expand Down
63 changes: 63 additions & 0 deletions libwebauthn/src/pin/persistent_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ pub(crate) async fn store_minted_token(
error!(len = info.aaguid.len(), "AAGUID was not 16 bytes");
Error::Ctap(CtapError::Other)
})?;
reap_superseded_records(store, &device_identifier).await;
let id = new_record_id();
let record = PersistentTokenRecord {
persistent_token: token.to_vec(),
Expand All @@ -228,6 +229,23 @@ pub(crate) async fn store_minted_token(
Ok(id)
}

/// Delete every stored record for this device epoch (matching device identifier). Run at
/// mint time, this replaces a token superseded out of band, e.g. a PIN change made on
/// another platform: the device identifier is stable across PIN changes, only the token
/// resets. Records for other devices carry a different identifier and are left untouched,
/// so a sibling key of the same model keeps its own token. A record left behind by an
/// authenticatorReset (which regenerates the identifier) is indistinguishable from a
/// sibling's and is therefore left for the embedder to prune rather than risk evicting a
/// live sibling token.
async fn reap_superseded_records(store: &dyn PersistentTokenStore, device_identifier: &[u8; 16]) {
for (id, record) in store.list().await {
if &record.device_identifier == device_identifier {
debug!(?id, "Reaping superseded persistent token record");
store.delete(&id).await;
}
}
}

/// Test-only: build an `encIdentifier` (`iv || ct`) for a device identifier under a
/// token, using the production key derivation. Shared across test modules.
#[cfg(test)]
Expand Down Expand Up @@ -474,4 +492,49 @@ mod test {
let info = Ctap2GetInfoResponse::default();
assert!(recognize_authenticator(&store, &info).await.is_none());
}

#[tokio::test]
async fn mint_reaps_same_device_and_preserves_sibling() {
let store = MemoryPersistentTokenStore::new();
let device = [0x42; 16];
let sibling = [0x99; 16];
let aaguid = [0x07; 16];

// A stale record for this device, plus a sibling key of the same model.
store
.put(&"old".to_string(), &record_with(vec![0x11; 32], device))
.await;
store
.put(
&"sibling".to_string(),
&record_with(vec![0x22; 32], sibling),
)
.await;

let minted = vec![0x33; 32];
let info = Ctap2GetInfoResponse {
aaguid: ByteBuf::from(aaguid.to_vec()),
enc_identifier: Some(ByteBuf::from(build_enc_identifier(
&minted,
&device,
&[0x55; 16],
))),
..Default::default()
};

let new_id = store_minted_token(&store, &info, &minted, Ctap2PinUvAuthProtocol::One)
.await
.unwrap();

let listed = store.list().await;
// The old same-device record is reaped; the sibling and the new record remain.
assert_eq!(listed.len(), 2);
assert!(listed.iter().all(|(id, _)| id != "old"));
let new = listed.iter().find(|(id, _)| id == &new_id).unwrap();
assert_eq!(new.1.device_identifier, device);
assert_eq!(new.1.persistent_token, minted);
let sib = listed.iter().find(|(id, _)| id == "sibling").unwrap();
assert_eq!(sib.1.device_identifier, sibling);
assert_eq!(sib.1.persistent_token, vec![0x22; 32]);
}
}
11 changes: 11 additions & 0 deletions libwebauthn/src/proto/ctap2/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ pub enum Ctap2CommandCode {
AuthenticatorCredentialManagementPreview = 0x41,
AuthenticatorSelection = 0x0B,
AuthenticatorConfig = 0x0D,
// TODO: authenticatorReset (0x07) is not implemented. When it is added, a successful
// reset must evict this device's persistent pcmr record from the persistent token
// store, since reset regenerates the device identifier and invalidates the token.
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -319,6 +322,14 @@ pub trait Ctap2UserVerifiableRequest {
fn wants_persistent_token(&self) -> bool {
false
}
/// Record that a reused persistent (pcmr) token was rejected by the authenticator, so
/// the retry stops reusing it and mints a fresh one instead. Default: no-op.
fn note_persistent_token_rejected(&mut self) {}
/// Whether a reused persistent token was already rejected during this ceremony, per
/// [`Self::note_persistent_token_rejected`]. Default false.
fn persistent_token_rejected(&self) -> bool {
false
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down
12 changes: 12 additions & 0 deletions libwebauthn/src/proto/ctap2/model/credential_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ pub struct Ctap2CredentialManagementRequest {
/// Set from getInfo and store availability before `permissions()` is read.
#[serde(skip)]
pub use_persistent_token: bool,

/// Set once a reused persistent token is rejected, so the retry skips recognition and
/// mints a fresh token instead of looping on the same stale record.
#[serde(skip)]
pub persistent_token_rejected: bool,
}

#[repr(u32)]
Expand Down Expand Up @@ -150,6 +155,7 @@ impl Ctap2CredentialManagementRequest {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
}
}

Expand All @@ -161,6 +167,7 @@ impl Ctap2CredentialManagementRequest {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
}
}

Expand All @@ -172,6 +179,7 @@ impl Ctap2CredentialManagementRequest {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
}
}

Expand All @@ -187,6 +195,7 @@ impl Ctap2CredentialManagementRequest {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
}
}

Expand All @@ -200,6 +209,7 @@ impl Ctap2CredentialManagementRequest {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
}
}

Expand All @@ -215,6 +225,7 @@ impl Ctap2CredentialManagementRequest {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
}
}

Expand All @@ -233,6 +244,7 @@ impl Ctap2CredentialManagementRequest {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions libwebauthn/src/proto/ctap2/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ mod tests {
uv_auth_param: None,
use_legacy_preview: false,
use_persistent_token: false,
persistent_token_rejected: false,
};
let expected_request: CborRequest = (&request).try_into().unwrap();
channel.push_command_pair(expected_request, error_response(CtapError::PINRequired));
Expand Down
25 changes: 25 additions & 0 deletions libwebauthn/src/webauthn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,20 @@ fn prf_forces_uv_upgrade(prf_present: bool, uv: UserVerificationRequirement) ->
}

macro_rules! handle_errors {
// Callers that never reuse a persistent token (make-credential, get-assertion,
// authenticator-config, bio-enrollment): nothing to notify on a persistent rejection.
($channel: expr, $resp: expr, $uv_auth_used: expr, $timeout: expr) => {
handle_errors!(@inner $channel, $resp, $uv_auth_used, $timeout, {})
};
// Credential-management callers pass their request so a rejected persistent token is
// marked on it, forcing the retry to mint a fresh token instead of reusing the same
// stale record. This keeps loop termination independent of the best-effort store delete.
($channel: expr, $resp: expr, $uv_auth_used: expr, $timeout: expr, $request: expr) => {
handle_errors!(@inner $channel, $resp, $uv_auth_used, $timeout, {
$request.note_persistent_token_rejected();
})
};
(@inner $channel: expr, $resp: expr, $uv_auth_used: expr, $timeout: expr, $on_persistent_reject: block) => {
match $resp {
Err(Error::Ctap(CtapError::PINAuthInvalid))
if $uv_auth_used == UsedPinUvAuthToken::FromEphemeralStorage =>
Expand All @@ -53,6 +66,18 @@ macro_rules! handle_errors {
$channel.clear_uv_auth_token_store();
continue;
}
Err(Error::Ctap(CtapError::PINAuthInvalid))
if matches!($uv_auth_used, UsedPinUvAuthToken::FromPersistentStorage(_)) =>
{
info!("PINAuthInvalid on a persistent token: evicting the record and retrying.");
if let UsedPinUvAuthToken::FromPersistentStorage(id) = &$uv_auth_used {
if let Some(store) = $channel.persistent_token_store() {
store.delete(id).await;
}
}
$on_persistent_reject
continue;
}
Err(Error::Ctap(CtapError::UVInvalid)) => {
let attempts_left = $channel
.ctap2_client_pin(&Ctap2ClientPinRequest::new_get_uv_retries(), $timeout)
Expand Down
Loading
Loading