pask_wire/chain.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// pask-wire is licensed Apache-2.0. No commercial agreement is required to use,
4// modify or redistribute it; see LICENSING.md in the workspace root.
5
6//! Chain-Verifier: the chain-level checks that apply when two or more receipts
7//! are presented as one contiguous chain.
8//!
9//! `-01` ยง4.1 adds two normative requirements on a Chain-Verifier presented
10//! with such a presentation:
11//!
12//! 1. MUST check `chain.seq` contiguity across the presentation.
13//! 2. MUST check each receipt's `chain.prevHash` equals the preceding
14//! receipt's `chain.hash`.
15//!
16//! [`Payload::from_json`](crate::Payload::from_json) already validates every
17//! individual receipt on its own, including the seq0/prevHash-null pairing
18//! and that a receipt's own `chain.hash` matches its content. This module
19//! does not repeat that work: [`verify_chain`] does NOT re-verify each
20//! receipt's own `chain.hash`, because parsing already did. It only checks
21//! the relationships *between* adjacent receipts in a presentation.
22//!
23//! `-02` adds a third chain-level rule, over `issuerAffiliation`, and it is
24//! deliberately not a rejection.
25//!
26//! The two `-01` checks are structural. Sequence numbering and the hash link
27//! are entirely under the Issuer's control, so a violation of either is always
28//! an error or tampering, with no honest explanation available. Affiliation is
29//! not structural. It is a claim about the world outside the receipt, and the
30//! world changes: an Issuer independent of the Site Owner in March can be
31//! acquired by that Site Owner in September. Treating that as a malformed chain
32//! would put an ordinary corporate event into the same bucket as tampering, and
33//! would report it to a relying party in the same words.
34//!
35//! It would also be destructive. The only way to comply with a rejection rule
36//! is to start a new chain, which resets `seq` to zero and `prevHash` to null,
37//! and so deletes the link between the receipts from before the change and the
38//! ones after. That is exactly the continuity a chain exists to carry.
39//!
40//! So [`verify_chain`] returns a [`ChainReport`] rather than a bare unit. A
41//! chain whose `issuerAffiliation` changes is still a valid chain, and the
42//! report names every point at which the value changed, identified by the
43//! `chain.seq` of the receipt that changed it.
44//!
45//! What is prohibited is collapsing the presentation to one affiliation value.
46//! That prohibition, not rejection, is what closes the relabelling attack: the
47//! resolution a reader reaches for is to take the value from the newest
48//! receipt, and doing that lets a whole chain be relabelled after the fact by
49//! appending a single receipt, with nothing in the record showing the label
50//! ever said anything else. Every reported value stays attached to the receipts
51//! that actually carry it.
52
53use crate::{Error, IssuerAffiliation, Payload, Result};
54use alloc::vec::Vec;
55
56/// One point inside a presentation at which `issuerAffiliation` changed.
57///
58/// `at_seq` is the `chain.seq` of the receipt carrying the new value, so a
59/// report can be quoted against the presentation without recounting positions.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AffiliationChange {
62 /// `chain.seq` of the receipt that introduced the new value.
63 pub at_seq: u64,
64 /// The value carried by the preceding receipt.
65 pub from: IssuerAffiliation,
66 /// The value carried by the receipt at `at_seq`.
67 pub to: IssuerAffiliation,
68}
69
70/// What a Chain-Verifier observed about a presentation it accepted.
71///
72/// An empty report is the ordinary case and means the presentation was
73/// structurally sound and uniform in its `issuerAffiliation`.
74///
75/// There is deliberately no accessor returning a single affiliation value for
76/// the chain. A caller wanting to know what a given receipt claims reads that
77/// receipt. Supplying one value for the whole presentation is the collapse this
78/// type exists to prevent.
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub struct ChainReport {
81 affiliation_changes: Vec<AffiliationChange>,
82}
83
84impl ChainReport {
85 /// Every point at which `issuerAffiliation` changed, in presentation order.
86 #[must_use]
87 pub fn affiliation_changes(&self) -> &[AffiliationChange] {
88 &self.affiliation_changes
89 }
90
91 /// True when every receipt in the presentation carried the same
92 /// `issuerAffiliation`.
93 #[must_use]
94 pub fn affiliation_is_uniform(&self) -> bool {
95 self.affiliation_changes.is_empty()
96 }
97}
98
99/// Verifies a slice of receipts as one contiguous chain.
100///
101/// This checks only the chain-level relationships between adjacent receipts.
102/// It does NOT re-verify any receipt's own `chain.hash` against its content.
103/// `Payload::from_json` already did that at parse time, for every receipt in
104/// the slice.
105///
106/// Rules, applied in this order:
107///
108/// - An empty slice is rejected.
109/// - The head of the presentation (`receipts[0]`) MUST carry `seq == 0` and
110/// `prevHash == None`. Per-receipt validation already enforces the
111/// seq0/prevHash-null pairing in general; this is the additional
112/// chain-level rule that the *head of a presentation* specifically must be
113/// sequence zero.
114/// - For each adjacent pair, `seq` MUST be contiguous: `seq[i] == seq[i-1] +
115/// 1`.
116/// - For each adjacent pair, `prevHash[i]` MUST equal `Some(hash[i-1])`.
117/// - A change in `issuerAffiliation` between adjacent receipts is recorded in
118/// the returned [`ChainReport`] and does NOT invalidate the presentation.
119/// See the module documentation for why this one is not a rejection.
120///
121/// # Errors
122///
123/// Returns [`Error::Validation`] on the first structural rule violated, in the
124/// order listed above.
125pub fn verify_chain(receipts: &[Payload]) -> Result<ChainReport> {
126 let Some((head, rest)) = receipts.split_first() else {
127 return Err(Error::Validation("chain must carry at least one receipt"));
128 };
129
130 if head.chain_seq() != 0 || head.chain_prev_hash().is_some() {
131 return Err(Error::Validation(
132 "chain head must have seq 0 and a null prevHash",
133 ));
134 }
135
136 let mut report = ChainReport::default();
137 let mut previous = head;
138 for receipt in rest {
139 if receipt.chain_seq() != previous.chain_seq() + 1 {
140 return Err(Error::Validation("chain.seq is not contiguous"));
141 }
142 if receipt.chain_prev_hash() != Some(previous.chain_hash()) {
143 return Err(Error::Validation(
144 "chain.prevHash does not match the preceding receipt",
145 ));
146 }
147 if receipt.issuer_affiliation() != previous.issuer_affiliation() {
148 report.affiliation_changes.push(AffiliationChange {
149 at_seq: receipt.chain_seq(),
150 from: previous.issuer_affiliation().clone(),
151 to: receipt.issuer_affiliation().clone(),
152 });
153 }
154 previous = receipt;
155 }
156
157 Ok(report)
158}