Skip to main content

arkhe_forge_platform/
verifier.rs

1//! Audit-receipt attestation verifier — L2 erasure-receipt signature check.
2//!
3//! Verifies the signatures carried by L2 audit receipts under a
4//! policy-pinned [`RuntimeSignatureClass`]. Mirrors the L0 kernel's
5//! WAL-record signature pattern (domain separation, canonical decode,
6//! Hybrid AND-mode) but is a self-contained L2 surface: the kernel's
7//! `PqcSigner`/`PqcVerifier` are sealed + `pub(crate)` + WAL-only, so this
8//! module takes a direct `ml-dsa` dependency (gated on `tier-2-pqc-receipts`).
9//!
10//! # Feature layering
11//!
12//! The module is exported unconditionally (`pub mod verifier;`), so the
13//! dispatcher, [`VerifyError`], the domain helper, the Ed25519 arm, and
14//! [`dek_shred_message`] compile in the DEFAULT build. The ML-DSA-using
15//! code (`verify_ml_dsa65`'s real body, `ReceiptSigner`) is gated on
16//! `tier-2-pqc-receipts`; the default build links a stub
17//! `verify_ml_dsa65` that returns [`VerifyError::PqcUnavailable`].
18
19use arkhe_forge_core::event::RuntimeSignatureClass;
20use ed25519_dalek::{Signature, VerifyingKey};
21
22#[cfg(feature = "tier-2-pqc-receipts")]
23use ml_dsa::signature::{Keypair, Signer};
24#[cfg(feature = "tier-2-pqc-receipts")]
25use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, B32};
26#[cfg(feature = "tier-2-pqc-receipts")]
27use zeroize::Zeroize;
28
29/// Ed25519 public-key width (RFC 8032).
30const ED25519_PK_LEN: usize = 32;
31/// Ed25519 signature width (RFC 8032).
32const ED25519_SIG_LEN: usize = 64;
33/// ML-DSA-65 verifying-key width (NIST FIPS 204 §4).
34const MLDSA65_PK_LEN: usize = 1952;
35/// ML-DSA-65 signature width (NIST FIPS 204 §4).
36const MLDSA65_SIG_LEN: usize = 3309;
37
38/// Domain-separation prefix bound into every L2 audit-receipt signature.
39///
40/// Distinct from the kernel WAL-record domain
41/// (`arkhe-kernel v0.14 WAL record signature domain` — the kernel
42/// deliberately holds that literal at its v0.14 wording through the
43/// v0.15 epoch; only the chain `DOMAIN_CTX` advanced, so this cite must
44/// stay byte-matched, not version-synced) — signing
45/// `FORGE_RECEIPT_SIG_DOMAIN || message` scopes a signature to the
46/// audit-receipt domain so the same key reused in the WAL protocol (or
47/// any other) cannot yield a cross-valid signature (prevents
48/// cross-protocol key reuse). Applied symmetrically on sign and verify.
49pub(crate) const FORGE_RECEIPT_SIG_DOMAIN: &[u8] =
50    b"arkhe-forge v0.15 audit receipt attestation domain";
51
52/// Prefix `message` with the audit-receipt domain tag (`TAG ++ message`).
53///
54/// Single source of truth — both the signer ([`ReceiptSigner`]) and the
55/// verifier ([`verify_attestation`]) route every message through this
56/// helper, so the domain binding is symmetric by construction.
57pub(crate) fn domain_separated(message: &[u8]) -> Vec<u8> {
58    let mut m = Vec::with_capacity(FORGE_RECEIPT_SIG_DOMAIN.len() + message.len());
59    m.extend_from_slice(FORGE_RECEIPT_SIG_DOMAIN);
60    m.extend_from_slice(message);
61    m
62}
63
64/// Failure modes for [`verify_attestation`].
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
67pub enum VerifyError {
68    /// The policy-pinned class was [`RuntimeSignatureClass::None`] — a
69    /// receipt that carries no signature cannot satisfy a class that
70    /// requires one.
71    #[error("a signature is required but the policy class is None")]
72    SignatureRequired,
73    /// The supplied public key was not the expected width for the class.
74    #[error("public key has the wrong length for the signature class")]
75    WrongKeyLength,
76    /// The supplied signature was not the expected width for the class.
77    #[error("signature has the wrong length for the signature class")]
78    WrongSignatureLength,
79    /// The signature did not validate against the message under the key.
80    #[error("signature did not validate")]
81    Mismatch,
82    /// An ML-DSA-65 verify was requested but the `tier-2-pqc-receipts`
83    /// feature is not compiled in.
84    #[error("PQC verification unavailable; build with feature tier-2-pqc-receipts")]
85    PqcUnavailable,
86    /// The receipt envelope is internally inconsistent — e.g. an
87    /// `MlDsa65`/`Hybrid` algorithm with no PQC signature material.
88    #[error("receipt envelope is internally incoherent")]
89    EnvelopeIncoherent,
90}
91
92/// Verify an audit-receipt attestation under a **policy-pinned** class.
93///
94/// # Security contract — `class` is policy-pinned, never wire-sourced
95///
96/// `class` is the REQUIRED signature class taken out-of-band from the
97/// trusted policy: the manifest `audit.signature_class` (the E13 axiom)
98/// or the audit-receipt key-policy `algorithm` field. It MUST NOT be
99/// read from the attacker-controlled receipt envelope field. A caller
100/// that pinned `class` from the wire would turn this function into a
101/// downgrade oracle: an attacker could present a receipt claiming
102/// `Hybrid` while passing `class = Ed25519`, skipping the ML-DSA half
103/// entirely and defeating the post-quantum guarantee. The policy class
104/// is the only safe source.
105///
106/// The message is domain-separated exactly once and the SAME bytes are
107/// passed to every arm — for `Hybrid` this guarantees both halves bind
108/// the identical message (AND-mode soundness).
109///
110/// # Errors
111///
112/// Returns [`VerifyError`] when the key/signature widths are wrong, the
113/// signature does not validate, the policy class is `None`, or (for an
114/// ML-DSA arm) the `tier-2-pqc-receipts` feature is absent.
115pub fn verify_attestation(
116    class: RuntimeSignatureClass,
117    public_key: &[u8],
118    message: &[u8],
119    sig: &[u8],
120) -> Result<(), VerifyError> {
121    // Domain-separate once; reuse the same bytes across every arm so the
122    // Hybrid halves bind an identical message (AND-mode soundness).
123    let m = domain_separated(message);
124    match class {
125        RuntimeSignatureClass::None => Err(VerifyError::SignatureRequired),
126        RuntimeSignatureClass::Ed25519 => verify_ed25519(public_key, &m, sig),
127        RuntimeSignatureClass::MlDsa65 => verify_ml_dsa65(public_key, &m, sig),
128        RuntimeSignatureClass::Hybrid => {
129            // Split key into ed25519(32) ++ mldsa(1952) and signature into
130            // ed25519(64) ++ mldsa(3309) with EXPLICIT length checks. We do
131            // not use `split_at_checked` — it sits at the 1.80 MSRV
132            // knife-edge; an explicit length guard + slicing is portable.
133            if public_key.len() != ED25519_PK_LEN + MLDSA65_PK_LEN {
134                return Err(VerifyError::WrongKeyLength);
135            }
136            if sig.len() != ED25519_SIG_LEN + MLDSA65_SIG_LEN {
137                return Err(VerifyError::WrongSignatureLength);
138            }
139            let (ed_pk, mldsa_pk) = public_key.split_at(ED25519_PK_LEN);
140            let (ed_sig, mldsa_sig) = sig.split_at(ED25519_SIG_LEN);
141            // AND-mode: both must pass. Verify Ed25519 first (cheap), then
142            // ML-DSA-65 — short-circuit on the first failure.
143            verify_ed25519(ed_pk, &m, ed_sig)?;
144            verify_ml_dsa65(mldsa_pk, &m, mldsa_sig)
145        }
146        // `RuntimeSignatureClass` is `#[non_exhaustive]`; a future variant
147        // has no defined verify semantics here and is rejected.
148        _ => Err(VerifyError::Mismatch),
149    }
150}
151
152/// Verify an audit-receipt attestation envelope.
153///
154/// `algorithm` is the POLICY-PINNED class (the manifest
155/// `audit.signature_class` / the audit-receipt key-policy `algorithm`
156/// field), never the wire field — see [`verify_attestation`] for why a
157/// wire-sourced class is a downgrade oracle.
158///
159/// This is the wire-shape entry point: the envelope splits its
160/// signature material into the 64-byte classical half (`attestation_64`,
161/// the key-policy `attestation` bytes) and an optional PQC half
162/// (`attestation_pqc`, the key-policy `attestation_pqc` slot). It first
163/// enforces algorithm<->slot COHERENCE — the PQC slot must be present
164/// iff the class is PQC-bearing (`MlDsa65`/`Hybrid`) — then assembles
165/// the canonical signature bytes and dispatches to
166/// [`verify_attestation`].
167///
168/// # Errors
169///
170/// - [`VerifyError::EnvelopeIncoherent`] — the PQC slot presence does
171///   not match the policy class, or a pure-PQC (`MlDsa65`) envelope
172///   carries a non-empty classical half.
173/// - [`VerifyError::WrongSignatureLength`] — a `Hybrid` envelope's
174///   classical half is not exactly 64 bytes (`ED25519_SIG_LEN`).
175/// - [`VerifyError::SignatureRequired`] — the class is `None`.
176/// - any error from [`verify_attestation`] for the dispatched arm.
177pub fn verify_receipt_envelope(
178    algorithm: RuntimeSignatureClass,
179    public_key: &[u8],
180    message: &[u8],
181    attestation_64: &[u8],
182    attestation_pqc: Option<&[u8]>,
183) -> Result<(), VerifyError> {
184    // Coherence: the PQC slot must be populated iff the policy class
185    // carries a post-quantum half. A mismatch is an incoherent envelope
186    // (e.g. an MlDsa65 receipt with no PQC bytes, or an Ed25519 receipt
187    // smuggling PQC bytes), rejected before any verify.
188    let pqc_required = matches!(
189        algorithm,
190        RuntimeSignatureClass::MlDsa65 | RuntimeSignatureClass::Hybrid
191    );
192    if attestation_pqc.is_some() != pqc_required {
193        return Err(VerifyError::EnvelopeIncoherent);
194    }
195    match algorithm {
196        RuntimeSignatureClass::None => Err(VerifyError::SignatureRequired),
197        RuntimeSignatureClass::Ed25519 => verify_attestation(
198            RuntimeSignatureClass::Ed25519,
199            public_key,
200            message,
201            attestation_64,
202        ),
203        RuntimeSignatureClass::MlDsa65 => {
204            // A pure-PQC class carries NO classical half; a non-empty
205            // `attestation_64` is dead material the dispatch would ignore,
206            // so reject it up front (mirrors the PQC-slot strictness).
207            if !attestation_64.is_empty() {
208                return Err(VerifyError::EnvelopeIncoherent);
209            }
210            // pqc_required holds => the slot is Some; bind it without
211            // unwrap so a future refactor can never panic here.
212            match attestation_pqc {
213                Some(pqc) => {
214                    verify_attestation(RuntimeSignatureClass::MlDsa65, public_key, message, pqc)
215                }
216                None => Err(VerifyError::EnvelopeIncoherent),
217            }
218        }
219        RuntimeSignatureClass::Hybrid => match attestation_pqc {
220            Some(pqc) => {
221                // Validate the classical-half width BEFORE concat so the
222                // ed25519/mldsa boundary is canonical at the envelope
223                // layer, not re-imposed downstream by split_at. A wrong
224                // width would otherwise survive into a total-length-only
225                // check and re-split at a different boundary.
226                if attestation_64.len() != ED25519_SIG_LEN {
227                    return Err(VerifyError::WrongSignatureLength);
228                }
229                // Reassemble the canonical Hybrid signature:
230                // ed25519(64) ++ mldsa(3309). verify_attestation applies
231                // the explicit length checks.
232                let mut sig = Vec::with_capacity(attestation_64.len() + pqc.len());
233                sig.extend_from_slice(attestation_64);
234                sig.extend_from_slice(pqc);
235                verify_attestation(RuntimeSignatureClass::Hybrid, public_key, message, &sig)
236            }
237            None => Err(VerifyError::EnvelopeIncoherent),
238        },
239        // Future `#[non_exhaustive]` variant: no defined envelope shape.
240        _ => Err(VerifyError::Mismatch),
241    }
242}
243
244/// Verify an Ed25519 signature over `m` under `vk_bytes`.
245///
246/// `verify_strict` rejects non-canonical / small-order signatures (RFC
247/// 8032 §5.1 strict path), closing Ed25519 signature malleability.
248fn verify_ed25519(vk_bytes: &[u8], m: &[u8], sig: &[u8]) -> Result<(), VerifyError> {
249    if vk_bytes.len() != ED25519_PK_LEN {
250        return Err(VerifyError::WrongKeyLength);
251    }
252    if sig.len() != ED25519_SIG_LEN {
253        return Err(VerifyError::WrongSignatureLength);
254    }
255    let mut pk = [0u8; ED25519_PK_LEN];
256    pk.copy_from_slice(vk_bytes);
257    let vk = VerifyingKey::from_bytes(&pk).map_err(|_| VerifyError::Mismatch)?;
258    let mut sb = [0u8; ED25519_SIG_LEN];
259    sb.copy_from_slice(sig);
260    let sig_obj = Signature::from_bytes(&sb);
261    vk.verify_strict(m, &sig_obj)
262        .map_err(|_| VerifyError::Mismatch)
263}
264
265/// Verify an ML-DSA-65 signature over `m` under `vk_bytes` (real path).
266///
267/// Canonical decode: `Signature::decode` returns `Option`, so a
268/// non-canonical signature is rejected. ML-DSA-65 verification is
269/// EUF-CMA secure (NIST FIPS 204); this path does not claim SUF-CMA.
270#[cfg(feature = "tier-2-pqc-receipts")]
271fn verify_ml_dsa65(vk_bytes: &[u8], m: &[u8], sig: &[u8]) -> Result<(), VerifyError> {
272    use ml_dsa::signature::Verifier as _;
273
274    if sig.len() != MLDSA65_SIG_LEN {
275        return Err(VerifyError::WrongSignatureLength);
276    }
277    if vk_bytes.len() != MLDSA65_PK_LEN {
278        return Err(VerifyError::WrongKeyLength);
279    }
280    let mut sb = EncodedSignature::<MlDsa65>::default();
281    sb.as_mut_slice().copy_from_slice(sig);
282    let sig_obj = ml_dsa::Signature::<MlDsa65>::decode(&sb).ok_or(VerifyError::Mismatch)?;
283    let mut kb = EncodedVerifyingKey::<MlDsa65>::default();
284    kb.as_mut_slice().copy_from_slice(vk_bytes);
285    let vk = ml_dsa::VerifyingKey::<MlDsa65>::decode(&kb);
286    vk.verify(m, &sig_obj).map_err(|_| VerifyError::Mismatch)
287}
288
289/// Verify an ML-DSA-65 signature (stub — `tier-2-pqc-receipts` absent).
290///
291/// The default build cannot perform a real ML-DSA verify, so every
292/// ML-DSA arm reports [`VerifyError::PqcUnavailable`] rather than
293/// silently passing.
294#[cfg(not(feature = "tier-2-pqc-receipts"))]
295fn verify_ml_dsa65(_vk_bytes: &[u8], _m: &[u8], _sig: &[u8]) -> Result<(), VerifyError> {
296    Err(VerifyError::PqcUnavailable)
297}
298
299/// Canonical message bytes for a DEK-erasure attestation.
300///
301/// Frozen layout: `dek_id.0 (16) ++ log_index.to_be_bytes() (8)` = 24
302/// bytes. This is the bare message; [`verify_attestation`] /
303/// `ReceiptSigner::sign` apply the domain prefix. The layout is pinned
304/// by a test so any reordering / width change is caught.
305#[must_use]
306pub fn dek_shred_message(dek_id: &arkhe_forge_core::pii::DekId, log_index: u64) -> Vec<u8> {
307    let mut m = Vec::with_capacity(16 + 8);
308    m.extend_from_slice(&dek_id.0);
309    m.extend_from_slice(&log_index.to_be_bytes());
310    m
311}
312
313/// Software ML-DSA-65 receipt signer (NIST FIPS 204, security category 3).
314///
315/// Holds an in-memory `SigningKey<MlDsa65>` plus the cached encoded
316/// verifying-key bytes. `Debug` redacts the signing key. Production HSM /
317/// KMS signers would land via a separate surface; this is the software
318/// path used to attest L2 erasure receipts.
319#[cfg(feature = "tier-2-pqc-receipts")]
320pub struct ReceiptSigner {
321    signing_key: ml_dsa::SigningKey<MlDsa65>,
322    verifying_key_bytes: Vec<u8>,
323}
324
325#[cfg(feature = "tier-2-pqc-receipts")]
326impl ReceiptSigner {
327    /// Construct deterministically from a 32-byte seed (FIPS 204
328    /// ML-DSA.KeyGen_internal — same seed yields the same key pair).
329    ///
330    /// Both transient seed copies (`seed` and the `B32` conversion) are
331    /// scrubbed before return; the long-lived [`ReceiptSigner`] retains
332    /// the key material in the `SigningKey`, which zeroizes on drop via
333    /// the ml-dsa `zeroize` feature.
334    #[must_use]
335    pub fn mldsa65_from_seed(mut seed: [u8; 32]) -> Self {
336        // `seed.into()` creates a second in-memory copy of the keygen
337        // seed; `B32` has no scrubbing Drop, so wipe it explicitly along
338        // with the caller's copy (mirrors the kernel's seed-scrub
339        // discipline).
340        let mut xi: B32 = seed.into();
341        let signing_key = ml_dsa::SigningKey::<MlDsa65>::from_seed(&xi);
342        let verifying_key_bytes = signing_key.verifying_key().encode().to_vec();
343        seed.zeroize();
344        xi.zeroize();
345        Self {
346            signing_key,
347            verifying_key_bytes,
348        }
349    }
350
351    /// Sign `message` and return the 3309-byte ML-DSA-65 signature over
352    /// the domain-separated bytes. ML-DSA `try_sign` is deterministic (no
353    /// RNG) and infallible on the software path.
354    #[must_use]
355    pub fn sign(&self, message: &[u8]) -> Vec<u8> {
356        let m = domain_separated(message);
357        // WHY: try_sign on the software SigningKey is the infallible
358        // deterministic path (no RNG, no provider I/O); a failure here is
359        // a logic bug, not a runtime condition.
360        #[allow(clippy::expect_used)]
361        let sig: ml_dsa::Signature<MlDsa65> = self
362            .signing_key
363            .try_sign(&m)
364            .expect("ml-dsa-65 software try_sign is infallible (deterministic, no RNG)");
365        sig.encode().to_vec()
366    }
367
368    /// Borrow the cached encoded verifying-key bytes (1952 bytes).
369    #[must_use]
370    pub fn public_key_bytes(&self) -> &[u8] {
371        &self.verifying_key_bytes
372    }
373}
374
375#[cfg(feature = "tier-2-pqc-receipts")]
376impl core::fmt::Debug for ReceiptSigner {
377    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
378        write!(
379            f,
380            "ReceiptSigner {{ verifying_key_bytes: <1952B>, signing_key: <redacted> }}"
381        )
382    }
383}
384
385#[cfg(test)]
386#[allow(clippy::unwrap_used, clippy::expect_used)]
387mod tests {
388    use super::*;
389    use arkhe_forge_core::pii::DekId;
390
391    #[test]
392    fn domain_separated_prefixes_tag() {
393        let m = domain_separated(b"payload");
394        assert!(m.starts_with(FORGE_RECEIPT_SIG_DOMAIN));
395        assert_eq!(&m[FORGE_RECEIPT_SIG_DOMAIN.len()..], b"payload");
396    }
397
398    #[test]
399    fn forge_domain_distinct_from_kernel_wal_domain() {
400        // Cross-protocol key-reuse defence: the L2 receipt domain must not
401        // collide with the kernel WAL domain.
402        assert_ne!(
403            FORGE_RECEIPT_SIG_DOMAIN,
404            b"arkhe-kernel v0.14 WAL record signature domain".as_slice()
405        );
406    }
407
408    #[test]
409    fn dek_shred_message_canonical_layout_frozen() {
410        // Frozen layout: dek_id.0 (16) ++ log_index.to_be_bytes() (8).
411        let dek_id = DekId([0xAB; 16]);
412        let m = dek_shred_message(&dek_id, 0x0102_0304_0506_0708);
413        assert_eq!(m.len(), 24);
414        assert_eq!(&m[..16], &[0xAB; 16]);
415        assert_eq!(
416            &m[16..24],
417            &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
418        );
419    }
420
421    #[test]
422    fn none_class_requires_signature() {
423        let err = verify_attestation(RuntimeSignatureClass::None, &[], b"m", &[]).unwrap_err();
424        assert_eq!(err, VerifyError::SignatureRequired);
425    }
426
427    #[test]
428    fn ed25519_round_trip_validates() {
429        use ed25519_dalek::{Signer as _, SigningKey};
430        let sk = SigningKey::from_bytes(&[7u8; 32]);
431        let vk = sk.verifying_key();
432        let message = b"erasure receipt body";
433        let m = domain_separated(message);
434        let sig = sk.sign(&m);
435        assert!(verify_attestation(
436            RuntimeSignatureClass::Ed25519,
437            &vk.to_bytes(),
438            message,
439            &sig.to_bytes()
440        )
441        .is_ok());
442    }
443
444    #[test]
445    fn ed25519_wrong_key_length_rejected() {
446        let err = verify_attestation(RuntimeSignatureClass::Ed25519, &[0u8; 31], b"m", &[0u8; 64])
447            .unwrap_err();
448        assert_eq!(err, VerifyError::WrongKeyLength);
449    }
450
451    #[test]
452    fn ed25519_wrong_sig_length_rejected() {
453        let err = verify_attestation(RuntimeSignatureClass::Ed25519, &[0u8; 32], b"m", &[0u8; 63])
454            .unwrap_err();
455        assert_eq!(err, VerifyError::WrongSignatureLength);
456    }
457
458    #[test]
459    fn ed25519_corrupt_sig_rejected() {
460        use ed25519_dalek::{Signer as _, SigningKey};
461        let sk = SigningKey::from_bytes(&[9u8; 32]);
462        let vk = sk.verifying_key();
463        let message = b"body";
464        let m = domain_separated(message);
465        let mut sig = sk.sign(&m).to_bytes();
466        sig[0] ^= 0xFF;
467        let err = verify_attestation(
468            RuntimeSignatureClass::Ed25519,
469            &vk.to_bytes(),
470            message,
471            &sig,
472        )
473        .unwrap_err();
474        assert_eq!(err, VerifyError::Mismatch);
475    }
476
477    #[cfg(not(feature = "tier-2-pqc-receipts"))]
478    #[test]
479    fn ml_dsa65_unavailable_in_default_build() {
480        let err = verify_attestation(
481            RuntimeSignatureClass::MlDsa65,
482            &[0u8; MLDSA65_PK_LEN],
483            b"m",
484            &[0u8; MLDSA65_SIG_LEN],
485        )
486        .unwrap_err();
487        assert_eq!(err, VerifyError::PqcUnavailable);
488    }
489
490    #[cfg(feature = "tier-2-pqc-receipts")]
491    #[test]
492    fn ml_dsa65_round_trip_validates() {
493        let signer = ReceiptSigner::mldsa65_from_seed([11u8; 32]);
494        let message = b"ml-dsa receipt body";
495        let sig = signer.sign(message);
496        assert_eq!(sig.len(), MLDSA65_SIG_LEN);
497        assert_eq!(signer.public_key_bytes().len(), MLDSA65_PK_LEN);
498        assert!(verify_attestation(
499            RuntimeSignatureClass::MlDsa65,
500            signer.public_key_bytes(),
501            message,
502            &sig
503        )
504        .is_ok());
505    }
506
507    #[cfg(feature = "tier-2-pqc-receipts")]
508    #[test]
509    fn ml_dsa65_corrupt_sig_rejected() {
510        let signer = ReceiptSigner::mldsa65_from_seed([13u8; 32]);
511        let message = b"body";
512        let mut sig = signer.sign(message);
513        sig[0] ^= 0xFF;
514        let err = verify_attestation(
515            RuntimeSignatureClass::MlDsa65,
516            signer.public_key_bytes(),
517            message,
518            &sig,
519        )
520        .unwrap_err();
521        assert_eq!(err, VerifyError::Mismatch);
522    }
523
524    #[cfg(feature = "tier-2-pqc-receipts")]
525    #[test]
526    fn hybrid_round_trip_validates_and_mode() {
527        use ed25519_dalek::{Signer as _, SigningKey};
528        let ed_sk = SigningKey::from_bytes(&[23u8; 32]);
529        let ed_vk = ed_sk.verifying_key();
530        let pqc = ReceiptSigner::mldsa65_from_seed([29u8; 32]);
531        let message = b"hybrid receipt body";
532        let m = domain_separated(message);
533        let ed_sig = ed_sk.sign(&m).to_bytes();
534        let pqc_sig = pqc.sign(message);
535
536        let mut public_key = Vec::new();
537        public_key.extend_from_slice(&ed_vk.to_bytes());
538        public_key.extend_from_slice(pqc.public_key_bytes());
539        let mut sig = Vec::new();
540        sig.extend_from_slice(&ed_sig);
541        sig.extend_from_slice(&pqc_sig);
542        assert_eq!(public_key.len(), ED25519_PK_LEN + MLDSA65_PK_LEN);
543        assert_eq!(sig.len(), ED25519_SIG_LEN + MLDSA65_SIG_LEN);
544
545        assert!(
546            verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig).is_ok()
547        );
548    }
549
550    #[cfg(feature = "tier-2-pqc-receipts")]
551    #[test]
552    fn hybrid_rejects_when_pqc_half_corrupt() {
553        // AND-mode: a valid Ed25519 half with a broken ML-DSA half fails.
554        use ed25519_dalek::{Signer as _, SigningKey};
555        let ed_sk = SigningKey::from_bytes(&[31u8; 32]);
556        let ed_vk = ed_sk.verifying_key();
557        let pqc = ReceiptSigner::mldsa65_from_seed([37u8; 32]);
558        let message = b"body";
559        let m = domain_separated(message);
560        let ed_sig = ed_sk.sign(&m).to_bytes();
561        let mut pqc_sig = pqc.sign(message);
562        pqc_sig[0] ^= 0xFF;
563
564        let mut public_key = Vec::new();
565        public_key.extend_from_slice(&ed_vk.to_bytes());
566        public_key.extend_from_slice(pqc.public_key_bytes());
567        let mut sig = Vec::new();
568        sig.extend_from_slice(&ed_sig);
569        sig.extend_from_slice(&pqc_sig);
570        let err = verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig)
571            .unwrap_err();
572        assert_eq!(err, VerifyError::Mismatch);
573    }
574
575    #[cfg(feature = "tier-2-pqc-receipts")]
576    #[test]
577    fn hybrid_rejects_when_ed25519_half_corrupt() {
578        // AND-mode: a broken Ed25519 half (with valid ML-DSA) fails first.
579        use ed25519_dalek::{Signer as _, SigningKey};
580        let ed_sk = SigningKey::from_bytes(&[41u8; 32]);
581        let ed_vk = ed_sk.verifying_key();
582        let pqc = ReceiptSigner::mldsa65_from_seed([43u8; 32]);
583        let message = b"body";
584        let m = domain_separated(message);
585        let mut ed_sig = ed_sk.sign(&m).to_bytes();
586        ed_sig[0] ^= 0xFF;
587        let pqc_sig = pqc.sign(message);
588
589        let mut public_key = Vec::new();
590        public_key.extend_from_slice(&ed_vk.to_bytes());
591        public_key.extend_from_slice(pqc.public_key_bytes());
592        let mut sig = Vec::new();
593        sig.extend_from_slice(&ed_sig);
594        sig.extend_from_slice(&pqc_sig);
595        let err = verify_attestation(RuntimeSignatureClass::Hybrid, &public_key, message, &sig)
596            .unwrap_err();
597        assert_eq!(err, VerifyError::Mismatch);
598    }
599
600    #[cfg(feature = "tier-2-pqc-receipts")]
601    #[test]
602    fn hybrid_wrong_key_length_rejected() {
603        let err = verify_attestation(
604            RuntimeSignatureClass::Hybrid,
605            &[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN - 1],
606            b"m",
607            &[0u8; ED25519_SIG_LEN + MLDSA65_SIG_LEN],
608        )
609        .unwrap_err();
610        assert_eq!(err, VerifyError::WrongKeyLength);
611    }
612
613    #[cfg(feature = "tier-2-pqc-receipts")]
614    #[test]
615    fn hybrid_wrong_sig_length_rejected() {
616        let err = verify_attestation(
617            RuntimeSignatureClass::Hybrid,
618            &[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN],
619            b"m",
620            &[0u8; ED25519_SIG_LEN + MLDSA65_SIG_LEN - 1],
621        )
622        .unwrap_err();
623        assert_eq!(err, VerifyError::WrongSignatureLength);
624    }
625
626    #[cfg(feature = "tier-2-pqc-receipts")]
627    #[test]
628    fn receipt_signer_debug_redacts_key() {
629        let signer = ReceiptSigner::mldsa65_from_seed([0u8; 32]);
630        let dbg = format!("{:?}", signer);
631        assert!(dbg.contains("<redacted>"));
632    }
633
634    #[test]
635    fn envelope_ed25519_ok() {
636        use ed25519_dalek::{Signer as _, SigningKey};
637        let sk = SigningKey::from_bytes(&[59u8; 32]);
638        let vk = sk.verifying_key();
639        let message = b"envelope ed25519 body";
640        let m = domain_separated(message);
641        let sig = sk.sign(&m).to_bytes();
642        assert!(verify_receipt_envelope(
643            RuntimeSignatureClass::Ed25519,
644            &vk.to_bytes(),
645            message,
646            &sig,
647            None,
648        )
649        .is_ok());
650    }
651
652    #[test]
653    fn envelope_incoherent_ed25519_with_pqc_slot() {
654        // Ed25519 must NOT carry a PQC slot.
655        let err = verify_receipt_envelope(
656            RuntimeSignatureClass::Ed25519,
657            &[0u8; ED25519_PK_LEN],
658            b"m",
659            &[0u8; ED25519_SIG_LEN],
660            Some(&[0u8; MLDSA65_SIG_LEN]),
661        )
662        .unwrap_err();
663        assert_eq!(err, VerifyError::EnvelopeIncoherent);
664    }
665
666    #[test]
667    fn envelope_incoherent_mldsa65_without_pqc_slot() {
668        // MlDsa65 requires a PQC slot — a None slot is incoherent.
669        let err = verify_receipt_envelope(
670            RuntimeSignatureClass::MlDsa65,
671            &[0u8; MLDSA65_PK_LEN],
672            b"m",
673            &[],
674            None,
675        )
676        .unwrap_err();
677        assert_eq!(err, VerifyError::EnvelopeIncoherent);
678    }
679
680    #[test]
681    fn envelope_none_class_requires_signature() {
682        let err =
683            verify_receipt_envelope(RuntimeSignatureClass::None, &[], b"m", &[], None).unwrap_err();
684        assert_eq!(err, VerifyError::SignatureRequired);
685    }
686
687    #[test]
688    fn envelope_incoherent_mldsa65_with_nonempty_classical_slot() {
689        // A pure-PQC MlDsa65 envelope carries no classical half; a
690        // non-empty `attestation_64` is dead material and must be rejected
691        // before dispatch (mirrors the PQC-slot strictness). The PQC slot
692        // is present so the slot-presence coherence check passes and this
693        // guard is the one exercised — feature-agnostic (it fires before
694        // any verify, so it holds in both the default and the
695        // tier-2-pqc-receipts build).
696        let err = verify_receipt_envelope(
697            RuntimeSignatureClass::MlDsa65,
698            &[0u8; MLDSA65_PK_LEN],
699            b"m",
700            &[0u8; ED25519_SIG_LEN],
701            Some(&[0u8; MLDSA65_SIG_LEN]),
702        )
703        .unwrap_err();
704        assert_eq!(err, VerifyError::EnvelopeIncoherent);
705    }
706
707    #[test]
708    fn envelope_hybrid_mis_split_classical_half_rejected() {
709        // The Hybrid classical half must be exactly ED25519_SIG_LEN before
710        // concat — a mis-sized classical half is rejected up front rather
711        // than re-split at a non-canonical boundary downstream. The PQC
712        // slot is present so the slot-presence coherence check passes;
713        // this width guard fires before any verify (feature-agnostic).
714        let err = verify_receipt_envelope(
715            RuntimeSignatureClass::Hybrid,
716            &[0u8; ED25519_PK_LEN + MLDSA65_PK_LEN],
717            b"m",
718            &[0u8; ED25519_SIG_LEN - 1],
719            Some(&[0u8; MLDSA65_SIG_LEN]),
720        )
721        .unwrap_err();
722        assert_eq!(err, VerifyError::WrongSignatureLength);
723    }
724
725    #[cfg(feature = "tier-2-pqc-receipts")]
726    #[test]
727    fn envelope_mldsa65_ok() {
728        let signer = ReceiptSigner::mldsa65_from_seed([61u8; 32]);
729        let message = b"envelope ml-dsa body";
730        let sig = signer.sign(message);
731        assert_eq!(sig.len(), MLDSA65_SIG_LEN);
732        assert!(verify_receipt_envelope(
733            RuntimeSignatureClass::MlDsa65,
734            signer.public_key_bytes(),
735            message,
736            &[],
737            Some(&sig),
738        )
739        .is_ok());
740    }
741
742    #[cfg(feature = "tier-2-pqc-receipts")]
743    #[test]
744    fn envelope_hybrid_ok() {
745        use ed25519_dalek::{Signer as _, SigningKey};
746        let ed_sk = SigningKey::from_bytes(&[67u8; 32]);
747        let ed_vk = ed_sk.verifying_key();
748        let pqc = ReceiptSigner::mldsa65_from_seed([71u8; 32]);
749        let message = b"envelope hybrid body";
750        let m = domain_separated(message);
751        let ed_sig = ed_sk.sign(&m).to_bytes();
752        let pqc_sig = pqc.sign(message);
753
754        let mut public_key = Vec::new();
755        public_key.extend_from_slice(&ed_vk.to_bytes());
756        public_key.extend_from_slice(pqc.public_key_bytes());
757        assert!(verify_receipt_envelope(
758            RuntimeSignatureClass::Hybrid,
759            &public_key,
760            message,
761            &ed_sig,
762            Some(&pqc_sig),
763        )
764        .is_ok());
765    }
766}