acme_proxy/sqlite/
nonce.rs1use std::time::{Duration, SystemTime};
2
3use sqlx::Row;
4use tracing::{debug, info};
5use uuid::Uuid;
6
7use crate::sqlite::db::Database;
8
9#[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#[must_use]
60pub fn fingerprint(value: &str) -> &str {
61 value.get(..8).unwrap_or(value)
62}
63
64pub(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 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 pub async fn verify(
105 nonce: &str,
106 database: &Database,
107 ttl: Duration,
108 ) -> Result<bool, sqlx::Error> {
109 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 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 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 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 assert!(Nonce::verify(&value, &database, TTL).await.unwrap());
201 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 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 #[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 #[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 assert_eq!(nonce_count(&database).await, 1);
289 assert!(Nonce::verify(&fresh_value, &database, TTL).await.unwrap());
290 }
291
292 #[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 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}