Skip to main content

pask_wire/
receipt_verification.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Wilder Management Inc. (d/b/a Wilder Robotics) <rob@wilder-robotics.com>
3// See LICENSING.md in the workspace root.
4
5//! Bounded offline Phase 2 coordination, not a transparent-statement verifier.
6//!
7//! Only caller-provisioned keys are tried. Receipt-controlled discovery is never
8//! performed. An issuer string or kid match cannot introduce an authorized key.
9//! Authentication of the trust configuration is the caller's responsibility:
10//! `CallerAuthenticated` is an explicit assertion, not a protocol implemented here.
11//! Local simulation is visibly distinguished and cannot pass registration acceptance.
12//! Subject correspondence, statement issuer verification, application acceptance,
13//! hardware appraisal and attachment-set coordination remain outside this API.
14
15use alloc::{vec, vec::Vec};
16use coset::cbor::Value;
17use ed25519_dalek::{Signature, VerifyingKey};
18use sha2::{Digest, Sha256};
19
20use crate::receipt_cbor::{Budget, Role};
21use crate::{
22    EnvelopeReport, InspectionFinding as Finding, InspectionLimits, InspectionPolicy,
23    InspectionStatus as Status, Receipt, derive_candidate_entry, inspect_scitt_receipt, leaf_hash,
24};
25
26/// This API does not authenticate an external configuration transport.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum TrustInputOrigin {
29    /// A local test/harness simulates authenticated provisioning. Never real TS evidence.
30    LocalSimulation,
31    /// Caller asserts its configuration came from an independently authenticated channel.
32    CallerAuthenticatedExternal,
33}
34
35/// Evidence must bind this row's exact service identity, algorithm and public key.
36/// These fields are caller assertions, not receipt fields and not fetched URLs.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BindingProvenance<'a> {
39    Missing,
40    /// Discovery, including an issuer/key pair copied from a receipt, is not an anchor.
41    Unauthenticated {
42        origin: &'a str,
43    },
44    CallerAuthenticated {
45        authority: &'a str,
46        evidence_ref: &'a str,
47    },
48}
49
50/// Algorithm and key type are independent inputs and must agree.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum TsPublicKey<'a> {
53    Ed25519([u8; 32]),
54    /// Represent unsupported input without reinterpreting it as Ed25519.
55    Unsupported {
56        key_type: &'a str,
57        bytes: &'a [u8],
58    },
59}
60
61/// An independently provisioned association, not an identity extracted from a Receipt.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct TsKeyAssociation<'a> {
64    pub service_identity: &'a str,
65    pub public_key: TsPublicKey<'a>,
66    pub algorithm: i64,
67    pub provenance: BindingProvenance<'a>,
68    /// Nonunique hint only. Lookup scans the entire bounded configured key set.
69    pub kid_hint: Option<&'a [u8]>,
70    /// Unix seconds, inclusive start and exclusive end, evaluated at caller-supplied time.
71    pub valid_from: Option<i64>,
72    pub valid_until: Option<i64>,
73    /// Conservative key-wide veto, across every row for the same actual public key.
74    pub explicitly_distrusted: bool,
75}
76
77/// The only implemented rotation convention. No historical signing time is inferred
78/// from untrusted claims; overlapping current windows permit key rollover.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum RotationPolicy {
81    ValidAtEvaluationTimeV1,
82}
83
84/// Borrowed offline input: no network, live clock, global key store, or hidden defaults.
85#[derive(Debug)]
86pub struct TsTrustContext<'a> {
87    pub accepted_ts_identities: &'a [&'a str],
88    pub associations: &'a [TsKeyAssociation<'a>],
89    pub evaluation_time: Option<i64>,
90    pub rotation_policy: Option<RotationPolicy>,
91    pub provisioned_by: Option<&'a str>,
92    pub origin: TrustInputOrigin,
93}
94
95/// Tightenable hard ceilings. Raw statement/trust limits precede allocating decoders
96/// and key/proof Cartesian work is checked before the first signature attempt.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct VerificationLimits {
99    pub max_statement_bytes: usize,
100    pub max_statement_cbor_items: usize,
101    pub max_statement_cbor_nesting: usize,
102    pub max_statement_map_entries: usize,
103    pub max_associations: usize,
104    pub max_accepted_identities: usize,
105    pub max_candidate_keys: usize,
106    pub max_signature_attempts: usize,
107    pub max_trust_field_bytes: usize,
108}
109impl Default for VerificationLimits {
110    fn default() -> Self {
111        Self {
112            max_statement_bytes: 1_048_576,
113            max_statement_cbor_items: 4_096,
114            max_statement_cbor_nesting: 16,
115            max_statement_map_entries: 64,
116            max_associations: 64,
117            max_accepted_identities: 64,
118            max_candidate_keys: 16,
119            max_signature_attempts: 256,
120            max_trust_field_bytes: 8_192,
121        }
122    }
123}
124impl VerificationLimits {
125    fn valid(&self) -> bool {
126        let d = Self::default();
127        macro_rules! bounded { ($($f:ident),+) => { true $(&& self.$f > 0 && self.$f <= d.$f)+ }; }
128        bounded!(
129            max_statement_bytes,
130            max_statement_cbor_items,
131            max_statement_cbor_nesting,
132            max_statement_map_entries,
133            max_associations,
134            max_accepted_identities,
135            max_candidate_keys,
136            max_signature_attempts,
137            max_trust_field_bytes
138        )
139    }
140}
141
142/// Cross-map strictness remains OFF by default in Phase 1. No #70 helper is called.
143/// Empty external AAD, strict Ed25519 verification and all-supplied-proofs success
144/// are fixed local policy. A valid proof never hides another proof's failure.
145#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct ReceiptVerificationPolicy {
147    pub envelope: InspectionPolicy,
148    pub limits: VerificationLimits,
149}
150impl ReceiptVerificationPolicy {
151    pub const ID: &'static str = "pask71-offline-service-association/1";
152}
153
154/// The actual crypto key, not kid. Indices reference the exact supplied context.
155#[derive(Debug, Clone)]
156pub struct VerifyingKeyEvidence {
157    pub public_key: [u8; 32],
158    pub sha256: [u8; 32],
159    pub association_indices: Vec<usize>,
160    pub kid_hint_match_indices: Vec<usize>,
161}
162
163/// Root reconstruction alone is explicitly not inclusion authentication.
164#[derive(Debug, Clone)]
165pub struct ProofVerification {
166    pub proof_index: usize,
167    pub reconstructed_root: Option<[u8; 32]>,
168    pub root_reconstruction: Finding,
169    pub attached_root_equality: Finding,
170    pub ts_signature: Finding,
171    pub inclusion: Finding,
172    /// Indices into `ReceiptVerificationReport::candidate_keys`.
173    pub verifying_key_indices: Vec<usize>,
174}
175
176/// Independent dimensions, with original inputs borrowed and signed bytes preserved.
177#[derive(Debug)]
178pub struct ReceiptVerificationReport<'a> {
179    pub statement_bytes: &'a [u8],
180    pub envelope: EnvelopeReport<'a>,
181    pub trust_context: &'a TsTrustContext<'a>,
182    pub policy: ReceiptVerificationPolicy,
183    pub policy_id: &'static str,
184    pub candidate_entry: Option<Vec<u8>>,
185    pub candidate_derivation: Finding,
186    pub selected_policy: Finding,
187    pub candidate_keys: Vec<VerifyingKeyEvidence>,
188    /// One finding per configured association, including skipped type/algorithm routes.
189    pub key_configuration: Vec<Finding>,
190    pub proofs: Vec<ProofVerification>,
191    pub signature_attempts: usize,
192    pub ts_signature: Finding,
193    pub inclusion: Finding,
194    pub ts_key_association: Finding,
195    pub ts_identity_trust: Finding,
196    pub subject_policy: Finding,
197    pub issuer_signature: Finding,
198    pub application_policy: Finding,
199    pub hardware_appraisal: Finding,
200    pub acceptable_for_registration: Finding,
201}
202
203fn finding(status: Status, code: &'static str, detail: &'static str) -> Finding {
204    Finding {
205        status,
206        code,
207        detail,
208        evidence_refs: vec![
209            "encoded_receipt",
210            "statement_bytes",
211            "trust_context",
212            "policy",
213        ],
214    }
215}
216fn skipped() -> Finding {
217    finding(
218        Status::NotEvaluated,
219        "prerequisite_not_established",
220        "A dependent check was not run.",
221    )
222}
223fn unestablished(code: &'static str) -> Finding {
224    finding(
225        Status::Unestablished,
226        code,
227        "Required independent evidence is absent; not acceptance.",
228    )
229}
230fn failed(code: &'static str) -> Finding {
231    finding(Status::Failed, code, "Failed only the named dimension.")
232}
233fn passed(code: &'static str) -> Finding {
234    finding(
235        Status::Passed,
236        code,
237        "Passed only the named dimension under the explicit supplied inputs.",
238    )
239}
240
241/// Verify one supported Receipt against a candidate derived from those exact
242/// statement bytes. This does NOT validate the outer statement's header semantics,
243/// issuer signature, attachment container, application policy or physical-event truth.
244/// `LocalSimulation` can demonstrate conditional trust but never passes registration.
245/// `CallerAuthenticatedExternal` still relies on the caller to authenticate provisioning.
246#[must_use]
247pub fn verify_scitt_receipt<'a>(
248    receipt_bytes: &'a [u8],
249    statement_bytes: &'a [u8],
250    trust_context: &'a TsTrustContext<'a>,
251    policy: &ReceiptVerificationPolicy,
252) -> ReceiptVerificationReport<'a> {
253    let mut r = ReceiptVerificationReport {
254        statement_bytes,
255        envelope: inspect_scitt_receipt(receipt_bytes, &policy.envelope),
256        trust_context,
257        policy: policy.clone(),
258        policy_id: ReceiptVerificationPolicy::ID,
259        candidate_entry: None,
260        candidate_derivation: skipped(),
261        selected_policy: passed("phase2_policy_selected"),
262        candidate_keys: Vec::new(),
263        key_configuration: Vec::new(),
264        proofs: Vec::new(),
265        signature_attempts: 0,
266        ts_signature: skipped(),
267        inclusion: skipped(),
268        ts_key_association: unestablished("verifying_key_not_established"),
269        ts_identity_trust: unestablished("trust_not_established"),
270        subject_policy: unestablished("phase3_subject_semantics_not_implemented"),
271        issuer_signature: finding(
272            Status::NotEvaluated,
273            "outside_phase2",
274            "Statement issuer verification is not implemented by this API.",
275        ),
276        application_policy: unestablished("phase3_application_not_implemented"),
277        hardware_appraisal: finding(
278            Status::NotEvaluated,
279            "outside_phase2",
280            "No hardware evidence is evaluated.",
281        ),
282        acceptable_for_registration: unestablished("registration_prerequisites_not_established"),
283    };
284    run(&mut r);
285    r
286}
287
288fn run(r: &mut ReceiptVerificationReport<'_>) {
289    let e = &r.envelope;
290    if [
291        &e.structure,
292        &e.required_claims,
293        &e.support,
294        &e.selected_policy,
295    ]
296    .iter()
297    .any(|f| f.status != Status::Passed)
298    {
299        return;
300    }
301    if !r.policy.limits.valid() {
302        r.selected_policy = failed("invalid_phase2_limits");
303        return;
304    }
305    let l = &r.policy.limits;
306    if r.statement_bytes.len() > l.max_statement_bytes {
307        r.candidate_derivation = failed("statement_byte_limit");
308        return;
309    }
310    // General, allocation-free preflight only; do not accidentally inherit the
311    // Receipt's claims rules or #70's unconditional outer cross-map strictness.
312    let limits = InspectionLimits {
313        max_receipt_bytes: l.max_statement_bytes,
314        max_cbor_items: l.max_statement_cbor_items,
315        max_cbor_nesting: l.max_statement_cbor_nesting,
316        max_map_entries: l.max_statement_map_entries,
317        ..InspectionLimits::default()
318    };
319    let mut budget = Budget {
320        limits: &limits,
321        items: 0,
322        unprotected_x5t_invalid: false,
323    };
324    if budget
325        .scan(r.statement_bytes, 0, Role::Any, "statement_trailing_data")
326        .is_err()
327    {
328        r.candidate_derivation = failed("statement_cbor_preflight");
329        return;
330    }
331    let Ok(candidate) = derive_candidate_entry(r.statement_bytes) else {
332        r.candidate_derivation = failed("statement_candidate_shape");
333        return;
334    };
335    r.candidate_derivation = passed("exact_statement_candidate_derived_not_outer_validation");
336    r.candidate_entry = Some(candidate);
337    if !bounded_context(r.trust_context, l) {
338        r.selected_policy = failed("trust_context_limit");
339        return;
340    }
341    let Ok(receipt) = Receipt::from_cose_sign1(r.envelope.encoded_receipt) else {
342        r.selected_policy = failed("inspection_parser_disagreement");
343        return;
344    };
345    let kid = r.envelope.effective_headers.iter().find_map(|(k, v)| {
346        if *k == Value::Integer(4.into())
347            && let Value::Bytes(b) = v
348        {
349            return Some(b.as_slice());
350        }
351        None
352    });
353    for (i, a) in r.trust_context.associations.iter().enumerate() {
354        let TsPublicKey::Ed25519(key) = a.public_key else {
355            r.key_configuration.push(finding(
356                Status::Unsupported,
357                "key_type_not_supported",
358                "This row is not used for signature attempts; no key-type coercion.",
359            ));
360            continue;
361        };
362        if a.algorithm != -8 {
363            r.key_configuration
364                .push(failed("configured_algorithm_key_mismatch"));
365            continue;
366        }
367        r.key_configuration
368            .push(passed("configured_ed25519_algorithm_key_agreement"));
369        let position = r.candidate_keys.iter().position(|k| k.public_key == key);
370        let index = match position {
371            Some(index) => index,
372            None => {
373                if r.candidate_keys.len() == l.max_candidate_keys {
374                    r.selected_policy = failed("candidate_key_limit");
375                    return;
376                }
377                r.candidate_keys.push(VerifyingKeyEvidence {
378                    public_key: key,
379                    sha256: Sha256::digest(key).into(),
380                    association_indices: Vec::new(),
381                    kid_hint_match_indices: Vec::new(),
382                });
383                r.candidate_keys.len() - 1
384            }
385        };
386        r.candidate_keys[index].association_indices.push(i);
387        if kid.is_some() && kid == a.kid_hint {
388            r.candidate_keys[index].kid_hint_match_indices.push(i);
389        }
390    }
391    if r.candidate_keys
392        .len()
393        .checked_mul(receipt.inclusion_proofs.len())
394        .is_none_or(|n| n > l.max_signature_attempts)
395    {
396        r.selected_policy = failed("signature_attempt_limit");
397        return;
398    }
399    let leaf = leaf_hash(r.candidate_entry.as_deref().unwrap_or_default());
400    let signature =
401        Signature::from_slice(r.envelope.signature_bytes.as_deref().unwrap_or_default());
402    for (i, proof) in receipt.inclusion_proofs.iter().enumerate() {
403        let mut p = ProofVerification {
404            proof_index: i,
405            reconstructed_root: None,
406            root_reconstruction: skipped(),
407            attached_root_equality: skipped(),
408            ts_signature: skipped(),
409            inclusion: unestablished("root_not_authenticated"),
410            verifying_key_indices: Vec::new(),
411        };
412        if let Ok(root) = proof.reconstruct_root(leaf) {
413            p.reconstructed_root = Some(root);
414            p.root_reconstruction = passed("root_reconstructed_not_authenticated");
415            if receipt
416                .payload
417                .as_ref()
418                .is_some_and(|b| b.as_slice() != root)
419            {
420                p.attached_root_equality = failed("attached_root_mismatch");
421                p.inclusion = failed("attached_root_mismatch");
422            } else {
423                p.attached_root_equality = if receipt.payload.is_some() {
424                    passed("attached_root_equal_not_authenticated")
425                } else {
426                    finding(
427                        Status::NotEvaluated,
428                        "detached_payload",
429                        "No attached root; signature must authenticate the reconstructed root.",
430                    )
431                };
432                if let Ok(signature) = &signature {
433                    // Never reserialize protected contents. Sig_structure outer encoding only.
434                    let structure = Value::Array(vec![
435                        Value::Text("Signature1".into()),
436                        Value::Bytes(r.envelope.protected_bytes.clone().unwrap_or_default()),
437                        Value::Bytes(Vec::new()),
438                        Value::Bytes(root.to_vec()),
439                    ]);
440                    let mut signed = Vec::new();
441                    if coset::cbor::ser::into_writer(&structure, &mut signed).is_err() {
442                        r.selected_policy = failed("sig_structure_encoding");
443                        return;
444                    }
445                    for (ki, key) in r.candidate_keys.iter().enumerate() {
446                        r.signature_attempts += 1;
447                        if VerifyingKey::from_bytes(&key.public_key)
448                            .is_ok_and(|k| k.verify_strict(&signed, signature).is_ok())
449                        {
450                            p.verifying_key_indices.push(ki);
451                        }
452                    }
453                    if p.verifying_key_indices.is_empty() {
454                        p.ts_signature = if r.candidate_keys.is_empty() {
455                            unestablished("no_algorithm_compatible_candidate_key")
456                        } else {
457                            failed("signature_did_not_verify")
458                        };
459                    } else {
460                        p.ts_signature = passed("signature_under_actual_supplied_key");
461                        p.inclusion = passed("candidate_included_in_signature_authenticated_root");
462                    }
463                } else {
464                    p.ts_signature = failed("invalid_ed25519_signature_length");
465                }
466            }
467        } else {
468            p.root_reconstruction = failed("invalid_merkle_path");
469            p.inclusion = failed("invalid_merkle_path");
470        }
471        r.proofs.push(p);
472    }
473    r.ts_signature = aggregate(
474        r.proofs.iter().map(|p| &p.ts_signature),
475        "all_proof_roots_signed",
476    );
477    r.inclusion = aggregate(
478        r.proofs.iter().map(|p| &p.inclusion),
479        "all_proofs_authenticated",
480    );
481    if r.ts_signature.status != Status::Passed || r.inclusion.status != Status::Passed {
482        return;
483    }
484    // Fail closed if more than one actual key verifies, or proofs disagree on the signer.
485    let first = &r.proofs[0].verifying_key_indices;
486    if first.len() != 1 || r.proofs.iter().any(|p| p.verifying_key_indices != *first) {
487        r.ts_key_association = failed("ambiguous_actual_verifying_key");
488        r.ts_identity_trust = failed("ambiguous_actual_verifying_key");
489        return;
490    }
491    associate(r, first[0]);
492}
493
494fn aggregate<'a>(items: impl Iterator<Item = &'a Finding>, code: &'static str) -> Finding {
495    let statuses: Vec<Status> = items.map(|f| f.status).collect();
496    if statuses.contains(&Status::Failed) {
497        return failed(code);
498    }
499    if statuses.iter().all(|s| *s == Status::Passed) && !statuses.is_empty() {
500        return passed(code);
501    }
502    if statuses.iter().all(|s| *s == Status::NotEvaluated) {
503        return skipped();
504    }
505    unestablished(code)
506}
507
508fn bounded_context(c: &TsTrustContext<'_>, l: &VerificationLimits) -> bool {
509    if c.associations.len() > l.max_associations
510        || c.accepted_ts_identities.len() > l.max_accepted_identities
511    {
512        return false;
513    }
514    let bounded = |s: &str| s.len() <= l.max_trust_field_bytes;
515    if !c.accepted_ts_identities.iter().all(|s| bounded(s))
516        || c.provisioned_by.is_some_and(|s| !bounded(s))
517    {
518        return false;
519    }
520    c.associations.iter().all(|a| {
521        bounded(a.service_identity)
522            && a.kid_hint
523                .is_none_or(|b| b.len() <= l.max_trust_field_bytes)
524            && match a.public_key {
525                TsPublicKey::Ed25519(_) => true,
526                TsPublicKey::Unsupported { key_type, bytes } => {
527                    bounded(key_type) && bytes.len() <= l.max_trust_field_bytes
528                }
529            }
530            && match a.provenance {
531                BindingProvenance::Missing => true,
532                BindingProvenance::Unauthenticated { origin } => bounded(origin),
533                BindingProvenance::CallerAuthenticated {
534                    authority,
535                    evidence_ref,
536                } => bounded(authority) && bounded(evidence_ref),
537            }
538    })
539}
540
541fn associate(r: &mut ReceiptVerificationReport<'_>, key_index: usize) {
542    let key = r.candidate_keys[key_index].public_key;
543    let c = r.trust_context;
544    let issuer = r
545        .envelope
546        .unauthenticated_claims
547        .as_ref()
548        .map(|v| v.issuer.as_str())
549        .unwrap_or("");
550    // Key-wide distrust wins even when another row has a different issuer, kid,
551    // algorithm, validity window or provenance; input is a caller-owned deny list.
552    if c.associations
553        .iter()
554        .any(|a| a.public_key == TsPublicKey::Ed25519(key) && a.explicitly_distrusted)
555    {
556        r.ts_key_association = failed("actual_key_explicitly_distrusted");
557        r.ts_identity_trust = failed("actual_key_explicitly_distrusted");
558        return;
559    }
560    let matching: Vec<_> = c
561        .associations
562        .iter()
563        .filter(|a| {
564            a.public_key == TsPublicKey::Ed25519(key)
565                && a.algorithm == -8
566                && a.service_identity == issuer
567        })
568        .collect();
569    if matching.is_empty() {
570        r.ts_key_association = failed("actual_key_not_associated_with_claimed_service");
571        r.ts_identity_trust = failed("unauthorized_verifying_key");
572        return;
573    }
574    let authenticated: Vec<_> = matching
575        .iter()
576        .filter(|a| {
577            matches!(
578                a.provenance, BindingProvenance::CallerAuthenticated { authority, evidence_ref }
579                    if !authority.is_empty() && !evidence_ref.is_empty()
580            )
581        })
582        .collect();
583    if authenticated.is_empty() || c.provisioned_by.is_none_or(str::is_empty) {
584        r.ts_key_association = unestablished("authenticated_binding_evidence_missing");
585        return;
586    }
587    r.ts_key_association = passed("actual_key_has_caller_authenticated_service_binding");
588    if !c.accepted_ts_identities.contains(&issuer) {
589        r.ts_identity_trust = failed("service_identity_not_accepted");
590        return;
591    }
592    let (Some(now), Some(RotationPolicy::ValidAtEvaluationTimeV1)) =
593        (c.evaluation_time, c.rotation_policy)
594    else {
595        r.ts_identity_trust = unestablished("evaluation_time_or_rotation_policy_missing");
596        return;
597    };
598    let mut missing_window = false;
599    let valid = authenticated
600        .iter()
601        .any(|a| match (a.valid_from, a.valid_until) {
602            (Some(from), Some(until)) => from < until && from <= now && now < until,
603            _ => {
604                missing_window = true;
605                false
606            }
607        });
608    if !valid {
609        r.ts_identity_trust = if missing_window {
610            unestablished("key_validity_evidence_missing")
611        } else {
612            failed("no_current_authorized_key_window")
613        };
614        return;
615    }
616    match c.origin {
617        TrustInputOrigin::LocalSimulation => {
618            r.ts_identity_trust = passed("local_simulation_conditional_trust_only");
619            r.acceptable_for_registration =
620                unestablished("simulation_is_not_real_registration_evidence");
621        }
622        TrustInputOrigin::CallerAuthenticatedExternal => {
623            r.ts_identity_trust =
624                passed("trusted_under_caller_authenticated_offline_configuration");
625            r.acceptable_for_registration =
626                passed("single_receipt_registration_evidence_only_not_application_acceptance");
627        }
628    }
629}