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