Skip to main content

h33_substrate_verifier/
headers.rs

1//! Parse the four `X-H33-*` HTTP response headers.
2//!
3//! Every attested H33 API response carries:
4//!
5//! ```text
6//! X-H33-Substrate:     <64 hex chars — SHA3-256 of the response body>
7//! X-H33-Receipt:       <84 hex chars — 42-byte CompactReceipt>
8//! X-H33-Algorithms:    ML-DSA-65,FALCON-512,SPHINCS+-SHA2-128f
9//! X-H33-Substrate-Ts:  <ms timestamp>
10//! ```
11//!
12//! This module is the thin, pure-Rust parser for those four strings.
13//! It does NOT do any verification — see [`crate::verify`] for that.
14//!
15//! The `Headers` type borrows its input strings (`&'a str`), so it is
16//! zero-allocation on the happy path when the caller already has the
17//! raw header bytes in a buffer.
18
19use crate::error::VerifierError;
20use alloc::string::ToString;
21
22/// The canonical lowercase name of the `X-H33-Substrate` header.
23pub const HEADER_SUBSTRATE: &str = "x-h33-substrate";
24/// The canonical lowercase name of the `X-H33-Receipt` header.
25pub const HEADER_RECEIPT: &str = "x-h33-receipt";
26/// The canonical lowercase name of the `X-H33-Algorithms` header.
27pub const HEADER_ALGORITHMS: &str = "x-h33-algorithms";
28/// The canonical lowercase name of the `X-H33-Substrate-Ts` header.
29pub const HEADER_SUBSTRATE_TS: &str = "x-h33-substrate-ts";
30/// The canonical lowercase name of the per-request opt-out REQUEST header.
31pub const HEADER_ATTEST_OPT_OUT: &str = "x-h33-attest";
32
33/// Exact expected length of the `X-H33-Substrate` header value in hex
34/// characters (64 chars = 32 bytes).
35pub const SUBSTRATE_HEADER_HEX_LEN: usize = 64;
36
37/// Zero-allocation view of the four substrate headers.
38///
39/// Construct with [`Self::from_strs`] when you have already extracted
40/// the raw string values, or enable the `reqwest-support` feature to
41/// use [`Self::from_reqwest`] for one-line extraction from a
42/// `reqwest::Response`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct Headers<'a> {
45    /// Value of `X-H33-Substrate` — 64 lowercase hex chars.
46    pub substrate: &'a str,
47    /// Value of `X-H33-Receipt` — 84 lowercase hex chars.
48    pub receipt: &'a str,
49    /// Value of `X-H33-Algorithms` — comma-separated algorithm identifiers.
50    pub algorithms: &'a str,
51    /// Value of `X-H33-Substrate-Ts` — already parsed from string to u64.
52    pub timestamp_ms: u64,
53}
54
55impl<'a> Headers<'a> {
56    /// Construct from already-extracted string slices. This is the
57    /// `no_std`-friendly constructor that every other constructor
58    /// ultimately calls into.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use h33_substrate_verifier::Headers;
64    ///
65    /// let headers = Headers::from_strs(
66    ///     "f3a8b2c1deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
67    ///     "012e891fa4cafebabedeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\
68    ///      0000000012345678\
69    ///      07",
70    ///     "ML-DSA-65,FALCON-512,SPHINCS+-SHA2-128f",
71    ///     1_733_942_731_234,
72    /// );
73    /// assert_eq!(headers.substrate.len(), 64);
74    /// ```
75    #[must_use]
76    pub const fn from_strs(
77        substrate: &'a str,
78        receipt: &'a str,
79        algorithms: &'a str,
80        timestamp_ms: u64,
81    ) -> Self {
82        Self {
83            substrate,
84            receipt,
85            algorithms,
86            timestamp_ms,
87        }
88    }
89
90    /// Parse the hex-encoded `X-H33-Substrate` value into the raw 32-byte
91    /// SHA3-256 digest it represents. Runs length and hex-character
92    /// validation before decoding.
93    pub fn decode_substrate(&self) -> Result<[u8; 32], VerifierError> {
94        if self.substrate.len() != SUBSTRATE_HEADER_HEX_LEN {
95            return Err(VerifierError::InvalidSubstrateHeaderLength {
96                actual: self.substrate.len(),
97            });
98        }
99        let bytes = hex::decode(self.substrate)
100            .map_err(|e| VerifierError::InvalidSubstrateHeaderHex(e.to_string()))?;
101        let mut out = [0u8; 32];
102        if bytes.len() != 32 {
103            return Err(VerifierError::InvalidSubstrateHeaderLength {
104                actual: self.substrate.len(),
105            });
106        }
107        out.copy_from_slice(&bytes);
108        Ok(out)
109    }
110
111    /// Split `X-H33-Algorithms` into its comma-separated parts, trimming
112    /// whitespace. Returns an iterator that is lazy and zero-copy.
113    pub fn algorithm_identifiers(&self) -> impl Iterator<Item = &'a str> {
114        self.algorithms.split(',').map(str::trim).filter(|s| !s.is_empty())
115    }
116}
117
118/// Reqwest adapter — extracts all four headers from a
119/// [`reqwest::Response`] in one call. Returns `Err` if any of the
120/// required headers is missing or not valid UTF-8.
121#[cfg(feature = "reqwest-support")]
122pub fn headers_from_reqwest(
123    response: &reqwest::Response,
124) -> Result<OwnedHeaders, VerifierError> {
125    use alloc::string::String;
126
127    fn get(
128        response: &reqwest::Response,
129        name: &str,
130    ) -> Result<String, VerifierError> {
131        response
132            .headers()
133            .get(name)
134            .and_then(|v| v.to_str().ok())
135            .map(ToString::to_string)
136            .ok_or_else(|| {
137                VerifierError::PublicKeysParse(alloc::format!(
138                    "missing required header: {name}"
139                ))
140            })
141    }
142
143    let substrate = get(response, HEADER_SUBSTRATE)?;
144    let receipt = get(response, HEADER_RECEIPT)?;
145    let algorithms = get(response, HEADER_ALGORITHMS)?;
146    let ts_str = get(response, HEADER_SUBSTRATE_TS)?;
147    let timestamp_ms = ts_str.parse::<u64>().map_err(|e| {
148        VerifierError::PublicKeysParse(alloc::format!(
149            "X-H33-Substrate-Ts is not a valid u64: {e}"
150        ))
151    })?;
152
153    Ok(OwnedHeaders {
154        substrate,
155        receipt,
156        algorithms,
157        timestamp_ms,
158    })
159}
160
161/// Owned variant of [`Headers`] used by the reqwest adapter when the
162/// caller doesn't have its own string buffer to borrow from. Construct
163/// via [`headers_from_reqwest`] and call [`OwnedHeaders::borrow`] to
164/// get back a `Headers<'_>` view for the verifier.
165#[cfg(feature = "reqwest-support")]
166#[derive(Debug, Clone)]
167pub struct OwnedHeaders {
168    /// Owned value of `X-H33-Substrate`.
169    pub substrate: alloc::string::String,
170    /// Owned value of `X-H33-Receipt`.
171    pub receipt: alloc::string::String,
172    /// Owned value of `X-H33-Algorithms`.
173    pub algorithms: alloc::string::String,
174    /// Parsed value of `X-H33-Substrate-Ts`.
175    pub timestamp_ms: u64,
176}
177
178#[cfg(feature = "reqwest-support")]
179impl OwnedHeaders {
180    /// Borrow this owned-headers value as a zero-allocation
181    /// [`Headers`] view suitable for handing to the verifier.
182    #[must_use]
183    pub fn borrow(&self) -> Headers<'_> {
184        Headers::from_strs(
185            &self.substrate,
186            &self.receipt,
187            &self.algorithms,
188            self.timestamp_ms,
189        )
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn decode_substrate_round_trips() {
199        let hex_str = "f3a8b2c1deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
200        let h = Headers::from_strs(hex_str, "00", "ML-DSA-65", 0);
201        let decoded = h.decode_substrate().unwrap();
202        assert_eq!(decoded.len(), 32);
203        assert_eq!(decoded[0], 0xF3);
204        assert_eq!(decoded[1], 0xA8);
205    }
206
207    #[test]
208    fn decode_substrate_rejects_wrong_length() {
209        let h = Headers::from_strs("f3a8", "00", "ML-DSA-65", 0);
210        assert!(matches!(
211            h.decode_substrate(),
212            Err(VerifierError::InvalidSubstrateHeaderLength { actual: 4 })
213        ));
214    }
215
216    #[test]
217    fn decode_substrate_rejects_bad_hex() {
218        let bad = "z".repeat(SUBSTRATE_HEADER_HEX_LEN);
219        let h = Headers::from_strs(&bad, "00", "ML-DSA-65", 0);
220        assert!(matches!(
221            h.decode_substrate(),
222            Err(VerifierError::InvalidSubstrateHeaderHex(_))
223        ));
224    }
225
226    #[test]
227    fn algorithm_identifiers_splits_and_trims() {
228        let h = Headers::from_strs(
229            "",
230            "",
231            "ML-DSA-65, FALCON-512 , SPHINCS+-SHA2-128f",
232            0,
233        );
234        let ids: alloc::vec::Vec<&str> = h.algorithm_identifiers().collect();
235        assert_eq!(ids, ["ML-DSA-65", "FALCON-512", "SPHINCS+-SHA2-128f"]);
236    }
237
238    #[test]
239    fn algorithm_identifiers_drops_empty_segments() {
240        let h = Headers::from_strs("", "", "ML-DSA-65,,FALCON-512,", 0);
241        let ids: alloc::vec::Vec<&str> = h.algorithm_identifiers().collect();
242        assert_eq!(ids, ["ML-DSA-65", "FALCON-512"]);
243    }
244}