1use pqrascv_core::{
16 config::PolicyConfig,
17 crypto::{pub_key_id, CryptoBackend, MlDsaBackend, SIGNING_CONTEXT_QUOTE},
18 error::PqRascvError,
19 nonce::NonceLedger,
20 pki::revocation::VerifiedRevocationList,
21 pki::{
22 validate_chain, validate_chain_with_store, validate_hardware_identity, CertChain,
23 DeviceCertificate, TrustAnchor, TrustStore,
24 },
25 policy::{PolicyContext, PolicyEngineV2},
26 provenance_v2::{ExternalProvenanceBundle, SigstoreConfig, VerifiedProvenance},
27 quote::{AttestationQuote, Challenge, PROTOCOL_VERSION},
28};
29use subtle::ConstantTimeEq;
30
31#[derive(Debug)]
39pub struct VerificationResult {
40 pub quote: AttestationQuote,
42}
43
44impl VerificationResult {
45 #[must_use]
47 pub fn slsa_level(&self) -> u8 {
48 self.quote.body.provenance.slsa_level()
49 }
50
51 #[must_use]
53 pub fn firmware_hash(&self) -> &[u8; 32] {
54 &self.quote.body.measurements.firmware_hash
55 }
56
57 #[must_use]
59 pub fn nonce(&self) -> &[u8; 32] {
60 &self.quote.body.nonce
61 }
62}
63
64#[derive(Debug)]
73pub struct PkiVerificationResult {
74 pub quote: AttestationQuote,
75 pub cert_chain: CertChain,
76}
77
78impl PkiVerificationResult {
79 #[must_use]
80 pub fn slsa_level(&self) -> u8 {
81 self.quote.body.provenance.slsa_level()
82 }
83 #[must_use]
84 pub fn firmware_hash(&self) -> &[u8; 32] {
85 &self.quote.body.measurements.firmware_hash
86 }
87 #[must_use]
88 pub fn nonce(&self) -> &[u8; 32] {
89 &self.quote.body.nonce
90 }
91 #[must_use]
92 pub fn device_serial(&self) -> &str {
93 &self.cert_chain.device_cert.serial
94 }
95
96 #[must_use]
98 pub fn trust_anchor_id(&self) -> &str {
99 &self.cert_chain.trust_anchor.ca_id
100 }
101
102 #[must_use]
104 pub fn trust_anchor_fingerprint(&self) -> &[u8; 32] {
105 &self.cert_chain.trust_anchor.fingerprint
106 }
107
108 #[must_use]
110 pub fn trust_anchor_valid_until(&self) -> u64 {
111 self.cert_chain.trust_anchor.not_after
112 }
113}
114
115pub struct Verifier {
132 policy: PolicyConfig,
133 engine: PolicyEngineV2,
134}
135
136impl Verifier {
137 #[must_use]
139 pub fn new(policy: PolicyConfig) -> Self {
140 Self {
141 policy,
142 engine: PolicyEngineV2::new(vec![]),
143 }
144 }
145
146 #[must_use]
150 pub fn with_engine(policy: PolicyConfig, engine: PolicyEngineV2) -> Self {
151 Self { policy, engine }
152 }
153
154 pub fn verify_cbor(
177 &self,
178 cbor: &[u8],
179 verifying_key: &[u8],
180 expected_nonce: &[u8; 32],
181 now_secs: u64,
182 ) -> Result<VerificationResult, PqRascvError> {
183 let quote = AttestationQuote::from_cbor(cbor)?;
184
185 self.verify_quote("e, verifying_key, expected_nonce, now_secs)?;
186
187 Ok(VerificationResult { quote })
188 }
189
190 pub fn verify_with_challenge(
203 &self,
204 cbor: &[u8],
205 verifying_key: &[u8],
206 challenge: &Challenge,
207 now_secs: u64,
208 ) -> Result<VerificationResult, PqRascvError> {
209 self.verify_cbor(cbor, verifying_key, &challenge.nonce, now_secs)
210 }
211
212 pub fn verify_cbor_consuming<L: NonceLedger>(
229 &self,
230 cbor: &[u8],
231 verifying_key: &[u8],
232 expected_nonce: &[u8; 32],
233 now_secs: u64,
234 ledger: &mut L,
235 ) -> Result<VerificationResult, PqRascvError> {
236 let result = self.verify_cbor(cbor, verifying_key, expected_nonce, now_secs)?;
237 if let Err(e) = ledger.consume(expected_nonce) {
240 tracing::warn!(error = %e, "nonce ledger rejected consume (replay or unregistered nonce)");
241 return Err(e);
242 }
243 Ok(result)
244 }
245
246 pub fn verify_with_challenge_consuming<L: NonceLedger>(
253 &self,
254 cbor: &[u8],
255 verifying_key: &[u8],
256 challenge: &Challenge,
257 now_secs: u64,
258 ledger: &mut L,
259 ) -> Result<VerificationResult, PqRascvError> {
260 self.verify_cbor_consuming(cbor, verifying_key, &challenge.nonce, now_secs, ledger)
261 }
262
263 #[allow(clippy::too_many_arguments)]
275 pub fn verify_cbor_with_pki(
276 &self,
277 cbor: &[u8],
278 device_cert: DeviceCertificate,
279 intermediates: Vec<DeviceCertificate>,
280 trust_anchor: &TrustAnchor,
281 crl: Option<&VerifiedRevocationList<'_>>,
282 expected_nonce: &[u8; 32],
283 now_secs: u64,
284 ) -> Result<PkiVerificationResult, PqRascvError> {
285 let chain = validate_chain(device_cert, intermediates, trust_anchor, now_secs)?;
286
287 if let Some(crl) = crl {
288 if crl.is_revoked(&chain.device_cert.serial) {
289 return Err(PqRascvError::CertificateRevoked);
290 }
291 }
292
293 let quote = AttestationQuote::from_cbor(cbor)?;
294 self.verify_signature_only("e, &chain.device_cert.subject_key, expected_nonce)?;
295
296 validate_hardware_identity(
297 &chain.device_cert.hardware_identity,
298 "e.body.measurements,
299 )?;
300
301 self.policy.evaluate(
302 quote.body.provenance.slsa_level(),
303 "e.body.measurements.firmware_hash,
304 quote.body.measurements.event_counter,
305 quote.body.timestamp,
306 now_secs,
307 )?;
308
309 let ctx = PolicyContext::from_verified_quote("e, Some(&chain), None, now_secs, None);
310 self.engine.evaluate(&ctx)?;
311
312 Ok(PkiVerificationResult {
313 quote,
314 cert_chain: chain,
315 })
316 }
317
318 #[allow(clippy::too_many_arguments)]
329 pub fn verify_cbor_with_trust_store(
330 &self,
331 cbor: &[u8],
332 device_cert: DeviceCertificate,
333 intermediates: Vec<DeviceCertificate>,
334 trust_store: &TrustStore,
335 crl: Option<&VerifiedRevocationList<'_>>,
336 expected_nonce: &[u8; 32],
337 now_secs: u64,
338 ) -> Result<PkiVerificationResult, PqRascvError> {
339 let chain = validate_chain_with_store(&device_cert, &intermediates, trust_store, now_secs)?;
340
341 if let Some(crl) = crl {
342 if crl.is_revoked(&chain.device_cert.serial) {
343 return Err(PqRascvError::CertificateRevoked);
344 }
345 }
346
347 let quote = AttestationQuote::from_cbor(cbor)?;
348 self.verify_signature_only("e, &chain.device_cert.subject_key, expected_nonce)?;
349
350 validate_hardware_identity(
351 &chain.device_cert.hardware_identity,
352 "e.body.measurements,
353 )?;
354
355 self.policy.evaluate(
356 quote.body.provenance.slsa_level(),
357 "e.body.measurements.firmware_hash,
358 quote.body.measurements.event_counter,
359 quote.body.timestamp,
360 now_secs,
361 )?;
362
363 let ctx = PolicyContext::from_verified_quote("e, Some(&chain), None, now_secs, None);
364 self.engine.evaluate(&ctx)?;
365
366 Ok(PkiVerificationResult {
367 quote,
368 cert_chain: chain,
369 })
370 }
371
372 pub fn verify_cbor_with_sigstore(
390 &self,
391 cbor: &[u8],
392 verifying_key: &[u8],
393 expected_nonce: &[u8; 32],
394 now_secs: u64,
395 bundle: &ExternalProvenanceBundle,
396 sigstore_config: &SigstoreConfig,
397 ) -> Result<VerificationResult, PqRascvError> {
398 let quote = AttestationQuote::from_cbor(cbor)?;
399 self.verify_signature_only("e, verifying_key, expected_nonce)?;
400
401 let vp: VerifiedProvenance = bundle.verify_all(
402 sigstore_config,
403 "e.body.measurements.firmware_hash,
404 now_secs,
405 )?;
406
407 self.policy.evaluate(
408 quote.body.provenance.slsa_level(),
409 "e.body.measurements.firmware_hash,
410 quote.body.measurements.event_counter,
411 quote.body.timestamp,
412 now_secs,
413 )?;
414
415 let ctx = PolicyContext::from_verified_quote("e, None, None, now_secs, Some(&vp));
416 self.engine.evaluate(&ctx)?;
417
418 Ok(VerificationResult { quote })
419 }
420
421 fn verify_signature_only(
422 &self,
423 quote: &AttestationQuote,
424 verifying_key: &[u8],
425 expected_nonce: &[u8; 32],
426 ) -> Result<(), PqRascvError> {
427 if quote.body.version != PROTOCOL_VERSION {
428 return Err(PqRascvError::UnsupportedVersion);
429 }
430 if quote.body.nonce.ct_eq(expected_nonce).unwrap_u8() == 0 {
431 return Err(PqRascvError::VerificationFailed);
432 }
433 let expected_id = pub_key_id(verifying_key);
434 if quote.body.pub_key_id != expected_id {
435 return Err(PqRascvError::VerificationFailed);
436 }
437 let body_cbor = quote.body.to_cbor()?;
438 MlDsaBackend.verify(
439 &body_cbor,
440 verifying_key,
441 "e.signature,
442 SIGNING_CONTEXT_QUOTE,
443 )?;
444 Ok(())
445 }
446
447 #[tracing::instrument(skip_all, err)]
454 pub fn verify_quote(
455 &self,
456 quote: &AttestationQuote,
457 verifying_key: &[u8],
458 expected_nonce: &[u8; 32],
459 now_secs: u64,
460 ) -> Result<(), PqRascvError> {
461 self.verify_signature_only(quote, verifying_key, expected_nonce)?;
462
463 self.policy.evaluate(
464 quote.body.provenance.slsa_level(),
465 "e.body.measurements.firmware_hash,
466 quote.body.measurements.event_counter,
467 quote.body.timestamp,
468 now_secs,
469 )?;
470
471 let ctx = PolicyContext::from_verified_quote(quote, None, None, now_secs, None);
472 self.engine.evaluate(&ctx)?;
473
474 tracing::debug!(
475 event_counter = quote.body.measurements.event_counter,
476 "attestation quote verified"
477 );
478 Ok(())
479 }
480}
481
482#[cfg(test)]
487mod tests {
488 use super::*;
489 use pqrascv_core::{
490 crypto::generate_ml_dsa_keypair,
491 measurement::SoftwareRoT,
492 nonce::InMemoryNonceLedger,
493 provenance::SlsaPredicateBuilder,
494 quote::{generate_quote, QuoteTimestamp},
495 };
496
497 fn setup() -> (
498 pqrascv_core::crypto::SigningKeySeed,
499 [u8; pqrascv_core::crypto::ML_DSA_65_VERIFYING_KEY_SIZE],
500 AttestationQuote,
501 ) {
502 let (sk, vk) = generate_ml_dsa_keypair().unwrap();
503 let rot = SoftwareRoT::new(b"verifier-test-firmware", None, 1);
504 let provenance = SlsaPredicateBuilder::new("https://ci.example.com")
505 .add_subject("fw.bin", &[0xabu8; 32])
506 .with_slsa_level(2)
507 .with_timestamps(1_700_000_000, 1_700_001_000)
508 .build()
509 .unwrap();
510 let nonce = [0x77u8; 32];
511 let quote = generate_quote(
512 &rot,
513 &pqrascv_core::crypto::MlDsaBackend,
514 sk.as_bytes(),
515 &vk,
516 &nonce,
517 provenance,
518 QuoteTimestamp::Rtc(1_700_000_500),
519 )
520 .unwrap();
521 (sk, vk, quote)
522 }
523
524 #[test]
525 fn verifier_accepts_valid_quote() {
526 let (_, vk, quote) = setup();
527 let verifier = Verifier::new(PolicyConfig::default());
528 let cbor = quote.to_cbor().unwrap();
529
530 let result = verifier.verify_cbor(&cbor, &vk, &[0x77u8; 32], 1_700_000_600);
531 assert!(result.is_ok(), "{result:?}");
532 }
533
534 #[test]
535 fn verifier_rejects_wrong_nonce() {
536 let (_, vk, quote) = setup();
537 let verifier = Verifier::new(PolicyConfig::default());
538 let cbor = quote.to_cbor().unwrap();
539
540 let result = verifier.verify_cbor(&cbor, &vk, &[0x00u8; 32], 1_700_000_600);
541 assert!(result.is_err());
542 }
543
544 #[test]
545 fn verify_cbor_consuming_blocks_replay() {
546 let (_, vk, quote) = setup();
547 let verifier = Verifier::new(PolicyConfig::default());
548 let cbor = quote.to_cbor().unwrap();
549 let nonce = [0x77u8; 32];
550
551 let mut ledger = InMemoryNonceLedger::new(1024);
552 ledger.register(nonce).unwrap();
553
554 assert!(verifier
556 .verify_cbor_consuming(&cbor, &vk, &nonce, 1_700_000_600, &mut ledger)
557 .is_ok());
558 let replay = verifier.verify_cbor_consuming(&cbor, &vk, &nonce, 1_700_000_600, &mut ledger);
561 assert!(
562 matches!(replay, Err(PqRascvError::InvalidNonce)),
563 "{replay:?}"
564 );
565 }
566
567 #[test]
568 fn verify_cbor_consuming_does_not_burn_nonce_on_verify_failure() {
569 let (_, vk, quote) = setup();
570 let verifier = Verifier::new(PolicyConfig::default());
571 let cbor = quote.to_cbor().unwrap();
572 let nonce = [0x77u8; 32];
573
574 let mut ledger = InMemoryNonceLedger::new(1024);
575 ledger.register(nonce).unwrap();
576
577 assert!(verifier
580 .verify_cbor_consuming(&cbor, &vk, &[0x00u8; 32], 1_700_000_600, &mut ledger)
581 .is_err());
582 assert!(verifier
584 .verify_cbor_consuming(&cbor, &vk, &nonce, 1_700_000_600, &mut ledger)
585 .is_ok());
586 }
587
588 #[test]
589 fn verifier_rejects_tampered_quote() {
590 let (_, vk, mut quote) = setup();
591 let verifier = Verifier::new(PolicyConfig::default());
592
593 quote.body.measurements.event_counter = 999;
594 let cbor = quote.to_cbor().unwrap();
595
596 let result = verifier.verify_cbor(&cbor, &vk, &[0x77u8; 32], 1_700_000_600);
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn verifier_rejects_wrong_verifying_key() {
602 let (_, _vk, quote) = setup();
603
604 let (_, different_vk) = generate_ml_dsa_keypair().unwrap();
605 let verifier = Verifier::new(PolicyConfig::default());
606 let cbor = quote.to_cbor().unwrap();
607
608 let result = verifier.verify_cbor(&cbor, &different_vk, &[0x77u8; 32], 1_700_000_600);
609 assert!(result.is_err());
610 }
611
612 #[test]
613 fn verifier_rejects_unsupported_version() {
614 let (_, vk, mut quote) = setup();
615 let verifier = Verifier::new(PolicyConfig::default());
616
617 quote.body.version = 99;
620 let cbor = quote.to_cbor().unwrap();
621
622 let result = verifier.verify_cbor(&cbor, &vk, &[0x77u8; 32], 1_700_000_600);
623 assert!(matches!(result, Err(PqRascvError::UnsupportedVersion)));
624 }
625
626 #[test]
627 fn verifier_rejects_rtcless_by_default() {
628 let (sk, vk) = generate_ml_dsa_keypair().unwrap();
629 let rot = SoftwareRoT::new(b"fw", None, 1);
630 let provenance = SlsaPredicateBuilder::new("https://ci.example.com")
631 .add_subject("fw.bin", &[0xabu8; 32])
632 .with_slsa_level(2)
633 .build()
634 .unwrap();
635 let quote = generate_quote(
636 &rot,
637 &pqrascv_core::crypto::MlDsaBackend,
638 sk.as_bytes(),
639 &vk,
640 &[0x77u8; 32],
641 provenance,
642 QuoteTimestamp::NoRtc,
643 )
644 .unwrap();
645 let cbor = quote.to_cbor().unwrap();
646 let verifier = Verifier::new(PolicyConfig::default());
647 assert!(matches!(
648 verifier.verify_cbor(&cbor, &vk, &[0x77u8; 32], 9_999_999),
649 Err(PqRascvError::RtcRequired)
650 ));
651 }
652
653 #[test]
654 fn verify_with_challenge_accepts_valid_quote() {
655 let (_, vk, quote) = setup();
656 let verifier = Verifier::new(PolicyConfig::default());
657 let cbor = quote.to_cbor().unwrap();
658
659 let challenge = pqrascv_core::quote::Challenge::new([0x77u8; 32]);
660 let result = verifier.verify_with_challenge(&cbor, &vk, &challenge, 1_700_000_600);
661 assert!(result.is_ok(), "{result:?}");
662 }
663
664 #[test]
665 fn verify_with_challenge_rejects_wrong_nonce() {
666 let (_, vk, quote) = setup();
667 let verifier = Verifier::new(PolicyConfig::default());
668 let cbor = quote.to_cbor().unwrap();
669
670 let challenge = pqrascv_core::quote::Challenge::new([0x00u8; 32]);
671 let result = verifier.verify_with_challenge(&cbor, &vk, &challenge, 1_700_000_600);
672 assert!(result.is_err());
673 }
674
675 #[test]
676 fn verify_cbor_with_sigstore_rejects_invalid_bundle() {
677 use pqrascv_core::provenance_v2::{
678 ExternalProvenanceBundle, ProvenancePredicate, ProvenanceSubject, SigstoreBundle,
679 SigstoreConfig,
680 };
681
682 let (_, vk, quote) = setup();
683 let cbor = quote.to_cbor().unwrap();
684
685 let predicate = ProvenancePredicate::new(
687 "https://slsa.dev/provenance/v1".to_string(),
688 "https://github.com/actions/runner".to_string(),
689 "abc123".to_string(),
690 0,
691 0,
692 [0u8; 32],
693 2,
694 vec![ProvenanceSubject {
695 name: "firmware.bin".to_string(),
696 digest_sha3_256: [0xabu8; 32],
697 }],
698 );
699 let bundle = ExternalProvenanceBundle::new(
700 predicate,
701 SigstoreBundle::new(vec![], vec![], "{}".to_string(), [0xffu8; 32]),
702 );
703 let config = SigstoreConfig {
704 rekor_public_key_der: vec![],
705 fulcio_root_der: vec![],
706 required_issuer: "https://token.actions.githubusercontent.com".to_string(),
707 allowed_builders: vec![],
708 max_clock_skew_secs: 60,
709 };
710
711 let result = Verifier::new(PolicyConfig::default()).verify_cbor_with_sigstore(
712 &cbor,
713 &vk,
714 &[0x77u8; 32],
715 1_700_000_600,
716 &bundle,
717 &config,
718 );
719 assert!(matches!(result, Err(PqRascvError::InvalidProvenance)));
720 }
721
722 #[test]
723 fn verify_cbor_with_sigstore_fails_quote_before_bundle() {
724 use pqrascv_core::provenance_v2::{
725 ExternalProvenanceBundle, ProvenancePredicate, ProvenanceSubject, SigstoreBundle,
726 SigstoreConfig,
727 };
728
729 let (_, vk, quote) = setup();
730 let cbor = quote.to_cbor().unwrap();
731
732 let predicate = ProvenancePredicate::new(
733 "https://slsa.dev/provenance/v1".to_string(),
734 "https://github.com/actions/runner".to_string(),
735 "abc123".to_string(),
736 0,
737 0,
738 [0u8; 32],
739 2,
740 vec![ProvenanceSubject {
741 name: "firmware.bin".to_string(),
742 digest_sha3_256: [0xabu8; 32],
743 }],
744 );
745 let bundle = ExternalProvenanceBundle::new(
746 predicate,
747 SigstoreBundle::new(vec![], vec![], "{}".to_string(), [0xffu8; 32]),
748 );
749 let config = SigstoreConfig {
750 rekor_public_key_der: vec![],
751 fulcio_root_der: vec![],
752 required_issuer: "https://token.actions.githubusercontent.com".to_string(),
753 allowed_builders: vec![],
754 max_clock_skew_secs: 60,
755 };
756
757 let result = Verifier::new(PolicyConfig::default()).verify_cbor_with_sigstore(
759 &cbor,
760 &vk,
761 &[0x00u8; 32],
762 1_700_000_600,
763 &bundle,
764 &config,
765 );
766 assert!(matches!(result, Err(PqRascvError::VerificationFailed)));
767 }
768
769 #[test]
770 fn verification_result_accessors_return_correct_data() {
771 let (_, vk, quote) = setup();
772 let verifier = Verifier::new(PolicyConfig::default());
773 let expected_firmware_hash = quote.body.measurements.firmware_hash;
774 let cbor = quote.to_cbor().unwrap();
775
776 let result = verifier
777 .verify_cbor(&cbor, &vk, &[0x77u8; 32], 1_700_000_600)
778 .unwrap();
779
780 assert_eq!(result.slsa_level(), 2);
781 assert_eq!(result.firmware_hash(), &expected_firmware_hash);
782 assert_eq!(result.nonce(), &[0x77u8; 32]);
783 }
784
785 #[test]
786 fn engine_rejects_software_rot_via_verify_cbor() {
787 use pqrascv_core::policy::{PolicyEngineV2, PolicyRule};
788
789 let (_, vk, quote) = setup();
790 let cbor = quote.to_cbor().unwrap();
791 let verifier = Verifier::with_engine(
792 PolicyConfig::default(),
793 PolicyEngineV2::new(vec![PolicyRule::RequireHardwareBackend]),
794 );
795 let result = verifier.verify_cbor(&cbor, &vk, &[0x77u8; 32], 1_700_000_600);
796 assert!(
797 matches!(result, Err(PqRascvError::PolicyViolation(_))),
798 "RequireHardwareBackend must reject SoftwareRoT, got {result:?}"
799 );
800 }
801
802 #[test]
803 fn empty_engine_does_not_break_existing_verify_cbor() {
804 use pqrascv_core::policy::PolicyEngineV2;
805
806 let (_, vk, quote) = setup();
807 let cbor = quote.to_cbor().unwrap();
808 let verifier = Verifier::with_engine(PolicyConfig::default(), PolicyEngineV2::new(vec![]));
809 assert!(verifier
810 .verify_cbor(&cbor, &vk, &[0x77u8; 32], 1_700_000_600)
811 .is_ok());
812 }
813}
814
815#[cfg(test)]
816mod pki_tests {
817 use super::*;
818 use pqrascv_core::{
819 crypto::{
820 generate_ml_dsa_keypair, CryptoBackend, MlDsaBackend, ML_DSA_65_VERIFYING_KEY_SIZE,
821 SIGNING_CONTEXT_CERT,
822 },
823 measurement::SoftwareRoT,
824 pki::{build_device_certificate, CaPublicKey, HardwareIdentity, TrustStore, CERT_VERSION},
825 provenance::SlsaPredicateBuilder,
826 quote::{generate_quote, QuoteTimestamp},
827 };
828
829 fn make_provenance() -> pqrascv_core::provenance::InTotoAttestation {
830 SlsaPredicateBuilder::new("https://ci.test")
831 .add_subject("fw.bin", &[0xabu8; 32])
832 .with_slsa_level(2)
833 .build()
834 .unwrap()
835 }
836
837 fn sign_cert(cert: &mut pqrascv_core::pki::DeviceCertificate, seed: &[u8]) {
838 let tbs = cert.tbs_cbor().unwrap();
839 let sig = MlDsaBackend.sign(&tbs, seed, SIGNING_CONTEXT_CERT).unwrap();
840 cert.issuer_signature = sig.as_ref().to_vec();
841 }
842
843 fn make_device_cert(
844 device_vk: &[u8; ML_DSA_65_VERIFYING_KEY_SIZE],
845 issuer_id: &str,
846 serial: &str,
847 signer_seed: &[u8],
848 ) -> pqrascv_core::pki::DeviceCertificate {
849 let subject_key_id = pqrascv_core::crypto::pub_key_id(device_vk);
850 let mut cert = build_device_certificate(
851 CERT_VERSION,
852 serial.to_string(),
853 issuer_id.to_string(),
854 0,
855 u64::MAX,
856 device_vk.to_vec(),
857 subject_key_id,
858 HardwareIdentity::TpmEkCertHash([0u8; 32]),
859 None,
860 vec![],
861 serial.to_string(),
862 Some(0),
863 );
864 sign_cert(&mut cert, signer_seed);
865 cert
866 }
867
868 #[test]
869 fn pki_verification_succeeds_with_valid_chain() {
870 let (ca_seed, ca_vk) = generate_ml_dsa_keypair().unwrap();
871 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
872
873 let anchor = TrustAnchor::new(CaPublicKey {
874 key_bytes: ca_vk,
875 ca_id: "https://ca.test".to_string(),
876 not_before: 0,
877 not_after: u64::MAX,
878 })
879 .unwrap();
880 let device_cert =
881 make_device_cert(&dev_vk, "https://ca.test", "DEV-001", ca_seed.as_bytes());
882
883 let rot = SoftwareRoT::new(b"fw", None, 1);
884 let nonce = [0xAAu8; 32];
885 let quote = generate_quote(
886 &rot,
887 &MlDsaBackend,
888 dev_seed.as_bytes(),
889 &dev_vk,
890 &nonce,
891 make_provenance(),
892 QuoteTimestamp::Rtc(1_700_000_000),
893 )
894 .unwrap();
895 let cbor = quote.to_cbor().unwrap();
896
897 let verifier = Verifier::new(PolicyConfig::default());
898 let result = verifier.verify_cbor_with_pki(
899 &cbor,
900 device_cert,
901 vec![],
902 &anchor,
903 None,
904 &nonce,
905 1_700_000_100,
906 );
907 assert!(result.is_ok());
908 assert_eq!(result.unwrap().device_serial(), "DEV-001");
909 }
910
911 #[test]
912 fn pki_verification_rejects_revoked_device() {
913 use pqrascv_core::crypto::SIGNING_CONTEXT_CRL;
914 use pqrascv_core::pki::revocation::{
915 build_revocation_list, RevocationEntry, RevocationReason,
916 };
917
918 let (ca_seed, ca_vk) = generate_ml_dsa_keypair().unwrap();
919 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
920 let anchor = TrustAnchor::new(CaPublicKey {
921 key_bytes: ca_vk,
922 ca_id: "https://ca.test".to_string(),
923 not_before: 0,
924 not_after: u64::MAX,
925 })
926 .unwrap();
927 let device_cert = make_device_cert(
928 &dev_vk,
929 "https://ca.test",
930 "DEV-REVOKED",
931 ca_seed.as_bytes(),
932 );
933
934 let mut crl = build_revocation_list(
935 "https://ca.test".to_string(),
936 1_000,
937 9_999_999,
938 vec![RevocationEntry {
939 serial: "DEV-REVOKED".to_string(),
940 revoked_at: 1_000,
941 reason: RevocationReason::KeyCompromise,
942 }],
943 vec![],
944 );
945 let crl_tbs = crl.tbs_cbor().unwrap();
946 let crl_sig = MlDsaBackend
947 .sign(&crl_tbs, ca_seed.as_bytes(), SIGNING_CONTEXT_CRL)
948 .unwrap();
949 crl.issuer_signature = crl_sig.as_ref().to_vec();
950 let verified_crl = crl.verify(&ca_vk, 2_000).unwrap();
951
952 let rot = SoftwareRoT::new(b"fw", None, 1);
953 let nonce = [0xBBu8; 32];
954 let quote = generate_quote(
955 &rot,
956 &MlDsaBackend,
957 dev_seed.as_bytes(),
958 &dev_vk,
959 &nonce,
960 make_provenance(),
961 QuoteTimestamp::Rtc(1_700_000_000),
962 )
963 .unwrap();
964 let cbor = quote.to_cbor().unwrap();
965
966 let verifier = Verifier::new(PolicyConfig::default());
967 let result = verifier.verify_cbor_with_pki(
968 &cbor,
969 device_cert,
970 vec![],
971 &anchor,
972 Some(&verified_crl),
973 &nonce,
974 1_700_000_100,
975 );
976 assert!(matches!(result, Err(PqRascvError::CertificateRevoked)));
977 }
978
979 #[test]
980 fn pki_verification_rejects_wrong_trust_anchor() {
981 let (ca_seed, _ca_vk) = generate_ml_dsa_keypair().unwrap();
982 let (_other_seed, other_vk) = generate_ml_dsa_keypair().unwrap();
983 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
984
985 let anchor = TrustAnchor::new(CaPublicKey {
987 key_bytes: other_vk,
988 ca_id: "https://ca.test".to_string(),
989 not_before: 0,
990 not_after: u64::MAX,
991 })
992 .unwrap();
993 let device_cert =
994 make_device_cert(&dev_vk, "https://ca.test", "DEV-001", ca_seed.as_bytes());
995
996 let rot = SoftwareRoT::new(b"fw", None, 1);
997 let nonce = [0xCCu8; 32];
998 let quote = generate_quote(
999 &rot,
1000 &MlDsaBackend,
1001 dev_seed.as_bytes(),
1002 &dev_vk,
1003 &nonce,
1004 make_provenance(),
1005 QuoteTimestamp::Rtc(1_700_000_000),
1006 )
1007 .unwrap();
1008 let cbor = quote.to_cbor().unwrap();
1009
1010 let verifier = Verifier::new(PolicyConfig::default());
1011 assert!(verifier
1012 .verify_cbor_with_pki(
1013 &cbor,
1014 device_cert,
1015 vec![],
1016 &anchor,
1017 None,
1018 &nonce,
1019 1_700_000_100,
1020 )
1021 .is_err());
1022 }
1023
1024 #[test]
1025 fn pki_verification_succeeds_with_intermediate_chain() {
1026 let (root_seed, root_vk) = generate_ml_dsa_keypair().unwrap();
1028 let (int_seed, int_vk) = generate_ml_dsa_keypair().unwrap();
1029 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
1030
1031 let anchor = TrustAnchor::new(CaPublicKey {
1032 key_bytes: root_vk,
1033 ca_id: "https://root.test".to_string(),
1034 not_before: 0,
1035 not_after: u64::MAX,
1036 })
1037 .unwrap();
1038
1039 let int_subject_key_id = pqrascv_core::crypto::pub_key_id(&int_vk);
1041 let mut intermediate = build_device_certificate(
1042 CERT_VERSION,
1043 "INT-001".to_string(),
1044 "https://root.test".to_string(), 0,
1046 u64::MAX,
1047 int_vk.to_vec(),
1048 int_subject_key_id,
1049 HardwareIdentity::TpmEkCertHash([0u8; 32]),
1050 None,
1051 vec![], "https://int.test".to_string(), None, );
1055 sign_cert(&mut intermediate, root_seed.as_bytes());
1056
1057 let device_cert = make_device_cert(
1059 &dev_vk,
1060 "https://int.test",
1061 "DEV-CHAIN-001",
1062 int_seed.as_bytes(),
1063 );
1064
1065 let rot = SoftwareRoT::new(b"fw", None, 1);
1066 let nonce = [0xDDu8; 32];
1067 let quote = generate_quote(
1068 &rot,
1069 &MlDsaBackend,
1070 dev_seed.as_bytes(),
1071 &dev_vk,
1072 &nonce,
1073 make_provenance(),
1074 QuoteTimestamp::Rtc(1_700_000_000),
1075 )
1076 .unwrap();
1077 let cbor = quote.to_cbor().unwrap();
1078
1079 let verifier = Verifier::new(PolicyConfig::default());
1080 let result = verifier.verify_cbor_with_pki(
1081 &cbor,
1082 device_cert,
1083 vec![intermediate],
1084 &anchor,
1085 None,
1086 &nonce,
1087 1_700_000_100,
1088 );
1089 assert!(
1090 result.is_ok(),
1091 "intermediate chain verification failed: {result:?}"
1092 );
1093 assert_eq!(result.unwrap().device_serial(), "DEV-CHAIN-001");
1094 }
1095
1096 #[test]
1097 fn pki_result_exposes_trust_anchor_metadata() {
1098 let (ca_seed, ca_vk) = generate_ml_dsa_keypair().unwrap();
1099 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
1100
1101 let expected_fingerprint = pqrascv_core::crypto::pub_key_id(&ca_vk);
1102 let anchor = TrustAnchor::new(CaPublicKey {
1103 key_bytes: ca_vk,
1104 ca_id: "https://audit.ca".to_string(),
1105 not_before: 0,
1106 not_after: u64::MAX,
1107 })
1108 .unwrap();
1109 let device_cert =
1110 make_device_cert(&dev_vk, "https://audit.ca", "DEV-AUDIT", ca_seed.as_bytes());
1111
1112 let rot = SoftwareRoT::new(b"fw", None, 1);
1113 let nonce = [0xCCu8; 32];
1114 let quote = generate_quote(
1115 &rot,
1116 &MlDsaBackend,
1117 dev_seed.as_bytes(),
1118 &dev_vk,
1119 &nonce,
1120 make_provenance(),
1121 QuoteTimestamp::Rtc(1_700_000_000),
1122 )
1123 .unwrap();
1124 let cbor = quote.to_cbor().unwrap();
1125
1126 let verifier = Verifier::new(PolicyConfig::default());
1127 let result = verifier
1128 .verify_cbor_with_pki(
1129 &cbor,
1130 device_cert,
1131 vec![],
1132 &anchor,
1133 None,
1134 &nonce,
1135 1_700_000_100,
1136 )
1137 .unwrap();
1138
1139 assert_eq!(result.trust_anchor_id(), "https://audit.ca");
1140 assert_eq!(result.trust_anchor_fingerprint(), &expected_fingerprint);
1141 assert_eq!(result.trust_anchor_valid_until(), u64::MAX);
1142 }
1143
1144 #[test]
1145 fn verify_cbor_with_trust_store_accepts_valid_chain() {
1146 let (ca_seed, ca_vk) = generate_ml_dsa_keypair().unwrap();
1147 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
1148
1149 let store = TrustStore::new(
1150 TrustAnchor::new(CaPublicKey {
1151 key_bytes: ca_vk,
1152 ca_id: "https://store.ca".to_string(),
1153 not_before: 0,
1154 not_after: u64::MAX,
1155 })
1156 .unwrap(),
1157 );
1158 let device_cert =
1159 make_device_cert(&dev_vk, "https://store.ca", "DEV-STORE", ca_seed.as_bytes());
1160
1161 let rot = SoftwareRoT::new(b"fw", None, 1);
1162 let nonce = [0xDDu8; 32];
1163 let quote = generate_quote(
1164 &rot,
1165 &MlDsaBackend,
1166 dev_seed.as_bytes(),
1167 &dev_vk,
1168 &nonce,
1169 make_provenance(),
1170 QuoteTimestamp::Rtc(1_700_000_000),
1171 )
1172 .unwrap();
1173 let cbor = quote.to_cbor().unwrap();
1174
1175 let verifier = Verifier::new(PolicyConfig::default());
1176 let result = verifier.verify_cbor_with_trust_store(
1177 &cbor,
1178 device_cert,
1179 vec![],
1180 &store,
1181 None,
1182 &nonce,
1183 1_700_000_100,
1184 );
1185 assert!(result.is_ok());
1186 assert_eq!(result.unwrap().trust_anchor_id(), "https://store.ca");
1187 }
1188
1189 #[test]
1190 fn verify_cbor_with_trust_store_rejects_expired_store() {
1191 let (ca_seed, ca_vk) = generate_ml_dsa_keypair().unwrap();
1192 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
1193
1194 let store = TrustStore::new(
1195 TrustAnchor::new(CaPublicKey {
1196 key_bytes: ca_vk,
1197 ca_id: "https://expired.ca".to_string(),
1198 not_before: 0,
1199 not_after: 999,
1200 })
1201 .unwrap(),
1202 );
1203 let device_cert =
1204 make_device_cert(&dev_vk, "https://expired.ca", "DEV-EXP", ca_seed.as_bytes());
1205
1206 let rot = SoftwareRoT::new(b"fw", None, 1);
1207 let nonce = [0xEEu8; 32];
1208 let quote = generate_quote(
1209 &rot,
1210 &MlDsaBackend,
1211 dev_seed.as_bytes(),
1212 &dev_vk,
1213 &nonce,
1214 make_provenance(),
1215 QuoteTimestamp::Rtc(1_700_000_000),
1216 )
1217 .unwrap();
1218 let cbor = quote.to_cbor().unwrap();
1219
1220 let verifier = Verifier::new(PolicyConfig::default());
1221 let result = verifier.verify_cbor_with_trust_store(
1222 &cbor,
1223 device_cert,
1224 vec![],
1225 &store,
1226 None,
1227 &nonce,
1228 1_700_000_100,
1229 );
1230 assert!(matches!(result, Err(PqRascvError::TrustAnchorExpired)));
1231 }
1232
1233 #[test]
1234 fn engine_require_cert_chain_passes_in_verify_cbor_with_pki() {
1235 use pqrascv_core::policy::{PolicyEngineV2, PolicyRule};
1236
1237 let (ca_seed, ca_vk) = generate_ml_dsa_keypair().unwrap();
1238 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
1239 let anchor = TrustAnchor::new(CaPublicKey {
1240 key_bytes: ca_vk,
1241 ca_id: "https://ca.test".to_string(),
1242 not_before: 0,
1243 not_after: u64::MAX,
1244 })
1245 .unwrap();
1246 let device_cert =
1247 make_device_cert(&dev_vk, "https://ca.test", "DEV-E1", ca_seed.as_bytes());
1248
1249 let rot = SoftwareRoT::new(b"fw", None, 1);
1250 let nonce = [0xE1u8; 32];
1251 let quote = generate_quote(
1252 &rot,
1253 &MlDsaBackend,
1254 dev_seed.as_bytes(),
1255 &dev_vk,
1256 &nonce,
1257 make_provenance(),
1258 QuoteTimestamp::Rtc(1_700_000_000),
1259 )
1260 .unwrap();
1261 let cbor = quote.to_cbor().unwrap();
1262
1263 let verifier = Verifier::with_engine(
1264 PolicyConfig::default(),
1265 PolicyEngineV2::new(vec![PolicyRule::RequireCertificateChain]),
1266 );
1267 let result = verifier.verify_cbor_with_pki(
1268 &cbor,
1269 device_cert,
1270 vec![],
1271 &anchor,
1272 None,
1273 &nonce,
1274 1_700_000_100,
1275 );
1276 assert!(
1277 result.is_ok(),
1278 "RequireCertificateChain must pass when chain is provided: {result:?}"
1279 );
1280 }
1281
1282 #[test]
1283 fn engine_require_cert_chain_fails_in_verify_cbor() {
1284 use pqrascv_core::policy::{PolicyEngineV2, PolicyRule};
1285
1286 let (_, vk, quote) = {
1287 let (sk, vk) = generate_ml_dsa_keypair().unwrap();
1288 let rot = SoftwareRoT::new(b"fw", None, 1);
1289 let nonce = [0xE2u8; 32];
1290 let quote = generate_quote(
1291 &rot,
1292 &MlDsaBackend,
1293 sk.as_bytes(),
1294 &vk,
1295 &nonce,
1296 make_provenance(),
1297 QuoteTimestamp::Rtc(1_700_000_000),
1298 )
1299 .unwrap();
1300 (sk, vk, quote)
1301 };
1302 let cbor = quote.to_cbor().unwrap();
1303
1304 let verifier = Verifier::with_engine(
1305 PolicyConfig::default(),
1306 PolicyEngineV2::new(vec![PolicyRule::RequireCertificateChain]),
1307 );
1308 assert!(matches!(
1309 verifier.verify_cbor(&cbor, &vk, &[0xE2u8; 32], 1_700_000_600),
1310 Err(PqRascvError::PolicyViolation(_))
1311 ));
1312 }
1313
1314 #[test]
1315 fn pki_verification_cross_validates_hardware_identity() {
1316 let (ca_seed, ca_vk) = generate_ml_dsa_keypair().unwrap();
1319 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
1320 let anchor = TrustAnchor::new(CaPublicKey {
1321 key_bytes: ca_vk,
1322 ca_id: "https://ca.test".to_string(),
1323 not_before: 0,
1324 not_after: u64::MAX,
1325 })
1326 .unwrap();
1327 let device_cert =
1329 make_device_cert(&dev_vk, "https://ca.test", "DEV-HW-001", ca_seed.as_bytes());
1330
1331 let rot = SoftwareRoT::new(b"fw", None, 1);
1332 let nonce = [0xCCu8; 32];
1333 let quote = generate_quote(
1334 &rot,
1335 &MlDsaBackend,
1336 dev_seed.as_bytes(),
1337 &dev_vk,
1338 &nonce,
1339 make_provenance(),
1340 QuoteTimestamp::Rtc(1_700_000_000),
1341 )
1342 .unwrap();
1343 let cbor = quote.to_cbor().unwrap();
1344
1345 let verifier = Verifier::new(PolicyConfig::default());
1346 assert!(verifier
1347 .verify_cbor_with_pki(
1348 &cbor,
1349 device_cert,
1350 vec![],
1351 &anchor,
1352 None,
1353 &nonce,
1354 1_700_000_100
1355 )
1356 .is_ok());
1357 }
1358
1359 #[test]
1365 fn e2e_pki_root_intermediate_device_verification() {
1366 use sha3::{Digest, Sha3_256};
1367
1368 let (root_seed, root_vk) = generate_ml_dsa_keypair().unwrap();
1370 let (int_seed, int_vk) = generate_ml_dsa_keypair().unwrap();
1371 let (dev_seed, dev_vk) = generate_ml_dsa_keypair().unwrap();
1372
1373 let root_anchor = TrustAnchor::new(CaPublicKey {
1375 ca_id: "https://root.pki.example.com".to_string(),
1376 key_bytes: root_vk,
1377 not_before: 0,
1378 not_after: u64::MAX,
1379 })
1380 .unwrap();
1381
1382 let mut int_cert = build_device_certificate(
1384 CERT_VERSION,
1385 "INT-CA-001".to_string(),
1386 "https://root.pki.example.com".to_string(),
1387 0,
1388 u64::MAX,
1389 int_vk.to_vec(),
1390 pqrascv_core::crypto::pub_key_id(&int_vk),
1391 HardwareIdentity::TpmEkCertHash([0u8; 32]),
1392 None,
1393 vec![],
1394 "https://int.pki.example.com".to_string(),
1395 Some(1), );
1397 sign_cert(&mut int_cert, root_seed.as_bytes());
1398
1399 let device_cert = make_device_cert(
1401 &dev_vk,
1402 "https://int.pki.example.com",
1403 "DEV-E2E-001",
1404 int_seed.as_bytes(),
1405 );
1406
1407 let firmware: &[u8] = b"enterprise firmware v1.0";
1409 let fw_hash: [u8; 32] = Sha3_256::digest(firmware).into();
1410 let nonce = [0xF0u8; 32];
1411
1412 let provenance = SlsaPredicateBuilder::new("https://ci.example.com")
1413 .add_subject("firmware.bin", &fw_hash)
1414 .with_slsa_level(2)
1415 .build()
1416 .unwrap();
1417
1418 let rot = SoftwareRoT::new(firmware, None, 0);
1419 let quote = generate_quote(
1420 &rot,
1421 &MlDsaBackend,
1422 dev_seed.as_bytes(),
1423 &dev_vk,
1424 &nonce,
1425 provenance,
1426 QuoteTimestamp::Rtc(1_700_001_000),
1427 )
1428 .unwrap();
1429 let cbor = quote.to_cbor().unwrap();
1430
1431 let verifier = Verifier::new(PolicyConfig::default());
1433 let result = verifier
1434 .verify_cbor_with_pki(
1435 &cbor,
1436 device_cert,
1437 vec![int_cert], &root_anchor,
1439 None,
1440 &nonce,
1441 1_700_001_100,
1442 )
1443 .unwrap();
1444
1445 assert_eq!(result.firmware_hash(), &fw_hash);
1447 assert_eq!(result.device_serial(), "DEV-E2E-001");
1448 assert_eq!(result.trust_anchor_id(), "https://root.pki.example.com");
1449 assert_eq!(result.nonce(), &nonce);
1450 }
1451}