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