1use 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
42pub const STORE_FILE_NAME: &str = "keys.db";
44
45pub const SELF_HOSTED_PLAN: &str = "self-hosted";
48
49pub const DOCUMENTED_EXAMPLE_SECRET: &str = "change-me";
56
57pub const DEFAULT_PLAN: &str = "hosted";
60
61const STATUS_ACTIVE: &str = "active";
63const STATUS_REVOKED: &str = "revoked";
65
66#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct VerifiedKey {
72 pub key_id: String,
74 pub plan: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct KeySummary {
81 pub key_id: String,
83 pub plan: String,
85 pub status: String,
87 pub created_at: i64,
89 pub rotated_at: Option<i64>,
91}
92
93struct KeyRow {
95 secret_hash: Vec<u8>,
96 salt: Vec<u8>,
97 plan: String,
98 status: String,
99}
100
101#[derive(Debug)]
110pub struct KeyStore {
111 conn: Mutex<Connection>,
112}
113
114impl KeyStore {
115 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 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 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 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 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 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 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 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 pub fn len(&self) -> usize {
322 self.count("SELECT COUNT(*) FROM api_keys")
323 }
324
325 pub fn is_empty(&self) -> bool {
327 self.len() == 0
328 }
329
330 pub fn active_len(&self) -> usize {
332 self.count("SELECT COUNT(*) FROM api_keys WHERE status = 'active'")
333 }
334
335 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 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 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 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 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
417pub 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
438pub fn default_keys_file() -> PathBuf {
440 PathBuf::from("./api_keys")
441}
442
443fn 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
466fn 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
474fn new_salt() -> Vec<u8> {
477 uuid::Uuid::new_v4().as_bytes().to_vec()
478}
479
480fn generate_secret() -> String {
483 format!(
484 "{}{}",
485 uuid::Uuid::new_v4().simple(),
486 uuid::Uuid::new_v4().simple()
487 )
488}
489
490fn 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
504fn unknown_key(key_id: &str) -> ApiError {
506 ApiError::NotFound {
507 code: "unknown_key",
508 message: format!("unknown key '{key_id}'"),
509 }
510}
511
512pub(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 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 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 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 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 #[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 #[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}