Skip to main content

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}