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    let secret = random_hex48()?;
616    let key = format!("mk-{tenant}-{secret}");
617    install_key(keys_path, tenant, lane, rate_limit, &key)?;
618    Ok(key)
619}
620
621/// Install an already-generated key into a ring. This is the fleet fan-out primitive: one
622/// control-plane key can be installed byte-identically on every serving origin without making
623/// any serving host the authority for the others. Replaying the exact same entry is idempotent;
624/// a prefix collision with different material or policy fails closed.
625pub fn install_key(
626    keys_path: &Path,
627    tenant: &str,
628    lane: LaneClass,
629    rate_limit: Option<usize>,
630    key: &str,
631) -> Result<String, String> {
632    if !valid_tenant(tenant) {
633        return Err(format!("bad tenant {tenant:?} (want [A-Za-z0-9_-]+)"));
634    }
635    if rate_limit == Some(0) {
636        return Err("rate limit 0 would admit nothing".into());
637    }
638    let key_prefix = format!("mk-{tenant}-");
639    let secret = key
640        .strip_prefix(&key_prefix)
641        .ok_or_else(|| format!("key must start with {key_prefix:?} and carry a 48-hex secret"))?;
642    if secret.len() != 48
643        || !secret
644            .bytes()
645            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
646    {
647        return Err("key secret must be exactly 48 lowercase hexadecimal characters".into());
648    }
649    let prefix = format!("mk-{tenant}-{}", &secret[..12]);
650    let created = SystemTime::now()
651        .duration_since(SystemTime::UNIX_EPOCH)
652        .map(|d| d.as_secs())
653        .unwrap_or(0);
654
655    // Validate the existing file first (never append to a broken ring), and refuse a
656    // prefix collision (the revoke handle must stay unambiguous).
657    if keys_path.is_file() {
658        let (text, _) = read_private_keyring(keys_path)?;
659        let f: KeyFile =
660            toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
661        Keyring::from_entries(f.keys.clone())?;
662        if let Some(existing) = f.keys.iter().find(|entry| entry.prefix == prefix) {
663            let same = existing.sha256 == sha256_hex(key)
664                && existing.tenant == tenant
665                && existing.lane.as_deref().unwrap_or("interactive") == lane.as_str()
666                && existing.rate_limit == rate_limit
667                && existing.enabled;
668            if same {
669                return Ok(prefix);
670            }
671            return Err(format!(
672                "prefix {prefix} already exists with different key material or policy"
673            ));
674        }
675    }
676
677    // Textual append preserves the file's comments; --revoke-key rewrites (see below).
678    let mut fragment = String::new();
679    if !keys_path.is_file() {
680        fragment.push_str(
681            "# memra API keyring (MEMRA_API_KEYS points here).\n\
682             # Entries store SHA-256 of the key, never the plaintext. Managed by\n\
683             # `memra-server --gen-key <tenant>` / `--revoke-key <prefix>` (revoke\n\
684             # rewrites the file; comments outside this header are not preserved).\n",
685        );
686    }
687    fragment.push_str(&format!(
688        "\n[[keys]]\nprefix = \"{prefix}\"\nsha256 = \"{}\"\ntenant = \"{tenant}\"\n\
689         lane = \"{}\"\nenabled = true\ncreated_unix = {created}\n",
690        sha256_hex(key),
691        lane.as_str()
692    ));
693    if let Some(rl) = rate_limit {
694        fragment.push_str(&format!("rate_limit = {rl}\n"));
695    }
696    use std::io::Write;
697    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
698    let creating = !keys_path.exists();
699    let mut f = std::fs::OpenOptions::new()
700        .create(true)
701        .append(true)
702        .mode(0o640)
703        .custom_flags(libc::O_NOFOLLOW)
704        .open(keys_path)
705        .map_err(|e| format!("{}: {e}", keys_path.display()))?;
706    validate_private_keyring_metadata(&f, keys_path)?;
707    if creating {
708        f.set_permissions(std::fs::Permissions::from_mode(0o640))
709            .map_err(|e| format!("{}: {e}", keys_path.display()))?;
710    }
711    f.write_all(fragment.as_bytes())
712        .map_err(|e| format!("{}: {e}", keys_path.display()))?;
713    f.sync_data()
714        .map_err(|e| format!("sync {}: {e}", keys_path.display()))?;
715    if creating {
716        sync_parent_dir(keys_path, "keyring")?;
717    }
718    Ok(prefix)
719}
720
721fn sync_parent_dir(path: &Path, label: &str) -> Result<(), String> {
722    let parent = path.parent().unwrap_or_else(|| Path::new("."));
723    std::fs::File::open(parent)
724        .and_then(|directory| directory.sync_all())
725        .map_err(|e| format!("sync {label} directory {}: {e}", parent.display()))
726}
727
728fn atomic_rewrite(keys_path: &Path, contents: &str) -> Result<(), String> {
729    use std::io::Write;
730    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
731
732    let parent = keys_path.parent().unwrap_or_else(|| Path::new("."));
733    let name = keys_path
734        .file_name()
735        .and_then(|name| name.to_str())
736        .unwrap_or("keys");
737    let mut random = [0u8; 16];
738    std::fs::File::open("/dev/urandom")
739        .and_then(|mut source| source.read_exact(&mut random))
740        .map_err(|e| format!("randomize keyring temporary name: {e}"))?;
741    let suffix = random
742        .iter()
743        .map(|byte| format!("{byte:02x}"))
744        .collect::<String>();
745    let tmp_path = parent.join(format!(".{name}.tmp.{suffix}"));
746    let mut tmp = std::fs::OpenOptions::new()
747        .create_new(true)
748        .write(true)
749        .mode(0o640)
750        .custom_flags(libc::O_NOFOLLOW)
751        .open(&tmp_path)
752        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
753    tmp.set_permissions(std::fs::Permissions::from_mode(0o640))
754        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
755    tmp.write_all(contents.as_bytes())
756        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
757    tmp.sync_all()
758        .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
759    validate_private_keyring_metadata(&tmp, &tmp_path)?;
760    drop(tmp);
761    std::fs::rename(&tmp_path, keys_path)
762        .map_err(|e| format!("{} -> {}: {e}", tmp_path.display(), keys_path.display()))?;
763    sync_parent_dir(keys_path, "keyring")
764}
765
766/// Disable every key whose `prefix` starts with `handle` (or whose sha256 matches the
767/// handle's hash, if a full plaintext key was pasted). Exactly one match required —
768/// ambiguity is an error, not a mass revoke. Rewrites the file (comments not preserved).
769pub fn revoke_key(keys_path: &Path, handle: &str) -> Result<String, String> {
770    let (text, _) = read_private_keyring(keys_path)?;
771    let mut f: KeyFile =
772        toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
773    Keyring::from_entries(f.keys.clone())?;
774    let full_hash = sha256_digest(handle);
775    let matches: Vec<usize> = f
776        .keys
777        .iter()
778        .enumerate()
779        .filter(|(_, e)| {
780            (!e.prefix.is_empty() && e.prefix.starts_with(handle))
781                || parse_sha256(&e.sha256)
782                    .is_some_and(|digest| constant_time_digest_eq(&digest, &full_hash))
783        })
784        .map(|(i, _)| i)
785        .collect();
786    match matches.len() {
787        0 => Err(format!("no key matches {handle:?}")),
788        1 => {
789            let i = matches[0];
790            if !f.keys[i].enabled {
791                return Err(format!("key {} is already revoked", f.keys[i].prefix));
792            }
793            f.keys[i].enabled = false;
794            let revoked = f.keys[i].prefix.clone();
795            let out = toml::to_string(&f).map_err(|e| e.to_string())?;
796            atomic_rewrite(keys_path, &out)?;
797            Ok(revoked)
798        }
799        n => Err(format!("{n} keys match {handle:?} — use a longer prefix")),
800    }
801}
802
803/// CLI dispatch: handles `--gen-key` / `--revoke-key` if present, returning the exit
804/// code; None = no key-management args, boot the server normally.
805pub fn run_cli(args: &[String]) -> Option<i32> {
806    let has = |flag: &str| args.iter().any(|a| a == flag);
807    if !has("--gen-key") && !has("--revoke-key") {
808        return None;
809    }
810    let value_of = |flag: &str| -> Option<String> {
811        args.iter()
812            .position(|a| a == flag)
813            .and_then(|i| args.get(i + 1).cloned())
814    };
815    let keys_path = value_of("--keys")
816        .or_else(|| std::env::var("MEMRA_API_KEYS").ok())
817        .map(PathBuf::from);
818    let Some(keys_path) = keys_path else {
819        eprintln!("error: no keys file — pass --keys /path/keys.toml or set MEMRA_API_KEYS");
820        return Some(2);
821    };
822    if keys_path.exists() && !keys_path.is_file() {
823        eprintln!("error: {} is not a file", keys_path.display());
824        return Some(2);
825    }
826
827    if has("--gen-key") {
828        let Some(tenant) = value_of("--gen-key") else {
829            eprintln!(
830                "usage: memra-server --gen-key <tenant> [--lane interactive|batch] \
831                       [--rate-limit N] [--keys /path/keys.toml]"
832            );
833            return Some(2);
834        };
835        let lane = match value_of("--lane") {
836            None => LaneClass::Interactive,
837            Some(v) => match LaneClass::parse(&v) {
838                Some(l) => l,
839                None => {
840                    eprintln!("error: bad --lane {v:?} (interactive|batch)");
841                    return Some(2);
842                }
843            },
844        };
845        let rate_limit = match value_of("--rate-limit") {
846            None => None,
847            Some(v) => match v.parse::<usize>() {
848                Ok(n) => Some(n),
849                Err(_) => {
850                    eprintln!("error: bad --rate-limit {v:?} (want a positive integer)");
851                    return Some(2);
852                }
853            },
854        };
855        return Some(match gen_key(&keys_path, &tenant, lane, rate_limit) {
856            Ok(key) => {
857                println!("{key}");
858                eprintln!(
859                    "[gen-key] tenant {tenant:?} lane {} appended to {} — \
860                           the plaintext above is shown ONCE and stored only as SHA-256",
861                    lane.as_str(),
862                    keys_path.display()
863                );
864                0
865            }
866            Err(e) => {
867                eprintln!("error: {e}");
868                1
869            }
870        });
871    }
872
873    // --revoke-key
874    let Some(handle) = value_of("--revoke-key") else {
875        eprintln!("usage: memra-server --revoke-key <prefix> [--keys /path/keys.toml]");
876        return Some(2);
877    };
878    Some(match revoke_key(&keys_path, &handle) {
879        Ok(prefix) => {
880            eprintln!(
881                "[revoke-key] {prefix} disabled in {} (takes effect on the next \
882                       keyring poll, <=2s on a running server)",
883                keys_path.display()
884            );
885            0
886        }
887        Err(e) => {
888            eprintln!("error: {e}");
889            1
890        }
891    })
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897
898    fn tmpfile(name: &str) -> PathBuf {
899        let p = std::env::temp_dir().join(format!("memra_auth_{}_{name}", std::process::id()));
900        let _ = std::fs::remove_file(&p);
901        p
902    }
903
904    fn write_private(path: &Path, contents: &str) {
905        use std::os::unix::fs::PermissionsExt;
906        std::fs::write(path, contents).unwrap();
907        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o640)).unwrap();
908    }
909
910    const K_A1: &str = "mk-acme-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
911    const K_A2: &str = "mk-acme-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
912    const K_B1: &str = "mk-blue-cccccccccccccccccccccccccccccccccccccccccccccccc";
913    const K_DIS: &str = "mk-dead-dddddddddddddddddddddddddddddddddddddddddddddddd";
914
915    fn toml_ring() -> String {
916        format!(
917            "[[keys]]\nprefix = \"mk-acme-aaaa\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\n\
918             [[keys]]\nprefix = \"mk-acme-bbbb\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\
919             rate_limit = 2\n\n\
920             [[keys]]\nprefix = \"mk-blue-cccc\"\nsha256 = \"{}\"\ntenant = \"blue\"\n\
921             lane = \"batch\"\n\n\
922             [[keys]]\nprefix = \"mk-dead-dddd\"\nsha256 = \"{}\"\ntenant = \"dead\"\n\
923             enabled = false\n",
924            sha256_hex(K_A1),
925            sha256_hex(K_A2),
926            sha256_hex(K_B1),
927            sha256_hex(K_DIS)
928        )
929    }
930
931    #[test]
932    fn sha256_hex_matches_known_vector() {
933        // sha256("abc") — the FIPS 180-2 test vector.
934        assert_eq!(
935            sha256_hex("abc"),
936            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
937        );
938    }
939
940    #[test]
941    fn fixed_digest_secret_comparison_preserves_auth_semantics() {
942        assert!(constant_time_secret_eq("same", "same"));
943        assert!(!constant_time_secret_eq("same", "same-but-longer"));
944        assert!(!constant_time_secret_eq("prefix-a", "prefix-b"));
945        assert!(!constant_time_secret_eq("", "nonempty"));
946    }
947
948    #[test]
949    fn toml_ring_parses_and_looks_up_by_hash() {
950        let ring = Keyring::from_toml(&toml_ring()).unwrap();
951        assert_eq!(ring.len(), 4);
952        // valid key -> tenant ctx with its lane class + override.
953        let ctx = ring.lookup(K_A1).unwrap();
954        assert_eq!(ctx.tenant, "acme");
955        assert_eq!(ctx.lane_class, LaneClass::Interactive);
956        assert_eq!(ctx.rate_limit, None);
957        let ctx = ring.lookup(K_A2).unwrap();
958        assert_eq!(ctx.tenant, "acme");
959        assert_eq!(ctx.rate_limit, Some(2));
960        let ctx = ring.lookup(K_B1).unwrap();
961        assert_eq!(ctx.tenant, "blue");
962        assert_eq!(ctx.lane_class, LaneClass::Batch);
963        // disabled -> Disabled (403), unknown -> Unknown (401).
964        assert_eq!(ring.lookup(K_DIS).unwrap_err(), AuthDenied::Disabled);
965        assert_eq!(ring.lookup("mk-nope-x").unwrap_err(), AuthDenied::Unknown);
966        // the PLAINTEXT never appears in the ring's source.
967        assert!(!toml_ring().contains(K_A1));
968    }
969
970    #[test]
971    fn malformed_rings_are_loud_errors() {
972        // bad tenant chars (anything outside [A-Za-z0-9_-] would weaken the \x1f
973        // namespace law; the \x1f char itself can't even be written in TOML).
974        let bad = format!(
975            "[[keys]]\nsha256 = \"{}\"\ntenant = \"a b\"\n",
976            sha256_hex("k")
977        );
978        assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad tenant"));
979        assert!(
980            Keyring::from_entries(vec![KeyEntry {
981                prefix: String::new(),
982                sha256: sha256_hex("k"),
983                tenant: format!("a{}b", '\u{1f}'),
984                lane: None,
985                enabled: true,
986                rate_limit: None,
987                created_unix: None,
988            }])
989            .unwrap_err()
990            .contains("bad tenant")
991        );
992        // short hash.
993        let bad = "[[keys]]\nsha256 = \"abc123\"\ntenant = \"t\"\n";
994        assert!(Keyring::from_toml(bad).unwrap_err().contains("64 hex"));
995        // bad lane.
996        let bad = format!(
997            "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nlane = \"turbo\"\n",
998            sha256_hex("k")
999        );
1000        assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad lane"));
1001        // duplicate key.
1002        let dup = format!(
1003            "[[keys]]\nsha256 = \"{h}\"\ntenant = \"t\"\n\n\
1004             [[keys]]\nsha256 = \"{h}\"\ntenant = \"u\"\n",
1005            h = sha256_hex("k")
1006        );
1007        assert!(Keyring::from_toml(&dup).unwrap_err().contains("duplicate"));
1008        // rate_limit 0.
1009        let z = format!(
1010            "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nrate_limit = 0\n",
1011            sha256_hex("k")
1012        );
1013        assert!(Keyring::from_toml(&z).unwrap_err().contains("rate_limit 0"));
1014    }
1015
1016    #[test]
1017    fn inline_env_list_parses() {
1018        let spec = format!("acme:{},blue:{}:batch", sha256_hex(K_A1), sha256_hex(K_B1));
1019        let ring = Keyring::from_inline(&spec).unwrap();
1020        assert_eq!(ring.lookup(K_A1).unwrap().tenant, "acme");
1021        assert_eq!(ring.lookup(K_B1).unwrap().lane_class, LaneClass::Batch);
1022        assert!(Keyring::from_inline("no-colon-here").is_err());
1023        assert!(Keyring::from_inline("").is_err());
1024    }
1025
1026    #[test]
1027    fn keystore_hot_reloads_on_mtime_change() {
1028        let path = tmpfile("reload.toml");
1029        write_private(&path, &toml_ring());
1030        let ks = KeyStore::from_spec(path.to_str().unwrap())
1031            .unwrap()
1032            .with_poll(Duration::ZERO);
1033        assert_eq!(ks.lookup(K_A1).unwrap().tenant, "acme");
1034        // revoke A1 out-of-band (what --revoke-key does) with a bumped mtime.
1035        let revoked = toml_ring().replace(
1036            &format!("sha256 = \"{}\"\ntenant = \"acme\"\n", sha256_hex(K_A1)),
1037            &format!(
1038                "sha256 = \"{}\"\ntenant = \"acme\"\nenabled = false\n",
1039                sha256_hex(K_A1)
1040            ),
1041        );
1042        std::fs::write(&path, revoked).unwrap();
1043        let new_mtime = SystemTime::now() + Duration::from_secs(2);
1044        let f = std::fs::File::options().write(true).open(&path).unwrap();
1045        f.set_modified(new_mtime).unwrap();
1046        drop(f);
1047        assert_eq!(
1048            ks.lookup(K_A1).unwrap_err(),
1049            AuthDenied::Disabled,
1050            "mtime bump must reload the ring"
1051        );
1052        // a BROKEN rewrite keeps the previous ring (never fail-open).
1053        std::fs::write(&path, "keys = \"not a ring\"").unwrap();
1054        let f = std::fs::File::options().write(true).open(&path).unwrap();
1055        f.set_modified(new_mtime + Duration::from_secs(2)).unwrap();
1056        drop(f);
1057        assert_eq!(
1058            ks.lookup(K_A1).unwrap_err(),
1059            AuthDenied::Disabled,
1060            "broken reload must keep the previous ring"
1061        );
1062        assert_eq!(ks.lookup(K_B1).unwrap().tenant, "blue");
1063        let _ = std::fs::remove_file(&path);
1064    }
1065
1066    #[test]
1067    fn auth_law_composes_keyring_and_single_key() {
1068        let path = tmpfile("law.toml");
1069        write_private(&path, &toml_ring());
1070        let ks = KeyStore::from_spec(path.to_str().unwrap()).unwrap();
1071        // keyring key -> its tenant; single key -> "default"; both live at once.
1072        assert_eq!(
1073            authenticate_with(Some(&ks), Some("daily"), Some(K_A1))
1074                .unwrap()
1075                .tenant,
1076            "acme"
1077        );
1078        assert_eq!(
1079            authenticate_with(Some(&ks), Some("daily"), Some("daily")).unwrap(),
1080            TenantCtx::default_tenant()
1081        );
1082        // wrong key -> Unknown; disabled -> Disabled; missing header -> Unknown.
1083        assert_eq!(
1084            authenticate_with(Some(&ks), Some("daily"), Some("nope")).unwrap_err(),
1085            AuthDenied::Unknown
1086        );
1087        assert_eq!(
1088            authenticate_with(Some(&ks), Some("daily"), Some(K_DIS)).unwrap_err(),
1089            AuthDenied::Disabled
1090        );
1091        assert_eq!(
1092            authenticate_with(Some(&ks), Some("daily"), None).unwrap_err(),
1093            AuthDenied::Unknown
1094        );
1095        // single key only (the back-compat daily driver): unchanged.
1096        assert_eq!(
1097            authenticate_with(None, Some("daily"), Some("daily")).unwrap(),
1098            TenantCtx::default_tenant()
1099        );
1100        assert_eq!(
1101            authenticate_with(None, Some("daily"), Some("x")).unwrap_err(),
1102            AuthDenied::Unknown
1103        );
1104        // nothing configured: open.
1105        assert_eq!(
1106            authenticate_with(None, None, None).unwrap(),
1107            TenantCtx::default_tenant()
1108        );
1109        let _ = std::fs::remove_file(&path);
1110    }
1111
1112    #[test]
1113    fn namespace_scoping_is_tenant_separated_and_unforgeable() {
1114        // same tenant, two keys, same salt -> SAME namespace (a tenant's keys share cache).
1115        assert_eq!(scope_namespace("acme", "s"), scope_namespace("acme", "s"));
1116        // different tenants never collide, salted or not.
1117        assert_ne!(scope_namespace("acme", ""), scope_namespace("blue", ""));
1118        assert_ne!(scope_namespace("acme", "s"), scope_namespace("blue", "s"));
1119        // a client salt cannot forge another tenant's namespace: the separator \x1f is
1120        // excluded from tenant ids, so "t:blue\x1f" can only be produced BY tenant blue.
1121        let forged_salt = format!("blue{}", '\u{1f}'); // attacker-controlled cache_salt
1122        assert_ne!(
1123            scope_namespace("acme", &forged_salt),
1124            scope_namespace("blue", "")
1125        );
1126        // salted vs unsalted stay distinct within a tenant.
1127        assert_ne!(scope_namespace("acme", "s"), scope_namespace("acme", ""));
1128    }
1129
1130    #[test]
1131    fn meter_key_extracts_tenant_and_passes_raw_salts_through() {
1132        // keyring namespaces collapse to the tenant half — salts never split a tenant's row.
1133        assert_eq!(meter_key(&scope_namespace("acme", "u1")), "t:acme");
1134        assert_eq!(meter_key(&scope_namespace("acme", "u2")), "t:acme");
1135        assert_eq!(meter_key(&scope_namespace("blue", "")), "t:blue");
1136        // no keyring: raw salts pass through, "" = the default namespace.
1137        assert_eq!(meter_key("session-7"), "session-7");
1138        assert_eq!(meter_key(""), "");
1139        // a raw salt that merely LOOKS like a scoped namespace but has no separator
1140        // stays itself (client text cannot contain NS_SEP-scoped rows without a keyring
1141        // because scope_namespace only runs when auth is configured).
1142        assert_eq!(meter_key("t:fake"), "t:fake");
1143        // unforgeable within a keyring: a salt carrying NS_SEP cannot escape its tenant.
1144        let forged = scope_namespace("acme", &format!("blue{}", '\u{1f}'));
1145        assert_eq!(meter_key(&forged), "t:acme");
1146    }
1147
1148    #[test]
1149    fn gen_key_prints_once_and_stores_only_the_hash() {
1150        use std::os::unix::fs::PermissionsExt;
1151
1152        let path = tmpfile("gen.toml");
1153        let key = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1154        assert!(key.starts_with("mk-acme-"));
1155        assert_eq!(key.len(), "mk-acme-".len() + 48);
1156        assert_eq!(
1157            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1158            0o640
1159        );
1160        let text = std::fs::read_to_string(&path).unwrap();
1161        assert!(!text.contains(&key), "plaintext must never reach the file");
1162        assert!(text.contains(&sha256_hex(&key)));
1163        // the ring authenticates the printed key.
1164        let ring = Keyring::from_toml(&text).unwrap();
1165        assert_eq!(ring.lookup(&key).unwrap().tenant, "acme");
1166        // a second key appends; a batch-lane + rate-limit key carries both fields.
1167        let key2 = gen_key(&path, "blue", LaneClass::Batch, Some(4)).unwrap();
1168        let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1169        assert_eq!(ring.len(), 2);
1170        let ctx = ring.lookup(&key2).unwrap();
1171        assert_eq!(ctx.lane_class, LaneClass::Batch);
1172        assert_eq!(ctx.rate_limit, Some(4));
1173        // bad tenant is refused before touching the file.
1174        assert!(gen_key(&path, "bad tenant", LaneClass::Interactive, None).is_err());
1175        let _ = std::fs::remove_file(&path);
1176    }
1177
1178    #[test]
1179    fn install_key_is_idempotent_and_rejects_fleet_policy_drift() {
1180        let path = tmpfile("install.toml");
1181        let key = "mk-acme-0123456789abcdef0123456789abcdef0123456789abcdef";
1182        let prefix = install_key(&path, "acme", LaneClass::Interactive, Some(2), key).unwrap();
1183        assert_eq!(prefix, "mk-acme-0123456789ab");
1184        assert_eq!(
1185            install_key(&path, "acme", LaneClass::Interactive, Some(2), key).unwrap(),
1186            prefix,
1187            "an exact fan-out retry must be idempotent"
1188        );
1189        assert_eq!(
1190            Keyring::from_toml(&std::fs::read_to_string(&path).unwrap())
1191                .unwrap()
1192                .len(),
1193            1,
1194        );
1195        assert!(
1196            install_key(&path, "acme", LaneClass::Batch, Some(2), key)
1197                .unwrap_err()
1198                .contains("different key material or policy")
1199        );
1200        assert!(
1201            install_key(
1202                &path,
1203                "acme",
1204                LaneClass::Interactive,
1205                Some(2),
1206                "mk-acme-0123456789abcdef0123456789abcdef0123456789abcdeg",
1207            )
1208            .unwrap_err()
1209            .contains("lowercase hexadecimal")
1210        );
1211        let _ = std::fs::remove_file(&path);
1212    }
1213
1214    #[test]
1215    fn revoke_key_flips_enabled_by_prefix_exactly_once() {
1216        use std::os::unix::fs::PermissionsExt;
1217
1218        let path = tmpfile("revoke.toml");
1219        let key_a = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1220        let key_b = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1221        // ambiguous prefix (both start mk-acme-) -> error, nothing revoked.
1222        assert!(
1223            revoke_key(&path, "mk-acme-")
1224                .unwrap_err()
1225                .contains("2 keys")
1226        );
1227        // unique prefix -> revoked; the other key still works; re-revoke errors.
1228        let prefix_a = format!(
1229            "mk-acme-{}",
1230            &key_a["mk-acme-".len().."mk-acme-".len() + 12]
1231        );
1232        revoke_key(&path, &prefix_a).unwrap();
1233        assert_eq!(
1234            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1235            0o640
1236        );
1237        assert!(!PathBuf::from(format!("{}.tmp", path.display())).exists());
1238        let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1239        assert_eq!(ring.lookup(&key_a).unwrap_err(), AuthDenied::Disabled);
1240        assert_eq!(ring.lookup(&key_b).unwrap().tenant, "acme");
1241        assert!(
1242            revoke_key(&path, &prefix_a)
1243                .unwrap_err()
1244                .contains("already revoked")
1245        );
1246        // full plaintext key also works as the handle (hash match).
1247        revoke_key(&path, &key_b).unwrap();
1248        let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1249        assert_eq!(ring.lookup(&key_b).unwrap_err(), AuthDenied::Disabled);
1250        // no match -> error.
1251        assert!(revoke_key(&path, "mk-zzz").unwrap_err().contains("no key"));
1252        let _ = std::fs::remove_file(&path);
1253    }
1254
1255    #[test]
1256    fn atomic_rewrite_survives_concurrent_hot_reload() {
1257        use std::sync::Arc;
1258        use std::sync::atomic::{AtomicBool, Ordering};
1259
1260        let path = tmpfile("atomic-reload.toml");
1261        let keys: Vec<KeyEntry> = (0..512)
1262            .map(|i| KeyEntry {
1263                prefix: format!("mk-tenant-{i:04}"),
1264                sha256: sha256_hex(&format!("secret-{i:04}")),
1265                tenant: "tenant".into(),
1266                lane: None,
1267                enabled: true,
1268                rate_limit: None,
1269                created_unix: None,
1270            })
1271            .collect();
1272        write_private(&path, &toml::to_string(&KeyFile { keys }).unwrap());
1273        let store = Arc::new(
1274            KeyStore::from_spec(path.to_str().unwrap())
1275                .unwrap()
1276                .with_poll(Duration::ZERO),
1277        );
1278        let running = Arc::new(AtomicBool::new(true));
1279        let start = Arc::new(std::sync::Barrier::new(2));
1280        let reader = {
1281            let path = path.clone();
1282            let store = store.clone();
1283            let running = running.clone();
1284            let start = start.clone();
1285            std::thread::spawn(move || {
1286                start.wait();
1287                while running.load(Ordering::Acquire) {
1288                    let text = std::fs::read_to_string(&path).unwrap();
1289                    let ring = Keyring::from_toml(&text)
1290                        .expect("a concurrent reader must see the old or new complete ring");
1291                    assert_eq!(ring.len(), 512, "the target must never be truncate-visible");
1292                    assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1293                }
1294            })
1295        };
1296
1297        start.wait();
1298        let rewrites =
1299            (0..32).try_for_each(|i| revoke_key(&path, &format!("mk-tenant-{i:04}")).map(|_| ()));
1300        running.store(false, Ordering::Release);
1301        reader.join().unwrap();
1302        rewrites.unwrap();
1303
1304        let new_mtime = SystemTime::now() + Duration::from_secs(2);
1305        let file = std::fs::File::options().write(true).open(&path).unwrap();
1306        file.set_modified(new_mtime).unwrap();
1307        drop(file);
1308        assert_eq!(
1309            store.lookup("secret-0000").unwrap_err(),
1310            AuthDenied::Disabled
1311        );
1312        assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1313        let _ = std::fs::remove_file(&path);
1314    }
1315}