Skip to main content

exo_core/
crypto.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Cryptographic primitives for EXOCHAIN.
18//!
19//! # Algorithm support
20//!
21//! | Variant        | Algorithm                       | Status      |
22//! |----------------|---------------------------------|-------------|
23//! | Ed25519        | Ed25519 / ed25519-dalek         | Production  |
24//! | PostQuantum    | ML-DSA-65 (NIST FIPS 204)       | Production  |
25//! | Hybrid         | Ed25519 + ML-DSA-65 (strict AND)| Production  |
26//!
27//! # Hybrid verification
28//!
29//! [`verify_hybrid`] requires **both** the Ed25519 and ML-DSA-65 components
30//! to pass.  It evaluates both top-level components before combining their
31//! results, removing a top-level short-circuit. Component-level timing remains
32//! governed by the verifier crates, so callers must not expose rejection timing
33//! as an oracle.
34//!
35//! # Security note — `verify()` and Hybrid signatures
36//!
37//! [`verify`] returns `false` for `Hybrid` signatures because it cannot
38//! verify the PQ component without a [`PqPublicKey`].  Use [`verify_hybrid`]
39//! for Hybrid signatures.  This closes the silent Ed25519-only downgrade that
40//! existed in the stub implementation.
41//!
42//! # Post-quantum implementation
43//!
44//! ML-DSA is implemented via the `ml-dsa` crate (RustCrypto, pure Rust,
45//! WASM-compatible). Version 0.1.0-rc.7 patches RUSTSEC-2025-0144 — a
46//! timing side-channel in the `decompose` function during signing, fixed via
47//! constant-time Barrett reduction in rc.3+.
48//!
49//! `PqSecretKey` stores the 32-byte ML-DSA seed (`ξ` in FIPS 204 §5.1).
50//! `PqPublicKey` stores the 1952-byte encoded ML-DSA-65 verifying key.
51
52use ed25519_dalek::{Signer, Verifier};
53use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65};
54use zeroize::{Zeroize, Zeroizing};
55
56use crate::{
57    error::{ExoError, Result},
58    types::{PqPublicKey, PqSecretKey, PublicKey, SecretKey, Signature},
59};
60
61// ---------------------------------------------------------------------------
62// Classical Ed25519 — KeyPair
63// ---------------------------------------------------------------------------
64
65/// An Ed25519 key pair.  The secret key is zeroized when this struct is
66/// dropped.
67pub struct KeyPair {
68    pub public: PublicKey,
69    secret: SecretKey,
70}
71
72impl KeyPair {
73    /// Generate a fresh random key pair.
74    #[must_use]
75    pub fn generate() -> Self {
76        let (public, secret) = generate_keypair();
77        Self { public, secret }
78    }
79
80    /// Reconstruct a key pair from raw secret-key bytes.
81    ///
82    /// # Errors
83    ///
84    /// Returns `ExoError::CryptoError` if the bytes are not a valid Ed25519
85    /// secret key.
86    pub fn from_secret_bytes(bytes: [u8; 32]) -> Result<Self> {
87        let signing_key = ed25519_dalek::SigningKey::from_bytes(&bytes);
88        let verifying_key = signing_key.verifying_key();
89        Ok(Self {
90            public: PublicKey::from_bytes(verifying_key.to_bytes()),
91            secret: SecretKey::from_bytes(bytes),
92        })
93    }
94
95    /// Sign a message (Ed25519).
96    #[must_use]
97    pub fn sign(&self, message: &[u8]) -> Signature {
98        sign(message, &self.secret)
99    }
100
101    /// Verify an Ed25519 signature against this key pair's public key.
102    ///
103    /// For Hybrid signatures use [`verify_hybrid`] instead.
104    #[must_use]
105    pub fn verify(&self, message: &[u8], signature: &Signature) -> bool {
106        verify(message, signature, &self.public)
107    }
108
109    /// Return a reference to the public key.
110    #[must_use]
111    pub fn public_key(&self) -> &PublicKey {
112        &self.public
113    }
114
115    /// Return a reference to the secret key.
116    #[must_use]
117    pub fn secret_key(&self) -> &SecretKey {
118        &self.secret
119    }
120}
121
122impl Drop for KeyPair {
123    fn drop(&mut self) {
124        self.secret.zeroize();
125    }
126}
127
128impl core::fmt::Debug for KeyPair {
129    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130        f.debug_struct("KeyPair")
131            .field("public", &self.public)
132            .field("secret", &"***")
133            .finish()
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Post-quantum ML-DSA-65 — PqKeyPair
139// ---------------------------------------------------------------------------
140
141/// An ML-DSA-65 key pair.
142///
143/// The 32-byte seed (`PqSecretKey`) is zeroized on drop.  Signing is
144/// deterministic: the same seed + message always produces the same signature.
145pub struct PqKeyPair {
146    pub public: PqPublicKey,
147    secret: PqSecretKey,
148}
149
150impl PqKeyPair {
151    /// Generate a fresh random ML-DSA-65 key pair.
152    #[must_use]
153    pub fn generate() -> Self {
154        let (public, secret) = generate_pq_keypair();
155        Self { public, secret }
156    }
157
158    /// Sign a message (ML-DSA-65, deterministic).
159    ///
160    /// # Errors
161    ///
162    /// Returns `ExoError::CryptoError` if the stored seed bytes are malformed.
163    pub fn sign(&self, message: &[u8]) -> Result<Signature> {
164        sign_pq(message, &self.secret)
165    }
166
167    /// Verify a `PostQuantum` signature against this key pair's public key.
168    #[must_use]
169    pub fn verify(&self, message: &[u8], signature: &Signature) -> bool {
170        verify_pq(message, signature, &self.public)
171    }
172
173    /// Return a reference to the PQ public key.
174    #[must_use]
175    pub fn public_key(&self) -> &PqPublicKey {
176        &self.public
177    }
178}
179
180impl core::fmt::Debug for PqKeyPair {
181    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
182        f.debug_struct("PqKeyPair")
183            .field("public", &self.public)
184            .field("secret", &"***")
185            .finish()
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Classical Ed25519 — free functions
191// ---------------------------------------------------------------------------
192
193/// Generate a fresh Ed25519 key pair.
194#[must_use]
195pub fn generate_keypair() -> (PublicKey, SecretKey) {
196    let mut csprng = rand::rngs::OsRng;
197    let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng);
198    let verifying_key = signing_key.verifying_key();
199    (
200        PublicKey::from_bytes(verifying_key.to_bytes()),
201        SecretKey::from_bytes(signing_key.to_bytes()),
202    )
203}
204
205/// Sign `message` with the given secret key (Ed25519).
206#[must_use]
207pub fn sign(message: &[u8], secret: &SecretKey) -> Signature {
208    let signing_key = ed25519_dalek::SigningKey::from_bytes(secret.as_bytes());
209    let sig = signing_key.sign(message);
210    Signature::Ed25519(sig.to_bytes())
211}
212
213/// Verify an Ed25519 signature against a public key.
214///
215/// - `Ed25519` — verifies the Ed25519 component.
216/// - `Hybrid` — **always returns `false`**.  Use [`verify_hybrid`] instead;
217///   this function cannot check the PQ component without a [`PqPublicKey`].
218///   This closes the silent Ed25519-only downgrade that existed in the stub.
219/// - `PostQuantum` — returns `false`.  Use [`verify_pq`] instead.
220/// - `Empty` — always returns `false`.
221#[must_use]
222pub fn verify(message: &[u8], signature: &Signature, public: &PublicKey) -> bool {
223    let sig_bytes = match signature {
224        Signature::Ed25519(b) => b,
225        // Cannot fully verify Hybrid without the PQ component — return false
226        // rather than silently accepting with only the Ed25519 half.
227        Signature::Hybrid { .. } => return false,
228        Signature::PostQuantum(_) => return false,
229        Signature::Empty => return false,
230    };
231    let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(public.as_bytes()) else {
232        return false;
233    };
234    let Ok(sig) = ed25519_dalek::Signature::from_slice(sig_bytes) else {
235        return false;
236    };
237    verifying_key.verify(message, &sig).is_ok()
238}
239
240// ---------------------------------------------------------------------------
241// Post-quantum ML-DSA-65 — free functions
242// ---------------------------------------------------------------------------
243
244/// Generate a fresh ML-DSA-65 key pair.
245///
246/// Returns `(PqPublicKey, PqSecretKey)`.
247///
248/// - `PqSecretKey` stores the 32-byte seed `ξ` (FIPS 204 §5.1).  All ML-DSA
249///   security levels use a 32-byte seed.
250/// - `PqPublicKey` stores the 1952-byte encoded ML-DSA-65 verifying key.
251#[must_use]
252pub fn generate_pq_keypair() -> (PqPublicKey, PqSecretKey) {
253    // Generate entropy via rand 0.8 (rand_core 0.6) — no conflict with
254    // ml-dsa's rand_core 0.10, because we fill a plain byte array and then
255    // hand it to ml-dsa's seed-based constructor.
256    let mut seed_bytes = Zeroizing::new([0u8; 32]);
257    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut *seed_bytes);
258
259    let seed: ml_dsa::Seed = (*seed_bytes).into();
260    let sk = ml_dsa::SigningKey::<MlDsa65>::from_seed(&seed);
261    let vk = sk.verifying_key();
262    let vk_encoded: EncodedVerifyingKey<MlDsa65> = vk.encode();
263    let vk_bytes: Vec<u8> = AsRef::<[u8]>::as_ref(&vk_encoded).to_vec();
264
265    (
266        PqPublicKey::from_bytes(vk_bytes),
267        PqSecretKey::from_bytes(seed_bytes.to_vec()),
268    )
269}
270
271/// Sign `message` with an ML-DSA-65 secret key (deterministic, empty context).
272///
273/// Returns `Signature::PostQuantum` containing the raw encoded ML-DSA-65
274/// signature bytes (3293 bytes for ML-DSA-65).
275///
276/// Signing is deterministic: the same seed and message always produce the
277/// same signature, satisfying the `DeterministicFinality` invariant.
278///
279/// # Errors
280///
281/// Returns `ExoError::CryptoError` if `secret` bytes are not exactly 32 bytes
282/// (the ML-DSA seed size) or if ML-DSA signing fails internally.
283pub fn sign_pq(message: &[u8], secret: &PqSecretKey) -> Result<Signature> {
284    let seed_arr: [u8; 32] = secret
285        .as_bytes()
286        .try_into()
287        .map_err(|_| ExoError::CryptoError {
288            reason: format!(
289                "ML-DSA seed must be 32 bytes, got {}",
290                secret.as_bytes().len()
291            ),
292        })?;
293    let seed: ml_dsa::Seed = seed_arr.into();
294    let sk = ml_dsa::SigningKey::<MlDsa65>::from_seed(&seed);
295    let pq_sig = sk
296        .sign_deterministic(message, &[])
297        .map_err(|e| ExoError::CryptoError {
298            reason: format!("ML-DSA-65 sign failed: {e}"),
299        })?;
300    let sig_encoded: EncodedSignature<MlDsa65> = pq_sig.encode();
301    Ok(Signature::PostQuantum(
302        AsRef::<[u8]>::as_ref(&sig_encoded).to_vec(),
303    ))
304}
305
306/// Verify a `PostQuantum` signature with an ML-DSA-65 public key.
307///
308/// Returns `false` for any `Signature` variant other than `PostQuantum`.
309#[must_use]
310pub fn verify_pq(message: &[u8], signature: &Signature, public: &PqPublicKey) -> bool {
311    let Signature::PostQuantum(sig_bytes) = signature else {
312        return false;
313    };
314    let Ok(encoded_vk) = EncodedVerifyingKey::<MlDsa65>::try_from(public.as_bytes()) else {
315        return false;
316    };
317    let vk = ml_dsa::VerifyingKey::<MlDsa65>::decode(&encoded_vk);
318    let Ok(ml_sig) = ml_dsa::Signature::<MlDsa65>::try_from(sig_bytes.as_slice()) else {
319        return false;
320    };
321    vk.verify_with_context(message, &[], &ml_sig)
322}
323
324// ---------------------------------------------------------------------------
325// Hybrid Ed25519 + ML-DSA-65 — free functions
326// ---------------------------------------------------------------------------
327
328/// Sign `message` with both Ed25519 and ML-DSA-65 (strict dual-sign).
329///
330/// Returns `Signature::Hybrid` containing both components.  Both must pass
331/// during verification via [`verify_hybrid`].  This is a cryptographic
332/// instantiation of the `DualControl` constitutional invariant.
333///
334/// # Errors
335///
336/// Returns `ExoError::CryptoError` if the PQ signing key bytes are invalid.
337pub fn sign_hybrid(
338    message: &[u8],
339    classical_secret: &SecretKey,
340    pq_secret: &PqSecretKey,
341) -> Result<Signature> {
342    let Signature::Ed25519(classical) = sign(message, classical_secret) else {
343        // sign() always returns Ed25519 — this branch is unreachable.
344        return Err(ExoError::CryptoError {
345            reason: "unexpected non-Ed25519 variant from sign()".into(),
346        });
347    };
348    let Signature::PostQuantum(pq) = sign_pq(message, pq_secret)? else {
349        return Err(ExoError::CryptoError {
350            reason: "unexpected non-PostQuantum variant from sign_pq()".into(),
351        });
352    };
353    Ok(Signature::Hybrid { classical, pq })
354}
355
356/// Verify a `Hybrid` signature.
357///
358/// **Both** the Ed25519 and ML-DSA-65 components must pass. Both top-level
359/// components are evaluated before results are combined, removing a top-level
360/// short-circuit. Component-level timing remains governed by the verifier crates,
361/// so callers must not expose rejection timing as an oracle.
362///
363/// Returns `false` for any `Signature` variant other than `Hybrid`.
364#[must_use]
365pub fn verify_hybrid(
366    message: &[u8],
367    signature: &Signature,
368    classical_public: &PublicKey,
369    pq_public: &PqPublicKey,
370) -> bool {
371    let Signature::Hybrid { classical, pq } = signature else {
372        return false;
373    };
374
375    // Evaluate both before combining. This removes top-level short-circuiting;
376    // component-level timing remains governed by the verifier crates.
377    let classical_ok = verify_ed25519_bytes(message, classical, classical_public);
378    let pq_ok = verify_pq(message, &Signature::PostQuantum(pq.clone()), pq_public);
379
380    // Strict AND: both must pass.
381    classical_ok & pq_ok
382}
383
384/// Internal helper: verify raw Ed25519 signature bytes against a public key.
385fn verify_ed25519_bytes(message: &[u8], sig_bytes: &[u8; 64], public: &PublicKey) -> bool {
386    let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(public.as_bytes()) else {
387        return false;
388    };
389    let Ok(sig) = ed25519_dalek::Signature::from_slice(sig_bytes) else {
390        return false;
391    };
392    verifying_key.verify(message, &sig).is_ok()
393}
394
395// ===========================================================================
396// Tests
397// ===========================================================================
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    // -----------------------------------------------------------------------
404    // Ed25519 — classical tests, all unchanged
405    // -----------------------------------------------------------------------
406
407    #[test]
408    fn generate_keypair_produces_valid_pair() {
409        let (pk, sk) = generate_keypair();
410        let msg = b"test message";
411        let sig = sign(msg, &sk);
412        assert!(verify(msg, &sig, &pk));
413    }
414
415    #[test]
416    fn sign_verify_roundtrip() {
417        let (pk, sk) = generate_keypair();
418        let msg = b"hello exochain";
419        let sig = sign(msg, &sk);
420        assert!(verify(msg, &sig, &pk));
421    }
422
423    #[test]
424    fn verify_fails_wrong_message() {
425        let (pk, sk) = generate_keypair();
426        let sig = sign(b"original", &sk);
427        assert!(!verify(b"tampered", &sig, &pk));
428    }
429
430    #[test]
431    fn verify_fails_wrong_key() {
432        let (_pk1, sk1) = generate_keypair();
433        let (pk2, _sk2) = generate_keypair();
434        let sig = sign(b"msg", &sk1);
435        assert!(!verify(b"msg", &sig, &pk2));
436    }
437
438    #[test]
439    fn verify_fails_corrupt_signature() {
440        let (pk, sk) = generate_keypair();
441        let sig = sign(b"msg", &sk);
442        let corrupted = match sig {
443            Signature::Ed25519(mut b) => {
444                b[0] ^= 0xff;
445                Signature::Ed25519(b)
446            }
447            _ => panic!("expected Ed25519"),
448        };
449        assert!(!verify(b"msg", &corrupted, &pk));
450    }
451
452    #[test]
453    fn verify_rejects_empty_signature() {
454        let (pk, _) = generate_keypair();
455        assert!(!verify(b"msg", &Signature::Empty, &pk));
456    }
457
458    #[test]
459    fn verify_rejects_pq_signature_via_classical_path() {
460        // verify() cannot verify PostQuantum — use verify_pq() instead.
461        let (pk, _) = generate_keypair();
462        assert!(!verify(b"msg", &Signature::PostQuantum(vec![1, 2, 3]), &pk));
463    }
464
465    #[test]
466    fn verify_rejects_hybrid_via_classical_path() {
467        // Regression: previously verify() silently accepted Hybrid with only
468        // the Ed25519 component valid. Now it must return false.
469        let (pk, sk) = generate_keypair();
470        let classical = match sign(b"msg", &sk) {
471            Signature::Ed25519(b) => b,
472            _ => panic!("expected Ed25519"),
473        };
474        let hybrid = Signature::Hybrid {
475            classical,
476            pq: vec![0u8; 32],
477        };
478        assert!(
479            !verify(b"msg", &hybrid, &pk),
480            "verify() must not silently downgrade Hybrid to Ed25519-only"
481        );
482    }
483
484    #[test]
485    fn verify_fails_invalid_public_key() {
486        let (_, sk) = generate_keypair();
487        let sig = sign(b"msg", &sk);
488        let bad_pk = PublicKey::from_bytes([0u8; 32]);
489        assert!(!verify(b"msg", &sig, &bad_pk));
490    }
491
492    #[test]
493    fn keypair_generate_and_use() {
494        let kp = KeyPair::generate();
495        let msg = b"keypair test";
496        let sig = kp.sign(msg);
497        assert!(kp.verify(msg, &sig));
498    }
499
500    #[test]
501    fn keypair_from_secret_bytes() {
502        let (_, sk) = generate_keypair();
503        let kp = KeyPair::from_secret_bytes(*sk.as_bytes()).expect("valid");
504        let msg = b"from bytes";
505        let sig = kp.sign(msg);
506        assert!(kp.verify(msg, &sig));
507    }
508
509    #[test]
510    fn keypair_public_key_accessor() {
511        let kp = KeyPair::generate();
512        let pk = kp.public_key();
513        assert_eq!(*pk, kp.public);
514    }
515
516    #[test]
517    fn keypair_secret_key_accessor() {
518        let kp = KeyPair::generate();
519        let sk = kp.secret_key();
520        let sig = sign(b"test", sk);
521        assert!(verify(b"test", &sig, kp.public_key()));
522    }
523
524    #[test]
525    fn keypair_debug_redacts_secret() {
526        let kp = KeyPair::generate();
527        let dbg = format!("{kp:?}");
528        assert!(dbg.contains("***"));
529        assert!(dbg.contains("KeyPair"));
530    }
531
532    #[test]
533    fn keypair_deterministic_from_same_bytes() {
534        let (_, sk) = generate_keypair();
535        let bytes = *sk.as_bytes();
536        let kp1 = KeyPair::from_secret_bytes(bytes).expect("ok");
537        let kp2 = KeyPair::from_secret_bytes(bytes).expect("ok");
538        assert_eq!(kp1.public, kp2.public);
539    }
540
541    #[test]
542    fn signature_deterministic() {
543        let (_, sk) = generate_keypair();
544        let msg = b"determinism test";
545        let sig1 = sign(msg, &sk);
546        let sig2 = sign(msg, &sk);
547        assert_eq!(sig1, sig2);
548    }
549
550    #[test]
551    fn empty_message_sign_verify() {
552        let (pk, sk) = generate_keypair();
553        let sig = sign(b"", &sk);
554        assert!(verify(b"", &sig, &pk));
555    }
556
557    #[test]
558    fn large_message_sign_verify() {
559        let (pk, sk) = generate_keypair();
560        let msg = vec![0xab_u8; 10_000];
561        let sig = sign(&msg, &sk);
562        assert!(verify(&msg, &sig, &pk));
563    }
564
565    // -----------------------------------------------------------------------
566    // ML-DSA-65 (PostQuantum) — new tests
567    // -----------------------------------------------------------------------
568
569    #[test]
570    fn pq_generate_keypair_produces_valid_sizes() {
571        let (pk, sk) = generate_pq_keypair();
572        // ML-DSA-65: verifying key = 1952 bytes, seed = 32 bytes
573        assert_eq!(
574            pk.as_bytes().len(),
575            1952,
576            "PQ public key should be 1952 bytes"
577        );
578        assert_eq!(
579            sk.as_bytes().len(),
580            32,
581            "PQ secret key (seed) should be 32 bytes"
582        );
583    }
584
585    #[test]
586    fn generate_pq_keypair_zeroizes_stack_seed_buffer() {
587        let source = include_str!("crypto.rs");
588        let Some(production) = source.split("#[cfg(test)]").next() else {
589            panic!("production source section exists");
590        };
591        let Some(start) = production.find("pub fn generate_pq_keypair()") else {
592            panic!("generate_pq_keypair source exists");
593        };
594        let Some(end) = production[start..].find("/// Sign `message`") else {
595            panic!("sign_pq docs follow generate_pq_keypair");
596        };
597        let generate_pq_keypair_source = &production[start..start + end];
598
599        assert!(
600            generate_pq_keypair_source.contains("Zeroizing::new([0u8; 32])"),
601            "ML-DSA seed scratch buffer must be wrapped in Zeroizing"
602        );
603        assert!(
604            !generate_pq_keypair_source.contains("let mut seed_bytes = [0u8; 32];"),
605            "plain stack seed buffer can leave ML-DSA seed bytes behind after key generation"
606        );
607    }
608
609    #[test]
610    fn pq_sign_verify_roundtrip() {
611        let (pk, sk) = generate_pq_keypair();
612        let msg = b"hello post-quantum exochain";
613        let sig = sign_pq(msg, &sk).expect("sign_pq should succeed");
614        assert!(
615            verify_pq(msg, &sig, &pk),
616            "verify_pq should accept a valid PostQuantum signature"
617        );
618    }
619
620    #[test]
621    fn pq_verify_fails_wrong_message() {
622        let (pk, sk) = generate_pq_keypair();
623        let sig = sign_pq(b"original", &sk).expect("sign_pq");
624        assert!(!verify_pq(b"tampered", &sig, &pk));
625    }
626
627    #[test]
628    fn pq_verify_fails_wrong_key() {
629        let (_pk1, sk1) = generate_pq_keypair();
630        let (pk2, _sk2) = generate_pq_keypair();
631        let sig = sign_pq(b"msg", &sk1).expect("sign_pq");
632        assert!(!verify_pq(b"msg", &sig, &pk2));
633    }
634
635    #[test]
636    fn pq_verify_fails_corrupt_signature() {
637        let (pk, sk) = generate_pq_keypair();
638        let sig = sign_pq(b"msg", &sk).expect("sign_pq");
639        let corrupted = match sig {
640            Signature::PostQuantum(mut b) => {
641                b[0] ^= 0xff;
642                Signature::PostQuantum(b)
643            }
644            _ => panic!("expected PostQuantum"),
645        };
646        assert!(!verify_pq(b"msg", &corrupted, &pk));
647    }
648
649    #[test]
650    fn pq_verify_rejects_wrong_variant() {
651        let (pk, _) = generate_pq_keypair();
652        assert!(!verify_pq(b"msg", &Signature::Empty, &pk));
653        // Ed25519 signature presented to PQ verifier must be rejected
654        let (_, classical_sk) = generate_keypair();
655        let ed_sig = sign(b"msg", &classical_sk);
656        assert!(!verify_pq(b"msg", &ed_sig, &pk));
657    }
658
659    #[test]
660    fn pq_signature_has_correct_byte_length() {
661        let (_, sk) = generate_pq_keypair();
662        let sig = sign_pq(b"msg", &sk).expect("sign_pq");
663        let Signature::PostQuantum(bytes) = sig else {
664            panic!("expected PostQuantum variant");
665        };
666        // ML-DSA-65 signature is 3309 bytes (FIPS 204 §Table 1)
667        assert_eq!(
668            bytes.len(),
669            3309,
670            "ML-DSA-65 signature should be 3309 bytes"
671        );
672    }
673
674    #[test]
675    fn pq_sign_is_deterministic() {
676        let (_, sk) = generate_pq_keypair();
677        let msg = b"determinism";
678        let sig1 = sign_pq(msg, &sk).expect("sign_pq");
679        let sig2 = sign_pq(msg, &sk).expect("sign_pq");
680        assert_eq!(
681            sig1, sig2,
682            "ML-DSA-65 deterministic signing must be reproducible"
683        );
684    }
685
686    #[test]
687    fn pq_keypair_struct_roundtrip() {
688        let kp = PqKeyPair::generate();
689        let msg = b"pq keypair test";
690        let sig = kp.sign(msg).expect("PqKeyPair::sign");
691        assert!(kp.verify(msg, &sig));
692    }
693
694    #[test]
695    fn pq_keypair_debug_redacts_secret() {
696        let kp = PqKeyPair::generate();
697        let dbg = format!("{kp:?}");
698        assert!(dbg.contains("***"));
699        assert!(dbg.contains("PqKeyPair"));
700    }
701
702    #[test]
703    fn pq_invalid_sk_bytes_returns_error() {
704        let bad_sk = PqSecretKey::from_bytes(vec![0u8; 8]); // wrong size
705        let result = sign_pq(b"msg", &bad_sk);
706        assert!(
707            result.is_err(),
708            "sign_pq with wrong-length seed should fail"
709        );
710    }
711
712    // -----------------------------------------------------------------------
713    // Hybrid Ed25519 + ML-DSA-65 — new tests
714    // -----------------------------------------------------------------------
715
716    #[test]
717    fn hybrid_sign_verify_roundtrip() {
718        let (classical_pk, classical_sk) = generate_keypair();
719        let (pq_pk, pq_sk) = generate_pq_keypair();
720        let msg = b"hybrid dual-sign";
721        let sig = sign_hybrid(msg, &classical_sk, &pq_sk).expect("sign_hybrid");
722        assert!(
723            verify_hybrid(msg, &sig, &classical_pk, &pq_pk),
724            "verify_hybrid should accept a valid Hybrid signature"
725        );
726    }
727
728    #[test]
729    fn hybrid_verification_docs_do_not_overstate_component_timing_privacy() {
730        let source = include_str!("crypto.rs");
731        let Some(production) = source.split("#[cfg(test)]").next() else {
732            panic!("production source section exists");
733        };
734
735        assert!(
736            !production.contains("does not reveal which component failed"),
737            "hybrid verifier docs must not promise component-level timing indistinguishability"
738        );
739        assert!(
740            production.contains("component-level timing remains governed by the verifier crates"),
741            "hybrid verifier docs must state the remaining component-level timing boundary"
742        );
743    }
744
745    #[test]
746    fn hybrid_verify_fails_wrong_message() {
747        let (classical_pk, classical_sk) = generate_keypair();
748        let (pq_pk, pq_sk) = generate_pq_keypair();
749        let sig = sign_hybrid(b"original", &classical_sk, &pq_sk).expect("sign_hybrid");
750        assert!(!verify_hybrid(b"tampered", &sig, &classical_pk, &pq_pk));
751    }
752
753    #[test]
754    fn hybrid_verify_fails_wrong_classical_key() {
755        let (_classical_pk1, classical_sk1) = generate_keypair();
756        let (classical_pk2, _classical_sk2) = generate_keypair();
757        let (pq_pk, pq_sk) = generate_pq_keypair();
758        let sig = sign_hybrid(b"msg", &classical_sk1, &pq_sk).expect("sign_hybrid");
759        assert!(!verify_hybrid(b"msg", &sig, &classical_pk2, &pq_pk));
760    }
761
762    #[test]
763    fn hybrid_verify_fails_wrong_pq_key() {
764        let (classical_pk, classical_sk) = generate_keypair();
765        let (_pq_pk1, pq_sk1) = generate_pq_keypair();
766        let (pq_pk2, _pq_sk2) = generate_pq_keypair();
767        let sig = sign_hybrid(b"msg", &classical_sk, &pq_sk1).expect("sign_hybrid");
768        assert!(!verify_hybrid(b"msg", &sig, &classical_pk, &pq_pk2));
769    }
770
771    #[test]
772    fn hybrid_verify_fails_stripped_pq_component() {
773        // Regression: previously verify() silently accepted Hybrid with only
774        // the Ed25519 component valid (silent downgrade). This test confirms
775        // verify_hybrid rejects a tampered PQ component — closing the gap
776        // documented in EXOCHAIN-REM-005.
777        let (classical_pk, classical_sk) = generate_keypair();
778        let (pq_pk, pq_sk) = generate_pq_keypair();
779        let sig = sign_hybrid(b"msg", &classical_sk, &pq_sk).expect("sign_hybrid");
780        let tampered = match sig {
781            Signature::Hybrid { classical, mut pq } => {
782                pq[0] ^= 0xff;
783                Signature::Hybrid { classical, pq }
784            }
785            _ => panic!("expected Hybrid"),
786        };
787        assert!(
788            !verify_hybrid(b"msg", &tampered, &classical_pk, &pq_pk),
789            "tampered PQ component must cause rejection (ExistentialSafeguard)"
790        );
791    }
792
793    #[test]
794    fn hybrid_verify_fails_stripped_classical_component() {
795        let (classical_pk, classical_sk) = generate_keypair();
796        let (pq_pk, pq_sk) = generate_pq_keypair();
797        let sig = sign_hybrid(b"msg", &classical_sk, &pq_sk).expect("sign_hybrid");
798        let tampered = match sig {
799            Signature::Hybrid { mut classical, pq } => {
800                classical[0] ^= 0xff;
801                Signature::Hybrid { classical, pq }
802            }
803            _ => panic!("expected Hybrid"),
804        };
805        assert!(
806            !verify_hybrid(b"msg", &tampered, &classical_pk, &pq_pk),
807            "tampered Ed25519 component must cause rejection (DualControl)"
808        );
809    }
810
811    #[test]
812    fn hybrid_verify_rejects_wrong_variant() {
813        let (classical_pk, _) = generate_keypair();
814        let (pq_pk, _) = generate_pq_keypair();
815        assert!(!verify_hybrid(
816            b"msg",
817            &Signature::Empty,
818            &classical_pk,
819            &pq_pk
820        ));
821        assert!(!verify_hybrid(
822            b"msg",
823            &Signature::PostQuantum(vec![0u8; 32]),
824            &classical_pk,
825            &pq_pk
826        ));
827    }
828}