1use std::sync::Arc;
29
30use base64::{Engine, engine::general_purpose::STANDARD as B64};
31use ijima_core::{IjimaError, Result, TokenRevocation};
32use schubert::{
33 AccessController, CapabilityId, PrincipalId,
34 crypto::{CapabilityIssuer, GrantPolicy, GrantToken, GrantVerifier, KeyStore},
35};
36use sha2::{Digest, Sha256};
37
38const POLICY_TOML: &str = include_str!("../policy/policy.toml");
40
41pub fn bearer_hash(bearer: &str) -> String {
48 let trimmed = bearer.trim();
49 let raw = trimmed.strip_prefix("Bearer ").unwrap_or(trimmed);
50 let mut hasher = Sha256::new();
51 hasher.update(raw.as_bytes());
52 hasher
55 .finalize()
56 .iter()
57 .map(|b| format!("{b:02x}"))
58 .collect()
59}
60
61#[derive(Debug, Clone)]
71pub struct AuthenticatedPrincipal {
72 pub principal: PrincipalId,
74 pub grant: GrantToken,
76 controller: Arc<AccessController>,
77 grant_verifier: Arc<GrantVerifier>,
78}
79
80impl AuthenticatedPrincipal {
81 pub fn may(&self, required: &str) -> bool {
87 match self.controller.capability(required) {
88 Some(cap) => self.grant_verifier.may(&self.grant, &cap.partition),
89 None => false,
90 }
91 }
92
93 pub fn granted_capabilities(&self) -> Vec<String> {
99 self.grant
100 .capabilities
101 .iter()
102 .map(|c| c.id.as_str().to_string())
103 .collect()
104 }
105
106 pub fn personal_namespace(&self) -> ijima_core::NamespaceId {
110 ijima_core::NamespaceId::new(format!("ns_{}_private", self.principal.as_str()))
111 }
112}
113
114#[derive(Debug)]
125pub struct IjimaAuth {
126 controller: Arc<AccessController>,
127 issuer: CapabilityIssuer,
128 grant_verifier: Arc<GrantVerifier>,
129 revocations: std::sync::Mutex<std::collections::HashSet<String>>,
133}
134
135impl IjimaAuth {
136 pub fn from_embedded_policy() -> Result<Self> {
149 Self::from_embedded_policy_with_seed(Self::generate_seed())
150 }
151
152 pub fn from_embedded_policy_with_seed(seed: [u8; 32]) -> Result<Self> {
160 let controller = AccessController::from_policy_toml(POLICY_TOML)
161 .map_err(|e| IjimaError::invalid_input(format!("policy load: {e}")))?;
162 let issuer = CapabilityIssuer::from_seed(seed);
163 let grant_verifier = GrantVerifier::new(issuer.public_key());
164 Ok(Self {
165 controller: Arc::new(controller),
166 issuer,
167 grant_verifier: Arc::new(grant_verifier),
168 revocations: std::sync::Mutex::new(std::collections::HashSet::new()),
169 })
170 }
171
172 pub fn generate_seed() -> [u8; 32] {
176 KeyStore::generate_seed()
177 }
178
179 pub fn issuer_public_key_hex(&self) -> String {
182 self.issuer.public_key_hex()
183 }
184
185 pub fn grassmannian(&self) -> (usize, usize) {
187 self.controller.grassmannian()
188 }
189
190 pub fn issue_grant_bearer(
203 &self,
204 principal: impl Into<PrincipalId>,
205 capabilities: &[&str],
206 ) -> Result<String> {
207 if capabilities.is_empty() {
208 return Err(IjimaError::invalid_input(
209 "grant must carry at least one capability",
210 ));
211 }
212 let entries = self.capability_entries(capabilities)?;
213 let grant = self
214 .issuer
215 .issue_grant(principal, &entries)
216 .map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
217 Ok(B64.encode(GrantToken::to_bytes(&grant)))
218 }
219
220 pub fn issue_grant_bearer_with_expiry(
232 &self,
233 principal: impl Into<PrincipalId>,
234 capabilities: &[&str],
235 expires_at_unix: u64,
236 ) -> Result<String> {
237 if capabilities.is_empty() {
238 return Err(IjimaError::invalid_input(
239 "grant must carry at least one capability",
240 ));
241 }
242 let entries = self.capability_entries(capabilities)?;
243 let grant = self
244 .issuer
245 .issue_grant_with_expiry(principal, &entries, expires_at_unix)
246 .map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
247 Ok(B64.encode(GrantToken::to_bytes(&grant)))
248 }
249
250 pub fn issue_grant_bearer_under_policy(
266 &self,
267 principal: impl Into<PrincipalId>,
268 capabilities: &[&str],
269 policy: &GrantPolicy,
270 expires_at: Option<u64>,
271 ) -> Result<String> {
272 if capabilities.is_empty() {
273 return Err(IjimaError::invalid_input(
274 "grant must carry at least one capability",
275 ));
276 }
277 let entries = self.capability_entries(capabilities)?;
278 let principal = principal.into();
279 policy
280 .may_issue(&principal, &entries)
281 .map_err(|e| IjimaError::invalid_input(format!("grant denied by policy: {e}")))?;
282 let grant = match expires_at {
283 Some(at) => self.issuer.issue_grant_with_expiry(principal, &entries, at),
284 None => self.issuer.issue_grant(principal, &entries),
285 }
286 .map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
287 Ok(B64.encode(GrantToken::to_bytes(&grant)))
288 }
289
290 fn capability_entries(&self, capabilities: &[&str]) -> Result<Vec<(CapabilityId, Vec<usize>)>> {
298 let mut entries = Vec::with_capacity(capabilities.len());
299 for cap in capabilities {
300 let partition = self
301 .controller
302 .capability(cap)
303 .map(|c| c.partition.clone())
304 .ok_or_else(|| IjimaError::invalid_input(format!("unknown capability: {cap}")))?;
305 entries.push((CapabilityId::new(*cap), partition));
306 }
307 Ok(entries)
308 }
309
310 pub fn grant_verifier(&self) -> &GrantVerifier {
312 &self.grant_verifier
313 }
314
315 pub fn resolve_issuance_policy(explicit: Option<&std::path::Path>) -> Result<String> {
328 let env_path = std::env::var_os("IJIMA_POLICY").map(std::path::PathBuf::from);
329 let dir = std::env::var_os("IJIMA_DIR").map(std::path::PathBuf::from);
330 Ok(
331 resolve_policy_source(explicit, env_path.as_deref(), dir.as_deref())?
332 .unwrap_or_else(|| POLICY_TOML.to_string()),
333 )
334 }
335
336 pub fn issuance_policy_from_source(toml_str: &str) -> Result<schubert::policy::PolicyConfig> {
358 #[derive(serde::Deserialize)]
359 struct PrincipalsOverlay {
360 #[serde(default)]
361 principals: std::collections::BTreeMap<String, schubert::policy::PrincipalConfig>,
362 }
363
364 if toml_str.contains("[capabilities") {
365 let cfg = schubert::policy::PolicyConfig::from_toml(toml_str)
366 .map_err(|e| IjimaError::invalid_input(format!("policy parse: {e}")))?;
367 cfg.validate()
368 .map_err(|e| IjimaError::invalid_input(format!("policy validate: {e}")))?;
369 return Ok(cfg);
370 }
371
372 let raw: toml::Value = toml::from_str(toml_str)
374 .map_err(|e| IjimaError::invalid_input(format!("policy overlay parse: {e}")))?;
375 if let Some(table) = raw.as_table() {
378 for key in table.keys() {
379 if key != "principals" {
380 return Err(IjimaError::invalid_input(format!(
381 "policy overlay may only contain [principals.*] (found `{key}`); \
382 a full policy must carry [capabilities] and validate as a whole"
383 )));
384 }
385 }
386 }
387 let overlay: PrincipalsOverlay = raw
388 .try_into()
389 .map_err(|e| IjimaError::invalid_input(format!("policy overlay parse: {e}")))?;
390 if overlay.principals.is_empty() {
391 return Err(IjimaError::invalid_input(
392 "policy overlay declares no principals",
393 ));
394 }
395 let mut merged = schubert::policy::PolicyConfig::from_toml(POLICY_TOML)
396 .map_err(|e| IjimaError::invalid_input(format!("embedded policy: {e}")))?;
397 merged.principals = overlay.principals;
398 merged
399 .validate()
400 .map_err(|e| IjimaError::invalid_input(format!("policy validate: {e}")))?;
401 Ok(merged)
402 }
403
404 pub fn issue_bearer(
412 &self,
413 principal: impl Into<PrincipalId>,
414 capability: impl AsRef<str>,
415 ) -> Result<String> {
416 self.issue_grant_bearer(principal, &[capability.as_ref()])
417 }
418
419 pub fn hydrate_revocations(&self, revocations: &[TokenRevocation]) {
422 let mut set = self.revocations.lock().expect("revocations poisoned");
423 *set = revocations.iter().map(|r| r.token_hash.clone()).collect();
424 }
425
426 pub fn revoke(&self, hash: &str) {
429 self.revocations
430 .lock()
431 .expect("revocations poisoned")
432 .insert(hash.to_string());
433 }
434
435 pub fn is_revoked(&self, bearer: &str) -> bool {
437 self.revocations
438 .lock()
439 .expect("revocations poisoned")
440 .contains(&bearer_hash(bearer))
441 }
442
443 pub fn verify_bearer(&self, bearer: &str) -> Result<AuthenticatedPrincipal> {
452 if self.is_revoked(bearer) {
453 return Err(IjimaError::invalid_input("token revoked"));
454 }
455 let buf = B64
456 .decode(bearer.trim())
457 .map_err(|e| IjimaError::invalid_input(format!("base64 decode: {e}")))?;
458 let grant = GrantToken::from_bytes(&buf)
459 .map_err(|e| IjimaError::invalid_input(format!("grant decode: {e}")))?;
460 self.grant_verifier
461 .verify(&grant)
462 .map_err(|e| IjimaError::invalid_input(format!("grant verify: {e}")))?;
463 Ok(AuthenticatedPrincipal {
464 principal: grant.principal.clone(),
465 grant,
466 controller: Arc::clone(&self.controller),
467 grant_verifier: Arc::clone(&self.grant_verifier),
468 })
469 }
470
471 pub fn require(&self, bearer: &str, required: &str) -> Result<AuthenticatedPrincipal> {
479 let principal = self.verify_bearer(bearer)?;
480 if principal.may(required) {
481 Ok(principal)
482 } else {
483 Err(IjimaError::invalid_input(format!(
484 "access denied: grant does not imply '{required}'"
485 )))
486 }
487 }
488}
489
490fn resolve_policy_source(
501 explicit: Option<&std::path::Path>,
502 env_path: Option<&std::path::Path>,
503 dir: Option<&std::path::Path>,
504) -> Result<Option<String>> {
505 let read_or_err = |p: &std::path::Path, origin: &str| {
506 std::fs::read_to_string(p).map(Some).map_err(|e| {
507 IjimaError::invalid_input(format!("policy file {origin} {}: {e}", p.display()))
508 })
509 };
510 if let Some(p) = explicit {
511 return read_or_err(p, "(--policy)");
512 }
513 if let Some(p) = env_path {
514 return read_or_err(p, "($IJIMA_POLICY)");
515 }
516 if let Some(dir) = dir {
517 let candidate = dir.join("policy.toml");
518 if candidate.exists() {
519 return read_or_err(&candidate, "($IJIMA_DIR/policy.toml)");
520 }
521 }
522 Ok(None)
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use ijima_core::capabilities::{ADMIN, KNOWLEDGE_READ, MEMORY_READ, MEMORY_WRITE};
529
530 fn fresh() -> IjimaAuth {
531 IjimaAuth::from_embedded_policy().expect("embedded policy must load")
532 }
533
534 #[test]
535 fn embedded_policy_loads_on_gr_4_8() {
536 let auth = fresh();
537 assert_eq!(auth.grassmannian(), (4, 8));
538 }
539
540 #[test]
541 fn issue_then_verify_round_trips() {
542 let auth = fresh();
543 let bearer = auth
544 .issue_bearer("elliott", MEMORY_READ)
545 .expect("must issue");
546 let principal = auth.verify_bearer(&bearer).expect("must verify");
547 assert_eq!(principal.principal.as_str(), "elliott");
548 assert_eq!(
549 principal.granted_capabilities(),
550 vec![MEMORY_READ.to_string()]
551 );
552 }
553
554 #[test]
557 fn expired_grant_is_rejected_with_expired_detail() {
558 let auth = fresh();
559 let now = std::time::SystemTime::now()
560 .duration_since(std::time::UNIX_EPOCH)
561 .unwrap()
562 .as_secs();
563 let bearer = auth
565 .issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ], now - 1)
566 .expect("issue");
567 let err = auth.verify_bearer(&bearer).expect_err("must be dead");
568 let msg = err.to_string();
569 assert!(msg.contains("expired"), "detail should name expiry: {msg}");
570 }
571
572 #[test]
573 fn expiry_boundary_is_inclusive_at_verify_at() {
574 let auth = fresh();
575 let bearer = auth
576 .issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ], 1_000_000)
577 .expect("issue");
578 let buf = B64.decode(bearer.trim()).expect("b64");
579 let grant = GrantToken::from_bytes(&buf).expect("grant");
580 assert!(auth.grant_verifier().verify_at(&grant, 999_999).is_ok());
582 let err = auth
583 .grant_verifier()
584 .verify_at(&grant, 1_000_000)
585 .expect_err("boundary is inclusive");
586 assert!(matches!(err, schubert::SchubertError::GrantExpired { .. }));
587 }
588
589 #[test]
590 fn unexpired_grant_with_expiry_still_verifies() {
591 let now = std::time::SystemTime::now()
592 .duration_since(std::time::UNIX_EPOCH)
593 .unwrap()
594 .as_secs();
595 let auth = fresh();
596 let bearer = auth
597 .issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ, MEMORY_WRITE], now + 3600)
598 .expect("issue");
599 let principal = auth.verify_bearer(&bearer).expect("valid for an hour");
600 assert!(principal.may(MEMORY_WRITE));
601 }
602
603 fn policy_with_alice() -> schubert::policy::PolicyConfig {
606 let toml = format!(
607 "{POLICY_TOML}\n[principals.alice]\ngrants = [\"memory:read\", \"memory:write\"]\n"
608 );
609 schubert::policy::PolicyConfig::from_toml(&toml).expect("policy parses")
610 }
611
612 #[test]
613 fn under_policy_allows_entitled_request() {
614 let auth = fresh();
615 let policy =
616 schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
617 let bearer = auth
618 .issue_grant_bearer_under_policy("alice", &[MEMORY_READ], &policy, None)
619 .expect("alice is entitled to memory:read");
620 let principal = auth.verify_bearer(&bearer).expect("verify");
621 assert_eq!(principal.principal.as_str(), "alice");
622 }
623
624 #[test]
625 fn under_policy_denies_unknown_principal() {
626 let auth = fresh();
627 let policy =
628 schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
629 let err = auth
630 .issue_grant_bearer_under_policy("mallory", &[MEMORY_READ], &policy, None)
631 .expect_err("fails closed on unknown principals");
632 assert!(err.to_string().contains("mallory"));
633 }
634
635 #[test]
636 fn under_policy_denies_over_entitled_request() {
637 let auth = fresh();
638 let policy =
639 schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
640 let err = auth
641 .issue_grant_bearer_under_policy("alice", &[ADMIN], &policy, None)
642 .expect_err("alice cannot smuggle admin");
643 assert!(err.to_string().contains("admin"));
644 }
645
646 #[test]
647 fn principals_only_overlay_merges_onto_embedded_partitions() {
648 let overlay = "[principals.elliott]\ngrants = [\"memory:read\", \"memory:write\"]\n";
649 let cfg = IjimaAuth::issuance_policy_from_source(overlay).expect("overlay");
650 let read = cfg.capabilities.get(MEMORY_READ).expect("embedded cap");
652 assert!(!read.partition.is_empty());
653 let grants = cfg.grants_for("elliott");
655 assert_eq!(grants.len(), 2);
656 assert!(cfg.grants_for("mallory").is_empty());
657 }
658
659 #[test]
660 fn overlay_rejects_non_principal_sections() {
661 let bad = "[principals.elliott]\ngrants = [\"memory:read\"]\n\n[grassmannian]\nk = 9\n";
662 let err = IjimaAuth::issuance_policy_from_source(bad)
663 .expect_err("overlay may not touch geometry");
664 assert!(err.to_string().contains("grassmannian"));
665 }
666
667 #[test]
668 fn overlay_with_no_principals_is_rejected() {
669 let err = IjimaAuth::issuance_policy_from_source("# nothing\n").expect_err("empty overlay");
670 assert!(err.to_string().contains("no principals"));
671 }
672
673 #[test]
674 fn policy_source_precedence_explicit_env_dir_fallback() {
675 let tmp = std::env::temp_dir().join(format!("ijima-pol-{}", std::process::id()));
676 std::fs::create_dir_all(&tmp).expect("mkdir");
677 let explicit = tmp.join("explicit.toml");
678 let env = tmp.join("env.toml");
679 let dir_policy = tmp.join("policy.toml");
680 std::fs::write(&explicit, "# explicit").expect("w");
681 std::fs::write(&env, "# env").expect("w");
682 std::fs::write(&dir_policy, "# dir").expect("w");
683
684 assert_eq!(
686 resolve_policy_source(Some(&explicit), Some(&env), Some(&tmp))
687 .expect("res")
688 .as_deref(),
689 Some("# explicit")
690 );
691 assert_eq!(
693 resolve_policy_source(None, Some(&env), Some(&tmp))
694 .expect("res")
695 .as_deref(),
696 Some("# env")
697 );
698 assert_eq!(
700 resolve_policy_source(None, None, Some(&tmp))
701 .expect("res")
702 .as_deref(),
703 Some("# dir")
704 );
705 let empty = tmp.join("empty");
707 std::fs::create_dir_all(&empty).expect("mkdir");
708 assert_eq!(
709 resolve_policy_source(None, None, Some(&empty)).expect("res"),
710 None
711 );
712 let missing = tmp.join("missing.toml");
714 assert!(resolve_policy_source(Some(&missing), None, None).is_err());
715 }
716
717 #[test]
718 fn tampered_signature_is_rejected() {
719 let auth = fresh();
720 let mut buf = B64
721 .decode(
722 auth.issue_bearer("elliott", MEMORY_READ)
723 .expect("must issue"),
724 )
725 .unwrap();
726 let last = buf.len() - 1;
728 buf[last] ^= 0xff;
729 let tampered = B64.encode(&buf);
730 assert!(auth.verify_bearer(&tampered).is_err());
731 }
732
733 #[test]
734 fn admin_grant_implies_any_capability_via_geometry() {
735 let auth = fresh();
737 let bearer = auth.issue_bearer("root", ADMIN).expect("must issue");
738 let principal = auth.require(&bearer, MEMORY_READ).expect("admin may read");
739 assert_eq!(principal.principal.as_str(), "root");
740 assert!(auth.require(&bearer, MEMORY_WRITE).is_ok());
742 assert!(auth.require(&bearer, KNOWLEDGE_READ).is_ok());
743 }
744
745 #[test]
746 fn read_does_not_imply_write() {
747 let auth = fresh();
749 let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
750 assert!(auth.require(&bearer, MEMORY_WRITE).is_err());
751 }
752
753 #[test]
754 fn write_implies_read() {
755 let auth = fresh();
758 let bearer = auth.issue_bearer("bob", MEMORY_WRITE).expect("must issue");
759 assert!(auth.require(&bearer, MEMORY_READ).is_ok());
760 }
761
762 #[test]
763 fn unknown_required_capability_is_denied() {
764 let auth = fresh();
765 let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
766 let principal = auth.verify_bearer(&bearer).expect("must verify");
767 assert!(!principal.may("memory:nonexistent"));
768 }
769
770 #[test]
771 fn multi_capability_grant() {
772 let auth = fresh();
774 let bearer = auth
775 .issue_grant_bearer("pi", &[MEMORY_READ, MEMORY_WRITE, KNOWLEDGE_READ])
776 .expect("must issue");
777 let principal = auth.verify_bearer(&bearer).expect("must verify");
778 assert_eq!(principal.principal.as_str(), "pi");
779 assert!(principal.may(MEMORY_READ));
781 assert!(principal.may(MEMORY_WRITE));
782 assert!(principal.may(KNOWLEDGE_READ));
783 }
784
785 #[test]
786 fn empty_grant_rejected() {
787 let auth = fresh();
788 assert!(auth.issue_grant_bearer("x", &[]).is_err());
789 }
790
791 #[test]
792 fn unknown_capability_rejected_at_issue() {
793 let auth = fresh();
794 assert!(auth.issue_bearer("x", "bogus:cap").is_err());
795 }
796
797 #[test]
798 fn malformed_bearer_rejected() {
799 let auth = fresh();
800 assert!(auth.verify_bearer("not-base64!!!").is_err());
801 assert!(auth.verify_bearer("").is_err());
802 }
803
804 #[test]
805 fn seed_based_issue_then_verify_across_instances() {
806 let seed = IjimaAuth::generate_seed();
809 let issuer = IjimaAuth::from_embedded_policy_with_seed(seed).expect("issuer");
810 let bearer = issuer
811 .issue_grant_bearer("elliott", &[MEMORY_READ, MEMORY_WRITE])
812 .expect("must issue");
813 let public_key = issuer.issuer_public_key_hex();
814 assert_eq!(public_key.len(), 64);
815
816 let daemon = IjimaAuth::from_embedded_policy_with_seed(seed).expect("daemon");
817 let principal = daemon.verify_bearer(&bearer).expect("must verify");
818 assert_eq!(principal.principal.as_str(), "elliott");
819 assert_eq!(daemon.issuer_public_key_hex(), public_key);
820 }
821
822 #[test]
825 fn revoked_bearer_is_rejected_after_crypto_verify_passes() {
826 let auth = fresh();
827 let bearer = auth
828 .issue_bearer("elliott", MEMORY_READ)
829 .expect("must issue");
830 assert!(auth.verify_bearer(&bearer).is_ok()); auth.revoke(&bearer_hash(&bearer));
832 let err = auth.verify_bearer(&bearer).expect_err("must reject");
833 assert!(err.to_string().contains("revoked"));
834 }
835
836 #[test]
837 fn hydration_replaces_prior_set() {
838 let auth = fresh();
839 let b1 = auth.issue_bearer("a", MEMORY_READ).expect("issue");
840 let b2 = auth.issue_bearer("b", MEMORY_READ).expect("issue");
841 auth.revoke(&bearer_hash(&b1));
842 assert!(auth.is_revoked(&b1));
843 auth.hydrate_revocations(&[TokenRevocation {
846 token_hash: bearer_hash(&b2),
847 revoked_at_unix: 0,
848 reason: None,
849 }]);
850 assert!(!auth.is_revoked(&b1));
851 assert!(auth.is_revoked(&b2));
852 }
853
854 #[test]
855 fn bearer_hash_is_sha256_hex_of_trimmed_bearer() {
856 let h1 = bearer_hash(" abc ");
857 let h2 = bearer_hash("abc");
858 let h3 = bearer_hash("Bearer abc");
859 assert_eq!(h1, h2); assert_eq!(h2, h3); assert_eq!(h1.len(), 64); assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
863 }
864}