1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::{Arc, Mutex};
4
5use arc_swap::ArcSwap;
6use tracing::{info, warn};
7
8use crate::config::Config;
9use crate::error::{CoreError, Result};
10use crate::request::ParsedCertId;
11use crate::state::StateStore;
12use ahu::Bundle;
13
14type ScopeKey = (Vec<u8>, Vec<u8>);
19
20#[derive(Debug, Clone)]
22pub struct ScopeEntry {
23 pub bundle_idx: usize,
24 pub ca_label: String,
25 pub nonce_policy: String,
26 pub completeness: String,
27 pub forward_to: Option<String>,
28 pub max_age_fraction: f64,
31}
32
33pub struct ScopeMap {
35 entries: HashMap<ScopeKey, Vec<ScopeEntry>>,
36 bundles: Vec<Arc<Bundle>>,
37}
38
39pub struct LookupResult {
42 pub response_bytes: Vec<u8>,
43 pub window: ahu::Window,
44 pub ca_label: String,
45 pub nonce_policy: String,
46 pub forward_to: Option<String>,
47 pub max_age_fraction: f64,
48}
49
50pub struct ScopeDetail {
54 pub ca_label: String,
55 pub producer_id: String,
56 pub epoch: u64,
57 pub completeness: String,
58 pub entry_count: u64,
59 pub window: ahu::Window,
60}
61
62pub struct ResponderState {
65 scope_map: ArcSwap<ScopeMap>,
66 pub config: Config,
67 state_store: Mutex<StateStore>,
68}
69
70impl ResponderState {
71 pub fn load(config: Config) -> Result<Self> {
72 let state_db_path = config.storage.state_db.join("state.json");
73 let mut state_store = StateStore::open(&state_db_path)?;
74 let scope_map = match load_scope_map(&config, &mut state_store) {
75 Ok(map) => map,
76 Err(original) if !state_store.active_bundles().is_empty() => {
77 warn!(error = %original, "configured bundle load failed; recovering committed generation");
78 let mut recovery = config.clone();
79 if recovery.ca.is_empty() {
80 let path = state_store
81 .active_bundles()
82 .get("default")
83 .ok_or(original)?;
84 let path = path.clone();
85 let mut candidate = state_store.transaction();
86 let bundle = load_and_verify_bundle(&path, &config)?;
87 candidate.check_rollback(&bundle)?;
88 candidate.check_continuity(&bundle)?;
89 candidate.advance_from_bundle(&bundle)?;
90 candidate.snapshot("default", &bundle)?;
91 let mut entries = HashMap::new();
92 register_bundle_scopes(
93 &bundle,
94 0,
95 "default",
96 "ignore",
97 "authoritative-complete",
98 None,
99 0.5,
100 &mut entries,
101 );
102 state_store.commit(candidate)?;
103 return Ok(Self {
104 scope_map: ArcSwap::from_pointee(ScopeMap {
105 entries,
106 bundles: vec![Arc::new(bundle)],
107 }),
108 config,
109 state_store: Mutex::new(state_store),
110 });
111 }
112 for ca in &mut recovery.ca {
113 ca.bundle_file = Some(
114 state_store
115 .active_bundles()
116 .get(&ca.label)
117 .ok_or_else(|| {
118 CoreError::Config(format!("no committed snapshot for {}", ca.label))
119 })?
120 .clone(),
121 );
122 }
123 load_scope_map(&recovery, &mut state_store)?
124 }
125 Err(err) => return Err(err),
126 };
127 Ok(ResponderState {
128 scope_map: ArcSwap::from_pointee(scope_map),
129 config,
130 state_store: Mutex::new(state_store),
131 })
132 }
133
134 pub fn lookup(&self, cert_id: &ParsedCertId, preferred_algs: &[u16]) -> Option<LookupResult> {
141 let map = self.scope_map.load();
142 let key = (
143 cert_id.issuer_name_hash.clone(),
144 cert_id.issuer_key_hash.clone(),
145 );
146
147 let scope_entries = map.entries.get(&key)?;
148
149 let mut hits: Vec<(&ScopeEntry, Vec<u8>, ahu::Window)> = Vec::new();
150
151 for entry in scope_entries {
152 let bundle = &map.bundles[entry.bundle_idx];
153 let found = if preferred_algs.is_empty() {
154 bundle.lookup(&cert_id.entry_key)
155 } else {
156 bundle.lookup_preferred(&cert_id.entry_key, preferred_algs)
157 };
158 if let Some(response_bytes) = found {
159 hits.push((
160 entry,
161 response_bytes.to_vec(),
162 bundle.manifest.window.clone(),
163 ));
164 }
165 }
166
167 let make_result =
168 |entry: &ScopeEntry, response_bytes: Vec<u8>, window: ahu::Window| LookupResult {
169 response_bytes,
170 window,
171 ca_label: entry.ca_label.clone(),
172 nonce_policy: entry.nonce_policy.clone(),
173 forward_to: entry.forward_to.clone(),
174 max_age_fraction: entry.max_age_fraction,
175 };
176
177 match hits.len() {
178 0 => None,
179 1 => {
180 let (entry, response_bytes, window) = hits.into_iter().next().unwrap();
181 Some(make_result(entry, response_bytes, window))
182 }
183 n => {
184 warn!(
185 count = n,
186 serial = hex::encode(&cert_id.serial_number),
187 issuer_key_hash = hex::encode(&cert_id.issuer_key_hash),
188 "multiple scopes hold entry for same serial — answering from first by config order"
189 );
190 let (entry, response_bytes, window) = hits.into_iter().next().unwrap();
191 Some(make_result(entry, response_bytes, window))
192 }
193 }
194 }
195
196 pub fn default_window(&self) -> Option<ahu::Window> {
200 let map = self.scope_map.load();
201 map.bundles.first().map(|b| b.manifest.window.clone())
202 }
203
204 pub fn total_entries(&self) -> u64 {
206 let map = self.scope_map.load();
207 map.bundles.iter().map(|b| b.manifest.entry_count).sum()
208 }
209
210 pub fn bundle_count(&self) -> usize {
212 let map = self.scope_map.load();
213 map.bundles.len()
214 }
215
216 pub fn scope_count(&self) -> usize {
218 let map = self.scope_map.load();
219 map.entries.len()
220 }
221
222 pub fn scope_info(&self) -> Vec<(String, u64, String)> {
224 let map = self.scope_map.load();
225 let mut info = Vec::new();
226 for entries in map.entries.values() {
227 for entry in entries {
228 let bundle = &map.bundles[entry.bundle_idx];
229 info.push((
230 entry.ca_label.clone(),
231 bundle
232 .manifest
233 .ca_scopes
234 .first()
235 .map(|s| s.epoch)
236 .unwrap_or(0),
237 entry.completeness.clone(),
238 ));
239 }
240 }
241 info
242 }
243
244 pub fn bundle_scopes(&self) -> Vec<ScopeDetail> {
248 let map = self.scope_map.load();
249 let mut out = Vec::new();
250 for entries in map.entries.values() {
251 for entry in entries {
252 let bundle = &map.bundles[entry.bundle_idx];
253 out.push(ScopeDetail {
254 ca_label: entry.ca_label.clone(),
255 producer_id: bundle.manifest.producer_id.clone(),
256 epoch: bundle
257 .manifest
258 .ca_scopes
259 .first()
260 .map(|s| s.epoch)
261 .unwrap_or(0),
262 completeness: entry.completeness.clone(),
263 entry_count: bundle.manifest.entry_count,
264 window: bundle.manifest.window.clone(),
265 });
266 }
267 }
268 out
269 }
270
271 pub fn reload(&self) -> Result<()> {
273 let mut store = self
274 .state_store
275 .lock()
276 .map_err(|e| CoreError::StateStore(format!("state store lock poisoned: {e}")))?;
277 let scope_map = load_scope_map(&self.config, &mut store)?;
278 let total: u64 = scope_map
279 .bundles
280 .iter()
281 .map(|b| b.manifest.entry_count)
282 .sum();
283 self.scope_map.store(Arc::new(scope_map));
284 info!(total_entries = total, "all bundles reloaded");
285 Ok(())
286 }
287}
288
289fn validate_nonce_config(config: &Config) -> Result<()> {
290 let mut labels = std::collections::HashSet::new();
291 for ca in &config.ca {
292 if !labels.insert(&ca.label) {
293 return Err(CoreError::Config(format!(
294 "duplicate CA label '{}'",
295 ca.label
296 )));
297 }
298 match ca.nonce_policy.as_str() {
299 "ignore" => {}
300 "live" => {
301 if config.server.mode == "edge" {
302 return Err(CoreError::Config(format!(
303 "CA '{}': nonce_policy \"live\" requires signer or combined mode \
304 (edge nodes have no signing key)",
305 ca.label
306 )));
307 }
308 if ca.signing_key.is_none() {
309 return Err(CoreError::Config(format!(
310 "CA '{}': nonce_policy \"live\" requires a signing_key",
311 ca.label
312 )));
313 }
314 }
315 "forward" => {
316 if ca.forward_to.is_none() {
317 return Err(CoreError::Config(format!(
318 "CA '{}': nonce_policy \"forward\" requires a forward_to URL",
319 ca.label
320 )));
321 }
322 }
323 other => {
324 return Err(CoreError::Config(format!(
325 "CA '{}': unknown nonce_policy \"{other}\" (expected: ignore, live, forward)",
326 ca.label
327 )));
328 }
329 }
330 }
331 Ok(())
332}
333
334fn load_scope_map(config: &Config, state_store: &mut StateStore) -> Result<ScopeMap> {
335 let mut candidate = state_store.transaction();
336 let map = load_scope_map_candidate(config, &mut candidate)?;
337 state_store.commit(candidate)?;
338 Ok(map)
339}
340
341fn load_scope_map_candidate(config: &Config, state_store: &mut StateStore) -> Result<ScopeMap> {
342 validate_nonce_config(config)?;
343
344 let mut bundles: Vec<Arc<Bundle>> = Vec::new();
345 let mut entries: HashMap<ScopeKey, Vec<ScopeEntry>> = HashMap::new();
346 let mut loaded_paths: HashMap<std::path::PathBuf, usize> = HashMap::new();
347
348 if config.ca.is_empty() {
349 let bundle = load_single_bundle(&config.storage.bundle_dir, None, config)?;
350 state_store.check_rollback(&bundle)?;
351 state_store.check_continuity(&bundle)?;
352 state_store.advance_from_bundle(&bundle)?;
353 let bundle_idx = 0;
354 register_bundle_scopes(
355 &bundle,
356 bundle_idx,
357 "default",
358 "ignore",
359 "authoritative-complete",
360 None,
361 0.5,
362 &mut entries,
363 );
364 bundles.push(Arc::new(bundle));
365 } else {
366 for ca_config in &config.ca {
367 let bundle_path = if let Some(bf) = &ca_config.bundle_file {
368 bf.clone()
369 } else {
370 config
374 .storage
375 .bundle_dir
376 .join(format!("{}.ahu", ca_config.label))
377 };
378
379 let canonical = bundle_path.canonicalize().unwrap_or(bundle_path.clone());
380
381 let bundle_idx = if let Some(&idx) = loaded_paths.get(&canonical) {
382 idx
383 } else {
384 let bundle = load_and_verify_bundle(&bundle_path, config)?;
385 state_store.check_rollback(&bundle)?;
386 state_store.check_continuity(&bundle)?;
387 state_store.advance_from_bundle(&bundle)?;
388 let idx = bundles.len();
389 bundles.push(Arc::new(bundle));
390 loaded_paths.insert(canonical, idx);
391 idx
392 };
393
394 register_bundle_scopes(
395 &bundles[bundle_idx],
396 bundle_idx,
397 &ca_config.label,
398 &ca_config.nonce_policy,
399 &ca_config.completeness,
400 ca_config.forward_to.as_deref(),
401 ca_config.max_age_fraction,
402 &mut entries,
403 );
404 }
405 }
406
407 let mut labels = std::collections::HashSet::new();
409 for entries_for_scope in entries.values() {
410 for entry in entries_for_scope {
411 if labels.insert(entry.ca_label.clone()) {
412 state_store.snapshot(&entry.ca_label, &bundles[entry.bundle_idx])?;
413 }
414 }
415 }
416
417 let scope_count = entries.len();
418 let bundle_count = bundles.len();
419 info!(
420 bundles = bundle_count,
421 scopes = scope_count,
422 "scope map loaded"
423 );
424
425 Ok(ScopeMap { entries, bundles })
426}
427
428#[allow(clippy::too_many_arguments)]
429fn register_bundle_scopes(
430 bundle: &Bundle,
431 bundle_idx: usize,
432 ca_label: &str,
433 nonce_policy: &str,
434 completeness: &str,
435 forward_to: Option<&str>,
436 max_age_fraction: f64,
437 entries: &mut HashMap<ScopeKey, Vec<ScopeEntry>>,
438) {
439 for scope in &bundle.manifest.ca_scopes {
440 let key = (
441 scope.issuer_name_hash.clone(),
442 scope.issuer_key_hash.clone(),
443 );
444
445 let entry = ScopeEntry {
446 bundle_idx,
447 ca_label: ca_label.to_string(),
448 nonce_policy: nonce_policy.to_string(),
449 completeness: completeness.to_string(),
450 forward_to: forward_to.map(|s| s.to_string()),
451 max_age_fraction,
452 };
453
454 entries.entry(key).or_default().push(entry);
455
456 info!(
457 ca = ca_label,
458 epoch = scope.epoch,
459 issuer_key_hash =
460 hex::encode(&scope.issuer_key_hash[..8.min(scope.issuer_key_hash.len())]),
461 "registered CA scope"
462 );
463 }
464}
465
466fn load_single_bundle(
467 bundle_dir: &Path,
468 bundle_file: Option<&Path>,
469 config: &Config,
470) -> Result<Bundle> {
471 let path = if let Some(bf) = bundle_file {
472 bf.to_path_buf()
473 } else {
474 find_newest_bundle(bundle_dir)?
475 };
476 load_and_verify_bundle(&path, config)
477}
478
479fn load_and_verify_bundle(path: &Path, config: &Config) -> Result<Bundle> {
480 info!(path = %path.display(), "loading bundle");
481 let bundle = Bundle::from_file(path)?;
482
483 let result = ahu::verify_structure(&bundle)?;
484 if !result.warnings.is_empty() {
485 for w in &result.warnings {
486 warn!(warning = w, "bundle verification warning");
487 }
488 }
489
490 verify_bundle_seal(&bundle, config)?;
492
493 info!(
494 entry_count = bundle.manifest.entry_count,
495 producer = %bundle.manifest.producer_id,
496 scopes = bundle.manifest.ca_scopes.len(),
497 "bundle loaded and verified"
498 );
499
500 Ok(bundle)
501}
502
503fn verify_bundle_seal(bundle: &Bundle, config: &Config) -> Result<()> {
504 let has_trust_anchors = config
505 .storage
506 .seal_trust_anchors
507 .as_ref()
508 .is_some_and(|v| !v.is_empty());
509
510 let has_pins = !config.storage.seal_signer_pins.is_empty();
511 if !has_trust_anchors && !has_pins && !config.storage.seal_authorizations.is_empty() {
512 return Err(CoreError::Config(
513 "seal_authorizations requires trust anchors or explicit signer pins".into(),
514 ));
515 }
516 if has_trust_anchors || has_pins {
517 if bundle.seal_bytes.is_empty() {
518 return Err(CoreError::Config(
519 "seal_trust_anchors configured but bundle has no seal".into(),
520 ));
521 }
522
523 use der::{Decode, DecodePem, Encode};
524 let read_certs = |paths: &[std::path::PathBuf]| -> Result<Vec<Vec<u8>>> {
525 let mut anchors = Vec::new();
526 for path in paths {
527 let bytes = std::fs::read(path).map_err(|e| {
528 CoreError::Config(format!(
529 "cannot read seal trust anchor {}: {e}",
530 path.display()
531 ))
532 })?;
533 let cert = if bytes.starts_with(b"-----BEGIN") {
534 x509_cert::Certificate::from_pem(&bytes)
535 } else {
536 x509_cert::Certificate::from_der(&bytes)
537 }
538 .map_err(|e| {
539 CoreError::Config(format!("invalid seal trust anchor {}: {e}", path.display()))
540 })?;
541 anchors.push(
542 cert.to_der()
543 .map_err(|e| CoreError::Config(e.to_string()))?,
544 );
545 }
546 Ok(anchors)
547 };
548 let anchors = read_certs(config.storage.seal_trust_anchors.as_deref().unwrap_or(&[]))?;
549 let pins = read_certs(&config.storage.seal_signer_pins)?;
550 let now = std::time::SystemTime::now()
551 .duration_since(std::time::UNIX_EPOCH)
552 .map_err(|e| CoreError::Config(e.to_string()))?
553 .as_secs();
554 let verification = if has_pins {
555 ahu::verify_seal_with_pins(&bundle.manifest_bytes, &bundle.seal_bytes, &pins, now)
556 .or_else(|pin_error| {
557 if has_trust_anchors {
558 ahu::verify_seal_with_anchors(
559 &bundle.manifest_bytes,
560 &bundle.seal_bytes,
561 &anchors,
562 now,
563 )
564 } else {
565 Err(pin_error)
566 }
567 })
568 } else {
569 ahu::verify_seal_with_anchors(&bundle.manifest_bytes, &bundle.seal_bytes, &anchors, now)
570 }
571 .map_err(|e| CoreError::Config(format!("seal verification failed: {e}")))?;
572 if !config.storage.seal_authorizations.is_empty() {
573 for scope in &bundle.manifest.ca_scopes {
574 if !config.storage.seal_authorizations.iter().any(|auth| {
575 auth.producer_id == bundle.manifest.producer_id
576 && auth
577 .issuer_key_hash
578 .eq_ignore_ascii_case(&hex::encode(&scope.issuer_key_hash))
579 && auth
580 .signer_sha256
581 .eq_ignore_ascii_case(&verification.signer_sha256)
582 }) {
583 return Err(CoreError::Config(
584 "seal signer is not authorized for producer/CA scope".into(),
585 ));
586 }
587 }
588 }
589 info!(signer = %verification.signer_sha256, "CMS seal authenticated against configured trust policy");
590 } else if !bundle.seal_bytes.is_empty() {
591 warn!("bundle has a CMS seal but no seal_trust_anchors configured — seal not verified");
592 }
593
594 Ok(())
595}
596
597fn find_newest_bundle(dir: &Path) -> Result<std::path::PathBuf> {
598 let ahu_files: Vec<_> = std::fs::read_dir(dir)?
599 .filter_map(|e| e.ok())
600 .filter(|e| e.path().extension().is_some_and(|ext| ext == "ahu"))
601 .map(|e| e.path())
602 .collect();
603
604 if ahu_files.is_empty() {
605 return Err(CoreError::Config(format!(
606 "no .ahu files in {}",
607 dir.display()
608 )));
609 }
610
611 let mut best: Option<(std::path::PathBuf, u64)> = None;
614 for path in &ahu_files {
615 let max_epoch = match ahu::Bundle::from_file(path) {
616 Ok(bundle) => bundle
617 .manifest
618 .ca_scopes
619 .iter()
620 .map(|s| s.epoch)
621 .max()
622 .unwrap_or(0),
623 Err(e) => {
624 warn!(
625 path = %path.display(),
626 error = %e,
627 "failed to parse bundle for epoch selection, skipping"
628 );
629 continue;
630 }
631 };
632 if best.as_ref().is_none_or(|(_, e)| max_epoch > *e) {
633 best = Some((path.clone(), max_epoch));
634 }
635 }
636
637 best.map(|(p, _)| p)
638 .ok_or_else(|| CoreError::Config(format!("no valid .ahu files in {}", dir.display())))
639}