Skip to content

Commit f67ab0b

Browse files
feat(pin): add PersistentTokenStore trait and in-memory store
1 parent 9425990 commit f67ab0b

5 files changed

Lines changed: 185 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libwebauthn/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ aes = "0.8.2"
7373
hmac = "0.12.1"
7474
cbc = { version = "0.1", features = ["alloc"] }
7575
hkdf = "0.12"
76+
zeroize = { version = "1.8", features = ["derive"] }
7677
text_io = "0.1"
7778
tungstenite = { version = "0.26.2" }
7879
tokio-tungstenite = { version = "0.26", features = [
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use sha2::{Digest, Sha256};
1515
use tracing::{error, instrument, warn};
1616
use x509_parser::nom::AsBytes;
1717

18+
pub mod persistent_token;
19+
1820
use crate::{
1921
proto::{
2022
ctap2::{Ctap2, Ctap2ClientPinRequest, Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol},
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
use std::collections::HashMap;
2+
use std::fmt;
3+
use std::sync::Arc;
4+
5+
use async_trait::async_trait;
6+
use tokio::sync::Mutex;
7+
use tracing::{debug, trace};
8+
use zeroize::ZeroizeOnDrop;
9+
10+
use crate::proto::ctap2::Ctap2PinUvAuthProtocol;
11+
12+
/// Opaque identifier for a stored persistent-token record. Random per record.
13+
pub type PersistentTokenRecordId = String;
14+
15+
/// A persistent pinUvAuthToken (`pcmr`) together with the data needed to recognize
16+
/// the authenticator it belongs to and to reuse the token on later connections.
17+
#[derive(Clone, ZeroizeOnDrop)]
18+
pub struct PersistentTokenRecord {
19+
/// Decrypted pcmr token; the HMAC key used to authenticate reuse.
20+
pub persistent_token: Vec<u8>,
21+
/// PIN/UV auth protocol the token was minted under.
22+
#[zeroize(skip)]
23+
pub pin_uv_auth_protocol: Ctap2PinUvAuthProtocol,
24+
/// 128-bit device identifier recovered from `encIdentifier`; the recognition key.
25+
#[zeroize(skip)]
26+
pub device_identifier: [u8; 16],
27+
/// Authenticator AAGUID; a non-secret label, used only for orphan reaping.
28+
#[zeroize(skip)]
29+
pub aaguid: [u8; 16],
30+
}
31+
32+
impl fmt::Debug for PersistentTokenRecord {
33+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34+
f.debug_struct("PersistentTokenRecord")
35+
.field("persistent_token", &"<redacted>")
36+
.field("pin_uv_auth_protocol", &self.pin_uv_auth_protocol)
37+
.field("device_identifier", &self.device_identifier)
38+
.field("aaguid", &self.aaguid)
39+
.finish()
40+
}
41+
}
42+
43+
/// Caller-supplied store for persistent pinUvAuthTokens (`pcmr`), surviving the
44+
/// authenticator power cycle so a credential manager need not re-prompt for the PIN
45+
/// on every launch or replug.
46+
///
47+
/// # Security
48+
///
49+
/// Each [`PersistentTokenRecord::persistent_token`] is a cleartext, long-lived bearer
50+
/// secret. Implementations MUST persist it with confidentiality equivalent to other
51+
/// credential secrets: an OS keyring, or encrypted-at-rest with OS access control.
52+
/// Implementations MUST NOT write it to world- or group-readable files, to logs, or to
53+
/// unprotected sync/backup. A leaked token lets an attacker, with no PIN and no user
54+
/// presence, perform read-only credential management on that one authenticator
55+
/// (enumerate RPs, usernames, display names, user handles, credential metadata). It
56+
/// grants no assertion, creation, update, or deletion.
57+
///
58+
/// libwebauthn ships only the in-memory [`MemoryPersistentTokenStore`] and leaves the
59+
/// choice of any durable backend to the embedder.
60+
///
61+
/// All methods are infallible by design: a failed read behaves as a cache miss and the
62+
/// flow falls back to a normal PIN/UV ceremony, and a failed write is best-effort.
63+
#[async_trait]
64+
pub trait PersistentTokenStore: fmt::Debug + Send + Sync {
65+
/// Returns every stored record. Recognition trial-decrypts `encIdentifier` against
66+
/// each, so the whole set is enumerated on connect.
67+
async fn list(&self) -> Vec<(PersistentTokenRecordId, PersistentTokenRecord)>;
68+
/// Inserts or replaces the record under `id`.
69+
async fn put(&self, id: &PersistentTokenRecordId, record: &PersistentTokenRecord);
70+
/// Removes the record under `id`, if present.
71+
async fn delete(&self, id: &PersistentTokenRecordId);
72+
}
73+
74+
/// In-memory [`PersistentTokenStore`], holding records for the lifetime of the process.
75+
/// Suitable for tests and for long-lived processes such as a system daemon.
76+
#[derive(Debug, Default, Clone)]
77+
pub struct MemoryPersistentTokenStore {
78+
records: Arc<Mutex<HashMap<PersistentTokenRecordId, PersistentTokenRecord>>>,
79+
}
80+
81+
impl MemoryPersistentTokenStore {
82+
pub fn new() -> Self {
83+
Self {
84+
records: Arc::new(Mutex::new(HashMap::new())),
85+
}
86+
}
87+
}
88+
89+
#[async_trait]
90+
impl PersistentTokenStore for MemoryPersistentTokenStore {
91+
async fn list(&self) -> Vec<(PersistentTokenRecordId, PersistentTokenRecord)> {
92+
let records = self.records.lock().await;
93+
debug!(count = records.len(), "Listing persistent token records");
94+
records
95+
.iter()
96+
.map(|(id, record)| (id.clone(), record.clone()))
97+
.collect()
98+
}
99+
100+
async fn put(&self, id: &PersistentTokenRecordId, record: &PersistentTokenRecord) {
101+
debug!(?id, "Storing persistent token record");
102+
trace!(?record);
103+
self.records.lock().await.insert(id.clone(), record.clone());
104+
}
105+
106+
async fn delete(&self, id: &PersistentTokenRecordId) {
107+
debug!(?id, "Deleting persistent token record");
108+
self.records.lock().await.remove(id);
109+
}
110+
}
111+
112+
#[cfg(test)]
113+
mod test {
114+
use super::*;
115+
116+
fn sample_record() -> PersistentTokenRecord {
117+
PersistentTokenRecord {
118+
persistent_token: vec![0xAB; 32],
119+
pin_uv_auth_protocol: Ctap2PinUvAuthProtocol::Two,
120+
device_identifier: [0x11; 16],
121+
aaguid: [0x22; 16],
122+
}
123+
}
124+
125+
#[tokio::test]
126+
async fn put_list_delete_round_trip() {
127+
let store = MemoryPersistentTokenStore::new();
128+
assert!(store.list().await.is_empty());
129+
130+
let id = "record-1".to_string();
131+
store.put(&id, &sample_record()).await;
132+
133+
let listed = store.list().await;
134+
assert_eq!(listed.len(), 1);
135+
let (listed_id, listed_record) = &listed[0];
136+
assert_eq!(listed_id, &id);
137+
assert_eq!(listed_record.persistent_token, vec![0xAB; 32]);
138+
assert_eq!(listed_record.device_identifier, [0x11; 16]);
139+
140+
store.delete(&id).await;
141+
assert!(store.list().await.is_empty());
142+
}
143+
144+
#[tokio::test]
145+
async fn put_replaces_existing_id() {
146+
let store = MemoryPersistentTokenStore::new();
147+
let id = "record-1".to_string();
148+
store.put(&id, &sample_record()).await;
149+
150+
let mut replacement = sample_record();
151+
replacement.persistent_token = vec![0xCD; 32];
152+
store.put(&id, &replacement).await;
153+
154+
let listed = store.list().await;
155+
assert_eq!(listed.len(), 1);
156+
assert_eq!(listed[0].1.persistent_token, vec![0xCD; 32]);
157+
}
158+
159+
#[test]
160+
fn debug_redacts_token() {
161+
let rendered = format!("{:?}", sample_record());
162+
assert!(rendered.contains("<redacted>"));
163+
// The token bytes (0xAB repeated) must never appear in any rendering.
164+
assert!(!rendered.contains("171, 171"));
165+
assert!(!rendered.contains("ab, ab"));
166+
}
167+
168+
#[test]
169+
fn record_is_zeroize_on_drop() {
170+
fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
171+
assert_zeroize_on_drop::<PersistentTokenRecord>();
172+
}
173+
}

libwebauthn/src/transport/channel.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::fmt::{Debug, Display};
2+
use std::sync::Arc;
23
use std::time::Duration;
34

5+
use crate::pin::persistent_token::PersistentTokenStore;
46
use crate::proto::ctap2::{
57
Ctap2AuthTokenPermissionRole, Ctap2PinUvAuthProtocol, Ctap2UserVerificationOperation,
68
};
@@ -164,4 +166,10 @@ pub trait Ctap2AuthTokenStore {
164166
}
165167
false
166168
}
169+
170+
/// Caller-supplied persistent pinUvAuthToken (pcmr) store, if one is configured.
171+
/// Defaults to `None`; only channels wired with a store override this.
172+
fn persistent_token_store(&self) -> Option<Arc<dyn PersistentTokenStore>> {
173+
None
174+
}
167175
}

0 commit comments

Comments
 (0)