pask_wire/entry.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//! Candidate-entry byte encoding for inclusion-proof verification.
7//!
8//! Derives the candidate entry from a presented Transparent Statement per the
9//! -04 candidate-entry design. The candidate entry is the untagged four-element
10//! CBOR array `[P, {}, M, S]` where P, M, and S are the original protected
11//! header, payload, and signature byte-string contents, and the unprotected
12//! header is replaced by an empty map.
13//!
14//! The derivation preserves P, M, and S by content. It does not parse and
15//! reserialize their contents. Only the outer array structure uses
16//! deterministic encoding per RFC 8949 Section 4.2.1.
17//!
18//! See `pask_-04_design_67_v3_consolidated.md` for the full design.
19
20use alloc::{vec, vec::Vec};
21
22use coset::cbor::Value;
23
24use crate::{Error, Result};
25
26/// The CBOR tag for a COSE_Sign1 structure (RFC 9052 Section 4.2).
27const COSE_SIGN1_TAG: u64 = 18;
28
29/// Derives the candidate entry from a presented Transparent Statement.
30///
31/// The input MUST be a COSE_Sign1 with four array elements: protected-header
32/// byte string, unprotected-header map, attached-payload byte string, and
33/// signature byte string. The payload MUST be present as a byte string; a
34/// null payload indicating detached content is not permitted. The input may
35/// be an untagged COSE_Sign1 or a COSE_Sign1 wrapped in tag 18.
36///
37/// The output is the deterministically encoded CBOR array `[P, {}, M, S]`
38/// where P, M, and S are the original byte-string contents and the
39/// unprotected header is replaced by an empty CBOR map (`0xa0`).
40///
41/// # Errors
42///
43/// Returns [`Error::Cose`] if the input is not valid CBOR, is not a
44/// four-element COSE_Sign1 array (with or without tag 18), the unprotected
45/// header is not a map, contains a null or detached payload, or has
46/// trailing bytes after the CBOR object.
47///
48/// # Derivation rules
49///
50/// - P, M, and S are preserved by content. Their internal bytes are not
51/// parsed or reserialized.
52/// - The outer array is definite-length with shortest definite-length
53/// byte-string encodings per RFC 8949 Section 4.2.1.
54/// - The unprotected header is replaced by an empty map. This means
55/// attaching, removing, or modifying receipts does not change the
56/// candidate entry.
57pub fn derive_candidate_entry(transparent_statement: &[u8]) -> Result<Vec<u8>> {
58 let mut cursor = transparent_statement;
59 let value: Value = coset::cbor::de::from_reader(&mut cursor)
60 .map_err(|_| Error::Cose("failed to parse Transparent Statement CBOR"))?;
61
62 if !cursor.is_empty() {
63 return Err(Error::Cose("trailing bytes after Transparent Statement"));
64 }
65
66 // Unwrap tag 18 if present; reject other tag wrappers.
67 let array_value = match value {
68 Value::Tag(tag, inner) => {
69 if tag != COSE_SIGN1_TAG {
70 return Err(Error::Cose(
71 "unsupported tag wrapper; only tag 18 is accepted",
72 ));
73 }
74 *inner
75 }
76 Value::Array(_) => value,
77 _ => {
78 return Err(Error::Cose(
79 "Transparent Statement must be a COSE_Sign1 array or tag-18 wrapper",
80 ));
81 }
82 };
83
84 let Value::Array(items) = array_value else {
85 return Err(Error::Cose(
86 "Transparent Statement must be a COSE_Sign1 array",
87 ));
88 };
89
90 if items.len() != 4 {
91 return Err(Error::Cose("COSE_Sign1 must have exactly 4 elements"));
92 }
93
94 // Extract P (protected header bytes)
95 let Value::Bytes(protected) = &items[0] else {
96 return Err(Error::Cose("protected header must be a byte string"));
97 };
98
99 // The unprotected header (items[1]) MUST be a CBOR map. Replacing its
100 // contents does not authorize repairing an invalid input type.
101 if !matches!(&items[1], Value::Map(_)) {
102 return Err(Error::Cose("unprotected header must be a map"));
103 }
104
105 // Extract M (payload bytes); reject null (detached payload)
106 let payload = match &items[2] {
107 Value::Bytes(bytes) => bytes.clone(),
108 Value::Null => {
109 return Err(Error::Cose(
110 "detached payload (null) is not permitted; payload must be present as a byte string",
111 ));
112 }
113 _ => return Err(Error::Cose("payload must be a byte string")),
114 };
115
116 // Extract S (signature bytes)
117 let Value::Bytes(signature) = &items[3] else {
118 return Err(Error::Cose("signature must be a byte string"));
119 };
120
121 // Construct candidate entry [P, {}, M, S] with deterministic encoding.
122 // The outer array uses RFC 8949 Section 4.2.1 core deterministic encoding.
123 // P, M, S are preserved by content — their bytes are not parsed or reserialized.
124 let candidate = Value::Array(vec![
125 Value::Bytes(protected.clone()),
126 Value::Map(vec![]),
127 Value::Bytes(payload),
128 Value::Bytes(signature.clone()),
129 ]);
130
131 let mut output = Vec::new();
132 coset::cbor::ser::into_writer(&candidate, &mut output)
133 .map_err(|_| Error::Cose("failed to serialize candidate entry"))?;
134
135 Ok(output)
136}
137
138/// Computes the leaf hash for a candidate entry using RFC 9162 Section 2.1.1.
139///
140/// `SHA256(0x00 || candidate_entry)`
141///
142/// This is a convenience wrapper around [`crate::leaf_hash`] for the
143/// candidate-entry derivation path.
144#[must_use]
145pub fn candidate_leaf_hash(candidate_entry: &[u8]) -> [u8; 32] {
146 crate::receipt::leaf_hash(candidate_entry)
147}