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