Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion libwebauthn/src/management/credential_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,12 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest {
}

fn permissions(&self) -> Ctap2AuthTokenPermissionRole {
Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT
if self.use_persistent_token {
// pcmr MUST be the sole permission requested (CTAP 2.3-PS 6.5.5.7).
Ctap2AuthTokenPermissionRole::PERSISTENT_CREDENTIAL_MANAGEMENT_READ_ONLY
} else {
Ctap2AuthTokenPermissionRole::CREDENTIAL_MANAGEMENT
}
}

fn permissions_rpid(&self) -> Option<&str> {
Expand All @@ -322,4 +327,16 @@ impl Ctap2UserVerifiableRequest for Ctap2CredentialManagementRequest {
fn needs_shared_secret(&self, _get_info_response: &Ctap2GetInfoResponse) -> bool {
false
}

fn set_persistent_token_use(&mut self, info: &Ctap2GetInfoResponse, store_available: bool) {
self.use_persistent_token = store_available
&& info.supports_persistent_credential_management_read_only()
&& self
.subcommand
.is_some_and(|subcommand| subcommand.is_read_only());
}

fn wants_persistent_token(&self) -> bool {
self.use_persistent_token
}
}
272 changes: 270 additions & 2 deletions libwebauthn/src/pin/persistent_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,27 @@ use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;

use aes::cipher::{block_padding::NoPadding, BlockDecryptMut};
use async_trait::async_trait;
use cbc::cipher::KeyIvInit;
use hkdf::Hkdf;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::Sha256;
use tokio::sync::Mutex;
use tracing::{debug, trace};
use tracing::{debug, error, trace, warn};
use zeroize::ZeroizeOnDrop;

use crate::proto::ctap2::Ctap2PinUvAuthProtocol;
use crate::proto::ctap2::{Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol};
use crate::proto::CtapError;
use crate::webauthn::error::{Error, PlatformError};

type Aes128CbcDecryptor = cbc::Decryptor<aes::Aes128>;

/// HKDF salt for `encIdentifier`/`encCredStoreState`: 32 zero bytes (CTAP 2.3-PS 6.4).
const ENC_IDENTIFIER_HKDF_SALT: [u8; 32] = [0u8; 32];
/// HKDF info string binding the derived key to the `encIdentifier` use.
const ENC_IDENTIFIER_HKDF_INFO: &[u8] = b"encIdentifier";

/// Opaque identifier for a stored persistent-token record. Random per record.
pub type PersistentTokenRecordId = String;
Expand Down Expand Up @@ -109,10 +124,136 @@ impl PersistentTokenStore for MemoryPersistentTokenStore {
}
}

/// Derive the 16-byte AES-128 key for `encIdentifier` from a persistent token, per
/// CTAP 2.3-PS 6.4: `HKDF-SHA-256(salt = 32 zero bytes, IKM = token, L = 16, info = "encIdentifier")`.
fn enc_identifier_key(token: &[u8]) -> Result<[u8; 16], Error> {
let hkdf = Hkdf::<Sha256>::new(Some(&ENC_IDENTIFIER_HKDF_SALT), token);
let mut key = [0u8; 16];
hkdf.expand(ENC_IDENTIFIER_HKDF_INFO, &mut key)
.map_err(|e| {
error!("HKDF expand error deriving encIdentifier key: {e}");
Error::Platform(PlatformError::CryptoError(format!(
"HKDF expand error: {e}"
)))
})?;
Ok(key)
}

/// Recover the 128-bit device identifier from an `encIdentifier` (`iv || ct`) using a
/// persistent token. `ct` is exactly one AES block, so decryption uses no padding.
pub(crate) fn decrypt_enc_identifier(
token: &[u8],
enc_identifier: &[u8],
) -> Result<[u8; 16], Error> {
if enc_identifier.len() != 32 {
error!(
len = enc_identifier.len(),
"encIdentifier is not a 16-byte IV followed by one 16-byte ciphertext block"
);
return Err(Error::Ctap(CtapError::Other));
}
let (iv, ciphertext) = enc_identifier.split_at(16);
let key = enc_identifier_key(token)?;
let Ok(decryptor) = Aes128CbcDecryptor::new_from_slices(&key, iv) else {
error!("Invalid key or IV for AES-128-CBC encIdentifier decryption");
return Err(Error::Ctap(CtapError::Other));
};
let Ok(plaintext) = decryptor.decrypt_padded_vec_mut::<NoPadding>(ciphertext) else {
error!("Decrypt error while recovering device identifier");
return Err(Error::Ctap(CtapError::Other));
};
plaintext.try_into().map_err(|_| {
error!("Recovered device identifier was not 16 bytes");
Error::Ctap(CtapError::Other)
})
}

/// Find the stored record whose persistent token reproduces this authenticator's
/// `encIdentifier`. The IV is fresh on every getInfo, so raw bytes never compare equal
/// across connections; recognition is decrypt-and-compare against each record's stored
/// device identifier. Returns the first match, or `None` if no stored token fits.
pub(crate) async fn recognize_authenticator(
store: &dyn PersistentTokenStore,
info: &Ctap2GetInfoResponse,
) -> Option<(PersistentTokenRecordId, PersistentTokenRecord)> {
let enc_identifier = info.enc_identifier.as_ref()?;
for (id, record) in store.list().await {
match decrypt_enc_identifier(&record.persistent_token, enc_identifier) {
Ok(device_identifier) if device_identifier == record.device_identifier => {
debug!(?id, "Recognized authenticator from persistent token store");
return Some((id, record));
}
_ => {}
}
}
None
}

/// A fresh, opaque record id: 16 random bytes, hex-encoded. Random rather than derived
/// from the device, so a record survives device-identifier changes only via reaping.
fn new_record_id() -> PersistentTokenRecordId {
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}

/// Capture a freshly minted pcmr token for cross-session reuse: recover this device's
/// identifier from `encIdentifier`, then store a new record under a fresh id. Returns the
/// id. Callers treat failures as best-effort (the current operation still proceeds with
/// the minted token).
pub(crate) async fn store_minted_token(
store: &dyn PersistentTokenStore,
info: &Ctap2GetInfoResponse,
token: &[u8],
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol,
) -> Result<PersistentTokenRecordId, Error> {
let Some(enc_identifier) = info.enc_identifier.as_ref() else {
warn!("perCredMgmtRO advertised but no encIdentifier returned; cannot persist token");
return Err(Error::Ctap(CtapError::Other));
};
let device_identifier = decrypt_enc_identifier(token, enc_identifier)?;
let aaguid: [u8; 16] = info.aaguid[..].try_into().map_err(|_| {
error!(len = info.aaguid.len(), "AAGUID was not 16 bytes");
Error::Ctap(CtapError::Other)
})?;
let id = new_record_id();
let record = PersistentTokenRecord {
persistent_token: token.to_vec(),
pin_uv_auth_protocol,
device_identifier,
aaguid,
};
store.put(&id, &record).await;
debug!(?id, "Stored freshly minted persistent token");
Ok(id)
}

/// Test-only: build an `encIdentifier` (`iv || ct`) for a device identifier under a
/// token, using the production key derivation. Shared across test modules.
#[cfg(test)]
pub(crate) fn build_enc_identifier(
token: &[u8],
device_identifier: &[u8; 16],
iv: &[u8; 16],
) -> Vec<u8> {
use aes::cipher::BlockEncryptMut;
type Aes128CbcEncryptor = cbc::Encryptor<aes::Aes128>;
let key = enc_identifier_key(token).expect("encIdentifier key derivation");
let encryptor = Aes128CbcEncryptor::new_from_slices(&key, iv).expect("valid key/iv");
let ciphertext = encryptor.encrypt_padded_vec_mut::<NoPadding>(device_identifier);
let mut enc = iv.to_vec();
enc.extend_from_slice(&ciphertext);
enc
}

#[cfg(test)]
mod test {
use super::*;

use serde_bytes::ByteBuf;

use crate::proto::ctap2::Ctap2GetInfoResponse;

fn sample_record() -> PersistentTokenRecord {
PersistentTokenRecord {
persistent_token: vec![0xAB; 32],
Expand All @@ -122,6 +263,22 @@ mod test {
}
}

fn record_with(token: Vec<u8>, device_identifier: [u8; 16]) -> PersistentTokenRecord {
PersistentTokenRecord {
persistent_token: token,
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::Two,
device_identifier,
aaguid: [0x22; 16],
}
}

fn info_with_enc_identifier(enc_identifier: Vec<u8>) -> Ctap2GetInfoResponse {
Ctap2GetInfoResponse {
enc_identifier: Some(ByteBuf::from(enc_identifier)),
..Default::default()
}
}

#[tokio::test]
async fn put_list_delete_round_trip() {
let store = MemoryPersistentTokenStore::new();
Expand Down Expand Up @@ -170,4 +327,115 @@ mod test {
fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<PersistentTokenRecord>();
}

#[test]
fn decrypt_enc_identifier_round_trips() {
let token = vec![0x07; 32];
let device_identifier = [0x42; 16];
let enc = build_enc_identifier(&token, &device_identifier, &[0x99; 16]);
assert_eq!(
decrypt_enc_identifier(&token, &enc).unwrap(),
device_identifier
);
}

#[test]
fn decrypt_enc_identifier_rejects_bad_length() {
let token = vec![0x07; 32];
assert!(decrypt_enc_identifier(&token, &[0u8; 31]).is_err());
assert!(decrypt_enc_identifier(&token, &[0u8; 33]).is_err());
assert!(decrypt_enc_identifier(&token, &[]).is_err());
}

#[tokio::test]
async fn recognizes_matching_record() {
let store = MemoryPersistentTokenStore::new();
let token = vec![0x07; 32];
let device_identifier = [0x42; 16];
store
.put(
&"id-1".to_string(),
&record_with(token.clone(), device_identifier),
)
.await;

// A second getInfo uses a fresh IV, so the bytes differ but recognition holds.
let info = info_with_enc_identifier(build_enc_identifier(
&token,
&device_identifier,
&[0x33; 16],
));
let (id, record) = recognize_authenticator(&store, &info).await.unwrap();
assert_eq!(id, "id-1");
assert_eq!(record.device_identifier, device_identifier);
}

#[tokio::test]
async fn rejects_wrong_token() {
let store = MemoryPersistentTokenStore::new();
let real_token = vec![0x07; 32];
let device_identifier = [0x42; 16];
// Stored record carries a different token, so its key cannot reproduce the id.
store
.put(
&"id-1".to_string(),
&record_with(vec![0xFF; 32], device_identifier),
)
.await;

let info = info_with_enc_identifier(build_enc_identifier(
&real_token,
&device_identifier,
&[0x33; 16],
));
assert!(recognize_authenticator(&store, &info).await.is_none());
}

#[tokio::test]
async fn rejects_stale_device_identifier() {
let store = MemoryPersistentTokenStore::new();
let token = vec![0x07; 32];
// Right token, but the stored device identifier is stale (e.g. after a reset).
store
.put(&"id-1".to_string(), &record_with(token.clone(), [0x00; 16]))
.await;

let info = info_with_enc_identifier(build_enc_identifier(&token, &[0x42; 16], &[0x33; 16]));
assert!(recognize_authenticator(&store, &info).await.is_none());
}

#[tokio::test]
async fn picks_correct_record_among_many() {
let store = MemoryPersistentTokenStore::new();
store
.put(
&"other".to_string(),
&record_with(vec![0x01; 32], [0xAA; 16]),
)
.await;
let token = vec![0x07; 32];
let device_identifier = [0x42; 16];
store
.put(
&"target".to_string(),
&record_with(token.clone(), device_identifier),
)
.await;

let info = info_with_enc_identifier(build_enc_identifier(
&token,
&device_identifier,
&[0x33; 16],
));
let (id, _) = recognize_authenticator(&store, &info).await.unwrap();
assert_eq!(id, "target");
}

#[tokio::test]
async fn none_without_enc_identifier() {
let store = MemoryPersistentTokenStore::new();
store.put(&"id-1".to_string(), &sample_record()).await;
let info = Ctap2GetInfoResponse::default();
assert!(recognize_authenticator(&store, &info).await.is_none());
}
}
9 changes: 9 additions & 0 deletions libwebauthn/src/proto/ctap2/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,15 @@ pub trait Ctap2UserVerifiableRequest {
fn handle_legacy_preview(&mut self, info: &Ctap2GetInfoResponse);
/// We need to establish a shared secret, even if no PIN or UV is set on the device
fn needs_shared_secret(&self, info: &Ctap2GetInfoResponse) -> bool;
/// Decide, and cache on the request, whether to acquire a persistent (pcmr) token.
/// Called once from the UV flow with whether a persistent token store is available.
/// Default: never request one.
fn set_persistent_token_use(&mut self, _info: &Ctap2GetInfoResponse, _store_available: bool) {}
/// Whether this request will reuse or mint a persistent (pcmr) token, per the cached
/// decision from [`Self::set_persistent_token_use`]. Default false.
fn wants_persistent_token(&self) -> bool {
false
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down
Loading
Loading