diff --git a/libwebauthn/src/management/credential_management.rs b/libwebauthn/src/management/credential_management.rs index f8d14d8..cecf430 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/pin/persistent_token.rs b/libwebauthn/src/pin/persistent_token.rs index 7487645..a41c134 100644 --- a/libwebauthn/src/pin/persistent_token.rs +++ b/libwebauthn/src/pin/persistent_token.rs @@ -2,12 +2,27 @@ 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 rand::rngs::OsRng; +use rand::RngCore; +use sha2::Sha256; use tokio::sync::Mutex; -use tracing::{debug, trace}; +use tracing::{debug, error, trace, warn}; 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 +124,136 @@ 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")`. +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. +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. +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 +} + +/// 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 serde_bytes::ByteBuf; + + use crate::proto::ctap2::Ctap2GetInfoResponse; + fn sample_record() -> PersistentTokenRecord { PersistentTokenRecord { persistent_token: vec![0xAB; 32], @@ -122,6 +263,22 @@ mod test { } } + 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 +327,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()); + } } diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index bc4f8a6..c01f88e 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 85e994d..4517cd3 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 d47ea60..157e5b4 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)); diff --git a/libwebauthn/src/transport/mock/channel.rs b/libwebauthn/src/transport/mock/channel.rs index 3535c65..9e94f5f 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 661f3c8..bf1b7cd 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()); + } }