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
139use crate::CryptoError;
140use crate::b64;
141
142// === Constants ===
143
144/// Recommended versioned context label for general-purpose signing.
145///
146/// Pass this (or another versioned `"namespace/purpose/vN"` label) as the
147/// `context` argument to [`sign`] / [`verify`] to bind signatures to a purpose.
148pub const SIGN_CONTEXT_V1: &str = "metamorphic/sign/v1";
149
150/// Version tag for Cat-2 (ML-DSA-44 + Ed25519). Local to this module.
151const VERSION_CAT2: u8 = 0x01;
152/// Version tag for Cat-3 (ML-DSA-65 + Ed25519, default). Local to this module.
153const VERSION_CAT3: u8 = 0x02;
154/// Version tag for Cat-5 (ML-DSA-87 + Ed25519). Local to this module.
155const VERSION_CAT5: u8 = 0x03;
156
157/// Ed25519 seed (secret key) length.
158const ED25519_SEED_LEN: usize = 32;
159/// Ed25519 public key length.
160const ED25519_PK_LEN: usize = 32;
161/// Ed25519 signature length.
162const ED25519_SIG_LEN: usize = 64;
163/// ML-DSA seed (`ξ`) length — identical across all parameter sets.
164const MLDSA_SEED_LEN: usize = 32;
165
166/// Combined secret key length: `tag || ed25519_seed || ml_dsa_seed`.
167const SECRET_KEY_LEN: usize = 1 + ED25519_SEED_LEN + MLDSA_SEED_LEN;
168
169// ML-DSA-44 (Cat-2)
170/// ML-DSA-44 public key length.
171const MLDSA44_PK_LEN: usize = 1312;
172/// ML-DSA-44 signature length.
173const MLDSA44_SIG_LEN: usize = 2420;
174// ML-DSA-65 (Cat-3)
175/// ML-DSA-65 public key length.
176const MLDSA65_PK_LEN: usize = 1952;
177/// ML-DSA-65 signature length.
178const MLDSA65_SIG_LEN: usize = 3309;
179// ML-DSA-87 (Cat-5)
180/// ML-DSA-87 public key length.
181const MLDSA87_PK_LEN: usize = 2592;
182/// ML-DSA-87 signature length.
183const MLDSA87_SIG_LEN: usize = 4627;
184
185// === Types ===
186
187/// A hybrid ML-DSA + Ed25519 signing keypair (base64-encoded).
188///
189/// The `secret_key` is zeroized on drop. Both fields are base64 strings using
190/// the byte layout documented at the [module level](crate::sign).
191#[derive(Clone, Zeroize, ZeroizeOnDrop)]
192pub struct HybridSignatureKeyPair {
193    /// Combined public key: `tag || ed25519_pk || ml_dsa_pk`. Base64. Public.
194    #[zeroize(skip)]
195    pub public_key: String,
196    /// Combined secret key: `tag || ed25519_seed || ml_dsa_seed`. Base64. Secret.
197    pub secret_key: String,
198}
199
200impl std::fmt::Debug for HybridSignatureKeyPair {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        f.debug_struct("HybridSignatureKeyPair")
203            .field("public_key", &self.public_key)
204            .field("secret_key", &"<redacted>")
205            .finish()
206    }
207}
208
209/// Security level for hybrid PQ signatures.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
211pub enum SignatureLevel {
212    /// NIST Category 2: ML-DSA-44 + Ed25519 (~AES-128).
213    Cat2,
214    /// NIST Category 3: ML-DSA-65 + Ed25519 (~AES-192). Default.
215    #[default]
216    Cat3,
217    /// NIST Category 5: ML-DSA-87 + Ed25519 (~AES-256).
218    Cat5,
219}
220
221impl SignatureLevel {
222    /// The 1-byte version tag for this level.
223    fn version_tag(self) -> u8 {
224        match self {
225            SignatureLevel::Cat2 => VERSION_CAT2,
226            SignatureLevel::Cat3 => VERSION_CAT3,
227            SignatureLevel::Cat5 => VERSION_CAT5,
228        }
229    }
230
231    /// The ML-DSA public-key length for this level.
232    fn mldsa_pk_len(self) -> usize {
233        match self {
234            SignatureLevel::Cat2 => MLDSA44_PK_LEN,
235            SignatureLevel::Cat3 => MLDSA65_PK_LEN,
236            SignatureLevel::Cat5 => MLDSA87_PK_LEN,
237        }
238    }
239
240    /// The ML-DSA signature length for this level.
241    fn mldsa_sig_len(self) -> usize {
242        match self {
243            SignatureLevel::Cat2 => MLDSA44_SIG_LEN,
244            SignatureLevel::Cat3 => MLDSA65_SIG_LEN,
245            SignatureLevel::Cat5 => MLDSA87_SIG_LEN,
246        }
247    }
248}
249
250// === Helpers ===
251
252/// Fill buffer with OS random bytes.
253#[inline]
254fn random_bytes(buf: &mut [u8]) {
255    getrandom::getrandom(buf).expect("OS CSPRNG unavailable");
256}
257
258/// An infallible, OS-backed CSPRNG adapter for `ml-dsa`'s randomized signer.
259///
260/// `ml-dsa`'s hedged signing path is generic over a `rand_core` RNG. This thin
261/// adapter sources every byte from the OS CSPRNG via [`getrandom`], exactly like
262/// the rest of this crate, so no userspace PRNG is introduced.
263struct OsCsprng;
264
265impl TryRng for OsCsprng {
266    type Error = Infallible;
267
268    fn try_next_u32(&mut self) -> Result<u32, Infallible> {
269        let mut b = [0u8; 4];
270        random_bytes(&mut b);
271        Ok(u32::from_le_bytes(b))
272    }
273
274    fn try_next_u64(&mut self) -> Result<u64, Infallible> {
275        let mut b = [0u8; 8];
276        random_bytes(&mut b);
277        Ok(u64::from_le_bytes(b))
278    }
279
280    fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> {
281        random_bytes(dst);
282        Ok(())
283    }
284}
285
286impl TryCryptoRng for OsCsprng {}
287
288/// Build the domain-separated message: `u64_be(len(context)) || context || message`.
289fn frame(context: &str, message: &[u8]) -> Vec<u8> {
290    let mut out = Vec::with_capacity(8 + context.len() + message.len());
291    out.extend_from_slice(&(context.len() as u64).to_be_bytes());
292    out.extend_from_slice(context.as_bytes());
293    out.extend_from_slice(message);
294    out
295}
296
297/// Map a leading version-tag byte to a [`SignatureLevel`].
298fn level_from_tag(tag: Option<&u8>) -> Result<SignatureLevel, CryptoError> {
299    match tag {
300        Some(&VERSION_CAT2) => Ok(SignatureLevel::Cat2),
301        Some(&VERSION_CAT3) => Ok(SignatureLevel::Cat3),
302        Some(&VERSION_CAT5) => Ok(SignatureLevel::Cat5),
303        _ => Err(CryptoError::Signature(
304            "unknown or missing signature version tag".into(),
305        )),
306    }
307}
308
309/// Derive the ML-DSA public key bytes from a 32-byte seed.
310fn mldsa_public_key<P: MlDsaParams>(seed: &B32) -> Vec<u8> {
311    let vk = SigningKey::<P>::from_seed(seed).verifying_key().encode();
312    AsRef::<[u8]>::as_ref(&vk).to_vec()
313}
314
315/// Produce a hedged (randomized) ML-DSA signature over `framed` (empty native ctx).
316fn mldsa_sign<P: MlDsaParams>(seed: &B32, framed: &[u8]) -> Vec<u8> {
317    let sig = ExpandedSigningKey::<P>::from_seed(seed)
318        .sign_randomized(framed, &[], &mut OsCsprng)
319        .expect("ML-DSA randomized signing (empty context, infallible RNG)")
320        .encode();
321    AsRef::<[u8]>::as_ref(&sig).to_vec()
322}
323
324/// Verify an ML-DSA signature; returns `false` on any malformed input.
325fn mldsa_verify<P: MlDsaParams>(pk: &[u8], framed: &[u8], sig: &[u8]) -> bool {
326    match (
327        VerifyingKey::<P>::new_from_slice(pk),
328        Signature::<P>::try_from(sig),
329    ) {
330        (Ok(vk), Ok(s)) => MlVerifier::verify(&vk, framed, &s).is_ok(),
331        _ => false,
332    }
333}
334
335// === Public API: keygen ===
336
337/// Generate a hybrid ML-DSA-65 + Ed25519 signing keypair (Cat-3, default).
338pub fn generate_signing_keypair() -> HybridSignatureKeyPair {
339    generate_signing_keypair_with_level(SignatureLevel::Cat3)
340}
341
342/// Generate a hybrid ML-DSA-44 + Ed25519 signing keypair (Cat-2).
343pub fn generate_signing_keypair_44() -> HybridSignatureKeyPair {
344    generate_signing_keypair_with_level(SignatureLevel::Cat2)
345}
346
347/// Generate a hybrid ML-DSA-87 + Ed25519 signing keypair (Cat-5).
348pub fn generate_signing_keypair_87() -> HybridSignatureKeyPair {
349    generate_signing_keypair_with_level(SignatureLevel::Cat5)
350}
351
352/// Generate a hybrid signing keypair at the specified security level.
353///
354/// Returns base64 `public_key` and `secret_key` using the documented byte
355/// layout. The two algorithms are seeded from independent OS randomness.
356pub fn generate_signing_keypair_with_level(level: SignatureLevel) -> HybridSignatureKeyPair {
357    let mut ed_seed = [0u8; ED25519_SEED_LEN];
358    random_bytes(&mut ed_seed);
359    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
360    random_bytes(&mut ml_seed_bytes);
361
362    let ed_sk = EdSigningKey::from_bytes(&ed_seed);
363    let ed_pk = ed_sk.verifying_key().to_bytes();
364
365    let ml_seed: B32 = ml_seed_bytes.into();
366    let ml_pk = match level {
367        SignatureLevel::Cat2 => mldsa_public_key::<MlDsa44>(&ml_seed),
368        SignatureLevel::Cat3 => mldsa_public_key::<MlDsa65>(&ml_seed),
369        SignatureLevel::Cat5 => mldsa_public_key::<MlDsa87>(&ml_seed),
370    };
371
372    let tag = level.version_tag();
373
374    let mut public_key = Vec::with_capacity(1 + ED25519_PK_LEN + ml_pk.len());
375    public_key.push(tag);
376    public_key.extend_from_slice(&ed_pk);
377    public_key.extend_from_slice(&ml_pk);
378
379    let mut secret_key = Vec::with_capacity(SECRET_KEY_LEN);
380    secret_key.push(tag);
381    secret_key.extend_from_slice(&ed_seed);
382    secret_key.extend_from_slice(&ml_seed_bytes);
383
384    let pair = HybridSignatureKeyPair {
385        public_key: b64::encode(&public_key),
386        secret_key: b64::encode(&secret_key),
387    };
388
389    ed_seed.zeroize();
390    ml_seed_bytes.zeroize();
391    secret_key.zeroize();
392    pair
393}
394
395// === Public API: sign / verify ===
396
397/// Re-derive the base64 public key from a base64 hybrid secret key.
398///
399/// Both component public keys are a deterministic function of the secret seeds,
400/// so this reproduces exactly the `public_key` returned by keygen. Useful for
401/// recovering a public key from a backed-up secret, or for verifying that a
402/// secret/public pair belong together. The security level is read from the
403/// secret key's version tag.
404pub fn derive_public_key(secret_key_b64: &str) -> Result<String, CryptoError> {
405    let mut sk_bytes = b64::decode(secret_key_b64)?;
406    let level = level_from_tag(sk_bytes.first())?;
407
408    if sk_bytes.len() != SECRET_KEY_LEN {
409        sk_bytes.zeroize();
410        return Err(CryptoError::InvalidLength {
411            expected: SECRET_KEY_LEN,
412            got: sk_bytes.len(),
413        });
414    }
415
416    let mut ed_seed = [0u8; ED25519_SEED_LEN];
417    ed_seed.copy_from_slice(&sk_bytes[1..1 + ED25519_SEED_LEN]);
418    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
419    ml_seed_bytes.copy_from_slice(&sk_bytes[1 + ED25519_SEED_LEN..SECRET_KEY_LEN]);
420    sk_bytes.zeroize();
421
422    let ed_pk = EdSigningKey::from_bytes(&ed_seed)
423        .verifying_key()
424        .to_bytes();
425    ed_seed.zeroize();
426
427    let ml_seed: B32 = ml_seed_bytes.into();
428    let ml_pk = match level {
429        SignatureLevel::Cat2 => mldsa_public_key::<MlDsa44>(&ml_seed),
430        SignatureLevel::Cat3 => mldsa_public_key::<MlDsa65>(&ml_seed),
431        SignatureLevel::Cat5 => mldsa_public_key::<MlDsa87>(&ml_seed),
432    };
433    ml_seed_bytes.zeroize();
434
435    let mut public_key = Vec::with_capacity(1 + ED25519_PK_LEN + ml_pk.len());
436    public_key.push(level.version_tag());
437    public_key.extend_from_slice(&ed_pk);
438    public_key.extend_from_slice(&ml_pk);
439
440    Ok(b64::encode(&public_key))
441}
442/// Sign `message` under `context` with a base64 hybrid `secret_key`.
443///
444/// Produces a composite signature: a hedged (randomized) ML-DSA signature and a
445/// deterministic Ed25519 signature, both over the domain-separated
446/// `frame(context, message)`. The security level is read from the secret key's
447/// version tag. Returns the base64 signature `tag || ed25519_sig || ml_dsa_sig`.
448///
449/// Because ML-DSA signing is randomized, signing the same message twice yields
450/// different (both-valid) signatures. Use [`SIGN_CONTEXT_V1`] (or another
451/// versioned label) for `context`.
452pub fn sign(message: &[u8], context: &str, secret_key_b64: &str) -> Result<String, CryptoError> {
453    let mut sk_bytes = b64::decode(secret_key_b64)?;
454    let level = level_from_tag(sk_bytes.first())?;
455
456    if sk_bytes.len() != SECRET_KEY_LEN {
457        sk_bytes.zeroize();
458        return Err(CryptoError::InvalidLength {
459            expected: SECRET_KEY_LEN,
460            got: sk_bytes.len(),
461        });
462    }
463
464    let mut ed_seed = [0u8; ED25519_SEED_LEN];
465    ed_seed.copy_from_slice(&sk_bytes[1..1 + ED25519_SEED_LEN]);
466    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
467    ml_seed_bytes.copy_from_slice(&sk_bytes[1 + ED25519_SEED_LEN..SECRET_KEY_LEN]);
468    sk_bytes.zeroize();
469
470    let framed = frame(context, message);
471
472    let ed_sk = EdSigningKey::from_bytes(&ed_seed);
473    let ed_sig = ed_sk.sign(&framed).to_bytes();
474    ed_seed.zeroize();
475
476    let ml_seed: B32 = ml_seed_bytes.into();
477    let ml_sig = match level {
478        SignatureLevel::Cat2 => mldsa_sign::<MlDsa44>(&ml_seed, &framed),
479        SignatureLevel::Cat3 => mldsa_sign::<MlDsa65>(&ml_seed, &framed),
480        SignatureLevel::Cat5 => mldsa_sign::<MlDsa87>(&ml_seed, &framed),
481    };
482    ml_seed_bytes.zeroize();
483
484    let mut out = Vec::with_capacity(1 + ED25519_SIG_LEN + ml_sig.len());
485    out.push(level.version_tag());
486    out.extend_from_slice(&ed_sig);
487    out.extend_from_slice(&ml_sig);
488
489    Ok(b64::encode(&out))
490}
491
492/// Verify a composite `signature_b64` over `message`/`context` against `public_key_b64`.
493///
494/// Returns `Ok(true)` **only if both** the Ed25519 and ML-DSA component
495/// signatures verify (strict AND). Returns `Ok(false)` for any cryptographic
496/// failure, including a tampered message/context, a wrong key, a tampered
497/// half-signature, or a signature/public-key whose version tags or lengths do
498/// not match (cross-level or stripped). Returns `Err` only when an input cannot
499/// be decoded as base64 or carries an unknown version tag.
500pub fn verify(
501    message: &[u8],
502    context: &str,
503    signature_b64: &str,
504    public_key_b64: &str,
505) -> Result<bool, CryptoError> {
506    let sig = b64::decode(signature_b64)?;
507    let pk = b64::decode(public_key_b64)?;
508
509    let sig_level = level_from_tag(sig.first())?;
510    let pk_level = level_from_tag(pk.first())?;
511    // Mismatched levels => verification fails (no cross-level confusion).
512    if sig_level != pk_level {
513        return Ok(false);
514    }
515    let level = sig_level;
516
517    if sig.len() != 1 + ED25519_SIG_LEN + level.mldsa_sig_len()
518        || pk.len() != 1 + ED25519_PK_LEN + level.mldsa_pk_len()
519    {
520        return Ok(false);
521    }
522
523    let framed = frame(context, message);
524
525    let ed_pk_bytes: [u8; ED25519_PK_LEN] = pk[1..1 + ED25519_PK_LEN].try_into().unwrap();
526    let ed_sig_bytes: [u8; ED25519_SIG_LEN] = sig[1..1 + ED25519_SIG_LEN].try_into().unwrap();
527    let ml_pk = &pk[1 + ED25519_PK_LEN..];
528    let ml_sig = &sig[1 + ED25519_SIG_LEN..];
529
530    let ed_ok = match EdVerifyingKey::from_bytes(&ed_pk_bytes) {
531        Ok(vk) => vk
532            .verify(&framed, &EdSignature::from_bytes(&ed_sig_bytes))
533            .is_ok(),
534        Err(_) => false,
535    };
536
537    let ml_ok = match level {
538        SignatureLevel::Cat2 => mldsa_verify::<MlDsa44>(ml_pk, &framed, ml_sig),
539        SignatureLevel::Cat3 => mldsa_verify::<MlDsa65>(ml_pk, &framed, ml_sig),
540        SignatureLevel::Cat5 => mldsa_verify::<MlDsa87>(ml_pk, &framed, ml_sig),
541    };
542
543    // Strict AND: both component signatures must verify.
544    Ok(ed_ok && ml_ok)
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    fn roundtrip(level: SignatureLevel) {
552        let kp = generate_signing_keypair_with_level(level);
553        let sig = sign(b"hello transparency log", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
554        assert!(
555            verify(
556                b"hello transparency log",
557                SIGN_CONTEXT_V1,
558                &sig,
559                &kp.public_key
560            )
561            .unwrap()
562        );
563    }
564
565    #[test]
566    fn cat2_roundtrip() {
567        roundtrip(SignatureLevel::Cat2);
568    }
569
570    #[test]
571    fn cat3_roundtrip() {
572        roundtrip(SignatureLevel::Cat3);
573    }
574
575    #[test]
576    fn cat5_roundtrip() {
577        roundtrip(SignatureLevel::Cat5);
578    }
579
580    #[test]
581    fn default_is_cat3() {
582        assert_eq!(SignatureLevel::default(), SignatureLevel::Cat3);
583        let kp = generate_signing_keypair();
584        let raw = b64::decode(&kp.public_key).unwrap();
585        assert_eq!(raw[0], VERSION_CAT3);
586    }
587
588    #[test]
589    fn version_tags() {
590        for (level, tag) in [
591            (SignatureLevel::Cat2, VERSION_CAT2),
592            (SignatureLevel::Cat3, VERSION_CAT3),
593            (SignatureLevel::Cat5, VERSION_CAT5),
594        ] {
595            let kp = generate_signing_keypair_with_level(level);
596            let sig = sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
597            assert_eq!(b64::decode(&kp.public_key).unwrap()[0], tag);
598            assert_eq!(b64::decode(&kp.secret_key).unwrap()[0], tag);
599            assert_eq!(b64::decode(&sig).unwrap()[0], tag);
600        }
601    }
602
603    #[test]
604    fn key_and_signature_sizes() {
605        for (level, pk_len, sig_len) in [
606            (SignatureLevel::Cat2, MLDSA44_PK_LEN, MLDSA44_SIG_LEN),
607            (SignatureLevel::Cat3, MLDSA65_PK_LEN, MLDSA65_SIG_LEN),
608            (SignatureLevel::Cat5, MLDSA87_PK_LEN, MLDSA87_SIG_LEN),
609        ] {
610            let kp = generate_signing_keypair_with_level(level);
611            let pk = b64::decode(&kp.public_key).unwrap();
612            let sk = b64::decode(&kp.secret_key).unwrap();
613            let sig = b64::decode(&sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
614            assert_eq!(pk.len(), 1 + ED25519_PK_LEN + pk_len);
615            assert_eq!(sk.len(), SECRET_KEY_LEN);
616            assert_eq!(sig.len(), 1 + ED25519_SIG_LEN + sig_len);
617        }
618    }
619
620    #[test]
621    fn wrong_key_fails() {
622        let kp1 = generate_signing_keypair();
623        let kp2 = generate_signing_keypair();
624        let sig = sign(b"msg", SIGN_CONTEXT_V1, &kp1.secret_key).unwrap();
625        assert!(!verify(b"msg", SIGN_CONTEXT_V1, &sig, &kp2.public_key).unwrap());
626    }
627
628    #[test]
629    fn tampered_message_fails() {
630        let kp = generate_signing_keypair();
631        let sig = sign(b"original", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
632        assert!(!verify(b"tampered", SIGN_CONTEXT_V1, &sig, &kp.public_key).unwrap());
633    }
634
635    #[test]
636    fn context_separation() {
637        let kp = generate_signing_keypair();
638        let sig = sign(b"msg", "metamorphic/sign/v1", &kp.secret_key).unwrap();
639        // Same message, different verification context => fails.
640        assert!(!verify(b"msg", "metamorphic/other/v1", &sig, &kp.public_key).unwrap());
641    }
642
643    #[test]
644    fn empty_message_and_context() {
645        let kp = generate_signing_keypair();
646        let sig = sign(b"", "", &kp.secret_key).unwrap();
647        assert!(verify(b"", "", &sig, &kp.public_key).unwrap());
648    }
649
650    #[test]
651    fn nondeterministic_but_both_valid() {
652        let kp = generate_signing_keypair();
653        let s1 = sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
654        let s2 = sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
655        // Hedged ML-DSA => signatures differ, but both verify.
656        assert_ne!(s1, s2);
657        assert!(verify(b"msg", SIGN_CONTEXT_V1, &s1, &kp.public_key).unwrap());
658        assert!(verify(b"msg", SIGN_CONTEXT_V1, &s2, &kp.public_key).unwrap());
659    }
660
661    /// Strict AND: tampering with *either* component signature must fail
662    /// verification, proving neither algorithm can be stripped or forged alone.
663    #[test]
664    fn strict_and_requires_both() {
665        let kp = generate_signing_keypair();
666        let good = b64::decode(&sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
667
668        // Corrupt a byte inside the Ed25519 component (ML-DSA still valid).
669        let mut bad_ed = good.clone();
670        bad_ed[1] ^= 0xFF;
671        assert!(
672            !verify(
673                b"msg",
674                SIGN_CONTEXT_V1,
675                &b64::encode(&bad_ed),
676                &kp.public_key
677            )
678            .unwrap()
679        );
680
681        // Corrupt a byte inside the ML-DSA component (Ed25519 still valid).
682        let mut bad_ml = good.clone();
683        let i = 1 + ED25519_SIG_LEN + 10;
684        bad_ml[i] ^= 0xFF;
685        assert!(
686            !verify(
687                b"msg",
688                SIGN_CONTEXT_V1,
689                &b64::encode(&bad_ml),
690                &kp.public_key
691            )
692            .unwrap()
693        );
694    }
695
696    #[test]
697    fn cross_level_fails() {
698        let kp3 = generate_signing_keypair_with_level(SignatureLevel::Cat3);
699        let kp5 = generate_signing_keypair_with_level(SignatureLevel::Cat5);
700        let sig3 = sign(b"msg", SIGN_CONTEXT_V1, &kp3.secret_key).unwrap();
701        // Cat-3 signature against a Cat-5 public key => fails (tag mismatch).
702        assert!(!verify(b"msg", SIGN_CONTEXT_V1, &sig3, &kp5.public_key).unwrap());
703    }
704
705    #[test]
706    fn unknown_tag_errors() {
707        let bad = b64::encode(&[0x09u8; SECRET_KEY_LEN]);
708        assert!(sign(b"x", SIGN_CONTEXT_V1, &bad).is_err());
709        let pk = b64::encode(&[0x09u8; 100]);
710        let sig = b64::encode(&[0x09u8; 100]);
711        assert!(verify(b"x", SIGN_CONTEXT_V1, &sig, &pk).is_err());
712    }
713
714    #[test]
715    fn bad_base64_errors() {
716        assert!(sign(b"x", SIGN_CONTEXT_V1, "not!base64!").is_err());
717        assert!(verify(b"x", SIGN_CONTEXT_V1, "not!base64!", "also!bad!").is_err());
718    }
719
720    #[test]
721    fn frame_matches_manual() {
722        let ctx = "metamorphic/sign/v1";
723        let msg = b"payload";
724        let mut expected = Vec::new();
725        expected.extend_from_slice(&(ctx.len() as u64).to_be_bytes());
726        expected.extend_from_slice(ctx.as_bytes());
727        expected.extend_from_slice(msg);
728        assert_eq!(frame(ctx, msg), expected);
729    }
730
731    #[test]
732    fn frame_no_boundary_confusion() {
733        assert_ne!(frame("ab", b"c"), frame("a", b"bc"));
734    }
735
736    #[test]
737    fn secret_key_debug_is_redacted() {
738        let kp = generate_signing_keypair();
739        let shown = format!("{kp:?}");
740        assert!(shown.contains("<redacted>"));
741        assert!(!shown.contains(&kp.secret_key));
742    }
743
744    #[test]
745    fn keygen_public_key_matches_derived() {
746        for level in [
747            SignatureLevel::Cat2,
748            SignatureLevel::Cat3,
749            SignatureLevel::Cat5,
750        ] {
751            let kp = generate_signing_keypair_with_level(level);
752            assert_eq!(derive_public_key(&kp.secret_key).unwrap(), kp.public_key);
753        }
754    }
755
756    use proptest::prelude::*;
757
758    proptest! {
759        #[test]
760        fn roundtrip_arbitrary_message(msg: Vec<u8>, ctx in "[a-z/]{0,32}") {
761            let kp = generate_signing_keypair();
762            let sig = sign(&msg, &ctx, &kp.secret_key).unwrap();
763            prop_assert!(verify(&msg, &ctx, &sig, &kp.public_key).unwrap());
764        }
765    }
766}