webx_core/security/once.rs
1//! Timing-safe comparison and single-use secrets (XR-3).
2//!
3//! Business endpoints keep re-implementing two small security primitives: a
4//! comparison that does not leak *where* two secrets differ, and a "hand this
5//! token out once, then burn it" store (installation wizards, invite links,
6//! password-reset links, one-shot webhook signatures). Both live here so
7//! applications do not hand-roll them:
8//!
9//! * [`constant_time_eq`] — byte-slice comparison without an early exit.
10//! * [`OneTimeSecret`] — a TTL-bound secret consumed at most once, with the
11//! check-and-consume done in one atomic step, so concurrent requests cannot
12//! both win.
13//!
14//! # The framework does not mint the secret
15//!
16//! [`OneTimeSecret::issue`] takes the value from the caller: this crate has no
17//! random-number dependency, and a security primitive must not quietly invent
18//! entropy. Generate the value with the RNG your application already uses
19//! (e.g. `uuid::Uuid::new_v4()` twice in `webx-host`, or `getrandom`), then
20//! hand it to [`OneTimeSecret::issue`].
21//!
22//! # Storing only a hash
23//!
24//! [`OneTimeSecret::consume`] compares the caller's value against the stored
25//! one with [`constant_time_eq`]. To keep only a digest in memory, issue the
26//! digest of the minted value and consume the digest of the candidate — the
27//! comparison stays constant-time and the plaintext never enters the store:
28//!
29//! ```ignore
30//! let store = OneTimeSecret::new(Duration::from_secs(30 * 60));
31//! store.issue(sha256_hex(&minted))?; // your own hasher
32//! let valid = store.consume(&sha256_hex(&candidate)); // same hasher
33//! ```
34
35use crate::error::{Error, Result};
36use std::sync::{Mutex, PoisonError};
37use std::time::{Duration, Instant};
38
39/// Compare two byte slices without returning early on a difference.
40///
41/// The loop always runs `max(a.len(), b.len())` iterations and ORs every
42/// difference into one accumulator, so the time spent does not depend on *where*
43/// the inputs differ — a plain `==` on `String`/`&[u8]` stops at the first
44/// mismatching byte and leaks how long a shared prefix is.
45///
46/// **Length is not hidden.** The runtime is proportional to the longer input and
47/// the result distinguishes lengths, which is safe because the *length* of a
48/// secret is public by construction (framework secrets are at least 32 bytes and
49/// digests have a fixed size); only the *content* must stay secret. Do not use
50/// this to compare values whose length is itself confidential — pad both sides
51/// first.
52///
53/// ```
54/// # use webx_core::security::constant_time_eq;
55/// assert!(constant_time_eq(b"correct horse", b"correct horse"));
56/// assert!(!constant_time_eq(b"correct horse", b"correct horsX"));
57/// assert!(!constant_time_eq(b"short", b"short and longer"));
58/// ```
59pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
60 let mut difference = 0u8;
61 // `max`, not `min`: comparing the tail of the longer input against a
62 // virtual zero byte keeps the work independent of where a mismatch starts.
63 for index in 0..a.len().max(b.len()) {
64 let left = a.get(index).copied().unwrap_or_default();
65 let right = b.get(index).copied().unwrap_or_default();
66 difference |= left ^ right;
67 }
68 // Both halves are already computed, so the extra length check cannot turn
69 // into a timing signal beyond the one described above.
70 (difference == 0) & (a.len() == b.len())
71}
72
73/// Lifecycle of a [`OneTimeSecret`] slot.
74///
75/// Returned by [`OneTimeSecret::state`] so callers can distinguish "never
76/// issued" from "already used" and "timed out" — the difference between a 401
77/// and a 410 for an installation or password-reset endpoint.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum OneTimeSecretState {
80 /// Nothing has been issued (new store, or after [`OneTimeSecret::revoke`]).
81 Empty,
82 /// A secret is issued and can still be consumed.
83 Pending,
84 /// The secret is beyond its TTL and can no longer be consumed.
85 Expired,
86 /// The secret was consumed; it cannot be consumed again.
87 Consumed,
88}
89
90struct Issued {
91 secret: String,
92 issued_at: Instant,
93 consumed: bool,
94}
95
96/// A TTL-bound secret that can be consumed at most once.
97///
98/// Typical use is an out-of-band one-time token: the server mints a value,
99/// hands it to the operator/user once (console line, invite link, e-mail), and
100/// the endpoint that accepts it calls [`consume`](Self::consume). The
101/// check-and-execute window is a single `Mutex` critical section, so N
102/// concurrent requests carrying the same value produce exactly one `true`
103/// (compare-and-swap, not "check now, mark later").
104///
105/// # Semantics
106///
107/// * [`issue`](Self::issue) replaces the slot and re-arms it; only **one**
108/// secret is active at a time. Empty values are rejected with
109/// [`Error::Validation`] and leave the current state untouched.
110/// * [`consume`](Self::consume) returns `true` exactly once per issued secret,
111/// only while it is neither expired nor consumed. A wrong candidate consumes
112/// nothing, so the legitimate holder can still use the secret.
113/// * The clock is [`Instant`]: a TTL is wall-clock independent and survives
114/// system time adjustments; the store itself is process-local, so a consumed
115/// or expired secret dies with the process.
116///
117/// # Example
118///
119/// ```
120/// # use std::time::Duration;
121/// # use webx_core::security::{OneTimeSecret, OneTimeSecretState};
122/// let setup = OneTimeSecret::new(Duration::from_secs(30 * 60));
123/// setup.issue("install-token").unwrap();
124///
125/// assert_eq!(setup.state(), OneTimeSecretState::Pending);
126/// assert!(!setup.consume("wrong-token"), "a wrong guess burns nothing");
127/// assert!(setup.consume("install-token"));
128/// assert!(!setup.consume("install-token"), "the second use fails");
129/// assert_eq!(setup.state(), OneTimeSecretState::Consumed);
130///
131/// // A consumed token can still be recognized for replay diagnostics (410).
132/// assert!(setup.matches("install-token"));
133/// ```
134///
135/// # Expiry
136///
137/// A secret is valid while less than the TTL has elapsed:
138///
139/// ```
140/// # use std::time::Duration;
141/// # use webx_core::security::{OneTimeSecret, OneTimeSecretState};
142/// let expired = OneTimeSecret::new(Duration::ZERO);
143/// expired.issue("too-late").unwrap();
144/// assert_eq!(expired.state(), OneTimeSecretState::Expired);
145/// assert!(!expired.consume("too-late"));
146/// assert!(!expired.matches("too-late"));
147/// ```
148///
149/// (Use a zero TTL only in tests — a real secret needs a positive lifetime.)
150pub struct OneTimeSecret {
151 ttl: Duration,
152 slot: Mutex<Option<Issued>>,
153}
154
155impl OneTimeSecret {
156 /// Create an empty slot whose secrets live for `ttl`.
157 ///
158 /// A zero TTL expires the next issued secret immediately; that is useful in
159 /// tests and useless in production, so pick a real lifetime.
160 pub fn new(ttl: Duration) -> Self {
161 Self {
162 ttl,
163 slot: Mutex::new(None),
164 }
165 }
166
167 /// Configured TTL.
168 pub fn ttl(&self) -> Duration {
169 self.ttl
170 }
171
172 /// Arm the slot with `secret`, replacing (and thereby revoking) any
173 /// previous value.
174 ///
175 /// The caller supplies the value — see the module-level docs for the RNG
176 /// and hash-at-rest recipes.
177 ///
178 /// An empty secret is rejected with [`Error::Validation`] because it would
179 /// make `consume("")` succeed; the previous secret stays in place so a
180 /// failed re-issue cannot silently extend or shorten the current one.
181 pub fn issue(&self, secret: impl Into<String>) -> Result<()> {
182 let secret = secret.into();
183 if secret.is_empty() {
184 return Err(Error::Validation(
185 "a one-time secret must not be empty".to_string(),
186 ));
187 }
188 *self.slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(Issued {
189 secret,
190 issued_at: Instant::now(),
191 consumed: false,
192 });
193 Ok(())
194 }
195
196 /// Consume the slot if `candidate` matches the issued secret.
197 ///
198 /// Atomic check-and-consume: among concurrent callers with the same
199 /// candidate exactly one observes `true`. Returns `false` for an empty
200 /// slot, a consumed secret, an expired secret, or a mismatch.
201 pub fn consume(&self, candidate: &str) -> bool {
202 let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
203 let Some(issued) = slot.as_mut() else {
204 return false;
205 };
206 if issued.consumed || self.is_expired(issued) {
207 return false;
208 }
209 if !constant_time_eq(issued.secret.as_bytes(), candidate.as_bytes()) {
210 // A wrong guess must not burn the secret: an attacker spraying
211 // candidates would otherwise lock the legitimate holder out.
212 return false;
213 }
214 issued.consumed = true;
215 true
216 }
217
218 /// Whether `candidate` is the issued secret, without consuming it.
219 ///
220 /// Meant for classifying a failed [`consume`](Self::consume) — e.g. an
221 /// installation endpoint that answers `410 Gone` when the consumed token is
222 /// replayed instead of `401`. Do **not** use it as the authorization gate:
223 /// it does not consume, so two concurrent requests could both pass it.
224 pub fn matches(&self, candidate: &str) -> bool {
225 let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
226 match slot.as_ref() {
227 Some(issued) => {
228 !self.is_expired(issued)
229 && constant_time_eq(issued.secret.as_bytes(), candidate.as_bytes())
230 }
231 None => false,
232 }
233 }
234
235 /// Current lifecycle state (diagnostics, tests, 401-vs-410 decisions).
236 pub fn state(&self) -> OneTimeSecretState {
237 let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
238 match slot.as_ref() {
239 None => OneTimeSecretState::Empty,
240 Some(issued) if issued.consumed => OneTimeSecretState::Consumed,
241 Some(issued) if self.is_expired(issued) => OneTimeSecretState::Expired,
242 Some(_) => OneTimeSecretState::Pending,
243 }
244 }
245
246 /// Drop the slot without consuming it (cancel an outstanding secret).
247 pub fn revoke(&self) {
248 *self.slot.lock().unwrap_or_else(PoisonError::into_inner) = None;
249 }
250
251 fn is_expired(&self, issued: &Issued) -> bool {
252 issued.issued_at.elapsed() >= self.ttl
253 }
254}
255
256impl std::fmt::Debug for OneTimeSecret {
257 /// Never renders the secret itself, only the TTL and the lifecycle state.
258 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 formatter
260 .debug_struct("OneTimeSecret")
261 .field("ttl", &self.ttl)
262 .field("state", &self.state())
263 .finish()
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::sync::atomic::{AtomicUsize, Ordering};
271 use std::sync::{Arc, Barrier};
272
273 #[test]
274 fn constant_time_eq_accepts_equal_input() {
275 assert!(constant_time_eq(b"", b""));
276 assert!(constant_time_eq(
277 b"correct horse battery staple",
278 b"correct horse battery staple"
279 ));
280 }
281
282 #[test]
283 fn constant_time_eq_rejects_any_difference() {
284 // First byte, middle byte, last byte — a prefix-only comparison would
285 // pass the last case.
286 assert!(!constant_time_eq(b"correct horse", b"Xorrect horse"));
287 assert!(!constant_time_eq(b"correct horse", b"correct Xorse"));
288 assert!(!constant_time_eq(b"correct horse", b"correct horsX"));
289 }
290
291 #[test]
292 fn constant_time_eq_rejects_length_mismatch_even_with_matching_prefix() {
293 assert!(!constant_time_eq(b"abc", b"abc "));
294 assert!(!constant_time_eq(b"abc ", b"abc"));
295 assert!(!constant_time_eq(b"", b"a"));
296 assert!(!constant_time_eq(b"a", b""));
297 }
298
299 #[test]
300 fn constant_time_eq_handles_non_utf8_bytes() {
301 assert!(constant_time_eq(&[0x00, 0xff, 0x80], &[0x00, 0xff, 0x80]));
302 assert!(!constant_time_eq(&[0x00, 0xff, 0x80], &[0x00, 0xff, 0x81]));
303 }
304
305 #[test]
306 fn issued_secret_is_consumable_exactly_once() {
307 let store = OneTimeSecret::new(Duration::from_secs(300));
308 assert_eq!(store.state(), OneTimeSecretState::Empty);
309 store.issue("token-value").unwrap();
310 assert_eq!(store.state(), OneTimeSecretState::Pending);
311
312 assert!(store.consume("token-value"));
313 assert_eq!(store.state(), OneTimeSecretState::Consumed);
314 assert!(!store.consume("token-value"), "single use");
315 assert!(!store.consume("token-value"), "still single use");
316 }
317
318 #[test]
319 fn wrong_candidate_does_not_burn_the_secret() {
320 let store = OneTimeSecret::new(Duration::from_secs(300));
321 store.issue("token-value").unwrap();
322
323 assert!(!store.consume("wrong-value"));
324 assert!(!store.consume(""));
325 assert_eq!(
326 store.state(),
327 OneTimeSecretState::Pending,
328 "a spray of wrong guesses must not lock the holder out"
329 );
330 assert!(store.consume("token-value"), "the real value still works");
331 }
332
333 #[test]
334 fn expired_secret_cannot_be_consumed_or_matched() {
335 let store = OneTimeSecret::new(Duration::ZERO);
336 store.issue("token-value").unwrap();
337 assert_eq!(store.state(), OneTimeSecretState::Expired);
338 assert!(!store.consume("token-value"));
339 assert!(!store.matches("token-value"));
340 }
341
342 #[test]
343 fn secret_survives_short_delay_but_not_beyond_ttl() {
344 let store = OneTimeSecret::new(Duration::from_millis(20));
345 store.issue("token-value").unwrap();
346 assert!(store.matches("token-value"));
347 std::thread::sleep(Duration::from_millis(120));
348 assert_eq!(store.state(), OneTimeSecretState::Expired);
349 assert!(!store.consume("token-value"));
350 }
351
352 #[test]
353 fn matches_recognizes_consumed_secret_without_reviving_it() {
354 let store = OneTimeSecret::new(Duration::from_secs(300));
355 store.issue("token-value").unwrap();
356 assert!(store.consume("token-value"));
357
358 // Replay diagnostics: the value is still recognizable (410 instead of
359 // 401), but it stays consumed.
360 assert!(store.matches("token-value"));
361 assert!(!store.matches("other-value"));
362 assert!(!store.consume("token-value"));
363 assert_eq!(store.state(), OneTimeSecretState::Consumed);
364 }
365
366 #[test]
367 fn matches_does_not_consume() {
368 let store = OneTimeSecret::new(Duration::from_secs(300));
369 store.issue("token-value").unwrap();
370 for _ in 0..3 {
371 assert!(store.matches("token-value"));
372 }
373 assert_eq!(store.state(), OneTimeSecretState::Pending);
374 assert!(store.consume("token-value"), "matches left it consumable");
375 }
376
377 #[test]
378 fn reissuing_replaces_and_rearms_the_slot() {
379 let store = OneTimeSecret::new(Duration::from_secs(300));
380 store.issue("first").unwrap();
381 assert!(store.consume("first"));
382
383 store.issue("second").unwrap();
384 assert_eq!(store.state(), OneTimeSecretState::Pending);
385 assert!(!store.consume("first"), "the replaced secret is gone");
386 assert!(store.consume("second"));
387 }
388
389 #[test]
390 fn empty_secret_is_rejected_and_keeps_the_current_one() {
391 let store = OneTimeSecret::new(Duration::from_secs(300));
392 store.issue("token-value").unwrap();
393
394 let error = store.issue("").expect_err("empty secrets are rejected");
395 assert!(matches!(error, Error::Validation(_)), "got {error:?}");
396 assert_eq!(store.state(), OneTimeSecretState::Pending);
397 assert!(store.consume("token-value"));
398 }
399
400 #[test]
401 fn revoke_clears_the_slot_without_consuming() {
402 let store = OneTimeSecret::new(Duration::from_secs(300));
403 store.issue("token-value").unwrap();
404
405 store.revoke();
406 assert_eq!(store.state(), OneTimeSecretState::Empty);
407 assert!(!store.consume("token-value"));
408 assert!(!store.matches("token-value"));
409
410 store.revoke(); // idempotent
411 assert_eq!(store.state(), OneTimeSecretState::Empty);
412 }
413
414 #[test]
415 fn consume_and_matches_on_empty_slot_fail() {
416 let store = OneTimeSecret::new(Duration::from_secs(300));
417 assert!(!store.consume("anything"));
418 assert!(!store.matches("anything"));
419 }
420
421 #[test]
422 fn debug_output_does_not_leak_the_secret() {
423 let store = OneTimeSecret::new(Duration::from_secs(300));
424 store.issue("super-secret-value").unwrap();
425 let rendered = format!("{store:?}");
426 assert!(!rendered.contains("super-secret-value"), "got {rendered}");
427 assert!(rendered.contains("Pending"), "got {rendered}");
428 }
429
430 #[test]
431 fn concurrent_consumers_have_exactly_one_winner() {
432 const THREADS: usize = 8;
433 const ROUNDS: usize = 25;
434
435 let store = Arc::new(OneTimeSecret::new(Duration::from_secs(300)));
436 for round in 0..ROUNDS {
437 store.issue(format!("round-{round}-secret")).unwrap();
438 let barrier = Arc::new(Barrier::new(THREADS));
439 let winners = Arc::new(AtomicUsize::new(0));
440
441 let handles: Vec<_> = (0..THREADS)
442 .map(|_| {
443 let store = Arc::clone(&store);
444 let barrier = Arc::clone(&barrier);
445 let winners = Arc::clone(&winners);
446 std::thread::spawn(move || {
447 // Line every thread up so they race on the same slot.
448 barrier.wait();
449 if store.consume(&format!("round-{round}-secret")) {
450 winners.fetch_add(1, Ordering::SeqCst);
451 }
452 })
453 })
454 .collect();
455 for handle in handles {
456 handle.join().expect("consumer thread must not panic");
457 }
458
459 assert_eq!(
460 winners.load(Ordering::SeqCst),
461 1,
462 "round {round}: check-and-consume is a compare-and-swap"
463 );
464 assert_eq!(store.state(), OneTimeSecretState::Consumed);
465 }
466 }
467
468 #[test]
469 fn one_time_secret_is_send_and_sync() {
470 fn assert_send_sync<T: Send + Sync>() {}
471 assert_send_sync::<OneTimeSecret>();
472 }
473}