Skip to main content

codetopo_server/
keys.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! Runtime API key store (paid-path plan S1, gap G3).
6//!
7//! Keys live in a SQLite database in the data dir (`keys.db`), not in a
8//! flat file loaded once at startup: a key can be issued, rotated, and
9//! revoked while the server runs, by writing to the store — the admin
10//! subcommand (`codetopo-server keys …`) does exactly that, and the
11//! server's next verification sees the change with no restart.
12//!
13//! A secret is never stored. The row carries a per-key random salt and
14//! `SHA-256(salt ‖ secret)`; verification recomputes the hash of the
15//! presented secret and compares hashes with [`subtle::ConstantTimeEq`],
16//! so the wire contract (`Authorization: Bearer <key_id>:<secret>`) and
17//! its constant-time property are unchanged — only the storage moved.
18//!
19//! The legacy flat `key_id:secret` file is an import source, not a store:
20//! at startup, when the database holds no active key and the file exists,
21//! its entries are imported (hashed, plan [`SELF_HOSTED_PLAN`]). Existing
22//! single-host deployments therefore keep working untouched, and the file
23//! can be deleted once the import has happened. One value never imports:
24//! the documented example secret the README quickstart once printed
25//! ([`DOCUMENTED_EXAMPLE_SECRET`]) — it is public, so the import refuses
26//! any file still carrying it.
27//!
28//! Keys are provisioned with a `plan` name (the paid tier's plans arrive
29//! with the billing bridge; the store only records the string) and a
30//! lifecycle `status`: `active` or `revoked`. Only `active` keys verify.
31
32use std::path::{Path, PathBuf};
33use std::sync::{Mutex, MutexGuard};
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use rusqlite::{Connection, OptionalExtension};
37use sha2::{Digest, Sha256};
38use subtle::ConstantTimeEq;
39
40use crate::error::ApiError;
41
42/// File name of the key store inside the data dir.
43pub const STORE_FILE_NAME: &str = "keys.db";
44
45/// Plan recorded for keys that predate plans: the operator's own keys
46/// (legacy flat-file import, [`KeyStore::parse`]), not a billed tenant's.
47pub const SELF_HOSTED_PLAN: &str = "self-hosted";
48
49/// The secret the README quickstart printed as its example credential
50/// (`demo-key:change-me`) until the release-gate fix loop. It is public
51/// knowledge — anyone who has read the README knows it — so a keys file
52/// still carrying it would hand the server to the first caller who
53/// tries it. [`KeyStore::import_legacy_file`] refuses it by name
54/// (fleet release gate F-3).
55pub const DOCUMENTED_EXAMPLE_SECRET: &str = "change-me";
56
57/// Plan a key is issued with when the admin names none: the hosted tier
58/// from the paid-path plan.
59pub const DEFAULT_PLAN: &str = "hosted";
60
61/// Lifecycle status of a key that may authenticate.
62const STATUS_ACTIVE: &str = "active";
63/// Lifecycle status of a key that may no longer authenticate.
64const STATUS_REVOKED: &str = "revoked";
65
66/// What a successful verification yields: the key's identity and plan.
67///
68/// The secret is not reachable from here — handlers and the metering
69/// layer see only this, exactly as they saw only the `key_id` before.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct VerifiedKey {
72    /// The authenticated `key_id`.
73    pub key_id: String,
74    /// The plan recorded for the key at issuance.
75    pub plan: String,
76}
77
78/// One key's operator-visible state. Never carries a secret or a hash.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct KeySummary {
81    /// The key's identifier.
82    pub key_id: String,
83    /// The plan recorded for the key.
84    pub plan: String,
85    /// `active` or `revoked`.
86    pub status: String,
87    /// Unix seconds at issuance.
88    pub created_at: i64,
89    /// Unix seconds at the last rotation, if the secret was ever rotated.
90    pub rotated_at: Option<i64>,
91}
92
93/// One `api_keys` row, as verification reads it.
94struct KeyRow {
95    secret_hash: Vec<u8>,
96    salt: Vec<u8>,
97    plan: String,
98    status: String,
99}
100
101/// The key store: one SQLite connection, serialised behind a mutex.
102///
103/// Verification is one indexed lookup per request — trivial next to the
104/// whole-graph load every query already performs — and reading the store
105/// on every request is what makes runtime issuance, rotation, and
106/// revocation visible without a restart or an invalidation protocol.
107/// The database runs in WAL mode with a busy timeout so the admin
108/// subcommand can write while the server reads.
109#[derive(Debug)]
110pub struct KeyStore {
111    conn: Mutex<Connection>,
112}
113
114impl KeyStore {
115    /// Opens (creating if needed) the key store database at `path`.
116    pub fn open(path: &Path) -> Result<Self, ApiError> {
117        let conn = Connection::open(path).map_err(|err| {
118            ApiError::Internal(format!(
119                "cannot open key store {}: {err}",
120                path.display()
121            ))
122        })?;
123        conn.execute_batch(
124            "PRAGMA journal_mode = WAL;
125             PRAGMA busy_timeout = 5000;
126             CREATE TABLE IF NOT EXISTS api_keys (
127                 key_id      TEXT PRIMARY KEY,
128                 secret_hash BLOB NOT NULL,
129                 salt        BLOB NOT NULL,
130                 plan        TEXT NOT NULL,
131                 status      TEXT NOT NULL,
132                 created_at  INTEGER NOT NULL,
133                 rotated_at  INTEGER
134             );",
135        )
136        .map_err(|err| ApiError::Internal(format!("cannot initialise key store: {err}")))?;
137        Ok(KeyStore {
138            conn: Mutex::new(conn),
139        })
140    }
141
142    /// An in-memory store, for [`KeyStore::parse`] and tests.
143    fn in_memory() -> Result<Self, ApiError> {
144        let conn = Connection::open_in_memory()
145            .map_err(|err| ApiError::Internal(format!("cannot open in-memory key store: {err}")))?;
146        conn.execute_batch(
147            "CREATE TABLE IF NOT EXISTS api_keys (
148                 key_id      TEXT PRIMARY KEY,
149                 secret_hash BLOB NOT NULL,
150                 salt        BLOB NOT NULL,
151                 plan        TEXT NOT NULL,
152                 status      TEXT NOT NULL,
153                 created_at  INTEGER NOT NULL,
154                 rotated_at  INTEGER
155             );",
156        )
157        .map_err(|err| ApiError::Internal(format!("cannot initialise key store: {err}")))?;
158        Ok(KeyStore {
159            conn: Mutex::new(conn),
160        })
161    }
162
163    /// Builds a store from legacy flat-file contents.
164    ///
165    /// Reads `key_id:secret` lines, ignoring blank lines and `#` comments.
166    /// A line is split on the *first* `:` so a secret may itself contain
167    /// colons. Lines without a colon are skipped. Imported keys are
168    /// `active` and carry plan [`SELF_HOSTED_PLAN`]. Building an in-memory
169    /// SQLite database cannot fail in any way a caller could act on, so
170    /// this keeps the infallible signature the original parser had.
171    pub fn parse(contents: &str) -> Self {
172        let store = KeyStore::in_memory().expect("in-memory key store");
173        for (key_id, secret) in parse_pairs(contents) {
174            store
175                .insert(&key_id, &secret, SELF_HOSTED_PLAN)
176                .expect("insert into a fresh in-memory store");
177        }
178        store
179    }
180
181    /// Imports a legacy flat keys file into this store.
182    ///
183    /// Keys already present (by `key_id`) are left untouched — an import
184    /// must never resurrect a revoked key or overwrite a rotated secret.
185    /// Returns how many keys were newly imported.
186    ///
187    /// The import refuses the whole file, before importing anything,
188    /// when any entry carries [`DOCUMENTED_EXAMPLE_SECRET`] — the
189    /// example credential the README quickstart used to print. That
190    /// value is published, so importing it would provision a key whose
191    /// secret every reader of the old README knows (gate F-3). Failing
192    /// the whole import, rather than skipping the one entry, keeps the
193    /// refusal loud: a half-imported file that boots with fewer keys
194    /// than the operator wrote is a quieter failure than not booting.
195    pub fn import_legacy_file(&self, path: &Path) -> Result<usize, ApiError> {
196        let contents = std::fs::read_to_string(path).map_err(|err| {
197            ApiError::Internal(format!("cannot read keys file {}: {err}", path.display()))
198        })?;
199        let pairs = parse_pairs(&contents);
200        if let Some((key_id, _)) = pairs
201            .iter()
202            .find(|(_, secret)| secret == DOCUMENTED_EXAMPLE_SECRET)
203        {
204            return Err(ApiError::Internal(format!(
205                "keys file {} carries key '{key_id}' with the documented example secret \
206                 '{DOCUMENTED_EXAMPLE_SECRET}' — that value was printed in the README \
207                 quickstart and is public; generate a real secret instead \
208                 (e.g. `openssl rand -hex 16`)",
209                path.display()
210            )));
211        }
212        let mut imported = 0;
213        for (key_id, secret) in pairs {
214            if self.lookup(&key_id)?.is_none() {
215                self.insert(&key_id, &secret, SELF_HOSTED_PLAN)?;
216                imported += 1;
217            }
218        }
219        Ok(imported)
220    }
221
222    /// Issues a new key and returns its secret — the only time the secret
223    /// exists outside the caller's hands, so the caller must record it
224    /// now. Fails if `key_id` is already taken or is unusable in a bearer
225    /// token (empty, or containing `:` or whitespace).
226    pub fn issue(&self, key_id: &str, plan: &str) -> Result<String, ApiError> {
227        validate_key_id(key_id)?;
228        if self.lookup(key_id)?.is_some() {
229            return Err(ApiError::BadRequest {
230                code: "key_exists",
231                message: format!("key '{key_id}' already exists"),
232            });
233        }
234        let secret = generate_secret();
235        self.insert(key_id, &secret, plan)?;
236        Ok(secret)
237    }
238
239    /// Replaces a key's secret and returns the new one. The old secret
240    /// stops verifying with this call. The key's plan, status, and
241    /// creation time are unchanged. A revoked key cannot be rotated back
242    /// to life — issue a new key instead.
243    pub fn rotate(&self, key_id: &str) -> Result<String, ApiError> {
244        let conn = self.lock()?;
245        let status = conn
246            .query_row(
247                "SELECT status FROM api_keys WHERE key_id = ?1",
248                rusqlite::params![key_id],
249                |row| row.get::<_, String>(0),
250            )
251            .optional()
252            .map_err(|err| ApiError::Internal(format!("cannot read key store: {err}")))?;
253        match status.as_deref() {
254            None => return Err(unknown_key(key_id)),
255            Some(STATUS_ACTIVE) => {}
256            Some(_) => {
257                return Err(ApiError::BadRequest {
258                    code: "key_revoked",
259                    message: format!("key '{key_id}' is revoked and cannot be rotated"),
260                })
261            }
262        }
263        let secret = generate_secret();
264        let salt = new_salt();
265        let hash = hash_secret(&salt, &secret);
266        conn.execute(
267            "UPDATE api_keys SET secret_hash = ?2, salt = ?3, rotated_at = ?4 WHERE key_id = ?1",
268            rusqlite::params![key_id, hash, salt, now_unix_secs()],
269        )
270        .map_err(|err| ApiError::Internal(format!("cannot rotate key: {err}")))?;
271        Ok(secret)
272    }
273
274    /// Revokes a key: it stops verifying immediately. Idempotent for an
275    /// already-revoked key; an unknown `key_id` is an error, so a typo in
276    /// an admin command cannot look like a successful revocation.
277    pub fn revoke(&self, key_id: &str) -> Result<(), ApiError> {
278        let conn = self.lock()?;
279        let updated = conn
280            .execute(
281                "UPDATE api_keys SET status = ?2 WHERE key_id = ?1",
282                rusqlite::params![key_id, STATUS_REVOKED],
283            )
284            .map_err(|err| ApiError::Internal(format!("cannot revoke key: {err}")))?;
285        if updated == 0 {
286            return Err(unknown_key(key_id));
287        }
288        Ok(())
289    }
290
291    /// Every key's summary, ordered by `key_id`.
292    pub fn list(&self) -> Result<Vec<KeySummary>, ApiError> {
293        let conn = self.lock()?;
294        let mut statement = conn
295            .prepare(
296                "SELECT key_id, plan, status, created_at, rotated_at
297                 FROM api_keys ORDER BY key_id",
298            )
299            .map_err(|err| ApiError::Internal(format!("cannot read key store: {err}")))?;
300        let rows = statement
301            .query_map([], |row| {
302                Ok(KeySummary {
303                    key_id: row.get(0)?,
304                    plan: row.get(1)?,
305                    status: row.get(2)?,
306                    created_at: row.get(3)?,
307                    rotated_at: row.get(4)?,
308                })
309            })
310            .map_err(|err| ApiError::Internal(format!("cannot read key store: {err}")))?;
311        let mut summaries = Vec::new();
312        for row in rows {
313            summaries.push(
314                row.map_err(|err| ApiError::Internal(format!("cannot read key store: {err}")))?,
315            );
316        }
317        Ok(summaries)
318    }
319
320    /// Number of provisioned keys, in any status.
321    pub fn len(&self) -> usize {
322        self.count("SELECT COUNT(*) FROM api_keys")
323    }
324
325    /// True when no key is provisioned at all.
326    pub fn is_empty(&self) -> bool {
327        self.len() == 0
328    }
329
330    /// Number of keys that can authenticate right now.
331    pub fn active_len(&self) -> usize {
332        self.count("SELECT COUNT(*) FROM api_keys WHERE status = 'active'")
333    }
334
335    /// Checks a presented token and returns the key's identity and plan.
336    ///
337    /// An unknown `key_id`, a wrong secret, and a revoked key are all the
338    /// same `401`, and the work done in each case is roughly equal (an
339    /// unknown key still burns a hash and a comparison), so the response
340    /// does not reveal which keys exist or why one failed.
341    pub fn verify(&self, key_id: &str, secret: &str) -> Result<VerifiedKey, ApiError> {
342        let row = self.lookup(key_id)?;
343        let Some(row) = row else {
344            // Burn the same work as a real verification — one hash over a
345            // fresh random salt and one constant-time comparison — so an
346            // unknown key is not measurably cheaper than a wrong secret.
347            let dummy = hash_secret(&new_salt(), secret);
348            let _ = dummy.as_slice().ct_eq(&[0u8; 32]);
349            return Err(ApiError::Unauthorized);
350        };
351        let presented = hash_secret(&row.salt, secret);
352        let matched: bool = row.secret_hash.as_slice().ct_eq(presented.as_slice()).into();
353        if row.status == STATUS_ACTIVE && matched {
354            Ok(VerifiedKey {
355                key_id: key_id.to_string(),
356                plan: row.plan,
357            })
358        } else {
359            Err(ApiError::Unauthorized)
360        }
361    }
362
363    /// Reads one key row, if the key exists.
364    fn lookup(&self, key_id: &str) -> Result<Option<KeyRow>, ApiError> {
365        let conn = self.lock()?;
366        conn.query_row(
367            "SELECT secret_hash, salt, plan, status FROM api_keys WHERE key_id = ?1",
368            rusqlite::params![key_id],
369            |row| {
370                Ok(KeyRow {
371                    secret_hash: row.get(0)?,
372                    salt: row.get(1)?,
373                    plan: row.get(2)?,
374                    status: row.get(3)?,
375                })
376            },
377        )
378        .optional()
379        .map_err(|err| ApiError::Internal(format!("cannot read key store: {err}")))
380    }
381
382    /// Inserts a key with a plaintext secret, storing only its hash.
383    fn insert(&self, key_id: &str, secret: &str, plan: &str) -> Result<(), ApiError> {
384        let salt = new_salt();
385        let hash = hash_secret(&salt, secret);
386        let conn = self.lock()?;
387        conn.execute(
388            "INSERT INTO api_keys (key_id, secret_hash, salt, plan, status, created_at, rotated_at)
389             VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)",
390            rusqlite::params![key_id, hash, salt, plan, STATUS_ACTIVE, now_unix_secs()],
391        )
392        .map_err(|err| ApiError::Internal(format!("cannot write key store: {err}")))?;
393        Ok(())
394    }
395
396    /// Runs a `COUNT(*)` query, treating a store failure as zero. The
397    /// count only feeds startup diagnostics and [`KeyStore::len`]; the
398    /// fail-closed decision lives in `Config::prepare`, which checks the
399    /// count it gets — a store that cannot be counted cannot serve
400    /// either, and zero sends startup down the fail-closed path.
401    fn count(&self, sql: &str) -> usize {
402        let Ok(conn) = self.lock() else {
403            return 0;
404        };
405        conn.query_row(sql, [], |row| row.get::<_, i64>(0))
406            .map(|n| n.max(0) as usize)
407            .unwrap_or(0)
408    }
409
410    fn lock(&self) -> Result<MutexGuard<'_, Connection>, ApiError> {
411        self.conn
412            .lock()
413            .map_err(|_| ApiError::Internal("key store lock poisoned".to_string()))
414    }
415}
416
417/// Splits `Authorization: Bearer <key_id>:<secret>` into its two parts.
418///
419/// The header is case-insensitive on the scheme. Returns `None` for a missing
420/// header, a non-`Bearer` scheme, or a token without a `:`, so the caller can
421/// answer with a single `401` in every case.
422pub fn parse_bearer(header: Option<&str>) -> Option<(&str, &str)> {
423    let raw = header?.trim();
424    let (scheme, token) = raw.split_once(char::is_whitespace)?;
425    if !scheme.eq_ignore_ascii_case("bearer") {
426        return None;
427    }
428    let token = token.trim();
429    let (key_id, secret) = token.split_once(':')?;
430    let key_id = key_id.trim();
431    let secret = secret.trim();
432    if key_id.is_empty() || secret.is_empty() {
433        return None;
434    }
435    Some((key_id, secret))
436}
437
438/// Convenience: where the legacy flat keys file lives by default.
439pub fn default_keys_file() -> PathBuf {
440    PathBuf::from("./api_keys")
441}
442
443/// Parses legacy flat-file contents into `(key_id, secret)` pairs.
444///
445/// Blank lines and `#` comments are ignored; a line splits on its first
446/// `:`; a repeated `key_id` keeps its last secret, matching the map the
447/// original parser built.
448fn parse_pairs(contents: &str) -> Vec<(String, String)> {
449    let mut pairs: std::collections::HashMap<String, String> = std::collections::HashMap::new();
450    for line in contents.lines() {
451        let line = line.trim();
452        if line.is_empty() || line.starts_with('#') {
453            continue;
454        }
455        if let Some((key_id, secret)) = line.split_once(':') {
456            let key_id = key_id.trim();
457            let secret = secret.trim();
458            if !key_id.is_empty() && !secret.is_empty() {
459                pairs.insert(key_id.to_string(), secret.to_string());
460            }
461        }
462    }
463    pairs.into_iter().collect()
464}
465
466/// `SHA-256(salt ‖ secret)` — the only form in which a secret is stored.
467fn hash_secret(salt: &[u8], secret: &str) -> Vec<u8> {
468    let mut hasher = Sha256::new();
469    hasher.update(salt);
470    hasher.update(secret.as_bytes());
471    hasher.finalize().to_vec()
472}
473
474/// A fresh per-key salt. A salt needs uniqueness, not secrecy; 128 bits
475/// from the UUID generator is far past sufficient.
476fn new_salt() -> Vec<u8> {
477    uuid::Uuid::new_v4().as_bytes().to_vec()
478}
479
480/// A fresh secret: 64 lowercase hex characters (two UUID v4s), ~244 bits
481/// of entropy, safe to print and to paste into a bearer token.
482fn generate_secret() -> String {
483    format!(
484        "{}{}",
485        uuid::Uuid::new_v4().simple(),
486        uuid::Uuid::new_v4().simple()
487    )
488}
489
490/// A `key_id` must survive the bearer format: non-empty, no `:`, no
491/// whitespace. Enforced at issuance so an unusable key is never created.
492fn validate_key_id(key_id: &str) -> Result<(), ApiError> {
493    if key_id.is_empty() || key_id.contains(':') || key_id.chars().any(char::is_whitespace) {
494        return Err(ApiError::BadRequest {
495            code: "invalid_key_id",
496            message: format!(
497                "invalid key_id '{key_id}': must be non-empty and contain no ':' or whitespace"
498            ),
499        });
500    }
501    Ok(())
502}
503
504/// 404 `unknown_key` for admin operations naming a key that does not exist.
505fn unknown_key(key_id: &str) -> ApiError {
506    ApiError::NotFound {
507        code: "unknown_key",
508        message: format!("unknown key '{key_id}'"),
509    }
510}
511
512/// Current time as Unix seconds; 0 if the clock predates the epoch.
513pub(crate) fn now_unix_secs() -> i64 {
514    SystemTime::now()
515        .duration_since(UNIX_EPOCH)
516        .map(|duration| duration.as_secs() as i64)
517        .unwrap_or(0)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    fn store() -> KeyStore {
525        KeyStore::in_memory().expect("in-memory store")
526    }
527
528    #[test]
529    fn parse_keeps_the_legacy_semantics() {
530        let store = KeyStore::parse("# comment\n\nagent:s3cr3t\nmulti:a:b:c\n");
531        assert_eq!(store.len(), 2);
532        let verified = store.verify("agent", "s3cr3t").expect("agent verifies");
533        assert_eq!(verified.key_id, "agent");
534        assert_eq!(verified.plan, SELF_HOSTED_PLAN);
535        // Split on the first colon: the rest is the secret.
536        assert!(store.verify("multi", "a:b:c").is_ok());
537        assert!(store.verify("agent", "wrong").is_err());
538    }
539
540    #[test]
541    fn issued_key_verifies_with_its_plan() {
542        let store = store();
543        let secret = store.issue("tenant-a", "hosted").expect("issue");
544        assert_eq!(secret.len(), 64, "secret is 64 hex chars");
545        assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
546        let verified = store.verify("tenant-a", &secret).expect("verifies");
547        assert_eq!(verified.plan, "hosted");
548        assert_eq!(store.active_len(), 1);
549    }
550
551    #[test]
552    fn issue_rejects_a_duplicate_and_an_unusable_id() {
553        let store = store();
554        store.issue("tenant-a", "hosted").expect("issue");
555        let err = store.issue("tenant-a", "hosted").expect_err("duplicate");
556        assert_eq!(err.code(), "key_exists");
557        for bad in ["", "has:colon", "has space"] {
558            let err = store.issue(bad, "hosted").expect_err("unusable id");
559            assert_eq!(err.code(), "invalid_key_id", "id {bad:?}");
560        }
561    }
562
563    #[test]
564    fn rotate_replaces_the_secret_and_keeps_the_plan() {
565        let store = store();
566        let old = store.issue("tenant-a", "hosted").expect("issue");
567        let new = store.rotate("tenant-a").expect("rotate");
568        assert_ne!(old, new);
569        assert!(matches!(
570            store.verify("tenant-a", &old),
571            Err(ApiError::Unauthorized)
572        ));
573        let verified = store.verify("tenant-a", &new).expect("new secret verifies");
574        assert_eq!(verified.plan, "hosted");
575        let summary = &store.list().expect("list")[0];
576        assert!(summary.rotated_at.is_some(), "rotation is recorded");
577        assert_eq!(summary.status, STATUS_ACTIVE);
578    }
579
580    #[test]
581    fn rotate_rejects_unknown_and_revoked_keys() {
582        let store = store();
583        assert_eq!(
584            store.rotate("ghost").expect_err("unknown").code(),
585            "unknown_key"
586        );
587        store.issue("tenant-a", "hosted").expect("issue");
588        store.revoke("tenant-a").expect("revoke");
589        assert_eq!(
590            store.rotate("tenant-a").expect_err("revoked").code(),
591            "key_revoked"
592        );
593    }
594
595    #[test]
596    fn revoke_stops_verification_immediately() {
597        let store = store();
598        let secret = store.issue("tenant-a", "hosted").expect("issue");
599        assert!(store.verify("tenant-a", &secret).is_ok());
600        store.revoke("tenant-a").expect("revoke");
601        assert!(matches!(
602            store.verify("tenant-a", &secret),
603            Err(ApiError::Unauthorized)
604        ));
605        assert_eq!(store.active_len(), 0);
606        assert_eq!(store.len(), 1, "the row remains for the record");
607        // Revoking again is not an error; revoking a ghost is.
608        store.revoke("tenant-a").expect("revoke is idempotent");
609        assert_eq!(
610            store.revoke("ghost").expect_err("unknown").code(),
611            "unknown_key"
612        );
613    }
614
615    #[test]
616    fn wrong_secret_and_unknown_key_are_both_unauthorized() {
617        let store = store();
618        store.issue("tenant-a", "hosted").expect("issue");
619        assert!(matches!(
620            store.verify("tenant-a", "not-the-secret"),
621            Err(ApiError::Unauthorized)
622        ));
623        assert!(matches!(
624            store.verify("ghost", "not-the-secret"),
625            Err(ApiError::Unauthorized)
626        ));
627    }
628
629    #[test]
630    fn secrets_are_never_stored_in_plaintext() {
631        let dir = tempfile::tempdir().expect("temp dir");
632        let path = dir.path().join("keys.db");
633        let store = KeyStore::open(&path).expect("open store");
634        let secret = store.issue("tenant-a", "hosted").expect("issue");
635        drop(store);
636        let bytes = std::fs::read(&path).expect("read store file");
637        let text = String::from_utf8_lossy(&bytes);
638        assert!(
639            !text.contains(&secret),
640            "the plaintext secret must not appear in the store file"
641        );
642        assert!(text.contains("tenant-a"), "the key_id is not a secret");
643    }
644
645    #[test]
646    fn a_file_store_survives_reopening() {
647        let dir = tempfile::tempdir().expect("temp dir");
648        let path = dir.path().join("keys.db");
649        let secret = {
650            let store = KeyStore::open(&path).expect("open");
651            store.issue("tenant-a", "hosted").expect("issue")
652        };
653        // A second handle on the same file — the admin subcommand beside
654        // a running server — sees the key with no restart anywhere.
655        let reopened = KeyStore::open(&path).expect("reopen");
656        assert!(reopened.verify("tenant-a", &secret).is_ok());
657        let rotated = reopened.rotate("tenant-a").expect("rotate");
658        let third = KeyStore::open(&path).expect("third handle");
659        assert!(third.verify("tenant-a", &rotated).is_ok());
660        assert!(third.verify("tenant-a", &secret).is_err());
661    }
662
663    #[test]
664    fn legacy_import_hashes_and_never_overwrites() {
665        let dir = tempfile::tempdir().expect("temp dir");
666        let file = dir.path().join("api_keys");
667        std::fs::write(&file, "agent:file-secret\nsecond:other-secret\n").expect("write file");
668        let store = store();
669        let existing = store.issue("agent", "hosted").expect("issue");
670
671        let imported = store.import_legacy_file(&file).expect("import");
672        assert_eq!(imported, 1, "only the new key is imported");
673        // The pre-existing key keeps its issued secret and plan; the file
674        // secret for the same id must not verify.
675        assert!(store.verify("agent", &existing).is_ok());
676        assert!(store.verify("agent", "file-secret").is_err());
677        let verified = store.verify("second", "other-secret").expect("imported key");
678        assert_eq!(verified.plan, SELF_HOSTED_PLAN);
679    }
680
681    /// F-3: a keys file still carrying the README's old documented
682    /// example credential is refused whole — the error names the value
683    /// as the documented example, and nothing from the file imports,
684    /// not even its well-formed entries.
685    #[test]
686    fn legacy_import_refuses_the_documented_example_secret() {
687        let dir = tempfile::tempdir().expect("temp dir");
688        let file = dir.path().join("api_keys");
689        std::fs::write(&file, "agent:real-secret\ndemo-key:change-me\n").expect("write file");
690        let store = store();
691
692        let err = store
693            .import_legacy_file(&file)
694            .expect_err("the documented example secret must be refused");
695        let message = err.to_string();
696        assert!(
697            message.contains("documented example secret"),
698            "the refusal names it as a documented example: {message}"
699        );
700        assert!(
701            message.contains("change-me"),
702            "the refusal names the value: {message}"
703        );
704        assert_eq!(store.len(), 0, "a refused import imports nothing");
705        assert!(store.verify("agent", "real-secret").is_err());
706    }
707
708    /// The refusal keys on the secret value, not the example's key id:
709    /// the published secret under any other name is the same hole.
710    #[test]
711    fn legacy_import_refuses_the_example_secret_under_any_key_id() {
712        let dir = tempfile::tempdir().expect("temp dir");
713        let file = dir.path().join("api_keys");
714        std::fs::write(&file, "my-own-key:change-me\n").expect("write file");
715        let store = store();
716        assert!(store.import_legacy_file(&file).is_err());
717        assert_eq!(store.len(), 0);
718    }
719
720    #[test]
721    fn list_reports_operator_state_without_secrets() {
722        let store = store();
723        store.issue("b-key", "hosted").expect("issue");
724        store.issue("a-key", SELF_HOSTED_PLAN).expect("issue");
725        store.revoke("b-key").expect("revoke");
726        let summaries = store.list().expect("list");
727        assert_eq!(summaries.len(), 2);
728        assert_eq!(summaries[0].key_id, "a-key", "ordered by key_id");
729        assert_eq!(summaries[1].key_id, "b-key");
730        assert_eq!(summaries[1].status, STATUS_REVOKED);
731        assert!(summaries.iter().all(|s| s.created_at > 0));
732        assert!(summaries.iter().all(|s| s.rotated_at.is_none()));
733    }
734}