From 3350410e47bb21e79e15fa1da66d41217d688600 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 28 May 2026 22:18:46 +0100 Subject: [PATCH 1/4] feat(pin): add persistent token device recognition --- libwebauthn/src/pin/persistent_token.rs | 231 +++++++++++++++++++++++- 1 file changed, 229 insertions(+), 2 deletions(-) diff --git a/libwebauthn/src/pin/persistent_token.rs b/libwebauthn/src/pin/persistent_token.rs index 74876458..8184480c 100644 --- a/libwebauthn/src/pin/persistent_token.rs +++ b/libwebauthn/src/pin/persistent_token.rs @@ -2,12 +2,25 @@ use std::collections::HashMap; use std::fmt; use std::sync::Arc; +use aes::cipher::{block_padding::NoPadding, BlockDecryptMut}; use async_trait::async_trait; +use cbc::cipher::KeyIvInit; +use hkdf::Hkdf; +use sha2::Sha256; use tokio::sync::Mutex; -use tracing::{debug, trace}; +use tracing::{debug, error, trace}; use zeroize::ZeroizeOnDrop; -use crate::proto::ctap2::Ctap2PinUvAuthProtocol; +use crate::proto::ctap2::{Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol}; +use crate::proto::CtapError; +use crate::webauthn::error::{Error, PlatformError}; + +type Aes128CbcDecryptor = cbc::Decryptor; + +/// HKDF salt for `encIdentifier`/`encCredStoreState`: 32 zero bytes (CTAP 2.3-PS 6.4). +const ENC_IDENTIFIER_HKDF_SALT: [u8; 32] = [0u8; 32]; +/// HKDF info string binding the derived key to the `encIdentifier` use. +const ENC_IDENTIFIER_HKDF_INFO: &[u8] = b"encIdentifier"; /// Opaque identifier for a stored persistent-token record. Random per record. pub type PersistentTokenRecordId = String; @@ -109,10 +122,86 @@ impl PersistentTokenStore for MemoryPersistentTokenStore { } } +/// Derive the 16-byte AES-128 key for `encIdentifier` from a persistent token, per +/// CTAP 2.3-PS 6.4: `HKDF-SHA-256(salt = 32 zero bytes, IKM = token, L = 16, info = "encIdentifier")`. +#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change +fn enc_identifier_key(token: &[u8]) -> Result<[u8; 16], Error> { + let hkdf = Hkdf::::new(Some(&ENC_IDENTIFIER_HKDF_SALT), token); + let mut key = [0u8; 16]; + hkdf.expand(ENC_IDENTIFIER_HKDF_INFO, &mut key) + .map_err(|e| { + error!("HKDF expand error deriving encIdentifier key: {e}"); + Error::Platform(PlatformError::CryptoError(format!( + "HKDF expand error: {e}" + ))) + })?; + Ok(key) +} + +/// Recover the 128-bit device identifier from an `encIdentifier` (`iv || ct`) using a +/// persistent token. `ct` is exactly one AES block, so decryption uses no padding. +#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change +pub(crate) fn decrypt_enc_identifier( + token: &[u8], + enc_identifier: &[u8], +) -> Result<[u8; 16], Error> { + if enc_identifier.len() != 32 { + error!( + len = enc_identifier.len(), + "encIdentifier is not a 16-byte IV followed by one 16-byte ciphertext block" + ); + return Err(Error::Ctap(CtapError::Other)); + } + let (iv, ciphertext) = enc_identifier.split_at(16); + let key = enc_identifier_key(token)?; + let Ok(decryptor) = Aes128CbcDecryptor::new_from_slices(&key, iv) else { + error!("Invalid key or IV for AES-128-CBC encIdentifier decryption"); + return Err(Error::Ctap(CtapError::Other)); + }; + let Ok(plaintext) = decryptor.decrypt_padded_vec_mut::(ciphertext) else { + error!("Decrypt error while recovering device identifier"); + return Err(Error::Ctap(CtapError::Other)); + }; + plaintext.try_into().map_err(|_| { + error!("Recovered device identifier was not 16 bytes"); + Error::Ctap(CtapError::Other) + }) +} + +/// Find the stored record whose persistent token reproduces this authenticator's +/// `encIdentifier`. The IV is fresh on every getInfo, so raw bytes never compare equal +/// across connections; recognition is decrypt-and-compare against each record's stored +/// device identifier. Returns the first match, or `None` if no stored token fits. +#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change +pub(crate) async fn recognize_authenticator( + store: &dyn PersistentTokenStore, + info: &Ctap2GetInfoResponse, +) -> Option<(PersistentTokenRecordId, PersistentTokenRecord)> { + let enc_identifier = info.enc_identifier.as_ref()?; + for (id, record) in store.list().await { + match decrypt_enc_identifier(&record.persistent_token, enc_identifier) { + Ok(device_identifier) if device_identifier == record.device_identifier => { + debug!(?id, "Recognized authenticator from persistent token store"); + return Some((id, record)); + } + _ => {} + } + } + None +} + #[cfg(test)] mod test { use super::*; + use aes::cipher::{block_padding::NoPadding as TestNoPadding, BlockEncryptMut}; + use cbc::cipher::KeyIvInit as TestKeyIvInit; + use serde_bytes::ByteBuf; + + use crate::proto::ctap2::Ctap2GetInfoResponse; + + type Aes128CbcEncryptor = cbc::Encryptor; + fn sample_record() -> PersistentTokenRecord { PersistentTokenRecord { persistent_token: vec![0xAB; 32], @@ -122,6 +211,33 @@ mod test { } } + /// The authenticator side: produce `iv || ct` for a device identifier under a token, + /// using the same key derivation the recognition path uses. + fn build_enc_identifier(token: &[u8], device_identifier: &[u8; 16], iv: &[u8; 16]) -> Vec { + let key = enc_identifier_key(token).unwrap(); + let encryptor = Aes128CbcEncryptor::new_from_slices(&key, iv).unwrap(); + let ciphertext = encryptor.encrypt_padded_vec_mut::(device_identifier); + let mut enc = iv.to_vec(); + enc.extend_from_slice(&ciphertext); + enc + } + + fn record_with(token: Vec, device_identifier: [u8; 16]) -> PersistentTokenRecord { + PersistentTokenRecord { + persistent_token: token, + pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::Two, + device_identifier, + aaguid: [0x22; 16], + } + } + + fn info_with_enc_identifier(enc_identifier: Vec) -> Ctap2GetInfoResponse { + Ctap2GetInfoResponse { + enc_identifier: Some(ByteBuf::from(enc_identifier)), + ..Default::default() + } + } + #[tokio::test] async fn put_list_delete_round_trip() { let store = MemoryPersistentTokenStore::new(); @@ -170,4 +286,115 @@ mod test { fn assert_zeroize_on_drop() {} assert_zeroize_on_drop::(); } + + #[test] + fn decrypt_enc_identifier_round_trips() { + let token = vec![0x07; 32]; + let device_identifier = [0x42; 16]; + let enc = build_enc_identifier(&token, &device_identifier, &[0x99; 16]); + assert_eq!( + decrypt_enc_identifier(&token, &enc).unwrap(), + device_identifier + ); + } + + #[test] + fn decrypt_enc_identifier_rejects_bad_length() { + let token = vec![0x07; 32]; + assert!(decrypt_enc_identifier(&token, &[0u8; 31]).is_err()); + assert!(decrypt_enc_identifier(&token, &[0u8; 33]).is_err()); + assert!(decrypt_enc_identifier(&token, &[]).is_err()); + } + + #[tokio::test] + async fn recognizes_matching_record() { + let store = MemoryPersistentTokenStore::new(); + let token = vec![0x07; 32]; + let device_identifier = [0x42; 16]; + store + .put( + &"id-1".to_string(), + &record_with(token.clone(), device_identifier), + ) + .await; + + // A second getInfo uses a fresh IV, so the bytes differ but recognition holds. + let info = info_with_enc_identifier(build_enc_identifier( + &token, + &device_identifier, + &[0x33; 16], + )); + let (id, record) = recognize_authenticator(&store, &info).await.unwrap(); + assert_eq!(id, "id-1"); + assert_eq!(record.device_identifier, device_identifier); + } + + #[tokio::test] + async fn rejects_wrong_token() { + let store = MemoryPersistentTokenStore::new(); + let real_token = vec![0x07; 32]; + let device_identifier = [0x42; 16]; + // Stored record carries a different token, so its key cannot reproduce the id. + store + .put( + &"id-1".to_string(), + &record_with(vec![0xFF; 32], device_identifier), + ) + .await; + + let info = info_with_enc_identifier(build_enc_identifier( + &real_token, + &device_identifier, + &[0x33; 16], + )); + assert!(recognize_authenticator(&store, &info).await.is_none()); + } + + #[tokio::test] + async fn rejects_stale_device_identifier() { + let store = MemoryPersistentTokenStore::new(); + let token = vec![0x07; 32]; + // Right token, but the stored device identifier is stale (e.g. after a reset). + store + .put(&"id-1".to_string(), &record_with(token.clone(), [0x00; 16])) + .await; + + let info = info_with_enc_identifier(build_enc_identifier(&token, &[0x42; 16], &[0x33; 16])); + assert!(recognize_authenticator(&store, &info).await.is_none()); + } + + #[tokio::test] + async fn picks_correct_record_among_many() { + let store = MemoryPersistentTokenStore::new(); + store + .put( + &"other".to_string(), + &record_with(vec![0x01; 32], [0xAA; 16]), + ) + .await; + let token = vec![0x07; 32]; + let device_identifier = [0x42; 16]; + store + .put( + &"target".to_string(), + &record_with(token.clone(), device_identifier), + ) + .await; + + let info = info_with_enc_identifier(build_enc_identifier( + &token, + &device_identifier, + &[0x33; 16], + )); + let (id, _) = recognize_authenticator(&store, &info).await.unwrap(); + assert_eq!(id, "target"); + } + + #[tokio::test] + async fn none_without_enc_identifier() { + let store = MemoryPersistentTokenStore::new(); + store.put(&"id-1".to_string(), &sample_record()).await; + let info = Ctap2GetInfoResponse::default(); + assert!(recognize_authenticator(&store, &info).await.is_none()); + } } From 7bbdd5364750e1330808e08731159f62416a9eb5 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 28 May 2026 22:31:55 +0100 Subject: [PATCH 2/4] feat(credmgmt): request pcmr for read-only subcommands --- .../src/management/credential_management.rs | 19 ++++++- libwebauthn/src/proto/ctap2/model.rs | 9 +++ .../ctap2/model/credential_management.rs | 56 +++++++++++++++++++ libwebauthn/src/proto/ctap2/protocol.rs | 1 + 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/libwebauthn/src/management/credential_management.rs b/libwebauthn/src/management/credential_management.rs index f8d14d81..cecf4304 100644 --- a/libwebauthn/src/management/credential_management.rs +++ b/libwebauthn/src/management/credential_management.rs @@ -295,7 +295,12 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest { } fn permissions(&self) -> Ctap2AuthTokenPermissionRole { - Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT + if self.use_persistent_token { + // pcmr MUST be the sole permission requested (CTAP 2.3-PS 6.5.5.7). + Ctap2AuthTokenPermissionRole::PERSISTENT_CREDENTIAL_MANAGEMENT_READ_ONLY + } else { + Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT + } } fn permissions_rpid(&self) -> Option<&str> { @@ -322,4 +327,16 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest { fn needs_shared_secret(&self, _get_info_response: &Ctap2GetInfoResponse) -> bool { false } + + fn set_persistent_token_use(&mut self, info: &Ctap2GetInfoResponse, store_available: bool) { + self.use_persistent_token = store_available + && info.supports_persistent_credential_management_read_only() + && self + .subcommand + .is_some_and(|subcommand| subcommand.is_read_only()); + } + + fn wants_persistent_token(&self) -> bool { + self.use_persistent_token + } } diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index bc4f8a6d..c01f88e2 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -310,6 +310,15 @@ pub trait Ctap2UserVerifiableRequest { fn handle_legacy_preview(&mut self, info: &Ctap2GetInfoResponse); /// We need to establish a shared secret, even if no PIN or UV is set on the device fn needs_shared_secret(&self, info: &Ctap2GetInfoResponse) -> bool; + /// Decide, and cache on the request, whether to acquire a persistent (pcmr) token. + /// Called once from the UV flow with whether a persistent token store is available. + /// Default: never request one. + fn set_persistent_token_use(&mut self, _info: &Ctap2GetInfoResponse, _store_available: bool) {} + /// Whether this request will reuse or mint a persistent (pcmr) token, per the cached + /// decision from [`Self::set_persistent_token_use`]. Default false. + fn wants_persistent_token(&self) -> bool { + false + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/libwebauthn/src/proto/ctap2/model/credential_management.rs b/libwebauthn/src/proto/ctap2/model/credential_management.rs index 85e994dc..4517cd3b 100644 --- a/libwebauthn/src/proto/ctap2/model/credential_management.rs +++ b/libwebauthn/src/proto/ctap2/model/credential_management.rs @@ -31,6 +31,11 @@ pub struct Ctap2CredentialManagementRequest { #[serde(skip)] pub use_legacy_preview: bool, + + /// Cached gate: request a persistent (pcmr) token instead of an ephemeral `cm` one. + /// Set from getInfo and store availability before `permissions()` is read. + #[serde(skip)] + pub use_persistent_token: bool, } #[repr(u32)] @@ -45,6 +50,21 @@ pub enum Ctap2CredentialManagementSubcommand { UpdateUserInformation = 0x07, } +impl Ctap2CredentialManagementSubcommand { + /// Read-only subcommands can be authorized by a persistent (pcmr) token; the write + /// subcommands (deleteCredential, updateUserInformation) cannot. + pub fn is_read_only(self) -> bool { + matches!( + self, + Self::GetCredsMetadata + | Self::EnumerateRPsBegin + | Self::EnumerateRPsGetNextRP + | Self::EnumerateCredentialsBegin + | Self::EnumerateCredentialsGetNextCredential + ) + } +} + #[derive(Debug, Clone, SerializeIndexed)] pub struct Ctap2CredentialManagementParams { // rpIDHash (0x01) Byte String RP ID SHA-256 hash @@ -129,6 +149,7 @@ impl Ctap2CredentialManagementRequest { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, } } @@ -139,6 +160,7 @@ impl Ctap2CredentialManagementRequest { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, } } @@ -149,6 +171,7 @@ impl Ctap2CredentialManagementRequest { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, } } @@ -163,6 +186,7 @@ impl Ctap2CredentialManagementRequest { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, } } @@ -175,6 +199,7 @@ impl Ctap2CredentialManagementRequest { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, } } @@ -189,6 +214,7 @@ impl Ctap2CredentialManagementRequest { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, } } @@ -206,6 +232,7 @@ impl Ctap2CredentialManagementRequest { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, } } } @@ -268,3 +295,32 @@ impl Ctap2RPData { Self { rp, rp_id_hash } } } + +#[cfg(test)] +mod test { + use super::Ctap2CredentialManagementSubcommand as Sub; + + #[test] + fn read_only_classification() { + // Read-only: authorizable by a pcmr token. + for subcommand in [ + Sub::GetCredsMetadata, + Sub::EnumerateRPsBegin, + Sub::EnumerateRPsGetNextRP, + Sub::EnumerateCredentialsBegin, + Sub::EnumerateCredentialsGetNextCredential, + ] { + assert!( + subcommand.is_read_only(), + "{subcommand:?} should be read-only" + ); + } + // Writes: never pcmr. + for subcommand in [Sub::DeleteCredential, Sub::UpdateUserInformation] { + assert!( + !subcommand.is_read_only(), + "{subcommand:?} should be a write" + ); + } + } +} diff --git a/libwebauthn/src/proto/ctap2/protocol.rs b/libwebauthn/src/proto/ctap2/protocol.rs index d47ea600..157e5b43 100644 --- a/libwebauthn/src/proto/ctap2/protocol.rs +++ b/libwebauthn/src/proto/ctap2/protocol.rs @@ -468,6 +468,7 @@ mod tests { protocol: None, uv_auth_param: None, use_legacy_preview: false, + use_persistent_token: false, }; let expected_request: CborRequest = (&request).try_into().unwrap(); channel.push_command_pair(expected_request, error_response(CtapError::PINRequired)); From a379d257a4dcc78ca60cde3f1425933ca187dd77 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 28 May 2026 22:32:03 +0100 Subject: [PATCH 3/4] feat(pin): reuse and mint persistent tokens --- libwebauthn/src/pin/persistent_token.rs | 79 +++-- libwebauthn/src/transport/mock/channel.rs | 12 + libwebauthn/src/webauthn/pin_uv_auth_token.rs | 313 ++++++++++++++++-- 3 files changed, 365 insertions(+), 39 deletions(-) diff --git a/libwebauthn/src/pin/persistent_token.rs b/libwebauthn/src/pin/persistent_token.rs index 8184480c..a41c134c 100644 --- a/libwebauthn/src/pin/persistent_token.rs +++ b/libwebauthn/src/pin/persistent_token.rs @@ -6,9 +6,11 @@ use aes::cipher::{block_padding::NoPadding, BlockDecryptMut}; use async_trait::async_trait; use cbc::cipher::KeyIvInit; use hkdf::Hkdf; +use rand::rngs::OsRng; +use rand::RngCore; use sha2::Sha256; use tokio::sync::Mutex; -use tracing::{debug, error, trace}; +use tracing::{debug, error, trace, warn}; use zeroize::ZeroizeOnDrop; use crate::proto::ctap2::{Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol}; @@ -124,7 +126,6 @@ impl PersistentTokenStore for MemoryPersistentTokenStore { /// Derive the 16-byte AES-128 key for `encIdentifier` from a persistent token, per /// CTAP 2.3-PS 6.4: `HKDF-SHA-256(salt = 32 zero bytes, IKM = token, L = 16, info = "encIdentifier")`. -#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change fn enc_identifier_key(token: &[u8]) -> Result<[u8; 16], Error> { let hkdf = Hkdf::::new(Some(&ENC_IDENTIFIER_HKDF_SALT), token); let mut key = [0u8; 16]; @@ -140,7 +141,6 @@ fn enc_identifier_key(token: &[u8]) -> Result<[u8; 16], Error> { /// Recover the 128-bit device identifier from an `encIdentifier` (`iv || ct`) using a /// persistent token. `ct` is exactly one AES block, so decryption uses no padding. -#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change pub(crate) fn decrypt_enc_identifier( token: &[u8], enc_identifier: &[u8], @@ -172,7 +172,6 @@ pub(crate) fn decrypt_enc_identifier( /// `encIdentifier`. The IV is fresh on every getInfo, so raw bytes never compare equal /// across connections; recognition is decrypt-and-compare against each record's stored /// device identifier. Returns the first match, or `None` if no stored token fits. -#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change pub(crate) async fn recognize_authenticator( store: &dyn PersistentTokenStore, info: &Ctap2GetInfoResponse, @@ -190,18 +189,71 @@ pub(crate) async fn recognize_authenticator( None } +/// A fresh, opaque record id: 16 random bytes, hex-encoded. Random rather than derived +/// from the device, so a record survives device-identifier changes only via reaping. +fn new_record_id() -> PersistentTokenRecordId { + let mut bytes = [0u8; 16]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Capture a freshly minted pcmr token for cross-session reuse: recover this device's +/// identifier from `encIdentifier`, then store a new record under a fresh id. Returns the +/// id. Callers treat failures as best-effort (the current operation still proceeds with +/// the minted token). +pub(crate) async fn store_minted_token( + store: &dyn PersistentTokenStore, + info: &Ctap2GetInfoResponse, + token: &[u8], + pin_uv_auth_protocol: Ctap2PinUvAuthProtocol, +) -> Result { + let Some(enc_identifier) = info.enc_identifier.as_ref() else { + warn!("perCredMgmtRO advertised but no encIdentifier returned; cannot persist token"); + return Err(Error::Ctap(CtapError::Other)); + }; + let device_identifier = decrypt_enc_identifier(token, enc_identifier)?; + let aaguid: [u8; 16] = info.aaguid[..].try_into().map_err(|_| { + error!(len = info.aaguid.len(), "AAGUID was not 16 bytes"); + Error::Ctap(CtapError::Other) + })?; + let id = new_record_id(); + let record = PersistentTokenRecord { + persistent_token: token.to_vec(), + pin_uv_auth_protocol, + device_identifier, + aaguid, + }; + store.put(&id, &record).await; + debug!(?id, "Stored freshly minted persistent token"); + Ok(id) +} + +/// 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)] +pub(crate) fn build_enc_identifier( + token: &[u8], + device_identifier: &[u8; 16], + iv: &[u8; 16], +) -> Vec { + use aes::cipher::BlockEncryptMut; + type Aes128CbcEncryptor = cbc::Encryptor; + let key = enc_identifier_key(token).expect("encIdentifier key derivation"); + let encryptor = Aes128CbcEncryptor::new_from_slices(&key, iv).expect("valid key/iv"); + let ciphertext = encryptor.encrypt_padded_vec_mut::(device_identifier); + let mut enc = iv.to_vec(); + enc.extend_from_slice(&ciphertext); + enc +} + #[cfg(test)] mod test { use super::*; - use aes::cipher::{block_padding::NoPadding as TestNoPadding, BlockEncryptMut}; - use cbc::cipher::KeyIvInit as TestKeyIvInit; use serde_bytes::ByteBuf; use crate::proto::ctap2::Ctap2GetInfoResponse; - type Aes128CbcEncryptor = cbc::Encryptor; - fn sample_record() -> PersistentTokenRecord { PersistentTokenRecord { persistent_token: vec![0xAB; 32], @@ -211,17 +263,6 @@ mod test { } } - /// The authenticator side: produce `iv || ct` for a device identifier under a token, - /// using the same key derivation the recognition path uses. - fn build_enc_identifier(token: &[u8], device_identifier: &[u8; 16], iv: &[u8; 16]) -> Vec { - let key = enc_identifier_key(token).unwrap(); - let encryptor = Aes128CbcEncryptor::new_from_slices(&key, iv).unwrap(); - let ciphertext = encryptor.encrypt_padded_vec_mut::(device_identifier); - let mut enc = iv.to_vec(); - enc.extend_from_slice(&ciphertext); - enc - } - fn record_with(token: Vec, device_identifier: [u8; 16]) -> PersistentTokenRecord { PersistentTokenRecord { persistent_token: token, diff --git a/libwebauthn/src/transport/mock/channel.rs b/libwebauthn/src/transport/mock/channel.rs index 3535c65e..9e94f5f7 100644 --- a/libwebauthn/src/transport/mock/channel.rs +++ b/libwebauthn/src/transport/mock/channel.rs @@ -1,9 +1,11 @@ use async_trait::async_trait; +use std::sync::Arc; use std::{collections::VecDeque, fmt::Display, time::Duration}; use tokio::sync::broadcast; use tokio::time::sleep; use crate::{ + pin::persistent_token::PersistentTokenStore, proto::{ ctap1::apdu::{ApduRequest, ApduResponse}, ctap2::cbor::{CborRequest, CborResponse}, @@ -19,6 +21,7 @@ pub struct MockChannel { expected_requests: VecDeque, responses: VecDeque, auth_token_data: Option, + persistent_token_store: Option>, ux_update_sender: broadcast::Sender, pre_send_delay: Option, } @@ -36,6 +39,7 @@ impl MockChannel { expected_requests: VecDeque::new(), responses: VecDeque::new(), auth_token_data: None, + persistent_token_store: None, ux_update_sender, pre_send_delay: None, } @@ -46,6 +50,10 @@ impl MockChannel { self.responses.push_front(response); } + pub fn set_persistent_token_store(&mut self, store: Arc) { + self.persistent_token_store = Some(store); + } + /// Make `cbor_send` sleep for `delay` before completing, modeling a transport that defers the actual send behind a handshake. pub fn set_pre_send_delay(&mut self, delay: Duration) { self.pre_send_delay = Some(delay); @@ -64,6 +72,10 @@ impl Ctap2AuthTokenStore for MockChannel { fn clear_uv_auth_token_store(&mut self) { self.auth_token_data = None; } + + fn persistent_token_store(&self) -> Option> { + self.persistent_token_store.clone() + } } impl Display for MockChannel { diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index 661f3c82..bf1b7cd6 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -6,6 +6,9 @@ use tracing::{debug, error, info, instrument, warn}; use cosey::PublicKey; use crate::ops::webauthn::UserVerificationRequirement; +use crate::pin::persistent_token::{ + recognize_authenticator, store_minted_token, PersistentTokenRecordId, +}; use crate::pin::{ internal::PinManagementInternal, pin_hash, PinNotSetReason, PinRequestReason, PinUvAuthProtocol, PinUvAuthProtocolOne, PinUvAuthProtocolTwo, @@ -19,10 +22,12 @@ use crate::transport::{AuthTokenData, Channel, Ctap2AuthTokenPermission}; pub use crate::webauthn::error::{CtapError, Error, PlatformError}; use crate::{PinNotSetUpdate, PinRequiredUpdate, UvUpdate}; -#[derive(Debug, Copy, Clone, PartialEq, Eq)] - +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum UsedPinUvAuthToken { FromEphemeralStorage, + /// A persistent (pcmr) token reused from the store. Carries the record id so the + /// invalidation path can evict it on rejection. + FromPersistentStorage(PersistentTokenRecordId), NewlyCalculated(Ctap2UserVerificationOperation), LegacyUV, SharedSecretOnly, @@ -70,6 +75,24 @@ where { let mut get_info_response = channel.ctap2_get_info().await?; ctap2_request.handle_legacy_preview(&get_info_response); + + // Decide whether this request acquires a persistent (pcmr) token. A persistent token + // outranks a same-session ephemeral one, so try it first. + let persistent_token_store = channel.persistent_token_store(); + ctap2_request.set_persistent_token_use(&get_info_response, persistent_token_store.is_some()); + if ctap2_request.wants_persistent_token() { + if let Some(store) = &persistent_token_store { + if let Some((id, record)) = + recognize_authenticator(store.as_ref(), &get_info_response).await + { + let uv_proto = record.pin_uv_auth_protocol.create_protocol_object(); + ctap2_request + .calculate_and_set_uv_auth(uv_proto.as_ref(), &record.persistent_token)?; + return Ok(UsedPinUvAuthToken::FromPersistentStorage(id)); + } + } + } + let maybe_uv_proto = select_uv_proto( #[cfg(feature = "virt")] channel.get_forced_pin_protocol(), @@ -377,22 +400,39 @@ where return Err(Error::Ctap(CtapError::Other)); } - let token_identifier = Ctap2AuthTokenPermission::new( - uv_proto.version(), - ctap2_request.permissions(), - ctap2_request.permissions_rpid(), - ); + if ctap2_request.wants_persistent_token() { + // pcmr mint: persist for cross-session reuse, and keep it out of the + // ephemeral cache entirely so reuse always flows through recognition. + if let Some(store) = channel.persistent_token_store() { + if let Err(e) = store_minted_token( + store.as_ref(), + get_info_response, + &uv_auth_token, + uv_proto.version(), + ) + .await + { + warn!(?e, "Failed to persist minted pcmr token; continuing"); + } + } + } else { + let token_identifier = Ctap2AuthTokenPermission::new( + uv_proto.version(), + ctap2_request.permissions(), + ctap2_request.permissions_rpid(), + ); - // Storing auth token for later (re)use - let auth_token_data = AuthTokenData { - shared_secret: shared_secret.to_vec(), - permission: Some(token_identifier), - pin_uv_auth_token: Some(uv_auth_token.clone()), - protocol_version: uv_proto.version(), - key_agreement: public_key, - uv_operation, - }; - channel.store_auth_data(auth_token_data); + // Storing auth token for later (re)use + let auth_token_data = AuthTokenData { + shared_secret: shared_secret.to_vec(), + permission: Some(token_identifier), + pin_uv_auth_token: Some(uv_auth_token.clone()), + protocol_version: uv_proto.version(), + key_agreement: public_key, + uv_operation, + }; + channel.store_auth_data(auth_token_data); + } // If successful, the platform creates the pinUvAuthParam parameter by calling // authenticate(pinUvAuthToken, clientDataHash), and goes to Step 1.1.1. @@ -548,17 +588,24 @@ mod test { use serde_bytes::ByteBuf; use tokio::sync::broadcast::Receiver; + use std::sync::Arc; + use crate::{ ops::webauthn::{ GetAssertionRequest, GetAssertionRequestExtensions, PrfInput, PrfInputValue, UserVerificationRequirement, }, + pin::persistent_token::{ + build_enc_identifier, MemoryPersistentTokenStore, PersistentTokenRecord, + PersistentTokenStore, + }, pin::{pin_hash, PinNotSetReason, PinUvAuthProtocol, PinUvAuthProtocolOne}, proto::ctap2::{ cbor::{to_vec, CborRequest, CborResponse}, - Ctap2ClientPinRequest, Ctap2ClientPinResponse, Ctap2CommandCode, - Ctap2GetAssertionRequest, Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol, - Ctap2UserVerifiableRequest, Ctap2UserVerificationOperation, + Ctap2AuthTokenPermissionRole, Ctap2ClientPinRequest, Ctap2ClientPinResponse, + Ctap2CommandCode, Ctap2CredentialManagementRequest, Ctap2GetAssertionRequest, + Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol, Ctap2UserVerifiableRequest, + Ctap2UserVerificationOperation, }, transport::{mock::channel::MockChannel, Channel, Ctap2AuthTokenStore}, webauthn::UsedPinUvAuthToken, @@ -1407,4 +1454,230 @@ mod test { assert!(recv.is_empty()); } } + + fn pcmr_get_info( + options: &[(&'static str, bool)], + token: &[u8], + device_identifier: [u8; 16], + aaguid: [u8; 16], + ) -> Ctap2GetInfoResponse { + let mut opts = HashMap::new(); + for (key, value) in options { + opts.insert(key.to_string(), *value); + } + Ctap2GetInfoResponse { + options: Some(opts), + pin_auth_protos: Some(vec![1]), + aaguid: ByteBuf::from(aaguid.to_vec()), + enc_identifier: Some(ByteBuf::from(build_enc_identifier( + token, + &device_identifier, + &[0x33; 16], + ))), + ..Default::default() + } + } + + #[test] + fn read_only_credmgmt_requests_pcmr_with_store_and_support() { + let info = create_info(&[("perCredMgmtRO", true)], None); + let mut req = Ctap2CredentialManagementRequest::new_get_credential_metadata(); + req.set_persistent_token_use(&info, true); + assert!(req.wants_persistent_token()); + assert_eq!( + req.permissions(), + Ctap2AuthTokenPermissionRole::PERSISTENT_CREDENTIAL_MANAGEMENT_READ_ONLY + ); + } + + #[test] + fn read_only_credmgmt_keeps_cm_without_store() { + let info = create_info(&[("perCredMgmtRO", true)], None); + let mut req = Ctap2CredentialManagementRequest::new_get_credential_metadata(); + req.set_persistent_token_use(&info, false); + assert!(!req.wants_persistent_token()); + assert_eq!( + req.permissions(), + Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT + ); + } + + #[test] + fn read_only_credmgmt_keeps_cm_without_support() { + let info = create_info(&[], None); + let mut req = Ctap2CredentialManagementRequest::new_get_credential_metadata(); + req.set_persistent_token_use(&info, true); + assert!(!req.wants_persistent_token()); + assert_eq!( + req.permissions(), + Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT + ); + } + + #[tokio::test] + async fn persistent_token_reused_on_recognition() { + let mut channel = MockChannel::new(); + let status_recv = channel.get_ux_update_receiver(); + + let token = vec![0x5A; 32]; + let device_identifier = [0x42; 16]; + let aaguid = [0x01; 16]; + + let store = Arc::new(MemoryPersistentTokenStore::new()); + store + .put( + &"rec-1".to_string(), + &PersistentTokenRecord { + persistent_token: token.clone(), + pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::One, + device_identifier, + aaguid, + }, + ) + .await; + channel.set_persistent_token_store(store.clone()); + + let info = pcmr_get_info( + &[ + ("clientPin", true), + ("pinUvAuthToken", true), + ("perCredMgmtRO", true), + ], + &token, + device_identifier, + aaguid, + ); + let info_req = CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo); + let info_resp = CborResponse::new_success_from_slice(to_vec(&info).unwrap().as_slice()); + channel.push_command_pair(info_req, info_resp); + + let mut req = Ctap2CredentialManagementRequest::new_get_credential_metadata(); + let result = user_verification( + &mut channel, + UserVerificationRequirement::Preferred, + &mut req, + TIMEOUT, + ) + .await; + + // Reused from the persistent store, carrying the record id for invalidation. + assert_eq!( + result, + Ok(UsedPinUvAuthToken::FromPersistentStorage( + "rec-1".to_string() + )) + ); + // The reused token must never enter the ephemeral cache. + assert!(channel.get_auth_data().is_none()); + // No PIN or presence prompt, because nothing was minted. + assert!(status_recv.is_empty()); + // The request carries a pinUvAuthParam computed under the record's protocol. + assert!(req.uv_auth_param.is_some()); + assert_eq!(req.protocol, Some(Ctap2PinUvAuthProtocol::One)); + } + + #[tokio::test] + async fn persistent_token_minted_with_pcmr_permission() { + let mut channel = MockChannel::new(); + let device_identifier = [0x42; 16]; + let aaguid = [0x07; 16]; + let token = [0x05; 16]; + + let store = Arc::new(MemoryPersistentTokenStore::new()); + channel.set_persistent_token_store(store.clone()); + + // UV path (uv + pinUvAuthToken), so no PIN entry; perCredMgmtRO advertised. + let info = pcmr_get_info( + &[ + ("uv", true), + ("pinUvAuthToken", true), + ("perCredMgmtRO", true), + ], + &token, + device_identifier, + aaguid, + ); + let info_req = CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo); + let info_resp = CborResponse::new_success_from_slice(to_vec(&info).unwrap().as_slice()); + channel.push_command_pair(info_req, 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 pin_protocol = PinUvAuthProtocolOne::new(); + let (public_key, shared_secret) = pin_protocol.encapsulate(&get_key_agreement()).unwrap(); + // The token request MUST carry pcmr (0x40) as the sole permission. MockChannel + // asserts request equality, so a wrong permission bit fails here. + 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(); + 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); + + 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 mut req = Ctap2CredentialManagementRequest::new_get_credential_metadata(); + let result = user_verification( + &mut channel, + UserVerificationRequirement::Preferred, + &mut req, + TIMEOUT, + ) + .await; + + assert_eq!( + result, + Ok(UsedPinUvAuthToken::NewlyCalculated( + Ctap2UserVerificationOperation::GetPinUvAuthTokenUsingUvWithPermissions + )) + ); + // The minted pcmr token is persisted, not cached ephemerally. + assert!(channel.get_auth_data().is_none()); + let listed = store.list().await; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].1.device_identifier, device_identifier); + assert_eq!(listed[0].1.persistent_token, token.to_vec()); + assert_eq!(listed[0].1.aaguid, aaguid); + assert_eq!( + listed[0].1.pin_uv_auth_protocol, + Ctap2PinUvAuthProtocol::One + ); + + let recv = recv_handle.await.expect("Failed to join update thread"); + assert!(recv.is_empty()); + } } From a68092771e3ffb749536047a32ebbae98da92de9 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 6 Jun 2026 21:17:15 +0100 Subject: [PATCH 4/4] test(pin): add encIdentifier known-answer vector --- libwebauthn/src/pin/persistent_token.rs | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/libwebauthn/src/pin/persistent_token.rs b/libwebauthn/src/pin/persistent_token.rs index a41c134c..3f1644bd 100644 --- a/libwebauthn/src/pin/persistent_token.rs +++ b/libwebauthn/src/pin/persistent_token.rs @@ -347,6 +347,42 @@ mod test { assert!(decrypt_enc_identifier(&token, &[]).is_err()); } + // Known-answer vector for the encIdentifier construction (CTAP 2.3-PS 6.4). The + // expected key and ciphertext were computed independently of this crate (RFC 5869 + // HKDF-SHA-256 via Python hashlib/hmac, AES-128-CBC via openssl), so a self- + // consistent but spec-wrong change to the HKDF salt, info string, output length, or + // cipher is caught here even though the round-trip tests would still pass. + #[test] + fn enc_identifier_matches_known_answer_vector() { + let token: [u8; 32] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, + 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, + 0x1C, 0x1D, 0x1E, 0x1F, + ]; + let device_identifier: [u8; 16] = [ + 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, + 0xAE, 0xAF, + ]; + // HKDF-SHA-256(salt = 32 zero bytes, IKM = token, L = 16, info = "encIdentifier"). + let expected_key: [u8; 16] = [ + 0x24, 0x15, 0x4D, 0xBE, 0x7E, 0xF3, 0xCE, 0x2D, 0x6A, 0xDD, 0x02, 0xC4, 0xE4, 0x8D, + 0xBB, 0x69, + ]; + assert_eq!(enc_identifier_key(&token).unwrap(), expected_key); + + // iv (0x30..0x3F) || ct, where ct = AES-128-CBC(expected_key, iv, device_identifier) + // with no padding (the device identifier is exactly one block). + let enc_identifier: [u8; 32] = [ + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, + 0x3E, 0x3F, 0xF4, 0xD6, 0x82, 0xA3, 0x6E, 0x94, 0x0D, 0x68, 0xD8, 0x62, 0xE3, 0x09, + 0x9C, 0x6C, 0xE9, 0xB4, + ]; + assert_eq!( + decrypt_enc_identifier(&token, &enc_identifier).unwrap(), + device_identifier + ); + } + #[tokio::test] async fn recognizes_matching_record() { let store = MemoryPersistentTokenStore::new();