Skip to main content

arkhe_kernel/persist/
signature.rs

1//! `SignatureClass` — Tier 1/2/3 signing configuration (A16) + PQC Hybrid (CNSA 2.0).
2//!
3//! Ships:
4//! - `None` — no signature; chain integrity rests entirely on the
5//!   BLAKE3 keyed chain (A13). Tier 1.
6//! - `Ed25519` — per-record Ed25519 signature over the canonical
7//!   `WalRecordBody` bytes; verifying key is pinned in the WAL header
8//!   so post-hoc verification is self-contained. Tier 2.
9//! - `Hybrid` — PQC dual-sign (Ed25519 + ML-DSA 65, NIST FIPS 204).
10//!   Both signatures must verify (AND-mode). Forward-secure default
11//!   for new WALs. CNSA 2.0 transition spec compliance.
12//!
13//! Tier 3 (`TransparencyLog`) is reserved (deferred).
14//!
15//! `SignatureClass` is **not** Serialize/Deserialize — it carries the
16//! `SigningKey` which must never appear in WAL bytes. Only the
17//! verifying keys (header) and per-record signatures persist.
18//! `Debug` for the `Ed25519` and `Hybrid` variants redacts signing keys.
19
20use ed25519_dalek::{
21    Signer as Ed25519SignerTrait, SigningKey, Verifier as Ed25519VerifierTrait, VerifyingKey,
22};
23use ml_dsa::signature::{
24    Keypair as MlDsaKeypairTrait, Signer as MlDsaSignerTrait, Verifier as MlDsaVerifierTrait,
25};
26use ml_dsa::{EncodedSignature, EncodedVerifyingKey, KeyGen, MlDsa65, B32};
27
28// Sealed-trait marker (per docs/sealing-pattern-lineage.md,
29// A24 sealed-trait pattern). External crates cannot add new
30// PqcSigner / PqcVerifier impls — universe is monomorphic to
31// SoftwareMlDsa65Signer / SoftwareMlDsa65Verifier. HSM/KMS impls
32// land via separate sealed-trait extension (deferred).
33mod private_seal {
34    pub trait Sealed {}
35    impl Sealed for super::SoftwareMlDsa65Signer {}
36    impl Sealed for super::SoftwareMlDsa65Verifier {}
37}
38
39/// Failure modes for `PqcSigner::sign` operations.
40#[derive(Debug, Clone)]
41#[non_exhaustive]
42pub enum PqcSignError {
43    /// PQC signing primitive returned an error (provider-specific).
44    Provider(String),
45}
46
47impl core::fmt::Display for PqcSignError {
48    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
49        match self {
50            Self::Provider(m) => write!(f, "PQC signer provider error: {}", m),
51        }
52    }
53}
54
55impl std::error::Error for PqcSignError {}
56
57/// Failure modes for `PqcVerifier::verify` operations.
58#[derive(Debug, Clone)]
59#[non_exhaustive]
60pub enum PqcVerifyError {
61    /// Signature buffer was not the expected fixed length for the
62    /// PQC scheme (e.g., 3309 bytes for ML-DSA 65).
63    WrongLength,
64    /// Signature did not validate against the message under the
65    /// pinned verifying key.
66    Mismatch,
67}
68
69impl core::fmt::Display for PqcVerifyError {
70    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        match self {
72            Self::WrongLength => write!(f, "PQC signature wrong length"),
73            Self::Mismatch => write!(f, "PQC signature did not validate"),
74        }
75    }
76}
77
78impl std::error::Error for PqcVerifyError {}
79
80/// Trait abstraction for PQC signers (forward-compat for HSM/KMS
81/// providers, deferred). Sealed — only same-crate impls.
82pub trait PqcSigner: private_seal::Sealed + Send + Sync {
83    /// Sign `msg` and return the canonical encoded signature bytes.
84    /// For ML-DSA 65 this is exactly 3309 bytes (FIPS 204 §4).
85    fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, PqcSignError>;
86    /// Encoded verifying-key bytes for header pinning. For ML-DSA 65
87    /// this is exactly 1952 bytes (FIPS 204 §4).
88    fn verifying_key_bytes(&self) -> Vec<u8>;
89}
90
91/// Trait abstraction for PQC verifiers (forward-compat for HSM/KMS
92/// verify path, deferred). Sealed — only same-crate impls.
93pub trait PqcVerifier: private_seal::Sealed + Send + Sync {
94    /// Verify `sig` against `msg` under the pinned verifying key.
95    fn verify(&self, msg: &[u8], sig: &[u8]) -> Result<(), PqcVerifyError>;
96}
97
98/// Software-only ML-DSA 65 signer (NIST FIPS 204, security category 3).
99/// Wraps the `ml-dsa` crate's `SigningKey<MlDsa65>`. Debug redacts the
100/// signing key — only the verifying key bytes appear in `Debug`.
101///
102/// Key material is in-memory only — never serialize the signing key
103/// (process protection guide: `docs/pqc-software-only.md`).
104pub struct SoftwareMlDsa65Signer {
105    signing_key: ml_dsa::SigningKey<MlDsa65>,
106    verifying_key_cache: ml_dsa::VerifyingKey<MlDsa65>,
107}
108
109impl SoftwareMlDsa65Signer {
110    /// Construct a signer deterministically from a 32-byte seed.
111    /// FIPS 204 ML-DSA.KeyGen_internal — same seed yields same key pair.
112    pub fn from_seed(seed: [u8; 32]) -> Self {
113        let xi: B32 = seed.into();
114        let signing_key = MlDsa65::from_seed(&xi);
115        let verifying_key_cache = signing_key.verifying_key();
116        Self {
117            signing_key,
118            verifying_key_cache,
119        }
120    }
121}
122
123impl PqcSigner for SoftwareMlDsa65Signer {
124    fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, PqcSignError> {
125        // Default Signer::sign for SigningKey<P> = sign_deterministic
126        // (no RNG dependency) per ml-dsa::Signer impl. Use try_sign to
127        // propagate provider errors.
128        let sig: ml_dsa::Signature<MlDsa65> = self
129            .signing_key
130            .try_sign(msg)
131            .map_err(|e| PqcSignError::Provider(format!("{}", e)))?;
132        Ok(sig.encode().to_vec())
133    }
134
135    fn verifying_key_bytes(&self) -> Vec<u8> {
136        self.verifying_key_cache.encode().to_vec()
137    }
138}
139
140impl core::fmt::Debug for SoftwareMlDsa65Signer {
141    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
142        write!(
143            f,
144            "SoftwareMlDsa65Signer {{ verifying_key_bytes: <1952B>, signing_key: <redacted> }}"
145        )
146    }
147}
148
149/// Software-only ML-DSA 65 verifier (NIST FIPS 204, security category 3).
150pub struct SoftwareMlDsa65Verifier {
151    verifying_key: ml_dsa::VerifyingKey<MlDsa65>,
152}
153
154impl SoftwareMlDsa65Verifier {
155    /// Reconstruct a verifier from canonical encoded verifying-key bytes
156    /// (1952 bytes for ML-DSA 65 per FIPS 204).
157    pub fn from_bytes(vk_bytes: &[u8]) -> Result<Self, PqcVerifyError> {
158        if vk_bytes.len() != 1952 {
159            return Err(PqcVerifyError::WrongLength);
160        }
161        let mut buf = EncodedVerifyingKey::<MlDsa65>::default();
162        buf.as_mut_slice().copy_from_slice(vk_bytes);
163        let verifying_key = ml_dsa::VerifyingKey::<MlDsa65>::decode(&buf);
164        Ok(Self { verifying_key })
165    }
166}
167
168impl PqcVerifier for SoftwareMlDsa65Verifier {
169    fn verify(&self, msg: &[u8], sig: &[u8]) -> Result<(), PqcVerifyError> {
170        if sig.len() != 3309 {
171            return Err(PqcVerifyError::WrongLength);
172        }
173        let mut sig_buf = EncodedSignature::<MlDsa65>::default();
174        sig_buf.as_mut_slice().copy_from_slice(sig);
175        let sig_obj =
176            ml_dsa::Signature::<MlDsa65>::decode(&sig_buf).ok_or(PqcVerifyError::Mismatch)?;
177        self.verifying_key
178            .verify(msg, &sig_obj)
179            .map_err(|_| PqcVerifyError::Mismatch)
180    }
181}
182
183impl core::fmt::Debug for SoftwareMlDsa65Verifier {
184    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
185        write!(
186            f,
187            "SoftwareMlDsa65Verifier {{ verifying_key_bytes: <1952B> }}"
188        )
189    }
190}
191
192/// Output of `SignatureClass::sign_hybrid` — paired Ed25519 (64 bytes)
193/// and ML-DSA 65 (3309 bytes) signatures over the same body bytes.
194/// Named fields prevent positional swap.
195#[derive(Debug)]
196pub struct HybridSignature {
197    /// Ed25519 signature bytes (RFC 8032 fixed 64 bytes).
198    pub ed25519: [u8; 64],
199    /// ML-DSA 65 signature bytes (NIST FIPS 204 fixed 3309 bytes).
200    pub pqc: Vec<u8>,
201}
202
203// Ed25519 carries 64 bytes (SigningKey 32 + VerifyingKey 32) vs None
204// 0 bytes vs Hybrid much larger. SignatureClass is held exactly once
205// per kernel — the size asymmetry is structurally negligible and
206// boxing would cost a heap allocation for the production path.
207/// Signature tier configuration for a [`WalWriter`](super::WalWriter).
208/// Default: [`None`](Self::None) (Tier 1 — chain integrity only).
209///
210/// Adds [`Hybrid`](Self::Hybrid) for PQC dual-sign per CNSA 2.0.
211#[allow(clippy::large_enum_variant)]
212#[non_exhaustive]
213#[derive(Default)]
214pub enum SignatureClass {
215    /// No signature path (chain integrity only).
216    #[default]
217    None,
218    /// RFC 8032 Ed25519 — deterministic per-record signatures.
219    Ed25519 {
220        /// Private signing key. Never serialized; redacted in `Debug`.
221        signing_key: SigningKey,
222        /// Verifying key derived from the signing key. Pinned in the
223        /// WAL header so post-hoc verification is self-contained.
224        verifying_key: VerifyingKey,
225    },
226    /// Hybrid — Ed25519 + ML-DSA 65 dual-sign. Both signatures emitted
227    /// per record. Verify path is AND-mode (both must pass).
228    Hybrid {
229        /// Ed25519 private signing key.
230        ed25519_signing_key: SigningKey,
231        /// Ed25519 verifying key (pinned in WAL header `verifying_key`).
232        ed25519_verifying_key: VerifyingKey,
233        /// PQC signer (trait object — currently SoftwareMlDsa65Signer;
234        /// HSM/KMS providers land via PqcSigner impl, deferred).
235        pqc_signer: Box<dyn PqcSigner>,
236    },
237}
238
239impl SignatureClass {
240    /// Construct an Ed25519 class from a 32-byte secret seed.
241    /// The verifying key is derived deterministically.
242    pub fn new_ed25519_from_secret(secret: [u8; 32]) -> Self {
243        let signing_key = SigningKey::from_bytes(&secret);
244        let verifying_key = signing_key.verifying_key();
245        Self::Ed25519 {
246            signing_key,
247            verifying_key,
248        }
249    }
250
251    /// Construct a Hybrid class from independent Ed25519 and ML-DSA 65
252    /// secret seeds. Both keys derived deterministically from their
253    /// respective 32-byte seeds. Use independent seeds (do not reuse
254    /// the same seed for both schemes).
255    pub fn new_hybrid_from_secrets(ed25519_secret: [u8; 32], ml_dsa_seed: [u8; 32]) -> Self {
256        let ed25519_signing_key = SigningKey::from_bytes(&ed25519_secret);
257        let ed25519_verifying_key = ed25519_signing_key.verifying_key();
258        let pqc_signer = Box::new(SoftwareMlDsa65Signer::from_seed(ml_dsa_seed));
259        Self::Hybrid {
260            ed25519_signing_key,
261            ed25519_verifying_key,
262            pqc_signer,
263        }
264    }
265
266    /// Bytes of the Ed25519 verifying (public) key, if Ed25519/Hybrid.
267    /// Returned bytes are the `[u8; 32]` form pinned in the WAL header
268    /// `verifying_key` field.
269    pub fn verifying_key_bytes(&self) -> Option<[u8; 32]> {
270        match self {
271            Self::None => None,
272            Self::Ed25519 { verifying_key, .. } => Some(verifying_key.to_bytes()),
273            Self::Hybrid {
274                ed25519_verifying_key,
275                ..
276            } => Some(ed25519_verifying_key.to_bytes()),
277        }
278    }
279
280    /// Bytes of the PQC verifying (public) key, if Hybrid (else None).
281    /// Returned bytes are the `Vec<u8>` form (1952 bytes for ML-DSA 65)
282    /// pinned in the WAL header `verifying_key_pqc` field.
283    pub fn verifying_key_pqc_bytes(&self) -> Option<Vec<u8>> {
284        match self {
285            Self::None | Self::Ed25519 { .. } => None,
286            Self::Hybrid { pqc_signer, .. } => Some(pqc_signer.verifying_key_bytes()),
287        }
288    }
289
290    /// Sign `body_bytes` and return the Ed25519 signature (if applicable).
291    /// RFC 8032 deterministic. For Hybrid records this returns the
292    /// Ed25519 component only — use [`sign_hybrid`](Self::sign_hybrid)
293    /// to obtain both Ed25519 + ML-DSA 65 signatures together.
294    pub(crate) fn sign(&self, body_bytes: &[u8]) -> Option<[u8; 64]> {
295        match self {
296            Self::None => None,
297            Self::Ed25519 { signing_key, .. } => Some(signing_key.sign(body_bytes).to_bytes()),
298            Self::Hybrid {
299                ed25519_signing_key,
300                ..
301            } => Some(ed25519_signing_key.sign(body_bytes).to_bytes()),
302        }
303    }
304
305    /// Sign `body_bytes` with both Ed25519 and ML-DSA 65 (Hybrid only).
306    /// Returns paired signatures via [`HybridSignature`].
307    /// Returns `None` for non-Hybrid variants.
308    ///
309    /// Consumed by `wal.rs` `WalWriter::append` Hybrid path.
310    pub(crate) fn sign_hybrid(&self, body_bytes: &[u8]) -> Option<HybridSignature> {
311        match self {
312            Self::Hybrid {
313                ed25519_signing_key,
314                pqc_signer,
315                ..
316            } => {
317                let ed25519 = ed25519_signing_key.sign(body_bytes).to_bytes();
318                let pqc = pqc_signer.sign(body_bytes).ok()?;
319                Some(HybridSignature { ed25519, pqc })
320            }
321            _ => None,
322        }
323    }
324}
325
326impl core::fmt::Debug for SignatureClass {
327    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
328        match self {
329            Self::None => write!(f, "SignatureClass::None"),
330            Self::Ed25519 { verifying_key, .. } => write!(
331                f,
332                "SignatureClass::Ed25519 {{ verifying_key: {:?}, signing_key: <redacted> }}",
333                verifying_key.to_bytes()
334            ),
335            Self::Hybrid {
336                ed25519_verifying_key,
337                ..
338            } => write!(
339                f,
340                "SignatureClass::Hybrid {{ ed25519_verifying_key: {:?}, ed25519_signing_key: <redacted>, pqc_signer: <redacted> }}",
341                ed25519_verifying_key.to_bytes()
342            ),
343        }
344    }
345}
346
347/// Failure modes when constructing a [`VerifierClass`] from a WAL
348/// header byte buffer.
349#[derive(Debug)]
350#[non_exhaustive]
351pub(crate) enum VerifierInitError {
352    /// Pinned Ed25519 verifying-key bytes did not parse.
353    InvalidEd25519Key,
354    /// Pinned PQC verifying-key bytes did not parse (ML-DSA 65 length).
355    InvalidPqcKey,
356    /// `(verifying_key=None, verifying_key_pqc=Some)` envelope — PQC
357    /// key without Ed25519 anchor (invalid per Hybrid spec; Ed25519 is
358    /// the chain-anchor companion).
359    PqcWithoutEd25519,
360}
361
362/// Failure modes when verifying a single record's signature.
363#[derive(Debug)]
364#[non_exhaustive]
365pub(crate) enum SignatureVerifyError {
366    /// Signature buffer was not exactly 64 bytes (Ed25519 fixed).
367    WrongLength,
368    /// Signature bytes did not validate against the body under the
369    /// pinned verifying key.
370    Mismatch,
371}
372
373/// Verifier-side counterpart of [`SignatureClass`] — public material
374/// only. Constructed once at the top of `verify_chain` from the
375/// sealed WAL header and reused per record.
376#[non_exhaustive]
377pub(crate) enum VerifierClass {
378    /// No-signature mode (chain integrity only). Calling
379    /// [`Self::verify`] on this variant is a caller-guard violation;
380    /// see the method doc.
381    None,
382    /// RFC 8032 Ed25519 — single pinned verifying key.
383    Ed25519(VerifyingKey),
384    /// Hybrid — Ed25519 + ML-DSA 65 dual verify (AND-mode).
385    Hybrid {
386        /// Ed25519 verifying key (chain-anchor).
387        ed25519: VerifyingKey,
388        /// PQC verifier (trait object — currently SoftwareMlDsa65Verifier).
389        /// Consumed by `Self::verify_hybrid` from `wal.rs` `verify_chain`
390        /// Hybrid AND-mode dispatch site.
391        pqc: Box<dyn PqcVerifier>,
392    },
393}
394
395impl VerifierClass {
396    /// Reconstruct from the WAL header's pinned verifying-key bytes.
397    /// 4-arm envelope-derived dispatch:
398    /// - `(None, None)` → `None` (Tier 1 / dev mode)
399    /// - `(Some, None)` → `Ed25519` (pre-Hybrid sticky + explicit Ed25519)
400    /// - `(Some, Some)` → `Hybrid` (PQC dual-sign)
401    /// - `(None, Some)` → invalid envelope (`PqcWithoutEd25519` reject)
402    pub(crate) fn from_header_bytes(
403        vk_ed25519: Option<&[u8; 32]>,
404        vk_pqc: Option<&[u8]>,
405    ) -> Result<Self, VerifierInitError> {
406        match (vk_ed25519, vk_pqc) {
407            (None, None) => Ok(Self::None),
408            (Some(vk), None) => VerifyingKey::from_bytes(vk)
409                .map(Self::Ed25519)
410                .map_err(|_| VerifierInitError::InvalidEd25519Key),
411            (Some(vk), Some(vk_p)) => {
412                let ed25519 = VerifyingKey::from_bytes(vk)
413                    .map_err(|_| VerifierInitError::InvalidEd25519Key)?;
414                let pqc = SoftwareMlDsa65Verifier::from_bytes(vk_p)
415                    .map_err(|_| VerifierInitError::InvalidPqcKey)?;
416                Ok(Self::Hybrid {
417                    ed25519,
418                    pqc: Box::new(pqc),
419                })
420            }
421            (None, Some(_)) => Err(VerifierInitError::PqcWithoutEd25519),
422        }
423    }
424
425    /// Verify a single record's Ed25519 signature against `body_bytes`.
426    /// `body_bytes` is the postcard-encoded body produced by the
427    /// caller; the callee does not re-derive (preserves WAL
428    /// byte-identity — derive site stays a single source of truth).
429    ///
430    /// `Self::None.verify(...)` panics — caller must guard with
431    /// `if !matches!(verifier, VerifierClass::None)`. Failing loud
432    /// beats a silent no-op on a configuration bug.
433    ///
434    /// For `Self::Hybrid`, this method verifies the Ed25519 component
435    /// only — use [`verify_hybrid`](Self::verify_hybrid) to verify
436    /// both Ed25519 and ML-DSA 65 signatures together (AND-mode).
437    pub(crate) fn verify(&self, body_bytes: &[u8], sig: &[u8]) -> Result<(), SignatureVerifyError> {
438        match self {
439            Self::None => {
440                unreachable!("VerifierClass::None.verify(): caller must guard with matches!")
441            }
442            Self::Ed25519(vk) => Self::verify_ed25519(vk, body_bytes, sig),
443            Self::Hybrid { ed25519, .. } => Self::verify_ed25519(ed25519, body_bytes, sig),
444        }
445    }
446
447    /// Verify a Hybrid record — both Ed25519 and ML-DSA 65 signatures
448    /// must pass (AND-mode). Short-circuit at first failure is
449    /// safe in WAL replay context (offline, not interactive sig API).
450    ///
451    /// `Self::Hybrid.verify_hybrid(...)` is the only valid path —
452    /// other variants panic (caller must guard with
453    /// `if matches!(verifier, VerifierClass::Hybrid { .. })`).
454    ///
455    /// Consumed by `wal.rs` `verify_chain` Hybrid AND-mode dispatch site.
456    pub(crate) fn verify_hybrid(
457        &self,
458        body_bytes: &[u8],
459        sig: &[u8],
460        sig_pqc: &[u8],
461    ) -> Result<(), SignatureVerifyError> {
462        match self {
463            Self::Hybrid { ed25519, pqc } => {
464                Self::verify_ed25519(ed25519, body_bytes, sig)?;
465                pqc.verify(body_bytes, sig_pqc)
466                    .map_err(|_| SignatureVerifyError::Mismatch)
467            }
468            _ => unreachable!("VerifierClass::verify_hybrid(): caller must guard with matches!"),
469        }
470    }
471
472    fn verify_ed25519(
473        vk: &VerifyingKey,
474        body_bytes: &[u8],
475        sig: &[u8],
476    ) -> Result<(), SignatureVerifyError> {
477        if sig.len() != 64 {
478            return Err(SignatureVerifyError::WrongLength);
479        }
480        let mut sig_bytes = [0u8; 64];
481        sig_bytes.copy_from_slice(sig);
482        let sig_obj = ed25519_dalek::Signature::from_bytes(&sig_bytes);
483        vk.verify(body_bytes, &sig_obj)
484            .map_err(|_| SignatureVerifyError::Mismatch)
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn none_default_and_no_verifying_key() {
494        let s = SignatureClass::default();
495        assert!(matches!(s, SignatureClass::None));
496        assert!(s.verifying_key_bytes().is_none());
497        assert!(s.verifying_key_pqc_bytes().is_none());
498        assert!(s.sign(b"anything").is_none());
499        assert!(s.sign_hybrid(b"anything").is_none());
500    }
501
502    #[test]
503    fn ed25519_from_secret_yields_verifying_key() {
504        let s = SignatureClass::new_ed25519_from_secret([7u8; 32]);
505        let vk = s.verifying_key_bytes().expect("Ed25519 has key");
506        assert_eq!(vk.len(), 32);
507        assert!(s.verifying_key_pqc_bytes().is_none());
508    }
509
510    #[test]
511    fn ed25519_sign_is_deterministic() {
512        // RFC 8032: same key + same body ⇒ same 64-byte signature.
513        let s1 = SignatureClass::new_ed25519_from_secret([3u8; 32]);
514        let s2 = SignatureClass::new_ed25519_from_secret([3u8; 32]);
515        let body = b"the body bytes to sign";
516        let sig1 = s1.sign(body).expect("ed25519 signs");
517        let sig2 = s2.sign(body).expect("ed25519 signs");
518        assert_eq!(sig1, sig2);
519    }
520
521    #[test]
522    fn debug_redacts_signing_key() {
523        let s = SignatureClass::new_ed25519_from_secret([42u8; 32]);
524        let dbg = format!("{:?}", s);
525        assert!(dbg.contains("<redacted>"));
526        assert!(!dbg.contains("signing_key:") || dbg.contains("<redacted>"));
527    }
528
529    #[test]
530    fn verifier_class_good_signature_validates() {
531        // Sign + verify round-trip: same body, same key, signature validates.
532        let sig_class = SignatureClass::new_ed25519_from_secret([29u8; 32]);
533        let vk_bytes = sig_class.verifying_key_bytes().unwrap();
534        let body = b"verifier round-trip body";
535        let sig = sig_class.sign(body).expect("ed25519 signs");
536        let verifier = VerifierClass::from_header_bytes(Some(&vk_bytes), None)
537            .expect("valid Ed25519 vk parses");
538        assert!(verifier.verify(body, &sig).is_ok());
539    }
540
541    #[test]
542    fn verifier_class_wrong_length_signature_rejected() {
543        // Distinct WrongLength variant: the centralized error type
544        // separates length failures from content failures (the WAL
545        // caller collapses both back to SignatureMismatch externally).
546        let sig_class = SignatureClass::new_ed25519_from_secret([31u8; 32]);
547        let vk_bytes = sig_class.verifying_key_bytes().unwrap();
548        let verifier = VerifierClass::from_header_bytes(Some(&vk_bytes), None)
549            .expect("valid Ed25519 vk parses");
550        let too_short = [0u8; 63];
551        assert!(matches!(
552            verifier.verify(b"any body", &too_short),
553            Err(SignatureVerifyError::WrongLength)
554        ));
555        let too_long = [0u8; 65];
556        assert!(matches!(
557            verifier.verify(b"any body", &too_long),
558            Err(SignatureVerifyError::WrongLength)
559        ));
560    }
561
562    #[test]
563    fn verifier_class_wrong_sig_bytes_rejected() {
564        // Correct length, wrong contents — ed25519_dalek verify fails.
565        let sig_class = SignatureClass::new_ed25519_from_secret([37u8; 32]);
566        let vk_bytes = sig_class.verifying_key_bytes().unwrap();
567        let body = b"wrong-sig body";
568        let mut sig = sig_class.sign(body).expect("ed25519 signs");
569        sig[0] ^= 0xFF; // flip a byte to break the signature
570        let verifier = VerifierClass::from_header_bytes(Some(&vk_bytes), None)
571            .expect("valid Ed25519 vk parses");
572        assert!(matches!(
573            verifier.verify(body, &sig),
574            Err(SignatureVerifyError::Mismatch)
575        ));
576    }
577
578    // ---- PQC Hybrid (Ed25519 + ML-DSA 65) ----
579
580    #[test]
581    fn ml_dsa_65_software_signer_round_trip() {
582        // Sign-then-verify positive path using SoftwareMlDsa65Signer +
583        // SoftwareMlDsa65Verifier reconstructed from encoded bytes.
584        let signer = SoftwareMlDsa65Signer::from_seed([11u8; 32]);
585        let body = b"ml-dsa 65 round-trip body";
586        let sig = signer.sign(body).expect("ml-dsa 65 signs");
587        let vk_bytes = signer.verifying_key_bytes();
588        let verifier = SoftwareMlDsa65Verifier::from_bytes(&vk_bytes).expect("vk bytes round-trip");
589        assert!(verifier.verify(body, &sig).is_ok());
590    }
591
592    #[test]
593    fn ml_dsa_65_signature_size_3309_bytes() {
594        // NIST FIPS 204 ML-DSA 65 fixed signature size pin.
595        let signer = SoftwareMlDsa65Signer::from_seed([13u8; 32]);
596        let sig = signer.sign(b"size pin").expect("ml-dsa 65 signs");
597        assert_eq!(sig.len(), 3309);
598    }
599
600    #[test]
601    fn ml_dsa_65_verifying_key_size_1952_bytes() {
602        // NIST FIPS 204 ML-DSA 65 fixed verifying-key size pin.
603        let signer = SoftwareMlDsa65Signer::from_seed([17u8; 32]);
604        let vk_bytes = signer.verifying_key_bytes();
605        assert_eq!(vk_bytes.len(), 1952);
606    }
607
608    #[test]
609    fn pqc_signer_trait_software_witness() {
610        // Compile-time witness that SoftwareMlDsa65Signer satisfies the
611        // PqcSigner sealed-trait bound. Trait-bound regression would
612        // fail typeck.
613        fn witness<T: PqcSigner>(_: &T) {}
614        let signer = SoftwareMlDsa65Signer::from_seed([0u8; 32]);
615        witness(&signer);
616    }
617
618    #[test]
619    fn pqc_verifier_trait_software_witness() {
620        // Compile-time witness that SoftwareMlDsa65Verifier satisfies
621        // the PqcVerifier sealed-trait bound.
622        fn witness<T: PqcVerifier>(_: &T) {}
623        let signer = SoftwareMlDsa65Signer::from_seed([1u8; 32]);
624        let verifier = SoftwareMlDsa65Verifier::from_bytes(&signer.verifying_key_bytes())
625            .expect("vk bytes round-trip");
626        witness(&verifier);
627    }
628
629    #[test]
630    fn hybrid_signature_class_construct() {
631        // Constructor smoke test — Hybrid variant constructs cleanly
632        // with independent Ed25519 + ML-DSA 65 seeds.
633        let s = SignatureClass::new_hybrid_from_secrets([23u8; 32], [29u8; 32]);
634        assert!(matches!(s, SignatureClass::Hybrid { .. }));
635        let vk_ed = s.verifying_key_bytes().expect("Hybrid has Ed25519 key");
636        assert_eq!(vk_ed.len(), 32);
637        let vk_pqc = s.verifying_key_pqc_bytes().expect("Hybrid has PQC key");
638        assert_eq!(vk_pqc.len(), 1952);
639        let body = b"hybrid construct body";
640        let hyb = s.sign_hybrid(body).expect("hybrid signs");
641        assert_eq!(hyb.ed25519.len(), 64);
642        assert_eq!(hyb.pqc.len(), 3309);
643    }
644}