1use serde_json::Value;
2use sqlx::Row;
3use sqlx::sqlite::SqliteRow;
4use tracing::{debug, info};
5use uuid::Uuid;
6
7use crate::random::random_bytes;
8use crate::sqlite::db::Database;
9use crate::sqlite::nonce::now_secs;
10use crate::sqlite::order::rfc3339;
11
12#[derive(Debug)]
30pub struct Eab {
31 pub kid: Uuid,
32 pub secret: Vec<u8>,
33 pub label: Option<String>,
34 pub profile: Option<String>,
37 pub status: String,
38 pub created_at: i64,
39}
40
41const SECRET_LEN: usize = 32;
44
45impl Eab {
46 fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
47 Ok(Eab {
48 kid: row.try_get("kid")?,
49 secret: row.try_get("secret")?,
50 label: row.try_get("label")?,
51 profile: row.try_get("profile")?,
52 status: row.try_get("status")?,
53 created_at: row.try_get("created_at")?,
54 })
55 }
56
57 pub async fn create(
62 label: Option<String>,
63 profile: Option<String>,
64 database: &Database,
65 ) -> Result<Eab, sqlx::Error> {
66 let eab = Eab {
67 kid: crate::sqlite::id::mint(),
68 secret: random_bytes::<SECRET_LEN>().to_vec(),
69 label,
70 profile,
71 status: "active".to_string(),
72 created_at: now_secs(),
73 };
74
75 debug!(event = "db_eab_create_started", outcome = "progress", kid = ?eab.kid, profile = ?eab.profile);
76 sqlx::query(
77 "INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) \
78 VALUES (?, ?, ?, ?, ?, ?);",
79 )
80 .bind(eab.kid)
81 .bind(&eab.secret)
82 .bind(&eab.label)
83 .bind(&eab.profile)
84 .bind(&eab.status)
85 .bind(eab.created_at)
86 .execute(&database.pool)
87 .await?;
88
89 info!(event = "db_eab_created", outcome = "success", kid = ?eab.kid);
90 Ok(eab)
91 }
92
93 pub async fn find_by_kid(
97 kid: &str,
98 profile: &str,
99 database: &Database,
100 ) -> Result<Option<Eab>, sqlx::Error> {
101 debug!(event = "db_eab_find_by_kid_started", outcome = "progress", kid = ?kid, profile = %profile);
102 let Some(kid) = crate::sqlite::id::parse(kid) else {
103 return Ok(None);
104 };
105 let row = sqlx::query(
106 "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
107 WHERE kid = ? AND (profile IS NULL OR profile = ?);",
108 )
109 .bind(kid)
110 .bind(profile)
111 .fetch_optional(&database.pool)
112 .await?;
113
114 row.map(Eab::from_row).transpose()
115 }
116
117 pub async fn find_any_by_kid(
122 kid: &str,
123 database: &Database,
124 ) -> Result<Option<Eab>, sqlx::Error> {
125 debug!(event = "db_eab_find_any_by_kid_started", outcome = "progress", kid = ?kid);
126 let Some(kid) = crate::sqlite::id::parse(kid) else {
127 return Ok(None);
128 };
129 let row = sqlx::query(
130 "SELECT kid, secret, label, profile, status, created_at FROM eab_keys WHERE kid = ?;",
131 )
132 .bind(kid)
133 .fetch_optional(&database.pool)
134 .await?;
135
136 row.map(Eab::from_row).transpose()
137 }
138
139 pub async fn search(
160 limit: i64,
161 offset: i64,
162 database: &Database,
163 ) -> Result<(Vec<Eab>, i64), sqlx::Error> {
164 debug!(
165 event = "db_eab_search_started",
166 outcome = "progress",
167 limit = limit,
168 offset = offset
169 );
170 let rows = sqlx::query(
171 "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
172 ORDER BY created_at DESC, kid DESC LIMIT ? OFFSET ?;",
173 )
174 .bind(limit)
175 .bind(offset)
176 .fetch_all(&database.pool)
177 .await?;
178 let total: i64 = sqlx::query("SELECT COUNT(*) FROM eab_keys;")
179 .fetch_one(&database.pool)
180 .await?
181 .try_get(0)?;
182
183 let keys = rows
184 .into_iter()
185 .map(Eab::from_row)
186 .collect::<Result<_, _>>()?;
187 Ok((keys, total))
188 }
189
190 pub async fn revoke(kid: &str, database: &Database) -> Result<bool, sqlx::Error> {
195 debug!(event = "db_eab_revoke_started", outcome = "progress", kid = ?kid);
196 let Some(kid) = crate::sqlite::id::parse(kid) else {
197 return Ok(false);
198 };
199 let result = sqlx::query("UPDATE eab_keys SET status = 'revoked' WHERE kid = ?;")
200 .bind(kid)
201 .execute(&database.pool)
202 .await?;
203
204 let updated = result.rows_affected() > 0;
205 if updated {
206 info!(event = "db_eab_revoked", outcome = "success", kid = ?kid);
207 } else {
208 debug!(event = "db_eab_revoke_missing", outcome = "success", kid = ?kid);
209 }
210 Ok(updated)
211 }
212
213 #[must_use]
215 pub fn is_active(&self) -> bool {
216 self.status == "active"
217 }
218
219 #[must_use]
225 pub fn to_json(&self) -> Value {
226 serde_json::json!({
227 "kid": self.kid,
228 "label": self.label,
229 "profile": self.profile,
230 "status": self.status,
231 "createdAt": rfc3339(self.created_at),
232 })
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::sync::Arc;
240
241 #[tokio::test]
242 async fn create_persists_an_active_key_with_a_32_byte_secret() {
243 let db = Arc::new(Database::connect_in_memory().await.unwrap());
244 let eab = Eab::create(Some("team-a".to_string()), None, &db)
245 .await
246 .unwrap();
247 assert_eq!(eab.status, "active");
248 assert_eq!(eab.secret.len(), 32);
249 assert_eq!(eab.label.as_deref(), Some("team-a"));
250 }
251
252 #[tokio::test]
253 async fn find_by_kid_round_trip() {
254 let db = Arc::new(Database::connect_in_memory().await.unwrap());
255 let created = Eab::create(None, None, &db).await.unwrap();
256 let found = Eab::find_by_kid(created.kid.to_string().as_str(), "default", &db)
257 .await
258 .unwrap()
259 .unwrap();
260 assert_eq!(found.secret, created.secret);
261 assert!(found.label.is_none());
262 }
263
264 #[tokio::test]
265 async fn find_by_kid_of_unknown_returns_none() {
266 let db = Arc::new(Database::connect_in_memory().await.unwrap());
267 assert!(
268 Eab::find_by_kid("nope", "default", &db)
269 .await
270 .unwrap()
271 .is_none()
272 );
273 }
274
275 #[tokio::test]
290 async fn search_returns_every_key_newest_first_and_empty_is_empty() {
291 let db = Arc::new(Database::connect_in_memory().await.unwrap());
292 assert!(Eab::search(50, 0, &db).await.unwrap().0.is_empty());
293
294 let first = Eab::create(None, None, &db).await.unwrap();
295 let second = Eab::create(None, None, &db).await.unwrap();
296 let (all, total) = Eab::search(50, 0, &db).await.unwrap();
297 assert_eq!((all.len(), total), (2, 2));
298 let kids: Vec<String> = all.iter().map(|eab| eab.kid.to_string()).collect();
299 assert_eq!(kids, [second.kid.to_string(), first.kid.to_string()]);
300 }
301
302 #[tokio::test]
307 async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
308 let db = Arc::new(Database::connect_in_memory().await.unwrap());
309 assert_eq!(Eab::search(50, 0, &db).await.unwrap().1, 0);
310
311 let created: Vec<String> = {
312 let mut kids = Vec::new();
313 for _ in 0..5 {
314 kids.push(Eab::create(None, None, &db).await.unwrap().kid);
315 }
316 kids.into_iter().map(|v| v.to_string()).collect()
317 };
318
319 let (first, total) = Eab::search(2, 0, &db).await.unwrap();
320 let (second, also_total) = Eab::search(2, 2, &db).await.unwrap();
321 let (third, _) = Eab::search(2, 4, &db).await.unwrap();
322
323 assert_eq!(total, 5);
324 assert_eq!(also_total, 5, "the total is the table, not the page");
325 assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
326
327 let walked: Vec<String> = first
330 .iter()
331 .chain(second.iter())
332 .chain(third.iter())
333 .map(|eab| eab.kid.to_string())
334 .collect();
335 assert_eq!(walked.len(), created.len());
336 for kid in &created {
337 assert_eq!(
338 walked.iter().filter(|seen| *seen == kid).count(),
339 1,
340 "{kid} was not on exactly one page"
341 );
342 }
343 }
344
345 #[tokio::test]
346 async fn revoke_marks_revoked_reports_true_and_is_idempotent() {
347 let db = Arc::new(Database::connect_in_memory().await.unwrap());
348 let eab = Eab::create(None, None, &db).await.unwrap();
349 assert!(
350 Eab::revoke(eab.kid.to_string().as_str(), &db)
351 .await
352 .unwrap()
353 );
354 assert!(
355 !Eab::find_by_kid(eab.kid.to_string().as_str(), "default", &db)
356 .await
357 .unwrap()
358 .unwrap()
359 .is_active()
360 );
361 assert!(
363 Eab::revoke(eab.kid.to_string().as_str(), &db)
364 .await
365 .unwrap()
366 );
367 }
368
369 #[tokio::test]
370 async fn revoke_of_unknown_kid_reports_false() {
371 let db = Arc::new(Database::connect_in_memory().await.unwrap());
372 assert!(!Eab::revoke("nope", &db).await.unwrap());
373 }
374
375 #[tokio::test]
376 async fn to_json_never_includes_the_secret() {
377 let db = Arc::new(Database::connect_in_memory().await.unwrap());
378 let eab = Eab::create(Some("x".to_string()), None, &db).await.unwrap();
379 let json = eab.to_json();
380 assert!(json.get("secret").is_none());
381 assert!(json.get("hmacKey").is_none());
382 assert_eq!(json["kid"], eab.kid.to_string());
383 assert_eq!(json["status"], "active");
384 assert_eq!(json["label"], "x");
385 }
386}