1use 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
39const NS_SEP: char = '\u{1f}';
42
43pub 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
60pub 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct KeyEntry {
103 #[serde(default)]
106 pub prefix: String,
107 pub sha256: String,
109 pub tenant: String,
110 #[serde(default)]
112 pub lane: Option<String>,
113 #[serde(default = "default_true")]
116 pub enabled: bool,
117 #[serde(default)]
120 pub rate_limit: Option<usize>,
121 #[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#[derive(Debug, Clone, PartialEq)]
139pub struct TenantCtx {
140 pub tenant: String,
141 pub lane_class: LaneClass,
142 pub rate_limit: Option<usize>,
143 pub key_prefix: Option<String>,
149}
150
151impl TenantCtx {
152 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#[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#[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 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 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 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 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
321pub 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
387fn 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 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 pub fn with_poll(mut self, poll: Duration) -> KeyStore {
459 self.poll = poll;
460 self
461 }
462
463 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 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; }
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; }
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
517static KEYSTORE: std::sync::OnceLock<Option<KeyStore>> = std::sync::OnceLock::new();
520
521pub 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
535pub fn global() -> Option<&'static KeyStore> {
537 KEYSTORE.get().and_then(|o| o.as_ref())
538}
539
540pub 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()); }
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) => {} }
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
571pub fn scope_namespace(tenant: &str, raw_salt: &str) -> String {
576 format!("t:{tenant}{NS_SEP}{raw_salt}")
577}
578
579pub 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
595fn 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
607pub 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
621pub 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 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 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
766pub 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
803pub 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 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 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 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 assert_eq!(ring.lookup(K_DIS).unwrap_err(), AuthDenied::Disabled);
965 assert_eq!(ring.lookup("mk-nope-x").unwrap_err(), AuthDenied::Unknown);
966 assert!(!toml_ring().contains(K_A1));
968 }
969
970 #[test]
971 fn malformed_rings_are_loud_errors() {
972 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 let bad = "[[keys]]\nsha256 = \"abc123\"\ntenant = \"t\"\n";
994 assert!(Keyring::from_toml(bad).unwrap_err().contains("64 hex"));
995 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 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 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 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 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 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 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 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 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 assert_eq!(scope_namespace("acme", "s"), scope_namespace("acme", "s"));
1116 assert_ne!(scope_namespace("acme", ""), scope_namespace("blue", ""));
1118 assert_ne!(scope_namespace("acme", "s"), scope_namespace("blue", "s"));
1119 let forged_salt = format!("blue{}", '\u{1f}'); assert_ne!(
1123 scope_namespace("acme", &forged_salt),
1124 scope_namespace("blue", "")
1125 );
1126 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 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 assert_eq!(meter_key("session-7"), "session-7");
1138 assert_eq!(meter_key(""), "");
1139 assert_eq!(meter_key("t:fake"), "t:fake");
1143 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 let ring = Keyring::from_toml(&text).unwrap();
1165 assert_eq!(ring.lookup(&key).unwrap().tenant, "acme");
1166 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 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 assert!(
1223 revoke_key(&path, "mk-acme-")
1224 .unwrap_err()
1225 .contains("2 keys")
1226 );
1227 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 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 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}