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