Skip to main content

metamorphic_crypto/
sign.rs

1//! Hybrid post-quantum signatures: ML-DSA (FIPS 204) + Ed25519 composite.
2//!
3//! This module implements a *composite* digital signature: every message is
4//! signed by **both** a post-quantum algorithm (ML-DSA, FIPS 204) **and** a
5//! classical algorithm (Ed25519, RFC 8032), and verification requires **both**
6//! component signatures to be valid (strict AND). An attacker therefore has to
7//! break *both* a lattice scheme *and* an elliptic-curve scheme to forge a
8//! signature, and cannot strip one algorithm off to downgrade the other
9//! (signature-stripping / cross-protocol mix-and-match are rejected by the
10//! length-framed, version-tagged wire format).
11//!
12//! It is the signing counterpart to this crate's hybrid KEM ([`crate::hybrid`]):
13//! the KEM combines ML-KEM + X25519 for *confidentiality*, this combines
14//! ML-DSA + Ed25519 for *authenticity / integrity*. It is the foundational
15//! primitive for transparency logs and key-transparency work, where entries
16//! must be signed once and verified byte-identically across native Rust, WASM,
17//! and the Elixir NIF.
18//!
19//! ## Security levels
20//!
21//! ML-DSA is standardized by NIST at three parameter sets only — categories 2,
22//! 3, and 5 — and each is paired here with Ed25519:
23//!
24//! | Level | ML-DSA   | NIST Category | Equivalent | Version Tag | Default |
25//! |-------|----------|---------------|------------|-------------|---------|
26//! | Cat-2 | ML-DSA-44| 2             | ~AES-128   | `0x01`      | No      |
27//! | Cat-3 | ML-DSA-65| 3             | ~AES-192   | `0x02`      | Yes     |
28//! | Cat-5 | ML-DSA-87| 5             | ~AES-256   | `0x03`      | No      |
29//!
30//! Cat-3 is the default, mirroring this crate's KEM default posture.
31//!
32//! ### About the version tags
33//!
34//! The version tag is a **per-artifact-type wire-format version**, *not* a
35//! global NIST-category code. A signature tag only ever appears as the first
36//! byte of a signature / key blob produced by this module, is parsed only by
37//! [`verify`] / [`derive_public_key`], and is never handed to the KEM / seal
38//! code. Signatures and ciphertexts are distinct artifacts processed by
39//! distinct functions, so a signature tag can never be confused with a
40//! sealed-box or hybrid-KEM byte — regardless of its value.
41//!
42//! By design these tags **agree with the KEM tags in [`crate::hybrid`] on every
43//! level the two families share**: Cat-3 = `0x02` and Cat-5 = `0x03` in both.
44//! The single divergence is at `0x01`, which here denotes Cat-2 (ML-DSA-44)
45//! while on the KEM side `0x01` denotes Cat-1 (ML-KEM-512). This is unavoidable:
46//! NIST standardizes ML-KEM at categories {1, 3, 5} but ML-DSA at {2, 3, 5}, so
47//! the two families have different lowest rungs and "tag == category" cannot
48//! hold for both.
49//!
50//! These bytes are **not legacy sentinels**, either. The pre-PQ `box_seal`
51//! ciphertext format is *unversioned* — its first byte is a random ephemeral
52//! public-key byte, not a reserved tag — so there is no `0x00`/`0x01` legacy
53//! marker anywhere for these values to clash with.
54//!
55//! ## Signing mode (hedged / randomized ML-DSA)
56//!
57//! ML-DSA signatures are produced with the **hedged (randomized)** variant from
58//! FIPS 204 — the standard's default and most conservative mode. Hedging mixes
59//! fresh OS randomness into each signature, which (a) is resilient to RNG
60//! failure (it still degrades gracefully toward the deterministic variant) and
61//! (b) hardens lattice signing against fault and side-channel attacks that
62//! deterministic signing is known to invite. Ed25519 is deterministic by design
63//! (RFC 8032), which is the standard, audited behavior and is left unchanged.
64//!
65//! As a result the **signature bytes are not reproducible** (two signatures over
66//! the same message differ) — but the **wire format is fully deterministic and
67//! pinnable**: the layout, version tags, public-key derivation, and the
68//! domain-separation framing are all fixed, so any client can reproduce keys and
69//! verify signatures byte-identically.
70//!
71//! ## Domain separation (stable wire format — reproduce exactly)
72//!
73//! Both algorithms sign the *same* domain-separated message, framed **exactly**
74//! like [`crate::hash::sha3_512_with_context`] (a length-prefixed context):
75//!
76//! ```text
77//! signed_msg = I2OSP(len(context_utf8), 8) || context_utf8 || message
78//! ```
79//!
80//! where `I2OSP(len, 8)` is the byte length of `context` (UTF-8) as a
81//! **big-endian unsigned 64-bit integer**. Each algorithm signs `signed_msg`
82//! directly: Ed25519 hashes it internally per RFC 8032; ML-DSA takes it as the
83//! message with an **empty** native context string (the domain separation lives
84//! entirely in `signed_msg`, so the framing is identical for both algorithms and
85//! across every language binding). The 8-byte length prefix makes the
86//! `(context, message)` boundary unambiguous. `context` is a UTF-8 label,
87//! conventionally a versioned namespace — see [`SIGN_CONTEXT_V1`].
88//!
89//! ## Byte layout
90//!
91//! Ed25519 components are fixed-size and placed first, so the variable-length
92//! ML-DSA tail needs no length prefix. `tag` is the 1-byte version tag above.
93//!
94//! ```text
95//! signature  = tag || ed25519_sig (64 B) || ml_dsa_sig (2420 / 3309 / 4627 B)
96//! public_key = tag || ed25519_pk  (32 B) || ml_dsa_pk  (1312 / 1952 / 2592 B)
97//! secret_key = tag || ed25519_seed(32 B) || ml_dsa_seed(32 B)              = 65 B
98//! ```
99//!
100//! Both algorithms are seeded from independent 32-byte seeds; the ML-DSA seed is
101//! the FIPS 204 `Seed` (`ξ`), which is the canonical 32-byte signing-key
102//! serialization across all three parameter sets.
103//!
104//! ## Encoding
105//!
106//! Native Rust takes raw bytes (`&[u8]`) and returns base64 strings for the
107//! key/signature artifacts (consistent with [`crate::hybrid`]). The WASM
108//! bindings (see [`crate::wasm`]) take and return base64 throughout. The Elixir
109//! NIF mirrors the native bytes. Secret key material is zeroized on drop (see
110//! [`HybridSignatureKeyPair`]).
111//!
112//! ## Dependency audit posture
113//!
114//! | Dependency      | Version | Audited            | Notes |
115//! |-----------------|---------|--------------------|-------|
116//! | `ed25519-dalek` | 2.x     | Yes (mature)       | Widely deployed RFC 8032 implementation. |
117//! | `ml-dsa`        | 0.1.x   | **No** (RustCrypto)| FIPS 204 (final). New crate, not yet independently audited. Pinned; tracked for FIPS-mode roadmap. |
118//!
119//! ML-DSA support is provided as defense-in-depth on top of the mature,
120//! independently-strong Ed25519 signature: even if a flaw were found in the
121//! young `ml-dsa` implementation, the composite remains at least as strong as
122//! Ed25519. This is called out honestly so integrators can make an informed
123//! choice while the post-quantum implementation matures toward audit / FIPS
124//! validation.
125
126use ed25519_dalek::{
127    Signature as EdSignature, Signer, SigningKey as EdSigningKey, Verifier,
128    VerifyingKey as EdVerifyingKey,
129};
130use ml_dsa::signature::rand_core::{TryCryptoRng, TryRng};
131use ml_dsa::signature::{Keypair, Verifier as MlVerifier};
132use ml_dsa::{
133    B32, ExpandedSigningKey, KeyInit, MlDsa44, MlDsa65, MlDsa87, MlDsaParams, Signature,
134    SigningKey, VerifyingKey,
135};
136use std::convert::Infallible;
137use zeroize::{Zeroize, ZeroizeOnDrop};
138
139// CNSA 2.0 matched classical signature partners (v0.7.0).
140use ed448_goldilocks::{
141    Signature as Ed448Signature, SigningKey as Ed448SigningKey, VerifyingKey as Ed448VerifyingKey,
142};
143use p521::ecdsa::signature::RandomizedSigner;
144use p521::ecdsa::{
145    Signature as P521Signature, SigningKey as P521SigningKey, VerifyingKey as P521VerifyingKey,
146};
147use p521::elliptic_curve::FieldBytes;
148use p521::elliptic_curve::ops::Reduce;
149use p521::{NistP521, NonZeroScalar, Scalar, SecretKey as P521SecretKey};
150
151use crate::CryptoError;
152use crate::b64;
153use crate::suite::Suite;
154
155// === Constants ===
156
157/// Recommended versioned context label for general-purpose signing.
158///
159/// Pass this (or another versioned `"namespace/purpose/vN"` label) as the
160/// `context` argument to [`sign`] / [`verify`] to bind signatures to a purpose.
161pub const SIGN_CONTEXT_V1: &str = "metamorphic/sign/v1";
162
163/// Version tag for Cat-2 (ML-DSA-44 + Ed25519). Local to this module.
164const VERSION_CAT2: u8 = 0x01;
165/// Version tag for Cat-3 (ML-DSA-65 + Ed25519, default). Local to this module.
166const VERSION_CAT3: u8 = 0x02;
167/// Version tag for Cat-5 (ML-DSA-87 + Ed25519). Local to this module.
168const VERSION_CAT5: u8 = 0x03;
169
170// === CNSA 2.0 signature suites (v0.7.0) ===
171//
172// New, additive version tags for the opt-in suites. Tag-space is shared with
173// the KEM side (#311) but signatures are distinct artifacts parsed only by
174// `verify` / `derive_public_key`, so there is no cross-family confusion.
175
176/// `0x10` — PureCnsa2 signature (ML-DSA-87 only, Cat-5).
177const VERSION_SIG_PURE_CNSA2: u8 = 0x10;
178/// `0x13` — HybridMatched Cat-3 signature (ML-DSA-65 + Ed448).
179const VERSION_SIG_MATCHED_CAT3: u8 = 0x13;
180/// `0x14` — HybridMatched Cat-5 signature (ML-DSA-87 + ECDSA-P-521, hedged).
181const VERSION_SIG_MATCHED_CAT5: u8 = 0x14;
182
183/// Ed448 seed / public-key length (RFC 8032).
184const ED448_SEED_LEN: usize = 57;
185/// Ed448 public-key length.
186const ED448_PK_LEN: usize = 57;
187/// Ed448 signature length.
188const ED448_SIG_LEN: usize = 114;
189
190/// ECDSA-P-521 secret seed length (wide bytes reduced mod n).
191const P521_SK_LEN: usize = 66;
192/// ECDSA-P-521 uncompressed SEC1 public-key length.
193const P521_PK_LEN: usize = 133;
194/// ECDSA-P-521 fixed-size signature length (`r(66) || s(66)`).
195const P521_SIG_LEN: usize = 132;
196
197/// Ed25519 seed (secret key) length.
198const ED25519_SEED_LEN: usize = 32;
199/// Ed25519 public key length.
200const ED25519_PK_LEN: usize = 32;
201/// Ed25519 signature length.
202const ED25519_SIG_LEN: usize = 64;
203/// ML-DSA seed (`ξ`) length — identical across all parameter sets.
204const MLDSA_SEED_LEN: usize = 32;
205
206/// Combined secret key length: `tag || ed25519_seed || ml_dsa_seed`.
207const SECRET_KEY_LEN: usize = 1 + ED25519_SEED_LEN + MLDSA_SEED_LEN;
208
209// ML-DSA-44 (Cat-2)
210/// ML-DSA-44 public key length.
211const MLDSA44_PK_LEN: usize = 1312;
212/// ML-DSA-44 signature length.
213const MLDSA44_SIG_LEN: usize = 2420;
214// ML-DSA-65 (Cat-3)
215/// ML-DSA-65 public key length.
216const MLDSA65_PK_LEN: usize = 1952;
217/// ML-DSA-65 signature length.
218const MLDSA65_SIG_LEN: usize = 3309;
219// ML-DSA-87 (Cat-5)
220/// ML-DSA-87 public key length.
221const MLDSA87_PK_LEN: usize = 2592;
222/// ML-DSA-87 signature length.
223const MLDSA87_SIG_LEN: usize = 4627;
224
225// === Types ===
226
227/// A hybrid ML-DSA + Ed25519 signing keypair (base64-encoded).
228///
229/// The `secret_key` is zeroized on drop. Both fields are base64 strings using
230/// the byte layout documented at the [module level](crate::sign).
231#[derive(Clone, Zeroize, ZeroizeOnDrop)]
232pub struct HybridSignatureKeyPair {
233    /// Combined public key: `tag || ed25519_pk || ml_dsa_pk`. Base64. Public.
234    #[zeroize(skip)]
235    pub public_key: String,
236    /// Combined secret key: `tag || ed25519_seed || ml_dsa_seed`. Base64. Secret.
237    pub secret_key: String,
238}
239
240impl std::fmt::Debug for HybridSignatureKeyPair {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        f.debug_struct("HybridSignatureKeyPair")
243            .field("public_key", &self.public_key)
244            .field("secret_key", &"<redacted>")
245            .finish()
246    }
247}
248
249/// Security level for hybrid PQ signatures.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
251pub enum SignatureLevel {
252    /// NIST Category 2: ML-DSA-44 + Ed25519 (~AES-128).
253    Cat2,
254    /// NIST Category 3: ML-DSA-65 + Ed25519 (~AES-192). Default.
255    #[default]
256    Cat3,
257    /// NIST Category 5: ML-DSA-87 + Ed25519 (~AES-256).
258    Cat5,
259}
260
261impl SignatureLevel {
262    /// The 1-byte version tag for this level.
263    fn version_tag(self) -> u8 {
264        match self {
265            SignatureLevel::Cat2 => VERSION_CAT2,
266            SignatureLevel::Cat3 => VERSION_CAT3,
267            SignatureLevel::Cat5 => VERSION_CAT5,
268        }
269    }
270
271    /// The ML-DSA public-key length for this level.
272    fn mldsa_pk_len(self) -> usize {
273        match self {
274            SignatureLevel::Cat2 => MLDSA44_PK_LEN,
275            SignatureLevel::Cat3 => MLDSA65_PK_LEN,
276            SignatureLevel::Cat5 => MLDSA87_PK_LEN,
277        }
278    }
279
280    /// The ML-DSA signature length for this level.
281    fn mldsa_sig_len(self) -> usize {
282        match self {
283            SignatureLevel::Cat2 => MLDSA44_SIG_LEN,
284            SignatureLevel::Cat3 => MLDSA65_SIG_LEN,
285            SignatureLevel::Cat5 => MLDSA87_SIG_LEN,
286        }
287    }
288}
289
290// === Helpers ===
291
292/// Fill buffer with OS random bytes.
293#[inline]
294fn random_bytes(buf: &mut [u8]) {
295    getrandom::getrandom(buf).expect("OS CSPRNG unavailable");
296}
297
298/// An infallible, OS-backed CSPRNG adapter for `ml-dsa`'s randomized signer.
299///
300/// `ml-dsa`'s hedged signing path is generic over a `rand_core` RNG. This thin
301/// adapter sources every byte from the OS CSPRNG via [`getrandom`], exactly like
302/// the rest of this crate, so no userspace PRNG is introduced.
303struct OsCsprng;
304
305impl TryRng for OsCsprng {
306    type Error = Infallible;
307
308    fn try_next_u32(&mut self) -> Result<u32, Infallible> {
309        let mut b = [0u8; 4];
310        random_bytes(&mut b);
311        Ok(u32::from_le_bytes(b))
312    }
313
314    fn try_next_u64(&mut self) -> Result<u64, Infallible> {
315        let mut b = [0u8; 8];
316        random_bytes(&mut b);
317        Ok(u64::from_le_bytes(b))
318    }
319
320    fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> {
321        random_bytes(dst);
322        Ok(())
323    }
324}
325
326impl TryCryptoRng for OsCsprng {}
327
328/// Build the domain-separated message: `u64_be(len(context)) || context || message`.
329fn frame(context: &str, message: &[u8]) -> Vec<u8> {
330    let mut out = Vec::with_capacity(8 + context.len() + message.len());
331    out.extend_from_slice(&(context.len() as u64).to_be_bytes());
332    out.extend_from_slice(context.as_bytes());
333    out.extend_from_slice(message);
334    out
335}
336
337/// Map a leading version-tag byte to a [`SignatureLevel`].
338fn level_from_tag(tag: Option<&u8>) -> Result<SignatureLevel, CryptoError> {
339    match tag {
340        Some(&VERSION_CAT2) => Ok(SignatureLevel::Cat2),
341        Some(&VERSION_CAT3) => Ok(SignatureLevel::Cat3),
342        Some(&VERSION_CAT5) => Ok(SignatureLevel::Cat5),
343        _ => Err(CryptoError::Signature(
344            "unknown or missing signature version tag".into(),
345        )),
346    }
347}
348
349/// Derive the ML-DSA public key bytes from a 32-byte seed.
350fn mldsa_public_key<P: MlDsaParams>(seed: &B32) -> Vec<u8> {
351    let vk = SigningKey::<P>::from_seed(seed).verifying_key().encode();
352    AsRef::<[u8]>::as_ref(&vk).to_vec()
353}
354
355/// Produce a hedged (randomized) ML-DSA signature over `framed` (empty native ctx).
356fn mldsa_sign<P: MlDsaParams>(seed: &B32, framed: &[u8]) -> Vec<u8> {
357    let sig = ExpandedSigningKey::<P>::from_seed(seed)
358        .sign_randomized(framed, &[], &mut OsCsprng)
359        .expect("ML-DSA randomized signing (empty context, infallible RNG)")
360        .encode();
361    AsRef::<[u8]>::as_ref(&sig).to_vec()
362}
363
364/// Verify an ML-DSA signature; returns `false` on any malformed input.
365fn mldsa_verify<P: MlDsaParams>(pk: &[u8], framed: &[u8], sig: &[u8]) -> bool {
366    match (
367        VerifyingKey::<P>::new_from_slice(pk),
368        Signature::<P>::try_from(sig),
369    ) {
370        (Ok(vk), Ok(s)) => MlVerifier::verify(&vk, framed, &s).is_ok(),
371        _ => false,
372    }
373}
374
375// === CNSA 2.0 suites: matched classical helpers ===
376
377/// Reduce 66 seed bytes to a non-zero P-521 scalar and wrap as an ECDSA key.
378fn p521_signing_key_from_bytes(bytes: &[u8; P521_SK_LEN]) -> P521SigningKey {
379    let fb: FieldBytes<NistP521> = (*bytes).into();
380    let scalar = <Scalar as Reduce<FieldBytes<NistP521>>>::reduce(&fb);
381    let nz: NonZeroScalar =
382        Option::from(NonZeroScalar::new(scalar)).expect("P-521 scalar reduced to zero");
383    P521SigningKey::from(P521SecretKey::from(nz))
384}
385
386/// Ed448 keygen (deterministic from a 57-byte seed). Returns `(pk(57), SigningKey)`.
387fn ed448_keypair(
388    seed: &[u8; ED448_SEED_LEN],
389) -> Result<([u8; ED448_PK_LEN], Ed448SigningKey), CryptoError> {
390    let sk = Ed448SigningKey::try_from(&seed[..])
391        .map_err(|_| CryptoError::Signature("invalid Ed448 seed".into()))?;
392    let pk = sk.verifying_key().to_bytes();
393    Ok((pk, sk))
394}
395
396// === CNSA 2.0 suites: keygen ===
397
398/// Generate a signing keypair for the given [`Suite`] + [`SignatureLevel`].
399///
400/// - `Suite::Hybrid` (any level) and `Suite::HybridMatched` at Cat-2 delegate to
401///   the existing [`generate_signing_keypair_with_level`] (ML-DSA + Ed25519;
402///   identical bytes/tags).
403/// - `Suite::HybridMatched` at Cat-3 (ML-DSA-65 + Ed448) / Cat-5 (ML-DSA-87 +
404///   ECDSA-P-521) and `Suite::PureCnsa2` at Cat-5 (ML-DSA-87 only) produce the
405///   new tagged layouts.
406///
407/// Returns an error for unsupported combinations (PureCnsa2 below Cat-5).
408pub fn generate_signing_keypair_suite(
409    suite: Suite,
410    level: SignatureLevel,
411) -> Result<HybridSignatureKeyPair, CryptoError> {
412    match (suite, level) {
413        (Suite::Hybrid, _) | (Suite::HybridMatched, SignatureLevel::Cat2) => {
414            Ok(generate_signing_keypair_with_level(level))
415        }
416        (Suite::HybridMatched, SignatureLevel::Cat3) => Ok(generate_matched_cat3_keypair()),
417        (Suite::HybridMatched, SignatureLevel::Cat5) => Ok(generate_matched_cat5_keypair()),
418        (Suite::PureCnsa2, SignatureLevel::Cat5) => Ok(generate_pure_cnsa2_keypair()),
419        (Suite::PureCnsa2, _) => Err(CryptoError::Signature(
420            "PureCnsa2 signatures are Cat-5 (ML-DSA-87) only in v0.7.0".into(),
421        )),
422    }
423}
424
425fn generate_pure_cnsa2_keypair() -> HybridSignatureKeyPair {
426    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
427    random_bytes(&mut ml_seed_bytes);
428    let ml_seed: B32 = ml_seed_bytes.into();
429    let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
430
431    let mut public_key = Vec::with_capacity(1 + ml_pk.len());
432    public_key.push(VERSION_SIG_PURE_CNSA2);
433    public_key.extend_from_slice(&ml_pk);
434
435    let mut secret_key = Vec::with_capacity(1 + MLDSA_SEED_LEN);
436    secret_key.push(VERSION_SIG_PURE_CNSA2);
437    secret_key.extend_from_slice(&ml_seed_bytes);
438
439    let pair = HybridSignatureKeyPair {
440        public_key: b64::encode(&public_key),
441        secret_key: b64::encode(&secret_key),
442    };
443    ml_seed_bytes.zeroize();
444    secret_key.zeroize();
445    pair
446}
447
448fn generate_matched_cat3_keypair() -> HybridSignatureKeyPair {
449    let mut ed_seed = [0u8; ED448_SEED_LEN];
450    random_bytes(&mut ed_seed);
451    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
452    random_bytes(&mut ml_seed_bytes);
453
454    let (ed_pk, _) = ed448_keypair(&ed_seed).expect("freshly generated Ed448 seed");
455    let ml_seed: B32 = ml_seed_bytes.into();
456    let ml_pk = mldsa_public_key::<MlDsa65>(&ml_seed);
457
458    let mut public_key = Vec::with_capacity(1 + ED448_PK_LEN + ml_pk.len());
459    public_key.push(VERSION_SIG_MATCHED_CAT3);
460    public_key.extend_from_slice(&ed_pk);
461    public_key.extend_from_slice(&ml_pk);
462
463    let mut secret_key = Vec::with_capacity(1 + ED448_SEED_LEN + MLDSA_SEED_LEN);
464    secret_key.push(VERSION_SIG_MATCHED_CAT3);
465    secret_key.extend_from_slice(&ed_seed);
466    secret_key.extend_from_slice(&ml_seed_bytes);
467
468    let pair = HybridSignatureKeyPair {
469        public_key: b64::encode(&public_key),
470        secret_key: b64::encode(&secret_key),
471    };
472    ed_seed.zeroize();
473    ml_seed_bytes.zeroize();
474    secret_key.zeroize();
475    pair
476}
477
478fn generate_matched_cat5_keypair() -> HybridSignatureKeyPair {
479    let mut ec_seed = [0u8; P521_SK_LEN];
480    random_bytes(&mut ec_seed);
481    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
482    random_bytes(&mut ml_seed_bytes);
483
484    let signing = p521_signing_key_from_bytes(&ec_seed);
485    let ec_pk: [u8; P521_PK_LEN] = signing
486        .verifying_key()
487        .to_sec1_point(false)
488        .as_bytes()
489        .try_into()
490        .expect("uncompressed P-521 public key is 133 bytes");
491    let ml_seed: B32 = ml_seed_bytes.into();
492    let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
493
494    let mut public_key = Vec::with_capacity(1 + P521_PK_LEN + ml_pk.len());
495    public_key.push(VERSION_SIG_MATCHED_CAT5);
496    public_key.extend_from_slice(&ec_pk);
497    public_key.extend_from_slice(&ml_pk);
498
499    let mut secret_key = Vec::with_capacity(1 + P521_SK_LEN + MLDSA_SEED_LEN);
500    secret_key.push(VERSION_SIG_MATCHED_CAT5);
501    secret_key.extend_from_slice(&ec_seed);
502    secret_key.extend_from_slice(&ml_seed_bytes);
503
504    let pair = HybridSignatureKeyPair {
505        public_key: b64::encode(&public_key),
506        secret_key: b64::encode(&secret_key),
507    };
508    ec_seed.zeroize();
509    ml_seed_bytes.zeroize();
510    secret_key.zeroize();
511    pair
512}
513
514// === Public API: keygen ===
515
516/// Generate a hybrid ML-DSA-65 + Ed25519 signing keypair (Cat-3, default).
517pub fn generate_signing_keypair() -> HybridSignatureKeyPair {
518    generate_signing_keypair_with_level(SignatureLevel::Cat3)
519}
520
521/// Generate a hybrid ML-DSA-44 + Ed25519 signing keypair (Cat-2).
522pub fn generate_signing_keypair_44() -> HybridSignatureKeyPair {
523    generate_signing_keypair_with_level(SignatureLevel::Cat2)
524}
525
526/// Generate a hybrid ML-DSA-87 + Ed25519 signing keypair (Cat-5).
527pub fn generate_signing_keypair_87() -> HybridSignatureKeyPair {
528    generate_signing_keypair_with_level(SignatureLevel::Cat5)
529}
530
531/// Generate a hybrid signing keypair at the specified security level.
532///
533/// Returns base64 `public_key` and `secret_key` using the documented byte
534/// layout. The two algorithms are seeded from independent OS randomness.
535pub fn generate_signing_keypair_with_level(level: SignatureLevel) -> HybridSignatureKeyPair {
536    let mut ed_seed = [0u8; ED25519_SEED_LEN];
537    random_bytes(&mut ed_seed);
538    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
539    random_bytes(&mut ml_seed_bytes);
540
541    let ed_sk = EdSigningKey::from_bytes(&ed_seed);
542    let ed_pk = ed_sk.verifying_key().to_bytes();
543
544    let ml_seed: B32 = ml_seed_bytes.into();
545    let ml_pk = match level {
546        SignatureLevel::Cat2 => mldsa_public_key::<MlDsa44>(&ml_seed),
547        SignatureLevel::Cat3 => mldsa_public_key::<MlDsa65>(&ml_seed),
548        SignatureLevel::Cat5 => mldsa_public_key::<MlDsa87>(&ml_seed),
549    };
550
551    let tag = level.version_tag();
552
553    let mut public_key = Vec::with_capacity(1 + ED25519_PK_LEN + ml_pk.len());
554    public_key.push(tag);
555    public_key.extend_from_slice(&ed_pk);
556    public_key.extend_from_slice(&ml_pk);
557
558    let mut secret_key = Vec::with_capacity(SECRET_KEY_LEN);
559    secret_key.push(tag);
560    secret_key.extend_from_slice(&ed_seed);
561    secret_key.extend_from_slice(&ml_seed_bytes);
562
563    let pair = HybridSignatureKeyPair {
564        public_key: b64::encode(&public_key),
565        secret_key: b64::encode(&secret_key),
566    };
567
568    ed_seed.zeroize();
569    ml_seed_bytes.zeroize();
570    secret_key.zeroize();
571    pair
572}
573
574// === Public API: sign / verify ===
575
576/// Re-derive the base64 public key from a base64 hybrid secret key.
577///
578/// Both component public keys are a deterministic function of the secret seeds,
579/// so this reproduces exactly the `public_key` returned by keygen. Useful for
580/// recovering a public key from a backed-up secret, or for verifying that a
581/// secret/public pair belong together. The security level is read from the
582/// secret key's version tag.
583pub fn derive_public_key(secret_key_b64: &str) -> Result<String, CryptoError> {
584    let mut sk_bytes = b64::decode(secret_key_b64)?;
585    if let Some(&tag) = sk_bytes.first() {
586        if is_new_suite_tag(tag) {
587            let out = derive_public_key_new_suite(&sk_bytes);
588            sk_bytes.zeroize();
589            return out;
590        }
591    }
592    let level = level_from_tag(sk_bytes.first())?;
593
594    if sk_bytes.len() != SECRET_KEY_LEN {
595        sk_bytes.zeroize();
596        return Err(CryptoError::InvalidLength {
597            expected: SECRET_KEY_LEN,
598            got: sk_bytes.len(),
599        });
600    }
601
602    let mut ed_seed = [0u8; ED25519_SEED_LEN];
603    ed_seed.copy_from_slice(&sk_bytes[1..1 + ED25519_SEED_LEN]);
604    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
605    ml_seed_bytes.copy_from_slice(&sk_bytes[1 + ED25519_SEED_LEN..SECRET_KEY_LEN]);
606    sk_bytes.zeroize();
607
608    let ed_pk = EdSigningKey::from_bytes(&ed_seed)
609        .verifying_key()
610        .to_bytes();
611    ed_seed.zeroize();
612
613    let ml_seed: B32 = ml_seed_bytes.into();
614    let ml_pk = match level {
615        SignatureLevel::Cat2 => mldsa_public_key::<MlDsa44>(&ml_seed),
616        SignatureLevel::Cat3 => mldsa_public_key::<MlDsa65>(&ml_seed),
617        SignatureLevel::Cat5 => mldsa_public_key::<MlDsa87>(&ml_seed),
618    };
619    ml_seed_bytes.zeroize();
620
621    let mut public_key = Vec::with_capacity(1 + ED25519_PK_LEN + ml_pk.len());
622    public_key.push(level.version_tag());
623    public_key.extend_from_slice(&ed_pk);
624    public_key.extend_from_slice(&ml_pk);
625
626    Ok(b64::encode(&public_key))
627}
628/// Sign `message` under `context` with a base64 hybrid `secret_key`.
629///
630/// Produces a composite signature: a hedged (randomized) ML-DSA signature and a
631/// deterministic Ed25519 signature, both over the domain-separated
632/// `frame(context, message)`. The security level is read from the secret key's
633/// version tag. Returns the base64 signature `tag || ed25519_sig || ml_dsa_sig`.
634///
635/// Because ML-DSA signing is randomized, signing the same message twice yields
636/// different (both-valid) signatures. Use [`SIGN_CONTEXT_V1`] (or another
637/// versioned label) for `context`.
638pub fn sign(message: &[u8], context: &str, secret_key_b64: &str) -> Result<String, CryptoError> {
639    let mut sk_bytes = b64::decode(secret_key_b64)?;
640    if let Some(&tag) = sk_bytes.first() {
641        if is_new_suite_tag(tag) {
642            let out = sign_new_suite(message, context, &sk_bytes);
643            sk_bytes.zeroize();
644            return out;
645        }
646    }
647    let level = level_from_tag(sk_bytes.first())?;
648
649    if sk_bytes.len() != SECRET_KEY_LEN {
650        sk_bytes.zeroize();
651        return Err(CryptoError::InvalidLength {
652            expected: SECRET_KEY_LEN,
653            got: sk_bytes.len(),
654        });
655    }
656
657    let mut ed_seed = [0u8; ED25519_SEED_LEN];
658    ed_seed.copy_from_slice(&sk_bytes[1..1 + ED25519_SEED_LEN]);
659    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
660    ml_seed_bytes.copy_from_slice(&sk_bytes[1 + ED25519_SEED_LEN..SECRET_KEY_LEN]);
661    sk_bytes.zeroize();
662
663    let framed = frame(context, message);
664
665    let ed_sk = EdSigningKey::from_bytes(&ed_seed);
666    let ed_sig = ed_sk.sign(&framed).to_bytes();
667    ed_seed.zeroize();
668
669    let ml_seed: B32 = ml_seed_bytes.into();
670    let ml_sig = match level {
671        SignatureLevel::Cat2 => mldsa_sign::<MlDsa44>(&ml_seed, &framed),
672        SignatureLevel::Cat3 => mldsa_sign::<MlDsa65>(&ml_seed, &framed),
673        SignatureLevel::Cat5 => mldsa_sign::<MlDsa87>(&ml_seed, &framed),
674    };
675    ml_seed_bytes.zeroize();
676
677    let mut out = Vec::with_capacity(1 + ED25519_SIG_LEN + ml_sig.len());
678    out.push(level.version_tag());
679    out.extend_from_slice(&ed_sig);
680    out.extend_from_slice(&ml_sig);
681
682    Ok(b64::encode(&out))
683}
684
685/// Verify a composite `signature_b64` over `message`/`context` against `public_key_b64`.
686///
687/// Returns `Ok(true)` **only if both** the Ed25519 and ML-DSA component
688/// signatures verify (strict AND). Returns `Ok(false)` for any cryptographic
689/// failure, including a tampered message/context, a wrong key, a tampered
690/// half-signature, or a signature/public-key whose version tags or lengths do
691/// not match (cross-level or stripped). Returns `Err` only when an input cannot
692/// be decoded as base64 or carries an unknown version tag.
693pub fn verify(
694    message: &[u8],
695    context: &str,
696    signature_b64: &str,
697    public_key_b64: &str,
698) -> Result<bool, CryptoError> {
699    let sig = b64::decode(signature_b64)?;
700    let pk = b64::decode(public_key_b64)?;
701
702    // CNSA-2.0 suites route on their own tags (no cross-family confusion).
703    match (sig.first(), pk.first()) {
704        (Some(&s), _) if is_new_suite_tag(s) => {
705            return verify_new_suite(message, context, &sig, &pk);
706        }
707        (_, Some(&p)) if is_new_suite_tag(p) => return Ok(false),
708        _ => {}
709    }
710
711    let sig_level = level_from_tag(sig.first())?;
712    let pk_level = level_from_tag(pk.first())?;
713    // Mismatched levels => verification fails (no cross-level confusion).
714    if sig_level != pk_level {
715        return Ok(false);
716    }
717    let level = sig_level;
718
719    if sig.len() != 1 + ED25519_SIG_LEN + level.mldsa_sig_len()
720        || pk.len() != 1 + ED25519_PK_LEN + level.mldsa_pk_len()
721    {
722        return Ok(false);
723    }
724
725    let framed = frame(context, message);
726
727    let ed_pk_bytes: [u8; ED25519_PK_LEN] = pk[1..1 + ED25519_PK_LEN].try_into().unwrap();
728    let ed_sig_bytes: [u8; ED25519_SIG_LEN] = sig[1..1 + ED25519_SIG_LEN].try_into().unwrap();
729    let ml_pk = &pk[1 + ED25519_PK_LEN..];
730    let ml_sig = &sig[1 + ED25519_SIG_LEN..];
731
732    let ed_ok = match EdVerifyingKey::from_bytes(&ed_pk_bytes) {
733        Ok(vk) => vk
734            .verify(&framed, &EdSignature::from_bytes(&ed_sig_bytes))
735            .is_ok(),
736        Err(_) => false,
737    };
738
739    let ml_ok = match level {
740        SignatureLevel::Cat2 => mldsa_verify::<MlDsa44>(ml_pk, &framed, ml_sig),
741        SignatureLevel::Cat3 => mldsa_verify::<MlDsa65>(ml_pk, &framed, ml_sig),
742        SignatureLevel::Cat5 => mldsa_verify::<MlDsa87>(ml_pk, &framed, ml_sig),
743    };
744
745    // Strict AND: both component signatures must verify.
746    Ok(ed_ok && ml_ok)
747}
748
749// === CNSA 2.0 suites: sign / verify / derive ===
750
751/// Returns `true` if `tag` is one of the new CNSA-2.0 signature suite tags.
752fn is_new_suite_tag(tag: u8) -> bool {
753    matches!(
754        tag,
755        VERSION_SIG_PURE_CNSA2 | VERSION_SIG_MATCHED_CAT3 | VERSION_SIG_MATCHED_CAT5
756    )
757}
758
759fn sign_new_suite(message: &[u8], context: &str, sk: &[u8]) -> Result<String, CryptoError> {
760    let framed = frame(context, message);
761    match sk[0] {
762        VERSION_SIG_PURE_CNSA2 => {
763            if sk.len() != 1 + MLDSA_SEED_LEN {
764                return Err(CryptoError::InvalidLength {
765                    expected: 1 + MLDSA_SEED_LEN,
766                    got: sk.len(),
767                });
768            }
769            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
770            ml_seed_bytes.copy_from_slice(&sk[1..]);
771            let ml_seed: B32 = ml_seed_bytes.into();
772            let ml_sig = mldsa_sign::<MlDsa87>(&ml_seed, &framed);
773            ml_seed_bytes.zeroize();
774            let mut out = Vec::with_capacity(1 + ml_sig.len());
775            out.push(VERSION_SIG_PURE_CNSA2);
776            out.extend_from_slice(&ml_sig);
777            Ok(b64::encode(&out))
778        }
779        VERSION_SIG_MATCHED_CAT3 => {
780            if sk.len() != 1 + ED448_SEED_LEN + MLDSA_SEED_LEN {
781                return Err(CryptoError::InvalidLength {
782                    expected: 1 + ED448_SEED_LEN + MLDSA_SEED_LEN,
783                    got: sk.len(),
784                });
785            }
786            let mut ed_seed = [0u8; ED448_SEED_LEN];
787            ed_seed.copy_from_slice(&sk[1..1 + ED448_SEED_LEN]);
788            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
789            ml_seed_bytes.copy_from_slice(&sk[1 + ED448_SEED_LEN..]);
790
791            let (_, ed_sk) = ed448_keypair(&ed_seed)?;
792            let ed_sig = ed_sk.sign_raw(&framed).to_bytes();
793            ed_seed.zeroize();
794            let ml_seed: B32 = ml_seed_bytes.into();
795            let ml_sig = mldsa_sign::<MlDsa65>(&ml_seed, &framed);
796            ml_seed_bytes.zeroize();
797
798            let mut out = Vec::with_capacity(1 + ED448_SIG_LEN + ml_sig.len());
799            out.push(VERSION_SIG_MATCHED_CAT3);
800            out.extend_from_slice(&ed_sig);
801            out.extend_from_slice(&ml_sig);
802            Ok(b64::encode(&out))
803        }
804        VERSION_SIG_MATCHED_CAT5 => {
805            if sk.len() != 1 + P521_SK_LEN + MLDSA_SEED_LEN {
806                return Err(CryptoError::InvalidLength {
807                    expected: 1 + P521_SK_LEN + MLDSA_SEED_LEN,
808                    got: sk.len(),
809                });
810            }
811            let mut ec_seed = [0u8; P521_SK_LEN];
812            ec_seed.copy_from_slice(&sk[1..1 + P521_SK_LEN]);
813            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
814            ml_seed_bytes.copy_from_slice(&sk[1 + P521_SK_LEN..]);
815
816            // Hedged RFC 6979 ECDSA: deterministic nonce + added OS entropy.
817            let signing = p521_signing_key_from_bytes(&ec_seed);
818            let ec_sig: P521Signature = signing
819                .try_sign_with_rng(&mut OsCsprng, &framed)
820                .map_err(|_| CryptoError::Signature("ECDSA-P-521 signing failed".into()))?;
821            let ec_sig_bytes = ec_sig.to_bytes();
822            ec_seed.zeroize();
823            let ml_seed: B32 = ml_seed_bytes.into();
824            let ml_sig = mldsa_sign::<MlDsa87>(&ml_seed, &framed);
825            ml_seed_bytes.zeroize();
826
827            let mut out = Vec::with_capacity(1 + P521_SIG_LEN + ml_sig.len());
828            out.push(VERSION_SIG_MATCHED_CAT5);
829            out.extend_from_slice(ec_sig_bytes.as_slice());
830            out.extend_from_slice(&ml_sig);
831            Ok(b64::encode(&out))
832        }
833        _ => Err(CryptoError::Signature("not a CNSA-2.0 suite tag".into())),
834    }
835}
836
837fn ed448_verify(pk: &[u8], framed: &[u8], sig: &[u8]) -> bool {
838    let Ok(pk_arr): Result<[u8; ED448_PK_LEN], _> = pk.try_into() else {
839        return false;
840    };
841    let Ok(vk) = Ed448VerifyingKey::from_bytes(&pk_arr) else {
842        return false;
843    };
844    let Ok(signature) = Ed448Signature::try_from(sig) else {
845        return false;
846    };
847    vk.verify_raw(&signature, framed).is_ok()
848}
849
850fn p521_ecdsa_verify(pk: &[u8], framed: &[u8], sig: &[u8]) -> bool {
851    let Ok(vk) = P521VerifyingKey::from_sec1_bytes(pk) else {
852        return false;
853    };
854    let Ok(signature) = P521Signature::from_slice(sig) else {
855        return false;
856    };
857    vk.verify(framed, &signature).is_ok()
858}
859
860fn verify_new_suite(
861    message: &[u8],
862    context: &str,
863    sig: &[u8],
864    pk: &[u8],
865) -> Result<bool, CryptoError> {
866    // Mismatched suite tags between signature and public key => fail (no
867    // cross-suite confusion / downgrade).
868    if sig[0] != pk[0] {
869        return Ok(false);
870    }
871    let framed = frame(context, message);
872    match sig[0] {
873        VERSION_SIG_PURE_CNSA2 => {
874            if sig.len() != 1 + MLDSA87_SIG_LEN || pk.len() != 1 + MLDSA87_PK_LEN {
875                return Ok(false);
876            }
877            Ok(mldsa_verify::<MlDsa87>(&pk[1..], &framed, &sig[1..]))
878        }
879        VERSION_SIG_MATCHED_CAT3 => {
880            if sig.len() != 1 + ED448_SIG_LEN + MLDSA65_SIG_LEN
881                || pk.len() != 1 + ED448_PK_LEN + MLDSA65_PK_LEN
882            {
883                return Ok(false);
884            }
885            let ed_ok = ed448_verify(
886                &pk[1..1 + ED448_PK_LEN],
887                &framed,
888                &sig[1..1 + ED448_SIG_LEN],
889            );
890            let ml_ok = mldsa_verify::<MlDsa65>(
891                &pk[1 + ED448_PK_LEN..],
892                &framed,
893                &sig[1 + ED448_SIG_LEN..],
894            );
895            Ok(ed_ok && ml_ok)
896        }
897        VERSION_SIG_MATCHED_CAT5 => {
898            if sig.len() != 1 + P521_SIG_LEN + MLDSA87_SIG_LEN
899                || pk.len() != 1 + P521_PK_LEN + MLDSA87_PK_LEN
900            {
901                return Ok(false);
902            }
903            let ec_ok =
904                p521_ecdsa_verify(&pk[1..1 + P521_PK_LEN], &framed, &sig[1..1 + P521_SIG_LEN]);
905            let ml_ok =
906                mldsa_verify::<MlDsa87>(&pk[1 + P521_PK_LEN..], &framed, &sig[1 + P521_SIG_LEN..]);
907            Ok(ec_ok && ml_ok)
908        }
909        _ => Err(CryptoError::Signature("not a CNSA-2.0 suite tag".into())),
910    }
911}
912
913fn derive_public_key_new_suite(sk: &[u8]) -> Result<String, CryptoError> {
914    match sk[0] {
915        VERSION_SIG_PURE_CNSA2 => {
916            if sk.len() != 1 + MLDSA_SEED_LEN {
917                return Err(CryptoError::InvalidLength {
918                    expected: 1 + MLDSA_SEED_LEN,
919                    got: sk.len(),
920                });
921            }
922            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
923            ml_seed_bytes.copy_from_slice(&sk[1..]);
924            let ml_seed: B32 = ml_seed_bytes.into();
925            let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
926            ml_seed_bytes.zeroize();
927            let mut pk = Vec::with_capacity(1 + ml_pk.len());
928            pk.push(VERSION_SIG_PURE_CNSA2);
929            pk.extend_from_slice(&ml_pk);
930            Ok(b64::encode(&pk))
931        }
932        VERSION_SIG_MATCHED_CAT3 => {
933            if sk.len() != 1 + ED448_SEED_LEN + MLDSA_SEED_LEN {
934                return Err(CryptoError::InvalidLength {
935                    expected: 1 + ED448_SEED_LEN + MLDSA_SEED_LEN,
936                    got: sk.len(),
937                });
938            }
939            let mut ed_seed = [0u8; ED448_SEED_LEN];
940            ed_seed.copy_from_slice(&sk[1..1 + ED448_SEED_LEN]);
941            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
942            ml_seed_bytes.copy_from_slice(&sk[1 + ED448_SEED_LEN..]);
943            let (ed_pk, _) = ed448_keypair(&ed_seed)?;
944            ed_seed.zeroize();
945            let ml_seed: B32 = ml_seed_bytes.into();
946            let ml_pk = mldsa_public_key::<MlDsa65>(&ml_seed);
947            ml_seed_bytes.zeroize();
948            let mut pk = Vec::with_capacity(1 + ED448_PK_LEN + ml_pk.len());
949            pk.push(VERSION_SIG_MATCHED_CAT3);
950            pk.extend_from_slice(&ed_pk);
951            pk.extend_from_slice(&ml_pk);
952            Ok(b64::encode(&pk))
953        }
954        VERSION_SIG_MATCHED_CAT5 => {
955            if sk.len() != 1 + P521_SK_LEN + MLDSA_SEED_LEN {
956                return Err(CryptoError::InvalidLength {
957                    expected: 1 + P521_SK_LEN + MLDSA_SEED_LEN,
958                    got: sk.len(),
959                });
960            }
961            let mut ec_seed = [0u8; P521_SK_LEN];
962            ec_seed.copy_from_slice(&sk[1..1 + P521_SK_LEN]);
963            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
964            ml_seed_bytes.copy_from_slice(&sk[1 + P521_SK_LEN..]);
965            let signing = p521_signing_key_from_bytes(&ec_seed);
966            let ec_pk: [u8; P521_PK_LEN] = signing
967                .verifying_key()
968                .to_sec1_point(false)
969                .as_bytes()
970                .try_into()
971                .expect("uncompressed P-521 public key is 133 bytes");
972            ec_seed.zeroize();
973            let ml_seed: B32 = ml_seed_bytes.into();
974            let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
975            ml_seed_bytes.zeroize();
976            let mut pk = Vec::with_capacity(1 + P521_PK_LEN + ml_pk.len());
977            pk.push(VERSION_SIG_MATCHED_CAT5);
978            pk.extend_from_slice(&ec_pk);
979            pk.extend_from_slice(&ml_pk);
980            Ok(b64::encode(&pk))
981        }
982        _ => Err(CryptoError::Signature("not a CNSA-2.0 suite tag".into())),
983    }
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    fn roundtrip(level: SignatureLevel) {
991        let kp = generate_signing_keypair_with_level(level);
992        let sig = sign(b"hello transparency log", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
993        assert!(
994            verify(
995                b"hello transparency log",
996                SIGN_CONTEXT_V1,
997                &sig,
998                &kp.public_key
999            )
1000            .unwrap()
1001        );
1002    }
1003
1004    #[test]
1005    fn cat2_roundtrip() {
1006        roundtrip(SignatureLevel::Cat2);
1007    }
1008
1009    #[test]
1010    fn cat3_roundtrip() {
1011        roundtrip(SignatureLevel::Cat3);
1012    }
1013
1014    #[test]
1015    fn cat5_roundtrip() {
1016        roundtrip(SignatureLevel::Cat5);
1017    }
1018
1019    #[test]
1020    fn default_is_cat3() {
1021        assert_eq!(SignatureLevel::default(), SignatureLevel::Cat3);
1022        let kp = generate_signing_keypair();
1023        let raw = b64::decode(&kp.public_key).unwrap();
1024        assert_eq!(raw[0], VERSION_CAT3);
1025    }
1026
1027    #[test]
1028    fn version_tags() {
1029        for (level, tag) in [
1030            (SignatureLevel::Cat2, VERSION_CAT2),
1031            (SignatureLevel::Cat3, VERSION_CAT3),
1032            (SignatureLevel::Cat5, VERSION_CAT5),
1033        ] {
1034            let kp = generate_signing_keypair_with_level(level);
1035            let sig = sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1036            assert_eq!(b64::decode(&kp.public_key).unwrap()[0], tag);
1037            assert_eq!(b64::decode(&kp.secret_key).unwrap()[0], tag);
1038            assert_eq!(b64::decode(&sig).unwrap()[0], tag);
1039        }
1040    }
1041
1042    #[test]
1043    fn key_and_signature_sizes() {
1044        for (level, pk_len, sig_len) in [
1045            (SignatureLevel::Cat2, MLDSA44_PK_LEN, MLDSA44_SIG_LEN),
1046            (SignatureLevel::Cat3, MLDSA65_PK_LEN, MLDSA65_SIG_LEN),
1047            (SignatureLevel::Cat5, MLDSA87_PK_LEN, MLDSA87_SIG_LEN),
1048        ] {
1049            let kp = generate_signing_keypair_with_level(level);
1050            let pk = b64::decode(&kp.public_key).unwrap();
1051            let sk = b64::decode(&kp.secret_key).unwrap();
1052            let sig = b64::decode(&sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
1053            assert_eq!(pk.len(), 1 + ED25519_PK_LEN + pk_len);
1054            assert_eq!(sk.len(), SECRET_KEY_LEN);
1055            assert_eq!(sig.len(), 1 + ED25519_SIG_LEN + sig_len);
1056        }
1057    }
1058
1059    #[test]
1060    fn wrong_key_fails() {
1061        let kp1 = generate_signing_keypair();
1062        let kp2 = generate_signing_keypair();
1063        let sig = sign(b"msg", SIGN_CONTEXT_V1, &kp1.secret_key).unwrap();
1064        assert!(!verify(b"msg", SIGN_CONTEXT_V1, &sig, &kp2.public_key).unwrap());
1065    }
1066
1067    #[test]
1068    fn tampered_message_fails() {
1069        let kp = generate_signing_keypair();
1070        let sig = sign(b"original", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1071        assert!(!verify(b"tampered", SIGN_CONTEXT_V1, &sig, &kp.public_key).unwrap());
1072    }
1073
1074    #[test]
1075    fn context_separation() {
1076        let kp = generate_signing_keypair();
1077        let sig = sign(b"msg", "metamorphic/sign/v1", &kp.secret_key).unwrap();
1078        // Same message, different verification context => fails.
1079        assert!(!verify(b"msg", "metamorphic/other/v1", &sig, &kp.public_key).unwrap());
1080    }
1081
1082    #[test]
1083    fn empty_message_and_context() {
1084        let kp = generate_signing_keypair();
1085        let sig = sign(b"", "", &kp.secret_key).unwrap();
1086        assert!(verify(b"", "", &sig, &kp.public_key).unwrap());
1087    }
1088
1089    #[test]
1090    fn nondeterministic_but_both_valid() {
1091        let kp = generate_signing_keypair();
1092        let s1 = sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1093        let s2 = sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1094        // Hedged ML-DSA => signatures differ, but both verify.
1095        assert_ne!(s1, s2);
1096        assert!(verify(b"msg", SIGN_CONTEXT_V1, &s1, &kp.public_key).unwrap());
1097        assert!(verify(b"msg", SIGN_CONTEXT_V1, &s2, &kp.public_key).unwrap());
1098    }
1099
1100    /// Strict AND: tampering with *either* component signature must fail
1101    /// verification, proving neither algorithm can be stripped or forged alone.
1102    #[test]
1103    fn strict_and_requires_both() {
1104        let kp = generate_signing_keypair();
1105        let good = b64::decode(&sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
1106
1107        // Corrupt a byte inside the Ed25519 component (ML-DSA still valid).
1108        let mut bad_ed = good.clone();
1109        bad_ed[1] ^= 0xFF;
1110        assert!(
1111            !verify(
1112                b"msg",
1113                SIGN_CONTEXT_V1,
1114                &b64::encode(&bad_ed),
1115                &kp.public_key
1116            )
1117            .unwrap()
1118        );
1119
1120        // Corrupt a byte inside the ML-DSA component (Ed25519 still valid).
1121        let mut bad_ml = good.clone();
1122        let i = 1 + ED25519_SIG_LEN + 10;
1123        bad_ml[i] ^= 0xFF;
1124        assert!(
1125            !verify(
1126                b"msg",
1127                SIGN_CONTEXT_V1,
1128                &b64::encode(&bad_ml),
1129                &kp.public_key
1130            )
1131            .unwrap()
1132        );
1133    }
1134
1135    #[test]
1136    fn cross_level_fails() {
1137        let kp3 = generate_signing_keypair_with_level(SignatureLevel::Cat3);
1138        let kp5 = generate_signing_keypair_with_level(SignatureLevel::Cat5);
1139        let sig3 = sign(b"msg", SIGN_CONTEXT_V1, &kp3.secret_key).unwrap();
1140        // Cat-3 signature against a Cat-5 public key => fails (tag mismatch).
1141        assert!(!verify(b"msg", SIGN_CONTEXT_V1, &sig3, &kp5.public_key).unwrap());
1142    }
1143
1144    #[test]
1145    fn unknown_tag_errors() {
1146        let bad = b64::encode(&[0x09u8; SECRET_KEY_LEN]);
1147        assert!(sign(b"x", SIGN_CONTEXT_V1, &bad).is_err());
1148        let pk = b64::encode(&[0x09u8; 100]);
1149        let sig = b64::encode(&[0x09u8; 100]);
1150        assert!(verify(b"x", SIGN_CONTEXT_V1, &sig, &pk).is_err());
1151    }
1152
1153    #[test]
1154    fn bad_base64_errors() {
1155        assert!(sign(b"x", SIGN_CONTEXT_V1, "not!base64!").is_err());
1156        assert!(verify(b"x", SIGN_CONTEXT_V1, "not!base64!", "also!bad!").is_err());
1157    }
1158
1159    #[test]
1160    fn frame_matches_manual() {
1161        let ctx = "metamorphic/sign/v1";
1162        let msg = b"payload";
1163        let mut expected = Vec::new();
1164        expected.extend_from_slice(&(ctx.len() as u64).to_be_bytes());
1165        expected.extend_from_slice(ctx.as_bytes());
1166        expected.extend_from_slice(msg);
1167        assert_eq!(frame(ctx, msg), expected);
1168    }
1169
1170    #[test]
1171    fn frame_no_boundary_confusion() {
1172        assert_ne!(frame("ab", b"c"), frame("a", b"bc"));
1173    }
1174
1175    #[test]
1176    fn secret_key_debug_is_redacted() {
1177        let kp = generate_signing_keypair();
1178        let shown = format!("{kp:?}");
1179        assert!(shown.contains("<redacted>"));
1180        assert!(!shown.contains(&kp.secret_key));
1181    }
1182
1183    #[test]
1184    fn keygen_public_key_matches_derived() {
1185        for level in [
1186            SignatureLevel::Cat2,
1187            SignatureLevel::Cat3,
1188            SignatureLevel::Cat5,
1189        ] {
1190            let kp = generate_signing_keypair_with_level(level);
1191            assert_eq!(derive_public_key(&kp.secret_key).unwrap(), kp.public_key);
1192        }
1193    }
1194
1195    use proptest::prelude::*;
1196
1197    proptest! {
1198        #[test]
1199        fn roundtrip_arbitrary_message(msg: Vec<u8>, ctx in "[a-z/]{0,32}") {
1200            let kp = generate_signing_keypair();
1201            let sig = sign(&msg, &ctx, &kp.secret_key).unwrap();
1202            prop_assert!(verify(&msg, &ctx, &sig, &kp.public_key).unwrap());
1203        }
1204    }
1205
1206    // === CNSA 2.0 signature suites (v0.7.0) ===
1207
1208    fn suite_roundtrip(suite: Suite, level: SignatureLevel, tag: u8) {
1209        let kp = generate_signing_keypair_suite(suite, level).unwrap();
1210        assert_eq!(b64::decode(&kp.public_key).unwrap()[0], tag);
1211        assert_eq!(b64::decode(&kp.secret_key).unwrap()[0], tag);
1212        let sig = sign(
1213            b"transparency-log checkpoint",
1214            SIGN_CONTEXT_V1,
1215            &kp.secret_key,
1216        )
1217        .unwrap();
1218        assert_eq!(b64::decode(&sig).unwrap()[0], tag);
1219        assert!(
1220            verify(
1221                b"transparency-log checkpoint",
1222                SIGN_CONTEXT_V1,
1223                &sig,
1224                &kp.public_key
1225            )
1226            .unwrap()
1227        );
1228        // Tampered message fails.
1229        assert!(!verify(b"tampered", SIGN_CONTEXT_V1, &sig, &kp.public_key).unwrap());
1230        // derive_public_key reproduces the keygen public key.
1231        assert_eq!(derive_public_key(&kp.secret_key).unwrap(), kp.public_key);
1232    }
1233
1234    #[test]
1235    fn pure_cnsa2_sign_roundtrip() {
1236        suite_roundtrip(
1237            Suite::PureCnsa2,
1238            SignatureLevel::Cat5,
1239            VERSION_SIG_PURE_CNSA2,
1240        );
1241    }
1242
1243    #[test]
1244    fn matched_cat3_sign_roundtrip() {
1245        suite_roundtrip(
1246            Suite::HybridMatched,
1247            SignatureLevel::Cat3,
1248            VERSION_SIG_MATCHED_CAT3,
1249        );
1250    }
1251
1252    #[test]
1253    fn matched_cat5_sign_roundtrip() {
1254        suite_roundtrip(
1255            Suite::HybridMatched,
1256            SignatureLevel::Cat5,
1257            VERSION_SIG_MATCHED_CAT5,
1258        );
1259    }
1260
1261    #[test]
1262    fn pure_cnsa2_sign_only_cat5() {
1263        assert!(generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat3).is_err());
1264        assert!(generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat2).is_err());
1265    }
1266
1267    #[test]
1268    fn matched_cat2_is_plain_hybrid() {
1269        // HybridMatched at Cat-2 reuses the existing Ed25519 construction (0x01).
1270        let kp =
1271            generate_signing_keypair_suite(Suite::HybridMatched, SignatureLevel::Cat2).unwrap();
1272        assert_eq!(b64::decode(&kp.public_key).unwrap()[0], VERSION_CAT2);
1273        let sig = sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1274        assert!(verify(b"x", SIGN_CONTEXT_V1, &sig, &kp.public_key).unwrap());
1275    }
1276
1277    #[test]
1278    fn matched_sign_strict_and_requires_both_halves() {
1279        // Corrupting either the classical or the ML-DSA half must fail (strict AND).
1280        for (suite, level, classical_offset) in [
1281            (Suite::HybridMatched, SignatureLevel::Cat3, 1usize), // inside Ed448 sig
1282            (Suite::HybridMatched, SignatureLevel::Cat5, 1usize), // inside ECDSA sig
1283        ] {
1284            let kp = generate_signing_keypair_suite(suite, level).unwrap();
1285            let good = b64::decode(&sign(b"m", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
1286            // Corrupt classical half.
1287            let mut bad_c = good.clone();
1288            bad_c[classical_offset] ^= 0xFF;
1289            assert!(!verify(b"m", SIGN_CONTEXT_V1, &b64::encode(&bad_c), &kp.public_key).unwrap());
1290            // Corrupt ML-DSA tail (last byte).
1291            let mut bad_ml = good.clone();
1292            let last = bad_ml.len() - 1;
1293            bad_ml[last] ^= 0xFF;
1294            assert!(!verify(b"m", SIGN_CONTEXT_V1, &b64::encode(&bad_ml), &kp.public_key).unwrap());
1295        }
1296    }
1297
1298    #[test]
1299    fn pure_cnsa2_nondeterministic_but_valid() {
1300        let kp = generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat5).unwrap();
1301        let s1 = sign(b"m", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1302        let s2 = sign(b"m", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1303        assert_ne!(s1, s2, "hedged ML-DSA => non-reproducible");
1304        assert!(verify(b"m", SIGN_CONTEXT_V1, &s1, &kp.public_key).unwrap());
1305        assert!(verify(b"m", SIGN_CONTEXT_V1, &s2, &kp.public_key).unwrap());
1306    }
1307
1308    #[test]
1309    fn sign_suites_context_separation_and_cross_key() {
1310        for (suite, level) in [
1311            (Suite::PureCnsa2, SignatureLevel::Cat5),
1312            (Suite::HybridMatched, SignatureLevel::Cat3),
1313            (Suite::HybridMatched, SignatureLevel::Cat5),
1314        ] {
1315            let kp = generate_signing_keypair_suite(suite, level).unwrap();
1316            let kp2 = generate_signing_keypair_suite(suite, level).unwrap();
1317            let sig = sign(b"m", "metamorphic/sign/v1", &kp.secret_key).unwrap();
1318            // Different context fails.
1319            assert!(!verify(b"m", "metamorphic/other/v1", &sig, &kp.public_key).unwrap());
1320            // Wrong key fails.
1321            assert!(!verify(b"m", "metamorphic/sign/v1", &sig, &kp2.public_key).unwrap());
1322        }
1323    }
1324
1325    #[test]
1326    fn sign_cross_suite_pk_rejected() {
1327        // A new-suite signature verified against a legacy public key (and vice
1328        // versa) must fail rather than error-route into the wrong family.
1329        let pure = generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat5).unwrap();
1330        let legacy = generate_signing_keypair(); // Cat-3 hybrid (0x02)
1331        let sig_pure = sign(b"m", SIGN_CONTEXT_V1, &pure.secret_key).unwrap();
1332        assert!(!verify(b"m", SIGN_CONTEXT_V1, &sig_pure, &legacy.public_key).unwrap());
1333        let sig_legacy = sign(b"m", SIGN_CONTEXT_V1, &legacy.secret_key).unwrap();
1334        assert!(!verify(b"m", SIGN_CONTEXT_V1, &sig_legacy, &pure.public_key).unwrap());
1335    }
1336}