Skip to main content

ai_memory/federation/identity/
issuer.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! First-party federation credential issuer — the CA half of the trust
5//! model.
6//!
7//! A [`FederationIssuer`] holds an Ed25519 signing key and mints
8//! short-lived [`SignedCredential`]s that bind a node's `agent_id` to its
9//! verifying key. Receivers trust the issuer's *verifying* key (published
10//! into a [`super::trust_bundle::TrustBundle`]) and thereby trust every
11//! credential it signs — the O(1) replacement for O(N²) per-peer `.pub`
12//! enrollment.
13//!
14//! ## Attestation
15//!
16//! Who is allowed to receive a credential for `agent_id` X? That gate is
17//! [`AttestationMap`]: it maps an *attested identity* — the mTLS-verified
18//! peer identity the TLS layer already extracts (cert CN / SAN) — to the
19//! `agent_id` the issuer is willing to vouch for. An empty map is
20//! *passthrough* (zero-config: the attested mTLS identity IS the subject);
21//! a populated map is a strict allowlist (an unmapped identity is denied).
22//! This keeps the heavy X.509 parsing at the TLS boundary and leaves the
23//! issuer reasoning over a clean identity string.
24//!
25//! The issuer takes `now_unix` as an explicit parameter (the same testable
26//! convention as [`SignedCredential::verify_against`]); the renewal timer
27//! in P3 supplies the real wall clock.
28
29use std::collections::BTreeMap;
30use std::path::Path;
31
32use ed25519_dalek::{SigningKey, VerifyingKey};
33
34use super::chain::CertChain;
35use super::credential::{CredentialError, FederationCredential, SignedCredential};
36
37/// Default credential lifetime. Short by design so a leaked credential
38/// self-expires quickly; the P3 renewal timer re-mints well before expiry.
39pub const DEFAULT_CREDENTIAL_TTL_SECS: i64 = crate::SECS_PER_HOUR;
40
41/// Default clock-skew allowance applied to `not_before` so a freshly
42/// minted credential is not rejected as `NotYetValid` by a verifier whose
43/// clock trails the issuer's by a few seconds.
44pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 30;
45
46/// Default lifetime for an *intermediate CA* credential. Longer than a
47/// leaf TTL — an intermediate is itself re-minted by its parent on a
48/// slower cadence than the leaves it signs — but still bounded so a
49/// compromised region key self-expires without a manual revocation list.
50pub const DEFAULT_INTERMEDIATE_TTL_SECS: i64 = crate::SECS_PER_DAY;
51
52/// Reasons issuance fails.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum IssuerError {
55    /// The attested identity is not permitted to receive a credential (the
56    /// attestation map is a non-empty allowlist and the identity is absent).
57    AttestationDenied,
58    /// The issuer was loaded from disk but no private signing key was found.
59    MissingPrivateKey,
60    /// The requested TTL is not strictly positive.
61    InvalidTtl,
62    /// A credential-layer error surfaced while signing.
63    Credential(CredentialError),
64    /// An I/O error surfaced while loading the issuer key from disk.
65    Io(String),
66}
67
68impl IssuerError {
69    /// Stable machine-readable tag for logs + JSON error envelopes.
70    #[must_use]
71    pub fn tag(&self) -> &'static str {
72        match self {
73            Self::AttestationDenied => "issuer_attestation_denied",
74            Self::MissingPrivateKey => "issuer_missing_private_key",
75            Self::InvalidTtl => "issuer_invalid_ttl",
76            Self::Credential(e) => e.tag(),
77            Self::Io(_) => "issuer_io_error",
78        }
79    }
80}
81
82impl std::fmt::Display for IssuerError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::Credential(e) => write!(f, "{e}"),
86            Self::Io(msg) => write!(f, "{} ({msg})", self.tag()),
87            _ => f.write_str(self.tag()),
88        }
89    }
90}
91
92impl std::error::Error for IssuerError {}
93
94impl From<CredentialError> for IssuerError {
95    fn from(e: CredentialError) -> Self {
96        Self::Credential(e)
97    }
98}
99
100/// Issuer configuration: identity, trust domain, and lifetime knobs.
101#[derive(Debug, Clone)]
102pub struct IssuerConfig {
103    /// The `issuer_id` stamped into every minted credential. Receivers key
104    /// their trust bundle on this string.
105    pub issuer_id: String,
106    /// The `trust_domain` stamped into every minted credential.
107    pub trust_domain: String,
108    /// Default credential lifetime in seconds.
109    pub ttl_secs: i64,
110    /// Clock-skew allowance applied to `not_before`.
111    pub clock_skew_secs: i64,
112}
113
114impl IssuerConfig {
115    /// Build a config with the default TTL + skew.
116    #[must_use]
117    pub fn new(issuer_id: impl Into<String>, trust_domain: impl Into<String>) -> Self {
118        Self {
119            issuer_id: issuer_id.into(),
120            trust_domain: trust_domain.into(),
121            ttl_secs: DEFAULT_CREDENTIAL_TTL_SECS,
122            clock_skew_secs: DEFAULT_CLOCK_SKEW_SECS,
123        }
124    }
125
126    /// Override the default credential lifetime (builder form).
127    #[must_use]
128    pub fn with_ttl_secs(mut self, ttl_secs: i64) -> Self {
129        self.ttl_secs = ttl_secs;
130        self
131    }
132}
133
134/// The first-party CA: an Ed25519 signing key plus issuance policy.
135#[derive(Debug, Clone)]
136pub struct FederationIssuer {
137    signing_key: SigningKey,
138    config: IssuerConfig,
139}
140
141impl FederationIssuer {
142    /// Construct an issuer from an in-memory signing key + config.
143    #[must_use]
144    pub fn new(signing_key: SigningKey, config: IssuerConfig) -> Self {
145        Self {
146            signing_key,
147            config,
148        }
149    }
150
151    /// Load the issuer signing key from `<key_dir>/<issuer_id>.priv`
152    /// (the substrate's raw-32-byte keypair format) and construct an issuer.
153    ///
154    /// # Errors
155    /// - [`IssuerError::Io`] if the key file cannot be read / parsed.
156    /// - [`IssuerError::MissingPrivateKey`] if only a public key is present.
157    pub fn load(config: IssuerConfig, key_dir: &Path) -> Result<Self, IssuerError> {
158        let keypair = crate::identity::keypair::load(&config.issuer_id, key_dir)
159            .map_err(|e| IssuerError::Io(e.to_string()))?;
160        let signing_key = keypair.private.ok_or(IssuerError::MissingPrivateKey)?;
161        Ok(Self::new(signing_key, config))
162    }
163
164    /// The issuer identity stamped into minted credentials.
165    #[must_use]
166    pub fn issuer_id(&self) -> &str {
167        &self.config.issuer_id
168    }
169
170    /// The issuer's verifying key — publish this into receivers' trust
171    /// bundles so they accept credentials this issuer signs.
172    #[must_use]
173    pub fn verifying_key(&self) -> VerifyingKey {
174        self.signing_key.verifying_key()
175    }
176
177    /// Mint a credential for `subject_agent_id` bound to `subject_pubkey`,
178    /// using the issuer's default TTL.
179    ///
180    /// # Errors
181    /// See [`Self::issue_with_ttl`].
182    pub fn issue(
183        &self,
184        subject_agent_id: impl Into<String>,
185        subject_pubkey: &VerifyingKey,
186        now_unix: i64,
187    ) -> Result<SignedCredential, IssuerError> {
188        self.issue_with_ttl(
189            subject_agent_id,
190            subject_pubkey,
191            self.config.ttl_secs,
192            now_unix,
193        )
194    }
195
196    /// Mint a credential with an explicit TTL.
197    ///
198    /// # Errors
199    /// - [`IssuerError::InvalidTtl`] when `ttl_secs <= 0`.
200    /// - [`IssuerError::Credential`] when signing fails.
201    pub fn issue_with_ttl(
202        &self,
203        subject_agent_id: impl Into<String>,
204        subject_pubkey: &VerifyingKey,
205        ttl_secs: i64,
206        now_unix: i64,
207    ) -> Result<SignedCredential, IssuerError> {
208        if ttl_secs <= 0 {
209            return Err(IssuerError::InvalidTtl);
210        }
211        let cred = FederationCredential {
212            subject_agent_id: subject_agent_id.into(),
213            subject_pubkey: subject_pubkey.to_bytes(),
214            issuer_id: self.config.issuer_id.clone(),
215            trust_domain: self.config.trust_domain.clone(),
216            not_before: now_unix - self.config.clock_skew_secs,
217            not_after: now_unix + ttl_secs,
218            cred_version: super::credential::CRED_VERSION,
219        };
220        Ok(cred.sign(&self.signing_key)?)
221    }
222
223    /// Mint an *intermediate CA* credential: a [`SignedCredential`] whose
224    /// subject is another issuer's identity (`intermediate_id`) bound to
225    /// that issuer's verifying key, signed by this (parent/root) issuer.
226    ///
227    /// Structurally this is an ordinary credential — there is no separate
228    /// "CA cert" wire type. It becomes the anchor link of a
229    /// [`super::chain::CertChain`]: a receiver that trusts *this* issuer's
230    /// key thereby trusts every leaf the intermediate signs, without ever
231    /// enrolling the intermediate's key directly. `intermediate_id` MUST
232    /// equal the intermediate issuer's own `issuer_id` — that name binding
233    /// (`child.issuer_id == parent.subject_agent_id`) is what
234    /// [`super::chain::CertChain::verify`] enforces between links.
235    ///
236    /// Uses [`DEFAULT_INTERMEDIATE_TTL_SECS`]; call
237    /// [`Self::issue_with_ttl`] directly for a custom intermediate lifetime.
238    ///
239    /// # Errors
240    /// See [`Self::issue_with_ttl`].
241    pub fn issue_intermediate(
242        &self,
243        intermediate_id: impl Into<String>,
244        intermediate_pubkey: &VerifyingKey,
245        now_unix: i64,
246    ) -> Result<SignedCredential, IssuerError> {
247        self.issue_with_ttl(
248            intermediate_id,
249            intermediate_pubkey,
250            DEFAULT_INTERMEDIATE_TTL_SECS,
251            now_unix,
252        )
253    }
254
255    /// Mint a leaf credential for `subject_agent_id` and assemble it into a
256    /// [`CertChain`] anchored by `intermediates`.
257    ///
258    /// `intermediates` is anchor-first: the root-signed credential for
259    /// *this* (intermediate) issuer comes first, then any deeper links, so
260    /// the chain reads root → … → this-issuer → leaf. A receiver verifies
261    /// the whole chain against a trust bundle holding only the *root* key —
262    /// this is the outbound-path assembly that lets a region node present
263    /// one self-contained chain instead of requiring the receiver to
264    /// pre-enroll every intermediate.
265    ///
266    /// Pass an empty `intermediates` to produce a single-level chain
267    /// equivalent to a bare [`Self::issue`] (back-compat with P2/P3).
268    ///
269    /// # Errors
270    /// See [`Self::issue_with_ttl`].
271    pub fn issue_chained(
272        &self,
273        subject_agent_id: impl Into<String>,
274        subject_pubkey: &VerifyingKey,
275        intermediates: Vec<SignedCredential>,
276        now_unix: i64,
277    ) -> Result<CertChain, IssuerError> {
278        let leaf = self.issue(subject_agent_id, subject_pubkey, now_unix)?;
279        Ok(CertChain::new(leaf, intermediates))
280    }
281
282    /// Mint a credential for a peer whose mTLS identity has been attested.
283    /// The `attestation` map resolves `attested_identity` to the
284    /// `subject_agent_id` the issuer will vouch for; passthrough maps use
285    /// the attested identity verbatim.
286    ///
287    /// # Errors
288    /// - [`IssuerError::AttestationDenied`] when the identity is not
289    ///   permitted by a non-empty allowlist.
290    /// - Otherwise as [`Self::issue`].
291    pub fn issue_for_attested(
292        &self,
293        attested_identity: &str,
294        attestation: &AttestationMap,
295        subject_pubkey: &VerifyingKey,
296        now_unix: i64,
297    ) -> Result<SignedCredential, IssuerError> {
298        let subject_agent_id = attestation
299            .resolve(attested_identity)
300            .ok_or(IssuerError::AttestationDenied)?;
301        self.issue(subject_agent_id, subject_pubkey, now_unix)
302    }
303}
304
305/// Maps an attested mTLS identity to the `agent_id` the issuer will vouch
306/// for. Empty = passthrough (the attested identity is the subject);
307/// non-empty = strict allowlist (an unmapped identity is denied).
308#[derive(Debug, Clone, Default)]
309pub struct AttestationMap {
310    entries: BTreeMap<String, String>,
311}
312
313impl AttestationMap {
314    /// An empty (passthrough) map.
315    #[must_use]
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    /// Add an `attested_identity -> agent_id` mapping (builder form).
321    #[must_use]
322    pub fn with_mapping(
323        mut self,
324        attested_identity: impl Into<String>,
325        agent_id: impl Into<String>,
326    ) -> Self {
327        self.entries
328            .insert(attested_identity.into(), agent_id.into());
329        self
330    }
331
332    /// Add an `attested_identity -> agent_id` mapping (mutating form).
333    pub fn insert(&mut self, attested_identity: impl Into<String>, agent_id: impl Into<String>) {
334        self.entries
335            .insert(attested_identity.into(), agent_id.into());
336    }
337
338    /// Whether this map is passthrough (no explicit mappings).
339    #[must_use]
340    pub fn is_passthrough(&self) -> bool {
341        self.entries.is_empty()
342    }
343
344    /// Resolve an attested identity to the `agent_id` to issue for. A
345    /// passthrough map returns the identity verbatim; a populated map
346    /// returns the mapped value or `None` (deny) for an unmapped identity.
347    #[must_use]
348    pub fn resolve(&self, attested_identity: &str) -> Option<String> {
349        if self.entries.is_empty() {
350            return Some(attested_identity.to_string());
351        }
352        self.entries.get(attested_identity).cloned()
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn issuer(seed: u8) -> FederationIssuer {
361        let key = SigningKey::from_bytes(&[seed; 32]);
362        FederationIssuer::new(key, IssuerConfig::new("root-ca", "fleet.example"))
363    }
364
365    fn subject_key(seed: u8) -> VerifyingKey {
366        SigningKey::from_bytes(&[seed; 32]).verifying_key()
367    }
368
369    #[test]
370    fn issued_credential_verifies_against_issuer_key() {
371        let iss = issuer(1);
372        let now = 1_900_000_000;
373        let subj = subject_key(50);
374        let signed = iss.issue("region/nyc/node-1", &subj, now).expect("issue");
375        signed
376            .verify_against(&iss.verifying_key(), now)
377            .expect("issued credential verifies");
378        assert_eq!(signed.credential().issuer_id, "root-ca");
379        assert_eq!(signed.credential().trust_domain, "fleet.example");
380        assert_eq!(signed.credential().subject_agent_id, "region/nyc/node-1");
381        assert_eq!(signed.credential().subject_pubkey, subj.to_bytes());
382    }
383
384    #[test]
385    fn default_ttl_window_brackets_now() {
386        let iss = issuer(2);
387        let now = 1_900_000_000;
388        let signed = iss.issue("node", &subject_key(51), now).expect("issue");
389        let cred = signed.credential();
390        assert_eq!(cred.not_before, now - DEFAULT_CLOCK_SKEW_SECS);
391        assert_eq!(cred.not_after, now + DEFAULT_CREDENTIAL_TTL_SECS);
392    }
393
394    #[test]
395    fn zero_or_negative_ttl_is_rejected() {
396        let iss = issuer(3);
397        let now = 1_900_000_000;
398        assert_eq!(
399            iss.issue_with_ttl("node", &subject_key(52), 0, now)
400                .unwrap_err(),
401            IssuerError::InvalidTtl
402        );
403        assert_eq!(
404            iss.issue_with_ttl("node", &subject_key(52), -10, now)
405                .unwrap_err(),
406            IssuerError::InvalidTtl
407        );
408    }
409
410    #[test]
411    fn custom_ttl_is_honoured() {
412        let iss = issuer(4);
413        let now = 1_900_000_000;
414        let signed = iss
415            .issue_with_ttl("node", &subject_key(53), 300, now)
416            .expect("issue");
417        assert_eq!(signed.credential().not_after, now + 300);
418    }
419
420    #[test]
421    fn passthrough_attestation_uses_identity_verbatim() {
422        let iss = issuer(5);
423        let now = 1_900_000_000;
424        let map = AttestationMap::new();
425        assert!(map.is_passthrough());
426        let signed = iss
427            .issue_for_attested("region/sfo/node-9", &map, &subject_key(54), now)
428            .expect("issue");
429        assert_eq!(signed.credential().subject_agent_id, "region/sfo/node-9");
430    }
431
432    #[test]
433    fn allowlist_attestation_maps_identity() {
434        let iss = issuer(6);
435        let now = 1_900_000_000;
436        let map = AttestationMap::new().with_mapping("CN=node9.fleet", "region/sfo/node-9");
437        let signed = iss
438            .issue_for_attested("CN=node9.fleet", &map, &subject_key(55), now)
439            .expect("issue");
440        assert_eq!(signed.credential().subject_agent_id, "region/sfo/node-9");
441    }
442
443    #[test]
444    fn allowlist_denies_unmapped_identity() {
445        let iss = issuer(7);
446        let now = 1_900_000_000;
447        let map = AttestationMap::new().with_mapping("CN=node9.fleet", "region/sfo/node-9");
448        assert_eq!(
449            iss.issue_for_attested("CN=intruder", &map, &subject_key(56), now)
450                .unwrap_err(),
451            IssuerError::AttestationDenied
452        );
453    }
454
455    #[test]
456    fn issuer_round_trips_through_trust_bundle() {
457        use super::super::trust_bundle::TrustBundle;
458        let iss = issuer(8);
459        let now = 1_900_000_000;
460        let bundle = TrustBundle::new().with_issuer(iss.issuer_id(), iss.verifying_key());
461        let signed = iss.issue("node", &subject_key(57), now).expect("issue");
462        let cred = bundle
463            .verify(&signed, now)
464            .expect("bundle verifies issued cred");
465        assert_eq!(cred.subject_agent_id, "node");
466    }
467
468    #[test]
469    fn load_missing_private_key_is_error() {
470        // A public-only keypair on disk must surface MissingPrivateKey.
471        let tmp = tempfile::tempdir().expect("tempdir");
472        let kp = crate::identity::keypair::generate("some-ca").expect("gen");
473        crate::identity::keypair::save_public_only(&kp, tmp.path()).expect("save pub");
474        let err = FederationIssuer::load(IssuerConfig::new("some-ca", "fleet.example"), tmp.path())
475            .unwrap_err();
476        assert_eq!(err, IssuerError::MissingPrivateKey);
477    }
478
479    #[test]
480    fn intermediate_credential_uses_intermediate_ttl() {
481        let root = issuer(20);
482        let now = 1_900_000_000;
483        let region_key = subject_key(60);
484        let anchor = root
485            .issue_intermediate("region/nyc/ca", &region_key, now)
486            .expect("issue intermediate");
487        let cred = anchor.credential();
488        assert_eq!(cred.subject_agent_id, "region/nyc/ca");
489        assert_eq!(cred.subject_pubkey, region_key.to_bytes());
490        assert_eq!(cred.not_after, now + DEFAULT_INTERMEDIATE_TTL_SECS);
491        anchor
492            .verify_against(&root.verifying_key(), now)
493            .expect("intermediate verifies under root key");
494    }
495
496    #[test]
497    fn issue_chained_assembles_root_verifiable_two_level_chain() {
498        use super::super::chain::{CertChain, DEFAULT_MAX_CHAIN_DEPTH};
499        use super::super::trust_bundle::TrustBundle;
500        let now = 1_900_000_000;
501
502        // Root mints an intermediate CA for region/nyc.
503        let root = issuer(21);
504        let region_signing = SigningKey::from_bytes(&[70; 32]);
505        let region = FederationIssuer::new(
506            region_signing.clone(),
507            IssuerConfig::new("region/nyc/ca", "fleet.example"),
508        );
509        let anchor = root
510            .issue_intermediate(region.issuer_id(), &region.verifying_key(), now)
511            .expect("issue intermediate");
512
513        // Region issuer mints a leaf for a node + wraps the chain.
514        let node_key = subject_key(71);
515        let chain: CertChain = region
516            .issue_chained("region/nyc/node-1", &node_key, vec![anchor], now)
517            .expect("issue chained");
518        assert_eq!(chain.depth(), 2);
519
520        // A receiver trusting ONLY the root key verifies the whole chain.
521        let bundle = TrustBundle::new().with_issuer(root.issuer_id(), root.verifying_key());
522        let verified = chain
523            .verify(&bundle, now, DEFAULT_MAX_CHAIN_DEPTH)
524            .expect("chain verifies against root-only bundle");
525        assert_eq!(verified.subject_agent_id, "region/nyc/node-1");
526        assert_eq!(verified.subject_pubkey, node_key.to_bytes());
527    }
528
529    #[test]
530    fn issue_chained_with_no_intermediates_is_single_level() {
531        use super::super::chain::{CertChain, DEFAULT_MAX_CHAIN_DEPTH};
532        use super::super::trust_bundle::TrustBundle;
533        let iss = issuer(22);
534        let now = 1_900_000_000;
535        let node_key = subject_key(72);
536        let chain: CertChain = iss
537            .issue_chained("node", &node_key, vec![], now)
538            .expect("issue chained");
539        assert_eq!(chain.depth(), 1);
540        let bundle = TrustBundle::new().with_issuer(iss.issuer_id(), iss.verifying_key());
541        let verified = chain
542            .verify(&bundle, now, DEFAULT_MAX_CHAIN_DEPTH)
543            .expect("single-level chain verifies");
544        assert_eq!(verified.subject_agent_id, "node");
545    }
546
547    #[test]
548    fn load_reads_signing_key_and_issues() {
549        let tmp = tempfile::tempdir().expect("tempdir");
550        let kp = crate::identity::keypair::generate("disk-ca").expect("gen");
551        crate::identity::keypair::save(&kp, tmp.path()).expect("save");
552        let iss = FederationIssuer::load(IssuerConfig::new("disk-ca", "fleet.example"), tmp.path())
553            .expect("load");
554        let now = 1_900_000_000;
555        let signed = iss.issue("node", &subject_key(58), now).expect("issue");
556        signed
557            .verify_against(&kp.public, now)
558            .expect("issued cred verifies under the on-disk key");
559    }
560}