diff --git a/libwebauthn/src/management/credential_management.rs b/libwebauthn/src/management/credential_management.rs index cecf4304..eedfbd1d 100644 --- a/libwebauthn/src/management/credential_management.rs +++ b/libwebauthn/src/management/credential_management.rs @@ -77,7 +77,8 @@ where self, self.ctap2_credential_management(&req, timeout).await, uv_auth_used, - timeout + timeout, + req ) }?; let metadata = Ctap2CredentialManagementMetadata::new( @@ -106,7 +107,8 @@ where self, self.ctap2_credential_management(&req, timeout).await, uv_auth_used, - timeout + timeout, + req ) }?; Ok(( @@ -134,7 +136,8 @@ where self, self.ctap2_credential_management(&req, timeout).await, uv_auth_used, - timeout + timeout, + req ) }?; Ok(Ctap2RPData::new( @@ -163,7 +166,8 @@ where self, self.ctap2_credential_management(&req, timeout).await, uv_auth_used, - timeout + timeout, + req ) }?; let cred = Ctap2CredentialData::new( @@ -196,7 +200,8 @@ where self, self.ctap2_credential_management(&req, timeout).await, uv_auth_used, - timeout + timeout, + req ) }?; let cred = Ctap2CredentialData::new( @@ -229,7 +234,8 @@ where self, self.ctap2_credential_management(&req, timeout).await, uv_auth_used, - timeout + timeout, + req ) }?; Ok(()) @@ -262,7 +268,8 @@ where self, self.ctap2_credential_management(&req, timeout).await, uv_auth_used, - timeout + timeout, + req ) }?; Ok(()) @@ -339,4 +346,12 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest { fn wants_persistent_token(&self) -> bool { self.use_persistent_token } + + fn note_persistent_token_rejected(&mut self) { + self.persistent_token_rejected = true; + } + + fn persistent_token_rejected(&self) -> bool { + self.persistent_token_rejected + } } diff --git a/libwebauthn/src/pin/mod.rs b/libwebauthn/src/pin/mod.rs index ba74b7eb..4fae16bd 100644 --- a/libwebauthn/src/pin/mod.rs +++ b/libwebauthn/src/pin/mod.rs @@ -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::{ @@ -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(), @@ -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(()) } } diff --git a/libwebauthn/src/pin/persistent_token.rs b/libwebauthn/src/pin/persistent_token.rs index 3f1644bd..ba6158d5 100644 --- a/libwebauthn/src/pin/persistent_token.rs +++ b/libwebauthn/src/pin/persistent_token.rs @@ -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(), @@ -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)] @@ -474,4 +492,49 @@ mod test { let info = Ctap2GetInfoResponse::default(); assert!(recognize_authenticator(&store, &info).await.is_none()); } + + #[tokio::test] + async fn mint_reaps_same_device_and_preserves_sibling() { + let store = MemoryPersistentTokenStore::new(); + let device = [0x42; 16]; + let sibling = [0x99; 16]; + let aaguid = [0x07; 16]; + + // A stale record for this device, plus a sibling key of the same model. + store + .put(&"old".to_string(), &record_with(vec![0x11; 32], device)) + .await; + store + .put( + &"sibling".to_string(), + &record_with(vec![0x22; 32], sibling), + ) + .await; + + let minted = vec![0x33; 32]; + let info = Ctap2GetInfoResponse { + aaguid: ByteBuf::from(aaguid.to_vec()), + enc_identifier: Some(ByteBuf::from(build_enc_identifier( + &minted, + &device, + &[0x55; 16], + ))), + ..Default::default() + }; + + let new_id = store_minted_token(&store, &info, &minted, Ctap2PinUvAuthProtocol::One) + .await + .unwrap(); + + let listed = store.list().await; + // The old same-device record is reaped; the sibling and the new record remain. + assert_eq!(listed.len(), 2); + assert!(listed.iter().all(|(id, _)| id != "old")); + let new = listed.iter().find(|(id, _)| id == &new_id).unwrap(); + assert_eq!(new.1.device_identifier, device); + assert_eq!(new.1.persistent_token, minted); + let sib = listed.iter().find(|(id, _)| id == "sibling").unwrap(); + assert_eq!(sib.1.device_identifier, sibling); + assert_eq!(sib.1.persistent_token, vec![0x22; 32]); + } } diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index c01f88e2..5ae1341e 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -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)] @@ -319,6 +322,14 @@ pub trait Ctap2UserVerifiableRequest { fn wants_persistent_token(&self) -> bool { false } + /// Record that a reused persistent (pcmr) token was rejected by the authenticator, so + /// the retry stops reusing it and mints a fresh one instead. Default: no-op. + fn note_persistent_token_rejected(&mut self) {} + /// Whether a reused persistent token was already rejected during this ceremony, per + /// [`Self::note_persistent_token_rejected`]. Default false. + fn persistent_token_rejected(&self) -> bool { + false + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/libwebauthn/src/proto/ctap2/model/credential_management.rs b/libwebauthn/src/proto/ctap2/model/credential_management.rs index 4517cd3b..ea2a05d6 100644 --- a/libwebauthn/src/proto/ctap2/model/credential_management.rs +++ b/libwebauthn/src/proto/ctap2/model/credential_management.rs @@ -36,6 +36,11 @@ pub struct Ctap2CredentialManagementRequest { /// Set from getInfo and store availability before `permissions()` is read. #[serde(skip)] pub use_persistent_token: bool, + + /// Set once a reused persistent token is rejected, so the retry skips recognition and + /// mints a fresh token instead of looping on the same stale record. + #[serde(skip)] + pub persistent_token_rejected: bool, } #[repr(u32)] @@ -150,6 +155,7 @@ impl Ctap2CredentialManagementRequest { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, } } @@ -161,6 +167,7 @@ impl Ctap2CredentialManagementRequest { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, } } @@ -172,6 +179,7 @@ impl Ctap2CredentialManagementRequest { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, } } @@ -187,6 +195,7 @@ impl Ctap2CredentialManagementRequest { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, } } @@ -200,6 +209,7 @@ impl Ctap2CredentialManagementRequest { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, } } @@ -215,6 +225,7 @@ impl Ctap2CredentialManagementRequest { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, } } @@ -233,6 +244,7 @@ impl Ctap2CredentialManagementRequest { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, } } } diff --git a/libwebauthn/src/proto/ctap2/protocol.rs b/libwebauthn/src/proto/ctap2/protocol.rs index 157e5b43..1d7248a3 100644 --- a/libwebauthn/src/proto/ctap2/protocol.rs +++ b/libwebauthn/src/proto/ctap2/protocol.rs @@ -469,6 +469,7 @@ mod tests { uv_auth_param: None, use_legacy_preview: false, use_persistent_token: false, + persistent_token_rejected: false, }; let expected_request: CborRequest = (&request).try_into().unwrap(); channel.push_command_pair(expected_request, error_response(CtapError::PINRequired)); diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 7685852c..bf9d6c59 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -44,7 +44,20 @@ fn prf_forces_uv_upgrade(prf_present: bool, uv: UserVerificationRequirement) -> } macro_rules! handle_errors { + // Callers that never reuse a persistent token (make-credential, get-assertion, + // authenticator-config, bio-enrollment): nothing to notify on a persistent rejection. ($channel: expr, $resp: expr, $uv_auth_used: expr, $timeout: expr) => { + handle_errors!(@inner $channel, $resp, $uv_auth_used, $timeout, {}) + }; + // Credential-management callers pass their request so a rejected persistent token is + // marked on it, forcing the retry to mint a fresh token instead of reusing the same + // stale record. This keeps loop termination independent of the best-effort store delete. + ($channel: expr, $resp: expr, $uv_auth_used: expr, $timeout: expr, $request: expr) => { + handle_errors!(@inner $channel, $resp, $uv_auth_used, $timeout, { + $request.note_persistent_token_rejected(); + }) + }; + (@inner $channel: expr, $resp: expr, $uv_auth_used: expr, $timeout: expr, $on_persistent_reject: block) => { match $resp { Err(Error::Ctap(CtapError::PINAuthInvalid)) if $uv_auth_used == UsedPinUvAuthToken::FromEphemeralStorage => @@ -53,6 +66,18 @@ macro_rules! handle_errors { $channel.clear_uv_auth_token_store(); continue; } + Err(Error::Ctap(CtapError::PINAuthInvalid)) + if matches!($uv_auth_used, UsedPinUvAuthToken::FromPersistentStorage(_)) => + { + info!("PINAuthInvalid on a persistent token: evicting the record and retrying."); + if let UsedPinUvAuthToken::FromPersistentStorage(id) = &$uv_auth_used { + if let Some(store) = $channel.persistent_token_store() { + store.delete(id).await; + } + } + $on_persistent_reject + continue; + } Err(Error::Ctap(CtapError::UVInvalid)) => { let attempts_left = $channel .ctap2_client_pin(&Ctap2ClientPinRequest::new_get_uv_retries(), $timeout) diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index bf1b7cd6..eedf2c19 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -77,10 +77,12 @@ where 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. + // outranks a same-session ephemeral one, so try it first. Skip reuse once a stored + // token has been rejected this ceremony, so the retry mints a fresh one rather than + // looping on the same stale record. 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 ctap2_request.wants_persistent_token() && !ctap2_request.persistent_token_rejected() { if let Some(store) = &persistent_token_store { if let Some((id, record)) = recognize_authenticator(store.as_ref(), &get_info_response).await @@ -612,7 +614,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] @@ -1680,4 +1682,338 @@ mod test { let recv = recv_handle.await.expect("Failed to join update thread"); assert!(recv.is_empty()); } + + // The spec-real foreign-invalidation path. A PIN change (or any out-of-band reset of the + // persistent token) regenerates the token bytes, so the stored token can no longer decrypt + // the new encIdentifier: recognition MISSES with no PINAuthInvalid round trip, a fresh pcmr + // token is minted, and reaping by the stable device identifier evicts the dead record. + // Complements persistent_token_self_heals_on_rejection, which covers the defensive + // recognized-but-rejected arm. + #[tokio::test] + async fn recognition_miss_remints_and_reaps() { + let mut channel = MockChannel::new(); + let device_identifier = [0x42; 16]; + let aaguid = [0x07; 16]; + let minted_token = [0x05; 16]; + let stale_token = vec![0x11; 32]; + + // A stale record for THIS device (same device identifier) holding the old token bytes. + let store = Arc::new(MemoryPersistentTokenStore::new()); + store + .put( + &"stale".to_string(), + &PersistentTokenRecord { + persistent_token: stale_token, + pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::One, + device_identifier, + aaguid, + }, + ) + .await; + channel.set_persistent_token_store(store.clone()); + + // encIdentifier is built under the NEW token, so the stale record fails recognition. + let info = pcmr_get_info( + &[ + ("uv", true), + ("pinUvAuthToken", true), + ("perCredMgmtRO", true), + ], + &minted_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 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, &minted_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; + + // Recognition missed, so the token was minted, not reused. + assert_eq!( + result, + Ok(UsedPinUvAuthToken::NewlyCalculated( + Ctap2UserVerificationOperation::GetPinUvAuthTokenUsingUvWithPermissions + )) + ); + // The stale record was reaped and replaced by exactly one fresh record. + let listed = store.list().await; + assert_eq!(listed.len(), 1); + assert!(listed.iter().all(|(id, _)| id != "stale")); + assert_eq!(listed[0].1.persistent_token, minted_token.to_vec()); + 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 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()); + + // Defensive path: a recognized token is rejected by the device. The encIdentifier is + // built under our stored token so recognition matches, yet the op returns + // PINAuthInvalid; we evict and re-mint. (A real PIN change regenerates the token bytes, + // per resetPersistentPinUvAuthToken, so recognition would instead miss and re-mint + // without a rejection; that path is covered by recognition_miss_remints_and_reaps.) + 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()); + } }