@@ -77,10 +77,12 @@ where
7777 ctap2_request. handle_legacy_preview ( & get_info_response) ;
7878
7979 // Decide whether this request acquires a persistent (pcmr) token. A persistent token
80- // outranks a same-session ephemeral one, so try it first.
80+ // outranks a same-session ephemeral one, so try it first. Skip reuse once a stored
81+ // token has been rejected this ceremony, so the retry mints a fresh one rather than
82+ // looping on the same stale record.
8183 let persistent_token_store = channel. persistent_token_store ( ) ;
8284 ctap2_request. set_persistent_token_use ( & get_info_response, persistent_token_store. is_some ( ) ) ;
83- if ctap2_request. wants_persistent_token ( ) {
85+ if ctap2_request. wants_persistent_token ( ) && !ctap2_request . persistent_token_rejected ( ) {
8486 if let Some ( store) = & persistent_token_store {
8587 if let Some ( ( id, record) ) =
8688 recognize_authenticator ( store. as_ref ( ) , & get_info_response) . await
@@ -1681,6 +1683,125 @@ mod test {
16811683 assert ! ( recv. is_empty( ) ) ;
16821684 }
16831685
1686+ // The spec-real foreign-invalidation path. A PIN change (or any out-of-band reset of the
1687+ // persistent token) regenerates the token bytes, so the stored token can no longer decrypt
1688+ // the new encIdentifier: recognition MISSES with no PINAuthInvalid round trip, a fresh pcmr
1689+ // token is minted, and reaping by the stable device identifier evicts the dead record.
1690+ // Complements persistent_token_self_heals_on_rejection, which covers the defensive
1691+ // recognized-but-rejected arm.
1692+ #[ tokio:: test]
1693+ async fn recognition_miss_remints_and_reaps ( ) {
1694+ let mut channel = MockChannel :: new ( ) ;
1695+ let device_identifier = [ 0x42 ; 16 ] ;
1696+ let aaguid = [ 0x07 ; 16 ] ;
1697+ let minted_token = [ 0x05 ; 16 ] ;
1698+ let stale_token = vec ! [ 0x11 ; 32 ] ;
1699+
1700+ // A stale record for THIS device (same device identifier) holding the old token bytes.
1701+ let store = Arc :: new ( MemoryPersistentTokenStore :: new ( ) ) ;
1702+ store
1703+ . put (
1704+ & "stale" . to_string ( ) ,
1705+ & PersistentTokenRecord {
1706+ persistent_token : stale_token,
1707+ pin_uv_auth_protocol : Ctap2PinUvAuthProtocol :: One ,
1708+ device_identifier,
1709+ aaguid,
1710+ } ,
1711+ )
1712+ . await ;
1713+ channel. set_persistent_token_store ( store. clone ( ) ) ;
1714+
1715+ // encIdentifier is built under the NEW token, so the stale record fails recognition.
1716+ let info = pcmr_get_info (
1717+ & [
1718+ ( "uv" , true ) ,
1719+ ( "pinUvAuthToken" , true ) ,
1720+ ( "perCredMgmtRO" , true ) ,
1721+ ] ,
1722+ & minted_token,
1723+ device_identifier,
1724+ aaguid,
1725+ ) ;
1726+ channel. push_command_pair (
1727+ CborRequest :: new ( Ctap2CommandCode :: AuthenticatorGetInfo ) ,
1728+ CborResponse :: new_success_from_slice ( to_vec ( & info) . unwrap ( ) . as_slice ( ) ) ,
1729+ ) ;
1730+
1731+ let key_agreement_req = CborRequest :: try_from (
1732+ & Ctap2ClientPinRequest :: new_get_key_agreement ( Ctap2PinUvAuthProtocol :: One ) ,
1733+ )
1734+ . unwrap ( ) ;
1735+ let key_agreement_resp = CborResponse :: new_success_from_slice (
1736+ to_vec ( & Ctap2ClientPinResponse {
1737+ key_agreement : Some ( get_key_agreement ( ) ) ,
1738+ pin_uv_auth_token : None ,
1739+ pin_retries : None ,
1740+ power_cycle_state : None ,
1741+ uv_retries : None ,
1742+ } )
1743+ . unwrap ( )
1744+ . as_slice ( ) ,
1745+ ) ;
1746+ channel. push_command_pair ( key_agreement_req, key_agreement_resp) ;
1747+
1748+ let pin_protocol = PinUvAuthProtocolOne :: new ( ) ;
1749+ let ( public_key, shared_secret) = pin_protocol. encapsulate ( & get_key_agreement ( ) ) . unwrap ( ) ;
1750+ let uv_token_req =
1751+ CborRequest :: try_from ( & Ctap2ClientPinRequest :: new_get_uv_token_with_perm (
1752+ Ctap2PinUvAuthProtocol :: One ,
1753+ public_key,
1754+ Ctap2AuthTokenPermissionRole :: PERSISTENT_CREDENTIAL_MANAGEMENT_READ_ONLY ,
1755+ None ,
1756+ ) )
1757+ . unwrap ( ) ;
1758+ let encrypted_token = pin_protocol. encrypt ( & shared_secret, & minted_token) . unwrap ( ) ;
1759+ let uv_token_resp = CborResponse :: new_success_from_slice (
1760+ to_vec ( & Ctap2ClientPinResponse {
1761+ key_agreement : None ,
1762+ pin_uv_auth_token : Some ( ByteBuf :: from ( encrypted_token) ) ,
1763+ pin_retries : None ,
1764+ power_cycle_state : None ,
1765+ uv_retries : None ,
1766+ } )
1767+ . unwrap ( )
1768+ . as_slice ( ) ,
1769+ ) ;
1770+ channel. push_command_pair ( uv_token_req, uv_token_resp) ;
1771+
1772+ let mut recv = channel. get_ux_update_receiver ( ) ;
1773+ let recv_handle = tokio:: task:: spawn ( async move {
1774+ assert_eq ! ( recv. recv( ) . await , Ok ( UvUpdate :: PresenceRequired ) ) ;
1775+ recv
1776+ } ) ;
1777+
1778+ let mut req = Ctap2CredentialManagementRequest :: new_get_credential_metadata ( ) ;
1779+ let result = user_verification (
1780+ & mut channel,
1781+ UserVerificationRequirement :: Preferred ,
1782+ & mut req,
1783+ TIMEOUT ,
1784+ )
1785+ . await ;
1786+
1787+ // Recognition missed, so the token was minted, not reused.
1788+ assert_eq ! (
1789+ result,
1790+ Ok ( UsedPinUvAuthToken :: NewlyCalculated (
1791+ Ctap2UserVerificationOperation :: GetPinUvAuthTokenUsingUvWithPermissions
1792+ ) )
1793+ ) ;
1794+ // The stale record was reaped and replaced by exactly one fresh record.
1795+ let listed = store. list ( ) . await ;
1796+ assert_eq ! ( listed. len( ) , 1 ) ;
1797+ assert ! ( listed. iter( ) . all( |( id, _) | id != "stale" ) ) ;
1798+ assert_eq ! ( listed[ 0 ] . 1 . persistent_token, minted_token. to_vec( ) ) ;
1799+ assert_eq ! ( listed[ 0 ] . 1 . device_identifier, device_identifier) ;
1800+
1801+ let recv = recv_handle. await . expect ( "Failed to join update thread" ) ;
1802+ assert ! ( recv. is_empty( ) ) ;
1803+ }
1804+
16841805 #[ tokio:: test]
16851806 async fn persistent_token_self_heals_on_rejection ( ) {
16861807 use crate :: management:: CredentialManagement ;
@@ -1704,8 +1825,11 @@ mod test {
17041825 . await ;
17051826 channel. set_persistent_token_store ( store. clone ( ) ) ;
17061827
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.
1828+ // Defensive path: a recognized token is rejected by the device. The encIdentifier is
1829+ // built under our stored token so recognition matches, yet the op returns
1830+ // PINAuthInvalid; we evict and re-mint. (A real PIN change regenerates the token bytes,
1831+ // per resetPersistentPinUvAuthToken, so recognition would instead miss and re-mint
1832+ // without a rejection; that path is covered by recognition_miss_remints_and_reaps.)
17091833 let info = pcmr_get_info (
17101834 & [
17111835 ( "uv" , true ) ,
0 commit comments