@@ -2,12 +2,27 @@ use std::collections::HashMap;
22use std:: fmt;
33use std:: sync:: Arc ;
44
5+ use aes:: cipher:: { block_padding:: NoPadding , BlockDecryptMut } ;
56use async_trait:: async_trait;
7+ use cbc:: cipher:: KeyIvInit ;
8+ use hkdf:: Hkdf ;
9+ use rand:: rngs:: OsRng ;
10+ use rand:: RngCore ;
11+ use sha2:: Sha256 ;
612use tokio:: sync:: Mutex ;
7- use tracing:: { debug, trace} ;
13+ use tracing:: { debug, error , trace, warn } ;
814use zeroize:: ZeroizeOnDrop ;
915
10- use crate :: proto:: ctap2:: Ctap2PinUvAuthProtocol ;
16+ use crate :: proto:: ctap2:: { Ctap2GetInfoResponse , Ctap2PinUvAuthProtocol } ;
17+ use crate :: proto:: CtapError ;
18+ use crate :: webauthn:: error:: { Error , PlatformError } ;
19+
20+ type Aes128CbcDecryptor = cbc:: Decryptor < aes:: Aes128 > ;
21+
22+ /// HKDF salt for `encIdentifier`/`encCredStoreState`: 32 zero bytes (CTAP 2.3-PS 6.4).
23+ const ENC_IDENTIFIER_HKDF_SALT : [ u8 ; 32 ] = [ 0u8 ; 32 ] ;
24+ /// HKDF info string binding the derived key to the `encIdentifier` use.
25+ const ENC_IDENTIFIER_HKDF_INFO : & [ u8 ] = b"encIdentifier" ;
1126
1227/// Opaque identifier for a stored persistent-token record. Random per record.
1328pub type PersistentTokenRecordId = String ;
@@ -109,10 +124,136 @@ impl PersistentTokenStore for MemoryPersistentTokenStore {
109124 }
110125}
111126
127+ /// Derive the 16-byte AES-128 key for `encIdentifier` from a persistent token, per
128+ /// CTAP 2.3-PS 6.4: `HKDF-SHA-256(salt = 32 zero bytes, IKM = token, L = 16, info = "encIdentifier")`.
129+ fn enc_identifier_key ( token : & [ u8 ] ) -> Result < [ u8 ; 16 ] , Error > {
130+ let hkdf = Hkdf :: < Sha256 > :: new ( Some ( & ENC_IDENTIFIER_HKDF_SALT ) , token) ;
131+ let mut key = [ 0u8 ; 16 ] ;
132+ hkdf. expand ( ENC_IDENTIFIER_HKDF_INFO , & mut key)
133+ . map_err ( |e| {
134+ error ! ( "HKDF expand error deriving encIdentifier key: {e}" ) ;
135+ Error :: Platform ( PlatformError :: CryptoError ( format ! (
136+ "HKDF expand error: {e}"
137+ ) ) )
138+ } ) ?;
139+ Ok ( key)
140+ }
141+
142+ /// Recover the 128-bit device identifier from an `encIdentifier` (`iv || ct`) using a
143+ /// persistent token. `ct` is exactly one AES block, so decryption uses no padding.
144+ pub ( crate ) fn decrypt_enc_identifier (
145+ token : & [ u8 ] ,
146+ enc_identifier : & [ u8 ] ,
147+ ) -> Result < [ u8 ; 16 ] , Error > {
148+ if enc_identifier. len ( ) != 32 {
149+ error ! (
150+ len = enc_identifier. len( ) ,
151+ "encIdentifier is not a 16-byte IV followed by one 16-byte ciphertext block"
152+ ) ;
153+ return Err ( Error :: Ctap ( CtapError :: Other ) ) ;
154+ }
155+ let ( iv, ciphertext) = enc_identifier. split_at ( 16 ) ;
156+ let key = enc_identifier_key ( token) ?;
157+ let Ok ( decryptor) = Aes128CbcDecryptor :: new_from_slices ( & key, iv) else {
158+ error ! ( "Invalid key or IV for AES-128-CBC encIdentifier decryption" ) ;
159+ return Err ( Error :: Ctap ( CtapError :: Other ) ) ;
160+ } ;
161+ let Ok ( plaintext) = decryptor. decrypt_padded_vec_mut :: < NoPadding > ( ciphertext) else {
162+ error ! ( "Decrypt error while recovering device identifier" ) ;
163+ return Err ( Error :: Ctap ( CtapError :: Other ) ) ;
164+ } ;
165+ plaintext. try_into ( ) . map_err ( |_| {
166+ error ! ( "Recovered device identifier was not 16 bytes" ) ;
167+ Error :: Ctap ( CtapError :: Other )
168+ } )
169+ }
170+
171+ /// Find the stored record whose persistent token reproduces this authenticator's
172+ /// `encIdentifier`. The IV is fresh on every getInfo, so raw bytes never compare equal
173+ /// across connections; recognition is decrypt-and-compare against each record's stored
174+ /// device identifier. Returns the first match, or `None` if no stored token fits.
175+ pub ( crate ) async fn recognize_authenticator (
176+ store : & dyn PersistentTokenStore ,
177+ info : & Ctap2GetInfoResponse ,
178+ ) -> Option < ( PersistentTokenRecordId , PersistentTokenRecord ) > {
179+ let enc_identifier = info. enc_identifier . as_ref ( ) ?;
180+ for ( id, record) in store. list ( ) . await {
181+ match decrypt_enc_identifier ( & record. persistent_token , enc_identifier) {
182+ Ok ( device_identifier) if device_identifier == record. device_identifier => {
183+ debug ! ( ?id, "Recognized authenticator from persistent token store" ) ;
184+ return Some ( ( id, record) ) ;
185+ }
186+ _ => { }
187+ }
188+ }
189+ None
190+ }
191+
192+ /// A fresh, opaque record id: 16 random bytes, hex-encoded. Random rather than derived
193+ /// from the device, so a record survives device-identifier changes only via reaping.
194+ fn new_record_id ( ) -> PersistentTokenRecordId {
195+ let mut bytes = [ 0u8 ; 16 ] ;
196+ OsRng . fill_bytes ( & mut bytes) ;
197+ hex:: encode ( bytes)
198+ }
199+
200+ /// Capture a freshly minted pcmr token for cross-session reuse: recover this device's
201+ /// identifier from `encIdentifier`, then store a new record under a fresh id. Returns the
202+ /// id. Callers treat failures as best-effort (the current operation still proceeds with
203+ /// the minted token).
204+ pub ( crate ) async fn store_minted_token (
205+ store : & dyn PersistentTokenStore ,
206+ info : & Ctap2GetInfoResponse ,
207+ token : & [ u8 ] ,
208+ pin_uv_auth_protocol : Ctap2PinUvAuthProtocol ,
209+ ) -> Result < PersistentTokenRecordId , Error > {
210+ let Some ( enc_identifier) = info. enc_identifier . as_ref ( ) else {
211+ warn ! ( "perCredMgmtRO advertised but no encIdentifier returned; cannot persist token" ) ;
212+ return Err ( Error :: Ctap ( CtapError :: Other ) ) ;
213+ } ;
214+ let device_identifier = decrypt_enc_identifier ( token, enc_identifier) ?;
215+ let aaguid: [ u8 ; 16 ] = info. aaguid [ ..] . try_into ( ) . map_err ( |_| {
216+ error ! ( len = info. aaguid. len( ) , "AAGUID was not 16 bytes" ) ;
217+ Error :: Ctap ( CtapError :: Other )
218+ } ) ?;
219+ let id = new_record_id ( ) ;
220+ let record = PersistentTokenRecord {
221+ persistent_token : token. to_vec ( ) ,
222+ pin_uv_auth_protocol,
223+ device_identifier,
224+ aaguid,
225+ } ;
226+ store. put ( & id, & record) . await ;
227+ debug ! ( ?id, "Stored freshly minted persistent token" ) ;
228+ Ok ( id)
229+ }
230+
231+ /// Test-only: build an `encIdentifier` (`iv || ct`) for a device identifier under a
232+ /// token, using the production key derivation. Shared across test modules.
233+ #[ cfg( test) ]
234+ pub ( crate ) fn build_enc_identifier (
235+ token : & [ u8 ] ,
236+ device_identifier : & [ u8 ; 16 ] ,
237+ iv : & [ u8 ; 16 ] ,
238+ ) -> Vec < u8 > {
239+ use aes:: cipher:: BlockEncryptMut ;
240+ type Aes128CbcEncryptor = cbc:: Encryptor < aes:: Aes128 > ;
241+ let key = enc_identifier_key ( token) . expect ( "encIdentifier key derivation" ) ;
242+ let encryptor = Aes128CbcEncryptor :: new_from_slices ( & key, iv) . expect ( "valid key/iv" ) ;
243+ let ciphertext = encryptor. encrypt_padded_vec_mut :: < NoPadding > ( device_identifier) ;
244+ let mut enc = iv. to_vec ( ) ;
245+ enc. extend_from_slice ( & ciphertext) ;
246+ enc
247+ }
248+
112249#[ cfg( test) ]
113250mod test {
114251 use super :: * ;
115252
253+ use serde_bytes:: ByteBuf ;
254+
255+ use crate :: proto:: ctap2:: Ctap2GetInfoResponse ;
256+
116257 fn sample_record ( ) -> PersistentTokenRecord {
117258 PersistentTokenRecord {
118259 persistent_token : vec ! [ 0xAB ; 32 ] ,
@@ -122,6 +263,22 @@ mod test {
122263 }
123264 }
124265
266+ fn record_with ( token : Vec < u8 > , device_identifier : [ u8 ; 16 ] ) -> PersistentTokenRecord {
267+ PersistentTokenRecord {
268+ persistent_token : token,
269+ pin_uv_auth_protocol : Ctap2PinUvAuthProtocol :: Two ,
270+ device_identifier,
271+ aaguid : [ 0x22 ; 16 ] ,
272+ }
273+ }
274+
275+ fn info_with_enc_identifier ( enc_identifier : Vec < u8 > ) -> Ctap2GetInfoResponse {
276+ Ctap2GetInfoResponse {
277+ enc_identifier : Some ( ByteBuf :: from ( enc_identifier) ) ,
278+ ..Default :: default ( )
279+ }
280+ }
281+
125282 #[ tokio:: test]
126283 async fn put_list_delete_round_trip ( ) {
127284 let store = MemoryPersistentTokenStore :: new ( ) ;
@@ -170,4 +327,151 @@ mod test {
170327 fn assert_zeroize_on_drop < T : ZeroizeOnDrop > ( ) { }
171328 assert_zeroize_on_drop :: < PersistentTokenRecord > ( ) ;
172329 }
330+
331+ #[ test]
332+ fn decrypt_enc_identifier_round_trips ( ) {
333+ let token = vec ! [ 0x07 ; 32 ] ;
334+ let device_identifier = [ 0x42 ; 16 ] ;
335+ let enc = build_enc_identifier ( & token, & device_identifier, & [ 0x99 ; 16 ] ) ;
336+ assert_eq ! (
337+ decrypt_enc_identifier( & token, & enc) . unwrap( ) ,
338+ device_identifier
339+ ) ;
340+ }
341+
342+ #[ test]
343+ fn decrypt_enc_identifier_rejects_bad_length ( ) {
344+ let token = vec ! [ 0x07 ; 32 ] ;
345+ assert ! ( decrypt_enc_identifier( & token, & [ 0u8 ; 31 ] ) . is_err( ) ) ;
346+ assert ! ( decrypt_enc_identifier( & token, & [ 0u8 ; 33 ] ) . is_err( ) ) ;
347+ assert ! ( decrypt_enc_identifier( & token, & [ ] ) . is_err( ) ) ;
348+ }
349+
350+ // Known-answer vector for the encIdentifier construction (CTAP 2.3-PS 6.4). The
351+ // expected key and ciphertext were computed independently of this crate (RFC 5869
352+ // HKDF-SHA-256 via Python hashlib/hmac, AES-128-CBC via openssl), so a self-
353+ // consistent but spec-wrong change to the HKDF salt, info string, output length, or
354+ // cipher is caught here even though the round-trip tests would still pass.
355+ #[ test]
356+ fn enc_identifier_matches_known_answer_vector ( ) {
357+ let token: [ u8 ; 32 ] = [
358+ 0x00 , 0x01 , 0x02 , 0x03 , 0x04 , 0x05 , 0x06 , 0x07 , 0x08 , 0x09 , 0x0A , 0x0B , 0x0C , 0x0D ,
359+ 0x0E , 0x0F , 0x10 , 0x11 , 0x12 , 0x13 , 0x14 , 0x15 , 0x16 , 0x17 , 0x18 , 0x19 , 0x1A , 0x1B ,
360+ 0x1C , 0x1D , 0x1E , 0x1F ,
361+ ] ;
362+ let device_identifier: [ u8 ; 16 ] = [
363+ 0xA0 , 0xA1 , 0xA2 , 0xA3 , 0xA4 , 0xA5 , 0xA6 , 0xA7 , 0xA8 , 0xA9 , 0xAA , 0xAB , 0xAC , 0xAD ,
364+ 0xAE , 0xAF ,
365+ ] ;
366+ // HKDF-SHA-256(salt = 32 zero bytes, IKM = token, L = 16, info = "encIdentifier").
367+ let expected_key: [ u8 ; 16 ] = [
368+ 0x24 , 0x15 , 0x4D , 0xBE , 0x7E , 0xF3 , 0xCE , 0x2D , 0x6A , 0xDD , 0x02 , 0xC4 , 0xE4 , 0x8D ,
369+ 0xBB , 0x69 ,
370+ ] ;
371+ assert_eq ! ( enc_identifier_key( & token) . unwrap( ) , expected_key) ;
372+
373+ // iv (0x30..0x3F) || ct, where ct = AES-128-CBC(expected_key, iv, device_identifier)
374+ // with no padding (the device identifier is exactly one block).
375+ let enc_identifier: [ u8 ; 32 ] = [
376+ 0x30 , 0x31 , 0x32 , 0x33 , 0x34 , 0x35 , 0x36 , 0x37 , 0x38 , 0x39 , 0x3A , 0x3B , 0x3C , 0x3D ,
377+ 0x3E , 0x3F , 0xF4 , 0xD6 , 0x82 , 0xA3 , 0x6E , 0x94 , 0x0D , 0x68 , 0xD8 , 0x62 , 0xE3 , 0x09 ,
378+ 0x9C , 0x6C , 0xE9 , 0xB4 ,
379+ ] ;
380+ assert_eq ! (
381+ decrypt_enc_identifier( & token, & enc_identifier) . unwrap( ) ,
382+ device_identifier
383+ ) ;
384+ }
385+
386+ #[ tokio:: test]
387+ async fn recognizes_matching_record ( ) {
388+ let store = MemoryPersistentTokenStore :: new ( ) ;
389+ let token = vec ! [ 0x07 ; 32 ] ;
390+ let device_identifier = [ 0x42 ; 16 ] ;
391+ store
392+ . put (
393+ & "id-1" . to_string ( ) ,
394+ & record_with ( token. clone ( ) , device_identifier) ,
395+ )
396+ . await ;
397+
398+ // A second getInfo uses a fresh IV, so the bytes differ but recognition holds.
399+ let info = info_with_enc_identifier ( build_enc_identifier (
400+ & token,
401+ & device_identifier,
402+ & [ 0x33 ; 16 ] ,
403+ ) ) ;
404+ let ( id, record) = recognize_authenticator ( & store, & info) . await . unwrap ( ) ;
405+ assert_eq ! ( id, "id-1" ) ;
406+ assert_eq ! ( record. device_identifier, device_identifier) ;
407+ }
408+
409+ #[ tokio:: test]
410+ async fn rejects_wrong_token ( ) {
411+ let store = MemoryPersistentTokenStore :: new ( ) ;
412+ let real_token = vec ! [ 0x07 ; 32 ] ;
413+ let device_identifier = [ 0x42 ; 16 ] ;
414+ // Stored record carries a different token, so its key cannot reproduce the id.
415+ store
416+ . put (
417+ & "id-1" . to_string ( ) ,
418+ & record_with ( vec ! [ 0xFF ; 32 ] , device_identifier) ,
419+ )
420+ . await ;
421+
422+ let info = info_with_enc_identifier ( build_enc_identifier (
423+ & real_token,
424+ & device_identifier,
425+ & [ 0x33 ; 16 ] ,
426+ ) ) ;
427+ assert ! ( recognize_authenticator( & store, & info) . await . is_none( ) ) ;
428+ }
429+
430+ #[ tokio:: test]
431+ async fn rejects_stale_device_identifier ( ) {
432+ let store = MemoryPersistentTokenStore :: new ( ) ;
433+ let token = vec ! [ 0x07 ; 32 ] ;
434+ // Right token, but the stored device identifier is stale (e.g. after a reset).
435+ store
436+ . put ( & "id-1" . to_string ( ) , & record_with ( token. clone ( ) , [ 0x00 ; 16 ] ) )
437+ . await ;
438+
439+ let info = info_with_enc_identifier ( build_enc_identifier ( & token, & [ 0x42 ; 16 ] , & [ 0x33 ; 16 ] ) ) ;
440+ assert ! ( recognize_authenticator( & store, & info) . await . is_none( ) ) ;
441+ }
442+
443+ #[ tokio:: test]
444+ async fn picks_correct_record_among_many ( ) {
445+ let store = MemoryPersistentTokenStore :: new ( ) ;
446+ store
447+ . put (
448+ & "other" . to_string ( ) ,
449+ & record_with ( vec ! [ 0x01 ; 32 ] , [ 0xAA ; 16 ] ) ,
450+ )
451+ . await ;
452+ let token = vec ! [ 0x07 ; 32 ] ;
453+ let device_identifier = [ 0x42 ; 16 ] ;
454+ store
455+ . put (
456+ & "target" . to_string ( ) ,
457+ & record_with ( token. clone ( ) , device_identifier) ,
458+ )
459+ . await ;
460+
461+ let info = info_with_enc_identifier ( build_enc_identifier (
462+ & token,
463+ & device_identifier,
464+ & [ 0x33 ; 16 ] ,
465+ ) ) ;
466+ let ( id, _) = recognize_authenticator ( & store, & info) . await . unwrap ( ) ;
467+ assert_eq ! ( id, "target" ) ;
468+ }
469+
470+ #[ tokio:: test]
471+ async fn none_without_enc_identifier ( ) {
472+ let store = MemoryPersistentTokenStore :: new ( ) ;
473+ store. put ( & "id-1" . to_string ( ) , & sample_record ( ) ) . await ;
474+ let info = Ctap2GetInfoResponse :: default ( ) ;
475+ assert ! ( recognize_authenticator( & store, & info) . await . is_none( ) ) ;
476+ }
173477}
0 commit comments