Skip to main content

h33_substrate_verifier/
verify.rs

1//! The structural verification pipeline.
2//!
3//! This module is the core of the crate. Given a response body and the
4//! four `X-H33-*` headers, it runs four independent integrity checks
5//! and returns a [`VerificationResult`] that reports each check
6//! separately.
7//!
8//! No network calls. No async. No allocations on the hot path beyond
9//! the single hex decode of the receipt header. Safe to call from WASM,
10//! embedded, or any other constrained environment.
11//!
12//! ## The four checks
13//!
14//! 1. **Body binding** — `SHA3-256(body) == X-H33-Substrate`
15//! 2. **Receipt structure** — 42 bytes, version 0x01, known algorithm flags
16//! 3. **Algorithm agreement** — `X-H33-Algorithms` contains exactly the
17//!    family names that `CompactReceipt::flags()` reports
18//! 4. **Timestamp agreement** — `X-H33-Substrate-Ts == CompactReceipt::verified_at_ms()`
19//!
20//! `verify_structural` returns `Ok(VerificationResult)` whenever the
21//! inputs are parseable at all. A parseable-but-failing result still
22//! returns `Ok`; the caller inspects the per-field booleans on the
23//! result to decide pass/fail.
24//!
25//! The only reason `verify_structural` returns `Err` is when the
26//! inputs themselves are malformed past the point of being inspected
27//! — bad hex, wrong receipt version, impossible lengths, etc.
28
29use crate::{
30    error::VerifierError,
31    headers::Headers,
32    receipt::{AlgorithmFlags, CompactReceipt},
33};
34use alloc::string::ToString;
35use sha3::{Digest, Sha3_256};
36
37/// Structured verdict from running the four structural checks.
38///
39/// Every field is a boolean, and every field answers a distinct,
40/// independently-valuable question. [`Self::is_valid`] is simply
41/// the AND of all four; callers that want partial verdicts (e.g.
42/// "body is bound and the receipt is structurally valid, but the
43/// algorithms header was stripped") can read the fields directly.
44///
45/// The four booleans map one-to-one to the four independent
46/// integrity checks and deliberately do not collapse into a
47/// state-machine enum: a caller often wants to surface a specific
48/// check that failed in a log or UI, and an enum would flatten
49/// that information.
50#[allow(clippy::struct_excessive_bools)] // Four independent yes/no integrity checks — see the doc comment above.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct VerificationResult {
53    /// `true` if `SHA3-256(body) == X-H33-Substrate`.
54    pub body_hash_matches: bool,
55    /// `true` if `X-H33-Receipt` decoded to a v1 42-byte receipt with
56    /// recognized algorithm flags.
57    pub receipt_well_formed: bool,
58    /// `true` if the algorithm names listed in `X-H33-Algorithms`
59    /// correspond exactly (set-equal) to the algorithm flags inside
60    /// the receipt.
61    pub algorithms_match_flags: bool,
62    /// `true` if `X-H33-Substrate-Ts` equals the `verified_at_ms`
63    /// field inside the receipt.
64    pub timestamps_agree: bool,
65    /// Typed view of the algorithm flags byte from the decoded receipt.
66    /// Populated as soon as the receipt itself parses, even if the
67    /// other checks fail.
68    pub flags_from_receipt: Option<AlgorithmFlags>,
69    /// The 32-byte SHA3-256 hash this verifier computed over the body,
70    /// surfaced so callers can log it alongside pass/fail.
71    pub computed_body_hash: [u8; 32],
72}
73
74impl VerificationResult {
75    /// The overall verdict: `true` only when every structural check passes.
76    #[must_use]
77    pub const fn is_valid(&self) -> bool {
78        self.body_hash_matches
79            && self.receipt_well_formed
80            && self.algorithms_match_flags
81            && self.timestamps_agree
82    }
83
84    /// A short human-readable description of which check failed first.
85    /// Useful in CLI output and logs. Returns `"verified"` when every
86    /// check passed.
87    #[must_use]
88    pub const fn summary(&self) -> &'static str {
89        if !self.body_hash_matches {
90            "body hash mismatch — response body does not match X-H33-Substrate"
91        } else if !self.receipt_well_formed {
92            "receipt malformed — X-H33-Receipt failed structural parsing"
93        } else if !self.algorithms_match_flags {
94            "algorithm disagreement — X-H33-Algorithms does not match receipt flags"
95        } else if !self.timestamps_agree {
96            "timestamp disagreement — X-H33-Substrate-Ts does not match receipt verified_at_ms"
97        } else {
98            "verified"
99        }
100    }
101}
102
103/// Run the four structural integrity checks over a response body and
104/// its four substrate attestation headers.
105///
106/// See the module-level docs for what each check does. The function
107/// never panics and never allocates on the happy path beyond the one
108/// hex decode of the 84-char receipt header.
109pub fn verify_structural(
110    body: &[u8],
111    headers: &Headers<'_>,
112) -> Result<VerificationResult, VerifierError> {
113    // ── Check 1: body binding ────────────────────────────────────────
114    let mut hasher = Sha3_256::new();
115    hasher.update(body);
116    let computed_body_hash: [u8; 32] = hasher.finalize().into();
117
118    let claimed_body_hash = headers.decode_substrate()?;
119    let body_hash_matches = constant_time_eq(&computed_body_hash, &claimed_body_hash);
120
121    // ── Check 2: receipt structure ───────────────────────────────────
122    let receipt_result = CompactReceipt::from_hex(headers.receipt);
123    let (receipt_well_formed, flags_from_receipt, receipt_timestamp) =
124        receipt_result.as_ref().map_or((false, None, None), |r| {
125            (true, Some(r.flags()), Some(r.verified_at_ms()))
126        });
127
128    // ── Check 3: algorithm agreement ─────────────────────────────────
129    //
130    // We compare the set of algorithm identifiers named in the
131    // X-H33-Algorithms header against the set of bits set in the
132    // decoded receipt's flag byte. Both sets must be exactly equal —
133    // a header that claims more than the receipt means someone added
134    // algorithm names that the signer did not actually run, and a
135    // receipt that claims more than the header means the header was
136    // trimmed in transit.
137    let algorithms_match_flags = if let Some(flags) = flags_from_receipt {
138        let header_set = parse_algorithm_set(headers)?;
139        let receipt_set = AlgorithmSet::from_flags(flags);
140        header_set == receipt_set
141    } else {
142        false
143    };
144
145    // ── Check 4: timestamp agreement ─────────────────────────────────
146    let timestamps_agree = matches!(
147        receipt_timestamp,
148        Some(ts) if ts == headers.timestamp_ms
149    );
150
151    Ok(VerificationResult {
152        body_hash_matches,
153        receipt_well_formed,
154        algorithms_match_flags,
155        timestamps_agree,
156        flags_from_receipt,
157        computed_body_hash,
158    })
159}
160
161/// The three-family set we know how to talk about. Implemented as a
162/// small bit-packed struct for `Eq` and fast comparison.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164struct AlgorithmSet {
165    has_dilithium: bool,
166    has_falcon: bool,
167    has_sphincs: bool,
168}
169
170impl AlgorithmSet {
171    const fn from_flags(flags: AlgorithmFlags) -> Self {
172        Self {
173            has_dilithium: flags.has_dilithium(),
174            has_falcon: flags.has_falcon(),
175            has_sphincs: flags.has_sphincs(),
176        }
177    }
178}
179
180/// Parse the `X-H33-Algorithms` header into an `AlgorithmSet`. Returns
181/// [`VerifierError::UnknownAlgorithm`] if any identifier in the header
182/// is not one of the three families this verifier build knows.
183fn parse_algorithm_set(headers: &Headers<'_>) -> Result<AlgorithmSet, VerifierError> {
184    let mut set = AlgorithmSet {
185        has_dilithium: false,
186        has_falcon: false,
187        has_sphincs: false,
188    };
189    for raw in headers.algorithm_identifiers() {
190        match canonicalize_alg_id(raw) {
191            Some(CanonicalAlg::Dilithium) => set.has_dilithium = true,
192            Some(CanonicalAlg::Falcon) => set.has_falcon = true,
193            Some(CanonicalAlg::Sphincs) => set.has_sphincs = true,
194            None => return Err(VerifierError::UnknownAlgorithm(raw.to_string())),
195        }
196    }
197    Ok(set)
198}
199
200enum CanonicalAlg {
201    Dilithium,
202    Falcon,
203    Sphincs,
204}
205
206/// Canonicalize an algorithm identifier from the `X-H33-Algorithms`
207/// header into one of the three families this verifier understands.
208///
209/// We accept **every known future variant** of each family deliberately,
210/// not just the specific parameter set scif-backend runs today. The
211/// substrate on-wire algorithm flag is a per-family bit, not a
212/// per-variant bit, so any Dilithium variant (ML-DSA-44/65/87) maps to
213/// the same Dilithium bit; any FALCON variant (512/1024) maps to the
214/// same FALCON bit; any SPHINCS+/SLH-DSA variant (SHA2/SHAKE × 128/192/256
215/// × f/s × simple/robust) maps to the same SPHINCS+ bit.
216///
217/// This means a customer verifier built today keeps working when
218/// scif-backend upgrades to FALCON-1024 or SPHINCS+-SHA2-192f in the
219/// future — no verifier release needed. The specific variant in use is
220/// still pinned by the server's `X-H33-Algorithms` header; the verifier
221/// simply doesn't gatekeep on the parameter choice. Parameter tracking
222/// is the server's responsibility, not the verifier's.
223fn canonicalize_alg_id(raw: &str) -> Option<CanonicalAlg> {
224    let r = raw.trim();
225
226    // ── Dilithium family (MLWE lattice) ──────────────────────────────
227    // Every ML-DSA parameter set that exists or is plausible:
228    //   ML-DSA-44  (NIST Level 2, FIPS 204 minimum)
229    //   ML-DSA-65  (NIST Level 3, FIPS 204 recommended)
230    //   ML-DSA-87  (NIST Level 5, FIPS 204 maximum)
231    // Pre-FIPS historical name: Dilithium2/3/5
232    if r.eq_ignore_ascii_case("ML-DSA-44")
233        || r.eq_ignore_ascii_case("ML-DSA-65")
234        || r.eq_ignore_ascii_case("ML-DSA-87")
235        || r.eq_ignore_ascii_case("Dilithium2")
236        || r.eq_ignore_ascii_case("Dilithium3")
237        || r.eq_ignore_ascii_case("Dilithium5")
238    {
239        return Some(CanonicalAlg::Dilithium);
240    }
241
242    // ── FALCON family (NTRU lattice) ─────────────────────────────────
243    // The two standardized FALCON parameter sets:
244    //   FALCON-512   (NIST Level 1)
245    //   FALCON-1024  (NIST Level 5)
246    // Pre-FIPS historical name: FN-DSA-512 / FN-DSA-1024
247    if r.eq_ignore_ascii_case("FALCON-512")
248        || r.eq_ignore_ascii_case("FALCON-1024")
249        || r.eq_ignore_ascii_case("FN-DSA-512")
250        || r.eq_ignore_ascii_case("FN-DSA-1024")
251    {
252        return Some(CanonicalAlg::Falcon);
253    }
254
255    // ── SPHINCS+ / SLH-DSA family (stateless hash-based) ─────────────
256    // FIPS 205 SLH-DSA parameter set grid:
257    //   SHA2 × {128, 192, 256} × {f, s}  (6 variants)
258    //   SHAKE × {128, 192, 256} × {f, s} (6 variants)
259    // Pre-FIPS historical name: SPHINCS+-<hash>-<bits><speed>-<simple|robust>
260    // f = fast (bigger signatures, faster signing)
261    // s = small (smaller signatures, slower signing)
262    // simple = NIST-standardized; robust = legacy variant, still accepted
263    //
264    // A newer server may emit any of these; a verifier built today
265    // should honor all of them so the customer does not have to ship a
266    // new verifier when the server's parameter set is rotated for
267    // security-level upgrade reasons.
268    if is_slh_dsa_identifier(r) || is_sphincs_plus_identifier(r) {
269        return Some(CanonicalAlg::Sphincs);
270    }
271
272    None
273}
274
275/// Match any NIST FIPS 205 SLH-DSA identifier.
276fn is_slh_dsa_identifier(r: &str) -> bool {
277    // SLH-DSA-<hash>-<level><speed>
278    // hash    ∈ { SHA2, SHAKE }
279    // level   ∈ { 128, 192, 256 }
280    // speed   ∈ { f, s }
281    matches!(
282        r.to_ascii_uppercase().as_str(),
283        "SLH-DSA-SHA2-128F"
284            | "SLH-DSA-SHA2-128S"
285            | "SLH-DSA-SHA2-192F"
286            | "SLH-DSA-SHA2-192S"
287            | "SLH-DSA-SHA2-256F"
288            | "SLH-DSA-SHA2-256S"
289            | "SLH-DSA-SHAKE-128F"
290            | "SLH-DSA-SHAKE-128S"
291            | "SLH-DSA-SHAKE-192F"
292            | "SLH-DSA-SHAKE-192S"
293            | "SLH-DSA-SHAKE-256F"
294            | "SLH-DSA-SHAKE-256S"
295    )
296}
297
298/// Match any pre-FIPS SPHINCS+ identifier (with or without the
299/// `-simple` / `-robust` suffix).
300fn is_sphincs_plus_identifier(r: &str) -> bool {
301    let upper = r.to_ascii_uppercase();
302    let trimmed = upper
303        .strip_suffix("-SIMPLE")
304        .or_else(|| upper.strip_suffix("-ROBUST"))
305        .unwrap_or(&upper);
306    matches!(
307        trimmed,
308        "SPHINCS+-SHA2-128F"
309            | "SPHINCS+-SHA2-128S"
310            | "SPHINCS+-SHA2-192F"
311            | "SPHINCS+-SHA2-192S"
312            | "SPHINCS+-SHA2-256F"
313            | "SPHINCS+-SHA2-256S"
314            | "SPHINCS+-SHAKE-128F"
315            | "SPHINCS+-SHAKE-128S"
316            | "SPHINCS+-SHAKE-192F"
317            | "SPHINCS+-SHAKE-192S"
318            | "SPHINCS+-SHAKE-256F"
319            | "SPHINCS+-SHAKE-256S"
320    )
321}
322
323/// Constant-time byte slice equality for fixed 32-byte hashes.
324///
325/// We never compare hashes with a short-circuiting `==` because the
326/// timing channel reveals the longest common prefix of the computed
327/// hash and the claimed hash. The `subtle` crate would do this too,
328/// but pulling it in as a dependency for a single comparison doubles
329/// the WASM binary size. Hand-rolled is fine because the inputs are
330/// fixed-length.
331#[inline]
332#[must_use]
333fn constant_time_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
334    let mut diff: u8 = 0;
335    for i in 0..32 {
336        // SAFETY-adjacent: indexing is bounds-checked but the `i`
337        // values are compile-time constants, so the bounds check is
338        // trivially eliminated. Doing this by index rather than by
339        // iterator to keep the control flow branch-free.
340        let left = a.get(i).copied().unwrap_or(0);
341        let right = b.get(i).copied().unwrap_or(0);
342        diff |= left ^ right;
343    }
344    diff == 0
345}
346
347/// Known-answer vector — computed from the SPEC.md test vectors so the
348/// parser behaves identically to the signer. The vector is the
349/// SHA3-256 of the empty string, hex-encoded.
350#[cfg(test)]
351pub(crate) const KNOWN_SHA3_EMPTY: &str =
352    "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a";
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::receipt::{
358        ALG_ALL_THREE, ALG_DILITHIUM, ALG_FALCON, ALG_SPHINCS, RECEIPT_SIZE,
359        RECEIPT_VERSION,
360    };
361
362    /// Build a matching receipt for the given body and timestamp so we
363    /// can test the happy path without needing a real signer.
364    fn fabricate_receipt_hex(
365        _body_hash: [u8; 32],
366        verified_at_ms: u64,
367        flags: u8,
368    ) -> alloc::string::String {
369        let mut bytes = [0u8; RECEIPT_SIZE];
370        bytes[0] = RECEIPT_VERSION;
371        // verification_hash is 32 bytes of 0xCC — real value would be
372        // SHA3 of (domain || msg || pks || sigs) but the structural
373        // verifier doesn't check it.
374        for b in &mut bytes[1..33] {
375            *b = 0xCC;
376        }
377        bytes[33..41].copy_from_slice(&verified_at_ms.to_be_bytes());
378        bytes[41] = flags;
379        hex::encode(bytes)
380    }
381
382    fn sha3_of(body: &[u8]) -> [u8; 32] {
383        let mut h = Sha3_256::new();
384        h.update(body);
385        h.finalize().into()
386    }
387
388    #[test]
389    fn verifies_a_known_good_response() {
390        let body = b"{\"tenant\":\"abc\",\"plan\":\"premium\"}";
391        let body_hash = sha3_of(body);
392        let substrate_hex = hex::encode(body_hash);
393        let ts = 1_733_942_731_234_u64;
394        let receipt_hex = fabricate_receipt_hex(body_hash, ts, ALG_ALL_THREE);
395
396        let headers = Headers::from_strs(
397            &substrate_hex,
398            &receipt_hex,
399            "ML-DSA-65,FALCON-512,SPHINCS+-SHA2-128f",
400            ts,
401        );
402
403        let result = verify_structural(body, &headers).unwrap();
404        assert!(result.is_valid(), "expected valid, got: {}", result.summary());
405        assert!(result.body_hash_matches);
406        assert!(result.receipt_well_formed);
407        assert!(result.algorithms_match_flags);
408        assert!(result.timestamps_agree);
409    }
410
411    #[test]
412    fn detects_body_tampering() {
413        let body = b"original body";
414        let tampered = b"tampered body";
415        let original_hash = sha3_of(body);
416        let substrate_hex = hex::encode(original_hash);
417        let ts = 1_000;
418        let receipt_hex = fabricate_receipt_hex(original_hash, ts, ALG_ALL_THREE);
419
420        let headers = Headers::from_strs(
421            &substrate_hex,
422            &receipt_hex,
423            "ML-DSA-65,FALCON-512,SPHINCS+-SHA2-128f",
424            ts,
425        );
426
427        // Verifier is given the TAMPERED body but the ORIGINAL headers.
428        // Expected: body_hash_matches is false, overall is_valid is false.
429        let result = verify_structural(tampered, &headers).unwrap();
430        assert!(!result.body_hash_matches);
431        assert!(!result.is_valid());
432        assert!(result.summary().contains("body hash mismatch"));
433    }
434
435    #[test]
436    fn detects_algorithm_header_stripping() {
437        let body = b"body";
438        let hash = sha3_of(body);
439        let ts = 2_000;
440        let receipt_hex = fabricate_receipt_hex(hash, ts, ALG_ALL_THREE);
441
442        // Receipt claims all three; header claims only two.
443        let substrate_hex = hex::encode(hash);
444        let headers = Headers::from_strs(
445            &substrate_hex,
446            &receipt_hex,
447            "ML-DSA-65,FALCON-512",
448            ts,
449        );
450
451        let result = verify_structural(body, &headers).unwrap();
452        assert!(result.body_hash_matches);
453        assert!(result.receipt_well_formed);
454        assert!(!result.algorithms_match_flags);
455        assert!(!result.is_valid());
456    }
457
458    #[test]
459    fn detects_timestamp_disagreement() {
460        let body = b"body";
461        let hash = sha3_of(body);
462        let receipt_hex = fabricate_receipt_hex(hash, 3_000, ALG_ALL_THREE);
463
464        // Header claims a different timestamp than the receipt.
465        let substrate_hex = hex::encode(hash);
466        let headers = Headers::from_strs(
467            &substrate_hex,
468            &receipt_hex,
469            "ML-DSA-65,FALCON-512,SPHINCS+-SHA2-128f",
470            4_000,
471        );
472
473        let result = verify_structural(body, &headers).unwrap();
474        assert!(!result.timestamps_agree);
475        assert!(!result.is_valid());
476    }
477
478    #[test]
479    fn partial_algorithm_sets_verify_when_header_matches() {
480        let body = b"body";
481        let hash = sha3_of(body);
482        let ts = 5_000;
483        // Receipt is Dilithium + FALCON only (no SPHINCS+).
484        let receipt_hex =
485            fabricate_receipt_hex(hash, ts, ALG_DILITHIUM | ALG_FALCON);
486
487        let substrate_hex = hex::encode(hash);
488        let headers = Headers::from_strs(
489            &substrate_hex,
490            &receipt_hex,
491            "ML-DSA-65,FALCON-512",
492            ts,
493        );
494
495        let result = verify_structural(body, &headers).unwrap();
496        assert!(result.is_valid());
497        assert_eq!(result.flags_from_receipt.unwrap().count(), 2);
498    }
499
500    #[test]
501    fn unknown_algorithm_identifier_is_an_error() {
502        let body = b"body";
503        let hash = sha3_of(body);
504        let ts = 6_000;
505        let receipt_hex = fabricate_receipt_hex(hash, ts, ALG_DILITHIUM);
506
507        let substrate_hex = hex::encode(hash);
508        let headers = Headers::from_strs(
509            &substrate_hex,
510            &receipt_hex,
511            "QUANTUM-MAGIC-9000",
512            ts,
513        );
514
515        // Structural verify should return Err for a totally unknown
516        // algorithm identifier — this catches clients that upgraded
517        // their server before upgrading their verifier and would
518        // otherwise see a misleading "ok" verdict.
519        let result = verify_structural(body, &headers);
520        assert!(matches!(
521            result,
522            Err(VerifierError::UnknownAlgorithm(_))
523        ));
524    }
525
526    #[test]
527    fn historical_aliases_are_accepted() {
528        let body = b"body";
529        let hash = sha3_of(body);
530        let ts = 7_000;
531        let receipt_hex = fabricate_receipt_hex(hash, ts, ALG_ALL_THREE);
532
533        let substrate_hex = hex::encode(hash);
534        let headers = Headers::from_strs(
535            &substrate_hex,
536            &receipt_hex,
537            "Dilithium3, FN-DSA-512, SLH-DSA-SHA2-128f",
538            ts,
539        );
540
541        let result = verify_structural(body, &headers).unwrap();
542        assert!(result.is_valid());
543    }
544
545    #[test]
546    fn every_known_dilithium_variant_maps_to_the_dilithium_bit() {
547        for name in [
548            "ML-DSA-44",
549            "ML-DSA-65",
550            "ML-DSA-87",
551            "Dilithium2",
552            "Dilithium3",
553            "Dilithium5",
554            "ml-dsa-65", // case-insensitive
555        ] {
556            assert!(
557                matches!(canonicalize_alg_id(name), Some(CanonicalAlg::Dilithium)),
558                "identifier {name} should map to Dilithium"
559            );
560        }
561    }
562
563    #[test]
564    fn every_known_falcon_variant_maps_to_the_falcon_bit() {
565        for name in [
566            "FALCON-512",
567            "FALCON-1024",
568            "FN-DSA-512",
569            "FN-DSA-1024",
570            "falcon-512",
571            "fn-dsa-1024",
572        ] {
573            assert!(
574                matches!(canonicalize_alg_id(name), Some(CanonicalAlg::Falcon)),
575                "identifier {name} should map to FALCON"
576            );
577        }
578    }
579
580    #[test]
581    fn every_known_sphincs_plus_variant_maps_to_the_sphincs_bit() {
582        // Every FIPS 205 SLH-DSA identifier.
583        for name in [
584            "SLH-DSA-SHA2-128f",
585            "SLH-DSA-SHA2-128s",
586            "SLH-DSA-SHA2-192f",
587            "SLH-DSA-SHA2-192s",
588            "SLH-DSA-SHA2-256f",
589            "SLH-DSA-SHA2-256s",
590            "SLH-DSA-SHAKE-128f",
591            "SLH-DSA-SHAKE-128s",
592            "SLH-DSA-SHAKE-192f",
593            "SLH-DSA-SHAKE-192s",
594            "SLH-DSA-SHAKE-256f",
595            "SLH-DSA-SHAKE-256s",
596        ] {
597            assert!(
598                matches!(canonicalize_alg_id(name), Some(CanonicalAlg::Sphincs)),
599                "FIPS 205 identifier {name} should map to SPHINCS+"
600            );
601        }
602
603        // Every pre-FIPS SPHINCS+ name, bare and -simple / -robust suffixed.
604        for base in [
605            "SPHINCS+-SHA2-128f",
606            "SPHINCS+-SHA2-128s",
607            "SPHINCS+-SHA2-192f",
608            "SPHINCS+-SHA2-192s",
609            "SPHINCS+-SHA2-256f",
610            "SPHINCS+-SHA2-256s",
611            "SPHINCS+-SHAKE-128f",
612            "SPHINCS+-SHAKE-128s",
613            "SPHINCS+-SHAKE-192f",
614            "SPHINCS+-SHAKE-192s",
615            "SPHINCS+-SHAKE-256f",
616            "SPHINCS+-SHAKE-256s",
617        ] {
618            for suffix in ["", "-simple", "-robust"] {
619                let name = alloc::format!("{base}{suffix}");
620                assert!(
621                    matches!(canonicalize_alg_id(&name), Some(CanonicalAlg::Sphincs)),
622                    "SPHINCS+ identifier {name} should map to SPHINCS+"
623                );
624            }
625        }
626    }
627
628    #[test]
629    fn level3_upgrade_algorithm_bundle_still_verifies() {
630        // Scenario: scif-backend has been upgraded to FALCON-1024 +
631        // SPHINCS+-SHA2-192f. A customer verifier built BEFORE the
632        // upgrade must continue to verify responses from the upgraded
633        // server without a verifier release.
634        let body = b"body";
635        let hash = sha3_of(body);
636        let ts = 9_000;
637        let receipt_hex = fabricate_receipt_hex(hash, ts, ALG_ALL_THREE);
638
639        let substrate_hex = hex::encode(hash);
640        let headers = Headers::from_strs(
641            &substrate_hex,
642            &receipt_hex,
643            "ML-DSA-65, FALCON-1024, SLH-DSA-SHA2-192f",
644            ts,
645        );
646
647        let result = verify_structural(body, &headers).unwrap();
648        assert!(
649            result.is_valid(),
650            "Level 3 upgrade bundle should still verify: {}",
651            result.summary()
652        );
653    }
654
655    #[test]
656    fn sphincs_only_receipt_verifies_with_sphincs_only_header() {
657        let body = b"body";
658        let hash = sha3_of(body);
659        let ts = 8_000;
660        let receipt_hex = fabricate_receipt_hex(hash, ts, ALG_SPHINCS);
661
662        let substrate_hex = hex::encode(hash);
663        let headers = Headers::from_strs(
664            &substrate_hex,
665            &receipt_hex,
666            "SPHINCS+-SHA2-128f",
667            ts,
668        );
669
670        let result = verify_structural(body, &headers).unwrap();
671        assert!(result.is_valid());
672    }
673
674    #[test]
675    fn constant_time_eq_rejects_last_byte_difference() {
676        let mut a = [0u8; 32];
677        let mut b = [0u8; 32];
678        assert!(constant_time_eq(&a, &b));
679        b[31] = 1;
680        assert!(!constant_time_eq(&a, &b));
681        a[0] = 255;
682        assert!(!constant_time_eq(&a, &b));
683    }
684
685    #[test]
686    fn empty_body_computes_known_sha3() {
687        let body: &[u8] = b"";
688        let hash = sha3_of(body);
689        assert_eq!(hex::encode(hash), KNOWN_SHA3_EMPTY);
690    }
691
692}