Skip to main content

hoike_core/
router.rs

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
14/// Routing key: (issuerNameHash, issuerKeyHash).
15/// Both SHA-1 and SHA-256 hash algorithms produce different CertIDs for the
16/// same CA, so both the SHA-1 and SHA-256 hashes from the bundle manifest
17/// are registered as separate scope keys pointing to the same bundle.
18type ScopeKey = (Vec<u8>, Vec<u8>);
19
20/// Metadata about a CA scope within a loaded bundle.
21#[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    /// Fraction of the validity window advertised as HTTP `max-age` for this
29    /// scope's responses (from `CaConfig.max_age_fraction`).
30    pub max_age_fraction: f64,
31}
32
33/// The loaded working set: bundles indexed by CA scope for routing.
34pub struct ScopeMap {
35    entries: HashMap<ScopeKey, Vec<ScopeEntry>>,
36    bundles: Vec<Arc<Bundle>>,
37}
38
39/// Result of a successful lookup — the response bytes plus the window
40/// from the serving bundle for HTTP header generation.
41pub 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
50/// Per-bundle scope detail for diagnostic/admin views. Unlike `scope_info`,
51/// this carries each bundle's own window and entry count, so multi-CA
52/// deployments report accurate per-bundle data instead of the first bundle's.
53pub 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
62/// The responder's loaded state: bundles indexed by CA scope,
63/// with persistent anti-rollback state.
64pub 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    /// Look up a CertID across all loaded CA scopes.
135    ///
136    /// Routes by (issuerNameHash, issuerKeyHash) to find candidate bundles,
137    /// then searches each by entry_key. On collision (multiple bundles hold
138    /// an entry for the same serial), logs a warning and returns the first
139    /// by configuration order.
140    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    /// Get the first loaded bundle's window (for backward compatibility
197    /// with single-CA deployments). Prefer `LookupResult.window` when
198    /// a specific lookup succeeded.
199    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    /// Total entry count across all loaded bundles.
205    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    /// Number of loaded bundles.
211    pub fn bundle_count(&self) -> usize {
212        let map = self.scope_map.load();
213        map.bundles.len()
214    }
215
216    /// Scope count (number of distinct routing keys).
217    pub fn scope_count(&self) -> usize {
218        let map = self.scope_map.load();
219        map.entries.len()
220    }
221
222    /// Get info about all loaded scopes for diagnostics.
223    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    /// Per-bundle scope details (label, epoch, completeness, entry count, and
245    /// the bundle's own validity window). Prefer this over `scope_info` +
246    /// `default_window` when reporting per-bundle data for multi-CA setups.
247    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    /// Hot-reload all bundles from disk with anti-rollback checks.
272    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                // Configured scopes must read the same per-label destination
371                // used by the signer; newest-file discovery is only for the
372                // legacy configuration with no explicit CA scopes.
373                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    // All inputs passed validation before writing any new immutable snapshots.
408    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    // CMS seal verification
491    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    // Select the bundle with the highest max epoch across its CA scopes.
612    // Bundles that fail to parse are skipped with a warning.
613    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}