Skip to main content

h33_substrate_verifier/
receipt.rs

1//! The 42-byte `CompactReceipt` wire format.
2//!
3//! Layout (v1):
4//!
5//! ```text
6//! Offset  Size  Field                Description
7//! ──────  ────  ─────                ───────────
8//! 0       1     version              Always 0x01 for v1.
9//! 1       32    verification_hash    SHA3-256("h33:pq3:v1:" ||
10//!                                              signing_message ||
11//!                                              dil_pk || fal_pk || sph_pk ||
12//!                                              dil_sig || fal_sig || sph_sig)
13//! 33      8     verified_at_ms       Milliseconds since Unix epoch, big-endian.
14//! 41      1     algorithm_flags      Bit flags: 0x01=Dilithium 0x02=FALCON 0x04=SPHINCS+
15//! ──────  ────
16//! Total:  42 bytes
17//! ```
18
19use crate::error::VerifierError;
20
21/// Total size of a v1 `CompactReceipt` in bytes.
22pub const RECEIPT_SIZE: usize = 42;
23
24/// Schema version byte for v1 receipts.
25pub const RECEIPT_VERSION: u8 = 0x01;
26
27/// Offset of the `verification_hash` field.
28pub const VERIFICATION_HASH_OFFSET: usize = 1;
29
30/// Size of the `verification_hash` field.
31pub const VERIFICATION_HASH_SIZE: usize = 32;
32
33/// Offset of the `verified_at_ms` field.
34pub const VERIFIED_AT_OFFSET: usize = 33;
35
36/// Size of the `verified_at_ms` field.
37pub const VERIFIED_AT_SIZE: usize = 8;
38
39/// Offset of the `algorithm_flags` byte.
40pub const ALGORITHM_FLAGS_OFFSET: usize = 41;
41
42/// Bit flag indicating Dilithium (ML-DSA-65) was verified.
43pub const ALG_DILITHIUM: u8 = 0b0000_0001;
44
45/// Bit flag indicating FALCON-512 was verified.
46pub const ALG_FALCON: u8 = 0b0000_0010;
47
48/// Bit flag indicating SPHINCS+-SHA2-128f was verified.
49pub const ALG_SPHINCS: u8 = 0b0000_0100;
50
51/// Bitmask of every algorithm flag the current verifier build
52/// understands. Any bits set outside this mask will trigger
53/// [`VerifierError::UnknownAlgorithmBits`].
54pub const ALG_KNOWN_MASK: u8 = ALG_DILITHIUM | ALG_FALCON | ALG_SPHINCS;
55
56/// Bit flag for the "all three" case, returned by
57/// [`AlgorithmFlags::all_three`].
58pub const ALG_ALL_THREE: u8 = ALG_DILITHIUM | ALG_FALCON | ALG_SPHINCS;
59
60/// Typed view of the algorithm flags byte from a decoded
61/// [`CompactReceipt`].
62///
63/// # Examples
64///
65/// ```
66/// use h33_substrate_verifier::AlgorithmFlags;
67///
68/// let flags = AlgorithmFlags::from_byte(0b0000_0111);
69/// assert!(flags.has_dilithium());
70/// assert!(flags.has_falcon());
71/// assert!(flags.has_sphincs());
72/// assert_eq!(flags.count(), 3);
73/// ```
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct AlgorithmFlags(u8);
76
77impl AlgorithmFlags {
78    /// The "all three families present" bit set.
79    #[must_use]
80    pub const fn all_three() -> Self {
81        Self(ALG_ALL_THREE)
82    }
83
84    /// Construct from a raw byte. Does not validate that the bits are
85    /// recognized — use
86    /// [`validated_from_byte`](Self::validated_from_byte) for that.
87    #[must_use]
88    pub const fn from_byte(byte: u8) -> Self {
89        Self(byte)
90    }
91
92    /// Construct from a raw byte, returning an error if unknown bits
93    /// are set.
94    pub const fn validated_from_byte(byte: u8) -> Result<Self, VerifierError> {
95        if byte & !ALG_KNOWN_MASK != 0 {
96            return Err(VerifierError::UnknownAlgorithmBits { flags: byte });
97        }
98        Ok(Self(byte))
99    }
100
101    /// Raw byte value.
102    #[must_use]
103    pub const fn as_byte(self) -> u8 {
104        self.0
105    }
106
107    /// Does the flag set include Dilithium?
108    #[must_use]
109    pub const fn has_dilithium(self) -> bool {
110        self.0 & ALG_DILITHIUM != 0
111    }
112
113    /// Does the flag set include FALCON?
114    #[must_use]
115    pub const fn has_falcon(self) -> bool {
116        self.0 & ALG_FALCON != 0
117    }
118
119    /// Does the flag set include SPHINCS+?
120    #[must_use]
121    pub const fn has_sphincs(self) -> bool {
122        self.0 & ALG_SPHINCS != 0
123    }
124
125    /// Count how many families are present.
126    #[must_use]
127    pub const fn count(self) -> u32 {
128        self.0.count_ones()
129    }
130}
131
132/// A decoded 42-byte `CompactReceipt`.
133///
134/// Construct one from the `X-H33-Receipt` header bytes with
135/// [`Self::from_bytes`] or from the hex-encoded header value with
136/// [`Self::from_hex`].
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct CompactReceipt {
139    verification_hash: [u8; VERIFICATION_HASH_SIZE],
140    verified_at_ms: u64,
141    flags: AlgorithmFlags,
142}
143
144impl CompactReceipt {
145    /// Parse a receipt from exactly 42 bytes.
146    ///
147    /// Returns [`VerifierError::InvalidReceiptSize`] if the slice is the
148    /// wrong length, and [`VerifierError::UnsupportedReceiptVersion`]
149    /// if the first byte is not [`RECEIPT_VERSION`].
150    pub fn from_bytes(bytes: &[u8]) -> Result<Self, VerifierError> {
151        if bytes.len() != RECEIPT_SIZE {
152            return Err(VerifierError::InvalidReceiptSize {
153                actual: bytes.len(),
154                expected: RECEIPT_SIZE,
155            });
156        }
157        // Safe because we just checked the length.
158        let version = bytes.first().copied().unwrap_or(0);
159        if version != RECEIPT_VERSION {
160            return Err(VerifierError::UnsupportedReceiptVersion {
161                actual: version,
162                expected: RECEIPT_VERSION,
163            });
164        }
165
166        let mut verification_hash = [0u8; VERIFICATION_HASH_SIZE];
167        let hash_end = VERIFICATION_HASH_OFFSET + VERIFICATION_HASH_SIZE;
168        let hash_slice = bytes
169            .get(VERIFICATION_HASH_OFFSET..hash_end)
170            .ok_or(VerifierError::InvalidReceiptSize {
171                actual: bytes.len(),
172                expected: RECEIPT_SIZE,
173            })?;
174        verification_hash.copy_from_slice(hash_slice);
175
176        let mut ts_bytes = [0u8; VERIFIED_AT_SIZE];
177        let ts_end = VERIFIED_AT_OFFSET + VERIFIED_AT_SIZE;
178        let ts_slice = bytes
179            .get(VERIFIED_AT_OFFSET..ts_end)
180            .ok_or(VerifierError::InvalidReceiptSize {
181                actual: bytes.len(),
182                expected: RECEIPT_SIZE,
183            })?;
184        ts_bytes.copy_from_slice(ts_slice);
185        let verified_at_ms = u64::from_be_bytes(ts_bytes);
186
187        let flags_byte = bytes
188            .get(ALGORITHM_FLAGS_OFFSET)
189            .copied()
190            .ok_or(VerifierError::InvalidReceiptSize {
191                actual: bytes.len(),
192                expected: RECEIPT_SIZE,
193            })?;
194        let flags = AlgorithmFlags::validated_from_byte(flags_byte)?;
195
196        Ok(Self {
197            verification_hash,
198            verified_at_ms,
199            flags,
200        })
201    }
202
203    /// Parse a receipt from the hex string used in the `X-H33-Receipt`
204    /// HTTP header. The input must be exactly 84 hex characters
205    /// (= 42 bytes).
206    pub fn from_hex(hex_str: &str) -> Result<Self, VerifierError> {
207        if hex_str.len() != RECEIPT_SIZE * 2 {
208            return Err(VerifierError::InvalidReceiptHeaderLength {
209                actual: hex_str.len(),
210            });
211        }
212        let bytes = hex::decode(hex_str).map_err(|e| {
213            VerifierError::InvalidReceiptHeaderHex(alloc::format!("{e}"))
214        })?;
215        Self::from_bytes(&bytes)
216    }
217
218    /// The 32-byte verification hash embedded in the receipt.
219    #[must_use]
220    pub const fn verification_hash(&self) -> &[u8; VERIFICATION_HASH_SIZE] {
221        &self.verification_hash
222    }
223
224    /// Unix timestamp in milliseconds when the receipt was issued.
225    #[must_use]
226    pub const fn verified_at_ms(&self) -> u64 {
227        self.verified_at_ms
228    }
229
230    /// Typed view of the algorithm flags byte.
231    #[must_use]
232    pub const fn flags(&self) -> AlgorithmFlags {
233        self.flags
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    /// Build a known-valid 42-byte receipt for tests.
242    fn fixture_bytes() -> [u8; RECEIPT_SIZE] {
243        let mut bytes = [0u8; RECEIPT_SIZE];
244        bytes[0] = RECEIPT_VERSION;
245        // verification_hash: 32 bytes of 0xAB
246        for b in &mut bytes[VERIFICATION_HASH_OFFSET..VERIFIED_AT_OFFSET] {
247            *b = 0xAB;
248        }
249        // verified_at_ms: 0x0000_0000_1234_5678 big-endian
250        bytes[VERIFIED_AT_OFFSET..VERIFIED_AT_OFFSET + VERIFIED_AT_SIZE]
251            .copy_from_slice(&0x1234_5678_u64.to_be_bytes());
252        // flags: all three
253        bytes[ALGORITHM_FLAGS_OFFSET] = ALG_ALL_THREE;
254        bytes
255    }
256
257    #[test]
258    fn parses_known_good_receipt() {
259        let receipt = CompactReceipt::from_bytes(&fixture_bytes()).unwrap();
260        assert_eq!(receipt.verified_at_ms(), 0x1234_5678);
261        assert!(receipt.flags().has_dilithium());
262        assert!(receipt.flags().has_falcon());
263        assert!(receipt.flags().has_sphincs());
264        assert_eq!(receipt.flags().count(), 3);
265        assert_eq!(receipt.verification_hash()[0], 0xAB);
266        assert_eq!(receipt.verification_hash()[31], 0xAB);
267    }
268
269    #[test]
270    fn rejects_wrong_size() {
271        let too_small = [0u8; RECEIPT_SIZE - 1];
272        assert!(matches!(
273            CompactReceipt::from_bytes(&too_small),
274            Err(VerifierError::InvalidReceiptSize { actual: 41, .. })
275        ));
276
277        let too_big = [0u8; RECEIPT_SIZE + 1];
278        assert!(matches!(
279            CompactReceipt::from_bytes(&too_big),
280            Err(VerifierError::InvalidReceiptSize { actual: 43, .. })
281        ));
282    }
283
284    #[test]
285    fn rejects_wrong_version() {
286        let mut bytes = fixture_bytes();
287        bytes[0] = 0x02;
288        assert!(matches!(
289            CompactReceipt::from_bytes(&bytes),
290            Err(VerifierError::UnsupportedReceiptVersion {
291                actual: 0x02,
292                expected: 0x01
293            })
294        ));
295    }
296
297    #[test]
298    fn rejects_unknown_algorithm_bits() {
299        let mut bytes = fixture_bytes();
300        bytes[ALGORITHM_FLAGS_OFFSET] = 0b0000_1111; // bit 3 is unknown
301        assert!(matches!(
302            CompactReceipt::from_bytes(&bytes),
303            Err(VerifierError::UnknownAlgorithmBits { flags: 0b0000_1111 })
304        ));
305    }
306
307    #[test]
308    fn accepts_partial_algorithm_sets() {
309        // Dilithium only
310        let mut bytes = fixture_bytes();
311        bytes[ALGORITHM_FLAGS_OFFSET] = ALG_DILITHIUM;
312        let receipt = CompactReceipt::from_bytes(&bytes).unwrap();
313        assert!(receipt.flags().has_dilithium());
314        assert!(!receipt.flags().has_falcon());
315        assert!(!receipt.flags().has_sphincs());
316        assert_eq!(receipt.flags().count(), 1);
317
318        // Dilithium + FALCON, no SPHINCS+
319        bytes[ALGORITHM_FLAGS_OFFSET] = ALG_DILITHIUM | ALG_FALCON;
320        let receipt = CompactReceipt::from_bytes(&bytes).unwrap();
321        assert_eq!(receipt.flags().count(), 2);
322    }
323
324    #[test]
325    fn parses_hex_from_header() {
326        let bytes = fixture_bytes();
327        let hex_str = hex::encode(bytes);
328        let receipt = CompactReceipt::from_hex(&hex_str).unwrap();
329        assert_eq!(receipt.verified_at_ms(), 0x1234_5678);
330    }
331
332    #[test]
333    fn rejects_wrong_hex_length() {
334        // 83 chars, not 84
335        let short = "ab".repeat(41) + "a";
336        assert!(matches!(
337            CompactReceipt::from_hex(&short),
338            Err(VerifierError::InvalidReceiptHeaderLength { actual: 83 })
339        ));
340    }
341
342    #[test]
343    fn rejects_invalid_hex_characters() {
344        // Right length, wrong charset
345        let bad = "z".repeat(RECEIPT_SIZE * 2);
346        assert!(matches!(
347            CompactReceipt::from_hex(&bad),
348            Err(VerifierError::InvalidReceiptHeaderHex(_))
349        ));
350    }
351}