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 · 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}