Skip to main content

acme_proxy_store/
nonce.rs

1//! Replay nonces (RFC 8555 §6.5): minted on every response, spent by the next
2//! signed request.
3//!
4//! Three rules the request path leans on:
5//!
6//! - **Spending is one `DELETE`.** [`Nonce::verify`] deletes the live row and
7//!   lets `rows_affected` answer, so two requests replaying one nonce cannot
8//!   both pass — there is no read to race.
9//! - **A value is never logged whole.** An unspent nonce is a bearer
10//!   credential; every log line here carries its [`fingerprint`].
11//! - **Expiry is a predicate, not a sweep.** A row older than the TTL is
12//!   refused by `verify` whether or not [`Nonce::cleanup`] has run; the sweep
13//!   only keeps the table from growing.
14
15use std::time::{Duration, SystemTime};
16
17use tracing::{debug, info};
18
19use crate::db::Database;
20use acme_proxy_core::random::random_token;
21
22/// A replay nonce used for ACME protocol anti-replay protection.
23///
24/// ## ACME Protocol Compliance
25///
26/// According to RFC 8555, nonces are used to prevent replay attacks:
27/// - Each nonce is 32 random bytes from the system CSPRNG, base64url-encoded
28///   without padding — the octet-string form §6.5.1 requires, and 256 bits
29///   like every other non-guessable value in this tree
30/// - Nonces are generated for every response
31/// - Clients must include a valid nonce in their requests
32/// - Nonces are single-use and expire after a TTL period
33///
34/// ## Security Considerations
35///
36/// - Nonces are stored in the database with timestamps
37/// - Expired nonces are automatically cleaned up
38/// - Single-use enforcement prevents replay attacks
39/// - Time-to-live limits prevent indefinite nonce accumulation
40///
41/// ## Database Storage
42///
43/// Nonces are stored in the `nonces` table with:
44/// - `value`: The base64url nonce string
45/// - `created_at`: Unix timestamp when the nonce was created
46///
47/// ## Lifecycle
48///
49/// 1. New nonce created for each response
50/// 2. Nonce saved to database
51/// 3. Client uses nonce in subsequent request
52/// 4. Nonce verified and consumed (single-use)
53/// 5. Expired nonces automatically cleaned up
54#[derive(Debug)]
55pub struct Nonce {
56    pub value: String,
57    pub created_at: i64,
58}
59
60impl Default for Nonce {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66/// A short, non-reusable stand-in for a nonce value, for logs.
67///
68/// A nonce is a bearer credential: until it is consumed, anyone holding it can
69/// sign a request with it. `Nonce::save` runs from the response middleware, so
70/// logging the value there put *every nonce this server has minted* into the
71/// log stream while it was still live and unused — a log reader could lift one
72/// straight out. Eight of the 43 base64url characters are enough to follow one
73/// request across lines and, at ~48 bits, far too few to replay.
74#[must_use]
75pub fn fingerprint(value: &str) -> &str {
76    value.get(..8).unwrap_or(value)
77}
78
79/// Seconds since the Unix epoch, saturating to `0` for the pre-1970 clocks that
80/// should never occur in practice.
81pub fn now_secs() -> i64 {
82    SystemTime::now()
83        .duration_since(SystemTime::UNIX_EPOCH)
84        .unwrap_or_default()
85        .as_secs() as i64
86}
87
88impl Nonce {
89    /// A fresh nonce, stamped now. Not stored until [`save`](Self::save).
90    #[must_use]
91    pub fn new() -> Self {
92        Nonce {
93            value: random_token(),
94            created_at: now_secs(),
95        }
96    }
97
98    /// Stores the nonce so a later [`verify`](Self::verify) can spend it.
99    pub async fn save(&self, database: &Database) -> Result<(), sqlx::Error> {
100        crate::sql::query("INSERT INTO nonces VALUES (?, ?);")
101            .bind(self.value.clone())
102            .bind(self.created_at)
103            .execute(database)
104            .await?;
105        // At `debug`, and a fingerprint rather than the value: this runs from
106        // the response middleware, so it is the one log line in the server that
107        // would otherwise publish a live, unconsumed credential on every single
108        // response.
109        debug!(event = "db_nonce_saved",
110               outcome = "success",
111               nonce_fp = %fingerprint(&self.value),
112               created_at = ?self.created_at);
113        Ok(())
114    }
115
116    /// Consumes `nonce` if it is live: a single `DELETE` whose `rows_affected`
117    /// decides, so two concurrent replays cannot both succeed.
118    ///
119    /// Takes `&str` rather than `String` — it only ever binds a reference, and
120    /// this runs on every signed POST, which is the hottest path in the server.
121    pub async fn verify(
122        nonce: &str,
123        database: &Database,
124        ttl: Duration,
125    ) -> Result<bool, sqlx::Error> {
126        // Saturating subtraction avoids the panic `SystemTime - Duration` raises
127        // when `ttl` reaches back past the epoch.
128        let cutoff = now_secs().saturating_sub(ttl.as_secs() as i64);
129
130        let result = crate::sql::query("DELETE FROM nonces WHERE value = ? AND created_at > ?;")
131            .bind(nonce)
132            .bind(cutoff)
133            .execute(database)
134            .await?;
135
136        // A verified nonce is spent, so logging it would leak nothing — but a
137        // *rejected* one may simply have been sent to the wrong endpoint and
138        // still be live elsewhere, and one rule is easier to keep than two.
139        // Two arms rather than one `event = if …`: the name has to stay a bare
140        // literal, or neither spelling is greppable from a log back to here.
141        let is_valid = result.rows_affected() == 1;
142        if is_valid {
143            debug!(
144                event = "db_nonce_verified_valid",
145                outcome = "success",
146                nonce_fp = %fingerprint(nonce),
147                cutoff = cutoff,
148                ttl_seconds = ttl.as_secs(),
149            );
150        } else {
151            debug!(
152                event = "db_nonce_verified_invalid",
153                outcome = "failure",
154                nonce_fp = %fingerprint(nonce),
155                cutoff = cutoff,
156                ttl_seconds = ttl.as_secs(),
157            );
158        }
159        Ok(is_valid)
160    }
161
162    /// How many nonce rows exist right now.
163    ///
164    /// The one thing worth showing about this table: individual values are
165    /// bearer credentials and are never listed (see the note on
166    /// [`fingerprint`]), but the *count* is a useful health signal — it should
167    /// hover around the request rate times the TTL, and a number far above
168    /// that means the reaper is not running.
169    pub async fn count(database: &Database) -> Result<i64, sqlx::Error> {
170        crate::sql::query("SELECT COUNT(*) FROM nonces;")
171            .fetch_one(database)
172            .await?
173            .try_get(0usize)
174    }
175
176    /// Deletes every nonce older than `ttl`, returning how many went. The
177    /// `nonce_sweep` job and `acme-proxy nonce cleanup` both land here.
178    pub async fn cleanup(database: &Database, ttl: Duration) -> Result<u64, sqlx::Error> {
179        let cutoff = now_secs().saturating_sub(ttl.as_secs() as i64);
180
181        let result = crate::sql::query("DELETE FROM nonces WHERE created_at <= ?;")
182            .bind(cutoff)
183            .execute(database)
184            .await?;
185
186        info!(event = "db_nonce_cleanup_completed",
187              outcome = "success",
188              rows_removed = ?result.rows_affected(),
189              cutoff = ?cutoff,
190              ttl_seconds = ?ttl.as_secs());
191        Ok(result.rows_affected())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use base64::prelude::*;
199    use std::sync::Arc;
200
201    /// Matches the default `nonce.ttl_seconds` (5 minutes).
202    const TTL: Duration = Duration::from_secs(300);
203
204    async fn nonce_count(database: &Arc<Database>) -> i64 {
205        crate::sql::query("SELECT COUNT(*) FROM nonces;")
206            .fetch_one(database)
207            .await
208            .unwrap()
209            .try_get(0usize)
210            .unwrap()
211    }
212
213    #[tokio::test]
214    async fn verify_accepts_fresh_nonce_exactly_once() {
215        let database = Arc::new(Database::connect_for_test().await.unwrap());
216
217        let nonce = Nonce::new();
218        let value = nonce.value.clone();
219        nonce.save(&database).await.unwrap();
220
221        // First use succeeds…
222        assert!(Nonce::verify(&value, &database, TTL).await.unwrap());
223        // …and the nonce is single-use: the row is gone, so a replay fails.
224        assert!(!Nonce::verify(&value, &database, TTL).await.unwrap());
225    }
226
227    #[tokio::test]
228    async fn verify_rejects_unknown_nonce() {
229        let database = Arc::new(Database::connect_for_test().await.unwrap());
230
231        assert!(!Nonce::verify("never-issued", &database, TTL).await.unwrap());
232    }
233
234    #[tokio::test]
235    async fn verify_rejects_expired_nonce() {
236        let database = Arc::new(Database::connect_for_test().await.unwrap());
237
238        // 10 minutes old — outside the 5-minute freshness window.
239        Nonce {
240            value: "stale".to_string(),
241            created_at: now_secs() - 600,
242        }
243        .save(&database)
244        .await
245        .unwrap();
246
247        assert!(!Nonce::verify("stale", &database, TTL).await.unwrap());
248    }
249
250    #[tokio::test]
251    async fn verify_accepts_nonce_near_edge_of_window() {
252        let database = Arc::new(Database::connect_for_test().await.unwrap());
253
254        // Just inside the freshness window — pins the `created_at > cutoff`
255        // acceptance boundary (2s margin avoids the exact-second tick flaking).
256        #[allow(clippy::cast_possible_wrap)]
257        let created_at = now_secs() - (TTL.as_secs() as i64 - 2);
258        Nonce {
259            value: "edge".to_string(),
260            created_at,
261        }
262        .save(&database)
263        .await
264        .unwrap();
265
266        assert!(Nonce::verify("edge", &database, TTL).await.unwrap());
267    }
268
269    #[tokio::test]
270    async fn verify_rejects_nonce_at_exact_cutoff_boundary() {
271        let database = Arc::new(Database::connect_for_test().await.unwrap());
272
273        // Pinned at exactly the cutoff — age == TTL, so the nonce is expired
274        // under the strict `> cutoff` check.
275        #[allow(clippy::cast_possible_wrap)]
276        let created_at = now_secs() - TTL.as_secs() as i64;
277        Nonce {
278            value: "boundary".to_string(),
279            created_at,
280        }
281        .save(&database)
282        .await
283        .unwrap();
284
285        assert!(!Nonce::verify("boundary", &database, TTL).await.unwrap());
286    }
287
288    #[tokio::test]
289    async fn cleanup_removes_only_stale_nonces() {
290        let database = Arc::new(Database::connect_for_test().await.unwrap());
291
292        Nonce {
293            value: "stale".to_string(),
294            created_at: now_secs() - 600,
295        }
296        .save(&database)
297        .await
298        .unwrap();
299
300        let fresh = Nonce::new();
301        let fresh_value = fresh.value.clone();
302        fresh.save(&database).await.unwrap();
303
304        assert_eq!(nonce_count(&database).await, 2);
305
306        let removed = Nonce::cleanup(&database, TTL).await.unwrap();
307        assert_eq!(removed, 1);
308
309        // Only the fresh nonce survives, and it is still usable.
310        assert_eq!(nonce_count(&database).await, 1);
311        assert!(Nonce::verify(&fresh_value, &database, TTL).await.unwrap());
312    }
313
314    /// RFC 8555 §6.5.1: the `Replay-Nonce` value "MUST be an octet string
315    /// encoded according to the base64url encoding", and clients are told to
316    /// ignore one that is not. This was a hyphenated UUID v4 until 0.2.0 —
317    /// out of the alphabet, and 122 bits where every other non-guessable
318    /// value in the tree is 256.
319    #[test]
320    fn a_minted_nonce_is_base64url_over_32_bytes() {
321        let value = Nonce::new().value;
322
323        assert!(
324            value
325                .bytes()
326                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
327            "outside the base64url alphabet: {value}"
328        );
329        assert_eq!(
330            BASE64_URL_SAFE_NO_PAD.decode(&value).unwrap().len(),
331            32,
332            "256 bits, like every other non-guessable value here"
333        );
334    }
335
336    /// `Default` exists so `Nonce` composes where one is expected; it must
337    /// agree with `new`, which is what every call site actually uses.
338    #[test]
339    fn the_default_nonce_is_a_fresh_one() {
340        let nonce = Nonce::default();
341        assert!(!nonce.value.is_empty());
342        assert_ne!(nonce.value, Nonce::default().value, "each must be unique");
343    }
344
345    #[tokio::test]
346    async fn count_reports_the_table_size_and_follows_cleanup() {
347        let db = Arc::new(Database::connect_for_test().await.unwrap());
348        assert_eq!(Nonce::count(&db).await.unwrap(), 0);
349
350        for _ in 0..3 {
351            Nonce::new().save(&db).await.unwrap();
352        }
353        assert_eq!(Nonce::count(&db).await.unwrap(), 3);
354
355        // A stale row, swept: the count is what makes a stalled reaper visible.
356        let stale = Nonce {
357            value: "stale".to_string(),
358            created_at: now_secs() - 10_000,
359        };
360        stale.save(&db).await.unwrap();
361        assert_eq!(Nonce::count(&db).await.unwrap(), 4);
362        Nonce::cleanup(&db, TTL).await.unwrap();
363        assert_eq!(Nonce::count(&db).await.unwrap(), 3);
364    }
365
366    /// Two simultaneous verifications of one nonce: exactly one may succeed.
367    ///
368    /// This is what replay protection *is*, and the surrounding tests cannot
369    /// show it — fresh/unknown/expired/boundary all drive one caller at a time,
370    /// which a `SELECT` followed by a `DELETE` would pass just as happily. The
371    /// property only has teeth under concurrency: the single `DELETE … WHERE`
372    /// is atomic, so `rows_affected` names one winner no matter how the two
373    /// interleave.
374    ///
375    /// A file-backed database, because `connect_in_memory` pins the pool to one
376    /// connection and would serialize the pair out of the race. The barrier
377    /// releases both at the same point.
378    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
379    async fn concurrent_verify_of_one_nonce_succeeds_exactly_once() {
380        let file =
381            std::env::temp_dir().join(format!("acme-proxy-test-{}.db", uuid::Uuid::now_v7()));
382        let url = format!("sqlite://{}", file.display());
383        let database = Arc::new(Database::connect_and_migrate(&url).await.unwrap());
384
385        let nonce = Nonce::new();
386        nonce.save(&database).await.unwrap();
387
388        const RACERS: usize = 8;
389        let barrier = Arc::new(tokio::sync::Barrier::new(RACERS));
390        let mut tasks = Vec::with_capacity(RACERS);
391        for _ in 0..RACERS {
392            let database = database.clone();
393            let barrier = barrier.clone();
394            let value = nonce.value.clone();
395            tasks.push(tokio::spawn(async move {
396                barrier.wait().await;
397                Nonce::verify(&value, &database, TTL).await
398            }));
399        }
400
401        let mut accepted = 0;
402        for task in tasks {
403            if task.await.unwrap().unwrap() {
404                accepted += 1;
405            }
406        }
407
408        assert_eq!(accepted, 1, "a nonce may be spent exactly once");
409        assert_eq!(nonce_count(&database).await, 0, "the row is consumed");
410
411        database.close().await;
412        for suffix in ["", "-wal", "-shm"] {
413            let _ = std::fs::remove_file(format!("{}{suffix}", file.display()));
414        }
415    }
416}