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}
144
145impl TenantCtx {
146 pub fn default_tenant() -> Self {
148 TenantCtx {
149 tenant: "default".into(),
150 lane_class: LaneClass::Interactive,
151 rate_limit: None,
152 }
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum AuthDenied {
159 Unknown,
160 Disabled,
161}
162
163#[derive(Debug)]
164struct StoredKey {
165 digest: [u8; 32],
166 entry: KeyEntry,
167}
168
169#[derive(Debug, Default)]
172pub struct Keyring {
173 keys: Vec<StoredKey>,
174}
175
176fn valid_tenant(t: &str) -> bool {
177 !t.is_empty()
178 && t.chars()
179 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
180}
181
182pub fn tenant_is_valid(tenant: &str) -> bool {
183 valid_tenant(tenant)
184}
185
186fn valid_sha256(s: &str) -> bool {
187 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
188}
189
190fn parse_sha256(s: &str) -> Option<[u8; 32]> {
191 if !valid_sha256(s) {
192 return None;
193 }
194 let mut digest = [0u8; 32];
195 for (i, byte) in digest.iter_mut().enumerate() {
196 *byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
197 }
198 Some(digest)
199}
200
201impl Keyring {
202 pub fn from_entries(entries: Vec<KeyEntry>) -> Result<Keyring, String> {
205 let mut seen = HashSet::with_capacity(entries.len());
206 let mut keys = Vec::with_capacity(entries.len());
207 for (i, mut e) in entries.into_iter().enumerate() {
208 if !valid_tenant(&e.tenant) {
209 return Err(format!(
210 "key entry {i}: bad tenant {:?} (want [A-Za-z0-9_-]+)",
211 e.tenant
212 ));
213 }
214 e.sha256 = e.sha256.to_lowercase();
215 if !valid_sha256(&e.sha256) {
216 return Err(format!(
217 "key entry {i} (tenant {:?}): sha256 must be 64 hex chars",
218 e.tenant
219 ));
220 }
221 if let Some(lane) = e.lane.as_deref() {
222 if LaneClass::parse(lane).is_none() {
223 return Err(format!(
224 "key entry {i} (tenant {:?}): bad lane {lane:?} (interactive|batch)",
225 e.tenant
226 ));
227 }
228 }
229 if e.rate_limit == Some(0) {
230 return Err(format!(
231 "key entry {i} (tenant {:?}): rate_limit 0 would admit nothing — \
232 use enabled = false to revoke",
233 e.tenant
234 ));
235 }
236 let digest = parse_sha256(&e.sha256).expect("validated SHA-256 hex");
237 if !seen.insert(digest) {
238 return Err(format!(
239 "key entry {i}: duplicate sha256 (same key listed twice)"
240 ));
241 }
242 keys.push(StoredKey { digest, entry: e });
243 }
244 Ok(Keyring { keys })
245 }
246
247 pub fn from_toml(text: &str) -> Result<Keyring, String> {
249 let f: KeyFile = toml::from_str(text).map_err(|e| format!("keys.toml parse: {e}"))?;
250 Keyring::from_entries(f.keys)
251 }
252
253 pub fn from_inline(spec: &str) -> Result<Keyring, String> {
256 let mut entries = Vec::new();
257 for part in spec.split(',').filter(|s| !s.trim().is_empty()) {
258 let fields: Vec<&str> = part.trim().split(':').collect();
259 if fields.len() < 2 || fields.len() > 3 {
260 return Err(format!(
261 "bad MEMRA_API_KEYS inline entry {part:?} (want tenant:sha256hex[:lane])"
262 ));
263 }
264 entries.push(KeyEntry {
265 prefix: String::new(),
266 sha256: fields[1].to_string(),
267 tenant: fields[0].to_string(),
268 lane: fields.get(2).map(|s| s.to_string()),
269 enabled: true,
270 rate_limit: None,
271 created_unix: None,
272 });
273 }
274 if entries.is_empty() {
275 return Err("MEMRA_API_KEYS inline list is empty".into());
276 }
277 Keyring::from_entries(entries)
278 }
279
280 pub fn len(&self) -> usize {
281 self.keys.len()
282 }
283
284 pub fn lookup(&self, key: &str) -> Result<TenantCtx, AuthDenied> {
286 let digest = sha256_digest(key);
287 let mut matched = None;
288 for stored in &self.keys {
289 if constant_time_digest_eq(&stored.digest, &digest) {
290 matched = Some(&stored.entry);
291 }
292 }
293 match matched {
294 None => Err(AuthDenied::Unknown),
295 Some(e) if !e.enabled => Err(AuthDenied::Disabled),
296 Some(e) => Ok(TenantCtx {
297 tenant: e.tenant.clone(),
298 lane_class: e
299 .lane
300 .as_deref()
301 .and_then(LaneClass::parse)
302 .unwrap_or_default(),
303 rate_limit: e.rate_limit,
304 }),
305 }
306 }
307}
308
309pub struct KeyStore {
313 source: Source,
314 poll: Duration,
315 state: RwLock<State>,
316}
317
318enum Source {
319 File(PathBuf),
320 Inline,
321}
322
323struct State {
324 ring: Keyring,
325 mtime: Option<SystemTime>,
326 checked: Instant,
327}
328
329fn file_mtime(p: &Path) -> Option<SystemTime> {
330 std::fs::symlink_metadata(p).and_then(|m| m.modified()).ok()
331}
332
333fn validate_private_keyring_metadata(
334 file: &std::fs::File,
335 path: &Path,
336) -> Result<SystemTime, String> {
337 let metadata = file
338 .metadata()
339 .map_err(|e| format!("stat keyring {}: {e}", path.display()))?;
340 if !metadata.is_file() {
341 return Err(format!("keyring {} is not a regular file", path.display()));
342 }
343 #[cfg(unix)]
344 {
345 use std::os::unix::fs::{MetadataExt, PermissionsExt};
346 let mode = metadata.permissions().mode() & 0o777;
347 if mode & 0o137 != 0 {
348 return Err(format!(
349 "keyring {} must have 0600 or 0640-class permissions; found {mode:04o}",
350 path.display()
351 ));
352 }
353 let expected_uid = unsafe { libc::geteuid() } as u32;
354 if metadata.uid() != expected_uid {
355 return Err(format!(
356 "keyring {} is not owned by the service uid {} (found {})",
357 path.display(),
358 expected_uid,
359 metadata.uid()
360 ));
361 }
362 if metadata.nlink() != 1 {
363 return Err(format!(
364 "keyring {} has {} hard links; expected exactly one",
365 path.display(),
366 metadata.nlink()
367 ));
368 }
369 }
370 metadata
371 .modified()
372 .map_err(|e| format!("stat keyring {}: {e}", path.display()))
373}
374
375fn read_private_keyring(path: &Path) -> Result<(String, SystemTime), String> {
380 use std::fs::OpenOptions;
381 use std::os::unix::fs::OpenOptionsExt;
382 let file = OpenOptions::new()
383 .read(true)
384 .custom_flags(libc::O_NOFOLLOW)
385 .open(path)
386 .map_err(|e| format!("{}: {e}", path.display()))?;
387 let mtime = validate_private_keyring_metadata(&file, path)?;
388 let mut text = String::new();
389 (&file)
390 .take(8 * 1024 * 1024 + 1)
391 .read_to_string(&mut text)
392 .map_err(|e| format!("read keyring {}: {e}", path.display()))?;
393 if text.len() > 8 * 1024 * 1024 {
394 return Err(format!(
395 "keyring {} exceeds the 8 MiB limit",
396 path.display()
397 ));
398 }
399 Ok((text, mtime))
400}
401
402impl KeyStore {
403 pub fn from_spec(spec: &str) -> Result<KeyStore, String> {
407 let p = Path::new(spec);
408 if p.is_file() {
409 let (text, mtime) =
410 read_private_keyring(p).map_err(|e| format!("MEMRA_API_KEYS {spec:?}: {e}"))?;
411 let ring = Keyring::from_toml(&text).map_err(|e| format!("{spec}: {e}"))?;
412 let n = ring.len();
413 eprintln!("[auth] keyring loaded: {n} key(s) from {spec}");
414 return Ok(KeyStore {
415 source: Source::File(p.to_path_buf()),
416 poll: Duration::from_secs(2),
417 state: RwLock::new(State {
418 ring,
419 mtime: Some(mtime),
420 checked: Instant::now(),
421 }),
422 });
423 }
424 if spec.contains(':') {
425 let ring = Keyring::from_inline(spec)?;
426 eprintln!("[auth] keyring loaded: {} inline key(s)", ring.len());
427 return Ok(KeyStore {
428 source: Source::Inline,
429 poll: Duration::from_secs(2),
430 state: RwLock::new(State {
431 ring,
432 mtime: None,
433 checked: Instant::now(),
434 }),
435 });
436 }
437 Err(format!(
438 "MEMRA_API_KEYS={spec:?} is neither an existing keys.toml path nor an inline \
439 tenant:sha256hex list"
440 ))
441 }
442
443 pub fn with_poll(mut self, poll: Duration) -> KeyStore {
447 self.poll = poll;
448 self
449 }
450
451 pub fn file_path(&self) -> Option<&Path> {
453 match &self.source {
454 Source::File(path) => Some(path),
455 Source::Inline => None,
456 }
457 }
458
459 fn maybe_reload(&self) {
462 let Source::File(path) = &self.source else {
463 return;
464 };
465 {
466 let st = self.state.read().unwrap();
467 if st.checked.elapsed() < self.poll {
468 return;
469 }
470 }
471 let mut st = self.state.write().unwrap();
472 if st.checked.elapsed() < self.poll {
473 return; }
475 st.checked = Instant::now();
476 let mtime = file_mtime(path);
477 if mtime == st.mtime {
478 return;
479 }
480 match read_private_keyring(path)
481 .and_then(|(text, mtime)| Keyring::from_toml(&text).map(|ring| (ring, mtime)))
482 {
483 Ok((ring, mtime)) => {
484 eprintln!(
485 "[auth] keyring reloaded: {} key(s) from {}",
486 ring.len(),
487 path.display()
488 );
489 st.ring = ring;
490 st.mtime = Some(mtime);
491 }
492 Err(e) => {
493 eprintln!("[auth] keyring reload FAILED ({e}); keeping the previous ring");
494 st.mtime = mtime; }
496 }
497 }
498
499 pub fn lookup(&self, key: &str) -> Result<TenantCtx, AuthDenied> {
500 self.maybe_reload();
501 self.state.read().unwrap().ring.lookup(key)
502 }
503}
504
505static KEYSTORE: std::sync::OnceLock<Option<KeyStore>> = std::sync::OnceLock::new();
508
509pub fn init_from_env() {
511 KEYSTORE.get_or_init(|| match std::env::var("MEMRA_API_KEYS") {
512 Err(_) => None,
513 Ok(spec) => match KeyStore::from_spec(&spec) {
514 Ok(ks) => Some(ks),
515 Err(e) => {
516 eprintln!("[auth] FATAL: {e}");
517 std::process::exit(1);
518 }
519 },
520 });
521}
522
523pub fn global() -> Option<&'static KeyStore> {
525 KEYSTORE.get().and_then(|o| o.as_ref())
526}
527
528pub fn authenticate_with(
536 keyring: Option<&KeyStore>,
537 single_key: Option<&str>,
538 bearer: Option<&str>,
539) -> Result<TenantCtx, AuthDenied> {
540 if keyring.is_none() && single_key.is_none() {
541 return Ok(TenantCtx::default_tenant()); }
543 let Some(candidate) = bearer else {
544 return Err(AuthDenied::Unknown);
545 };
546 if let Some(ks) = keyring {
547 match ks.lookup(candidate) {
548 Ok(ctx) => return Ok(ctx),
549 Err(AuthDenied::Disabled) => return Err(AuthDenied::Disabled),
550 Err(AuthDenied::Unknown) => {} }
552 }
553 if single_key.is_some_and(|k| constant_time_secret_eq(k, candidate)) {
554 return Ok(TenantCtx::default_tenant());
555 }
556 Err(AuthDenied::Unknown)
557}
558
559pub fn scope_namespace(tenant: &str, raw_salt: &str) -> String {
564 format!("t:{tenant}{NS_SEP}{raw_salt}")
565}
566
567pub fn meter_key(cache_ns: &str) -> &str {
574 match cache_ns
575 .strip_prefix("t:")
576 .and_then(|rest| rest.find(NS_SEP))
577 {
578 Some(sep) => &cache_ns[..2 + sep],
579 None => cache_ns,
580 }
581}
582
583fn random_hex48() -> Result<String, String> {
587 use std::io::Read;
588 let mut f = std::fs::File::open("/dev/urandom").map_err(|e| format!("/dev/urandom: {e}"))?;
589 let mut buf = [0u8; 24];
590 f.read_exact(&mut buf)
591 .map_err(|e| format!("/dev/urandom read: {e}"))?;
592 Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
593}
594
595pub fn gen_key(
598 keys_path: &Path,
599 tenant: &str,
600 lane: LaneClass,
601 rate_limit: Option<usize>,
602) -> Result<String, String> {
603 if !valid_tenant(tenant) {
604 return Err(format!("bad tenant {tenant:?} (want [A-Za-z0-9_-]+)"));
605 }
606 if rate_limit == Some(0) {
607 return Err("rate limit 0 would admit nothing".into());
608 }
609 let secret = random_hex48()?;
610 let key = format!("mk-{tenant}-{secret}");
611 let prefix = format!("mk-{tenant}-{}", &secret[..12]);
612 let created = SystemTime::now()
613 .duration_since(SystemTime::UNIX_EPOCH)
614 .map(|d| d.as_secs())
615 .unwrap_or(0);
616
617 if keys_path.is_file() {
620 let (text, _) = read_private_keyring(keys_path)?;
621 let f: KeyFile =
622 toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
623 Keyring::from_entries(f.keys.clone())?;
624 if f.keys.iter().any(|e| e.prefix == prefix) {
625 return Err(format!(
626 "prefix {prefix} already exists (rerun to draw a new key)"
627 ));
628 }
629 }
630
631 let mut fragment = String::new();
633 if !keys_path.is_file() {
634 fragment.push_str(
635 "# memra API keyring (MEMRA_API_KEYS points here).\n\
636 # Entries store SHA-256 of the key, never the plaintext. Managed by\n\
637 # `memra-server --gen-key <tenant>` / `--revoke-key <prefix>` (revoke\n\
638 # rewrites the file; comments outside this header are not preserved).\n",
639 );
640 }
641 fragment.push_str(&format!(
642 "\n[[keys]]\nprefix = \"{prefix}\"\nsha256 = \"{}\"\ntenant = \"{tenant}\"\n\
643 lane = \"{}\"\nenabled = true\ncreated_unix = {created}\n",
644 sha256_hex(&key),
645 lane.as_str()
646 ));
647 if let Some(rl) = rate_limit {
648 fragment.push_str(&format!("rate_limit = {rl}\n"));
649 }
650 use std::io::Write;
651 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
652 let creating = !keys_path.exists();
653 let mut f = std::fs::OpenOptions::new()
654 .create(true)
655 .append(true)
656 .mode(0o640)
657 .custom_flags(libc::O_NOFOLLOW)
658 .open(keys_path)
659 .map_err(|e| format!("{}: {e}", keys_path.display()))?;
660 validate_private_keyring_metadata(&f, keys_path)?;
661 if creating {
662 f.set_permissions(std::fs::Permissions::from_mode(0o640))
663 .map_err(|e| format!("{}: {e}", keys_path.display()))?;
664 }
665 f.write_all(fragment.as_bytes())
666 .map_err(|e| format!("{}: {e}", keys_path.display()))?;
667 f.sync_data()
668 .map_err(|e| format!("sync {}: {e}", keys_path.display()))?;
669 if creating {
670 sync_parent_dir(keys_path, "keyring")?;
671 }
672 Ok(key)
673}
674
675fn sync_parent_dir(path: &Path, label: &str) -> Result<(), String> {
676 let parent = path.parent().unwrap_or_else(|| Path::new("."));
677 std::fs::File::open(parent)
678 .and_then(|directory| directory.sync_all())
679 .map_err(|e| format!("sync {label} directory {}: {e}", parent.display()))
680}
681
682fn atomic_rewrite(keys_path: &Path, contents: &str) -> Result<(), String> {
683 use std::io::Write;
684 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
685
686 let parent = keys_path.parent().unwrap_or_else(|| Path::new("."));
687 let name = keys_path
688 .file_name()
689 .and_then(|name| name.to_str())
690 .unwrap_or("keys");
691 let mut random = [0u8; 16];
692 std::fs::File::open("/dev/urandom")
693 .and_then(|mut source| source.read_exact(&mut random))
694 .map_err(|e| format!("randomize keyring temporary name: {e}"))?;
695 let suffix = random
696 .iter()
697 .map(|byte| format!("{byte:02x}"))
698 .collect::<String>();
699 let tmp_path = parent.join(format!(".{name}.tmp.{suffix}"));
700 let mut tmp = std::fs::OpenOptions::new()
701 .create_new(true)
702 .write(true)
703 .mode(0o640)
704 .custom_flags(libc::O_NOFOLLOW)
705 .open(&tmp_path)
706 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
707 tmp.set_permissions(std::fs::Permissions::from_mode(0o640))
708 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
709 tmp.write_all(contents.as_bytes())
710 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
711 tmp.sync_all()
712 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
713 validate_private_keyring_metadata(&tmp, &tmp_path)?;
714 drop(tmp);
715 std::fs::rename(&tmp_path, keys_path)
716 .map_err(|e| format!("{} -> {}: {e}", tmp_path.display(), keys_path.display()))?;
717 sync_parent_dir(keys_path, "keyring")
718}
719
720pub fn revoke_key(keys_path: &Path, handle: &str) -> Result<String, String> {
724 let (text, _) = read_private_keyring(keys_path)?;
725 let mut f: KeyFile =
726 toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
727 Keyring::from_entries(f.keys.clone())?;
728 let full_hash = sha256_digest(handle);
729 let matches: Vec<usize> = f
730 .keys
731 .iter()
732 .enumerate()
733 .filter(|(_, e)| {
734 (!e.prefix.is_empty() && e.prefix.starts_with(handle))
735 || parse_sha256(&e.sha256)
736 .is_some_and(|digest| constant_time_digest_eq(&digest, &full_hash))
737 })
738 .map(|(i, _)| i)
739 .collect();
740 match matches.len() {
741 0 => Err(format!("no key matches {handle:?}")),
742 1 => {
743 let i = matches[0];
744 if !f.keys[i].enabled {
745 return Err(format!("key {} is already revoked", f.keys[i].prefix));
746 }
747 f.keys[i].enabled = false;
748 let revoked = f.keys[i].prefix.clone();
749 let out = toml::to_string(&f).map_err(|e| e.to_string())?;
750 atomic_rewrite(keys_path, &out)?;
751 Ok(revoked)
752 }
753 n => Err(format!("{n} keys match {handle:?} — use a longer prefix")),
754 }
755}
756
757pub fn run_cli(args: &[String]) -> Option<i32> {
760 let has = |flag: &str| args.iter().any(|a| a == flag);
761 if !has("--gen-key") && !has("--revoke-key") {
762 return None;
763 }
764 let value_of = |flag: &str| -> Option<String> {
765 args.iter()
766 .position(|a| a == flag)
767 .and_then(|i| args.get(i + 1).cloned())
768 };
769 let keys_path = value_of("--keys")
770 .or_else(|| std::env::var("MEMRA_API_KEYS").ok())
771 .map(PathBuf::from);
772 let Some(keys_path) = keys_path else {
773 eprintln!("error: no keys file — pass --keys /path/keys.toml or set MEMRA_API_KEYS");
774 return Some(2);
775 };
776 if keys_path.exists() && !keys_path.is_file() {
777 eprintln!("error: {} is not a file", keys_path.display());
778 return Some(2);
779 }
780
781 if has("--gen-key") {
782 let Some(tenant) = value_of("--gen-key") else {
783 eprintln!(
784 "usage: memra-server --gen-key <tenant> [--lane interactive|batch] \
785 [--rate-limit N] [--keys /path/keys.toml]"
786 );
787 return Some(2);
788 };
789 let lane = match value_of("--lane") {
790 None => LaneClass::Interactive,
791 Some(v) => match LaneClass::parse(&v) {
792 Some(l) => l,
793 None => {
794 eprintln!("error: bad --lane {v:?} (interactive|batch)");
795 return Some(2);
796 }
797 },
798 };
799 let rate_limit = match value_of("--rate-limit") {
800 None => None,
801 Some(v) => match v.parse::<usize>() {
802 Ok(n) => Some(n),
803 Err(_) => {
804 eprintln!("error: bad --rate-limit {v:?} (want a positive integer)");
805 return Some(2);
806 }
807 },
808 };
809 return Some(match gen_key(&keys_path, &tenant, lane, rate_limit) {
810 Ok(key) => {
811 println!("{key}");
812 eprintln!(
813 "[gen-key] tenant {tenant:?} lane {} appended to {} — \
814 the plaintext above is shown ONCE and stored only as SHA-256",
815 lane.as_str(),
816 keys_path.display()
817 );
818 0
819 }
820 Err(e) => {
821 eprintln!("error: {e}");
822 1
823 }
824 });
825 }
826
827 let Some(handle) = value_of("--revoke-key") else {
829 eprintln!("usage: memra-server --revoke-key <prefix> [--keys /path/keys.toml]");
830 return Some(2);
831 };
832 Some(match revoke_key(&keys_path, &handle) {
833 Ok(prefix) => {
834 eprintln!(
835 "[revoke-key] {prefix} disabled in {} (takes effect on the next \
836 keyring poll, <=2s on a running server)",
837 keys_path.display()
838 );
839 0
840 }
841 Err(e) => {
842 eprintln!("error: {e}");
843 1
844 }
845 })
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 fn tmpfile(name: &str) -> PathBuf {
853 let p = std::env::temp_dir().join(format!("memra_auth_{}_{name}", std::process::id()));
854 let _ = std::fs::remove_file(&p);
855 p
856 }
857
858 fn write_private(path: &Path, contents: &str) {
859 use std::os::unix::fs::PermissionsExt;
860 std::fs::write(path, contents).unwrap();
861 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o640)).unwrap();
862 }
863
864 const K_A1: &str = "mk-acme-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
865 const K_A2: &str = "mk-acme-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
866 const K_B1: &str = "mk-blue-cccccccccccccccccccccccccccccccccccccccccccccccc";
867 const K_DIS: &str = "mk-dead-dddddddddddddddddddddddddddddddddddddddddddddddd";
868
869 fn toml_ring() -> String {
870 format!(
871 "[[keys]]\nprefix = \"mk-acme-aaaa\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\n\
872 [[keys]]\nprefix = \"mk-acme-bbbb\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\
873 rate_limit = 2\n\n\
874 [[keys]]\nprefix = \"mk-blue-cccc\"\nsha256 = \"{}\"\ntenant = \"blue\"\n\
875 lane = \"batch\"\n\n\
876 [[keys]]\nprefix = \"mk-dead-dddd\"\nsha256 = \"{}\"\ntenant = \"dead\"\n\
877 enabled = false\n",
878 sha256_hex(K_A1),
879 sha256_hex(K_A2),
880 sha256_hex(K_B1),
881 sha256_hex(K_DIS)
882 )
883 }
884
885 #[test]
886 fn sha256_hex_matches_known_vector() {
887 assert_eq!(
889 sha256_hex("abc"),
890 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
891 );
892 }
893
894 #[test]
895 fn fixed_digest_secret_comparison_preserves_auth_semantics() {
896 assert!(constant_time_secret_eq("same", "same"));
897 assert!(!constant_time_secret_eq("same", "same-but-longer"));
898 assert!(!constant_time_secret_eq("prefix-a", "prefix-b"));
899 assert!(!constant_time_secret_eq("", "nonempty"));
900 }
901
902 #[test]
903 fn toml_ring_parses_and_looks_up_by_hash() {
904 let ring = Keyring::from_toml(&toml_ring()).unwrap();
905 assert_eq!(ring.len(), 4);
906 let ctx = ring.lookup(K_A1).unwrap();
908 assert_eq!(ctx.tenant, "acme");
909 assert_eq!(ctx.lane_class, LaneClass::Interactive);
910 assert_eq!(ctx.rate_limit, None);
911 let ctx = ring.lookup(K_A2).unwrap();
912 assert_eq!(ctx.tenant, "acme");
913 assert_eq!(ctx.rate_limit, Some(2));
914 let ctx = ring.lookup(K_B1).unwrap();
915 assert_eq!(ctx.tenant, "blue");
916 assert_eq!(ctx.lane_class, LaneClass::Batch);
917 assert_eq!(ring.lookup(K_DIS).unwrap_err(), AuthDenied::Disabled);
919 assert_eq!(ring.lookup("mk-nope-x").unwrap_err(), AuthDenied::Unknown);
920 assert!(!toml_ring().contains(K_A1));
922 }
923
924 #[test]
925 fn malformed_rings_are_loud_errors() {
926 let bad = format!(
929 "[[keys]]\nsha256 = \"{}\"\ntenant = \"a b\"\n",
930 sha256_hex("k")
931 );
932 assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad tenant"));
933 assert!(
934 Keyring::from_entries(vec![KeyEntry {
935 prefix: String::new(),
936 sha256: sha256_hex("k"),
937 tenant: format!("a{}b", '\u{1f}'),
938 lane: None,
939 enabled: true,
940 rate_limit: None,
941 created_unix: None,
942 }])
943 .unwrap_err()
944 .contains("bad tenant")
945 );
946 let bad = "[[keys]]\nsha256 = \"abc123\"\ntenant = \"t\"\n";
948 assert!(Keyring::from_toml(bad).unwrap_err().contains("64 hex"));
949 let bad = format!(
951 "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nlane = \"turbo\"\n",
952 sha256_hex("k")
953 );
954 assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad lane"));
955 let dup = format!(
957 "[[keys]]\nsha256 = \"{h}\"\ntenant = \"t\"\n\n\
958 [[keys]]\nsha256 = \"{h}\"\ntenant = \"u\"\n",
959 h = sha256_hex("k")
960 );
961 assert!(Keyring::from_toml(&dup).unwrap_err().contains("duplicate"));
962 let z = format!(
964 "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nrate_limit = 0\n",
965 sha256_hex("k")
966 );
967 assert!(Keyring::from_toml(&z).unwrap_err().contains("rate_limit 0"));
968 }
969
970 #[test]
971 fn inline_env_list_parses() {
972 let spec = format!("acme:{},blue:{}:batch", sha256_hex(K_A1), sha256_hex(K_B1));
973 let ring = Keyring::from_inline(&spec).unwrap();
974 assert_eq!(ring.lookup(K_A1).unwrap().tenant, "acme");
975 assert_eq!(ring.lookup(K_B1).unwrap().lane_class, LaneClass::Batch);
976 assert!(Keyring::from_inline("no-colon-here").is_err());
977 assert!(Keyring::from_inline("").is_err());
978 }
979
980 #[test]
981 fn keystore_hot_reloads_on_mtime_change() {
982 let path = tmpfile("reload.toml");
983 write_private(&path, &toml_ring());
984 let ks = KeyStore::from_spec(path.to_str().unwrap())
985 .unwrap()
986 .with_poll(Duration::ZERO);
987 assert_eq!(ks.lookup(K_A1).unwrap().tenant, "acme");
988 let revoked = toml_ring().replace(
990 &format!("sha256 = \"{}\"\ntenant = \"acme\"\n", sha256_hex(K_A1)),
991 &format!(
992 "sha256 = \"{}\"\ntenant = \"acme\"\nenabled = false\n",
993 sha256_hex(K_A1)
994 ),
995 );
996 std::fs::write(&path, revoked).unwrap();
997 let new_mtime = SystemTime::now() + Duration::from_secs(2);
998 let f = std::fs::File::options().write(true).open(&path).unwrap();
999 f.set_modified(new_mtime).unwrap();
1000 drop(f);
1001 assert_eq!(
1002 ks.lookup(K_A1).unwrap_err(),
1003 AuthDenied::Disabled,
1004 "mtime bump must reload the ring"
1005 );
1006 std::fs::write(&path, "keys = \"not a ring\"").unwrap();
1008 let f = std::fs::File::options().write(true).open(&path).unwrap();
1009 f.set_modified(new_mtime + Duration::from_secs(2)).unwrap();
1010 drop(f);
1011 assert_eq!(
1012 ks.lookup(K_A1).unwrap_err(),
1013 AuthDenied::Disabled,
1014 "broken reload must keep the previous ring"
1015 );
1016 assert_eq!(ks.lookup(K_B1).unwrap().tenant, "blue");
1017 let _ = std::fs::remove_file(&path);
1018 }
1019
1020 #[test]
1021 fn auth_law_composes_keyring_and_single_key() {
1022 let path = tmpfile("law.toml");
1023 write_private(&path, &toml_ring());
1024 let ks = KeyStore::from_spec(path.to_str().unwrap()).unwrap();
1025 assert_eq!(
1027 authenticate_with(Some(&ks), Some("daily"), Some(K_A1))
1028 .unwrap()
1029 .tenant,
1030 "acme"
1031 );
1032 assert_eq!(
1033 authenticate_with(Some(&ks), Some("daily"), Some("daily")).unwrap(),
1034 TenantCtx::default_tenant()
1035 );
1036 assert_eq!(
1038 authenticate_with(Some(&ks), Some("daily"), Some("nope")).unwrap_err(),
1039 AuthDenied::Unknown
1040 );
1041 assert_eq!(
1042 authenticate_with(Some(&ks), Some("daily"), Some(K_DIS)).unwrap_err(),
1043 AuthDenied::Disabled
1044 );
1045 assert_eq!(
1046 authenticate_with(Some(&ks), Some("daily"), None).unwrap_err(),
1047 AuthDenied::Unknown
1048 );
1049 assert_eq!(
1051 authenticate_with(None, Some("daily"), Some("daily")).unwrap(),
1052 TenantCtx::default_tenant()
1053 );
1054 assert_eq!(
1055 authenticate_with(None, Some("daily"), Some("x")).unwrap_err(),
1056 AuthDenied::Unknown
1057 );
1058 assert_eq!(
1060 authenticate_with(None, None, None).unwrap(),
1061 TenantCtx::default_tenant()
1062 );
1063 let _ = std::fs::remove_file(&path);
1064 }
1065
1066 #[test]
1067 fn namespace_scoping_is_tenant_separated_and_unforgeable() {
1068 assert_eq!(scope_namespace("acme", "s"), scope_namespace("acme", "s"));
1070 assert_ne!(scope_namespace("acme", ""), scope_namespace("blue", ""));
1072 assert_ne!(scope_namespace("acme", "s"), scope_namespace("blue", "s"));
1073 let forged_salt = format!("blue{}", '\u{1f}'); assert_ne!(
1077 scope_namespace("acme", &forged_salt),
1078 scope_namespace("blue", "")
1079 );
1080 assert_ne!(scope_namespace("acme", "s"), scope_namespace("acme", ""));
1082 }
1083
1084 #[test]
1085 fn meter_key_extracts_tenant_and_passes_raw_salts_through() {
1086 assert_eq!(meter_key(&scope_namespace("acme", "u1")), "t:acme");
1088 assert_eq!(meter_key(&scope_namespace("acme", "u2")), "t:acme");
1089 assert_eq!(meter_key(&scope_namespace("blue", "")), "t:blue");
1090 assert_eq!(meter_key("session-7"), "session-7");
1092 assert_eq!(meter_key(""), "");
1093 assert_eq!(meter_key("t:fake"), "t:fake");
1097 let forged = scope_namespace("acme", &format!("blue{}", '\u{1f}'));
1099 assert_eq!(meter_key(&forged), "t:acme");
1100 }
1101
1102 #[test]
1103 fn gen_key_prints_once_and_stores_only_the_hash() {
1104 use std::os::unix::fs::PermissionsExt;
1105
1106 let path = tmpfile("gen.toml");
1107 let key = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1108 assert!(key.starts_with("mk-acme-"));
1109 assert_eq!(key.len(), "mk-acme-".len() + 48);
1110 assert_eq!(
1111 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1112 0o640
1113 );
1114 let text = std::fs::read_to_string(&path).unwrap();
1115 assert!(!text.contains(&key), "plaintext must never reach the file");
1116 assert!(text.contains(&sha256_hex(&key)));
1117 let ring = Keyring::from_toml(&text).unwrap();
1119 assert_eq!(ring.lookup(&key).unwrap().tenant, "acme");
1120 let key2 = gen_key(&path, "blue", LaneClass::Batch, Some(4)).unwrap();
1122 let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1123 assert_eq!(ring.len(), 2);
1124 let ctx = ring.lookup(&key2).unwrap();
1125 assert_eq!(ctx.lane_class, LaneClass::Batch);
1126 assert_eq!(ctx.rate_limit, Some(4));
1127 assert!(gen_key(&path, "bad tenant", LaneClass::Interactive, None).is_err());
1129 let _ = std::fs::remove_file(&path);
1130 }
1131
1132 #[test]
1133 fn revoke_key_flips_enabled_by_prefix_exactly_once() {
1134 use std::os::unix::fs::PermissionsExt;
1135
1136 let path = tmpfile("revoke.toml");
1137 let key_a = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1138 let key_b = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1139 assert!(
1141 revoke_key(&path, "mk-acme-")
1142 .unwrap_err()
1143 .contains("2 keys")
1144 );
1145 let prefix_a = format!(
1147 "mk-acme-{}",
1148 &key_a["mk-acme-".len().."mk-acme-".len() + 12]
1149 );
1150 revoke_key(&path, &prefix_a).unwrap();
1151 assert_eq!(
1152 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1153 0o640
1154 );
1155 assert!(!PathBuf::from(format!("{}.tmp", path.display())).exists());
1156 let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1157 assert_eq!(ring.lookup(&key_a).unwrap_err(), AuthDenied::Disabled);
1158 assert_eq!(ring.lookup(&key_b).unwrap().tenant, "acme");
1159 assert!(
1160 revoke_key(&path, &prefix_a)
1161 .unwrap_err()
1162 .contains("already revoked")
1163 );
1164 revoke_key(&path, &key_b).unwrap();
1166 let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1167 assert_eq!(ring.lookup(&key_b).unwrap_err(), AuthDenied::Disabled);
1168 assert!(revoke_key(&path, "mk-zzz").unwrap_err().contains("no key"));
1170 let _ = std::fs::remove_file(&path);
1171 }
1172
1173 #[test]
1174 fn atomic_rewrite_survives_concurrent_hot_reload() {
1175 use std::sync::Arc;
1176 use std::sync::atomic::{AtomicBool, Ordering};
1177
1178 let path = tmpfile("atomic-reload.toml");
1179 let keys: Vec<KeyEntry> = (0..512)
1180 .map(|i| KeyEntry {
1181 prefix: format!("mk-tenant-{i:04}"),
1182 sha256: sha256_hex(&format!("secret-{i:04}")),
1183 tenant: "tenant".into(),
1184 lane: None,
1185 enabled: true,
1186 rate_limit: None,
1187 created_unix: None,
1188 })
1189 .collect();
1190 write_private(&path, &toml::to_string(&KeyFile { keys }).unwrap());
1191 let store = Arc::new(
1192 KeyStore::from_spec(path.to_str().unwrap())
1193 .unwrap()
1194 .with_poll(Duration::ZERO),
1195 );
1196 let running = Arc::new(AtomicBool::new(true));
1197 let start = Arc::new(std::sync::Barrier::new(2));
1198 let reader = {
1199 let path = path.clone();
1200 let store = store.clone();
1201 let running = running.clone();
1202 let start = start.clone();
1203 std::thread::spawn(move || {
1204 start.wait();
1205 while running.load(Ordering::Acquire) {
1206 let text = std::fs::read_to_string(&path).unwrap();
1207 let ring = Keyring::from_toml(&text)
1208 .expect("a concurrent reader must see the old or new complete ring");
1209 assert_eq!(ring.len(), 512, "the target must never be truncate-visible");
1210 assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1211 }
1212 })
1213 };
1214
1215 start.wait();
1216 let rewrites =
1217 (0..32).try_for_each(|i| revoke_key(&path, &format!("mk-tenant-{i:04}")).map(|_| ()));
1218 running.store(false, Ordering::Release);
1219 reader.join().unwrap();
1220 rewrites.unwrap();
1221
1222 let new_mtime = SystemTime::now() + Duration::from_secs(2);
1223 let file = std::fs::File::options().write(true).open(&path).unwrap();
1224 file.set_modified(new_mtime).unwrap();
1225 drop(file);
1226 assert_eq!(
1227 store.lookup("secret-0000").unwrap_err(),
1228 AuthDenied::Disabled
1229 );
1230 assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1231 let _ = std::fs::remove_file(&path);
1232 }
1233}