Skip to main content

cortex_verifier/
verify.rs

1//! Pure trusted-evidence reducer.
2//!
3//! `verify(input, witnesses, now, max_age) -> VerifiedTrustState`. No I/O. No
4//! filesystem reads, no network calls, no clock reads — `now` is injected.
5//!
6//! Order of checks (each is a hard fail when violated):
7//!
8//! 1. Advisory-only short-circuit — local / dev / pre-v2 evidence cannot be
9//!    promoted by witnesses (`witness.tier_insufficient`).
10//! 2. Subject binding — every witness's `asserted_subject_blake3` must match
11//!    `input.evidence_blake3` (`witness.disagreement`).
12//! 3. Domain integrity — every witness's `authority_domain` must equal
13//!    `class.required_authority_domain()` and no two witnesses may share a
14//!    domain (`witness.authority_overlap`).
15//! 4. Freshness — `now - witness.asserted_at <= max_age` (`witness.stale`).
16//! 5. Tier sufficiency — remote-CI and reproducible-build witnesses must be
17//!    `ThirdParty` (`witness.tier_insufficient`).
18//! 6. Signature — Ed25519 verify over the canonical preimage
19//!    (`witness.signature_invalid`).
20//! 7. Class coverage — `FullChainVerified` requires one witness from each of
21//!    the four ADR 0041 classes; missing classes drop the result to
22//!    `Partial`, never to a falsely-trusted state.
23//! 8. Composition — effective ceiling for the input must be at or above the
24//!    required ceiling for the claim kind
25//!    (`composition.ceiling_below_required`).
26
27use std::collections::HashSet;
28
29use chrono::{DateTime, Duration, Utc};
30use cortex_core::{effective_ceiling, ClaimCeiling, PolicyDecision, PolicyOutcome};
31use cortex_runtime::RuntimeClaimKind;
32use ed25519_dalek::{Signature, Verifier, VerifyingKey};
33
34use crate::input::{EvidenceInput, EvidenceKind};
35use crate::invariant::{
36    COMPOSITION_CEILING_BELOW_REQUIRED, COMPOSITION_POLICY_FAIL_CLOSED, WITNESS_AUTHORITY_OVERLAP,
37    WITNESS_DISAGREEMENT, WITNESS_MISSING, WITNESS_SIGNATURE_INVALID, WITNESS_STALE,
38    WITNESS_TIER_INSUFFICIENT,
39};
40use crate::state::{BrokenEdge, VerifiedTrustState};
41use crate::witness::{
42    AuthorityDomain, IndependentWitness, SelfSignedAlgorithm, SelfSignedKeyRegistry, WitnessClass,
43    WitnessSignature, WitnessSummary, WitnessTier,
44};
45
46/// Pure reducer over independent witnesses.
47///
48/// `now` and `max_age` are injected by the caller so the trust path remains
49/// deterministic and clock-independent. The CLI is responsible for resolving
50/// `now` (typically `Utc::now()`) and passing it in.
51#[must_use]
52pub fn verify(
53    input: &EvidenceInput,
54    witnesses: &[IndependentWitness],
55    now: DateTime<Utc>,
56    max_age: Duration,
57) -> VerifiedTrustState {
58    verify_with_policy(input, witnesses, now, max_age, None)
59}
60
61/// Options for [`verify_with_options`]. Groups the optional policy decision
62/// and the optional operator-supplied key registry so the call site stays
63/// readable as the option count grows.
64#[derive(Debug)]
65pub struct VerifyOptions<'a> {
66    /// ADR 0026 policy decision to compose with the witness result. When
67    /// `Reject` or `Quarantine`, the trust path falls closed before witness
68    /// checks run. `None` means no policy composition.
69    pub policy: Option<&'a cortex_core::PolicyDecision>,
70    /// Operator-supplied key registry for `SelfSigned` witnesses. When
71    /// `None`, `SelfSigned` witnesses fail with `UnsupportedAlgorithm`.
72    /// When `Some`, the registry is consulted and verification proceeds for
73    /// known key ids.
74    pub self_signed_keys: Option<&'a SelfSignedKeyRegistry>,
75}
76
77/// Full-featured verify entry point: composes ADR 0026 policy AND resolves
78/// `SelfSigned` witnesses against the operator-supplied key registry.
79///
80/// Use this from CLI surfaces that accept `--witness-key-registry`. The
81/// simpler [`verify`] and [`verify_with_policy`] entry points delegate here
82/// with `self_signed_keys: None`.
83#[must_use]
84pub fn verify_with_options(
85    input: &EvidenceInput,
86    witnesses: &[IndependentWitness],
87    now: DateTime<Utc>,
88    max_age: Duration,
89    opts: VerifyOptions<'_>,
90) -> VerifiedTrustState {
91    let summaries: Vec<WitnessSummary> =
92        witnesses.iter().map(WitnessSummary::from_witness).collect();
93
94    // 0. Policy fail-closed.
95    if let Some(decision) = opts.policy {
96        if matches!(
97            decision.final_outcome,
98            cortex_core::PolicyOutcome::Reject | cortex_core::PolicyOutcome::Quarantine
99        ) {
100            return VerifiedTrustState::Broken {
101                edge: BrokenEdge::new(
102                    COMPOSITION_POLICY_FAIL_CLOSED,
103                    format!(
104                        "policy outcome {:?} fails closed for {:?}",
105                        decision.final_outcome, input.kind
106                    ),
107                ),
108                witnesses: summaries,
109            };
110        }
111    }
112
113    // Delegate the remaining checks to the shared inner path.
114    verify_inner(
115        input,
116        witnesses,
117        now,
118        max_age,
119        opts.self_signed_keys,
120        summaries,
121    )
122}
123
124/// Same as [`verify`], but composes with an ADR 0026 policy decision so the
125/// trust path falls closed independently of witness composition when policy
126/// returns `Reject` or `Quarantine`. The verifier honours `Allow` and `Warn`
127/// without ceiling impact (mirrors `runtime_claim_preflight_with_policy`).
128#[must_use]
129pub fn verify_with_policy(
130    input: &EvidenceInput,
131    witnesses: &[IndependentWitness],
132    now: DateTime<Utc>,
133    max_age: Duration,
134    policy: Option<&PolicyDecision>,
135) -> VerifiedTrustState {
136    let summaries: Vec<WitnessSummary> =
137        witnesses.iter().map(WitnessSummary::from_witness).collect();
138
139    // 0. Policy fail-closed has highest precedence so a verifier bug cannot
140    //    bypass an ADR 0026 `Reject` / `Quarantine`.
141    if let Some(decision) = policy {
142        if matches!(
143            decision.final_outcome,
144            PolicyOutcome::Reject | PolicyOutcome::Quarantine
145        ) {
146            return VerifiedTrustState::Broken {
147                edge: BrokenEdge::new(
148                    COMPOSITION_POLICY_FAIL_CLOSED,
149                    format!(
150                        "policy outcome {:?} fails closed for {:?}",
151                        decision.final_outcome, input.kind
152                    ),
153                ),
154                witnesses: summaries,
155            };
156        }
157    }
158
159    verify_inner(input, witnesses, now, max_age, None, summaries)
160}
161
162/// Shared inner reducer. Called by all public entry points after policy
163/// fail-closed is resolved. `registry` is `None` when the caller does not
164/// supply a `--witness-key-registry`; `SelfSigned` witnesses then fail closed
165/// with `UnsupportedAlgorithm`.
166fn verify_inner(
167    input: &EvidenceInput,
168    witnesses: &[IndependentWitness],
169    now: DateTime<Utc>,
170    max_age: Duration,
171    registry: Option<&SelfSignedKeyRegistry>,
172    summaries: Vec<WitnessSummary>,
173) -> VerifiedTrustState {
174    // 1. Advisory-only short-circuit. ADR 0041 acceptance §132: absent or
175    //    advisory-only paths cannot be promoted by witnesses. We bind this to
176    //    `tier_insufficient` because the runtime mode itself is below trust.
177    if input.is_advisory_only() {
178        return VerifiedTrustState::Broken {
179            edge: BrokenEdge::new(
180                WITNESS_TIER_INSUFFICIENT,
181                format!(
182                    "evidence input is advisory-only and cannot be promoted by witnesses (runtime_mode={:?}, advisory_only=true)",
183                    input.runtime_mode
184                ),
185            ),
186            witnesses: summaries,
187        };
188    }
189
190    // 2. Subject binding — every witness must declare the same digest as the
191    //    producer-supplied input.
192    for witness in witnesses {
193        if witness.asserted_subject_blake3 != input.evidence_blake3 {
194            return VerifiedTrustState::Broken {
195                edge: BrokenEdge::new(
196                    WITNESS_DISAGREEMENT,
197                    format!(
198                        "witness class={} asserted subject {} but input declares {}",
199                        witness.class.wire_str(),
200                        witness.asserted_subject_blake3,
201                        input.evidence_blake3,
202                    ),
203                ),
204                witnesses: summaries,
205            };
206        }
207    }
208
209    // 3a. Domain integrity per witness — declared `authority_domain` must
210    //     match the class.
211    for witness in witnesses {
212        let expected = witness.class.required_authority_domain();
213        if witness.authority_domain != expected {
214            return VerifiedTrustState::Broken {
215                edge: BrokenEdge::new(
216                    WITNESS_AUTHORITY_OVERLAP,
217                    format!(
218                        "witness class={} declared authority_domain={} but class requires {}",
219                        witness.class.wire_str(),
220                        witness.authority_domain.wire_str(),
221                        expected.wire_str(),
222                    ),
223                ),
224                witnesses: summaries,
225            };
226        }
227    }
228
229    // 3b. Pairwise disjointness — no two witnesses may share a domain.
230    let mut seen_domains: HashSet<AuthorityDomain> = HashSet::new();
231    for witness in witnesses {
232        if !seen_domains.insert(witness.authority_domain) {
233            return VerifiedTrustState::Broken {
234                edge: BrokenEdge::new(
235                    WITNESS_AUTHORITY_OVERLAP,
236                    format!(
237                        "two witnesses share authority_domain={}",
238                        witness.authority_domain.wire_str()
239                    ),
240                ),
241                witnesses: summaries,
242            };
243        }
244    }
245
246    // 4. Freshness.
247    for witness in witnesses {
248        let age = now - witness.asserted_at;
249        if age > max_age {
250            return VerifiedTrustState::Broken {
251                edge: BrokenEdge::new(
252                    WITNESS_STALE,
253                    format!(
254                        "witness class={} is stale: age {}s exceeds max_age {}s",
255                        witness.class.wire_str(),
256                        age.num_seconds(),
257                        max_age.num_seconds(),
258                    ),
259                ),
260                witnesses: summaries,
261            };
262        }
263    }
264
265    // 5. Tier sufficiency. Remote-CI and reproducible-build witnesses must be
266    //    `ThirdParty` for both supported `EvidenceKind`s.
267    for witness in witnesses {
268        if requires_third_party(input.kind, witness.class)
269            && witness.tier != WitnessTier::ThirdParty
270        {
271            return VerifiedTrustState::Broken {
272                edge: BrokenEdge::new(
273                    WITNESS_TIER_INSUFFICIENT,
274                    format!(
275                        "witness class={} requires tier=third_party for {}, got {}",
276                        witness.class.wire_str(),
277                        input.kind.wire_str(),
278                        witness.tier.wire_str(),
279                    ),
280                ),
281                witnesses: summaries,
282            };
283        }
284    }
285
286    // 6. Signature verification.
287    for witness in witnesses {
288        if let Err(detail) = verify_witness_signature(witness, registry) {
289            return VerifiedTrustState::Broken {
290                edge: BrokenEdge::new(
291                    WITNESS_SIGNATURE_INVALID,
292                    format!(
293                        "witness class={} signature did not verify: {}",
294                        witness.class.wire_str(),
295                        detail
296                    ),
297                ),
298                witnesses: summaries,
299            };
300        }
301    }
302
303    // 7. Class coverage. Missing required classes drop to Partial — never
304    //    silently promote.
305    let required_classes = required_classes_for(input.kind);
306    let present_classes: HashSet<WitnessClass> = witnesses.iter().map(|w| w.class).collect();
307    let missing: Vec<WitnessClass> = required_classes
308        .iter()
309        .copied()
310        .filter(|class| !present_classes.contains(class))
311        .collect();
312
313    if !missing.is_empty() {
314        let reasons: Vec<String> = missing
315            .iter()
316            .map(|class| {
317                format!(
318                    "{}: required witness class {} is missing",
319                    WITNESS_MISSING,
320                    class.wire_str()
321                )
322            })
323            .collect();
324        return VerifiedTrustState::Partial {
325            reasons,
326            witnesses: summaries,
327        };
328    }
329
330    // 8. Composition — effective ceiling must reach the required ceiling for
331    //    the claim kind.
332    let runtime_kind = runtime_claim_kind_for(input.kind);
333    let required_ceiling = runtime_kind.required_ceiling();
334    let effective = effective_ceiling(
335        input.runtime_mode,
336        input.authority_class,
337        input.proof_state,
338        input.requested_ceiling,
339    );
340    if effective < required_ceiling {
341        return VerifiedTrustState::Broken {
342            edge: BrokenEdge::new(
343                COMPOSITION_CEILING_BELOW_REQUIRED,
344                format!(
345                    "effective ceiling {effective:?} is below required ceiling {required_ceiling:?} for {runtime_kind:?}"
346                ),
347            ),
348            witnesses: summaries,
349        };
350    }
351
352    VerifiedTrustState::FullChainVerified {
353        ceiling: effective,
354        witnesses: summaries,
355    }
356}
357
358/// Required witness classes per evidence kind. Per ADR 0041 §4, both
359/// `ReleaseReadiness` and `ComplianceEvidence` require all four classes for
360/// `FullChainVerified`.
361fn required_classes_for(kind: EvidenceKind) -> &'static [WitnessClass] {
362    match kind {
363        EvidenceKind::ReleaseReadiness | EvidenceKind::ComplianceEvidence => &[
364            WitnessClass::SignedLedgerChainHead,
365            WitnessClass::ExternalAnchorCrossing,
366            WitnessClass::RemoteCiConclusion,
367            WitnessClass::ReproducibleBuildProvenance,
368        ],
369    }
370}
371
372/// Project an [`EvidenceKind`] onto the existing [`RuntimeClaimKind`] without
373/// adding new variants (design choice from
374/// `DESIGN_release_compliance_verifier.md` §11).
375const fn runtime_claim_kind_for(kind: EvidenceKind) -> RuntimeClaimKind {
376    match kind {
377        EvidenceKind::ReleaseReadiness => RuntimeClaimKind::ReleaseReadiness,
378        EvidenceKind::ComplianceEvidence => RuntimeClaimKind::ComplianceEvidence,
379    }
380}
381
382/// Whether a witness class must be `ThirdParty` for the given evidence kind.
383fn requires_third_party(kind: EvidenceKind, class: WitnessClass) -> bool {
384    matches!(
385        (kind, class),
386        (
387            EvidenceKind::ReleaseReadiness | EvidenceKind::ComplianceEvidence,
388            WitnessClass::RemoteCiConclusion | WitnessClass::ReproducibleBuildProvenance,
389        )
390    )
391}
392
393/// Verify a witness's signature over its canonical preimage. Dispatches on the
394/// [`WitnessSignature`] variant. Pure: no I/O, no key fetch — all material is
395/// already in memory.
396///
397/// `EcdsaP256` returns `Err` with an `UnsupportedAlgorithm` detail until the
398/// matching crate dependencies are wired in; the adapter-boundary verifier in
399/// `crates/cortex-ledger/src/external_sink/rekor.rs` handles P-256 today.
400///
401/// `SelfSigned` resolves the `key_id` against `registry` when `Some`. If the
402/// registry is `None` or the key is absent, the witness fails closed.
403fn verify_witness_signature(
404    witness: &IndependentWitness,
405    registry: Option<&SelfSignedKeyRegistry>,
406) -> Result<(), String> {
407    if witness.payload.class() != witness.class {
408        return Err(format!(
409            "payload class {} does not match declared class {}",
410            witness.payload.class().wire_str(),
411            witness.class.wire_str()
412        ));
413    }
414    match &witness.signature {
415        WitnessSignature::Ed25519 {
416            public_key_bytes,
417            signature_bytes,
418            ..
419        } => {
420            let key = VerifyingKey::from_bytes(public_key_bytes)
421                .map_err(|err| format!("public_key_bytes is not a valid Ed25519 point: {err}"))?;
422            let sig = Signature::from_bytes(signature_bytes);
423            let preimage = witness.canonical_preimage();
424            key.verify(&preimage, &sig)
425                .map_err(|err| format!("Ed25519 verification failed: {err}"))?;
426            Ok(())
427        }
428        WitnessSignature::EcdsaP256 { .. } => {
429            // ECDSA P-256 is verified at the adapter boundary
430            // (crates/cortex-ledger/src/external_sink/rekor.rs). Verifier-layer
431            // dispatch is deferred per ADR 0041 amendment; add the `p256` crate
432            // and implement here when the trigger conditions are met.
433            Err("UnsupportedAlgorithm: EcdsaP256 verification not yet implemented at the verifier layer".to_string())
434        }
435        WitnessSignature::SelfSigned {
436            key_id,
437            signature_bytes,
438        } => {
439            let reg = registry.ok_or_else(|| {
440                format!(
441                    "SelfSigned key_id={key_id}: no SelfSignedKeyRegistry supplied \
442                     (pass --witness-key-registry to provide one)"
443                )
444            })?;
445            let entry = reg.get(key_id).ok_or_else(|| {
446                format!(
447                    "SelfSigned key_id={key_id} is not in SelfSignedKeyRegistry \
448                     (registry contains {} entries)",
449                    reg.len()
450                )
451            })?;
452            let key_bytes = entry.key_bytes().map_err(|e| {
453                format!("SelfSigned key_id={key_id}: malformed key_bytes_hex in registry: {e}")
454            })?;
455            let preimage = witness.canonical_preimage();
456            match entry.algorithm {
457                SelfSignedAlgorithm::Ed25519 => {
458                    let raw: [u8; 32] = key_bytes.as_slice().try_into().map_err(|_| {
459                        format!(
460                            "SelfSigned key_id={key_id}: Ed25519 key must be 32 bytes, got {}",
461                            key_bytes.len()
462                        )
463                    })?;
464                    let key = VerifyingKey::from_bytes(&raw).map_err(|e| {
465                        format!("SelfSigned key_id={key_id}: invalid Ed25519 public key: {e}")
466                    })?;
467                    let sig_raw: [u8; 64] =
468                        signature_bytes.as_slice().try_into().map_err(|_| {
469                            format!(
470                                "SelfSigned key_id={key_id}: Ed25519 signature must be 64 bytes, \
471                                 got {}",
472                                signature_bytes.len()
473                            )
474                        })?;
475                    let sig = Signature::from_bytes(&sig_raw);
476                    key.verify(&preimage, &sig).map_err(|e| {
477                        format!("SelfSigned key_id={key_id}: Ed25519 verification failed: {e}")
478                    })
479                }
480                SelfSignedAlgorithm::EcdsaP256 => {
481                    // P-256 SelfSigned verification is deferred — the same
482                    // trigger conditions apply as for EcdsaP256 witnesses.
483                    Err(format!(
484                        "SelfSigned key_id={key_id}: \
485                         UnsupportedAlgorithm: EcdsaP256 SelfSigned verification \
486                         not yet implemented at the verifier layer"
487                    ))
488                }
489            }
490        }
491    }
492}
493
494/// Map a [`VerifiedTrustState`] result to a `ClaimCeiling` for downstream
495/// composition with `runtime_claim_preflight_with_policy`. Non-promoted
496/// states fall to `DevOnly` so any verifier-side bug cannot lift the trust
497/// path silently.
498#[must_use]
499pub fn ceiling_from_state(state: &VerifiedTrustState) -> ClaimCeiling {
500    match state {
501        VerifiedTrustState::FullChainVerified { ceiling, .. } => *ceiling,
502        VerifiedTrustState::Partial { .. } | VerifiedTrustState::Broken { .. } => {
503            ClaimCeiling::DevOnly
504        }
505    }
506}