use rand::RngExt;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChallengePurpose {
Register,
Authenticate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WebAuthnChallengeError {
NotFound,
Expired,
PurposeMismatch,
UserMismatch,
}
impl std::fmt::Display for WebAuthnChallengeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "challenge invalid or already used"),
Self::Expired => write!(f, "challenge expired"),
Self::PurposeMismatch => write!(f, "challenge purpose mismatch"),
Self::UserMismatch => write!(f, "challenge user binding mismatch"),
}
}
}
impl std::error::Error for WebAuthnChallengeError {}
struct Entry {
issued_at: Instant,
purpose: ChallengePurpose,
user_id: Option<String>,
}
pub struct WebAuthnChallengeStore {
entries: Arc<RwLock<HashMap<String, Entry>>>,
ttl: Duration,
cap: usize,
}
impl Default for WebAuthnChallengeStore {
fn default() -> Self {
Self::new()
}
}
static SHARED_CHALLENGES: tokio::sync::OnceCell<WebAuthnChallengeStore> =
tokio::sync::OnceCell::const_new();
impl WebAuthnChallengeStore {
pub async fn shared() -> &'static Self {
SHARED_CHALLENGES
.get_or_init(|| async { Self::new() })
.await
}
}
impl WebAuthnChallengeStore {
#[must_use]
pub fn new() -> Self {
Self {
entries: Arc::new(RwLock::new(HashMap::new())),
ttl: Duration::from_secs(180),
cap: 10_000,
}
}
#[must_use]
pub fn with_limits(ttl: Duration, cap: usize) -> Self {
Self {
entries: Arc::new(RwLock::new(HashMap::new())),
ttl,
cap,
}
}
pub async fn issue(
&self,
purpose: ChallengePurpose,
user_id: Option<&str>,
) -> Result<String, WebAuthnChallengeError> {
self.prune().await;
let encoded = {
let mut rng = rand::rng();
let raw: [u8; 32] = rng.random();
base64url_encode(&Sha256::digest(raw))
};
let mut store = self.entries.write().await;
if store.len() >= self.cap {
let mut v: Vec<(String, Instant)> = store
.iter()
.map(|(k, e)| (k.clone(), e.issued_at))
.collect();
v.sort_by_key(|(_, t)| *t);
let drop_n = v.len().saturating_sub(self.cap.saturating_sub(1));
if drop_n > 0 {
for (k, _) in v.into_iter().take(drop_n) {
store.remove(&k);
}
}
}
store.insert(
encoded.clone(),
Entry {
issued_at: Instant::now(),
purpose,
user_id: user_id.map(ToString::to_string),
},
);
Ok(encoded)
}
pub async fn verify(
&self,
challenge_b64: &str,
expected_purpose: ChallengePurpose,
expected_user: Option<&str>,
) -> Result<(), WebAuthnChallengeError> {
let entry = {
let mut store = self.entries.write().await;
store.remove(challenge_b64)
};
let Some(entry) = entry else {
return Err(WebAuthnChallengeError::NotFound);
};
if entry.issued_at.elapsed() > self.ttl {
return Err(WebAuthnChallengeError::Expired);
}
if entry.purpose != expected_purpose {
return Err(WebAuthnChallengeError::PurposeMismatch);
}
match (&entry.user_id, expected_user) {
(Some(bound), Some(want)) if bound != want => {
return Err(WebAuthnChallengeError::UserMismatch);
}
(Some(_), None) | (None, Some(_)) => {
return Err(WebAuthnChallengeError::UserMismatch);
}
(None, None) | (Some(_), Some(_)) => {}
}
Ok(())
}
pub async fn len(&self) -> usize {
self.entries.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.entries.read().await.is_empty()
}
async fn prune(&self) {
let mut store = self.entries.write().await;
store.retain(|_, e| e.issued_at.elapsed() <= self.ttl);
}
}
#[must_use]
pub fn base64url_encode(bytes: &[u8]) -> String {
crate::utils::base64url::encode(bytes)
}
pub fn base64url_decode(s: &str) -> Result<Vec<u8>, WebAuthnChallengeError> {
crate::utils::base64url::decode(s).map_err(|_| WebAuthnChallengeError::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn roundtrip_and_single_use() {
let s = WebAuthnChallengeStore::new();
let c = s.issue(ChallengePurpose::Authenticate, None).await.unwrap();
assert!(s
.verify(&c, ChallengePurpose::Authenticate, None)
.await
.is_ok());
assert_eq!(
s.verify(&c, ChallengePurpose::Authenticate, None).await,
Err(WebAuthnChallengeError::NotFound)
);
}
#[tokio::test]
async fn purpose_mismatch_rejected() {
let s = WebAuthnChallengeStore::new();
let c = s.issue(ChallengePurpose::Register, None).await.unwrap();
assert_eq!(
s.verify(&c, ChallengePurpose::Authenticate, None).await,
Err(WebAuthnChallengeError::PurposeMismatch)
);
}
#[tokio::test]
async fn user_binding_enforced() {
let s = WebAuthnChallengeStore::new();
let c = s
.issue(ChallengePurpose::Register, Some("user-1"))
.await
.unwrap();
assert_eq!(
s.verify(&c, ChallengePurpose::Register, Some("user-2"))
.await,
Err(WebAuthnChallengeError::UserMismatch)
);
assert_eq!(
s.verify(&c, ChallengePurpose::Register, Some("user-1"))
.await,
Err(WebAuthnChallengeError::NotFound)
);
let c2 = s
.issue(ChallengePurpose::Register, Some("user-1"))
.await
.unwrap();
assert!(s
.verify(&c2, ChallengePurpose::Register, Some("user-1"))
.await
.is_ok());
}
#[tokio::test]
async fn expired_challenge_rejected_and_removed() {
let s = WebAuthnChallengeStore::with_limits(Duration::from_millis(1), 100);
let c = s.issue(ChallengePurpose::Authenticate, None).await.unwrap();
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(
s.verify(&c, ChallengePurpose::Authenticate, None).await,
Err(WebAuthnChallengeError::Expired)
);
assert_eq!(s.len().await, 0);
}
#[test]
fn base64url_roundtrip_known_vectors() {
assert_eq!(base64url_encode(b""), "");
assert_eq!(base64url_encode(b"f"), "Zg");
assert_eq!(base64url_encode(b"fo"), "Zm8");
assert_eq!(base64url_encode(b"foo"), "Zm9v");
assert_eq!(base64url_encode(b"foob"), "Zm9vYg");
assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
for case in [b"".as_slice(), b"a", b"ab", b"abc", b"\x00\xff\x10\x20"] {
assert_eq!(
base64url_decode(&base64url_encode(case)).unwrap(),
case,
"roundtrip {case:?}"
);
}
}
#[test]
fn issued_challenges_are_unpadded_and_urlsafe() {
for _ in 0..8 {
let c = futures_lite_block_on(
WebAuthnChallengeStore::new().issue(ChallengePurpose::Authenticate, None),
)
.unwrap();
assert!(!c.contains('='), "challenge {c} must be unpadded");
assert!(!c.contains('+') && !c.contains('/'), "{c} not url-safe");
}
}
#[test]
fn base64url_decode_rejects_malformed() {
assert!(base64url_decode("A").is_err()); assert!(base64url_decode("Zm9=").is_err()); assert!(base64url_decode("Zm$v").is_err()); assert_eq!(base64url_decode("Zg==").unwrap(), b"f"); }
fn futures_lite_block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime")
.block_on(fut)
}
}