1use 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
37pub const DEFAULT_CREDENTIAL_TTL_SECS: i64 = crate::SECS_PER_HOUR;
40
41pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 30;
45
46pub const DEFAULT_INTERMEDIATE_TTL_SECS: i64 = crate::SECS_PER_DAY;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum IssuerError {
55 AttestationDenied,
58 MissingPrivateKey,
60 InvalidTtl,
62 Credential(CredentialError),
64 Io(String),
66}
67
68impl IssuerError {
69 #[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#[derive(Debug, Clone)]
102pub struct IssuerConfig {
103 pub issuer_id: String,
106 pub trust_domain: String,
108 pub ttl_secs: i64,
110 pub clock_skew_secs: i64,
112}
113
114impl IssuerConfig {
115 #[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 #[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#[derive(Debug, Clone)]
136pub struct FederationIssuer {
137 signing_key: SigningKey,
138 config: IssuerConfig,
139}
140
141impl FederationIssuer {
142 #[must_use]
144 pub fn new(signing_key: SigningKey, config: IssuerConfig) -> Self {
145 Self {
146 signing_key,
147 config,
148 }
149 }
150
151 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 #[must_use]
166 pub fn issuer_id(&self) -> &str {
167 &self.config.issuer_id
168 }
169
170 #[must_use]
173 pub fn verifying_key(&self) -> VerifyingKey {
174 self.signing_key.verifying_key()
175 }
176
177 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 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 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 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 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#[derive(Debug, Clone, Default)]
309pub struct AttestationMap {
310 entries: BTreeMap<String, String>,
311}
312
313impl AttestationMap {
314 #[must_use]
316 pub fn new() -> Self {
317 Self::default()
318 }
319
320 #[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 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 #[must_use]
340 pub fn is_passthrough(&self) -> bool {
341 self.entries.is_empty()
342 }
343
344 #[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 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", ®ion_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 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(), ®ion.verifying_key(), now)
511 .expect("issue intermediate");
512
513 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 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}