Skip to main content

zlayer_secrets/
client_keys.rs

1//! Persistent storage for SDK / browser client public keys, used as
2//! recipients for sealed-box secret reads. Shares the secrets `SQLite`
3//! database with [`PersistentSecretsStore`](crate::PersistentSecretsStore).
4//!
5//! Each registered key is bound to an actor (a user or an API key) and
6//! stored alongside an opaque `key_id`. Keys are never deleted — `revoke`
7//! is a soft-delete that hides the key from `list_by_actor` while keeping
8//! it retrievable via `get` so the actor's audit trail stays intact.
9//!
10//! The schema lives in the same `secrets.sqlite` file as the secrets
11//! table, so callers should construct a single [`SqlitePool`] (typically
12//! via [`PersistentSecretsStore::open`](crate::PersistentSecretsStore::open))
13//! and hand the same pool to [`PersistentClientKeyStore::new`].
14
15use 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
23/// Required length, in bytes, of an X25519 / Curve25519 public key.
24const PUBLIC_KEY_LEN: usize = 32;
25
26/// SQL schema for the client public keys table. Idempotent — safe to run
27/// on every [`PersistentClientKeyStore::new`].
28const 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/// The kind of actor a registered client key belongs to.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ActorKind {
47    /// A human user (matches the `users` table in the auth backend).
48    User,
49    /// A programmatic API key (matches the `api_keys` table).
50    ApiKey,
51}
52
53impl ActorKind {
54    /// Database / wire representation of this actor kind.
55    #[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    /// Parses an [`ActorKind`] from its database / wire string form.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`SecretsError::Storage`] if `s` is not one of the
68    /// recognized values (`"user"` or `"api_key"`).
69    #[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/// A registered client public key bound to an actor.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ClientPublicKey {
84    /// Opaque, unique identifier (`ck_<32-hex>`). Surfaced to clients so
85    /// they can reference the key on subsequent requests.
86    pub key_id: String,
87    /// Whether the owning actor is a `User` or an `ApiKey`.
88    pub actor_kind: ActorKind,
89    /// Identifier of the owning actor (UUID/text, opaque to this crate).
90    pub actor_id: String,
91    /// The 32-byte X25519 public key bytes.
92    pub public_key: Vec<u8>,
93    /// Optional human-readable label (e.g. browser/device name).
94    pub label: Option<String>,
95    /// When the key was registered.
96    pub created_at: DateTime<Utc>,
97    /// Most recent successful use (sealed-box decrypt), if any.
98    pub last_used_at: Option<DateTime<Utc>>,
99    /// Soft-delete timestamp; `None` means the key is still active.
100    pub revoked_at: Option<DateTime<Utc>>,
101}
102
103/// Storage trait for SDK / browser client public keys.
104#[async_trait]
105pub trait ClientKeyStore: Send + Sync {
106    /// Registers a new public key for `actor_kind` / `actor_id`.
107    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    /// Looks up a key by its `key_id`. Returns the row regardless of
116    /// whether it has been revoked.
117    async fn get(&self, key_id: &str) -> Result<Option<ClientPublicKey>>;
118
119    /// Lists all *active* (non-revoked) keys for an actor, newest first.
120    async fn list_by_actor(
121        &self,
122        actor_kind: ActorKind,
123        actor_id: &str,
124    ) -> Result<Vec<ClientPublicKey>>;
125
126    /// Soft-deletes a key by setting `revoked_at`.
127    async fn revoke(&self, key_id: &str) -> Result<()>;
128
129    /// Updates the `last_used_at` timestamp on a key.
130    async fn touch_last_used(&self, key_id: &str) -> Result<()>;
131}
132
133/// SQLite-backed [`ClientKeyStore`].
134///
135/// Constructed from a [`SqlitePool`] so it can share the same database
136/// file as [`PersistentSecretsStore`](crate::PersistentSecretsStore).
137pub struct PersistentClientKeyStore {
138    pool: SqlitePool,
139}
140
141impl PersistentClientKeyStore {
142    /// Wraps `pool` and runs the schema migration.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`SecretsError::Storage`] if the migration fails.
147    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    /// Returns a borrowed reference to the underlying pool. Useful for
158    /// callers that want to compose multiple stores against one DB file.
159    #[must_use]
160    pub fn pool(&self) -> &SqlitePool {
161        &self.pool
162    }
163
164    /// Generates a fresh `ck_<32-hex>` key id.
165    fn generate_key_id() -> String {
166        let bytes: [u8; 16] = rand::random();
167        format!("ck_{}", hex::encode(bytes))
168    }
169
170    /// Format a [`DateTime<Utc>`] as RFC 3339 for `SQLite` TEXT storage.
171    /// Uses millisecond precision so rapidly-issued rows still sort
172    /// deterministically by `created_at`.
173    fn format_timestamp(ts: DateTime<Utc>) -> String {
174        ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
175    }
176
177    /// Parses an RFC 3339 timestamp from a row.
178    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    /// Hydrates a [`ClientPublicKey`] from a `SELECT *` style row.
185    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(&registered.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        // Unknown key id returns None.
407        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        // Force a second insert with the same key_id; the PRIMARY KEY
421        // constraint should reject it as a Storage error.
422        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(&registered.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        // Sleep between inserts so created_at strictly increases at
445        // millisecond resolution, making the DESC ordering deterministic.
446        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        // A different actor — must not leak in.
457        let _other = store
458            .register(ActorKind::User, "u2", &pk, Some("other"))
459            .await
460            .unwrap();
461        // A different actor kind with the same id — also must not leak.
462        let _other_kind = store
463            .register(ActorKind::ApiKey, "u1", &pk, Some("api"))
464            .await
465            .unwrap();
466
467        // Revoke `a` so only b and c remain active.
468        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        // Newest first ordering: c was registered after b.
473        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        // Active before revoke.
492        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        // Hidden from the active list.
502        let post = store
503            .list_by_actor(ActorKind::ApiKey, "svc-1")
504            .await
505            .unwrap();
506        assert!(post.is_empty());
507
508        // But `get` still returns it, with `revoked_at` populated.
509        let fetched = store.get(&key.key_id).await.unwrap().unwrap();
510        assert!(fetched.revoked_at.is_some());
511
512        // Revoking an unknown key is a NotFound.
513        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        // Nothing was inserted for any of the failed registers.
543        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        // Unknown id is NotFound.
564        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}