Skip to content

Commit ada22af

Browse files
feat(pin): invalidate persistent tokens on rejection and pin change
1 parent b16de46 commit ada22af

5 files changed

Lines changed: 308 additions & 1 deletion

File tree

libwebauthn/src/pin/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use tracing::{error, instrument, warn};
3131
use x509_parser::nom::AsBytes;
3232

3333
pub mod persistent_token;
34+
use persistent_token::recognize_authenticator;
3435

3536
use crate::{
3637
proto::{
@@ -525,6 +526,16 @@ pub(crate) mod internal {
525526
return Err(Error::Platform(PlatformError::PinTooLong));
526527
}
527528

529+
// A successful PIN set/change invalidates this authenticator's persistent token
530+
// (resetPersistentPinUvAuthToken). Identify our record now, while the current
531+
// token still matches encIdentifier, so we can evict it once the change succeeds.
532+
let persistent_record_id = match self.persistent_token_store() {
533+
Some(store) => recognize_authenticator(store.as_ref(), get_info_response)
534+
.await
535+
.map(|(id, _)| id),
536+
None => None,
537+
};
538+
528539
let Some(uv_proto) = select_uv_proto(
529540
#[cfg(feature = "virt")]
530541
self.get_forced_pin_protocol(),
@@ -610,6 +621,13 @@ pub(crate) mod internal {
610621

611622
// On success, this is an all-empty Ctap2ClientPinResponse
612623
let _ = self.ctap2_client_pin(&req, timeout).await?;
624+
625+
// The PIN set/change cleared the persistent token; drop our now-stale record.
626+
if let Some(id) = persistent_record_id {
627+
if let Some(store) = self.persistent_token_store() {
628+
store.delete(&id).await;
629+
}
630+
}
613631
Ok(())
614632
}
615633
}

libwebauthn/src/pin/persistent_token.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ pub(crate) async fn store_minted_token(
216216
error!(len = info.aaguid.len(), "AAGUID was not 16 bytes");
217217
Error::Ctap(CtapError::Other)
218218
})?;
219+
reap_superseded_records(store, &device_identifier).await;
219220
let id = new_record_id();
220221
let record = PersistentTokenRecord {
221222
persistent_token: token.to_vec(),
@@ -228,6 +229,23 @@ pub(crate) async fn store_minted_token(
228229
Ok(id)
229230
}
230231

232+
/// Delete every stored record for this device epoch (matching device identifier). Run at
233+
/// mint time, this replaces a token superseded out of band, e.g. a PIN change made on
234+
/// another platform: the device identifier is stable across PIN changes, only the token
235+
/// resets. Records for other devices carry a different identifier and are left untouched,
236+
/// so a sibling key of the same model keeps its own token. A record left behind by an
237+
/// authenticatorReset (which regenerates the identifier) is indistinguishable from a
238+
/// sibling's and is therefore left for the embedder to prune rather than risk evicting a
239+
/// live sibling token.
240+
async fn reap_superseded_records(store: &dyn PersistentTokenStore, device_identifier: &[u8; 16]) {
241+
for (id, record) in store.list().await {
242+
if &record.device_identifier == device_identifier {
243+
debug!(?id, "Reaping superseded persistent token record");
244+
store.delete(&id).await;
245+
}
246+
}
247+
}
248+
231249
/// Test-only: build an `encIdentifier` (`iv || ct`) for a device identifier under a
232250
/// token, using the production key derivation. Shared across test modules.
233251
#[cfg(test)]
@@ -438,4 +456,49 @@ mod test {
438456
let info = Ctap2GetInfoResponse::default();
439457
assert!(recognize_authenticator(&store, &info).await.is_none());
440458
}
459+
460+
#[tokio::test]
461+
async fn mint_reaps_same_device_and_preserves_sibling() {
462+
let store = MemoryPersistentTokenStore::new();
463+
let device = [0x42; 16];
464+
let sibling = [0x99; 16];
465+
let aaguid = [0x07; 16];
466+
467+
// A stale record for this device, plus a sibling key of the same model.
468+
store
469+
.put(&"old".to_string(), &record_with(vec![0x11; 32], device))
470+
.await;
471+
store
472+
.put(
473+
&"sibling".to_string(),
474+
&record_with(vec![0x22; 32], sibling),
475+
)
476+
.await;
477+
478+
let minted = vec![0x33; 32];
479+
let info = Ctap2GetInfoResponse {
480+
aaguid: ByteBuf::from(aaguid.to_vec()),
481+
enc_identifier: Some(ByteBuf::from(build_enc_identifier(
482+
&minted,
483+
&device,
484+
&[0x55; 16],
485+
))),
486+
..Default::default()
487+
};
488+
489+
let new_id = store_minted_token(&store, &info, &minted, Ctap2PinUvAuthProtocol::One)
490+
.await
491+
.unwrap();
492+
493+
let listed = store.list().await;
494+
// The old same-device record is reaped; the sibling and the new record remain.
495+
assert_eq!(listed.len(), 2);
496+
assert!(listed.iter().all(|(id, _)| id != "old"));
497+
let new = listed.iter().find(|(id, _)| id == &new_id).unwrap();
498+
assert_eq!(new.1.device_identifier, device);
499+
assert_eq!(new.1.persistent_token, minted);
500+
let sib = listed.iter().find(|(id, _)| id == "sibling").unwrap();
501+
assert_eq!(sib.1.device_identifier, sibling);
502+
assert_eq!(sib.1.persistent_token, vec![0x22; 32]);
503+
}
441504
}

libwebauthn/src/proto/ctap2/model.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ pub enum Ctap2CommandCode {
5959
AuthenticatorCredentialManagementPreview = 0x41,
6060
AuthenticatorSelection = 0x0B,
6161
AuthenticatorConfig = 0x0D,
62+
// TODO: authenticatorReset (0x07) is not implemented. When it is added, a successful
63+
// reset must evict this device's persistent pcmr record from the persistent token
64+
// store, since reset regenerates the device identifier and invalidates the token.
6265
}
6366

6467
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

libwebauthn/src/webauthn.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ macro_rules! handle_errors {
5353
$channel.clear_uv_auth_token_store();
5454
continue;
5555
}
56+
Err(Error::Ctap(CtapError::PINAuthInvalid))
57+
if matches!($uv_auth_used, UsedPinUvAuthToken::FromPersistentStorage(_)) =>
58+
{
59+
info!("PINAuthInvalid on a persistent token: evicting the record and retrying.");
60+
if let UsedPinUvAuthToken::FromPersistentStorage(id) = &$uv_auth_used {
61+
if let Some(store) = $channel.persistent_token_store() {
62+
store.delete(id).await;
63+
}
64+
}
65+
continue;
66+
}
5667
Err(Error::Ctap(CtapError::UVInvalid)) => {
5768
let attempts_left = $channel
5869
.ctap2_client_pin(&Ctap2ClientPinRequest::new_get_uv_retries(), $timeout)

libwebauthn/src/webauthn/pin_uv_auth_token.rs

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ mod test {
612612
UvUpdate,
613613
};
614614

615-
use super::{pin_uv_auth_token_len_valid, user_verification, Error};
615+
use super::{pin_uv_auth_token_len_valid, user_verification, CtapError, Error};
616616
const TIMEOUT: Duration = Duration::from_secs(1);
617617

618618
#[test]
@@ -1680,4 +1680,216 @@ mod test {
16801680
let recv = recv_handle.await.expect("Failed to join update thread");
16811681
assert!(recv.is_empty());
16821682
}
1683+
1684+
#[tokio::test]
1685+
async fn persistent_token_self_heals_on_rejection() {
1686+
use crate::management::CredentialManagement;
1687+
1688+
let mut channel = MockChannel::new();
1689+
let token = vec![0x5A; 32];
1690+
let device_identifier = [0x42; 16];
1691+
let aaguid = [0x07; 16];
1692+
1693+
let store = Arc::new(MemoryPersistentTokenStore::new());
1694+
store
1695+
.put(
1696+
&"stale".to_string(),
1697+
&PersistentTokenRecord {
1698+
persistent_token: token.clone(),
1699+
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::One,
1700+
device_identifier,
1701+
aaguid,
1702+
},
1703+
)
1704+
.await;
1705+
channel.set_persistent_token_store(store.clone());
1706+
1707+
// The device still computes encIdentifier under our token (a PIN change cleared the
1708+
// token's permissions, not its bytes), so recognition matches but the op is rejected.
1709+
let info = pcmr_get_info(
1710+
&[
1711+
("uv", true),
1712+
("pinUvAuthToken", true),
1713+
("perCredMgmtRO", true),
1714+
],
1715+
&token,
1716+
device_identifier,
1717+
aaguid,
1718+
);
1719+
let info_resp = || CborResponse::new_success_from_slice(to_vec(&info).unwrap().as_slice());
1720+
1721+
let pin_protocol = PinUvAuthProtocolOne::new();
1722+
// The credMgmt request is identical on reuse and re-mint (same token, same data).
1723+
let mut expected_credmgmt = Ctap2CredentialManagementRequest::new_get_credential_metadata();
1724+
expected_credmgmt
1725+
.calculate_and_set_uv_auth(&pin_protocol, &token)
1726+
.unwrap();
1727+
let expected_credmgmt_cbor = CborRequest::try_from(&expected_credmgmt).unwrap();
1728+
1729+
// Iteration 1: getInfo, recognize + reuse, device rejects with PINAuthInvalid.
1730+
channel.push_command_pair(
1731+
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
1732+
info_resp(),
1733+
);
1734+
channel.push_command_pair(
1735+
expected_credmgmt_cbor.clone(),
1736+
CborResponse {
1737+
status_code: CtapError::PINAuthInvalid,
1738+
data: None,
1739+
},
1740+
);
1741+
1742+
// Iteration 2: getInfo, mint via UV (keyAgreement, getUvToken pcmr), then success.
1743+
channel.push_command_pair(
1744+
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
1745+
info_resp(),
1746+
);
1747+
let key_agreement_req = CborRequest::try_from(
1748+
&Ctap2ClientPinRequest::new_get_key_agreement(Ctap2PinUvAuthProtocol::One),
1749+
)
1750+
.unwrap();
1751+
let key_agreement_resp = CborResponse::new_success_from_slice(
1752+
to_vec(&Ctap2ClientPinResponse {
1753+
key_agreement: Some(get_key_agreement()),
1754+
pin_uv_auth_token: None,
1755+
pin_retries: None,
1756+
power_cycle_state: None,
1757+
uv_retries: None,
1758+
})
1759+
.unwrap()
1760+
.as_slice(),
1761+
);
1762+
channel.push_command_pair(key_agreement_req, key_agreement_resp);
1763+
1764+
let (public_key, shared_secret) = pin_protocol.encapsulate(&get_key_agreement()).unwrap();
1765+
let uv_token_req =
1766+
CborRequest::try_from(&Ctap2ClientPinRequest::new_get_uv_token_with_perm(
1767+
Ctap2PinUvAuthProtocol::One,
1768+
public_key,
1769+
Ctap2AuthTokenPermissionRole::PERSISTENT_CREDENTIAL_MANAGEMENT_READ_ONLY,
1770+
None,
1771+
))
1772+
.unwrap();
1773+
// The device re-grants the same persistent token (bytes unchanged).
1774+
let encrypted_token = pin_protocol.encrypt(&shared_secret, &token).unwrap();
1775+
let uv_token_resp = CborResponse::new_success_from_slice(
1776+
to_vec(&Ctap2ClientPinResponse {
1777+
key_agreement: None,
1778+
pin_uv_auth_token: Some(ByteBuf::from(encrypted_token)),
1779+
pin_retries: None,
1780+
power_cycle_state: None,
1781+
uv_retries: None,
1782+
})
1783+
.unwrap()
1784+
.as_slice(),
1785+
);
1786+
channel.push_command_pair(uv_token_req, uv_token_resp);
1787+
1788+
// CBOR map {0x01: 3, 0x02: 20}: existingResidentCredentialsCount and max remaining.
1789+
let metadata_resp = CborResponse::new_success_from_slice(&[0xA2, 0x01, 0x03, 0x02, 0x14]);
1790+
channel.push_command_pair(expected_credmgmt_cbor, metadata_resp);
1791+
1792+
let mut recv = channel.get_ux_update_receiver();
1793+
let recv_handle = tokio::task::spawn(async move {
1794+
assert_eq!(recv.recv().await, Ok(UvUpdate::PresenceRequired));
1795+
recv
1796+
});
1797+
1798+
let metadata = channel.get_credential_metadata(TIMEOUT).await.unwrap();
1799+
assert_eq!(metadata.existing_resident_credentials_count, 3);
1800+
1801+
// The stale record was evicted and replaced by a freshly minted one.
1802+
let listed = store.list().await;
1803+
assert_eq!(listed.len(), 1);
1804+
assert!(listed.iter().all(|(id, _)| id != "stale"));
1805+
assert_eq!(listed[0].1.device_identifier, device_identifier);
1806+
1807+
let recv = recv_handle.await.expect("Failed to join update thread");
1808+
assert!(recv.is_empty());
1809+
}
1810+
1811+
#[tokio::test]
1812+
async fn pin_change_evicts_persistent_record() {
1813+
use crate::pin::PinManagement;
1814+
1815+
let mut channel = MockChannel::new();
1816+
let token = vec![0x5A; 32];
1817+
let device_identifier = [0x42; 16];
1818+
let aaguid = [0x07; 16];
1819+
1820+
let store = Arc::new(MemoryPersistentTokenStore::new());
1821+
store
1822+
.put(
1823+
&"to-evict".to_string(),
1824+
&PersistentTokenRecord {
1825+
persistent_token: token.clone(),
1826+
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::One,
1827+
device_identifier,
1828+
aaguid,
1829+
},
1830+
)
1831+
.await;
1832+
channel.set_persistent_token_store(store.clone());
1833+
1834+
// Set-PIN path (clientPin=false): no current-PIN prompt.
1835+
let info = pcmr_get_info(
1836+
&[("clientPin", false), ("perCredMgmtRO", true)],
1837+
&token,
1838+
device_identifier,
1839+
aaguid,
1840+
);
1841+
channel.push_command_pair(
1842+
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
1843+
CborResponse::new_success_from_slice(to_vec(&info).unwrap().as_slice()),
1844+
);
1845+
1846+
let key_agreement_req = CborRequest::try_from(
1847+
&Ctap2ClientPinRequest::new_get_key_agreement(Ctap2PinUvAuthProtocol::One),
1848+
)
1849+
.unwrap();
1850+
let key_agreement_resp = CborResponse::new_success_from_slice(
1851+
to_vec(&Ctap2ClientPinResponse {
1852+
key_agreement: Some(get_key_agreement()),
1853+
pin_uv_auth_token: None,
1854+
pin_retries: None,
1855+
power_cycle_state: None,
1856+
uv_retries: None,
1857+
})
1858+
.unwrap()
1859+
.as_slice(),
1860+
);
1861+
channel.push_command_pair(key_agreement_req, key_agreement_resp);
1862+
1863+
let pin_protocol = PinUvAuthProtocolOne::new();
1864+
let (public_key, shared_secret) = pin_protocol.encapsulate(&get_key_agreement()).unwrap();
1865+
let mut padded_new_pin = "1234".as_bytes().to_vec();
1866+
padded_new_pin.resize(64, 0x00);
1867+
let new_pin_enc = pin_protocol
1868+
.encrypt(&shared_secret, &padded_new_pin)
1869+
.unwrap();
1870+
let uv_auth_param = pin_protocol
1871+
.authenticate(&shared_secret, &new_pin_enc)
1872+
.unwrap();
1873+
let set_pin_req = CborRequest::try_from(&Ctap2ClientPinRequest::new_set_pin(
1874+
pin_protocol.version(),
1875+
&new_pin_enc,
1876+
public_key,
1877+
&uv_auth_param,
1878+
))
1879+
.unwrap();
1880+
let set_pin_resp = CborResponse::new_success_from_slice(
1881+
to_vec(&Ctap2ClientPinResponse::default())
1882+
.unwrap()
1883+
.as_slice(),
1884+
);
1885+
channel.push_command_pair(set_pin_req, set_pin_resp);
1886+
1887+
channel
1888+
.change_pin("1234".to_string(), TIMEOUT)
1889+
.await
1890+
.unwrap();
1891+
1892+
// The connected device's record is evicted after a successful PIN change.
1893+
assert!(store.list().await.is_empty());
1894+
}
16831895
}

0 commit comments

Comments
 (0)