1use base64::Engine;
41use base64::engine::general_purpose::STANDARD as B64;
42use ed25519_dalek::VerifyingKey;
43
44use super::credential::{
45 CREDENTIAL_PREFIX, CredentialError, FederationCredential, SignedCredential,
46};
47use super::trust_bundle::TrustBundle;
48
49pub const CHAIN_HEADER: &str = "x-memory-cred-chain";
56
57pub const DEFAULT_MAX_CHAIN_DEPTH: usize = 2;
62
63#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ChainError {
68 EmptyChain,
71 ChainTooDeep {
73 depth: usize,
75 max: usize,
77 },
78 NameMismatch,
82 DomainMismatch,
85 Link(CredentialError),
89 DelegationOutOfNamespace {
96 subject: String,
98 delegated_namespace: String,
100 },
101}
102
103impl ChainError {
104 #[must_use]
106 pub fn tag(&self) -> &'static str {
107 match self {
108 Self::EmptyChain => "chain_empty",
109 Self::ChainTooDeep { .. } => "chain_too_deep",
110 Self::NameMismatch => "chain_name_mismatch",
111 Self::DomainMismatch => "chain_domain_mismatch",
112 Self::DelegationOutOfNamespace { .. } => "chain_delegation_out_of_namespace",
113 Self::Link(e) => e.tag(),
114 }
115 }
116}
117
118impl std::fmt::Display for ChainError {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match self {
121 Self::ChainTooDeep { depth, max } => {
122 write!(f, "{} (depth {depth} > max {max})", self.tag())
123 }
124 Self::Link(e) => write!(f, "{e}"),
125 _ => f.write_str(self.tag()),
126 }
127 }
128}
129
130impl std::error::Error for ChainError {}
131
132impl From<CredentialError> for ChainError {
133 fn from(e: CredentialError) -> Self {
134 Self::Link(e)
135 }
136}
137
138#[derive(Debug, Clone)]
141pub struct CertChain {
142 pub intermediates: Vec<SignedCredential>,
148 pub leaf: SignedCredential,
151}
152
153impl CertChain {
154 #[must_use]
156 pub fn new(leaf: SignedCredential, intermediates: Vec<SignedCredential>) -> Self {
157 Self {
158 intermediates,
159 leaf,
160 }
161 }
162
163 #[must_use]
165 pub fn direct(leaf: SignedCredential) -> Self {
166 Self {
167 intermediates: Vec::new(),
168 leaf,
169 }
170 }
171
172 #[must_use]
174 pub fn depth(&self) -> usize {
175 self.intermediates.len() + 1
176 }
177
178 pub fn intermediates_header_value(&self) -> Result<Option<String>, CredentialError> {
188 intermediates_to_header_value(&self.intermediates)
189 }
190
191 pub fn intermediates_from_header_value(
199 value: &str,
200 ) -> Result<Vec<SignedCredential>, CredentialError> {
201 let b64 = value
202 .strip_prefix(CREDENTIAL_PREFIX)
203 .ok_or(CredentialError::Malformed)?;
204 let wire = B64.decode(b64).map_err(|_| CredentialError::Malformed)?;
205 let parsed: ciborium::Value =
206 ciborium::de::from_reader(&wire[..]).map_err(|_| CredentialError::Malformed)?;
207 let items = match parsed {
208 ciborium::Value::Array(a) => a,
209 _ => return Err(CredentialError::Malformed),
210 };
211 let mut out = Vec::with_capacity(items.len());
212 for item in items {
213 let bytes = match item {
214 ciborium::Value::Bytes(b) => b,
215 _ => return Err(CredentialError::Malformed),
216 };
217 out.push(SignedCredential::from_wire_bytes(&bytes)?);
218 }
219 Ok(out)
220 }
221
222 pub fn verify(
238 &self,
239 bundle: &TrustBundle,
240 now_unix: i64,
241 max_depth: usize,
242 ) -> Result<FederationCredential, ChainError> {
243 let depth = self.depth();
244 if depth > max_depth {
245 return Err(ChainError::ChainTooDeep {
246 depth,
247 max: max_depth,
248 });
249 }
250
251 let Some((anchor, rest)) = self.intermediates.split_first() else {
255 return Ok(bundle.verify(&self.leaf, now_unix)?);
256 };
257
258 let mut parent = bundle.verify(anchor, now_unix)?;
262
263 for cert in rest.iter().chain(std::iter::once(&self.leaf)) {
266 verify_link(cert, &parent, now_unix)?;
267 parent = cert.credential().clone();
268 }
269
270 Ok(self.leaf.credential().clone())
271 }
272}
273
274fn verify_link(
278 child: &SignedCredential,
279 parent: &FederationCredential,
280 now_unix: i64,
281) -> Result<(), ChainError> {
282 let c = child.credential();
283 if c.issuer_id != parent.subject_agent_id {
284 return Err(ChainError::NameMismatch);
285 }
286 if c.trust_domain != parent.trust_domain {
287 return Err(ChainError::DomainMismatch);
288 }
289 let parent_key: VerifyingKey = parent.subject_verifying_key()?;
290 child.verify_against(&parent_key, now_unix)?;
291 let delegated = delegated_namespace_of(&parent.subject_agent_id);
298 if !subject_in_delegated_namespace(&c.subject_agent_id, delegated) {
299 return Err(ChainError::DelegationOutOfNamespace {
300 subject: c.subject_agent_id.clone(),
301 delegated_namespace: delegated.to_string(),
302 });
303 }
304 Ok(())
305}
306
307pub const CA_MARKER_SUFFIX: &str = "/ca";
313
314fn delegated_namespace_of(parent_subject: &str) -> &str {
320 parent_subject
321 .strip_suffix(CA_MARKER_SUFFIX)
322 .unwrap_or(parent_subject)
323}
324
325#[must_use]
336pub fn subject_in_delegated_namespace(subject_agent_id: &str, ca_namespace: &str) -> bool {
337 if ca_namespace.is_empty() {
338 return true;
339 }
340 if subject_agent_id == ca_namespace {
341 return true;
342 }
343 let boundary = if ca_namespace.ends_with('/') {
345 ca_namespace.to_string()
346 } else {
347 format!("{ca_namespace}/")
348 };
349 subject_agent_id.starts_with(&boundary)
350}
351
352pub const FED_CRED_CHAIN_PATH_ENV: &str = "AI_MEMORY_FED_CRED_CHAIN_PATH";
358
359pub fn intermediates_to_header_value(
367 intermediates: &[SignedCredential],
368) -> Result<Option<String>, CredentialError> {
369 if intermediates.is_empty() {
370 return Ok(None);
371 }
372 let mut items = Vec::with_capacity(intermediates.len());
373 for ic in intermediates {
374 items.push(ciborium::Value::Bytes(ic.to_wire_bytes()?));
375 }
376 let value = ciborium::Value::Array(items);
377 let mut out = Vec::new();
378 ciborium::ser::into_writer(&value, &mut out).map_err(|_| CredentialError::Malformed)?;
379 Ok(Some(format!("{CREDENTIAL_PREFIX}{}", B64.encode(out))))
380}
381
382pub fn load_intermediates_from_path(
390 path: &std::path::Path,
391) -> std::io::Result<Vec<SignedCredential>> {
392 let raw = match std::fs::read_to_string(path) {
393 Ok(s) => s,
394 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
395 Err(e) => return Err(e),
396 };
397 CertChain::intermediates_from_header_value(raw.trim())
398 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
399}
400
401pub fn load_intermediates_from_env() -> std::io::Result<Vec<SignedCredential>> {
407 match std::env::var(FED_CRED_CHAIN_PATH_ENV) {
408 Ok(path) => load_intermediates_from_path(std::path::Path::new(&path)),
409 Err(_) => Ok(Vec::new()),
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use ed25519_dalek::SigningKey;
417
418 const NOW: i64 = 1_900_000_000;
419 const DOMAIN: &str = "fleet.example";
420
421 fn signing_key(seed: u8) -> SigningKey {
422 SigningKey::from_bytes(&[seed; 32])
423 }
424
425 fn mint(
428 signer: &SigningKey,
429 issuer_id: &str,
430 subject_id: &str,
431 subject_key: &VerifyingKey,
432 domain: &str,
433 not_after: i64,
434 ) -> SignedCredential {
435 FederationCredential {
436 subject_agent_id: subject_id.to_string(),
437 subject_pubkey: subject_key.to_bytes(),
438 issuer_id: issuer_id.to_string(),
439 trust_domain: domain.to_string(),
440 not_before: NOW - 10,
441 not_after,
442 cred_version: super::super::credential::CRED_VERSION,
443 }
444 .sign(signer)
445 .expect("sign")
446 }
447
448 fn two_level_setup() -> (TrustBundle, SignedCredential, SignedCredential) {
451 let root = signing_key(1);
452 let intermediate = signing_key(2);
453 let node = signing_key(3);
454
455 let inter_cert = mint(
457 &root,
458 "root",
459 "region/nyc/ca",
460 &intermediate.verifying_key(),
461 DOMAIN,
462 NOW + 7200,
463 );
464 let leaf = mint(
466 &intermediate,
467 "region/nyc/ca",
468 "region/nyc/node-1",
469 &node.verifying_key(),
470 DOMAIN,
471 NOW + 3600,
472 );
473 let bundle = TrustBundle::new().with_issuer("root", root.verifying_key());
474 (bundle, inter_cert, leaf)
475 }
476
477 #[test]
478 fn two_level_chain_verifies() {
479 let (bundle, inter, leaf) = two_level_setup();
480 let chain = CertChain::new(leaf, vec![inter]);
481 let verified = chain
482 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
483 .expect("chain verifies");
484 assert_eq!(verified.subject_agent_id, "region/nyc/node-1");
485 }
486
487 #[test]
488 fn intermediates_header_round_trips_and_reverifies() {
489 let (bundle, inter, leaf) = two_level_setup();
490 let chain = CertChain::new(leaf, vec![inter]);
491
492 let header = chain
496 .intermediates_header_value()
497 .expect("encode chain header")
498 .expect("two-level chain emits a header");
499 let parsed_inters =
500 CertChain::intermediates_from_header_value(&header).expect("parse chain header");
501 let leaf_header = chain.leaf.to_header_value().expect("encode leaf");
502 let parsed_leaf = SignedCredential::from_header_value(&leaf_header).expect("parse leaf");
503
504 let rebuilt = CertChain::new(parsed_leaf, parsed_inters);
505 let verified = rebuilt
506 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
507 .expect("rebuilt chain verifies");
508 assert_eq!(verified.subject_agent_id, "region/nyc/node-1");
509 }
510
511 #[test]
512 fn direct_chain_emits_no_intermediates_header() {
513 let root = signing_key(60);
514 let node = signing_key(61);
515 let leaf = mint(
516 &root,
517 "root",
518 "region/nyc/node-1",
519 &node.verifying_key(),
520 DOMAIN,
521 NOW + 3600,
522 );
523 let chain = CertChain::direct(leaf);
524 assert!(
525 chain
526 .intermediates_header_value()
527 .expect("encode")
528 .is_none(),
529 "a one-level chain must emit no chain header"
530 );
531 }
532
533 #[test]
534 fn malformed_chain_header_is_rejected() {
535 assert_eq!(
536 CertChain::intermediates_from_header_value("not-a-prefix").unwrap_err(),
537 CredentialError::Malformed
538 );
539 assert_eq!(
540 CertChain::intermediates_from_header_value("v1=@@notbase64@@").unwrap_err(),
541 CredentialError::Malformed
542 );
543 }
544
545 fn scratch_dir() -> std::path::PathBuf {
546 let mut dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
547 dir.push(".local-runs");
548 dir.push("test-tmp");
549 std::fs::create_dir_all(&dir).expect("create scratch dir");
550 dir
551 }
552
553 fn unique_chain_path(label: &str) -> std::path::PathBuf {
554 let nanos = std::time::SystemTime::now()
555 .duration_since(std::time::UNIX_EPOCH)
556 .map(|d| d.as_nanos())
557 .unwrap_or(0);
558 scratch_dir().join(format!("chain-{label}-{nanos}.chain"))
559 }
560
561 #[test]
562 fn free_encoder_matches_the_chain_method() {
563 let (_bundle, inter, leaf) = two_level_setup();
564 let chain = CertChain::new(leaf, vec![inter.clone()]);
565 assert_eq!(
566 intermediates_to_header_value(&[inter]).expect("free encode"),
567 chain.intermediates_header_value().expect("method encode"),
568 );
569 assert!(
570 intermediates_to_header_value(&[])
571 .expect("empty encode")
572 .is_none(),
573 "an empty intermediates list emits no header"
574 );
575 }
576
577 #[test]
578 fn load_intermediates_from_path_round_trips() {
579 let (bundle, inter, leaf) = two_level_setup();
580 let header = intermediates_to_header_value(std::slice::from_ref(&inter))
581 .expect("encode")
582 .expect("two-level emits a header");
583 let path = unique_chain_path("roundtrip");
584 std::fs::write(&path, format!("{header}\n")).expect("write chain file");
585
586 let loaded = load_intermediates_from_path(&path).expect("io ok");
587 let rebuilt = CertChain::new(leaf, loaded);
588 let verified = rebuilt
589 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
590 .expect("rebuilt chain verifies");
591 assert_eq!(verified.subject_agent_id, "region/nyc/node-1");
592 let _ = std::fs::remove_file(&path);
593 }
594
595 #[test]
596 fn load_intermediates_from_path_missing_file_is_empty() {
597 let path = unique_chain_path("missing");
598 assert!(
599 load_intermediates_from_path(&path)
600 .expect("missing file is not an error")
601 .is_empty()
602 );
603 }
604
605 #[test]
606 fn load_intermediates_from_path_malformed_is_invalid_data() {
607 let path = unique_chain_path("garbage");
608 std::fs::write(&path, "not-a-chain-header").expect("write");
609 let err = load_intermediates_from_path(&path).expect_err("malformed must error");
610 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
611 let _ = std::fs::remove_file(&path);
612 }
613
614 #[test]
615 fn one_level_direct_chain_still_verifies() {
616 let root = signing_key(10);
618 let node = signing_key(11);
619 let leaf = mint(
620 &root,
621 "root",
622 "region/nyc/node-1",
623 &node.verifying_key(),
624 DOMAIN,
625 NOW + 3600,
626 );
627 let bundle = TrustBundle::new().with_issuer("root", root.verifying_key());
628 let verified = CertChain::direct(leaf)
629 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
630 .expect("direct chain verifies");
631 assert_eq!(verified.subject_agent_id, "region/nyc/node-1");
632 }
633
634 #[test]
635 fn chain_deeper_than_max_is_rejected() {
636 let (bundle, inter, leaf) = two_level_setup();
637 let chain = CertChain::new(leaf, vec![inter.clone(), inter]);
640 let err = chain
641 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
642 .unwrap_err();
643 assert_eq!(err, ChainError::ChainTooDeep { depth: 3, max: 2 });
644 assert_eq!(err.tag(), "chain_too_deep");
645 }
646
647 #[test]
648 fn name_mismatch_between_levels_is_rejected() {
649 let root = signing_key(20);
650 let intermediate = signing_key(21);
651 let node = signing_key(22);
652 let inter_cert = mint(
653 &root,
654 "root",
655 "region/nyc/ca",
656 &intermediate.verifying_key(),
657 DOMAIN,
658 NOW + 7200,
659 );
660 let leaf = mint(
662 &intermediate,
663 "region/sfo/ca",
664 "region/nyc/node-1",
665 &node.verifying_key(),
666 DOMAIN,
667 NOW + 3600,
668 );
669 let bundle = TrustBundle::new().with_issuer("root", root.verifying_key());
670 let err = CertChain::new(leaf, vec![inter_cert])
671 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
672 .unwrap_err();
673 assert_eq!(err, ChainError::NameMismatch);
674 }
675
676 #[test]
677 fn intermediate_minting_out_of_namespace_leaf_is_rejected() {
678 let ca_id = "region/nyc/ca";
688 let foreign_subject = "region/sfo/node-1";
689 let expected_ns = ca_id.strip_suffix(CA_MARKER_SUFFIX).unwrap();
690
691 let root = signing_key(50);
692 let intermediate = signing_key(51);
693 let node = signing_key(52);
694 let inter_cert = mint(
695 &root,
696 "root",
697 ca_id,
698 &intermediate.verifying_key(),
699 DOMAIN,
700 NOW + 7200,
701 );
702 let leaf = mint(
705 &intermediate,
706 ca_id,
707 foreign_subject,
708 &node.verifying_key(),
709 DOMAIN,
710 NOW + 3600,
711 );
712 let bundle = TrustBundle::new().with_issuer("root", root.verifying_key());
713 let err = CertChain::new(leaf, vec![inter_cert])
714 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
715 .unwrap_err();
716 assert_eq!(
717 err,
718 ChainError::DelegationOutOfNamespace {
719 subject: foreign_subject.to_string(),
720 delegated_namespace: expected_ns.to_string(),
721 }
722 );
723 }
724
725 #[test]
726 fn intermediate_minting_in_namespace_leaf_is_accepted() {
727 let (bundle, inter_cert, leaf) = two_level_setup(); let verified = CertChain::new(leaf, vec![inter_cert])
732 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
733 .expect("in-namespace leaf must verify");
734 assert_eq!(verified.subject_agent_id, "region/nyc/node-1");
735 }
736
737 #[test]
738 fn delegated_namespace_of_strips_ca_marker_else_self() {
739 let ca_id = format!("region/nyc{CA_MARKER_SUFFIX}");
742 assert_eq!(delegated_namespace_of(&ca_id), "region/nyc");
743 assert_eq!(delegated_namespace_of("region/nyc"), "region/nyc");
744 let ns = delegated_namespace_of(&ca_id);
747 assert!(subject_in_delegated_namespace("region/nyc/node-1", ns));
748 assert!(!subject_in_delegated_namespace("region/nyceast/node-2", ns));
749 assert!(!subject_in_delegated_namespace("region/sfo/node-1", ns));
750 }
751
752 #[test]
753 fn delegation_violation_has_stable_tag() {
754 let err = ChainError::DelegationOutOfNamespace {
755 subject: "region/sfo/node-1".to_string(),
756 delegated_namespace: "region/nyc".to_string(),
757 };
758 assert_eq!(err.tag(), "chain_delegation_out_of_namespace");
759 }
760
761 #[test]
762 fn domain_mismatch_between_levels_is_rejected() {
763 let root = signing_key(30);
764 let intermediate = signing_key(31);
765 let node = signing_key(32);
766 let inter_cert = mint(
767 &root,
768 "root",
769 "region/nyc/ca",
770 &intermediate.verifying_key(),
771 DOMAIN,
772 NOW + 7200,
773 );
774 let leaf = mint(
776 &intermediate,
777 "region/nyc/ca",
778 "region/nyc/node-1",
779 &node.verifying_key(),
780 "other.tenant",
781 NOW + 3600,
782 );
783 let bundle = TrustBundle::new().with_issuer("root", root.verifying_key());
784 let err = CertChain::new(leaf, vec![inter_cert])
785 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
786 .unwrap_err();
787 assert_eq!(err, ChainError::DomainMismatch);
788 }
789
790 #[test]
791 fn rogue_intermediate_not_signed_by_root_is_rejected() {
792 let root = signing_key(40);
794 let attacker = signing_key(41);
795 let node = signing_key(42);
796 let rogue_inter = mint(
797 &attacker,
798 "root",
799 "region/nyc/ca",
800 &attacker.verifying_key(),
801 DOMAIN,
802 NOW + 7200,
803 );
804 let leaf = mint(
805 &attacker,
806 "region/nyc/ca",
807 "region/nyc/node-1",
808 &node.verifying_key(),
809 DOMAIN,
810 NOW + 3600,
811 );
812 let bundle = TrustBundle::new().with_issuer("root", root.verifying_key());
813 let err = CertChain::new(leaf, vec![rogue_inter])
814 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
815 .unwrap_err();
816 assert_eq!(err, ChainError::Link(CredentialError::BadSignature));
818 }
819
820 #[test]
821 fn leaf_signed_by_wrong_key_is_bad_signature() {
822 let (bundle, inter, _leaf) = two_level_setup();
823 let imposter = signing_key(99);
825 let node = signing_key(98);
826 let forged_leaf = mint(
827 &imposter,
828 "region/nyc/ca",
829 "region/nyc/node-1",
830 &node.verifying_key(),
831 DOMAIN,
832 NOW + 3600,
833 );
834 let err = CertChain::new(forged_leaf, vec![inter])
835 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
836 .unwrap_err();
837 assert_eq!(err, ChainError::Link(CredentialError::BadSignature));
838 }
839
840 #[test]
841 fn expired_intermediate_propagates_window_error() {
842 let root = signing_key(50);
843 let intermediate = signing_key(51);
844 let node = signing_key(52);
845 let inter_cert = mint(
847 &root,
848 "root",
849 "region/nyc/ca",
850 &intermediate.verifying_key(),
851 DOMAIN,
852 NOW - 1,
853 );
854 let leaf = mint(
855 &intermediate,
856 "region/nyc/ca",
857 "region/nyc/node-1",
858 &node.verifying_key(),
859 DOMAIN,
860 NOW + 3600,
861 );
862 let bundle = TrustBundle::new().with_issuer("root", root.verifying_key());
863 let err = CertChain::new(leaf, vec![inter_cert])
864 .verify(&bundle, NOW, DEFAULT_MAX_CHAIN_DEPTH)
865 .unwrap_err();
866 assert_eq!(err, ChainError::Link(CredentialError::Expired));
867 }
868
869 #[test]
870 fn anchor_issuer_not_in_bundle_is_unknown_issuer() {
871 let (_bundle, inter, leaf) = two_level_setup();
872 let other_root = signing_key(60);
874 let empty_for_root =
875 TrustBundle::new().with_issuer("other-root", other_root.verifying_key());
876 let err = CertChain::new(leaf, vec![inter])
877 .verify(&empty_for_root, NOW, DEFAULT_MAX_CHAIN_DEPTH)
878 .unwrap_err();
879 assert_eq!(err, ChainError::Link(CredentialError::UnknownIssuer));
880 }
881
882 #[test]
883 fn delegated_namespace_accepts_child_and_self_rejects_sibling() {
884 assert!(subject_in_delegated_namespace(
886 "region/nyc/node-1",
887 "region/nyc"
888 ));
889 assert!(subject_in_delegated_namespace("region/nyc", "region/nyc"));
890 assert!(!subject_in_delegated_namespace(
892 "region/sfo/node-9",
893 "region/nyc"
894 ));
895 assert!(!subject_in_delegated_namespace(
896 "region/nyceast/node-2",
897 "region/nyc"
898 ));
899 assert!(subject_in_delegated_namespace("anything/at/all", ""));
901 }
902}