Skip to main content

ai_memory/federation/identity/
chain.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Hierarchical trust — chain-of-N credential verification (FED-P4).
5//!
6//! P2/P3 verify a node credential directly against a trusted issuer key
7//! (one level: the root signs every node). At fleet scale a single root that
8//! must sign every node credential is both an operational bottleneck and a
9//! blast-radius concentration — every issuance touches the one key whose
10//! compromise re-keys the whole fleet. Hierarchical trust introduces
11//! *intermediate* CAs (typically one per region per the inventory's
12//! `region.intermediate_ca`): the root signs a small number of intermediate
13//! certs; each intermediate signs the node credentials in its region.
14//! Receivers still enroll only the *root* key — the chain carries the
15//! intermediate cert inline and [`CertChain::verify`] walks
16//! root → … → leaf.
17//!
18//! ## An intermediate cert is just a credential
19//!
20//! No new wire type: an intermediate CA cert is a [`SignedCredential`] whose
21//! `subject_agent_id` is the intermediate's own `issuer_id` and whose
22//! `subject_pubkey` is the intermediate's verifying key, signed by the
23//! parent (root). Verifying it yields the key that verifies the next level
24//! down. This reuses the entire [`super::credential`] encoding + window +
25//! version machinery unchanged — no `CRED_VERSION` bump.
26//!
27//! ## What this core checks (and what it leaves to the caller)
28//!
29//! [`CertChain::verify`] enforces, at every link: the issuer signature, the
30//! validity window, **name binding** (a cert's `issuer_id` equals its
31//! parent's `subject_agent_id`, so the key chain and the *name* chain agree
32//! — a stolen intermediate key cannot impersonate a differently-named CA),
33//! cross-level `trust_domain` consistency, and a **depth cap** (an unbounded
34//! chain is a verify-cost DoS). It deliberately does NOT decide whether an
35//! intermediate has *authority* over the leaf's namespace — that delegation
36//! policy is the caller's, exactly as identity-binding is the caller's in
37//! [`SignedCredential::verify_against`]. [`subject_in_delegated_namespace`]
38//! is a pure helper callers may apply on the returned leaf.
39
40use 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
49/// HTTP header carrying the base64(CBOR) array of *intermediate* CA certs
50/// (anchor-first) for a hierarchical credential. The leaf still travels in
51/// [`super::credential::CREDENTIAL_HEADER`]; this header is emitted only when
52/// the leaf is signed by an intermediate rather than a root, so single-level
53/// P2/P3 peers never send it and receivers that see it absent verify exactly
54/// as they did pre-P4.
55pub const CHAIN_HEADER: &str = "x-memory-cred-chain";
56
57/// Default maximum chain depth (levels, leaf inclusive). `2` is the P4
58/// target: root → intermediate → leaf is depth 2 (one intermediate + the
59/// leaf). A direct root-signed leaf is depth 1. Receivers raise this only
60/// for deliberately deeper hierarchies.
61pub const DEFAULT_MAX_CHAIN_DEPTH: usize = 2;
62
63/// Reasons a certificate chain fails to verify. `tag()` yields a stable
64/// machine string for structured logging + JSON error envelopes, mirroring
65/// [`CredentialError::tag`].
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ChainError {
68    /// The chain carried no leaf at all (structurally impossible to present
69    /// a [`CertChain`], retained for completeness of the error surface).
70    EmptyChain,
71    /// The chain is deeper than the receiver's configured maximum.
72    ChainTooDeep {
73        /// The presented depth (intermediates + leaf).
74        depth: usize,
75        /// The configured cap.
76        max: usize,
77    },
78    /// A cert's `issuer_id` does not equal its parent's `subject_agent_id`:
79    /// the key chain links but the *name* chain does not, so the parent
80    /// never vouched for this issuer name.
81    NameMismatch,
82    /// Two adjacent links disagree on `trust_domain` — a credential from one
83    /// tenant must not ride a chain anchored in another.
84    DomainMismatch,
85    /// A credential-layer failure at some link (bad signature, expired,
86    /// not-yet-valid, unknown/anchor issuer, bad subject key, unsupported
87    /// version).
88    Link(CredentialError),
89    /// #1554 — an intermediate signed a child whose `subject_agent_id` falls
90    /// OUTSIDE the namespace that intermediate is delegated to (e.g.
91    /// `region/nyc/ca` minting `region/sfo/node-1`). The key + name chain link,
92    /// but the parent has no authority to vouch for this subject. Enforcing
93    /// this inside `verify` (not as caller-optional policy) closes the
94    /// delegation-confinement bypass.
95    DelegationOutOfNamespace {
96        /// The child subject the parent had no authority to sign.
97        subject: String,
98        /// The namespace the issuing intermediate is confined to.
99        delegated_namespace: String,
100    },
101}
102
103impl ChainError {
104    /// Stable machine-readable tag for logs + JSON error envelopes.
105    #[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/// A presented certificate chain: zero or more intermediate CA certs plus
139/// the leaf (node) credential.
140#[derive(Debug, Clone)]
141pub struct CertChain {
142    /// Intermediate CA certs, ordered **anchor-first**: `intermediates[0]`
143    /// is signed by a trusted root in the receiver's bundle; each subsequent
144    /// cert is signed by the previous one; the last signed the leaf. Empty
145    /// ⇒ the leaf is signed directly by a trusted root (the P2/P3 one-level
146    /// case, preserved for back-compat).
147    pub intermediates: Vec<SignedCredential>,
148    /// The leaf (node) credential whose `subject_pubkey` the caller will use
149    /// as the peer's verifying key once the chain verifies.
150    pub leaf: SignedCredential,
151}
152
153impl CertChain {
154    /// A chain with an explicit leaf and intermediates (anchor-first).
155    #[must_use]
156    pub fn new(leaf: SignedCredential, intermediates: Vec<SignedCredential>) -> Self {
157        Self {
158            intermediates,
159            leaf,
160        }
161    }
162
163    /// A one-level chain: the leaf is signed directly by a trusted root.
164    #[must_use]
165    pub fn direct(leaf: SignedCredential) -> Self {
166        Self {
167            intermediates: Vec::new(),
168            leaf,
169        }
170    }
171
172    /// Chain depth in levels (intermediates + the leaf).
173    #[must_use]
174    pub fn depth(&self) -> usize {
175        self.intermediates.len() + 1
176    }
177
178    /// Encode the anchor-first intermediates as the [`CHAIN_HEADER`] value
179    /// (`v1=<base64(CBOR array of credential wire envelopes)>`), or `None`
180    /// when the chain is one-level (no intermediates ⇒ no chain header, so a
181    /// hierarchical sender degrades to the exact P2/P3 wire when it happens to
182    /// hold a root-signed leaf). The leaf is encoded separately via
183    /// [`SignedCredential::to_header_value`] into the credential header.
184    ///
185    /// # Errors
186    /// [`CredentialError::Malformed`] on an internal serialisation fault.
187    pub fn intermediates_header_value(&self) -> Result<Option<String>, CredentialError> {
188        intermediates_to_header_value(&self.intermediates)
189    }
190
191    /// Parse a [`CHAIN_HEADER`] value (`v1=<base64(CBOR array)>`) into the
192    /// anchor-first intermediates list. Pair with a leaf parsed from the
193    /// credential header to reconstruct a [`CertChain`] via [`Self::new`].
194    ///
195    /// # Errors
196    /// [`CredentialError::Malformed`] on a missing prefix, bad base64, or a
197    /// structurally invalid CBOR array / envelope.
198    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    /// Verify the whole chain against `bundle` at `now_unix`, rejecting
223    /// chains deeper than `max_depth`. On success returns the verified leaf
224    /// [`FederationCredential`] whose `subject_pubkey` is the peer's key.
225    ///
226    /// Identity-binding (does the leaf's `subject_agent_id` match the wire
227    /// `peer_id`?) and delegation-authority (may this intermediate vouch for
228    /// this leaf's namespace?) stay the caller's responsibility — the same
229    /// crypto/policy split as [`SignedCredential::verify_against`].
230    ///
231    /// # Errors
232    /// - [`ChainError::ChainTooDeep`] when `depth() > max_depth`.
233    /// - [`ChainError::NameMismatch`] / [`ChainError::DomainMismatch`] on a
234    ///   broken name or domain link.
235    /// - [`ChainError::Link`] wrapping any credential-layer failure (anchor
236    ///   not in bundle, bad signature, expired, etc.).
237    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        // One-level: the leaf is signed directly by a trusted root. Defer
252        // wholesale to the bundle (issuer-in-bundle + domain scope + sig +
253        // window) — preserves the exact P2/P3 verify semantics.
254        let Some((anchor, rest)) = self.intermediates.split_first() else {
255            return Ok(bundle.verify(&self.leaf, now_unix)?);
256        };
257
258        // Anchor the chain: the topmost intermediate must be signed by a
259        // root the bundle trusts. `bundle.verify` returns the intermediate's
260        // own claims (its subject name + key become the next verifier).
261        let mut parent = bundle.verify(anchor, now_unix)?;
262
263        // Walk the remaining intermediates, then the leaf, each verified
264        // against the previous link's subject key + name + domain.
265        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
274/// Verify one child→parent link: name binding, domain consistency, then the
275/// child's issuer signature + validity window against the parent's subject
276/// key.
277fn 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    // #1554 — delegation-namespace confinement. The name + key chain now
292    // links, but an intermediate may only vouch for subjects WITHIN the
293    // namespace it is delegated to. Enforcing this here (rather than leaving it
294    // as caller-optional policy) is what closes the cross-namespace
295    // identity-spoof: the only production caller never applied the check, so a
296    // `region/nyc/ca` intermediate could mint a leaf for `region/sfo/node-1`.
297    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
307/// Suffix marking an intermediate-CA identity (e.g. `region/nyc/ca`). The
308/// namespace such an intermediate is delegated to is its `subject_agent_id`
309/// with this suffix stripped (`region/nyc`); a non-suffixed parent delegates
310/// only its own subtree. Centralised so the convention is not a scattered
311/// literal (pm-v3.1).
312pub const CA_MARKER_SUFFIX: &str = "/ca";
313
314/// Derive the namespace an intermediate is confined to from its
315/// `subject_agent_id`. `region/nyc/ca` → `region/nyc` (may sign `region/nyc/*`);
316/// a parent without the CA marker delegates only its own subtree
317/// (`subject_agent_id/*`). The root anchor is verified by the trust bundle, not
318/// this path, so it is unconstrained by design.
319fn delegated_namespace_of(parent_subject: &str) -> &str {
320    parent_subject
321        .strip_suffix(CA_MARKER_SUFFIX)
322        .unwrap_or(parent_subject)
323}
324
325/// Whether `subject_agent_id` falls within the namespace an intermediate CA
326/// is delegated to sign. A region intermediate whose authority is
327/// `region/nyc` may vouch for `region/nyc/node-1` (and for the bare
328/// `region/nyc` itself) but NOT for `region/sfo/node-9` nor the
329/// sibling-prefix `region/nyceast/node-2`. An empty `ca_namespace` delegates
330/// everything (the root).
331///
332/// This is pure delegation *policy* a caller applies AFTER
333/// [`CertChain::verify`] proves the chain crypto; it is intentionally not
334/// baked into `verify` (same crypto/policy split as identity-binding).
335#[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    // Trailing-slash prefix so `region/nyc` does NOT match `region/nyceast`.
344    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
352/// Env var naming a file whose contents are a [`CHAIN_HEADER`] value
353/// (`v1=<base64(CBOR array)>`) — the anchor-first intermediate CA certs a
354/// node presents alongside its leaf credential so peers can verify a
355/// hierarchical chain against a root-only trust bundle. Unset ⇒ the node
356/// holds no intermediates and presents a direct (P2/P3) leaf only.
357pub const FED_CRED_CHAIN_PATH_ENV: &str = "AI_MEMORY_FED_CRED_CHAIN_PATH";
358
359/// Encode anchor-first `intermediates` as the [`CHAIN_HEADER`] value, or
360/// `None` when the list is empty (a direct root-signed leaf emits no chain
361/// header, degrading to the exact P2/P3 wire). Shared by
362/// [`CertChain::intermediates_header_value`] and the outbound sender path.
363///
364/// # Errors
365/// [`CredentialError::Malformed`] on an internal serialisation fault.
366pub 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
382/// Load anchor-first intermediates from a file whose contents are a
383/// [`CHAIN_HEADER`] value. A missing file is `Ok(Vec::new())` — holding no
384/// intermediates is the normal one-level posture, not an error.
385///
386/// # Errors
387/// [`std::io::Error`] on a read fault other than not-found, or `InvalidData`
388/// if the content is not a parseable chain-header value.
389pub 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
401/// Load the held outbound intermediates named by [`FED_CRED_CHAIN_PATH_ENV`].
402/// An unset env var is `Ok(Vec::new())` — the node presents a direct leaf.
403///
404/// # Errors
405/// Propagates [`load_intermediates_from_path`] faults.
406pub 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    /// Mint a credential: `signer` vouches for `subject_id` bound to
426    /// `subject_key`, naming itself `issuer_id`, in `domain`.
427    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    /// Build a standard root → intermediate → leaf trio and a bundle that
449    /// trusts only the root. Returns (bundle, intermediate_cert, leaf).
450    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        // root signs the intermediate cert (subject = intermediate issuer).
456        let inter_cert = mint(
457            &root,
458            "root",
459            "region/nyc/ca",
460            &intermediate.verifying_key(),
461            DOMAIN,
462            NOW + 7200,
463        );
464        // intermediate signs the node leaf.
465        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        // Encode the intermediates to the chain header, parse them back,
493        // reassemble with a freshly-decoded leaf, and prove the rebuilt
494        // chain still verifies against the root-only bundle.
495        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        // Back-compat: a leaf signed directly by the trusted root.
617        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        // Two intermediates + leaf = depth 3 > max 2 (content irrelevant —
638        // the cap is checked before any crypto).
639        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        // Leaf claims a DIFFERENT issuer name than the intermediate's subject.
661        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        // #1554 — the core spoof: a region intermediate (or its compromise)
679        // mints a leaf for a subject OUTSIDE its delegated namespace. The name
680        // chain binds (leaf.issuer_id == intermediate.subject == region/nyc/ca)
681        // and the signature is valid, so only the delegation-namespace check
682        // can stop it. Pre-fix, `verify` returned Ok and the receiver trusted
683        // the attacker key for region/sfo/node-1.
684        // Fixtures bound once: the intermediate CA, the foreign subject it has
685        // no authority over, and the namespace it IS delegated to (= its id with
686        // the CA marker stripped — the value `delegated_namespace_of` derives).
687        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        // ca_id vouches for a foreign-region subject — name binds (leaf.issuer_id
703        // == intermediate.subject), but the intermediate has no authority there.
704        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        // #1554 positive case: a leaf WITHIN the intermediate's delegated
728        // namespace verifies, so the confinement check does not regress the
729        // legitimate hierarchical path.
730        let (bundle, inter_cert, leaf) = two_level_setup(); // region/nyc/ca → region/nyc/node-1
731        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        // region/nyc/ca delegates region/nyc; a non-CA-marked parent delegates
740        // only its own subtree (its full subject).
741        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        // A subject is within its own delegated namespace and its children's,
745        // but a sibling prefix is NOT (trailing-slash boundary).
746        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        // Leaf rides a different trust domain than the intermediate.
775        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        // Attacker self-signs an intermediate cert the root never vouched for.
793        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        // Anchor signature fails against the real root key.
817        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        // Forge a leaf signed by a key that is NOT the intermediate.
824        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        // Intermediate already expired at NOW.
846        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        // Bundle trusts a DIFFERENT root id than the intermediate names.
873        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        // In-region child + the bare CA namespace pass.
885        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        // Out-of-region + sibling-prefix are rejected.
891        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        // Root (empty namespace) delegates everything.
900        assert!(subject_in_delegated_namespace("anything/at/all", ""));
901    }
902}