use std::{
collections::HashMap,
sync::{Mutex, PoisonError},
time::{Duration, Instant},
};
use async_trait::async_trait;
use rand::{Rng, rng};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NonceOutcome {
Valid,
Expired,
Reused,
Unknown,
}
#[allow(clippy::double_must_use)]
#[async_trait]
pub trait NonceStore: Send + Sync {
async fn issue(&self) -> String;
async fn consume(&self, nonce: &str) -> NonceOutcome;
}
#[derive(Debug)]
struct Entry {
expires_at: Instant,
used: bool,
}
#[derive(Debug)]
pub struct InMemoryNonceStore {
ttl: Duration,
entries: Mutex<HashMap<String, Entry>>,
}
impl InMemoryNonceStore {
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
entries: Mutex::new(HashMap::new()),
}
}
pub fn issue_with_ttl(&self, ttl: Duration) -> String {
let nonce = generate_nonce();
let entry = Entry {
expires_at: Instant::now() + ttl,
used: false,
};
let mut entries = self.lock();
entries.retain(|_, e| Instant::now() < e.expires_at);
entries.insert(nonce.clone(), entry);
nonce
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
fn len(&self) -> usize {
self.lock().len()
}
}
#[async_trait]
impl NonceStore for InMemoryNonceStore {
async fn issue(&self) -> String {
self.issue_with_ttl(self.ttl)
}
async fn consume(&self, nonce: &str) -> NonceOutcome {
let mut entries = self.lock();
match entries.get_mut(nonce) {
None => NonceOutcome::Unknown,
Some(entry) if Instant::now() >= entry.expires_at => NonceOutcome::Expired,
Some(entry) if entry.used => NonceOutcome::Reused,
Some(entry) => {
entry.used = true;
NonceOutcome::Valid
}
}
}
}
fn generate_nonce() -> String {
let mut bytes = [0u8; 16];
rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
#[cfg(test)]
mod tests {
use super::{InMemoryNonceStore, NonceOutcome, NonceStore};
use std::time::Duration;
#[tokio::test]
async fn valid_then_reused() {
let store = InMemoryNonceStore::new(Duration::from_secs(30));
let nonce = store.issue().await;
assert_eq!(store.consume(&nonce).await, NonceOutcome::Valid);
assert_eq!(store.consume(&nonce).await, NonceOutcome::Reused);
}
#[tokio::test]
async fn unknown_nonce() {
let store = InMemoryNonceStore::new(Duration::from_secs(30));
assert_eq!(store.consume("never-issued").await, NonceOutcome::Unknown);
}
#[tokio::test]
async fn expired_before_use() {
let store = InMemoryNonceStore::new(Duration::from_secs(30));
let nonce = store.issue_with_ttl(Duration::ZERO);
assert_eq!(store.consume(&nonce).await, NonceOutcome::Expired);
}
#[tokio::test]
async fn issued_nonces_are_unique() {
let store = InMemoryNonceStore::new(Duration::from_secs(30));
assert_ne!(store.issue().await, store.issue().await);
}
#[tokio::test]
async fn expired_entries_are_reaped_on_issue() {
let store = InMemoryNonceStore::new(Duration::from_secs(30));
let first = store.issue_with_ttl(Duration::ZERO);
let second = store.issue_with_ttl(Duration::from_secs(30));
assert_eq!(store.len(), 1);
assert_eq!(store.consume(&first).await, NonceOutcome::Unknown);
assert_eq!(store.consume(&second).await, NonceOutcome::Valid);
}
}