Skip to main content

acme_proxy/sqlite/
nonce.rs

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