Skip to main content

pqrascv_verifier/
lib.rs

1//! # pqrascv-verifier
2//!
3//! Reference verifier for the PQ-RASCV protocol.
4//!
5//! ## Verification procedure
6//!
7//! 1. Deserialize the CBOR-encoded [`AttestationQuote`] from the prover.
8//! 2. Re-serialize the [`QuoteBody`] to reproduce the exact bytes that were signed.
9//! 3. Verify the ML-DSA-65 signature using the prover's known verifying key.
10//! 4. Check `body.pub_key_id` matches the expected key fingerprint.
11//! 5. Apply [`PolicyConfig`] (SLSA level, age, firmware hash presence, etc.).
12//!
13//! This crate is `std`-only and intended for server-side or CI use.
14
15use 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// ────────────────────────────────────────────────────────────────────────────
32// VerificationResult
33// ────────────────────────────────────────────────────────────────────────────
34
35/// Outcome of a successful attestation verification.
36///
37/// Only returned when ALL checks pass — errors surface as `Err(PqRascvError)`.
38#[derive(Debug)]
39pub struct VerificationResult {
40    /// The verified quote, available for further inspection.
41    pub quote: AttestationQuote,
42}
43
44impl VerificationResult {
45    /// SLSA level claimed by the prover's provenance predicate.
46    #[must_use]
47    pub fn slsa_level(&self) -> u8 {
48        self.quote.body.provenance.slsa_level()
49    }
50
51    /// SHA3-256 digest of the firmware image that was measured.
52    #[must_use]
53    pub fn firmware_hash(&self) -> &[u8; 32] {
54        &self.quote.body.measurements.firmware_hash
55    }
56
57    /// The nonce that was bound into this quote.
58    #[must_use]
59    pub fn nonce(&self) -> &[u8; 32] {
60        &self.quote.body.nonce
61    }
62}
63
64// ────────────────────────────────────────────────────────────────────────────
65// PkiVerificationResult
66// ────────────────────────────────────────────────────────────────────────────
67
68/// Outcome of a successful PKI-backed attestation verification.
69///
70/// Both the ML-DSA-65 signature and the certificate chain are verified.
71/// The signing key is derived exclusively from the validated certificate.
72#[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    /// The CA identifier URI of the trust anchor that validated this chain.
97    #[must_use]
98    pub fn trust_anchor_id(&self) -> &str {
99        &self.cert_chain.trust_anchor.ca_id
100    }
101
102    /// SHA3-256 fingerprint of the trust anchor's public key.
103    #[must_use]
104    pub fn trust_anchor_fingerprint(&self) -> &[u8; 32] {
105        &self.cert_chain.trust_anchor.fingerprint
106    }
107
108    /// Unix timestamp after which the trust anchor must not be used.
109    #[must_use]
110    pub fn trust_anchor_valid_until(&self) -> u64 {
111        self.cert_chain.trust_anchor.not_after
112    }
113}
114
115// ────────────────────────────────────────────────────────────────────────────
116// Verifier
117// ────────────────────────────────────────────────────────────────────────────
118
119/// Stateless PQ-RASCV quote verifier.
120///
121/// # Example
122///
123/// ```rust,no_run
124/// use pqrascv_verifier::Verifier;
125/// use pqrascv_core::config::PolicyConfig;
126///
127/// // cbor, vk, nonce, and timestamp come from your protocol layer.
128/// let verifier = Verifier::new(PolicyConfig::default());
129/// // let result = verifier.verify_cbor(&cbor, &vk, &nonce, now_secs);
130/// ```
131pub struct Verifier {
132    policy: PolicyConfig,
133    engine: PolicyEngineV2,
134}
135
136impl Verifier {
137    /// Creates a new [`Verifier`] with the given policy.
138    #[must_use]
139    pub fn new(policy: PolicyConfig) -> Self {
140        Self {
141            policy,
142            engine: PolicyEngineV2::new(vec![]),
143        }
144    }
145
146    /// Creates a [`Verifier`] with both a legacy [`PolicyConfig`] and a
147    /// [`PolicyEngineV2`]. The engine is evaluated on every verification
148    /// path with the richest context available.
149    #[must_use]
150    pub fn with_engine(policy: PolicyConfig, engine: PolicyEngineV2) -> Self {
151        Self { policy, engine }
152    }
153
154    /// Verifies a CBOR-encoded [`AttestationQuote`].
155    ///
156    /// # Arguments
157    ///
158    /// - `cbor`: raw CBOR bytes received from the prover.
159    /// - `verifying_key`: the prover's trusted ML-DSA-65 verifying key bytes.
160    /// - `expected_nonce`: the nonce sent in the [`Challenge`]; must match `body.nonce`.
161    /// - `now_secs`: current Unix time for age-check policy evaluation.
162    ///
163    /// # Replay Protection — Caller Obligation
164    ///
165    /// This method does **not** consume from a [`NonceLedger`], so on its own it
166    /// will accept the same nonce again. Prefer
167    /// [`verify_cbor_consuming`](Self::verify_cbor_consuming), which verifies and
168    /// consumes in one call so the replay gate cannot be forgotten. If you call
169    /// this method directly you must `ledger.consume(&nonce)` yourself.
170    ///
171    /// [`NonceLedger`]: pqrascv_core::nonce::NonceLedger
172    ///
173    /// # Errors
174    ///
175    /// Returns the first [`PqRascvError`] encountered.
176    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(&quote, verifying_key, expected_nonce, now_secs)?;
186
187        Ok(VerificationResult { quote })
188    }
189
190    /// Convenience wrapper that takes a [`Challenge`] directly instead of a raw nonce.
191    ///
192    /// Use this when you generated the challenge with [`Challenge::new`] and want
193    /// to pair it with the quote the prover returned.
194    ///
195    /// # Replay Protection — Caller Obligation
196    ///
197    /// Same as [`verify_cbor`]: this method does not consume from a [`NonceLedger`].
198    /// Call `ledger.consume_handle(handle)` separately.
199    ///
200    /// [`verify_cbor`]: Self::verify_cbor
201    /// [`NonceLedger`]: pqrascv_core::nonce::NonceLedger
202    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    /// Verifies a CBOR quote **and** consumes its nonce from `ledger`, enforcing
213    /// single-use replay protection in one call so it cannot be forgotten.
214    ///
215    /// The nonce is consumed only after the quote verifies, so a failed
216    /// verification does not burn the nonce. A replay — whose nonce the ledger
217    /// has already consumed — is rejected by the ledger's single-use guarantee,
218    /// so the call **fails closed**. Prefer this over
219    /// [`verify_cbor`](Self::verify_cbor) wherever a [`NonceLedger`] is available.
220    ///
221    /// # Errors
222    ///
223    /// Propagates any verification error, or the ledger error (typically
224    /// [`PqRascvError::InvalidNonce`]) if the nonce was already consumed or was
225    /// never registered.
226    ///
227    /// [`NonceLedger`]: pqrascv_core::nonce::NonceLedger
228    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        // Single-use: consume after a successful verify. The ledger rejects a
238        // replayed (already-consumed) nonce, so this is the replay gate.
239        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    /// [`verify_with_challenge`](Self::verify_with_challenge) plus single-use
247    /// nonce consumption — see [`verify_cbor_consuming`](Self::verify_cbor_consuming).
248    ///
249    /// # Errors
250    ///
251    /// Same as [`verify_cbor_consuming`](Self::verify_cbor_consuming).
252    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    /// Verifies a CBOR quote using a cryptographically-validated certificate chain.
264    ///
265    /// The signing key is extracted exclusively from the validated certificate.
266    /// No caller-supplied verifying key is accepted — the chain IS the source of trust.
267    ///
268    /// # Arguments
269    ///
270    /// - `device_cert`: leaf certificate for the attesting device.
271    /// - `intermediates`: ordered CA chain from root-adjacent to device-adjacent (may be empty).
272    /// - `trust_anchor`: root CA trust anchor.
273    /// - `crl`: optional verified CRL; if `Some`, the device serial is checked for revocation.
274    #[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(&quote, &chain.device_cert.subject_key, expected_nonce)?;
295
296        validate_hardware_identity(
297            &chain.device_cert.hardware_identity,
298            &quote.body.measurements,
299        )?;
300
301        self.policy.evaluate(
302            quote.body.provenance.slsa_level(),
303            &quote.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(&quote, 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    /// Verifies a CBOR quote using any currently-valid anchor in a [`TrustStore`].
319    ///
320    /// Tries each valid (temporally active) anchor in insertion order. Returns
321    /// [`PqRascvError::TrustAnchorExpired`] if the store has no valid anchor,
322    /// or [`PqRascvError::CertificateInvalid`] if no valid anchor accepts the chain.
323    ///
324    /// # Arguments
325    ///
326    /// - `trust_store`: holds primary and rollover root CA anchors.
327    /// - `crl`: optional verified CRL; if `Some`, the device serial is checked for revocation.
328    #[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(&quote, &chain.device_cert.subject_key, expected_nonce)?;
349
350        validate_hardware_identity(
351            &chain.device_cert.hardware_identity,
352            &quote.body.measurements,
353        )?;
354
355        self.policy.evaluate(
356            quote.body.provenance.slsa_level(),
357            &quote.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(&quote, 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    /// Verifies a CBOR quote and an accompanying Sigstore provenance bundle.
373    ///
374    /// Runs the standard quote verification first (ML-DSA-65 signature, nonce, policy),
375    /// then verifies all 10 conditions of the Provenance Enforcement Invariant via
376    /// [`ExternalProvenanceBundle::verify_all`].  Both checks must pass — an error
377    /// from either is returned immediately.
378    ///
379    /// The `firmware_hash` used for provenance binding is the one measured and signed
380    /// inside the quote (`body.measurements.firmware_hash`).  The Sigstore bundle must
381    /// bind the same hash to pass condition 7.
382    ///
383    /// # Arguments
384    ///
385    /// - `bundle`: Sigstore provenance bundle provided by the CI/CD pipeline alongside
386    ///   the quote.  Must bind the same firmware hash measured in the quote.
387    /// - `sigstore_config`: Rekor public key, Fulcio root cert, OIDC issuer, and
388    ///   allowed builder list.  There are no insecure defaults — every field is required.
389    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(&quote, verifying_key, expected_nonce)?;
400
401        let vp: VerifiedProvenance = bundle.verify_all(
402            sigstore_config,
403            &quote.body.measurements.firmware_hash,
404            now_secs,
405        )?;
406
407        self.policy.evaluate(
408            quote.body.provenance.slsa_level(),
409            &quote.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(&quote, 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            &quote.signature,
442            SIGNING_CONTEXT_QUOTE,
443        )?;
444        Ok(())
445    }
446
447    /// Verifies an already-parsed [`AttestationQuote`]. Useful if you've already
448    /// deserialized the CBOR yourself and don't want to do it twice.
449    ///
450    /// Emits a `tracing` span; verification failures are logged at error level
451    /// (via `instrument(err)`) and a success is logged at debug level, so an
452    /// operator with a subscriber installed gets per-verification observability.
453    #[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            &quote.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// ────────────────────────────────────────────────────────────────────────────
483// Tests
484// ────────────────────────────────────────────────────────────────────────────
485
486#[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        // First presentation: verifies and consumes the nonce.
555        assert!(verifier
556            .verify_cbor_consuming(&cbor, &vk, &nonce, 1_700_000_600, &mut ledger)
557            .is_ok());
558        // Replay of the same quote+nonce: verification still passes, but the
559        // ledger rejects the already-consumed nonce, so the call fails closed.
560        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        // A wrong expected nonce fails verification BEFORE the consume step, so
578        // the legitimately-registered nonce remains available.
579        assert!(verifier
580            .verify_cbor_consuming(&cbor, &vk, &[0x00u8; 32], 1_700_000_600, &mut ledger)
581            .is_err());
582        // The real nonce is still consumable by the legitimate attempt.
583        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        // Tamper with the version field — signature will break too, but version
618        // check must fire first and return UnsupportedVersion.
619        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        // Predicate with a valid subject, but predicate_hash is wrong — condition 1 fails.
686        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        // Wrong nonce → quote fails before bundle is checked.
758        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        // Anchor uses `other_vk` but cert was signed by `ca_seed`
986        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        // root CA signs intermediate CA; intermediate CA signs device cert
1027        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        // Intermediate cert: signed by root, self_id = "https://int.test", max_path_length = None
1040        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(), // issuer_id must match root ca_id
1045            0,
1046            u64::MAX,
1047            int_vk.to_vec(),
1048            int_subject_key_id,
1049            HardwareIdentity::TpmEkCertHash([0u8; 32]),
1050            None,
1051            vec![],                         // issuer_signature filled in below
1052            "https://int.test".to_string(), // self_id
1053            None,                           // no path length constraint — can sign device cert
1054        );
1055        sign_cert(&mut intermediate, root_seed.as_bytes());
1056
1057        // Device cert: signed by intermediate
1058        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        // TpmEkCertHash always passes (no PCR cross-check for TPM).
1317        // Confirms validate_hardware_identity doesn't break the TPM happy path.
1318        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        // Re-use the make_device_cert helper already defined in pki_tests.
1328        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    /// End-to-end PKI integration test: Root CA → Intermediate CA → Device.
1360    ///
1361    /// This covers the full 3-tier certificate hierarchy described in the README.
1362    /// The verifier must traverse two hops (device → intermediate → root) and
1363    /// confirm both signatures before accepting the attestation quote.
1364    #[test]
1365    fn e2e_pki_root_intermediate_device_verification() {
1366        use sha3::{Digest, Sha3_256};
1367
1368        // ── 1. Generate keys for all three tiers ──────────────────────────────
1369        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        // ── 2. Root CA trust anchor ───────────────────────────────────────────
1374        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        // ── 3. Intermediate CA certificate (signed by root CA) ────────────────
1383        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), // may sign one more level of certs
1396        );
1397        sign_cert(&mut int_cert, root_seed.as_bytes());
1398
1399        // ── 4. Device certificate (signed by intermediate CA) ─────────────────
1400        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        // ── 5. Quote generation using the device key ──────────────────────────
1408        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        // ── 6. Full PKI verification ──────────────────────────────────────────
1432        let verifier = Verifier::new(PolicyConfig::default());
1433        let result = verifier
1434            .verify_cbor_with_pki(
1435                &cbor,
1436                device_cert,
1437                vec![int_cert], // intermediate sits between device and root
1438                &root_anchor,
1439                None,
1440                &nonce,
1441                1_700_001_100,
1442            )
1443            .unwrap();
1444
1445        // ── 7. Assertions ─────────────────────────────────────────────────────
1446        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}