1use async_trait::async_trait;
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use sqlx::{Row, SqlitePool};
19use tracing::{debug, info};
20
21use crate::{Result, SecretsError};
22
23const PUBLIC_KEY_LEN: usize = 32;
25
26const SCHEMA: &str = r"
29CREATE TABLE IF NOT EXISTS client_public_keys (
30 key_id TEXT PRIMARY KEY,
31 actor_kind TEXT NOT NULL CHECK(actor_kind IN ('user','api_key')),
32 actor_id TEXT NOT NULL,
33 public_key BLOB NOT NULL,
34 label TEXT,
35 created_at TEXT NOT NULL,
36 last_used_at TEXT,
37 revoked_at TEXT
38);
39CREATE INDEX IF NOT EXISTS idx_client_public_keys_actor
40 ON client_public_keys(actor_kind, actor_id) WHERE revoked_at IS NULL;
41";
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ActorKind {
47 User,
49 ApiKey,
51}
52
53impl ActorKind {
54 #[must_use]
56 pub fn as_str(&self) -> &'static str {
57 match self {
58 Self::User => "user",
59 Self::ApiKey => "api_key",
60 }
61 }
62
63 #[allow(clippy::should_implement_trait)]
70 pub fn from_str(s: &str) -> Result<Self> {
71 match s {
72 "user" => Ok(Self::User),
73 "api_key" => Ok(Self::ApiKey),
74 other => Err(SecretsError::Storage(format!(
75 "invalid actor_kind in client_public_keys row: {other:?}"
76 ))),
77 }
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ClientPublicKey {
84 pub key_id: String,
87 pub actor_kind: ActorKind,
89 pub actor_id: String,
91 pub public_key: Vec<u8>,
93 pub label: Option<String>,
95 pub created_at: DateTime<Utc>,
97 pub last_used_at: Option<DateTime<Utc>>,
99 pub revoked_at: Option<DateTime<Utc>>,
101}
102
103#[async_trait]
105pub trait ClientKeyStore: Send + Sync {
106 async fn register(
108 &self,
109 actor_kind: ActorKind,
110 actor_id: &str,
111 public_key: &[u8],
112 label: Option<&str>,
113 ) -> Result<ClientPublicKey>;
114
115 async fn get(&self, key_id: &str) -> Result<Option<ClientPublicKey>>;
118
119 async fn list_by_actor(
121 &self,
122 actor_kind: ActorKind,
123 actor_id: &str,
124 ) -> Result<Vec<ClientPublicKey>>;
125
126 async fn revoke(&self, key_id: &str) -> Result<()>;
128
129 async fn touch_last_used(&self, key_id: &str) -> Result<()>;
131}
132
133pub struct PersistentClientKeyStore {
138 pool: SqlitePool,
139}
140
141impl PersistentClientKeyStore {
142 pub async fn new(pool: SqlitePool) -> Result<Self> {
148 sqlx::query(SCHEMA)
149 .execute(&pool)
150 .await
151 .map_err(|e| SecretsError::Storage(format!("Failed to initialize schema: {e}")))?;
152
153 info!("Initialized client public keys schema");
154 Ok(Self { pool })
155 }
156
157 #[must_use]
160 pub fn pool(&self) -> &SqlitePool {
161 &self.pool
162 }
163
164 fn generate_key_id() -> String {
166 let bytes: [u8; 16] = rand::random();
167 format!("ck_{}", hex::encode(bytes))
168 }
169
170 fn format_timestamp(ts: DateTime<Utc>) -> String {
174 ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
175 }
176
177 fn parse_timestamp(s: &str) -> Result<DateTime<Utc>> {
179 DateTime::parse_from_rfc3339(s)
180 .map(|dt| dt.with_timezone(&Utc))
181 .map_err(|e| SecretsError::Storage(format!("invalid timestamp {s:?}: {e}")))
182 }
183
184 fn row_to_key(row: &sqlx::sqlite::SqliteRow) -> Result<ClientPublicKey> {
186 let key_id: String = row
187 .try_get("key_id")
188 .map_err(|e| SecretsError::Storage(format!("Failed to read key_id: {e}")))?;
189 let actor_kind_str: String = row
190 .try_get("actor_kind")
191 .map_err(|e| SecretsError::Storage(format!("Failed to read actor_kind: {e}")))?;
192 let actor_kind = ActorKind::from_str(&actor_kind_str)?;
193 let actor_id: String = row
194 .try_get("actor_id")
195 .map_err(|e| SecretsError::Storage(format!("Failed to read actor_id: {e}")))?;
196 let public_key: Vec<u8> = row
197 .try_get("public_key")
198 .map_err(|e| SecretsError::Storage(format!("Failed to read public_key: {e}")))?;
199 let label: Option<String> = row
200 .try_get("label")
201 .map_err(|e| SecretsError::Storage(format!("Failed to read label: {e}")))?;
202 let created_at_str: String = row
203 .try_get("created_at")
204 .map_err(|e| SecretsError::Storage(format!("Failed to read created_at: {e}")))?;
205 let last_used_at_str: Option<String> = row
206 .try_get("last_used_at")
207 .map_err(|e| SecretsError::Storage(format!("Failed to read last_used_at: {e}")))?;
208 let revoked_at_str: Option<String> = row
209 .try_get("revoked_at")
210 .map_err(|e| SecretsError::Storage(format!("Failed to read revoked_at: {e}")))?;
211
212 let created_at = Self::parse_timestamp(&created_at_str)?;
213 let last_used_at = match last_used_at_str {
214 Some(s) => Some(Self::parse_timestamp(&s)?),
215 None => None,
216 };
217 let revoked_at = match revoked_at_str {
218 Some(s) => Some(Self::parse_timestamp(&s)?),
219 None => None,
220 };
221
222 Ok(ClientPublicKey {
223 key_id,
224 actor_kind,
225 actor_id,
226 public_key,
227 label,
228 created_at,
229 last_used_at,
230 revoked_at,
231 })
232 }
233}
234
235#[async_trait]
236impl ClientKeyStore for PersistentClientKeyStore {
237 async fn register(
238 &self,
239 actor_kind: ActorKind,
240 actor_id: &str,
241 public_key: &[u8],
242 label: Option<&str>,
243 ) -> Result<ClientPublicKey> {
244 if public_key.len() != PUBLIC_KEY_LEN {
245 return Err(SecretsError::Storage(format!(
246 "invalid public key length: expected {PUBLIC_KEY_LEN} bytes, got {}",
247 public_key.len()
248 )));
249 }
250
251 let key_id = Self::generate_key_id();
252 let created_at = Utc::now();
253 let created_at_str = Self::format_timestamp(created_at);
254 let public_key_vec = public_key.to_vec();
255
256 sqlx::query(
257 "INSERT INTO client_public_keys \
258 (key_id, actor_kind, actor_id, public_key, label, created_at, last_used_at, revoked_at) \
259 VALUES (?, ?, ?, ?, ?, ?, NULL, NULL)",
260 )
261 .bind(&key_id)
262 .bind(actor_kind.as_str())
263 .bind(actor_id)
264 .bind(&public_key_vec)
265 .bind(label)
266 .bind(&created_at_str)
267 .execute(&self.pool)
268 .await
269 .map_err(|e| SecretsError::Storage(format!("Failed to insert client public key: {e}")))?;
270
271 debug!(
272 "Registered client public key {} for {} {}",
273 key_id,
274 actor_kind.as_str(),
275 actor_id
276 );
277
278 Ok(ClientPublicKey {
279 key_id,
280 actor_kind,
281 actor_id: actor_id.to_string(),
282 public_key: public_key_vec,
283 label: label.map(str::to_string),
284 created_at,
285 last_used_at: None,
286 revoked_at: None,
287 })
288 }
289
290 async fn get(&self, key_id: &str) -> Result<Option<ClientPublicKey>> {
291 let row = sqlx::query(
292 "SELECT key_id, actor_kind, actor_id, public_key, label, created_at, last_used_at, revoked_at \
293 FROM client_public_keys WHERE key_id = ?",
294 )
295 .bind(key_id)
296 .fetch_optional(&self.pool)
297 .await
298 .map_err(|e| SecretsError::Storage(format!("Failed to query client public key: {e}")))?;
299
300 match row {
301 Some(row) => Ok(Some(Self::row_to_key(&row)?)),
302 None => Ok(None),
303 }
304 }
305
306 async fn list_by_actor(
307 &self,
308 actor_kind: ActorKind,
309 actor_id: &str,
310 ) -> Result<Vec<ClientPublicKey>> {
311 let rows = sqlx::query(
312 "SELECT key_id, actor_kind, actor_id, public_key, label, created_at, last_used_at, revoked_at \
313 FROM client_public_keys \
314 WHERE actor_kind = ? AND actor_id = ? AND revoked_at IS NULL \
315 ORDER BY created_at DESC",
316 )
317 .bind(actor_kind.as_str())
318 .bind(actor_id)
319 .fetch_all(&self.pool)
320 .await
321 .map_err(|e| SecretsError::Storage(format!("Failed to list client public keys: {e}")))?;
322
323 let mut out = Vec::with_capacity(rows.len());
324 for row in &rows {
325 out.push(Self::row_to_key(row)?);
326 }
327 Ok(out)
328 }
329
330 async fn revoke(&self, key_id: &str) -> Result<()> {
331 let now = Self::format_timestamp(Utc::now());
332
333 let result = sqlx::query("UPDATE client_public_keys SET revoked_at = ? WHERE key_id = ?")
334 .bind(&now)
335 .bind(key_id)
336 .execute(&self.pool)
337 .await
338 .map_err(|e| {
339 SecretsError::Storage(format!("Failed to revoke client public key: {e}"))
340 })?;
341
342 if result.rows_affected() == 0 {
343 return Err(SecretsError::NotFound {
344 name: key_id.to_string(),
345 });
346 }
347
348 debug!("Revoked client public key {}", key_id);
349 Ok(())
350 }
351
352 async fn touch_last_used(&self, key_id: &str) -> Result<()> {
353 let now = Self::format_timestamp(Utc::now());
354
355 let result = sqlx::query("UPDATE client_public_keys SET last_used_at = ? WHERE key_id = ?")
356 .bind(&now)
357 .bind(key_id)
358 .execute(&self.pool)
359 .await
360 .map_err(|e| SecretsError::Storage(format!("Failed to update last_used_at: {e}")))?;
361
362 if result.rows_affected() == 0 {
363 return Err(SecretsError::NotFound {
364 name: key_id.to_string(),
365 });
366 }
367
368 Ok(())
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 async fn create_test_store() -> PersistentClientKeyStore {
377 let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
378 PersistentClientKeyStore::new(pool).await.unwrap()
379 }
380
381 #[tokio::test]
382 async fn register_and_get_roundtrip() {
383 let store = create_test_store().await;
384 let pk = [7u8; 32];
385 let registered = store
386 .register(ActorKind::User, "user-123", &pk, Some("laptop"))
387 .await
388 .unwrap();
389
390 assert!(registered.key_id.starts_with("ck_"));
391 assert_eq!(registered.key_id.len(), 3 + 32);
392 assert_eq!(registered.actor_kind, ActorKind::User);
393 assert_eq!(registered.actor_id, "user-123");
394 assert_eq!(registered.public_key, pk.to_vec());
395 assert_eq!(registered.label.as_deref(), Some("laptop"));
396 assert!(registered.last_used_at.is_none());
397 assert!(registered.revoked_at.is_none());
398
399 let fetched = store.get(®istered.key_id).await.unwrap().unwrap();
400 assert_eq!(fetched.key_id, registered.key_id);
401 assert_eq!(fetched.actor_kind, ActorKind::User);
402 assert_eq!(fetched.actor_id, "user-123");
403 assert_eq!(fetched.public_key, pk.to_vec());
404 assert_eq!(fetched.label.as_deref(), Some("laptop"));
405
406 assert!(store.get("ck_does_not_exist").await.unwrap().is_none());
408 }
409
410 #[tokio::test]
411 async fn duplicate_key_id_errors() {
412 let store = create_test_store().await;
413 let pk = [3u8; 32];
414
415 let registered = store
416 .register(ActorKind::ApiKey, "api-1", &pk, None)
417 .await
418 .unwrap();
419
420 let result = sqlx::query(
423 "INSERT INTO client_public_keys \
424 (key_id, actor_kind, actor_id, public_key, label, created_at, last_used_at, revoked_at) \
425 VALUES (?, 'api_key', 'api-1', ?, NULL, '2026-01-01T00:00:00Z', NULL, NULL)",
426 )
427 .bind(®istered.key_id)
428 .bind(pk.to_vec())
429 .execute(store.pool())
430 .await;
431
432 assert!(result.is_err(), "duplicate key_id must be rejected");
433 }
434
435 #[tokio::test]
436 async fn list_by_actor_returns_only_active() {
437 let store = create_test_store().await;
438 let pk = [9u8; 32];
439
440 let a = store
441 .register(ActorKind::User, "u1", &pk, Some("a"))
442 .await
443 .unwrap();
444 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
447 let _b = store
448 .register(ActorKind::User, "u1", &pk, Some("b"))
449 .await
450 .unwrap();
451 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
452 let c = store
453 .register(ActorKind::User, "u1", &pk, Some("c"))
454 .await
455 .unwrap();
456 let _other = store
458 .register(ActorKind::User, "u2", &pk, Some("other"))
459 .await
460 .unwrap();
461 let _other_kind = store
463 .register(ActorKind::ApiKey, "u1", &pk, Some("api"))
464 .await
465 .unwrap();
466
467 store.revoke(&a.key_id).await.unwrap();
469
470 let active = store.list_by_actor(ActorKind::User, "u1").await.unwrap();
471 assert_eq!(active.len(), 2);
472 assert_eq!(active[0].key_id, c.key_id);
474 assert!(active.iter().all(|k| k.revoked_at.is_none()));
475 assert!(active.iter().all(|k| k.actor_id == "u1"));
476 assert!(active
477 .iter()
478 .all(|k| matches!(k.actor_kind, ActorKind::User)));
479 }
480
481 #[tokio::test]
482 async fn revoke_hides_from_list_but_get_still_finds_with_revoked_at() {
483 let store = create_test_store().await;
484 let pk = [1u8; 32];
485
486 let key = store
487 .register(ActorKind::ApiKey, "svc-1", &pk, None)
488 .await
489 .unwrap();
490
491 let pre = store
493 .list_by_actor(ActorKind::ApiKey, "svc-1")
494 .await
495 .unwrap();
496 assert_eq!(pre.len(), 1);
497 assert_eq!(pre[0].key_id, key.key_id);
498
499 store.revoke(&key.key_id).await.unwrap();
500
501 let post = store
503 .list_by_actor(ActorKind::ApiKey, "svc-1")
504 .await
505 .unwrap();
506 assert!(post.is_empty());
507
508 let fetched = store.get(&key.key_id).await.unwrap().unwrap();
510 assert!(fetched.revoked_at.is_some());
511
512 let missing = store.revoke("ck_nope").await;
514 assert!(matches!(missing, Err(SecretsError::NotFound { .. })));
515 }
516
517 #[tokio::test]
518 async fn invalid_public_key_length_rejected() {
519 let store = create_test_store().await;
520
521 let too_short = [0u8; 16];
522 let err = store
523 .register(ActorKind::User, "u1", &too_short, None)
524 .await
525 .unwrap_err();
526 assert!(matches!(err, SecretsError::Storage(_)));
527
528 let too_long = [0u8; 64];
529 let err = store
530 .register(ActorKind::User, "u1", &too_long, None)
531 .await
532 .unwrap_err();
533 assert!(matches!(err, SecretsError::Storage(_)));
534
535 let empty: &[u8] = &[];
536 let err = store
537 .register(ActorKind::User, "u1", empty, None)
538 .await
539 .unwrap_err();
540 assert!(matches!(err, SecretsError::Storage(_)));
541
542 let list = store.list_by_actor(ActorKind::User, "u1").await.unwrap();
544 assert!(list.is_empty());
545 }
546
547 #[tokio::test]
548 async fn touch_last_used_updates_timestamp() {
549 let store = create_test_store().await;
550 let pk = [2u8; 32];
551
552 let key = store
553 .register(ActorKind::User, "u1", &pk, None)
554 .await
555 .unwrap();
556 assert!(key.last_used_at.is_none());
557
558 store.touch_last_used(&key.key_id).await.unwrap();
559
560 let fetched = store.get(&key.key_id).await.unwrap().unwrap();
561 assert!(fetched.last_used_at.is_some());
562
563 let err = store.touch_last_used("ck_nope").await.unwrap_err();
565 assert!(matches!(err, SecretsError::NotFound { .. }));
566 }
567
568 #[test]
569 fn actor_kind_str_roundtrip() {
570 assert_eq!(ActorKind::User.as_str(), "user");
571 assert_eq!(ActorKind::ApiKey.as_str(), "api_key");
572 assert_eq!(ActorKind::from_str("user").unwrap(), ActorKind::User);
573 assert_eq!(ActorKind::from_str("api_key").unwrap(), ActorKind::ApiKey);
574 assert!(matches!(
575 ActorKind::from_str("garbage"),
576 Err(SecretsError::Storage(_))
577 ));
578 }
579}