Skip to main content

pask_wire/
receipt.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//! Offline verification of a SCITT Receipt attached to a Physical-Site
7//! Engagement Receipt Signed Statement.
8//!
9//! `-02` "SCITT registration and Receipt attachment" makes registration mandatory and places a
10//! matching obligation on the reader: a relying party MUST NOT accept a
11//! Physical-Site Engagement Receipt as conforming to this profile unless at
12//! least one attached Receipt from a Transparency Service that relying party
13//! trusts verifies per RFC 9942.
14//!
15//! Until this module existed, nothing in this repository could evaluate that
16//! sentence. [`verify_ed25519`](crate::verify_ed25519) returned `Ok(Payload)`
17//! for a statement carrying no attached Receipt at all, so the library's
18//! success value said more than the library had checked. That is the narrower
19//! and sharper half of the gap recorded in `KNOWN-LIMITATIONS.md` 5.2: the
20//! recorded limitation is that no crate *registers* anything, but the
21//! consequence a relying party actually meets is that no crate could *check* a
22//! registration either. This module closes the checking half. It does not
23//! close the producing half, and the limitations file still says so.
24//!
25//! # What this module will not do
26//!
27//! It will not tell a caller that a statement is conforming. Conformance under
28//! `-02` "SCITT registration and Receipt attachment" turns on a Receipt from a Transparency Service *that
29//! relying party trusts*, and trust in a Transparency Service is the relying
30//! party's decision, held outside this library. What this module reports is
31//! narrower and checkable: this inclusion proof is well formed, it reconstructs
32//! this Merkle root over the entry bytes you supplied, and the signature over
33//! that root verifies under the key you supplied. Turning that into a
34//! conformance decision is the caller's step, and naming the boundary is the
35//! point rather than a shortcoming.
36//!
37//! The same discipline governs [`AttachedReceipts`]. It distinguishes a
38//! statement with no `receipts` header from one whose header is present but
39//! unreadable, and it does not offer a single boolean over the set. Collapsing
40//! "no Receipt was attached" and "a Receipt was attached and did not verify"
41//! into one falsey value is the specific error that would let an unregistered
42//! statement and a tampered one be reported to a relying party in the same
43//! words, which is the mistake [`ChainReport`](crate::ChainReport) exists to
44//! avoid one level up.
45//!
46//! # The entry bytes are a caller input, deliberately
47//!
48//! RFC 9942 Section 5.2 verification begins "the verifier obtains the bytes of
49//! a candidate entry" and applies the inclusion proof to them. It does not say
50//! what a SCITT log entry is, and neither does `-02`: the profile requires
51//! registration and asserts the result is offline-checkable, but never pins the
52//! byte sequence the Merkle leaf covers. Two conforming implementations can
53//! therefore disagree about what was logged while both believing they follow
54//! the profile, and no proof either produces will verify against the other.
55//!
56//! This module does not paper over that. [`verify_inclusion`] takes the entry
57//! bytes as an explicit argument rather than deriving them from the statement,
58//! so the ambiguity stays visible at the call site instead of being silently
59//! resolved one way inside a library. Resolving it is document work, not code
60//! work, and it belongs in a revision.
61
62use alloc::{vec, vec::Vec};
63use coset::cbor::Value;
64use sha2::{Digest, Sha256};
65
66use crate::{Error, Result};
67
68/// COSE header parameter carrying attached Receipts (RFC 9942 Section 5.1).
69pub const RECEIPTS_LABEL: i64 = 394;
70
71/// COSE protected header parameter carrying the VDS identifier.
72pub const VDS_LABEL: i64 = 395;
73
74/// COSE unprotected header parameter carrying Verifiable Data Structure Proofs.
75pub const VDP_LABEL: i64 = 396;
76
77/// Key within the `vdp` map holding inclusion proofs.
78pub const INCLUSION_PROOF_LABEL: i64 = -1;
79
80/// The `RFC9162_SHA256` Verifiable Data Structure identifier.
81pub const RFC9162_SHA256: i64 = 1;
82
83/// Domain-separation prefix for a Merkle leaf (RFC 9162 Section 2.1.1).
84const LEAF_PREFIX: u8 = 0x00;
85
86/// Domain-separation prefix for a Merkle interior node (RFC 9162 Section 2.1.1).
87const NODE_PREFIX: u8 = 0x01;
88
89/// `MTH({d})` for a single entry: `HASH(0x00 || d)`.
90#[must_use]
91pub fn leaf_hash(entry: &[u8]) -> [u8; 32] {
92    let mut hasher = Sha256::new();
93    hasher.update([LEAF_PREFIX]);
94    hasher.update(entry);
95    hasher.finalize().into()
96}
97
98/// An interior node: `HASH(0x01 || left || right)`.
99fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
100    let mut hasher = Sha256::new();
101    hasher.update([NODE_PREFIX]);
102    hasher.update(left);
103    hasher.update(right);
104    hasher.finalize().into()
105}
106
107/// A decoded `RFC9162_SHA256` inclusion proof.
108///
109/// The wire form is a `bstr` wrapping the CBOR array
110/// `[tree_size: uint, leaf_index: uint, inclusion_path: [+ bstr]]`
111/// (RFC 9942 Section 5.2). Note the field order: `tree_size` precedes
112/// `leaf_index`.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct InclusionProof {
115    /// Size of the tree whose root this proof reconstructs.
116    pub tree_size: u64,
117    /// Index of the proven leaf, relative to `tree_size`.
118    pub leaf_index: u64,
119    /// Sibling hashes on the path from the leaf to the root.
120    pub inclusion_path: Vec<[u8; 32]>,
121}
122
123impl InclusionProof {
124    /// Decodes one inclusion proof from the CBOR inside its `bstr` wrapper.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`Error::Receipt`] if the bytes are not the CBOR three-element
129    /// array RFC 9942 Section 5.2 specifies, if either count is not an
130    /// unsigned integer, if the path is empty, or if any path element is not
131    /// exactly 32 bytes. An empty `inclusion_path` is rejected because the
132    /// CDDL requires at least one element (`[+ bstr]`); a single-leaf tree
133    /// still carries a proof, and a genuinely empty array is a malformed
134    /// proof rather than a proof of a one-entry log.
135    pub fn from_wrapped_cbor(wrapped: &[u8]) -> Result<Self> {
136        let mut cursor = wrapped;
137        let value: Value = coset::cbor::de::from_reader(&mut cursor)
138            .map_err(|_| Error::Receipt("inclusion proof is not valid CBOR"))?;
139        if !cursor.is_empty() {
140            return Err(Error::Receipt("trailing bytes after inclusion proof"));
141        }
142        let Value::Array(items) = value else {
143            return Err(Error::Receipt("inclusion proof must be a CBOR array"));
144        };
145        let [tree_size, leaf_index, path] = items.as_slice() else {
146            return Err(Error::Receipt(
147                "inclusion proof must carry exactly three elements",
148            ));
149        };
150        let tree_size = unsigned(tree_size)
151            .ok_or(Error::Receipt("inclusion proof tree_size must be a uint"))?;
152        let leaf_index = unsigned(leaf_index)
153            .ok_or(Error::Receipt("inclusion proof leaf_index must be a uint"))?;
154        let Value::Array(path) = path else {
155            return Err(Error::Receipt(
156                "inclusion proof inclusion_path must be an array",
157            ));
158        };
159        if path.is_empty() {
160            return Err(Error::Receipt(
161                "inclusion proof inclusion_path must not be empty",
162            ));
163        }
164        let mut inclusion_path = Vec::with_capacity(path.len());
165        for element in path {
166            let Value::Bytes(bytes) = element else {
167                return Err(Error::Receipt(
168                    "inclusion proof inclusion_path elements must be byte strings",
169                ));
170            };
171            let hash: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
172                Error::Receipt("inclusion proof inclusion_path elements must be 32 bytes")
173            })?;
174            inclusion_path.push(hash);
175        }
176        Ok(Self {
177            tree_size,
178            leaf_index,
179            inclusion_path,
180        })
181    }
182
183    /// Reconstructs the Merkle root this proof claims, given the proven leaf hash.
184    ///
185    /// This is RFC 9162 Section 2.1.3.2 verbatim, up to but not including its
186    /// final comparison against a known root. The comparison is left to the
187    /// caller because in the detached-payload case there is no known root to
188    /// compare against: the reconstructed root *becomes* the `COSE_Sign1`
189    /// payload, and the signature check is what binds it.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`Error::Receipt`] when `leaf_index >= tree_size`, when the
194    /// path is longer than the tree can justify, or when the path runs out
195    /// before the root is reached.
196    pub fn reconstruct_root(&self, leaf: [u8; 32]) -> Result<[u8; 32]> {
197        // Step 1.
198        if self.leaf_index >= self.tree_size {
199            return Err(Error::Receipt(
200                "inclusion proof leaf_index is not less than tree_size",
201            ));
202        }
203        // Step 2.
204        let mut node_index = self.leaf_index;
205        let mut last_index = self.tree_size - 1;
206        // Step 3.
207        let mut root = leaf;
208        // Step 4.
209        for sibling in &self.inclusion_path {
210            // Step 4a.
211            if last_index == 0 {
212                return Err(Error::Receipt(
213                    "inclusion proof path is longer than the tree permits",
214                ));
215            }
216            // Step 4b.
217            if node_index & 1 == 1 || node_index == last_index {
218                root = node_hash(sibling, &root);
219                // Step 4b.ii.
220                if node_index & 1 == 0 {
221                    loop {
222                        node_index >>= 1;
223                        last_index >>= 1;
224                        if node_index & 1 == 1 || node_index == 0 {
225                            break;
226                        }
227                    }
228                }
229            } else {
230                root = node_hash(&root, sibling);
231            }
232            // Step 4c.
233            node_index >>= 1;
234            last_index >>= 1;
235        }
236        // Step 5.
237        if last_index != 0 {
238            return Err(Error::Receipt(
239                "inclusion proof path ended before the root was reached",
240            ));
241        }
242        Ok(root)
243    }
244}
245
246/// The `receipts` (394) header of a Signed Statement, as read.
247///
248/// The two states are kept apart on purpose. A statement that carries no
249/// `receipts` header is unregistered as far as the presented bytes can show. A
250/// statement whose header is present but unreadable is a different finding,
251/// and a relying party that treats the second as the first has been told a
252/// tampered or truncated envelope was merely never registered.
253///
254/// There is deliberately no method returning a single boolean over the set,
255/// and no `is_conforming`. Which attached Receipts count is a function of which
256/// Transparency Services the relying party trusts, and this type does not know
257/// that.
258///
259/// Both states describe a header that *was read*. This type is only ever
260/// produced by [`attached_receipts`], so it cannot represent "the envelope was
261/// never examined". A caller that has not called [`attached_receipts`] holds no
262/// value of this type at all, which is the distinction giskard09's
263/// `negotiation_linkage` invariant draws with an explicit `None`: never report
264/// absence you did not look for.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum AttachedReceipts {
267    /// The statement carried no `receipts` header.
268    Absent,
269    /// The statement carried a `receipts` header that could not be read as an
270    /// array of Receipts. Carries the reason.
271    Malformed(&'static str),
272    /// One or more attached Receipts, in the priority order presented.
273    Present(Vec<Vec<u8>>),
274}
275
276impl AttachedReceipts {
277    /// The attached Receipts, or an empty slice in the `Absent` and
278    /// `Malformed` cases.
279    ///
280    /// Callers deciding conformance must match on the variant rather than
281    /// reach for this, because an empty slice here does not distinguish a
282    /// statement that carried nothing from one whose header was unreadable.
283    #[must_use]
284    pub fn as_slice(&self) -> &[Vec<u8>] {
285        match self {
286            Self::Present(receipts) => receipts,
287            Self::Absent | Self::Malformed(_) => &[],
288        }
289    }
290}
291
292/// Reads the `receipts` (394) header from a `COSE_Sign1` Signed Statement.
293///
294/// The header may appear in either the protected or the unprotected map per
295/// RFC 9942 Section 5.1. This reads the unprotected map, where
296/// `-02` "SCITT registration and Receipt attachment" places it, or the protected map. Placement in
297/// the unprotected map is what allows a Receipt to be attached after signing
298/// without invalidating the Issuer's signature.
299///
300/// This parses the envelope structurally and does not verify the Issuer
301/// signature. Reading a header is not accepting a statement, and the two steps
302/// are kept separate so that neither can be mistaken for the other.
303/// Tag-18 transmitted statements and legacy untagged statements are accepted.
304/// Header maps must be well formed, with unique integer/text labels and no
305/// label shared between protected and unprotected maps. Ambiguous headers are
306/// rejected, never resolved by selecting the first occurrence.
307///
308/// # Errors
309///
310/// Returns [`Error::Cose`] if the outer bytes are not a `COSE_Sign1`. A
311/// well-formed envelope whose `receipts` header is itself unusable yields
312/// `Ok(`[`AttachedReceipts::Malformed`]`)` rather than an error, because that
313/// distinction is a finding to report to a relying party rather than a parse
314/// failure.
315pub fn attached_receipts(statement: &[u8]) -> Result<AttachedReceipts> {
316    let mut cursor = statement;
317    let value: Value = coset::cbor::de::from_reader(&mut cursor)
318        .map_err(|_| Error::Cose("failed to parse COSE_Sign1 CBOR"))?;
319    if !cursor.is_empty() {
320        return Err(Error::Cose("trailing bytes after COSE_Sign1"));
321    }
322    let items = cose_sign1_items(&value).ok_or(Error::Cose("COSE_Sign1 must be an array"))?;
323    let [
324        Value::Bytes(protected),
325        Value::Map(unprotected),
326        Value::Bytes(_),
327        Value::Bytes(_),
328    ] = items
329    else {
330        return Err(Error::Cose(
331            "COSE_Sign1 must carry protected bytes, unprotected map, payload bytes, signature bytes",
332        ));
333    };
334    let header = if protected.is_empty() {
335        Value::Map(Vec::new())
336    } else {
337        let mut cursor = protected.as_slice();
338        let header: Value = coset::cbor::de::from_reader(&mut cursor)
339            .map_err(|_| Error::Cose("protected header is not valid CBOR"))?;
340        if !cursor.is_empty() {
341            return Err(Error::Cose("trailing bytes in protected header"));
342        }
343        header
344    };
345    let Value::Map(protected) = &header else {
346        return Err(Error::Cose("protected header must encode a map"));
347    };
348    for map in [protected, unprotected] {
349        for (index, (key, _)) in map.iter().enumerate() {
350            if !matches!(key, Value::Integer(_) | Value::Text(_)) {
351                return Err(Error::Cose("COSE header labels must be integers or text"));
352            }
353            if map[..index].iter().any(|(other, _)| key == other) {
354                return Err(Error::Cose("duplicate COSE header label"));
355            }
356        }
357    }
358    if protected
359        .iter()
360        .any(|(key, _)| unprotected.iter().any(|(other, _)| key == other))
361    {
362        return Err(Error::Cose(
363            "header label occurs in both protected and unprotected maps",
364        ));
365    }
366
367    if let Some((_, found)) = unprotected
368        .iter()
369        .chain(protected)
370        .find(|(key, _)| signed(key) == Some(RECEIPTS_LABEL))
371    {
372        return Ok(read_receipts_array(found));
373    }
374    Ok(AttachedReceipts::Absent)
375}
376
377fn read_receipts_array(value: &Value) -> AttachedReceipts {
378    let Value::Array(items) = value else {
379        return AttachedReceipts::Malformed("receipts header is not an array");
380    };
381    if items.is_empty() {
382        return AttachedReceipts::Malformed("receipts header is an empty array");
383    }
384    let mut receipts = Vec::with_capacity(items.len());
385    for item in items {
386        let encoded = match item {
387            // RFC 9942 Section 4.3 / RFC 9943 Section 7: attached receipts
388            // are byte strings containing encoded Receipt objects.
389            // Extract the byte-string contents directly and preserve them
390            // for later validation.
391            Value::Bytes(b) => b.clone(),
392            // Compatibility: accept a bare COSE_Sign1 array or a tag-18
393            // tagged COSE_Sign1 and re-serialize it to bytes. This path
394            // exists for historical pask-ts-client output. The repaired
395            // sender emits only byte strings and refuses to append to these
396            // legacy arrays. This read-only compatibility path is not the
397            // RFC-prescribed wire form or a byte-preservation guarantee.
398            Value::Array(_) | Value::Tag(18, _) => {
399                let Some(
400                    [
401                        Value::Bytes(_),
402                        Value::Map(_),
403                        Value::Bytes(_) | Value::Null,
404                        Value::Bytes(_),
405                    ],
406                ) = cose_sign1_items(item)
407                else {
408                    return AttachedReceipts::Malformed(
409                        "a legacy receipts element is not a COSE_Sign1 container",
410                    );
411                };
412                let mut buf = Vec::new();
413                if coset::cbor::ser::into_writer(item, &mut buf).is_err() {
414                    return AttachedReceipts::Malformed(
415                        "a receipts element could not be re-encoded",
416                    );
417                }
418                buf
419            }
420            // Any other CBOR type (scalar, map, tag other than 18) is not a
421            // valid receipt representation.
422            _ => {
423                return AttachedReceipts::Malformed(
424                    "a receipts element is not a byte string, array, or tag-18 value",
425                );
426            }
427        };
428        receipts.push(encoded);
429    }
430    AttachedReceipts::Present(receipts)
431}
432
433/// A decoded attached Receipt, before its signature has been checked.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct Receipt {
436    /// The Verifiable Data Structure identifier from the protected header.
437    pub vds: i64,
438    /// The inclusion proofs from the `vdp` map, in the order presented.
439    pub inclusion_proofs: Vec<InclusionProof>,
440    /// The payload, absent when detached.
441    pub payload: Option<Vec<u8>>,
442    protected_raw: Vec<u8>,
443    signature: Vec<u8>,
444}
445
446impl Receipt {
447    /// Decodes an attached Receipt from its `COSE_Sign1` bytes.
448    ///
449    /// Accepts both the CBOR-tagged form (`#6.18`, RFC 9942 Section 5.2) and
450    /// the untagged array, because a Receipt read out of a `receipts` array has
451    /// already been located by position and the tag carries no information the
452    /// reader lacks at that point.
453    ///
454    /// # Errors
455    ///
456    /// Returns [`Error::Receipt`] if the bytes are not a four-element
457    /// `COSE_Sign1`, if the protected header is absent or does not carry an
458    /// integer `vds`, or if no inclusion proof can be read from the `vdp` map.
459    /// Duplicate keys (including nested claims/proof maps and unknown labels)
460    /// and trailing protected CBOR are rejected before header lookup. This
461    /// generic parser remains a compatibility/crypto primitive, not the tagged
462    /// text-claim inspection API [`crate::inspect_scitt_receipt`].
463    pub fn from_cose_sign1(receipt: &[u8]) -> Result<Self> {
464        let mut cursor = receipt;
465        let value: Value = coset::cbor::de::from_reader(&mut cursor)
466            .map_err(|_| Error::Receipt("receipt is not valid CBOR"))?;
467        if !cursor.is_empty() {
468            return Err(Error::Receipt("trailing bytes after receipt"));
469        }
470        crate::receipt_inspection::check_unique_maps(&value).map_err(Error::Receipt)?;
471        let items =
472            cose_sign1_items(&value).ok_or(Error::Receipt("receipt must be a COSE_Sign1 array"))?;
473        let [protected, unprotected, payload, signature] = items else {
474            return Err(Error::Receipt("receipt must carry exactly four elements"));
475        };
476        let Value::Bytes(protected_raw) = protected else {
477            return Err(Error::Receipt("receipt protected header must be bytes"));
478        };
479        let Value::Bytes(signature) = signature else {
480            return Err(Error::Receipt("receipt signature must be bytes"));
481        };
482
483        let mut cursor = protected_raw.as_slice();
484        let header: Value = coset::cbor::de::from_reader(&mut cursor)
485            .map_err(|_| Error::Receipt("receipt protected header is not valid CBOR"))?;
486        if !cursor.is_empty() {
487            return Err(Error::Receipt("trailing bytes in receipt protected header"));
488        }
489        crate::receipt_inspection::check_unique_maps(&header).map_err(Error::Receipt)?;
490        let Value::Map(_) = &header else {
491            return Err(Error::Receipt("receipt protected header must encode a map"));
492        };
493        let Value::Map(_) = unprotected else {
494            return Err(Error::Receipt("receipt unprotected header must be a map"));
495        };
496        let vds = map_entry(&header, VDS_LABEL)
497            .and_then(signed)
498            .ok_or(Error::Receipt("receipt protected header must carry vds"))?;
499
500        let vdp = map_entry(unprotected, VDP_LABEL)
501            .ok_or(Error::Receipt("receipt unprotected header must carry vdp"))?;
502        let proofs = map_entry(vdp, INCLUSION_PROOF_LABEL)
503            .ok_or(Error::Receipt("vdp map must carry an inclusion proof"))?;
504        let Value::Array(proofs) = proofs else {
505            return Err(Error::Receipt("inclusion proofs must be an array"));
506        };
507        if proofs.is_empty() {
508            return Err(Error::Receipt("inclusion proofs must not be empty"));
509        }
510        let mut inclusion_proofs = Vec::with_capacity(proofs.len());
511        for proof in proofs {
512            let Value::Bytes(wrapped) = proof else {
513                return Err(Error::Receipt("each inclusion proof must be a byte string"));
514            };
515            inclusion_proofs.push(InclusionProof::from_wrapped_cbor(wrapped)?);
516        }
517
518        let payload = match payload {
519            Value::Bytes(bytes) => Some(bytes.clone()),
520            Value::Null => None,
521            _ => return Err(Error::Receipt("receipt payload must be bytes or null")),
522        };
523
524        Ok(Self {
525            vds,
526            inclusion_proofs,
527            payload,
528            protected_raw: protected_raw.clone(),
529            signature: signature.clone(),
530        })
531    }
532
533    /// The `Sig_structure` bytes this Receipt's signature covers, for a given root.
534    ///
535    /// When the payload is attached, `root` must equal it; the caller is
536    /// expected to have checked that already. When it is detached, the
537    /// reconstructed root supplies the payload, which is the mechanism RFC 9942
538    /// Section 5.2 describes.
539    fn signed_bytes(&self, root: &[u8]) -> Result<Vec<u8>> {
540        let structure = Value::Array(vec![
541            Value::Text("Signature1".into()),
542            Value::Bytes(self.protected_raw.clone()),
543            Value::Bytes(Vec::new()),
544            Value::Bytes(root.to_vec()),
545        ]);
546        let mut encoded = Vec::new();
547        coset::cbor::ser::into_writer(&structure, &mut encoded)
548            .map_err(|_| Error::Receipt("failed to encode Sig_structure"))?;
549        Ok(encoded)
550    }
551}
552
553/// What an attached Receipt was found to prove.
554///
555/// Holding one of these means: an inclusion proof reconstructed
556/// [`root`](Self::root) over the entry bytes supplied, and the Receipt's
557/// signature over that root verified under the Transparency Service key
558/// supplied. It does not mean the statement is conforming, because that turns
559/// on whether the relying party trusts the Transparency Service holding that
560/// key.
561#[derive(Debug, Clone, PartialEq, Eq)]
562pub struct VerifiedInclusion {
563    /// The Merkle root the proof reconstructed and the signature covers.
564    pub root: [u8; 32],
565    /// Size of the tree the proof was taken against.
566    pub tree_size: u64,
567    /// Index of the proven leaf.
568    pub leaf_index: u64,
569}
570
571/// Verifies an attached Receipt's inclusion proof and Ed25519 signature, offline.
572///
573/// This is the two-step algorithm of RFC 9942 Section 5.2, in order:
574///
575/// 1. Apply the inclusion proof to the leaf hash of `entry` to reconstruct a
576///    Merkle root. When the Receipt carries an attached payload, that payload
577///    MUST equal the reconstructed root; a mismatch fails here rather than
578///    being deferred to the signature check, so the failure names the proof
579///    rather than the key.
580/// 2. Verify the Receipt's `COSE_Sign1` signature over that root under
581///    `transparency_service_key`.
582///
583/// The first inclusion proof that satisfies both steps is returned. No network
584/// access occurs: everything checked is either in `receipt`, in `entry`, or in
585/// the key, all of which the caller already holds.
586///
587/// On `entry`: see the module documentation. The profile does not specify what
588/// a SCITT log entry is for a Physical-Site Engagement Receipt, so the caller
589/// supplies the bytes rather than this function guessing them.
590///
591/// # Errors
592///
593/// Returns [`Error::Receipt`] if the Receipt cannot be decoded, if its `vds`
594/// is not [`RFC9162_SHA256`], or if no inclusion proof both reconstructs a
595/// root and carries a signature verifying under the supplied key.
596pub fn verify_inclusion(
597    receipt: &[u8],
598    entry: &[u8],
599    transparency_service_key: &ed25519_dalek::VerifyingKey,
600) -> Result<VerifiedInclusion> {
601    use ed25519_dalek::Verifier;
602
603    let receipt = Receipt::from_cose_sign1(receipt)?;
604    if receipt.vds != RFC9162_SHA256 {
605        return Err(Error::Receipt(
606            "unsupported verifiable data structure; only RFC9162_SHA256 is implemented",
607        ));
608    }
609    let signature =
610        ed25519_dalek::Signature::from_slice(&receipt.signature).map_err(|_| Error::Signature)?;
611    let leaf = leaf_hash(entry);
612
613    let mut last = Error::Receipt("receipt carried no usable inclusion proof");
614    for proof in &receipt.inclusion_proofs {
615        let root = match proof.reconstruct_root(leaf) {
616            Ok(root) => root,
617            Err(error) => {
618                last = error;
619                continue;
620            }
621        };
622        if let Some(attached) = &receipt.payload
623            && attached.as_slice() != root.as_slice()
624        {
625            last =
626                Error::Receipt("reconstructed root does not match the receipt's attached payload");
627            continue;
628        }
629        let signed = receipt.signed_bytes(&root)?;
630        if transparency_service_key
631            .verify(&signed, &signature)
632            .is_err()
633        {
634            last = Error::Signature;
635            continue;
636        }
637        return Ok(VerifiedInclusion {
638            root,
639            tree_size: proof.tree_size,
640            leaf_index: proof.leaf_index,
641        });
642    }
643    Err(last)
644}
645
646/// Returns the four `COSE_Sign1` elements, unwrapping a `#6.18` tag if present.
647fn cose_sign1_items(value: &Value) -> Option<&[Value]> {
648    let value = match value {
649        Value::Tag(18, inner) => inner.as_ref(),
650        other => other,
651    };
652    match value {
653        Value::Array(items) => Some(items.as_slice()),
654        _ => None,
655    }
656}
657
658/// Looks up an integer-labelled entry in a CBOR map.
659fn map_entry(value: &Value, label: i64) -> Option<&Value> {
660    let Value::Map(entries) = value else {
661        return None;
662    };
663    entries
664        .iter()
665        .find(|(key, _)| signed(key) == Some(label))
666        .map(|(_, found)| found)
667}
668
669/// Reads a CBOR integer as `i64`.
670fn signed(value: &Value) -> Option<i64> {
671    match value {
672        Value::Integer(integer) => i128::from(*integer).try_into().ok(),
673        _ => None,
674    }
675}
676
677/// Reads a CBOR unsigned integer as `u64`.
678fn unsigned(value: &Value) -> Option<u64> {
679    match value {
680        Value::Integer(integer) => i128::from(*integer).try_into().ok(),
681        _ => None,
682    }
683}