Skip to main content

acme_proxy/sqlite/
nonce.rs

1use std::time::{Duration, SystemTime};
2
3use sqlx::Row;
4use tracing::{debug, info};
5use uuid::Uuid;
6
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 a UUID v4 string
15/// - Nonces are generated for every response
16/// - Clients must include a valid nonce in their requests
17/// - Nonces are single-use and expire after a TTL period
18///
19/// ## Security Considerations
20///
21/// - Nonces are stored in the database with timestamps
22/// - Expired nonces are automatically cleaned up
23/// - Single-use enforcement prevents replay attacks
24/// - Time-to-live limits prevent indefinite nonce accumulation
25///
26/// ## Database Storage
27///
28/// Nonces are stored in the `nonces` table with:
29/// - `value`: The UUID string of the nonce
30/// - `created_at`: Unix timestamp when the nonce was created
31///
32/// ## Lifecycle
33///
34/// 1. New nonce created for each response
35/// 2. Nonce saved to database
36/// 3. Client uses nonce in subsequent request
37/// 4. Nonce verified and consumed (single-use)
38/// 5. Expired nonces automatically cleaned up
39#[derive(Debug)]
40pub struct Nonce {
41    pub value: String,
42    pub created_at: i64,
43}
44
45impl Default for Nonce {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51/// A short, non-reusable stand-in for a nonce value, for logs.
52///
53/// A nonce is a bearer credential: until it is consumed, anyone holding it can
54/// sign a request with it. `Nonce::save` runs from the response middleware, so
55/// logging the value there put *every nonce this server has minted* into the
56/// log stream while it was still live and unused — a log reader could lift one
57/// straight out. Eight characters of a UUID v4 are enough to follow one request
58/// across lines and far too few to replay.
59#[must_use]
60pub fn fingerprint(value: &str) -> &str {
61    value.get(..8).unwrap_or(value)
62}
63
64/// Seconds since the Unix epoch, saturating to `0` for the pre-1970 clocks that
65/// should never occur in practice.
66pub(crate) fn now_secs() -> i64 {
67    SystemTime::now()
68        .duration_since(SystemTime::UNIX_EPOCH)
69        .unwrap_or_default()
70        .as_secs() as i64
71}
72
73impl Nonce {
74    #[must_use]
75    pub fn new() -> Self {
76        Nonce {
77            value: Uuid::new_v4().to_string(),
78            created_at: now_secs(),
79        }
80    }
81
82    pub async fn save(&self, database: &Database) -> Result<(), sqlx::Error> {
83        sqlx::query("INSERT INTO nonces VALUES (?, ?);")
84            .bind(self.value.clone())
85            .bind(self.created_at)
86            .execute(&database.pool)
87            .await?;
88        // At `debug`, and a fingerprint rather than the value: this runs from
89        // the response middleware, so it is the one log line in the server that
90        // would otherwise publish a live, unconsumed credential on every single
91        // response.
92        debug!(event = "db_nonce_saved",
93               outcome = "success",
94               nonce_fp = %fingerprint(&self.value),
95               created_at = ?self.created_at);
96        Ok(())
97    }
98
99    /// Consumes `nonce` if it is live: a single `DELETE` whose `rows_affected`
100    /// decides, so two concurrent replays cannot both succeed.
101    ///
102    /// Takes `&str` rather than `String` — it only ever binds a reference, and
103    /// this runs on every signed POST, which is the hottest path in the server.
104    pub async fn verify(
105        nonce: &str,
106        database: &Database,
107        ttl: Duration,
108    ) -> Result<bool, sqlx::Error> {
109        // Saturating subtraction avoids the panic `SystemTime - Duration` raises
110        // when `ttl` reaches back past the epoch.
111        let cutoff = now_secs().saturating_sub(ttl.as_secs() as i64);
112
113        let result = sqlx::query("DELETE FROM nonces WHERE value = ? AND created_at > ?;")
114            .bind(nonce)
115            .bind(cutoff)
116            .execute(&database.pool)
117            .await?;
118
119        // A verified nonce is spent, so logging it would leak nothing — but a
120        // *rejected* one may simply have been sent to the wrong endpoint and
121        // still be live elsewhere, and one rule is easier to keep than two.
122        // Two arms rather than one `event = if …`: the name has to stay a bare
123        // literal, or neither spelling is greppable from a log back to here.
124        let is_valid = result.rows_affected() == 1;
125        if is_valid {
126            debug!(
127                event = "db_nonce_verified_valid",
128                outcome = "success",
129                nonce_fp = %fingerprint(nonce),
130                cutoff = cutoff,
131                ttl_seconds = ttl.as_secs(),
132            );
133        } else {
134            debug!(
135                event = "db_nonce_verified_invalid",
136                outcome = "failure",
137                nonce_fp = %fingerprint(nonce),
138                cutoff = cutoff,
139                ttl_seconds = ttl.as_secs(),
140            );
141        }
142        Ok(is_valid)
143    }
144
145    /// How many nonce rows exist right now.
146    ///
147    /// The one thing worth showing about this table: individual values are
148    /// bearer credentials and are never listed (see the note on
149    /// [`fingerprint`]), but the *count* is a useful health signal — it should
150    /// hover around the request rate times the TTL, and a number far above
151    /// that means the reaper is not running.
152    pub async fn count(database: &Database) -> Result<i64, sqlx::Error> {
153        sqlx::query("SELECT COUNT(*) FROM nonces;")
154            .fetch_one(&database.pool)
155            .await?
156            .try_get(0)
157    }
158
159    pub async fn cleanup(database: &Database, ttl: Duration) -> Result<u64, sqlx::Error> {
160        let cutoff = now_secs().saturating_sub(ttl.as_secs() as i64);
161
162        let result = sqlx::query("DELETE FROM nonces WHERE created_at <= ?;")
163            .bind(cutoff)
164            .execute(&database.pool)
165            .await?;
166
167        info!(event = "db_nonce_cleanup_completed",
168              outcome = "success",
169              rows_removed = ?result.rows_affected(),
170              cutoff = ?cutoff,
171              ttl_seconds = ?ttl.as_secs());
172        Ok(result.rows_affected())
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::sync::Arc;
180
181    /// Matches the default `nonce.ttl_seconds` (5 minutes).
182    const TTL: Duration = Duration::from_secs(300);
183
184    async fn nonce_count(database: &Arc<Database>) -> i64 {
185        sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nonces;")
186            .fetch_one(&database.pool)
187            .await
188            .unwrap()
189    }
190
191    #[tokio::test]
192    async fn verify_accepts_fresh_nonce_exactly_once() {
193        let database = Arc::new(Database::connect_in_memory().await.unwrap());
194
195        let nonce = Nonce::new();
196        let value = nonce.value.clone();
197        nonce.save(&database).await.unwrap();
198
199        // First use succeeds…
200        assert!(Nonce::verify(&value, &database, TTL).await.unwrap());
201        // …and the nonce is single-use: the row is gone, so a replay fails.
202        assert!(!Nonce::verify(&value, &database, TTL).await.unwrap());
203    }
204
205    #[tokio::test]
206    async fn verify_rejects_unknown_nonce() {
207        let database = Arc::new(Database::connect_in_memory().await.unwrap());
208
209        assert!(!Nonce::verify("never-issued", &database, TTL).await.unwrap());
210    }
211
212    #[tokio::test]
213    async fn verify_rejects_expired_nonce() {
214        let database = Arc::new(Database::connect_in_memory().await.unwrap());
215
216        // 10 minutes old — outside the 5-minute freshness window.
217        Nonce {
218            value: "stale".to_string(),
219            created_at: now_secs() - 600,
220        }
221        .save(&database)
222        .await
223        .unwrap();
224
225        assert!(!Nonce::verify("stale", &database, TTL).await.unwrap());
226    }
227
228    #[tokio::test]
229    async fn verify_accepts_nonce_near_edge_of_window() {
230        let database = Arc::new(Database::connect_in_memory().await.unwrap());
231
232        // Just inside the freshness window — pins the `created_at > cutoff`
233        // acceptance boundary (2s margin avoids the exact-second tick flaking).
234        #[allow(clippy::cast_possible_wrap)]
235        let created_at = now_secs() - (TTL.as_secs() as i64 - 2);
236        Nonce {
237            value: "edge".to_string(),
238            created_at,
239        }
240        .save(&database)
241        .await
242        .unwrap();
243
244        assert!(Nonce::verify("edge", &database, TTL).await.unwrap());
245    }
246
247    #[tokio::test]
248    async fn verify_rejects_nonce_at_exact_cutoff_boundary() {
249        let database = Arc::new(Database::connect_in_memory().await.unwrap());
250
251        // Pinned at exactly the cutoff — age == TTL, so the nonce is expired
252        // under the strict `> cutoff` check.
253        #[allow(clippy::cast_possible_wrap)]
254        let created_at = now_secs() - TTL.as_secs() as i64;
255        Nonce {
256            value: "boundary".to_string(),
257            created_at,
258        }
259        .save(&database)
260        .await
261        .unwrap();
262
263        assert!(!Nonce::verify("boundary", &database, TTL).await.unwrap());
264    }
265
266    #[tokio::test]
267    async fn cleanup_removes_only_stale_nonces() {
268        let database = Arc::new(Database::connect_in_memory().await.unwrap());
269
270        Nonce {
271            value: "stale".to_string(),
272            created_at: now_secs() - 600,
273        }
274        .save(&database)
275        .await
276        .unwrap();
277
278        let fresh = Nonce::new();
279        let fresh_value = fresh.value.clone();
280        fresh.save(&database).await.unwrap();
281
282        assert_eq!(nonce_count(&database).await, 2);
283
284        let removed = Nonce::cleanup(&database, TTL).await.unwrap();
285        assert_eq!(removed, 1);
286
287        // Only the fresh nonce survives, and it is still usable.
288        assert_eq!(nonce_count(&database).await, 1);
289        assert!(Nonce::verify(&fresh_value, &database, TTL).await.unwrap());
290    }
291
292    /// `Default` exists so `Nonce` composes where one is expected; it must
293    /// agree with `new`, which is what every call site actually uses.
294    #[test]
295    fn the_default_nonce_is_a_fresh_one() {
296        let nonce = Nonce::default();
297        assert!(!nonce.value.is_empty());
298        assert_ne!(nonce.value, Nonce::default().value, "each must be unique");
299    }
300
301    #[tokio::test]
302    async fn count_reports_the_table_size_and_follows_cleanup() {
303        let db = Arc::new(Database::connect_in_memory().await.unwrap());
304        assert_eq!(Nonce::count(&db).await.unwrap(), 0);
305
306        for _ in 0..3 {
307            Nonce::new().save(&db).await.unwrap();
308        }
309        assert_eq!(Nonce::count(&db).await.unwrap(), 3);
310
311        // A stale row, swept: the count is what makes a stalled reaper visible.
312        let stale = Nonce {
313            value: "stale".to_string(),
314            created_at: now_secs() - 10_000,
315        };
316        stale.save(&db).await.unwrap();
317        assert_eq!(Nonce::count(&db).await.unwrap(), 4);
318        Nonce::cleanup(&db, TTL).await.unwrap();
319        assert_eq!(Nonce::count(&db).await.unwrap(), 3);
320    }
321}