Skip to content

Commit 1bba900

Browse files
feat(pin): acquire and reuse persistent tokens via encIdentifier recognition (#233)
1 parent 7ed48b9 commit 1bba900

7 files changed

Lines changed: 695 additions & 23 deletions

File tree

libwebauthn/src/management/credential_management.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,12 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest {
295295
}
296296

297297
fn permissions(&self) -> Ctap2AuthTokenPermissionRole {
298-
Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT
298+
if self.use_persistent_token {
299+
// pcmr MUST be the sole permission requested (CTAP 2.3-PS 6.5.5.7).
300+
Ctap2AuthTokenPermissionRole::PERSISTENT_CREDENTIAL_MANAGEMENT_READ_ONLY
301+
} else {
302+
Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT
303+
}
299304
}
300305

301306
fn permissions_rpid(&self) -> Option<&str> {
@@ -322,4 +327,16 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest {
322327
fn needs_shared_secret(&self, _get_info_response: &Ctap2GetInfoResponse) -> bool {
323328
false
324329
}
330+
331+
fn set_persistent_token_use(&mut self, info: &Ctap2GetInfoResponse, store_available: bool) {
332+
self.use_persistent_token = store_available
333+
&& info.supports_persistent_credential_management_read_only()
334+
&& self
335+
.subcommand
336+
.is_some_and(|subcommand| subcommand.is_read_only());
337+
}
338+
339+
fn wants_persistent_token(&self) -> bool {
340+
self.use_persistent_token
341+
}
325342
}

libwebauthn/src/pin/persistent_token.rs

Lines changed: 306 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,27 @@ use std::collections::HashMap;
22
use std::fmt;
33
use std::sync::Arc;
44

5+
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut};
56
use 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;
612
use tokio::sync::Mutex;
7-
use tracing::{debug, trace};
13+
use tracing::{debug, error, trace, warn};
814
use 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.
1328
pub 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)]
113250
mod 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
}

libwebauthn/src/proto/ctap2/model.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,15 @@ pub trait Ctap2UserVerifiableRequest {
310310
fn handle_legacy_preview(&mut self, info: &Ctap2GetInfoResponse);
311311
/// We need to establish a shared secret, even if no PIN or UV is set on the device
312312
fn needs_shared_secret(&self, info: &Ctap2GetInfoResponse) -> bool;
313+
/// Decide, and cache on the request, whether to acquire a persistent (pcmr) token.
314+
/// Called once from the UV flow with whether a persistent token store is available.
315+
/// Default: never request one.
316+
fn set_persistent_token_use(&mut self, _info: &Ctap2GetInfoResponse, _store_available: bool) {}
317+
/// Whether this request will reuse or mint a persistent (pcmr) token, per the cached
318+
/// decision from [`Self::set_persistent_token_use`]. Default false.
319+
fn wants_persistent_token(&self) -> bool {
320+
false
321+
}
313322
}
314323

315324
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

0 commit comments

Comments
 (0)