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 · Built with Muse by Meta.
4
5//! API keys loaded from a newline-delimited `key_id:secret` file.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use subtle::ConstantTimeEq;
11
12use crate::error::ApiError;
13
14/// An in-memory set of `key_id` → `secret` pairs loaded at startup.
15///
16/// Secrets are compared with [`subtle::ConstantTimeEq`] so a caller cannot
17/// learn a valid key one byte at a time by measuring how long a rejection
18/// takes. Only the `key_id` is ever recorded in the metering log.
19#[derive(Debug, Clone)]
20pub struct KeyStore {
21 secrets: HashMap<String, String>,
22}
23
24impl KeyStore {
25 /// Reads `key_id:secret` lines, ignoring blank lines and `#` comments.
26 ///
27 /// A line is split on the *first* `:` so a secret may itself contain
28 /// colons. Lines without a colon are skipped; they are reported by
29 /// [`KeyStore::load`] so a malformed file is visible at startup.
30 pub fn parse(contents: &str) -> Self {
31 let mut secrets = HashMap::new();
32 for line in contents.lines() {
33 let line = line.trim();
34 if line.is_empty() || line.starts_with('#') {
35 continue;
36 }
37 if let Some((key_id, secret)) = line.split_once(':') {
38 let key_id = key_id.trim();
39 let secret = secret.trim();
40 if !key_id.is_empty() && !secret.is_empty() {
41 secrets.insert(key_id.to_string(), secret.to_string());
42 }
43 }
44 }
45 KeyStore { secrets }
46 }
47
48 /// Loads and parses the keys file at `path`.
49 ///
50 /// Fails closed (Phase 2 design §4): a missing file, an unreadable file, or
51 /// a file that yields no usable `key_id:secret` line is a startup error.
52 /// Starting with zero keys would serve 401 for every request while looking
53 /// healthy, which is harder to diagnose than refusing to boot.
54 pub fn load(path: &Path) -> Result<Self, ApiError> {
55 let contents = std::fs::read_to_string(path).map_err(|err| {
56 ApiError::Internal(format!("cannot read keys file {}: {err}", path.display()))
57 })?;
58 let store = KeyStore::parse(&contents);
59 if store.is_empty() {
60 return Err(ApiError::Internal(format!(
61 "keys file {} has no usable 'key_id:secret' line",
62 path.display()
63 )));
64 }
65 Ok(store)
66 }
67
68 /// Number of provisioned keys.
69 pub fn len(&self) -> usize {
70 self.secrets.len()
71 }
72
73 /// True when no key is provisioned, i.e. every request will be rejected.
74 pub fn is_empty(&self) -> bool {
75 self.secrets.is_empty()
76 }
77
78 /// Checks a presented token and returns its `key_id` on success.
79 ///
80 /// Both an unknown `key_id` and a wrong secret are rejected with the same
81 /// error, and the work done in each case is roughly equal, so the response
82 /// does not reveal which keys exist.
83 pub fn verify(&self, key_id: &str, secret: &str) -> Result<String, ApiError> {
84 let expected = self.secrets.get(key_id);
85 let Some(expected) = expected else {
86 // Still burn a comparison so an unknown key is not faster.
87 let _ = "unauthenticated".as_bytes().ct_eq(secret.as_bytes());
88 return Err(ApiError::Unauthorized);
89 };
90 if expected.as_bytes().ct_eq(secret.as_bytes()).into() {
91 Ok(key_id.to_string())
92 } else {
93 Err(ApiError::Unauthorized)
94 }
95 }
96}
97
98/// Splits `Authorization: Bearer <key_id>:<secret>` into its two parts.
99///
100/// The header is case-insensitive on the scheme. Returns `None` for a missing
101/// header, a non-`Bearer` scheme, or a token without a `:`, so the caller can
102/// answer with a single `401` in every case.
103pub fn parse_bearer(header: Option<&str>) -> Option<(&str, &str)> {
104 let raw = header?.trim();
105 let (scheme, token) = raw.split_once(char::is_whitespace)?;
106 if !scheme.eq_ignore_ascii_case("bearer") {
107 return None;
108 }
109 let token = token.trim();
110 let (key_id, secret) = token.split_once(':')?;
111 let key_id = key_id.trim();
112 let secret = secret.trim();
113 if key_id.is_empty() || secret.is_empty() {
114 return None;
115 }
116 Some((key_id, secret))
117}
118
119/// Convenience: where the keys file lives by default.
120pub fn default_keys_file() -> PathBuf {
121 PathBuf::from("./api_keys")
122}