Skip to content
Draft
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
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 @@ -438,4 +456,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]);
}
}
3 changes: 3 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
11 changes: 11 additions & 0 deletions libwebauthn/src/webauthn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ 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;
}
}
continue;
}
Err(Error::Ctap(CtapError::UVInvalid)) => {
let attempts_left = $channel
.ctap2_client_pin(&Ctap2ClientPinRequest::new_get_uv_retries(), $timeout)
Expand Down
214 changes: 213 additions & 1 deletion libwebauthn/src/webauthn/pin_uv_auth_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ mod test {
UvUpdate,
};

use super::{pin_uv_auth_token_len_valid, user_verification, Error};
use super::{pin_uv_auth_token_len_valid, user_verification, CtapError, Error};
const TIMEOUT: Duration = Duration::from_secs(1);

#[test]
Expand Down Expand Up @@ -1680,4 +1680,216 @@ mod test {
let recv = recv_handle.await.expect("Failed to join update thread");
assert!(recv.is_empty());
}

#[tokio::test]
async fn persistent_token_self_heals_on_rejection() {
use crate::management::CredentialManagement;

let mut channel = MockChannel::new();
let token = vec![0x5A; 32];
let device_identifier = [0x42; 16];
let aaguid = [0x07; 16];

let store = Arc::new(MemoryPersistentTokenStore::new());
store
.put(
&"stale".to_string(),
&PersistentTokenRecord {
persistent_token: token.clone(),
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::One,
device_identifier,
aaguid,
},
)
.await;
channel.set_persistent_token_store(store.clone());

// The device still computes encIdentifier under our token (a PIN change cleared the
// token's permissions, not its bytes), so recognition matches but the op is rejected.
let info = pcmr_get_info(
&[
("uv", true),
("pinUvAuthToken", true),
("perCredMgmtRO", true),
],
&token,
device_identifier,
aaguid,
);
let info_resp = || CborResponse::new_success_from_slice(to_vec(&info).unwrap().as_slice());

let pin_protocol = PinUvAuthProtocolOne::new();
// The credMgmt request is identical on reuse and re-mint (same token, same data).
let mut expected_credmgmt = Ctap2CredentialManagementRequest::new_get_credential_metadata();
expected_credmgmt
.calculate_and_set_uv_auth(&pin_protocol, &token)
.unwrap();
let expected_credmgmt_cbor = CborRequest::try_from(&expected_credmgmt).unwrap();

// Iteration 1: getInfo, recognize + reuse, device rejects with PINAuthInvalid.
channel.push_command_pair(
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
info_resp(),
);
channel.push_command_pair(
expected_credmgmt_cbor.clone(),
CborResponse {
status_code: CtapError::PINAuthInvalid,
data: None,
},
);

// Iteration 2: getInfo, mint via UV (keyAgreement, getUvToken pcmr), then success.
channel.push_command_pair(
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
info_resp(),
);
let key_agreement_req = CborRequest::try_from(
&Ctap2ClientPinRequest::new_get_key_agreement(Ctap2PinUvAuthProtocol::One),
)
.unwrap();
let key_agreement_resp = CborResponse::new_success_from_slice(
to_vec(&Ctap2ClientPinResponse {
key_agreement: Some(get_key_agreement()),
pin_uv_auth_token: None,
pin_retries: None,
power_cycle_state: None,
uv_retries: None,
})
.unwrap()
.as_slice(),
);
channel.push_command_pair(key_agreement_req, key_agreement_resp);

let (public_key, shared_secret) = pin_protocol.encapsulate(&get_key_agreement()).unwrap();
let uv_token_req =
CborRequest::try_from(&Ctap2ClientPinRequest::new_get_uv_token_with_perm(
Ctap2PinUvAuthProtocol::One,
public_key,
Ctap2AuthTokenPermissionRole::PERSISTENT_CREDENTIAL_MANAGEMENT_READ_ONLY,
None,
))
.unwrap();
// The device re-grants the same persistent token (bytes unchanged).
let encrypted_token = pin_protocol.encrypt(&shared_secret, &token).unwrap();
let uv_token_resp = CborResponse::new_success_from_slice(
to_vec(&Ctap2ClientPinResponse {
key_agreement: None,
pin_uv_auth_token: Some(ByteBuf::from(encrypted_token)),
pin_retries: None,
power_cycle_state: None,
uv_retries: None,
})
.unwrap()
.as_slice(),
);
channel.push_command_pair(uv_token_req, uv_token_resp);

// CBOR map {0x01: 3, 0x02: 20}: existingResidentCredentialsCount and max remaining.
let metadata_resp = CborResponse::new_success_from_slice(&[0xA2, 0x01, 0x03, 0x02, 0x14]);
channel.push_command_pair(expected_credmgmt_cbor, metadata_resp);

let mut recv = channel.get_ux_update_receiver();
let recv_handle = tokio::task::spawn(async move {
assert_eq!(recv.recv().await, Ok(UvUpdate::PresenceRequired));
recv
});

let metadata = channel.get_credential_metadata(TIMEOUT).await.unwrap();
assert_eq!(metadata.existing_resident_credentials_count, 3);

// The stale record was evicted and replaced by a freshly minted one.
let listed = store.list().await;
assert_eq!(listed.len(), 1);
assert!(listed.iter().all(|(id, _)| id != "stale"));
assert_eq!(listed[0].1.device_identifier, device_identifier);

let recv = recv_handle.await.expect("Failed to join update thread");
assert!(recv.is_empty());
}

#[tokio::test]
async fn pin_change_evicts_persistent_record() {
use crate::pin::PinManagement;

let mut channel = MockChannel::new();
let token = vec![0x5A; 32];
let device_identifier = [0x42; 16];
let aaguid = [0x07; 16];

let store = Arc::new(MemoryPersistentTokenStore::new());
store
.put(
&"to-evict".to_string(),
&PersistentTokenRecord {
persistent_token: token.clone(),
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::One,
device_identifier,
aaguid,
},
)
.await;
channel.set_persistent_token_store(store.clone());

// Set-PIN path (clientPin=false): no current-PIN prompt.
let info = pcmr_get_info(
&[("clientPin", false), ("perCredMgmtRO", true)],
&token,
device_identifier,
aaguid,
);
channel.push_command_pair(
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
CborResponse::new_success_from_slice(to_vec(&info).unwrap().as_slice()),
);

let key_agreement_req = CborRequest::try_from(
&Ctap2ClientPinRequest::new_get_key_agreement(Ctap2PinUvAuthProtocol::One),
)
.unwrap();
let key_agreement_resp = CborResponse::new_success_from_slice(
to_vec(&Ctap2ClientPinResponse {
key_agreement: Some(get_key_agreement()),
pin_uv_auth_token: None,
pin_retries: None,
power_cycle_state: None,
uv_retries: None,
})
.unwrap()
.as_slice(),
);
channel.push_command_pair(key_agreement_req, key_agreement_resp);

let pin_protocol = PinUvAuthProtocolOne::new();
let (public_key, shared_secret) = pin_protocol.encapsulate(&get_key_agreement()).unwrap();
let mut padded_new_pin = "1234".as_bytes().to_vec();
padded_new_pin.resize(64, 0x00);
let new_pin_enc = pin_protocol
.encrypt(&shared_secret, &padded_new_pin)
.unwrap();
let uv_auth_param = pin_protocol
.authenticate(&shared_secret, &new_pin_enc)
.unwrap();
let set_pin_req = CborRequest::try_from(&Ctap2ClientPinRequest::new_set_pin(
pin_protocol.version(),
&new_pin_enc,
public_key,
&uv_auth_param,
))
.unwrap();
let set_pin_resp = CborResponse::new_success_from_slice(
to_vec(&Ctap2ClientPinResponse::default())
.unwrap()
.as_slice(),
);
channel.push_command_pair(set_pin_req, set_pin_resp);

channel
.change_pin("1234".to_string(), TIMEOUT)
.await
.unwrap();

// The connected device's record is evicted after a successful PIN change.
assert!(store.list().await.is_empty());
}
}
Loading