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