use crate::ceremony::MINIMUM_CHALLENGE_BYTES;
use rustlavel_auth::{base64, random, unix_now};
use rustlavel_core::{Error, Result};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
pub const CHALLENGE_BYTES: usize = 32;
pub const DEFAULT_LIFETIME: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ceremony {
Registration,
Authentication,
}
impl Ceremony {
pub fn client_data_type(self) -> &'static str {
match self {
Ceremony::Registration => "webauthn.create",
Ceremony::Authentication => "webauthn.get",
}
}
pub fn name(self) -> &'static str {
match self {
Ceremony::Registration => "registration",
Ceremony::Authentication => "authentication",
}
}
}
#[derive(Clone)]
pub struct Challenge {
bytes: Vec<u8>,
ceremony: Ceremony,
expires_at: u64,
user_handle: Option<Vec<u8>>,
}
impl Challenge {
pub fn issue(ceremony: Ceremony) -> Challenge {
Challenge::issue_lasting(ceremony, DEFAULT_LIFETIME)
}
pub fn issue_lasting(ceremony: Ceremony, lifetime: u64) -> Challenge {
Challenge {
bytes: random::bytes(CHALLENGE_BYTES),
ceremony,
expires_at: unix_now().saturating_add(lifetime),
user_handle: None,
}
}
pub fn bound_to(mut self, user_handle: impl Into<Vec<u8>>) -> Challenge {
self.user_handle = Some(user_handle.into());
self
}
pub fn restore(
bytes: Vec<u8>,
ceremony: Ceremony,
expires_at: u64,
user_handle: Option<Vec<u8>>,
) -> Result<Challenge> {
if bytes.len() < MINIMUM_CHALLENGE_BYTES {
return Err(Error::msg(format!(
"a stored challenge is {} bytes, and anything below {MINIMUM_CHALLENGE_BYTES} \
is guessable rather than unpredictable",
bytes.len()
)));
}
Ok(Challenge { bytes, ceremony, expires_at, user_handle })
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn encoded(&self) -> String {
base64::encode_url(&self.bytes)
}
pub fn ceremony(&self) -> Ceremony {
self.ceremony
}
pub fn expires_at(&self) -> u64 {
self.expires_at
}
pub fn user_handle(&self) -> Option<&[u8]> {
self.user_handle.as_deref()
}
pub fn has_expired(&self, now: u64) -> bool {
now >= self.expires_at
}
pub(crate) fn accept(&self, ceremony: Ceremony, now: u64) -> Result<()> {
if self.ceremony != ceremony {
return Err(Error::msg(format!(
"that challenge was issued for {} and this is {}. A challenge is good for one \
ceremony, or a response collected by one endpoint can be spent at the other.",
self.ceremony.name(),
ceremony.name()
)));
}
if self.has_expired(now) {
return Err(Error::msg(format!(
"that challenge expired {} seconds ago. A ceremony has {DEFAULT_LIFETIME} \
seconds by default, which is longer than a person needs and shorter than an \
attacker wants.",
now - self.expires_at
)));
}
Ok(())
}
}
impl std::fmt::Debug for Challenge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Challenge")
.field("ceremony", &self.ceremony)
.field("expires_at", &self.expires_at)
.field("bound", &self.user_handle.is_some())
.field("challenge", &format_args!("<{} bytes>", self.bytes.len()))
.finish()
}
}
pub type ChallengeFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
pub trait ChallengeStore: Send + Sync + 'static {
fn store(&self, challenge: Challenge) -> ChallengeFuture<'_, ()>;
fn take<'a>(&'a self, encoded: &'a str) -> ChallengeFuture<'a, Option<Challenge>>;
}
pub type SharedChallengeStore = Arc<dyn ChallengeStore>;
pub struct MemoryChallengeStore {
challenges: Mutex<HashMap<String, Challenge>>,
}
impl MemoryChallengeStore {
pub fn new() -> MemoryChallengeStore {
MemoryChallengeStore { challenges: Mutex::new(HashMap::new()) }
}
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn purge_expired(&self, now: u64) {
self.lock().retain(|_, challenge| !challenge.has_expired(now));
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Challenge>> {
self.challenges.lock().expect("challenge store lock poisoned")
}
}
impl Default for MemoryChallengeStore {
fn default() -> Self {
MemoryChallengeStore::new()
}
}
impl std::fmt::Debug for MemoryChallengeStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemoryChallengeStore").field("outstanding", &self.len()).finish()
}
}
impl ChallengeStore for MemoryChallengeStore {
fn store(&self, challenge: Challenge) -> ChallengeFuture<'_, ()> {
Box::pin(async move {
self.purge_expired(unix_now());
self.lock().insert(challenge.encoded(), challenge);
Ok(())
})
}
fn take<'a>(&'a self, encoded: &'a str) -> ChallengeFuture<'a, Option<Challenge>> {
Box::pin(async move { Ok(self.lock().remove(encoded)) })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_challenge_is_thirty_two_unpredictable_bytes() {
let one = Challenge::issue(Ceremony::Registration);
let two = Challenge::issue(Ceremony::Registration);
assert_eq!(one.bytes().len(), CHALLENGE_BYTES);
assert_ne!(one.bytes(), two.bytes());
assert_eq!(base64::decode(&one.encoded()).unwrap(), one.bytes());
}
#[tokio::test]
async fn answering_a_challenge_takes_it_out_of_the_store() {
let store = MemoryChallengeStore::new();
let challenge = Challenge::issue(Ceremony::Authentication);
let key = challenge.encoded();
store.store(challenge).await.unwrap();
assert_eq!(store.len(), 1);
assert!(store.take(&key).await.unwrap().is_some());
assert!(store.take(&key).await.unwrap().is_none(), "a replay found the challenge again");
assert!(store.is_empty());
}
#[test]
fn a_challenge_from_the_other_ceremony_is_refused() {
let challenge = Challenge::issue(Ceremony::Registration);
let error =
challenge.accept(Ceremony::Authentication, unix_now()).unwrap_err().to_string();
assert!(error.contains("issued for registration"), "got {error}");
}
#[test]
fn an_expired_challenge_is_refused() {
let challenge = Challenge::issue_lasting(Ceremony::Registration, 0);
assert!(challenge.has_expired(unix_now()));
let error = challenge.accept(Ceremony::Registration, unix_now()).unwrap_err().to_string();
assert!(error.contains("expired"), "got {error}");
}
#[tokio::test]
async fn storing_purges_challenges_nobody_will_ever_spend() {
let store = MemoryChallengeStore::new();
let expired = Challenge::issue_lasting(Ceremony::Registration, 0);
let key = expired.encoded();
store.store(expired).await.unwrap();
assert_eq!(store.len(), 1);
store.store(Challenge::issue(Ceremony::Registration)).await.unwrap();
assert_eq!(store.len(), 1);
assert!(store.take(&key).await.unwrap().is_none(), "the expired challenge was kept");
}
#[test]
fn a_restored_challenge_must_still_be_long_enough_to_be_unguessable() {
let error = Challenge::restore(vec![1, 2, 3], Ceremony::Registration, u64::MAX, None)
.unwrap_err()
.to_string();
assert!(error.contains("guessable"), "got {error}");
let restored =
Challenge::restore(vec![7u8; 32], Ceremony::Authentication, 99, Some(vec![1])).unwrap();
assert_eq!(restored.bytes(), &[7u8; 32]);
assert_eq!(restored.expires_at(), 99);
assert_eq!(restored.user_handle(), Some([1u8].as_slice()));
}
#[test]
fn each_ceremony_names_the_client_data_type_the_browser_writes() {
assert_eq!(Ceremony::Registration.client_data_type(), "webauthn.create");
assert_eq!(Ceremony::Authentication.client_data_type(), "webauthn.get");
}
#[test]
fn debug_output_does_not_carry_a_live_challenge() {
let challenge = Challenge::issue(Ceremony::Registration).bound_to(vec![1, 2, 3]);
let printed = format!("{challenge:?}");
assert!(!printed.contains(&challenge.encoded()), "the challenge was printed: {printed}");
assert!(printed.contains("<32 bytes>"), "got {printed}");
assert!(printed.contains("Registration"));
assert!(format!("{:?}", MemoryChallengeStore::new()).contains("outstanding"));
}
}