Skip to main content

memra_server/
auth.rs

1//! API-key management (lane/api-keys, 2026-08-05): multi-key bearer auth that maps
2//! key -> tenant, so metering, QoS lane class, and prefix-cache isolation key off a real
3//! tenant identity instead of one shared trust domain.
4//!
5//! DESIGN (launch-shaped, not enterprise-shaped):
6//!   - Keyring source: `MEMRA_API_KEYS` — a TOML file path (`[[keys]]` entries, see
7//!     `KeyEntry`) or an inline env list `tenant:sha256hex[:lane],...` for
8//!     file-less deploys. Keys are stored as SHA-256 hex ONLY — the plaintext exists
9//!     exactly once, on the `--gen-key` terminal.
10//!   - Hot reload: mtime-poll (default 2s throttle) on every lookup — chosen over SIGHUP
11//!     because it needs no signal thread and cannot be missed; a bad reload keeps the old
12//!     ring and logs loudly (auth never degrades to open because of a typo).
13//!   - Back-compat: `MEMRA_API_KEY` (the single static bearer — the owner's daily driver
14//!     and every serve script) keeps working unchanged as tenant `"default"`, with or
15//!     without a keyring configured. No keyring + no single key = open (dev behavior).
16//!   - Tenant -> cache namespace: when a keyring is configured, every request's PC-ISO
17//!     namespace is `t:<tenant>\x1f<cache_salt>` (see `scope_namespace`). Tenant ids are
18//!     validated `[A-Za-z0-9_-]+`, so the `\x1f` separator cannot be forged from a
19//!     client-controlled `cache_salt` — cross-tenant cache probing is structurally
20//!     impossible. Keyring ABSENT keeps the raw-salt namespace, byte-identical to PC-ISO.
21//!   - Lane class: a key is `interactive` (default) or `batch`. Batch-class keys default
22//!     to the harvest QoS lane and are refused the protected interactive lane (403, loud
23//!     — never a silent downgrade, per the honesty doctrine).
24//!   - Per-key `rate_limit`: optional concurrency-slot override; the effective cap is
25//!     min(override, global lane cap) — the global cap stays authoritative.
26//!
27//! CLI (`--gen-key` / `--revoke-key`, see `run_cli`): prints the plaintext once and
28//! appends the hash entry; revoke flips `enabled = false` by key prefix. No web UI.
29
30use std::collections::HashSet;
31use std::io::Read;
32use std::path::{Path, PathBuf};
33use std::sync::RwLock;
34use std::time::{Duration, Instant, SystemTime};
35
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38
39/// The namespace separator between tenant id and client salt. Tenant ids are validated
40/// `[A-Za-z0-9_-]+`, so no client-controlled string can produce a colliding namespace.
41const NS_SEP: char = '\u{1f}';
42
43/// SHA-256 of a plaintext key, lower-case hex — the only form a key is ever stored in.
44pub fn sha256_hex(key: &str) -> String {
45    sha256_digest(key)
46        .iter()
47        .map(|b| format!("{b:02x}"))
48        .collect()
49}
50
51fn sha256_digest(key: &str) -> [u8; 32] {
52    let mut h = Sha256::new();
53    h.update(key.as_bytes());
54    let digest = h.finalize();
55    let mut out = [0u8; 32];
56    out.copy_from_slice(&digest);
57    out
58}
59
60/// Compare secrets without length- or prefix-dependent early exit. Hashing first gives
61/// the comparison a fixed 32-byte shape even when callers supply different-length values.
62pub fn constant_time_secret_eq(left: &str, right: &str) -> bool {
63    constant_time_digest_eq(&sha256_digest(left), &sha256_digest(right))
64}
65
66fn constant_time_digest_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
67    let mut different = 0u8;
68    for i in 0..left.len() {
69        different |= left[i] ^ right[i];
70    }
71    different == 0
72}
73
74/// A key's QoS lane class. `Interactive` keys behave exactly like pre-lane traffic
75/// (default lane interactive, any `x-lane` honored). `Batch` keys default to the
76/// harvest lane and may not claim the protected interactive lane.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum LaneClass {
79    #[default]
80    Interactive,
81    Batch,
82}
83
84impl LaneClass {
85    pub fn parse(v: &str) -> Option<LaneClass> {
86        match v {
87            "interactive" => Some(LaneClass::Interactive),
88            "batch" => Some(LaneClass::Batch),
89            _ => None,
90        }
91    }
92    pub fn as_str(&self) -> &'static str {
93        match self {
94            LaneClass::Interactive => "interactive",
95            LaneClass::Batch => "batch",
96        }
97    }
98}
99
100/// One keyring entry — the on-disk TOML shape (`[[keys]]`).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct KeyEntry {
103    /// Identification prefix of the plaintext key (safe to store/display; the revoke
104    /// handle). `mk-<tenant>-<first 12 hex>` for generated keys.
105    #[serde(default)]
106    pub prefix: String,
107    /// SHA-256 hex of the full plaintext key. Never the plaintext.
108    pub sha256: String,
109    pub tenant: String,
110    /// "interactive" (default) | "batch".
111    #[serde(default)]
112    pub lane: Option<String>,
113    /// enabled=false = revoked: the key authenticates as DISABLED (403), distinct from
114    /// unknown (401) so a revoked caller gets an actionable error.
115    #[serde(default = "default_true")]
116    pub enabled: bool,
117    /// Optional per-key concurrency-slot override; effective cap is
118    /// min(rate_limit, global lane cap).
119    #[serde(default)]
120    pub rate_limit: Option<usize>,
121    /// Unix seconds at generation (informational).
122    #[serde(default)]
123    pub created_unix: Option<u64>,
124}
125
126fn default_true() -> bool {
127    true
128}
129
130#[derive(Debug, Serialize, Deserialize, Default)]
131struct KeyFile {
132    #[serde(default)]
133    keys: Vec<KeyEntry>,
134}
135
136/// The resolved identity a request acts as — what flows to cache scoping, lane
137/// admission, rate-limit headers, and the usage/meter log line.
138#[derive(Debug, Clone, PartialEq)]
139pub struct TenantCtx {
140    pub tenant: String,
141    pub lane_class: LaneClass,
142    pub rate_limit: Option<usize>,
143}
144
145impl TenantCtx {
146    /// The single-key / open-server identity: tenant "default", interactive, no override.
147    pub fn default_tenant() -> Self {
148        TenantCtx {
149            tenant: "default".into(),
150            lane_class: LaneClass::Interactive,
151            rate_limit: None,
152        }
153    }
154}
155
156/// Why a presented key was refused. `Unknown` -> 401, `Disabled` -> 403.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum AuthDenied {
159    Unknown,
160    Disabled,
161}
162
163#[derive(Debug)]
164struct StoredKey {
165    digest: [u8; 32],
166    entry: KeyEntry,
167}
168
169/// Parsed keyring. Request-time lookup scans fixed-length digests with constant-time
170/// equality rather than relying on short-circuit String/HashMap key comparison.
171#[derive(Debug, Default)]
172pub struct Keyring {
173    keys: Vec<StoredKey>,
174}
175
176fn valid_tenant(t: &str) -> bool {
177    !t.is_empty()
178        && t.chars()
179            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
180}
181
182pub fn tenant_is_valid(tenant: &str) -> bool {
183    valid_tenant(tenant)
184}
185
186fn valid_sha256(s: &str) -> bool {
187    s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
188}
189
190fn parse_sha256(s: &str) -> Option<[u8; 32]> {
191    if !valid_sha256(s) {
192        return None;
193    }
194    let mut digest = [0u8; 32];
195    for (i, byte) in digest.iter_mut().enumerate() {
196        *byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
197    }
198    Some(digest)
199}
200
201impl Keyring {
202    /// Build from entries, validating every field (a keyring with a malformed entry is
203    /// refused whole — auth config errors must be loud, never partially applied).
204    pub fn from_entries(entries: Vec<KeyEntry>) -> Result<Keyring, String> {
205        let mut seen = HashSet::with_capacity(entries.len());
206        let mut keys = Vec::with_capacity(entries.len());
207        for (i, mut e) in entries.into_iter().enumerate() {
208            if !valid_tenant(&e.tenant) {
209                return Err(format!(
210                    "key entry {i}: bad tenant {:?} (want [A-Za-z0-9_-]+)",
211                    e.tenant
212                ));
213            }
214            e.sha256 = e.sha256.to_lowercase();
215            if !valid_sha256(&e.sha256) {
216                return Err(format!(
217                    "key entry {i} (tenant {:?}): sha256 must be 64 hex chars",
218                    e.tenant
219                ));
220            }
221            if let Some(lane) = e.lane.as_deref() {
222                if LaneClass::parse(lane).is_none() {
223                    return Err(format!(
224                        "key entry {i} (tenant {:?}): bad lane {lane:?} (interactive|batch)",
225                        e.tenant
226                    ));
227                }
228            }
229            if e.rate_limit == Some(0) {
230                return Err(format!(
231                    "key entry {i} (tenant {:?}): rate_limit 0 would admit nothing — \
232                     use enabled = false to revoke",
233                    e.tenant
234                ));
235            }
236            let digest = parse_sha256(&e.sha256).expect("validated SHA-256 hex");
237            if !seen.insert(digest) {
238                return Err(format!(
239                    "key entry {i}: duplicate sha256 (same key listed twice)"
240                ));
241            }
242            keys.push(StoredKey { digest, entry: e });
243        }
244        Ok(Keyring { keys })
245    }
246
247    /// Parse the TOML file form.
248    pub fn from_toml(text: &str) -> Result<Keyring, String> {
249        let f: KeyFile = toml::from_str(text).map_err(|e| format!("keys.toml parse: {e}"))?;
250        Keyring::from_entries(f.keys)
251    }
252
253    /// Parse the inline env-list form: `tenant:sha256hex[:lane],...` — the file-less
254    /// fallback. Revocation in this form = remove the entry (no disabled state).
255    pub fn from_inline(spec: &str) -> Result<Keyring, String> {
256        let mut entries = Vec::new();
257        for part in spec.split(',').filter(|s| !s.trim().is_empty()) {
258            let fields: Vec<&str> = part.trim().split(':').collect();
259            if fields.len() < 2 || fields.len() > 3 {
260                return Err(format!(
261                    "bad MEMRA_API_KEYS inline entry {part:?} (want tenant:sha256hex[:lane])"
262                ));
263            }
264            entries.push(KeyEntry {
265                prefix: String::new(),
266                sha256: fields[1].to_string(),
267                tenant: fields[0].to_string(),
268                lane: fields.get(2).map(|s| s.to_string()),
269                enabled: true,
270                rate_limit: None,
271                created_unix: None,
272            });
273        }
274        if entries.is_empty() {
275            return Err("MEMRA_API_KEYS inline list is empty".into());
276        }
277        Keyring::from_entries(entries)
278    }
279
280    pub fn len(&self) -> usize {
281        self.keys.len()
282    }
283
284    /// Authenticate a plaintext bearer key against the ring.
285    pub fn lookup(&self, key: &str) -> Result<TenantCtx, AuthDenied> {
286        let digest = sha256_digest(key);
287        let mut matched = None;
288        for stored in &self.keys {
289            if constant_time_digest_eq(&stored.digest, &digest) {
290                matched = Some(&stored.entry);
291            }
292        }
293        match matched {
294            None => Err(AuthDenied::Unknown),
295            Some(e) if !e.enabled => Err(AuthDenied::Disabled),
296            Some(e) => Ok(TenantCtx {
297                tenant: e.tenant.clone(),
298                lane_class: e
299                    .lane
300                    .as_deref()
301                    .and_then(LaneClass::parse)
302                    .unwrap_or_default(),
303                rate_limit: e.rate_limit,
304            }),
305        }
306    }
307}
308
309/// The live keyring: source + hot-reload state. File-backed rings re-stat on lookup
310/// (throttled to `poll`) and swap in the new ring when mtime moves; a reload that fails
311/// to parse KEEPS the old ring and logs the error (never fail-open, never flap).
312pub struct KeyStore {
313    source: Source,
314    poll: Duration,
315    state: RwLock<State>,
316}
317
318enum Source {
319    File(PathBuf),
320    Inline,
321}
322
323struct State {
324    ring: Keyring,
325    mtime: Option<SystemTime>,
326    checked: Instant,
327}
328
329fn file_mtime(p: &Path) -> Option<SystemTime> {
330    std::fs::symlink_metadata(p).and_then(|m| m.modified()).ok()
331}
332
333fn validate_private_keyring_metadata(
334    file: &std::fs::File,
335    path: &Path,
336) -> Result<SystemTime, String> {
337    let metadata = file
338        .metadata()
339        .map_err(|e| format!("stat keyring {}: {e}", path.display()))?;
340    if !metadata.is_file() {
341        return Err(format!("keyring {} is not a regular file", path.display()));
342    }
343    #[cfg(unix)]
344    {
345        use std::os::unix::fs::{MetadataExt, PermissionsExt};
346        let mode = metadata.permissions().mode() & 0o777;
347        if mode & 0o137 != 0 {
348            return Err(format!(
349                "keyring {} must have 0600 or 0640-class permissions; found {mode:04o}",
350                path.display()
351            ));
352        }
353        let expected_uid = unsafe { libc::geteuid() } as u32;
354        if metadata.uid() != expected_uid {
355            return Err(format!(
356                "keyring {} is not owned by the service uid {} (found {})",
357                path.display(),
358                expected_uid,
359                metadata.uid()
360            ));
361        }
362        if metadata.nlink() != 1 {
363            return Err(format!(
364                "keyring {} has {} hard links; expected exactly one",
365                path.display(),
366                metadata.nlink()
367            ));
368        }
369    }
370    metadata
371        .modified()
372        .map_err(|e| format!("stat keyring {}: {e}", path.display()))
373}
374
375/// Read a file-backed keyring without following a final symlink and only accept a private,
376/// single-link regular file owned by the service account. This is checked on every startup and
377/// hot reload; a valid TOML document is not sufficient authorization state if an untrusted local
378/// user can redirect or replace the path.
379fn read_private_keyring(path: &Path) -> Result<(String, SystemTime), String> {
380    use std::fs::OpenOptions;
381    use std::os::unix::fs::OpenOptionsExt;
382    let file = OpenOptions::new()
383        .read(true)
384        .custom_flags(libc::O_NOFOLLOW)
385        .open(path)
386        .map_err(|e| format!("{}: {e}", path.display()))?;
387    let mtime = validate_private_keyring_metadata(&file, path)?;
388    let mut text = String::new();
389    (&file)
390        .take(8 * 1024 * 1024 + 1)
391        .read_to_string(&mut text)
392        .map_err(|e| format!("read keyring {}: {e}", path.display()))?;
393    if text.len() > 8 * 1024 * 1024 {
394        return Err(format!(
395            "keyring {} exceeds the 8 MiB limit",
396            path.display()
397        ));
398    }
399    Ok((text, mtime))
400}
401
402impl KeyStore {
403    /// Resolve the `MEMRA_API_KEYS` value: an existing file path loads as TOML;
404    /// otherwise a value containing ':' parses as the inline list; anything else is a
405    /// loud config error (a mistyped path must not silently become an empty ring).
406    pub fn from_spec(spec: &str) -> Result<KeyStore, String> {
407        let p = Path::new(spec);
408        if p.is_file() {
409            let (text, mtime) =
410                read_private_keyring(p).map_err(|e| format!("MEMRA_API_KEYS {spec:?}: {e}"))?;
411            let ring = Keyring::from_toml(&text).map_err(|e| format!("{spec}: {e}"))?;
412            let n = ring.len();
413            eprintln!("[auth] keyring loaded: {n} key(s) from {spec}");
414            return Ok(KeyStore {
415                source: Source::File(p.to_path_buf()),
416                poll: Duration::from_secs(2),
417                state: RwLock::new(State {
418                    ring,
419                    mtime: Some(mtime),
420                    checked: Instant::now(),
421                }),
422            });
423        }
424        if spec.contains(':') {
425            let ring = Keyring::from_inline(spec)?;
426            eprintln!("[auth] keyring loaded: {} inline key(s)", ring.len());
427            return Ok(KeyStore {
428                source: Source::Inline,
429                poll: Duration::from_secs(2),
430                state: RwLock::new(State {
431                    ring,
432                    mtime: None,
433                    checked: Instant::now(),
434                }),
435            });
436        }
437        Err(format!(
438            "MEMRA_API_KEYS={spec:?} is neither an existing keys.toml path nor an inline \
439             tenant:sha256hex list"
440        ))
441    }
442
443    /// Override how often the key file is re-statted for hot reload. Public as a
444    /// real knob: deployment-side tests (and unusual deployments) set it; the
445    /// default poll is right for production.
446    pub fn with_poll(mut self, poll: Duration) -> KeyStore {
447        self.poll = poll;
448        self
449    }
450
451    /// `pub`: a deployment admin surface provisions keys against this file.
452    pub fn file_path(&self) -> Option<&Path> {
453        match &self.source {
454            Source::File(path) => Some(path),
455            Source::Inline => None,
456        }
457    }
458
459    /// Hot reload: if the file's mtime moved since the last (throttled) check, swap in
460    /// the re-parsed ring. Parse failure keeps the old ring and logs.
461    fn maybe_reload(&self) {
462        let Source::File(path) = &self.source else {
463            return;
464        };
465        {
466            let st = self.state.read().unwrap();
467            if st.checked.elapsed() < self.poll {
468                return;
469            }
470        }
471        let mut st = self.state.write().unwrap();
472        if st.checked.elapsed() < self.poll {
473            return; // another thread just did the check
474        }
475        st.checked = Instant::now();
476        let mtime = file_mtime(path);
477        if mtime == st.mtime {
478            return;
479        }
480        match read_private_keyring(path)
481            .and_then(|(text, mtime)| Keyring::from_toml(&text).map(|ring| (ring, mtime)))
482        {
483            Ok((ring, mtime)) => {
484                eprintln!(
485                    "[auth] keyring reloaded: {} key(s) from {}",
486                    ring.len(),
487                    path.display()
488                );
489                st.ring = ring;
490                st.mtime = Some(mtime);
491            }
492            Err(e) => {
493                eprintln!("[auth] keyring reload FAILED ({e}); keeping the previous ring");
494                st.mtime = mtime; // don't re-log every poll until the file changes again
495            }
496        }
497    }
498
499    pub fn lookup(&self, key: &str) -> Result<TenantCtx, AuthDenied> {
500        self.maybe_reload();
501        self.state.read().unwrap().ring.lookup(key)
502    }
503}
504
505/// Process-global keystore, initialized once from `MEMRA_API_KEYS` at startup
506/// (`init_from_env` — a bad config is a startup FATAL, not a per-request surprise).
507static KEYSTORE: std::sync::OnceLock<Option<KeyStore>> = std::sync::OnceLock::new();
508
509/// Called once from main() before serving. Exits the process on a bad config.
510pub fn init_from_env() {
511    KEYSTORE.get_or_init(|| match std::env::var("MEMRA_API_KEYS") {
512        Err(_) => None,
513        Ok(spec) => match KeyStore::from_spec(&spec) {
514            Ok(ks) => Some(ks),
515            Err(e) => {
516                eprintln!("[auth] FATAL: {e}");
517                std::process::exit(1);
518            }
519        },
520    });
521}
522
523/// The global keystore, if `MEMRA_API_KEYS` configured one.
524pub fn global() -> Option<&'static KeyStore> {
525    KEYSTORE.get().and_then(|o| o.as_ref())
526}
527
528/// The full auth law, pure over its inputs (unit-testable without env):
529///   keyring key match      -> that key's tenant (or Disabled -> 403)
530///   single static key match-> tenant "default" (the back-compat daily driver)
531///   nothing configured     -> open, tenant "default"
532///   anything else          -> Unknown -> 401
533/// The keyring and the single key COMPOSE: setting MEMRA_API_KEYS does not break
534/// MEMRA_API_KEY callers (the owner's serve scripts keep working unchanged).
535pub fn authenticate_with(
536    keyring: Option<&KeyStore>,
537    single_key: Option<&str>,
538    bearer: Option<&str>,
539) -> Result<TenantCtx, AuthDenied> {
540    if keyring.is_none() && single_key.is_none() {
541        return Ok(TenantCtx::default_tenant()); // open server (dev behavior)
542    }
543    let Some(candidate) = bearer else {
544        return Err(AuthDenied::Unknown);
545    };
546    if let Some(ks) = keyring {
547        match ks.lookup(candidate) {
548            Ok(ctx) => return Ok(ctx),
549            Err(AuthDenied::Disabled) => return Err(AuthDenied::Disabled),
550            Err(AuthDenied::Unknown) => {} // fall through to the single key
551        }
552    }
553    if single_key.is_some_and(|k| constant_time_secret_eq(k, candidate)) {
554        return Ok(TenantCtx::default_tenant());
555    }
556    Err(AuthDenied::Unknown)
557}
558
559/// PC-ISO namespace scoping: with a keyring configured, a request's cache namespace is
560/// `t:<tenant>\x1f<salt>` — a tenant's keys share cache; different tenants never do; a
561/// client-controlled salt cannot cross the `\x1f` boundary (tenant ids exclude it).
562/// Without a keyring the raw salt passes through, byte-identical to PC-ISO behavior.
563pub fn scope_namespace(tenant: &str, raw_salt: &str) -> String {
564    format!("t:{tenant}{NS_SEP}{raw_salt}")
565}
566
567/// The per-tenant METERING key for a PC-ISO namespace (lane/cache-metering): the tenant
568/// half of `scope_namespace` — keyring deployments aggregate one row per tenant across
569/// all its end-user salts; no-keyring namespaces (the raw salt) pass through unchanged
570/// ("" = the default single-tenant namespace). Unforgeable for the same reason
571/// scope_namespace is: NS_SEP is excluded from tenant ids, so a salt can never move its
572/// tokens into another tenant's row.
573pub fn meter_key(cache_ns: &str) -> &str {
574    match cache_ns
575        .strip_prefix("t:")
576        .and_then(|rest| rest.find(NS_SEP))
577    {
578        Some(sep) => &cache_ns[..2 + sep],
579        None => cache_ns,
580    }
581}
582
583// ---- key lifecycle CLI (--gen-key / --revoke-key) ----
584
585/// 24 bytes of /dev/urandom as 48 hex chars — the key's secret part.
586fn random_hex48() -> Result<String, String> {
587    use std::io::Read;
588    let mut f = std::fs::File::open("/dev/urandom").map_err(|e| format!("/dev/urandom: {e}"))?;
589    let mut buf = [0u8; 24];
590    f.read_exact(&mut buf)
591        .map_err(|e| format!("/dev/urandom read: {e}"))?;
592    Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
593}
594
595/// Generate a key for `tenant`, print the plaintext ONCE, append the hash entry to the
596/// keys file (created if missing). Returns the plaintext (for tests).
597pub fn gen_key(
598    keys_path: &Path,
599    tenant: &str,
600    lane: LaneClass,
601    rate_limit: Option<usize>,
602) -> Result<String, String> {
603    if !valid_tenant(tenant) {
604        return Err(format!("bad tenant {tenant:?} (want [A-Za-z0-9_-]+)"));
605    }
606    if rate_limit == Some(0) {
607        return Err("rate limit 0 would admit nothing".into());
608    }
609    let secret = random_hex48()?;
610    let key = format!("mk-{tenant}-{secret}");
611    let prefix = format!("mk-{tenant}-{}", &secret[..12]);
612    let created = SystemTime::now()
613        .duration_since(SystemTime::UNIX_EPOCH)
614        .map(|d| d.as_secs())
615        .unwrap_or(0);
616
617    // Validate the existing file first (never append to a broken ring), and refuse a
618    // prefix collision (the revoke handle must stay unambiguous).
619    if keys_path.is_file() {
620        let (text, _) = read_private_keyring(keys_path)?;
621        let f: KeyFile =
622            toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
623        Keyring::from_entries(f.keys.clone())?;
624        if f.keys.iter().any(|e| e.prefix == prefix) {
625            return Err(format!(
626                "prefix {prefix} already exists (rerun to draw a new key)"
627            ));
628        }
629    }
630
631    // Textual append preserves the file's comments; --revoke-key rewrites (see below).
632    let mut fragment = String::new();
633    if !keys_path.is_file() {
634        fragment.push_str(
635            "# memra API keyring (MEMRA_API_KEYS points here).\n\
636             # Entries store SHA-256 of the key, never the plaintext. Managed by\n\
637             # `memra-server --gen-key <tenant>` / `--revoke-key <prefix>` (revoke\n\
638             # rewrites the file; comments outside this header are not preserved).\n",
639        );
640    }
641    fragment.push_str(&format!(
642        "\n[[keys]]\nprefix = \"{prefix}\"\nsha256 = \"{}\"\ntenant = \"{tenant}\"\n\
643         lane = \"{}\"\nenabled = true\ncreated_unix = {created}\n",
644        sha256_hex(&key),
645        lane.as_str()
646    ));
647    if let Some(rl) = rate_limit {
648        fragment.push_str(&format!("rate_limit = {rl}\n"));
649    }
650    use std::io::Write;
651    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
652    let creating = !keys_path.exists();
653    let mut f = std::fs::OpenOptions::new()
654        .create(true)
655        .append(true)
656        .mode(0o640)
657        .custom_flags(libc::O_NOFOLLOW)
658        .open(keys_path)
659        .map_err(|e| format!("{}: {e}", keys_path.display()))?;
660    validate_private_keyring_metadata(&f, keys_path)?;
661    if creating {
662        f.set_permissions(std::fs::Permissions::from_mode(0o640))
663            .map_err(|e| format!("{}: {e}", keys_path.display()))?;
664    }
665    f.write_all(fragment.as_bytes())
666        .map_err(|e| format!("{}: {e}", keys_path.display()))?;
667    f.sync_data()
668        .map_err(|e| format!("sync {}: {e}", keys_path.display()))?;
669    if creating {
670        sync_parent_dir(keys_path, "keyring")?;
671    }
672    Ok(key)
673}
674
675fn sync_parent_dir(path: &Path, label: &str) -> Result<(), String> {
676    let parent = path.parent().unwrap_or_else(|| Path::new("."));
677    std::fs::File::open(parent)
678        .and_then(|directory| directory.sync_all())
679        .map_err(|e| format!("sync {label} directory {}: {e}", parent.display()))
680}
681
682fn atomic_rewrite(keys_path: &Path, contents: &str) -> Result<(), String> {
683    use std::io::Write;
684    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
685
686    let parent = keys_path.parent().unwrap_or_else(|| Path::new("."));
687    let name = keys_path
688        .file_name()
689        .and_then(|name| name.to_str())
690        .unwrap_or("keys");
691    let mut random = [0u8; 16];
692    std::fs::File::open("/dev/urandom")
693        .and_then(|mut source| source.read_exact(&mut random))
694        .map_err(|e| format!("randomize keyring temporary name: {e}"))?;
695    let suffix = random
696        .iter()
697        .map(|byte| format!("{byte:02x}"))
698        .collect::<String>();
699    let tmp_path = parent.join(format!(".{name}.tmp.{suffix}"));
700    let mut tmp = std::fs::OpenOptions::new()
701        .create_new(true)
702        .write(true)
703        .mode(0o640)
704        .custom_flags(libc::O_NOFOLLOW)
705        .open(&tmp_path)
706        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
707    tmp.set_permissions(std::fs::Permissions::from_mode(0o640))
708        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
709    tmp.write_all(contents.as_bytes())
710        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
711    tmp.sync_all()
712        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
713    validate_private_keyring_metadata(&tmp, &tmp_path)?;
714    drop(tmp);
715    std::fs::rename(&tmp_path, keys_path)
716        .map_err(|e| format!("{} -> {}: {e}", tmp_path.display(), keys_path.display()))?;
717    sync_parent_dir(keys_path, "keyring")
718}
719
720/// Disable every key whose `prefix` starts with `handle` (or whose sha256 matches the
721/// handle's hash, if a full plaintext key was pasted). Exactly one match required —
722/// ambiguity is an error, not a mass revoke. Rewrites the file (comments not preserved).
723pub fn revoke_key(keys_path: &Path, handle: &str) -> Result<String, String> {
724    let (text, _) = read_private_keyring(keys_path)?;
725    let mut f: KeyFile =
726        toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
727    Keyring::from_entries(f.keys.clone())?;
728    let full_hash = sha256_digest(handle);
729    let matches: Vec<usize> = f
730        .keys
731        .iter()
732        .enumerate()
733        .filter(|(_, e)| {
734            (!e.prefix.is_empty() && e.prefix.starts_with(handle))
735                || parse_sha256(&e.sha256)
736                    .is_some_and(|digest| constant_time_digest_eq(&digest, &full_hash))
737        })
738        .map(|(i, _)| i)
739        .collect();
740    match matches.len() {
741        0 => Err(format!("no key matches {handle:?}")),
742        1 => {
743            let i = matches[0];
744            if !f.keys[i].enabled {
745                return Err(format!("key {} is already revoked", f.keys[i].prefix));
746            }
747            f.keys[i].enabled = false;
748            let revoked = f.keys[i].prefix.clone();
749            let out = toml::to_string(&f).map_err(|e| e.to_string())?;
750            atomic_rewrite(keys_path, &out)?;
751            Ok(revoked)
752        }
753        n => Err(format!("{n} keys match {handle:?} — use a longer prefix")),
754    }
755}
756
757/// CLI dispatch: handles `--gen-key` / `--revoke-key` if present, returning the exit
758/// code; None = no key-management args, boot the server normally.
759pub fn run_cli(args: &[String]) -> Option<i32> {
760    let has = |flag: &str| args.iter().any(|a| a == flag);
761    if !has("--gen-key") && !has("--revoke-key") {
762        return None;
763    }
764    let value_of = |flag: &str| -> Option<String> {
765        args.iter()
766            .position(|a| a == flag)
767            .and_then(|i| args.get(i + 1).cloned())
768    };
769    let keys_path = value_of("--keys")
770        .or_else(|| std::env::var("MEMRA_API_KEYS").ok())
771        .map(PathBuf::from);
772    let Some(keys_path) = keys_path else {
773        eprintln!("error: no keys file — pass --keys /path/keys.toml or set MEMRA_API_KEYS");
774        return Some(2);
775    };
776    if keys_path.exists() && !keys_path.is_file() {
777        eprintln!("error: {} is not a file", keys_path.display());
778        return Some(2);
779    }
780
781    if has("--gen-key") {
782        let Some(tenant) = value_of("--gen-key") else {
783            eprintln!(
784                "usage: memra-server --gen-key <tenant> [--lane interactive|batch] \
785                       [--rate-limit N] [--keys /path/keys.toml]"
786            );
787            return Some(2);
788        };
789        let lane = match value_of("--lane") {
790            None => LaneClass::Interactive,
791            Some(v) => match LaneClass::parse(&v) {
792                Some(l) => l,
793                None => {
794                    eprintln!("error: bad --lane {v:?} (interactive|batch)");
795                    return Some(2);
796                }
797            },
798        };
799        let rate_limit = match value_of("--rate-limit") {
800            None => None,
801            Some(v) => match v.parse::<usize>() {
802                Ok(n) => Some(n),
803                Err(_) => {
804                    eprintln!("error: bad --rate-limit {v:?} (want a positive integer)");
805                    return Some(2);
806                }
807            },
808        };
809        return Some(match gen_key(&keys_path, &tenant, lane, rate_limit) {
810            Ok(key) => {
811                println!("{key}");
812                eprintln!(
813                    "[gen-key] tenant {tenant:?} lane {} appended to {} — \
814                           the plaintext above is shown ONCE and stored only as SHA-256",
815                    lane.as_str(),
816                    keys_path.display()
817                );
818                0
819            }
820            Err(e) => {
821                eprintln!("error: {e}");
822                1
823            }
824        });
825    }
826
827    // --revoke-key
828    let Some(handle) = value_of("--revoke-key") else {
829        eprintln!("usage: memra-server --revoke-key <prefix> [--keys /path/keys.toml]");
830        return Some(2);
831    };
832    Some(match revoke_key(&keys_path, &handle) {
833        Ok(prefix) => {
834            eprintln!(
835                "[revoke-key] {prefix} disabled in {} (takes effect on the next \
836                       keyring poll, <=2s on a running server)",
837                keys_path.display()
838            );
839            0
840        }
841        Err(e) => {
842            eprintln!("error: {e}");
843            1
844        }
845    })
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    fn tmpfile(name: &str) -> PathBuf {
853        let p = std::env::temp_dir().join(format!("memra_auth_{}_{name}", std::process::id()));
854        let _ = std::fs::remove_file(&p);
855        p
856    }
857
858    fn write_private(path: &Path, contents: &str) {
859        use std::os::unix::fs::PermissionsExt;
860        std::fs::write(path, contents).unwrap();
861        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o640)).unwrap();
862    }
863
864    const K_A1: &str = "mk-acme-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
865    const K_A2: &str = "mk-acme-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
866    const K_B1: &str = "mk-blue-cccccccccccccccccccccccccccccccccccccccccccccccc";
867    const K_DIS: &str = "mk-dead-dddddddddddddddddddddddddddddddddddddddddddddddd";
868
869    fn toml_ring() -> String {
870        format!(
871            "[[keys]]\nprefix = \"mk-acme-aaaa\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\n\
872             [[keys]]\nprefix = \"mk-acme-bbbb\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\
873             rate_limit = 2\n\n\
874             [[keys]]\nprefix = \"mk-blue-cccc\"\nsha256 = \"{}\"\ntenant = \"blue\"\n\
875             lane = \"batch\"\n\n\
876             [[keys]]\nprefix = \"mk-dead-dddd\"\nsha256 = \"{}\"\ntenant = \"dead\"\n\
877             enabled = false\n",
878            sha256_hex(K_A1),
879            sha256_hex(K_A2),
880            sha256_hex(K_B1),
881            sha256_hex(K_DIS)
882        )
883    }
884
885    #[test]
886    fn sha256_hex_matches_known_vector() {
887        // sha256("abc") — the FIPS 180-2 test vector.
888        assert_eq!(
889            sha256_hex("abc"),
890            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
891        );
892    }
893
894    #[test]
895    fn fixed_digest_secret_comparison_preserves_auth_semantics() {
896        assert!(constant_time_secret_eq("same", "same"));
897        assert!(!constant_time_secret_eq("same", "same-but-longer"));
898        assert!(!constant_time_secret_eq("prefix-a", "prefix-b"));
899        assert!(!constant_time_secret_eq("", "nonempty"));
900    }
901
902    #[test]
903    fn toml_ring_parses_and_looks_up_by_hash() {
904        let ring = Keyring::from_toml(&toml_ring()).unwrap();
905        assert_eq!(ring.len(), 4);
906        // valid key -> tenant ctx with its lane class + override.
907        let ctx = ring.lookup(K_A1).unwrap();
908        assert_eq!(ctx.tenant, "acme");
909        assert_eq!(ctx.lane_class, LaneClass::Interactive);
910        assert_eq!(ctx.rate_limit, None);
911        let ctx = ring.lookup(K_A2).unwrap();
912        assert_eq!(ctx.tenant, "acme");
913        assert_eq!(ctx.rate_limit, Some(2));
914        let ctx = ring.lookup(K_B1).unwrap();
915        assert_eq!(ctx.tenant, "blue");
916        assert_eq!(ctx.lane_class, LaneClass::Batch);
917        // disabled -> Disabled (403), unknown -> Unknown (401).
918        assert_eq!(ring.lookup(K_DIS).unwrap_err(), AuthDenied::Disabled);
919        assert_eq!(ring.lookup("mk-nope-x").unwrap_err(), AuthDenied::Unknown);
920        // the PLAINTEXT never appears in the ring's source.
921        assert!(!toml_ring().contains(K_A1));
922    }
923
924    #[test]
925    fn malformed_rings_are_loud_errors() {
926        // bad tenant chars (anything outside [A-Za-z0-9_-] would weaken the \x1f
927        // namespace law; the \x1f char itself can't even be written in TOML).
928        let bad = format!(
929            "[[keys]]\nsha256 = \"{}\"\ntenant = \"a b\"\n",
930            sha256_hex("k")
931        );
932        assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad tenant"));
933        assert!(
934            Keyring::from_entries(vec![KeyEntry {
935                prefix: String::new(),
936                sha256: sha256_hex("k"),
937                tenant: format!("a{}b", '\u{1f}'),
938                lane: None,
939                enabled: true,
940                rate_limit: None,
941                created_unix: None,
942            }])
943            .unwrap_err()
944            .contains("bad tenant")
945        );
946        // short hash.
947        let bad = "[[keys]]\nsha256 = \"abc123\"\ntenant = \"t\"\n";
948        assert!(Keyring::from_toml(bad).unwrap_err().contains("64 hex"));
949        // bad lane.
950        let bad = format!(
951            "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nlane = \"turbo\"\n",
952            sha256_hex("k")
953        );
954        assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad lane"));
955        // duplicate key.
956        let dup = format!(
957            "[[keys]]\nsha256 = \"{h}\"\ntenant = \"t\"\n\n\
958             [[keys]]\nsha256 = \"{h}\"\ntenant = \"u\"\n",
959            h = sha256_hex("k")
960        );
961        assert!(Keyring::from_toml(&dup).unwrap_err().contains("duplicate"));
962        // rate_limit 0.
963        let z = format!(
964            "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nrate_limit = 0\n",
965            sha256_hex("k")
966        );
967        assert!(Keyring::from_toml(&z).unwrap_err().contains("rate_limit 0"));
968    }
969
970    #[test]
971    fn inline_env_list_parses() {
972        let spec = format!("acme:{},blue:{}:batch", sha256_hex(K_A1), sha256_hex(K_B1));
973        let ring = Keyring::from_inline(&spec).unwrap();
974        assert_eq!(ring.lookup(K_A1).unwrap().tenant, "acme");
975        assert_eq!(ring.lookup(K_B1).unwrap().lane_class, LaneClass::Batch);
976        assert!(Keyring::from_inline("no-colon-here").is_err());
977        assert!(Keyring::from_inline("").is_err());
978    }
979
980    #[test]
981    fn keystore_hot_reloads_on_mtime_change() {
982        let path = tmpfile("reload.toml");
983        write_private(&path, &toml_ring());
984        let ks = KeyStore::from_spec(path.to_str().unwrap())
985            .unwrap()
986            .with_poll(Duration::ZERO);
987        assert_eq!(ks.lookup(K_A1).unwrap().tenant, "acme");
988        // revoke A1 out-of-band (what --revoke-key does) with a bumped mtime.
989        let revoked = toml_ring().replace(
990            &format!("sha256 = \"{}\"\ntenant = \"acme\"\n", sha256_hex(K_A1)),
991            &format!(
992                "sha256 = \"{}\"\ntenant = \"acme\"\nenabled = false\n",
993                sha256_hex(K_A1)
994            ),
995        );
996        std::fs::write(&path, revoked).unwrap();
997        let new_mtime = SystemTime::now() + Duration::from_secs(2);
998        let f = std::fs::File::options().write(true).open(&path).unwrap();
999        f.set_modified(new_mtime).unwrap();
1000        drop(f);
1001        assert_eq!(
1002            ks.lookup(K_A1).unwrap_err(),
1003            AuthDenied::Disabled,
1004            "mtime bump must reload the ring"
1005        );
1006        // a BROKEN rewrite keeps the previous ring (never fail-open).
1007        std::fs::write(&path, "keys = \"not a ring\"").unwrap();
1008        let f = std::fs::File::options().write(true).open(&path).unwrap();
1009        f.set_modified(new_mtime + Duration::from_secs(2)).unwrap();
1010        drop(f);
1011        assert_eq!(
1012            ks.lookup(K_A1).unwrap_err(),
1013            AuthDenied::Disabled,
1014            "broken reload must keep the previous ring"
1015        );
1016        assert_eq!(ks.lookup(K_B1).unwrap().tenant, "blue");
1017        let _ = std::fs::remove_file(&path);
1018    }
1019
1020    #[test]
1021    fn auth_law_composes_keyring_and_single_key() {
1022        let path = tmpfile("law.toml");
1023        write_private(&path, &toml_ring());
1024        let ks = KeyStore::from_spec(path.to_str().unwrap()).unwrap();
1025        // keyring key -> its tenant; single key -> "default"; both live at once.
1026        assert_eq!(
1027            authenticate_with(Some(&ks), Some("daily"), Some(K_A1))
1028                .unwrap()
1029                .tenant,
1030            "acme"
1031        );
1032        assert_eq!(
1033            authenticate_with(Some(&ks), Some("daily"), Some("daily")).unwrap(),
1034            TenantCtx::default_tenant()
1035        );
1036        // wrong key -> Unknown; disabled -> Disabled; missing header -> Unknown.
1037        assert_eq!(
1038            authenticate_with(Some(&ks), Some("daily"), Some("nope")).unwrap_err(),
1039            AuthDenied::Unknown
1040        );
1041        assert_eq!(
1042            authenticate_with(Some(&ks), Some("daily"), Some(K_DIS)).unwrap_err(),
1043            AuthDenied::Disabled
1044        );
1045        assert_eq!(
1046            authenticate_with(Some(&ks), Some("daily"), None).unwrap_err(),
1047            AuthDenied::Unknown
1048        );
1049        // single key only (the back-compat daily driver): unchanged.
1050        assert_eq!(
1051            authenticate_with(None, Some("daily"), Some("daily")).unwrap(),
1052            TenantCtx::default_tenant()
1053        );
1054        assert_eq!(
1055            authenticate_with(None, Some("daily"), Some("x")).unwrap_err(),
1056            AuthDenied::Unknown
1057        );
1058        // nothing configured: open.
1059        assert_eq!(
1060            authenticate_with(None, None, None).unwrap(),
1061            TenantCtx::default_tenant()
1062        );
1063        let _ = std::fs::remove_file(&path);
1064    }
1065
1066    #[test]
1067    fn namespace_scoping_is_tenant_separated_and_unforgeable() {
1068        // same tenant, two keys, same salt -> SAME namespace (a tenant's keys share cache).
1069        assert_eq!(scope_namespace("acme", "s"), scope_namespace("acme", "s"));
1070        // different tenants never collide, salted or not.
1071        assert_ne!(scope_namespace("acme", ""), scope_namespace("blue", ""));
1072        assert_ne!(scope_namespace("acme", "s"), scope_namespace("blue", "s"));
1073        // a client salt cannot forge another tenant's namespace: the separator \x1f is
1074        // excluded from tenant ids, so "t:blue\x1f" can only be produced BY tenant blue.
1075        let forged_salt = format!("blue{}", '\u{1f}'); // attacker-controlled cache_salt
1076        assert_ne!(
1077            scope_namespace("acme", &forged_salt),
1078            scope_namespace("blue", "")
1079        );
1080        // salted vs unsalted stay distinct within a tenant.
1081        assert_ne!(scope_namespace("acme", "s"), scope_namespace("acme", ""));
1082    }
1083
1084    #[test]
1085    fn meter_key_extracts_tenant_and_passes_raw_salts_through() {
1086        // keyring namespaces collapse to the tenant half — salts never split a tenant's row.
1087        assert_eq!(meter_key(&scope_namespace("acme", "u1")), "t:acme");
1088        assert_eq!(meter_key(&scope_namespace("acme", "u2")), "t:acme");
1089        assert_eq!(meter_key(&scope_namespace("blue", "")), "t:blue");
1090        // no keyring: raw salts pass through, "" = the default namespace.
1091        assert_eq!(meter_key("session-7"), "session-7");
1092        assert_eq!(meter_key(""), "");
1093        // a raw salt that merely LOOKS like a scoped namespace but has no separator
1094        // stays itself (client text cannot contain NS_SEP-scoped rows without a keyring
1095        // because scope_namespace only runs when auth is configured).
1096        assert_eq!(meter_key("t:fake"), "t:fake");
1097        // unforgeable within a keyring: a salt carrying NS_SEP cannot escape its tenant.
1098        let forged = scope_namespace("acme", &format!("blue{}", '\u{1f}'));
1099        assert_eq!(meter_key(&forged), "t:acme");
1100    }
1101
1102    #[test]
1103    fn gen_key_prints_once_and_stores_only_the_hash() {
1104        use std::os::unix::fs::PermissionsExt;
1105
1106        let path = tmpfile("gen.toml");
1107        let key = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1108        assert!(key.starts_with("mk-acme-"));
1109        assert_eq!(key.len(), "mk-acme-".len() + 48);
1110        assert_eq!(
1111            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1112            0o640
1113        );
1114        let text = std::fs::read_to_string(&path).unwrap();
1115        assert!(!text.contains(&key), "plaintext must never reach the file");
1116        assert!(text.contains(&sha256_hex(&key)));
1117        // the ring authenticates the printed key.
1118        let ring = Keyring::from_toml(&text).unwrap();
1119        assert_eq!(ring.lookup(&key).unwrap().tenant, "acme");
1120        // a second key appends; a batch-lane + rate-limit key carries both fields.
1121        let key2 = gen_key(&path, "blue", LaneClass::Batch, Some(4)).unwrap();
1122        let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1123        assert_eq!(ring.len(), 2);
1124        let ctx = ring.lookup(&key2).unwrap();
1125        assert_eq!(ctx.lane_class, LaneClass::Batch);
1126        assert_eq!(ctx.rate_limit, Some(4));
1127        // bad tenant is refused before touching the file.
1128        assert!(gen_key(&path, "bad tenant", LaneClass::Interactive, None).is_err());
1129        let _ = std::fs::remove_file(&path);
1130    }
1131
1132    #[test]
1133    fn revoke_key_flips_enabled_by_prefix_exactly_once() {
1134        use std::os::unix::fs::PermissionsExt;
1135
1136        let path = tmpfile("revoke.toml");
1137        let key_a = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1138        let key_b = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1139        // ambiguous prefix (both start mk-acme-) -> error, nothing revoked.
1140        assert!(
1141            revoke_key(&path, "mk-acme-")
1142                .unwrap_err()
1143                .contains("2 keys")
1144        );
1145        // unique prefix -> revoked; the other key still works; re-revoke errors.
1146        let prefix_a = format!(
1147            "mk-acme-{}",
1148            &key_a["mk-acme-".len().."mk-acme-".len() + 12]
1149        );
1150        revoke_key(&path, &prefix_a).unwrap();
1151        assert_eq!(
1152            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1153            0o640
1154        );
1155        assert!(!PathBuf::from(format!("{}.tmp", path.display())).exists());
1156        let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1157        assert_eq!(ring.lookup(&key_a).unwrap_err(), AuthDenied::Disabled);
1158        assert_eq!(ring.lookup(&key_b).unwrap().tenant, "acme");
1159        assert!(
1160            revoke_key(&path, &prefix_a)
1161                .unwrap_err()
1162                .contains("already revoked")
1163        );
1164        // full plaintext key also works as the handle (hash match).
1165        revoke_key(&path, &key_b).unwrap();
1166        let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1167        assert_eq!(ring.lookup(&key_b).unwrap_err(), AuthDenied::Disabled);
1168        // no match -> error.
1169        assert!(revoke_key(&path, "mk-zzz").unwrap_err().contains("no key"));
1170        let _ = std::fs::remove_file(&path);
1171    }
1172
1173    #[test]
1174    fn atomic_rewrite_survives_concurrent_hot_reload() {
1175        use std::sync::Arc;
1176        use std::sync::atomic::{AtomicBool, Ordering};
1177
1178        let path = tmpfile("atomic-reload.toml");
1179        let keys: Vec<KeyEntry> = (0..512)
1180            .map(|i| KeyEntry {
1181                prefix: format!("mk-tenant-{i:04}"),
1182                sha256: sha256_hex(&format!("secret-{i:04}")),
1183                tenant: "tenant".into(),
1184                lane: None,
1185                enabled: true,
1186                rate_limit: None,
1187                created_unix: None,
1188            })
1189            .collect();
1190        write_private(&path, &toml::to_string(&KeyFile { keys }).unwrap());
1191        let store = Arc::new(
1192            KeyStore::from_spec(path.to_str().unwrap())
1193                .unwrap()
1194                .with_poll(Duration::ZERO),
1195        );
1196        let running = Arc::new(AtomicBool::new(true));
1197        let start = Arc::new(std::sync::Barrier::new(2));
1198        let reader = {
1199            let path = path.clone();
1200            let store = store.clone();
1201            let running = running.clone();
1202            let start = start.clone();
1203            std::thread::spawn(move || {
1204                start.wait();
1205                while running.load(Ordering::Acquire) {
1206                    let text = std::fs::read_to_string(&path).unwrap();
1207                    let ring = Keyring::from_toml(&text)
1208                        .expect("a concurrent reader must see the old or new complete ring");
1209                    assert_eq!(ring.len(), 512, "the target must never be truncate-visible");
1210                    assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1211                }
1212            })
1213        };
1214
1215        start.wait();
1216        let rewrites =
1217            (0..32).try_for_each(|i| revoke_key(&path, &format!("mk-tenant-{i:04}")).map(|_| ()));
1218        running.store(false, Ordering::Release);
1219        reader.join().unwrap();
1220        rewrites.unwrap();
1221
1222        let new_mtime = SystemTime::now() + Duration::from_secs(2);
1223        let file = std::fs::File::options().write(true).open(&path).unwrap();
1224        file.set_modified(new_mtime).unwrap();
1225        drop(file);
1226        assert_eq!(
1227            store.lookup("secret-0000").unwrap_err(),
1228            AuthDenied::Disabled
1229        );
1230        assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1231        let _ = std::fs::remove_file(&path);
1232    }
1233}