Skip to content

Commit e150a60

Browse files
feat(pin): reuse and mint persistent tokens
1 parent 497280a commit e150a60

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};
@@ -128,7 +130,6 @@ impl PersistentTokenStore for EphemeralPersistentTokenStore {
128130

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

145146
/// Recover the 128-bit device identifier from an `encIdentifier` (`iv || ct`) using a
146147
/// persistent token. `ct` is exactly one AES block, so decryption uses no padding.
147-
#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change
148148
pub(crate) fn decrypt_enc_identifier(
149149
token: &[u8],
150150
enc_identifier: &[u8],
@@ -176,7 +176,6 @@ pub(crate) fn decrypt_enc_identifier(
176176
/// `encIdentifier`. The IV is fresh on every getInfo, so raw bytes never compare equal
177177
/// across connections; recognition is decrypt-and-compare against each record's stored
178178
/// device identifier. Returns the first match, or `None` if no stored token fits.
179-
#[allow(dead_code)] // wired into the acquisition/reuse flow in a later change
180179
pub(crate) async fn recognize_authenticator(
181180
store: &dyn PersistentTokenStore,
182181
info: &Ctap2GetInfoResponse,
@@ -194,18 +193,71 @@ pub(crate) async fn recognize_authenticator(
194193
None
195194
}
196195

196+
/// A fresh, opaque record id: 16 random bytes, hex-encoded. Random rather than derived
197+
/// from the device, so a record survives device-identifier changes only via reaping.
198+
fn new_record_id() -> PersistentTokenRecordId {
199+
let mut bytes = [0u8; 16];
200+
OsRng.fill_bytes(&mut bytes);
201+
hex::encode(bytes)
202+
}
203+
204+
/// Capture a freshly minted pcmr token for cross-session reuse: recover this device's
205+
/// identifier from `encIdentifier`, then store a new record under a fresh id. Returns the
206+
/// id. Callers treat failures as best-effort (the current operation still proceeds with
207+
/// the minted token).
208+
pub(crate) async fn store_minted_token(
209+
store: &dyn PersistentTokenStore,
210+
info: &Ctap2GetInfoResponse,
211+
token: &[u8],
212+
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol,
213+
) -> Result<PersistentTokenRecordId, Error> {
214+
let Some(enc_identifier) = info.enc_identifier.as_ref() else {
215+
warn!("perCredMgmtRO advertised but no encIdentifier returned; cannot persist token");
216+
return Err(Error::Ctap(CtapError::Other));
217+
};
218+
let device_identifier = decrypt_enc_identifier(token, enc_identifier)?;
219+
let aaguid: [u8; 16] = info.aaguid[..].try_into().map_err(|_| {
220+
error!(len = info.aaguid.len(), "AAGUID was not 16 bytes");
221+
Error::Ctap(CtapError::Other)
222+
})?;
223+
let id = new_record_id();
224+
let record = PersistentTokenRecord {
225+
persistent_token: token.to_vec(),
226+
pin_uv_auth_protocol,
227+
device_identifier,
228+
aaguid,
229+
};
230+
store.put(&id, &record).await;
231+
debug!(?id, "Stored freshly minted persistent token");
232+
Ok(id)
233+
}
234+
235+
/// Test-only: build an `encIdentifier` (`iv || ct`) for a device identifier under a
236+
/// token, using the production key derivation. Shared across test modules.
237+
#[cfg(test)]
238+
pub(crate) fn build_enc_identifier(
239+
token: &[u8],
240+
device_identifier: &[u8; 16],
241+
iv: &[u8; 16],
242+
) -> Vec<u8> {
243+
use aes::cipher::BlockEncryptMut;
244+
type Aes128CbcEncryptor = cbc::Encryptor<aes::Aes128>;
245+
let key = enc_identifier_key(token).expect("encIdentifier key derivation");
246+
let encryptor = Aes128CbcEncryptor::new_from_slices(&key, iv).expect("valid key/iv");
247+
let ciphertext = encryptor.encrypt_padded_vec_mut::<NoPadding>(device_identifier);
248+
let mut enc = iv.to_vec();
249+
enc.extend_from_slice(&ciphertext);
250+
enc
251+
}
252+
197253
#[cfg(test)]
198254
mod test {
199255
use super::*;
200256

201-
use aes::cipher::{block_padding::NoPadding as TestNoPadding, BlockEncryptMut};
202-
use cbc::cipher::KeyIvInit as TestKeyIvInit;
203257
use serde_bytes::ByteBuf;
204258

205259
use crate::proto::ctap2::Ctap2GetInfoResponse;
206260

207-
type Aes128CbcEncryptor = cbc::Encryptor<aes::Aes128>;
208-
209261
fn sample_record() -> PersistentTokenRecord {
210262
PersistentTokenRecord {
211263
persistent_token: vec![0xAB; 32],
@@ -215,17 +267,6 @@ mod test {
215267
}
216268
}
217269

218-
/// The authenticator side: produce `iv || ct` for a device identifier under a token,
219-
/// using the same key derivation the recognition path uses.
220-
fn build_enc_identifier(token: &[u8], device_identifier: &[u8; 16], iv: &[u8; 16]) -> Vec<u8> {
221-
let key = enc_identifier_key(token).unwrap();
222-
let encryptor = Aes128CbcEncryptor::new_from_slices(&key, iv).unwrap();
223-
let ciphertext = encryptor.encrypt_padded_vec_mut::<TestNoPadding>(device_identifier);
224-
let mut enc = iv.to_vec();
225-
enc.extend_from_slice(&ciphertext);
226-
enc
227-
}
228-
229270
fn record_with(token: Vec<u8>, device_identifier: [u8; 16]) -> PersistentTokenRecord {
230271
PersistentTokenRecord {
231272
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)