acme_proxy/sqlite/
nonce.rs1use 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#[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#[must_use]
62pub fn fingerprint(value: &str) -> &str {
63 value.get(..8).unwrap_or(value)
64}
65
66pub(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 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 pub async fn verify(
107 nonce: &str,
108 database: &Database,
109 ttl: Duration,
110 ) -> Result<bool, sqlx::Error> {
111 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 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 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 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 assert!(Nonce::verify(&value, &database, TTL).await.unwrap());
204 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 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 #[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 #[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 assert_eq!(nonce_count(&database).await, 1);
292 assert!(Nonce::verify(&fresh_value, &database, TTL).await.unwrap());
293 }
294
295 #[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 #[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 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 #[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}