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 if !valid_tenant(tenant) {
616 return Err(format!("bad tenant {tenant:?} (want [A-Za-z0-9_-]+)"));
617 }
618 if rate_limit == Some(0) {
619 return Err("rate limit 0 would admit nothing".into());
620 }
621 let secret = random_hex48()?;
622 let key = format!("mk-{tenant}-{secret}");
623 let prefix = format!("mk-{tenant}-{}", &secret[..12]);
624 let created = SystemTime::now()
625 .duration_since(SystemTime::UNIX_EPOCH)
626 .map(|d| d.as_secs())
627 .unwrap_or(0);
628
629 if keys_path.is_file() {
632 let (text, _) = read_private_keyring(keys_path)?;
633 let f: KeyFile =
634 toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
635 Keyring::from_entries(f.keys.clone())?;
636 if f.keys.iter().any(|e| e.prefix == prefix) {
637 return Err(format!(
638 "prefix {prefix} already exists (rerun to draw a new key)"
639 ));
640 }
641 }
642
643 let mut fragment = String::new();
645 if !keys_path.is_file() {
646 fragment.push_str(
647 "# memra API keyring (MEMRA_API_KEYS points here).\n\
648 # Entries store SHA-256 of the key, never the plaintext. Managed by\n\
649 # `memra-server --gen-key <tenant>` / `--revoke-key <prefix>` (revoke\n\
650 # rewrites the file; comments outside this header are not preserved).\n",
651 );
652 }
653 fragment.push_str(&format!(
654 "\n[[keys]]\nprefix = \"{prefix}\"\nsha256 = \"{}\"\ntenant = \"{tenant}\"\n\
655 lane = \"{}\"\nenabled = true\ncreated_unix = {created}\n",
656 sha256_hex(&key),
657 lane.as_str()
658 ));
659 if let Some(rl) = rate_limit {
660 fragment.push_str(&format!("rate_limit = {rl}\n"));
661 }
662 use std::io::Write;
663 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
664 let creating = !keys_path.exists();
665 let mut f = std::fs::OpenOptions::new()
666 .create(true)
667 .append(true)
668 .mode(0o640)
669 .custom_flags(libc::O_NOFOLLOW)
670 .open(keys_path)
671 .map_err(|e| format!("{}: {e}", keys_path.display()))?;
672 validate_private_keyring_metadata(&f, keys_path)?;
673 if creating {
674 f.set_permissions(std::fs::Permissions::from_mode(0o640))
675 .map_err(|e| format!("{}: {e}", keys_path.display()))?;
676 }
677 f.write_all(fragment.as_bytes())
678 .map_err(|e| format!("{}: {e}", keys_path.display()))?;
679 f.sync_data()
680 .map_err(|e| format!("sync {}: {e}", keys_path.display()))?;
681 if creating {
682 sync_parent_dir(keys_path, "keyring")?;
683 }
684 Ok(key)
685}
686
687fn sync_parent_dir(path: &Path, label: &str) -> Result<(), String> {
688 let parent = path.parent().unwrap_or_else(|| Path::new("."));
689 std::fs::File::open(parent)
690 .and_then(|directory| directory.sync_all())
691 .map_err(|e| format!("sync {label} directory {}: {e}", parent.display()))
692}
693
694fn atomic_rewrite(keys_path: &Path, contents: &str) -> Result<(), String> {
695 use std::io::Write;
696 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
697
698 let parent = keys_path.parent().unwrap_or_else(|| Path::new("."));
699 let name = keys_path
700 .file_name()
701 .and_then(|name| name.to_str())
702 .unwrap_or("keys");
703 let mut random = [0u8; 16];
704 std::fs::File::open("/dev/urandom")
705 .and_then(|mut source| source.read_exact(&mut random))
706 .map_err(|e| format!("randomize keyring temporary name: {e}"))?;
707 let suffix = random
708 .iter()
709 .map(|byte| format!("{byte:02x}"))
710 .collect::<String>();
711 let tmp_path = parent.join(format!(".{name}.tmp.{suffix}"));
712 let mut tmp = std::fs::OpenOptions::new()
713 .create_new(true)
714 .write(true)
715 .mode(0o640)
716 .custom_flags(libc::O_NOFOLLOW)
717 .open(&tmp_path)
718 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
719 tmp.set_permissions(std::fs::Permissions::from_mode(0o640))
720 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
721 tmp.write_all(contents.as_bytes())
722 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
723 tmp.sync_all()
724 .map_err(|e| format!("{}: {e}", tmp_path.display()))?;
725 validate_private_keyring_metadata(&tmp, &tmp_path)?;
726 drop(tmp);
727 std::fs::rename(&tmp_path, keys_path)
728 .map_err(|e| format!("{} -> {}: {e}", tmp_path.display(), keys_path.display()))?;
729 sync_parent_dir(keys_path, "keyring")
730}
731
732pub fn revoke_key(keys_path: &Path, handle: &str) -> Result<String, String> {
736 let (text, _) = read_private_keyring(keys_path)?;
737 let mut f: KeyFile =
738 toml::from_str(&text).map_err(|e| format!("{}: {e}", keys_path.display()))?;
739 Keyring::from_entries(f.keys.clone())?;
740 let full_hash = sha256_digest(handle);
741 let matches: Vec<usize> = f
742 .keys
743 .iter()
744 .enumerate()
745 .filter(|(_, e)| {
746 (!e.prefix.is_empty() && e.prefix.starts_with(handle))
747 || parse_sha256(&e.sha256)
748 .is_some_and(|digest| constant_time_digest_eq(&digest, &full_hash))
749 })
750 .map(|(i, _)| i)
751 .collect();
752 match matches.len() {
753 0 => Err(format!("no key matches {handle:?}")),
754 1 => {
755 let i = matches[0];
756 if !f.keys[i].enabled {
757 return Err(format!("key {} is already revoked", f.keys[i].prefix));
758 }
759 f.keys[i].enabled = false;
760 let revoked = f.keys[i].prefix.clone();
761 let out = toml::to_string(&f).map_err(|e| e.to_string())?;
762 atomic_rewrite(keys_path, &out)?;
763 Ok(revoked)
764 }
765 n => Err(format!("{n} keys match {handle:?} — use a longer prefix")),
766 }
767}
768
769pub fn run_cli(args: &[String]) -> Option<i32> {
772 let has = |flag: &str| args.iter().any(|a| a == flag);
773 if !has("--gen-key") && !has("--revoke-key") {
774 return None;
775 }
776 let value_of = |flag: &str| -> Option<String> {
777 args.iter()
778 .position(|a| a == flag)
779 .and_then(|i| args.get(i + 1).cloned())
780 };
781 let keys_path = value_of("--keys")
782 .or_else(|| std::env::var("MEMRA_API_KEYS").ok())
783 .map(PathBuf::from);
784 let Some(keys_path) = keys_path else {
785 eprintln!("error: no keys file — pass --keys /path/keys.toml or set MEMRA_API_KEYS");
786 return Some(2);
787 };
788 if keys_path.exists() && !keys_path.is_file() {
789 eprintln!("error: {} is not a file", keys_path.display());
790 return Some(2);
791 }
792
793 if has("--gen-key") {
794 let Some(tenant) = value_of("--gen-key") else {
795 eprintln!(
796 "usage: memra-server --gen-key <tenant> [--lane interactive|batch] \
797 [--rate-limit N] [--keys /path/keys.toml]"
798 );
799 return Some(2);
800 };
801 let lane = match value_of("--lane") {
802 None => LaneClass::Interactive,
803 Some(v) => match LaneClass::parse(&v) {
804 Some(l) => l,
805 None => {
806 eprintln!("error: bad --lane {v:?} (interactive|batch)");
807 return Some(2);
808 }
809 },
810 };
811 let rate_limit = match value_of("--rate-limit") {
812 None => None,
813 Some(v) => match v.parse::<usize>() {
814 Ok(n) => Some(n),
815 Err(_) => {
816 eprintln!("error: bad --rate-limit {v:?} (want a positive integer)");
817 return Some(2);
818 }
819 },
820 };
821 return Some(match gen_key(&keys_path, &tenant, lane, rate_limit) {
822 Ok(key) => {
823 println!("{key}");
824 eprintln!(
825 "[gen-key] tenant {tenant:?} lane {} appended to {} — \
826 the plaintext above is shown ONCE and stored only as SHA-256",
827 lane.as_str(),
828 keys_path.display()
829 );
830 0
831 }
832 Err(e) => {
833 eprintln!("error: {e}");
834 1
835 }
836 });
837 }
838
839 let Some(handle) = value_of("--revoke-key") else {
841 eprintln!("usage: memra-server --revoke-key <prefix> [--keys /path/keys.toml]");
842 return Some(2);
843 };
844 Some(match revoke_key(&keys_path, &handle) {
845 Ok(prefix) => {
846 eprintln!(
847 "[revoke-key] {prefix} disabled in {} (takes effect on the next \
848 keyring poll, <=2s on a running server)",
849 keys_path.display()
850 );
851 0
852 }
853 Err(e) => {
854 eprintln!("error: {e}");
855 1
856 }
857 })
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 fn tmpfile(name: &str) -> PathBuf {
865 let p = std::env::temp_dir().join(format!("memra_auth_{}_{name}", std::process::id()));
866 let _ = std::fs::remove_file(&p);
867 p
868 }
869
870 fn write_private(path: &Path, contents: &str) {
871 use std::os::unix::fs::PermissionsExt;
872 std::fs::write(path, contents).unwrap();
873 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o640)).unwrap();
874 }
875
876 const K_A1: &str = "mk-acme-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
877 const K_A2: &str = "mk-acme-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
878 const K_B1: &str = "mk-blue-cccccccccccccccccccccccccccccccccccccccccccccccc";
879 const K_DIS: &str = "mk-dead-dddddddddddddddddddddddddddddddddddddddddddddddd";
880
881 fn toml_ring() -> String {
882 format!(
883 "[[keys]]\nprefix = \"mk-acme-aaaa\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\n\
884 [[keys]]\nprefix = \"mk-acme-bbbb\"\nsha256 = \"{}\"\ntenant = \"acme\"\n\
885 rate_limit = 2\n\n\
886 [[keys]]\nprefix = \"mk-blue-cccc\"\nsha256 = \"{}\"\ntenant = \"blue\"\n\
887 lane = \"batch\"\n\n\
888 [[keys]]\nprefix = \"mk-dead-dddd\"\nsha256 = \"{}\"\ntenant = \"dead\"\n\
889 enabled = false\n",
890 sha256_hex(K_A1),
891 sha256_hex(K_A2),
892 sha256_hex(K_B1),
893 sha256_hex(K_DIS)
894 )
895 }
896
897 #[test]
898 fn sha256_hex_matches_known_vector() {
899 assert_eq!(
901 sha256_hex("abc"),
902 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
903 );
904 }
905
906 #[test]
907 fn fixed_digest_secret_comparison_preserves_auth_semantics() {
908 assert!(constant_time_secret_eq("same", "same"));
909 assert!(!constant_time_secret_eq("same", "same-but-longer"));
910 assert!(!constant_time_secret_eq("prefix-a", "prefix-b"));
911 assert!(!constant_time_secret_eq("", "nonempty"));
912 }
913
914 #[test]
915 fn toml_ring_parses_and_looks_up_by_hash() {
916 let ring = Keyring::from_toml(&toml_ring()).unwrap();
917 assert_eq!(ring.len(), 4);
918 let ctx = ring.lookup(K_A1).unwrap();
920 assert_eq!(ctx.tenant, "acme");
921 assert_eq!(ctx.lane_class, LaneClass::Interactive);
922 assert_eq!(ctx.rate_limit, None);
923 let ctx = ring.lookup(K_A2).unwrap();
924 assert_eq!(ctx.tenant, "acme");
925 assert_eq!(ctx.rate_limit, Some(2));
926 let ctx = ring.lookup(K_B1).unwrap();
927 assert_eq!(ctx.tenant, "blue");
928 assert_eq!(ctx.lane_class, LaneClass::Batch);
929 assert_eq!(ring.lookup(K_DIS).unwrap_err(), AuthDenied::Disabled);
931 assert_eq!(ring.lookup("mk-nope-x").unwrap_err(), AuthDenied::Unknown);
932 assert!(!toml_ring().contains(K_A1));
934 }
935
936 #[test]
937 fn malformed_rings_are_loud_errors() {
938 let bad = format!(
941 "[[keys]]\nsha256 = \"{}\"\ntenant = \"a b\"\n",
942 sha256_hex("k")
943 );
944 assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad tenant"));
945 assert!(
946 Keyring::from_entries(vec![KeyEntry {
947 prefix: String::new(),
948 sha256: sha256_hex("k"),
949 tenant: format!("a{}b", '\u{1f}'),
950 lane: None,
951 enabled: true,
952 rate_limit: None,
953 created_unix: None,
954 }])
955 .unwrap_err()
956 .contains("bad tenant")
957 );
958 let bad = "[[keys]]\nsha256 = \"abc123\"\ntenant = \"t\"\n";
960 assert!(Keyring::from_toml(bad).unwrap_err().contains("64 hex"));
961 let bad = format!(
963 "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nlane = \"turbo\"\n",
964 sha256_hex("k")
965 );
966 assert!(Keyring::from_toml(&bad).unwrap_err().contains("bad lane"));
967 let dup = format!(
969 "[[keys]]\nsha256 = \"{h}\"\ntenant = \"t\"\n\n\
970 [[keys]]\nsha256 = \"{h}\"\ntenant = \"u\"\n",
971 h = sha256_hex("k")
972 );
973 assert!(Keyring::from_toml(&dup).unwrap_err().contains("duplicate"));
974 let z = format!(
976 "[[keys]]\nsha256 = \"{}\"\ntenant = \"t\"\nrate_limit = 0\n",
977 sha256_hex("k")
978 );
979 assert!(Keyring::from_toml(&z).unwrap_err().contains("rate_limit 0"));
980 }
981
982 #[test]
983 fn inline_env_list_parses() {
984 let spec = format!("acme:{},blue:{}:batch", sha256_hex(K_A1), sha256_hex(K_B1));
985 let ring = Keyring::from_inline(&spec).unwrap();
986 assert_eq!(ring.lookup(K_A1).unwrap().tenant, "acme");
987 assert_eq!(ring.lookup(K_B1).unwrap().lane_class, LaneClass::Batch);
988 assert!(Keyring::from_inline("no-colon-here").is_err());
989 assert!(Keyring::from_inline("").is_err());
990 }
991
992 #[test]
993 fn keystore_hot_reloads_on_mtime_change() {
994 let path = tmpfile("reload.toml");
995 write_private(&path, &toml_ring());
996 let ks = KeyStore::from_spec(path.to_str().unwrap())
997 .unwrap()
998 .with_poll(Duration::ZERO);
999 assert_eq!(ks.lookup(K_A1).unwrap().tenant, "acme");
1000 let revoked = toml_ring().replace(
1002 &format!("sha256 = \"{}\"\ntenant = \"acme\"\n", sha256_hex(K_A1)),
1003 &format!(
1004 "sha256 = \"{}\"\ntenant = \"acme\"\nenabled = false\n",
1005 sha256_hex(K_A1)
1006 ),
1007 );
1008 std::fs::write(&path, revoked).unwrap();
1009 let new_mtime = SystemTime::now() + Duration::from_secs(2);
1010 let f = std::fs::File::options().write(true).open(&path).unwrap();
1011 f.set_modified(new_mtime).unwrap();
1012 drop(f);
1013 assert_eq!(
1014 ks.lookup(K_A1).unwrap_err(),
1015 AuthDenied::Disabled,
1016 "mtime bump must reload the ring"
1017 );
1018 std::fs::write(&path, "keys = \"not a ring\"").unwrap();
1020 let f = std::fs::File::options().write(true).open(&path).unwrap();
1021 f.set_modified(new_mtime + Duration::from_secs(2)).unwrap();
1022 drop(f);
1023 assert_eq!(
1024 ks.lookup(K_A1).unwrap_err(),
1025 AuthDenied::Disabled,
1026 "broken reload must keep the previous ring"
1027 );
1028 assert_eq!(ks.lookup(K_B1).unwrap().tenant, "blue");
1029 let _ = std::fs::remove_file(&path);
1030 }
1031
1032 #[test]
1033 fn auth_law_composes_keyring_and_single_key() {
1034 let path = tmpfile("law.toml");
1035 write_private(&path, &toml_ring());
1036 let ks = KeyStore::from_spec(path.to_str().unwrap()).unwrap();
1037 assert_eq!(
1039 authenticate_with(Some(&ks), Some("daily"), Some(K_A1))
1040 .unwrap()
1041 .tenant,
1042 "acme"
1043 );
1044 assert_eq!(
1045 authenticate_with(Some(&ks), Some("daily"), Some("daily")).unwrap(),
1046 TenantCtx::default_tenant()
1047 );
1048 assert_eq!(
1050 authenticate_with(Some(&ks), Some("daily"), Some("nope")).unwrap_err(),
1051 AuthDenied::Unknown
1052 );
1053 assert_eq!(
1054 authenticate_with(Some(&ks), Some("daily"), Some(K_DIS)).unwrap_err(),
1055 AuthDenied::Disabled
1056 );
1057 assert_eq!(
1058 authenticate_with(Some(&ks), Some("daily"), None).unwrap_err(),
1059 AuthDenied::Unknown
1060 );
1061 assert_eq!(
1063 authenticate_with(None, Some("daily"), Some("daily")).unwrap(),
1064 TenantCtx::default_tenant()
1065 );
1066 assert_eq!(
1067 authenticate_with(None, Some("daily"), Some("x")).unwrap_err(),
1068 AuthDenied::Unknown
1069 );
1070 assert_eq!(
1072 authenticate_with(None, None, None).unwrap(),
1073 TenantCtx::default_tenant()
1074 );
1075 let _ = std::fs::remove_file(&path);
1076 }
1077
1078 #[test]
1079 fn namespace_scoping_is_tenant_separated_and_unforgeable() {
1080 assert_eq!(scope_namespace("acme", "s"), scope_namespace("acme", "s"));
1082 assert_ne!(scope_namespace("acme", ""), scope_namespace("blue", ""));
1084 assert_ne!(scope_namespace("acme", "s"), scope_namespace("blue", "s"));
1085 let forged_salt = format!("blue{}", '\u{1f}'); assert_ne!(
1089 scope_namespace("acme", &forged_salt),
1090 scope_namespace("blue", "")
1091 );
1092 assert_ne!(scope_namespace("acme", "s"), scope_namespace("acme", ""));
1094 }
1095
1096 #[test]
1097 fn meter_key_extracts_tenant_and_passes_raw_salts_through() {
1098 assert_eq!(meter_key(&scope_namespace("acme", "u1")), "t:acme");
1100 assert_eq!(meter_key(&scope_namespace("acme", "u2")), "t:acme");
1101 assert_eq!(meter_key(&scope_namespace("blue", "")), "t:blue");
1102 assert_eq!(meter_key("session-7"), "session-7");
1104 assert_eq!(meter_key(""), "");
1105 assert_eq!(meter_key("t:fake"), "t:fake");
1109 let forged = scope_namespace("acme", &format!("blue{}", '\u{1f}'));
1111 assert_eq!(meter_key(&forged), "t:acme");
1112 }
1113
1114 #[test]
1115 fn gen_key_prints_once_and_stores_only_the_hash() {
1116 use std::os::unix::fs::PermissionsExt;
1117
1118 let path = tmpfile("gen.toml");
1119 let key = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1120 assert!(key.starts_with("mk-acme-"));
1121 assert_eq!(key.len(), "mk-acme-".len() + 48);
1122 assert_eq!(
1123 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1124 0o640
1125 );
1126 let text = std::fs::read_to_string(&path).unwrap();
1127 assert!(!text.contains(&key), "plaintext must never reach the file");
1128 assert!(text.contains(&sha256_hex(&key)));
1129 let ring = Keyring::from_toml(&text).unwrap();
1131 assert_eq!(ring.lookup(&key).unwrap().tenant, "acme");
1132 let key2 = gen_key(&path, "blue", LaneClass::Batch, Some(4)).unwrap();
1134 let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1135 assert_eq!(ring.len(), 2);
1136 let ctx = ring.lookup(&key2).unwrap();
1137 assert_eq!(ctx.lane_class, LaneClass::Batch);
1138 assert_eq!(ctx.rate_limit, Some(4));
1139 assert!(gen_key(&path, "bad tenant", LaneClass::Interactive, None).is_err());
1141 let _ = std::fs::remove_file(&path);
1142 }
1143
1144 #[test]
1145 fn revoke_key_flips_enabled_by_prefix_exactly_once() {
1146 use std::os::unix::fs::PermissionsExt;
1147
1148 let path = tmpfile("revoke.toml");
1149 let key_a = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1150 let key_b = gen_key(&path, "acme", LaneClass::Interactive, None).unwrap();
1151 assert!(
1153 revoke_key(&path, "mk-acme-")
1154 .unwrap_err()
1155 .contains("2 keys")
1156 );
1157 let prefix_a = format!(
1159 "mk-acme-{}",
1160 &key_a["mk-acme-".len().."mk-acme-".len() + 12]
1161 );
1162 revoke_key(&path, &prefix_a).unwrap();
1163 assert_eq!(
1164 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1165 0o640
1166 );
1167 assert!(!PathBuf::from(format!("{}.tmp", path.display())).exists());
1168 let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1169 assert_eq!(ring.lookup(&key_a).unwrap_err(), AuthDenied::Disabled);
1170 assert_eq!(ring.lookup(&key_b).unwrap().tenant, "acme");
1171 assert!(
1172 revoke_key(&path, &prefix_a)
1173 .unwrap_err()
1174 .contains("already revoked")
1175 );
1176 revoke_key(&path, &key_b).unwrap();
1178 let ring = Keyring::from_toml(&std::fs::read_to_string(&path).unwrap()).unwrap();
1179 assert_eq!(ring.lookup(&key_b).unwrap_err(), AuthDenied::Disabled);
1180 assert!(revoke_key(&path, "mk-zzz").unwrap_err().contains("no key"));
1182 let _ = std::fs::remove_file(&path);
1183 }
1184
1185 #[test]
1186 fn atomic_rewrite_survives_concurrent_hot_reload() {
1187 use std::sync::Arc;
1188 use std::sync::atomic::{AtomicBool, Ordering};
1189
1190 let path = tmpfile("atomic-reload.toml");
1191 let keys: Vec<KeyEntry> = (0..512)
1192 .map(|i| KeyEntry {
1193 prefix: format!("mk-tenant-{i:04}"),
1194 sha256: sha256_hex(&format!("secret-{i:04}")),
1195 tenant: "tenant".into(),
1196 lane: None,
1197 enabled: true,
1198 rate_limit: None,
1199 created_unix: None,
1200 })
1201 .collect();
1202 write_private(&path, &toml::to_string(&KeyFile { keys }).unwrap());
1203 let store = Arc::new(
1204 KeyStore::from_spec(path.to_str().unwrap())
1205 .unwrap()
1206 .with_poll(Duration::ZERO),
1207 );
1208 let running = Arc::new(AtomicBool::new(true));
1209 let start = Arc::new(std::sync::Barrier::new(2));
1210 let reader = {
1211 let path = path.clone();
1212 let store = store.clone();
1213 let running = running.clone();
1214 let start = start.clone();
1215 std::thread::spawn(move || {
1216 start.wait();
1217 while running.load(Ordering::Acquire) {
1218 let text = std::fs::read_to_string(&path).unwrap();
1219 let ring = Keyring::from_toml(&text)
1220 .expect("a concurrent reader must see the old or new complete ring");
1221 assert_eq!(ring.len(), 512, "the target must never be truncate-visible");
1222 assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1223 }
1224 })
1225 };
1226
1227 start.wait();
1228 let rewrites =
1229 (0..32).try_for_each(|i| revoke_key(&path, &format!("mk-tenant-{i:04}")).map(|_| ()));
1230 running.store(false, Ordering::Release);
1231 reader.join().unwrap();
1232 rewrites.unwrap();
1233
1234 let new_mtime = SystemTime::now() + Duration::from_secs(2);
1235 let file = std::fs::File::options().write(true).open(&path).unwrap();
1236 file.set_modified(new_mtime).unwrap();
1237 drop(file);
1238 assert_eq!(
1239 store.lookup("secret-0000").unwrap_err(),
1240 AuthDenied::Disabled
1241 );
1242 assert_eq!(store.lookup("secret-0511").unwrap().tenant, "tenant");
1243 let _ = std::fs::remove_file(&path);
1244 }
1245}