Skip to content

Commit b16de46

Browse files
feat(pin): reuse and mint persistent tokens
1 parent 1a50d39 commit b16de46

3 files changed

Lines changed: 365 additions & 39 deletions

File tree

libwebauthn/src/pin/persistent_token.rs

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ use aes::cipher::{block_padding::NoPadding, BlockDecryptMut};
66
use async_trait::async_trait;
77
use cbc::cipher::KeyIvInit;
88
use hkdf::Hkdf;
9+
use rand::rngs::OsRng;
10+
use rand::RngCore;
911
use sha2::Sha256;
1012
use tokio::sync::Mutex;
11-
use tracing::{debug, error, trace};
13+
use tracing::{debug, error, trace, warn};
1214
use zeroize::ZeroizeOnDrop;
1315

1416
use crate::proto::ctap2::{Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol};
@@ -124,7 +126,6 @@ impl PersistentTokenStore for MemoryPersistentTokenStore {
124126

125127
/// Derive the 16-byte AES-128 key for `encIdentifier` from a persistent token, per
126128
/// CTAP 2.3-PS 6.4: `HKDF-SHA-256(salt = 32 zero bytes, IKM = token, L = 16, info = "encIdentifier")`.
127-
#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change
128129
fn enc_identifier_key(token: &[u8]) -> Result<[u8; 16], Error> {
129130
let hkdf = Hkdf::<Sha256>::new(Some(&ENC_IDENTIFIER_HKDF_SALT), token);
130131
let mut key = [0u8; 16];
@@ -140,7 +141,6 @@ fn enc_identifier_key(token: &[u8]) -> Result<[u8; 16], Error> {
140141

141142
/// Recover the 128-bit device identifier from an `encIdentifier` (`iv || ct`) using a
142143
/// persistent token. `ct` is exactly one AES block, so decryption uses no padding.
143-
#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change
144144
pub(crate) fn decrypt_enc_identifier(
145145
token: &[u8],
146146
enc_identifier: &[u8],
@@ -172,7 +172,6 @@ pub(crate) fn decrypt_enc_identifier(
172172
/// `encIdentifier`. The IV is fresh on every getInfo, so raw bytes never compare equal
173173
/// across connections; recognition is decrypt-and-compare against each record's stored
174174
/// device identifier. Returns the first match, or `None` if no stored token fits.
175-
#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change
176175
pub(crate) async fn recognize_authenticator(
177176
store: &dyn PersistentTokenStore,
178177
info: &Ctap2GetInfoResponse,
@@ -190,18 +189,71 @@ pub(crate) async fn recognize_authenticator(
190189
None
191190
}
192191

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+
193249
#[cfg(test)]
194250
mod test {
195251
use super::*;
196252

197-
use aes::cipher::{block_padding::NoPadding as TestNoPadding, BlockEncryptMut};
198-
use cbc::cipher::KeyIvInit as TestKeyIvInit;
199253
use serde_bytes::ByteBuf;
200254

201255
use crate::proto::ctap2::Ctap2GetInfoResponse;
202256

203-
type Aes128CbcEncryptor = cbc::Encryptor<aes::Aes128>;
204-
205257
fn sample_record() -> PersistentTokenRecord {
206258
PersistentTokenRecord {
207259
persistent_token: vec![0xAB; 32],
@@ -211,17 +263,6 @@ mod test {
211263
}
212264
}
213265

214-
/// The authenticator side: produce `iv || ct` for a device identifier under a token,
215-
/// using the same key derivation the recognition path uses.
216-
fn build_enc_identifier(token: &[u8], device_identifier: &[u8; 16], iv: &[u8; 16]) -> Vec<u8> {
217-
let key = enc_identifier_key(token).unwrap();
218-
let encryptor = Aes128CbcEncryptor::new_from_slices(&key, iv).unwrap();
219-
let ciphertext = encryptor.encrypt_padded_vec_mut::<TestNoPadding>(device_identifier);
220-
let mut enc = iv.to_vec();
221-
enc.extend_from_slice(&ciphertext);
222-
enc
223-
}
224-
225266
fn record_with(token: Vec<u8>, device_identifier: [u8; 16]) -> PersistentTokenRecord {
226267
PersistentTokenRecord {
227268
persistent_token: token,

libwebauthn/src/transport/mock/channel.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
use async_trait::async_trait;
2+
use std::sync::Arc;
23
use std::{collections::VecDeque, fmt::Display, time::Duration};
34
use tokio::sync::broadcast;
45
use tokio::time::sleep;
56

67
use crate::{
8+
pin::persistent_token::PersistentTokenStore,
79
proto::{
810
ctap1::apdu::{ApduRequest, ApduResponse},
911
ctap2::cbor::{CborRequest, CborResponse},
@@ -19,6 +21,7 @@ pub struct MockChannel {
1921
expected_requests: VecDeque<CborRequest>,
2022
responses: VecDeque<CborResponse>,
2123
auth_token_data: Option<AuthTokenData>,
24+
persistent_token_store: Option<Arc<dyn PersistentTokenStore>>,
2225
ux_update_sender: broadcast::Sender<UvUpdate>,
2326
pre_send_delay: Option<Duration>,
2427
}
@@ -36,6 +39,7 @@ impl MockChannel {
3639
expected_requests: VecDeque::new(),
3740
responses: VecDeque::new(),
3841
auth_token_data: None,
42+
persistent_token_store: None,
3943
ux_update_sender,
4044
pre_send_delay: None,
4145
}
@@ -46,6 +50,10 @@ impl MockChannel {
4650
self.responses.push_front(response);
4751
}
4852

53+
pub fn set_persistent_token_store(&mut self, store: Arc<dyn PersistentTokenStore>) {
54+
self.persistent_token_store = Some(store);
55+
}
56+
4957
/// Make `cbor_send` sleep for `delay` before completing, modeling a transport that defers the actual send behind a handshake.
5058
pub fn set_pre_send_delay(&mut self, delay: Duration) {
5159
self.pre_send_delay = Some(delay);
@@ -64,6 +72,10 @@ impl Ctap2AuthTokenStore for MockChannel {
6472
fn clear_uv_auth_token_store(&mut self) {
6573
self.auth_token_data = None;
6674
}
75+
76+
fn persistent_token_store(&self) -> Option<Arc<dyn PersistentTokenStore>> {
77+
self.persistent_token_store.clone()
78+
}
6779
}
6880

6981
impl Display for MockChannel {

0 commit comments

Comments
 (0)