acme-proxy-store 0.6.0

The SQLite storage layer of acme-proxy, one module per table (internal crate, no semver promise)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Replay nonces (RFC 8555 §6.5): minted on every response, spent by the next
//! signed request.
//!
//! Three rules the request path leans on:
//!
//! - **Spending is one `DELETE`.** [`Nonce::verify`] deletes the live row and
//!   lets `rows_affected` answer, so two requests replaying one nonce cannot
//!   both pass — there is no read to race.
//! - **A value is never logged whole.** An unspent nonce is a bearer
//!   credential; every log line here carries its [`fingerprint`].
//! - **Expiry is a predicate, not a sweep.** A row older than the TTL is
//!   refused by `verify` whether or not [`Nonce::cleanup`] has run; the sweep
//!   only keeps the table from growing.

use std::time::{Duration, SystemTime};

use tracing::{debug, info};

use crate::db::Database;
use acme_proxy_core::random::random_token;

/// A replay nonce used for ACME protocol anti-replay protection.
///
/// ## ACME Protocol Compliance
///
/// According to RFC 8555, nonces are used to prevent replay attacks:
/// - Each nonce is 32 random bytes from the system CSPRNG, base64url-encoded
///   without padding — the octet-string form §6.5.1 requires, and 256 bits
///   like every other non-guessable value in this tree
/// - Nonces are generated for every response
/// - Clients must include a valid nonce in their requests
/// - Nonces are single-use and expire after a TTL period
///
/// ## Security Considerations
///
/// - Nonces are stored in the database with timestamps
/// - Expired nonces are automatically cleaned up
/// - Single-use enforcement prevents replay attacks
/// - Time-to-live limits prevent indefinite nonce accumulation
///
/// ## Database Storage
///
/// Nonces are stored in the `nonces` table with:
/// - `value`: The base64url nonce string
/// - `created_at`: Unix timestamp when the nonce was created
///
/// ## Lifecycle
///
/// 1. New nonce created for each response
/// 2. Nonce saved to database
/// 3. Client uses nonce in subsequent request
/// 4. Nonce verified and consumed (single-use)
/// 5. Expired nonces automatically cleaned up
#[derive(Debug)]
pub struct Nonce {
    pub value: String,
    pub created_at: i64,
}

impl Default for Nonce {
    fn default() -> Self {
        Self::new()
    }
}

/// A short, non-reusable stand-in for a nonce value, for logs.
///
/// A nonce is a bearer credential: until it is consumed, anyone holding it can
/// sign a request with it. `Nonce::save` runs from the response middleware, so
/// logging the value there put *every nonce this server has minted* into the
/// log stream while it was still live and unused — a log reader could lift one
/// straight out. Eight of the 43 base64url characters are enough to follow one
/// request across lines and, at ~48 bits, far too few to replay.
#[must_use]
pub fn fingerprint(value: &str) -> &str {
    value.get(..8).unwrap_or(value)
}

/// Seconds since the Unix epoch, saturating to `0` for the pre-1970 clocks that
/// should never occur in practice.
pub fn now_secs() -> i64 {
    SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64
}

impl Nonce {
    /// A fresh nonce, stamped now. Not stored until [`save`](Self::save).
    #[must_use]
    pub fn new() -> Self {
        Nonce {
            value: random_token(),
            created_at: now_secs(),
        }
    }

    /// Stores the nonce so a later [`verify`](Self::verify) can spend it.
    pub async fn save(&self, database: &Database) -> Result<(), sqlx::Error> {
        crate::sql::query("INSERT INTO nonces VALUES (?, ?);")
            .bind(self.value.clone())
            .bind(self.created_at)
            .execute(database)
            .await?;
        // At `debug`, and a fingerprint rather than the value: this runs from
        // the response middleware, so it is the one log line in the server that
        // would otherwise publish a live, unconsumed credential on every single
        // response.
        debug!(event = "db_nonce_saved",
               outcome = "success",
               nonce_fp = %fingerprint(&self.value),
               created_at = ?self.created_at);
        Ok(())
    }

    /// Consumes `nonce` if it is live: a single `DELETE` whose `rows_affected`
    /// decides, so two concurrent replays cannot both succeed.
    ///
    /// Takes `&str` rather than `String` — it only ever binds a reference, and
    /// this runs on every signed POST, which is the hottest path in the server.
    pub async fn verify(
        nonce: &str,
        database: &Database,
        ttl: Duration,
    ) -> Result<bool, sqlx::Error> {
        // Saturating subtraction avoids the panic `SystemTime - Duration` raises
        // when `ttl` reaches back past the epoch.
        let cutoff = now_secs().saturating_sub(ttl.as_secs() as i64);

        let result = crate::sql::query("DELETE FROM nonces WHERE value = ? AND created_at > ?;")
            .bind(nonce)
            .bind(cutoff)
            .execute(database)
            .await?;

        // A verified nonce is spent, so logging it would leak nothing — but a
        // *rejected* one may simply have been sent to the wrong endpoint and
        // still be live elsewhere, and one rule is easier to keep than two.
        // Two arms rather than one `event = if …`: the name has to stay a bare
        // literal, or neither spelling is greppable from a log back to here.
        let is_valid = result.rows_affected() == 1;
        if is_valid {
            debug!(
                event = "db_nonce_verified_valid",
                outcome = "success",
                nonce_fp = %fingerprint(nonce),
                cutoff = cutoff,
                ttl_seconds = ttl.as_secs(),
            );
        } else {
            debug!(
                event = "db_nonce_verified_invalid",
                outcome = "failure",
                nonce_fp = %fingerprint(nonce),
                cutoff = cutoff,
                ttl_seconds = ttl.as_secs(),
            );
        }
        Ok(is_valid)
    }

    /// How many nonce rows exist right now.
    ///
    /// The one thing worth showing about this table: individual values are
    /// bearer credentials and are never listed (see the note on
    /// [`fingerprint`]), but the *count* is a useful health signal — it should
    /// hover around the request rate times the TTL, and a number far above
    /// that means the reaper is not running.
    pub async fn count(database: &Database) -> Result<i64, sqlx::Error> {
        crate::sql::query("SELECT COUNT(*) FROM nonces;")
            .fetch_one(database)
            .await?
            .try_get(0usize)
    }

    /// Deletes every nonce older than `ttl`, returning how many went. The
    /// `nonce_sweep` job and `acme-proxy nonce cleanup` both land here.
    pub async fn cleanup(database: &Database, ttl: Duration) -> Result<u64, sqlx::Error> {
        let cutoff = now_secs().saturating_sub(ttl.as_secs() as i64);

        let result = crate::sql::query("DELETE FROM nonces WHERE created_at <= ?;")
            .bind(cutoff)
            .execute(database)
            .await?;

        info!(event = "db_nonce_cleanup_completed",
              outcome = "success",
              rows_removed = ?result.rows_affected(),
              cutoff = ?cutoff,
              ttl_seconds = ?ttl.as_secs());
        Ok(result.rows_affected())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use base64::prelude::*;
    use std::sync::Arc;

    /// Matches the default `nonce.ttl_seconds` (5 minutes).
    const TTL: Duration = Duration::from_secs(300);

    async fn nonce_count(database: &Arc<Database>) -> i64 {
        crate::sql::query("SELECT COUNT(*) FROM nonces;")
            .fetch_one(database)
            .await
            .unwrap()
            .try_get(0usize)
            .unwrap()
    }

    #[tokio::test]
    async fn verify_accepts_fresh_nonce_exactly_once() {
        let database = Arc::new(Database::connect_for_test().await.unwrap());

        let nonce = Nonce::new();
        let value = nonce.value.clone();
        nonce.save(&database).await.unwrap();

        // First use succeeds…
        assert!(Nonce::verify(&value, &database, TTL).await.unwrap());
        // …and the nonce is single-use: the row is gone, so a replay fails.
        assert!(!Nonce::verify(&value, &database, TTL).await.unwrap());
    }

    #[tokio::test]
    async fn verify_rejects_unknown_nonce() {
        let database = Arc::new(Database::connect_for_test().await.unwrap());

        assert!(!Nonce::verify("never-issued", &database, TTL).await.unwrap());
    }

    #[tokio::test]
    async fn verify_rejects_expired_nonce() {
        let database = Arc::new(Database::connect_for_test().await.unwrap());

        // 10 minutes old — outside the 5-minute freshness window.
        Nonce {
            value: "stale".to_string(),
            created_at: now_secs() - 600,
        }
        .save(&database)
        .await
        .unwrap();

        assert!(!Nonce::verify("stale", &database, TTL).await.unwrap());
    }

    #[tokio::test]
    async fn verify_accepts_nonce_near_edge_of_window() {
        let database = Arc::new(Database::connect_for_test().await.unwrap());

        // Just inside the freshness window — pins the `created_at > cutoff`
        // acceptance boundary (2s margin avoids the exact-second tick flaking).
        #[allow(clippy::cast_possible_wrap)]
        let created_at = now_secs() - (TTL.as_secs() as i64 - 2);
        Nonce {
            value: "edge".to_string(),
            created_at,
        }
        .save(&database)
        .await
        .unwrap();

        assert!(Nonce::verify("edge", &database, TTL).await.unwrap());
    }

    #[tokio::test]
    async fn verify_rejects_nonce_at_exact_cutoff_boundary() {
        let database = Arc::new(Database::connect_for_test().await.unwrap());

        // Pinned at exactly the cutoff — age == TTL, so the nonce is expired
        // under the strict `> cutoff` check.
        #[allow(clippy::cast_possible_wrap)]
        let created_at = now_secs() - TTL.as_secs() as i64;
        Nonce {
            value: "boundary".to_string(),
            created_at,
        }
        .save(&database)
        .await
        .unwrap();

        assert!(!Nonce::verify("boundary", &database, TTL).await.unwrap());
    }

    #[tokio::test]
    async fn cleanup_removes_only_stale_nonces() {
        let database = Arc::new(Database::connect_for_test().await.unwrap());

        Nonce {
            value: "stale".to_string(),
            created_at: now_secs() - 600,
        }
        .save(&database)
        .await
        .unwrap();

        let fresh = Nonce::new();
        let fresh_value = fresh.value.clone();
        fresh.save(&database).await.unwrap();

        assert_eq!(nonce_count(&database).await, 2);

        let removed = Nonce::cleanup(&database, TTL).await.unwrap();
        assert_eq!(removed, 1);

        // Only the fresh nonce survives, and it is still usable.
        assert_eq!(nonce_count(&database).await, 1);
        assert!(Nonce::verify(&fresh_value, &database, TTL).await.unwrap());
    }

    /// RFC 8555 §6.5.1: the `Replay-Nonce` value "MUST be an octet string
    /// encoded according to the base64url encoding", and clients are told to
    /// ignore one that is not. This was a hyphenated UUID v4 until 0.2.0 —
    /// out of the alphabet, and 122 bits where every other non-guessable
    /// value in the tree is 256.
    #[test]
    fn a_minted_nonce_is_base64url_over_32_bytes() {
        let value = Nonce::new().value;

        assert!(
            value
                .bytes()
                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
            "outside the base64url alphabet: {value}"
        );
        assert_eq!(
            BASE64_URL_SAFE_NO_PAD.decode(&value).unwrap().len(),
            32,
            "256 bits, like every other non-guessable value here"
        );
    }

    /// `Default` exists so `Nonce` composes where one is expected; it must
    /// agree with `new`, which is what every call site actually uses.
    #[test]
    fn the_default_nonce_is_a_fresh_one() {
        let nonce = Nonce::default();
        assert!(!nonce.value.is_empty());
        assert_ne!(nonce.value, Nonce::default().value, "each must be unique");
    }

    #[tokio::test]
    async fn count_reports_the_table_size_and_follows_cleanup() {
        let db = Arc::new(Database::connect_for_test().await.unwrap());
        assert_eq!(Nonce::count(&db).await.unwrap(), 0);

        for _ in 0..3 {
            Nonce::new().save(&db).await.unwrap();
        }
        assert_eq!(Nonce::count(&db).await.unwrap(), 3);

        // A stale row, swept: the count is what makes a stalled reaper visible.
        let stale = Nonce {
            value: "stale".to_string(),
            created_at: now_secs() - 10_000,
        };
        stale.save(&db).await.unwrap();
        assert_eq!(Nonce::count(&db).await.unwrap(), 4);
        Nonce::cleanup(&db, TTL).await.unwrap();
        assert_eq!(Nonce::count(&db).await.unwrap(), 3);
    }

    /// Two simultaneous verifications of one nonce: exactly one may succeed.
    ///
    /// This is what replay protection *is*, and the surrounding tests cannot
    /// show it — fresh/unknown/expired/boundary all drive one caller at a time,
    /// which a `SELECT` followed by a `DELETE` would pass just as happily. The
    /// property only has teeth under concurrency: the single `DELETE … WHERE`
    /// is atomic, so `rows_affected` names one winner no matter how the two
    /// interleave.
    ///
    /// A file-backed database, because `connect_in_memory` pins the pool to one
    /// connection and would serialize the pair out of the race. The barrier
    /// releases both at the same point.
    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn concurrent_verify_of_one_nonce_succeeds_exactly_once() {
        let file =
            std::env::temp_dir().join(format!("acme-proxy-test-{}.db", uuid::Uuid::now_v7()));
        let url = format!("sqlite://{}", file.display());
        let database = Arc::new(Database::connect_and_migrate(&url).await.unwrap());

        let nonce = Nonce::new();
        nonce.save(&database).await.unwrap();

        const RACERS: usize = 8;
        let barrier = Arc::new(tokio::sync::Barrier::new(RACERS));
        let mut tasks = Vec::with_capacity(RACERS);
        for _ in 0..RACERS {
            let database = database.clone();
            let barrier = barrier.clone();
            let value = nonce.value.clone();
            tasks.push(tokio::spawn(async move {
                barrier.wait().await;
                Nonce::verify(&value, &database, TTL).await
            }));
        }

        let mut accepted = 0;
        for task in tasks {
            if task.await.unwrap().unwrap() {
                accepted += 1;
            }
        }

        assert_eq!(accepted, 1, "a nonce may be spent exactly once");
        assert_eq!(nonce_count(&database).await, 0, "the row is consumed");

        database.close().await;
        for suffix in ["", "-wal", "-shm"] {
            let _ = std::fs::remove_file(format!("{}{suffix}", file.display()));
        }
    }
}