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