use crate::error::{Error, Result};
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
let mut difference = 0u8;
for index in 0..a.len().max(b.len()) {
let left = a.get(index).copied().unwrap_or_default();
let right = b.get(index).copied().unwrap_or_default();
difference |= left ^ right;
}
(difference == 0) & (a.len() == b.len())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OneTimeSecretState {
Empty,
Pending,
Expired,
Consumed,
}
struct Issued {
secret: String,
issued_at: Instant,
consumed: bool,
}
pub struct OneTimeSecret {
ttl: Duration,
slot: Mutex<Option<Issued>>,
}
impl OneTimeSecret {
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
slot: Mutex::new(None),
}
}
pub fn ttl(&self) -> Duration {
self.ttl
}
pub fn issue(&self, secret: impl Into<String>) -> Result<()> {
let secret = secret.into();
if secret.is_empty() {
return Err(Error::Validation(
"a one-time secret must not be empty".to_string(),
));
}
*self.slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(Issued {
secret,
issued_at: Instant::now(),
consumed: false,
});
Ok(())
}
pub fn consume(&self, candidate: &str) -> bool {
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
let Some(issued) = slot.as_mut() else {
return false;
};
if issued.consumed || self.is_expired(issued) {
return false;
}
if !constant_time_eq(issued.secret.as_bytes(), candidate.as_bytes()) {
return false;
}
issued.consumed = true;
true
}
pub fn matches(&self, candidate: &str) -> bool {
let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
match slot.as_ref() {
Some(issued) => {
!self.is_expired(issued)
&& constant_time_eq(issued.secret.as_bytes(), candidate.as_bytes())
}
None => false,
}
}
pub fn state(&self) -> OneTimeSecretState {
let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
match slot.as_ref() {
None => OneTimeSecretState::Empty,
Some(issued) if issued.consumed => OneTimeSecretState::Consumed,
Some(issued) if self.is_expired(issued) => OneTimeSecretState::Expired,
Some(_) => OneTimeSecretState::Pending,
}
}
pub fn revoke(&self) {
*self.slot.lock().unwrap_or_else(PoisonError::into_inner) = None;
}
fn is_expired(&self, issued: &Issued) -> bool {
issued.issued_at.elapsed() >= self.ttl
}
}
impl std::fmt::Debug for OneTimeSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("OneTimeSecret")
.field("ttl", &self.ttl)
.field("state", &self.state())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
#[test]
fn constant_time_eq_accepts_equal_input() {
assert!(constant_time_eq(b"", b""));
assert!(constant_time_eq(
b"correct horse battery staple",
b"correct horse battery staple"
));
}
#[test]
fn constant_time_eq_rejects_any_difference() {
assert!(!constant_time_eq(b"correct horse", b"Xorrect horse"));
assert!(!constant_time_eq(b"correct horse", b"correct Xorse"));
assert!(!constant_time_eq(b"correct horse", b"correct horsX"));
}
#[test]
fn constant_time_eq_rejects_length_mismatch_even_with_matching_prefix() {
assert!(!constant_time_eq(b"abc", b"abc "));
assert!(!constant_time_eq(b"abc ", b"abc"));
assert!(!constant_time_eq(b"", b"a"));
assert!(!constant_time_eq(b"a", b""));
}
#[test]
fn constant_time_eq_handles_non_utf8_bytes() {
assert!(constant_time_eq(&[0x00, 0xff, 0x80], &[0x00, 0xff, 0x80]));
assert!(!constant_time_eq(&[0x00, 0xff, 0x80], &[0x00, 0xff, 0x81]));
}
#[test]
fn issued_secret_is_consumable_exactly_once() {
let store = OneTimeSecret::new(Duration::from_secs(300));
assert_eq!(store.state(), OneTimeSecretState::Empty);
store.issue("token-value").unwrap();
assert_eq!(store.state(), OneTimeSecretState::Pending);
assert!(store.consume("token-value"));
assert_eq!(store.state(), OneTimeSecretState::Consumed);
assert!(!store.consume("token-value"), "single use");
assert!(!store.consume("token-value"), "still single use");
}
#[test]
fn wrong_candidate_does_not_burn_the_secret() {
let store = OneTimeSecret::new(Duration::from_secs(300));
store.issue("token-value").unwrap();
assert!(!store.consume("wrong-value"));
assert!(!store.consume(""));
assert_eq!(
store.state(),
OneTimeSecretState::Pending,
"a spray of wrong guesses must not lock the holder out"
);
assert!(store.consume("token-value"), "the real value still works");
}
#[test]
fn expired_secret_cannot_be_consumed_or_matched() {
let store = OneTimeSecret::new(Duration::ZERO);
store.issue("token-value").unwrap();
assert_eq!(store.state(), OneTimeSecretState::Expired);
assert!(!store.consume("token-value"));
assert!(!store.matches("token-value"));
}
#[test]
fn secret_survives_short_delay_but_not_beyond_ttl() {
let store = OneTimeSecret::new(Duration::from_millis(20));
store.issue("token-value").unwrap();
assert!(store.matches("token-value"));
std::thread::sleep(Duration::from_millis(120));
assert_eq!(store.state(), OneTimeSecretState::Expired);
assert!(!store.consume("token-value"));
}
#[test]
fn matches_recognizes_consumed_secret_without_reviving_it() {
let store = OneTimeSecret::new(Duration::from_secs(300));
store.issue("token-value").unwrap();
assert!(store.consume("token-value"));
assert!(store.matches("token-value"));
assert!(!store.matches("other-value"));
assert!(!store.consume("token-value"));
assert_eq!(store.state(), OneTimeSecretState::Consumed);
}
#[test]
fn matches_does_not_consume() {
let store = OneTimeSecret::new(Duration::from_secs(300));
store.issue("token-value").unwrap();
for _ in 0..3 {
assert!(store.matches("token-value"));
}
assert_eq!(store.state(), OneTimeSecretState::Pending);
assert!(store.consume("token-value"), "matches left it consumable");
}
#[test]
fn reissuing_replaces_and_rearms_the_slot() {
let store = OneTimeSecret::new(Duration::from_secs(300));
store.issue("first").unwrap();
assert!(store.consume("first"));
store.issue("second").unwrap();
assert_eq!(store.state(), OneTimeSecretState::Pending);
assert!(!store.consume("first"), "the replaced secret is gone");
assert!(store.consume("second"));
}
#[test]
fn empty_secret_is_rejected_and_keeps_the_current_one() {
let store = OneTimeSecret::new(Duration::from_secs(300));
store.issue("token-value").unwrap();
let error = store.issue("").expect_err("empty secrets are rejected");
assert!(matches!(error, Error::Validation(_)), "got {error:?}");
assert_eq!(store.state(), OneTimeSecretState::Pending);
assert!(store.consume("token-value"));
}
#[test]
fn revoke_clears_the_slot_without_consuming() {
let store = OneTimeSecret::new(Duration::from_secs(300));
store.issue("token-value").unwrap();
store.revoke();
assert_eq!(store.state(), OneTimeSecretState::Empty);
assert!(!store.consume("token-value"));
assert!(!store.matches("token-value"));
store.revoke(); assert_eq!(store.state(), OneTimeSecretState::Empty);
}
#[test]
fn consume_and_matches_on_empty_slot_fail() {
let store = OneTimeSecret::new(Duration::from_secs(300));
assert!(!store.consume("anything"));
assert!(!store.matches("anything"));
}
#[test]
fn debug_output_does_not_leak_the_secret() {
let store = OneTimeSecret::new(Duration::from_secs(300));
store.issue("super-secret-value").unwrap();
let rendered = format!("{store:?}");
assert!(!rendered.contains("super-secret-value"), "got {rendered}");
assert!(rendered.contains("Pending"), "got {rendered}");
}
#[test]
fn concurrent_consumers_have_exactly_one_winner() {
const THREADS: usize = 8;
const ROUNDS: usize = 25;
let store = Arc::new(OneTimeSecret::new(Duration::from_secs(300)));
for round in 0..ROUNDS {
store.issue(format!("round-{round}-secret")).unwrap();
let barrier = Arc::new(Barrier::new(THREADS));
let winners = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..THREADS)
.map(|_| {
let store = Arc::clone(&store);
let barrier = Arc::clone(&barrier);
let winners = Arc::clone(&winners);
std::thread::spawn(move || {
barrier.wait();
if store.consume(&format!("round-{round}-secret")) {
winners.fetch_add(1, Ordering::SeqCst);
}
})
})
.collect();
for handle in handles {
handle.join().expect("consumer thread must not panic");
}
assert_eq!(
winners.load(Ordering::SeqCst),
1,
"round {round}: check-and-consume is a compare-and-swap"
);
assert_eq!(store.state(), OneTimeSecretState::Consumed);
}
}
#[test]
fn one_time_secret_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<OneTimeSecret>();
}
}