1use crate::{
30 error::VerifierError,
31 headers::Headers,
32 receipt::{AlgorithmFlags, CompactReceipt},
33};
34use alloc::string::ToString;
35use sha3::{Digest, Sha3_256};
36
37#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, PartialEq, Eq)]
52pub struct VerificationResult {
53 pub body_hash_matches: bool,
55 pub receipt_well_formed: bool,
58 pub algorithms_match_flags: bool,
62 pub timestamps_agree: bool,
65 pub flags_from_receipt: Option<AlgorithmFlags>,
69 pub computed_body_hash: [u8; 32],
72}
73
74impl VerificationResult {
75 #[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 #[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
103pub fn verify_structural(
110 body: &[u8],
111 headers: &Headers<'_>,
112) -> Result<VerificationResult, VerifierError> {
113 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 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 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 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#[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
180fn 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
206fn canonicalize_alg_id(raw: &str) -> Option<CanonicalAlg> {
224 let r = raw.trim();
225
226 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 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 if is_slh_dsa_identifier(r) || is_sphincs_plus_identifier(r) {
269 return Some(CanonicalAlg::Sphincs);
270 }
271
272 None
273}
274
275fn is_slh_dsa_identifier(r: &str) -> bool {
277 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
298fn 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#[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 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#[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 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 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 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 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 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 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 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", ] {
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 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 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 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}