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/// Map a leading version-tag byte to its `(Suite, SignatureLevel)` posture.
350///
351/// This is the single source of truth for the tag → posture mapping shared by
352/// [`signature_posture`] and [`signature_posture_from_signature`]. It never
353/// exposes the raw tag; the tag stays a private wire detail and only its
354/// *meaning* is surfaced. Returns `None` for an unknown tag.
355///
356/// Note: the Cat-2 hybrid tag (`0x01`) is byte-identical for `Suite::Hybrid`
357/// and `Suite::HybridMatched` (the latter delegates to the former at Cat-2), so
358/// it canonically decodes to `(Suite::Hybrid, SignatureLevel::Cat2)`.
359fn posture_from_tag(tag: u8) -> Option<(Suite, SignatureLevel)> {
360    match tag {
361        VERSION_CAT2 => Some((Suite::Hybrid, SignatureLevel::Cat2)),
362        VERSION_CAT3 => Some((Suite::Hybrid, SignatureLevel::Cat3)),
363        VERSION_CAT5 => Some((Suite::Hybrid, SignatureLevel::Cat5)),
364        VERSION_SIG_PURE_CNSA2 => Some((Suite::PureCnsa2, SignatureLevel::Cat5)),
365        VERSION_SIG_MATCHED_CAT3 => Some((Suite::HybridMatched, SignatureLevel::Cat3)),
366        VERSION_SIG_MATCHED_CAT5 => Some((Suite::HybridMatched, SignatureLevel::Cat5)),
367        _ => None,
368    }
369}
370
371/// Expected full public-key blob length (including the 1-byte tag) for `tag`.
372fn expected_public_key_len(tag: u8) -> Option<usize> {
373    match tag {
374        VERSION_CAT2 => Some(1 + ED25519_PK_LEN + MLDSA44_PK_LEN),
375        VERSION_CAT3 => Some(1 + ED25519_PK_LEN + MLDSA65_PK_LEN),
376        VERSION_CAT5 => Some(1 + ED25519_PK_LEN + MLDSA87_PK_LEN),
377        VERSION_SIG_PURE_CNSA2 => Some(1 + MLDSA87_PK_LEN),
378        VERSION_SIG_MATCHED_CAT3 => Some(1 + ED448_PK_LEN + MLDSA65_PK_LEN),
379        VERSION_SIG_MATCHED_CAT5 => Some(1 + P521_PK_LEN + MLDSA87_PK_LEN),
380        _ => None,
381    }
382}
383
384/// Expected full signature blob length (including the 1-byte tag) for `tag`.
385fn expected_signature_len(tag: u8) -> Option<usize> {
386    match tag {
387        VERSION_CAT2 => Some(1 + ED25519_SIG_LEN + MLDSA44_SIG_LEN),
388        VERSION_CAT3 => Some(1 + ED25519_SIG_LEN + MLDSA65_SIG_LEN),
389        VERSION_CAT5 => Some(1 + ED25519_SIG_LEN + MLDSA87_SIG_LEN),
390        VERSION_SIG_PURE_CNSA2 => Some(1 + MLDSA87_SIG_LEN),
391        VERSION_SIG_MATCHED_CAT3 => Some(1 + ED448_SIG_LEN + MLDSA65_SIG_LEN),
392        VERSION_SIG_MATCHED_CAT5 => Some(1 + P521_SIG_LEN + MLDSA87_SIG_LEN),
393        _ => None,
394    }
395}
396
397/// Derive the ML-DSA public key bytes from a 32-byte seed.
398fn mldsa_public_key<P: MlDsaParams>(seed: &B32) -> Vec<u8> {
399    let vk = SigningKey::<P>::from_seed(seed).verifying_key().encode();
400    AsRef::<[u8]>::as_ref(&vk).to_vec()
401}
402
403/// Produce a hedged (randomized) ML-DSA signature over `framed` (empty native ctx).
404fn mldsa_sign<P: MlDsaParams>(seed: &B32, framed: &[u8]) -> Vec<u8> {
405    let sig = ExpandedSigningKey::<P>::from_seed(seed)
406        .sign_randomized(framed, &[], &mut OsCsprng)
407        .expect("ML-DSA randomized signing (empty context, infallible RNG)")
408        .encode();
409    AsRef::<[u8]>::as_ref(&sig).to_vec()
410}
411
412/// Verify an ML-DSA signature; returns `false` on any malformed input.
413fn mldsa_verify<P: MlDsaParams>(pk: &[u8], framed: &[u8], sig: &[u8]) -> bool {
414    match (
415        VerifyingKey::<P>::new_from_slice(pk),
416        Signature::<P>::try_from(sig),
417    ) {
418        (Ok(vk), Ok(s)) => MlVerifier::verify(&vk, framed, &s).is_ok(),
419        _ => false,
420    }
421}
422
423// === CNSA 2.0 suites: matched classical helpers ===
424
425/// Reduce 66 seed bytes to a non-zero P-521 scalar and wrap as an ECDSA key.
426fn p521_signing_key_from_bytes(bytes: &[u8; P521_SK_LEN]) -> P521SigningKey {
427    let fb: FieldBytes<NistP521> = (*bytes).into();
428    let scalar = <Scalar as Reduce<FieldBytes<NistP521>>>::reduce(&fb);
429    let nz: NonZeroScalar =
430        Option::from(NonZeroScalar::new(scalar)).expect("P-521 scalar reduced to zero");
431    P521SigningKey::from(P521SecretKey::from(nz))
432}
433
434/// Ed448 keygen (deterministic from a 57-byte seed). Returns `(pk(57), SigningKey)`.
435fn ed448_keypair(
436    seed: &[u8; ED448_SEED_LEN],
437) -> Result<([u8; ED448_PK_LEN], Ed448SigningKey), CryptoError> {
438    let sk = Ed448SigningKey::try_from(&seed[..])
439        .map_err(|_| CryptoError::Signature("invalid Ed448 seed".into()))?;
440    let pk = sk.verifying_key().to_bytes();
441    Ok((pk, sk))
442}
443
444// === CNSA 2.0 suites: keygen ===
445
446/// Generate a signing keypair for the given [`Suite`] + [`SignatureLevel`].
447///
448/// - `Suite::Hybrid` (any level) and `Suite::HybridMatched` at Cat-2 delegate to
449///   the existing [`generate_signing_keypair_with_level`] (ML-DSA + Ed25519;
450///   identical bytes/tags).
451/// - `Suite::HybridMatched` at Cat-3 (ML-DSA-65 + Ed448) / Cat-5 (ML-DSA-87 +
452///   ECDSA-P-521) and `Suite::PureCnsa2` at Cat-5 (ML-DSA-87 only) produce the
453///   new tagged layouts.
454///
455/// Returns an error for unsupported combinations (PureCnsa2 below Cat-5).
456pub fn generate_signing_keypair_suite(
457    suite: Suite,
458    level: SignatureLevel,
459) -> Result<HybridSignatureKeyPair, CryptoError> {
460    match (suite, level) {
461        (Suite::Hybrid, _) | (Suite::HybridMatched, SignatureLevel::Cat2) => {
462            Ok(generate_signing_keypair_with_level(level))
463        }
464        (Suite::HybridMatched, SignatureLevel::Cat3) => Ok(generate_matched_cat3_keypair()),
465        (Suite::HybridMatched, SignatureLevel::Cat5) => Ok(generate_matched_cat5_keypair()),
466        (Suite::PureCnsa2, SignatureLevel::Cat5) => Ok(generate_pure_cnsa2_keypair()),
467        (Suite::PureCnsa2, _) => Err(CryptoError::Signature(
468            "PureCnsa2 signatures are Cat-5 (ML-DSA-87) only in v0.7.0".into(),
469        )),
470    }
471}
472
473fn generate_pure_cnsa2_keypair() -> HybridSignatureKeyPair {
474    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
475    random_bytes(&mut ml_seed_bytes);
476    let ml_seed: B32 = ml_seed_bytes.into();
477    let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
478
479    let mut public_key = Vec::with_capacity(1 + ml_pk.len());
480    public_key.push(VERSION_SIG_PURE_CNSA2);
481    public_key.extend_from_slice(&ml_pk);
482
483    let mut secret_key = Vec::with_capacity(1 + MLDSA_SEED_LEN);
484    secret_key.push(VERSION_SIG_PURE_CNSA2);
485    secret_key.extend_from_slice(&ml_seed_bytes);
486
487    let pair = HybridSignatureKeyPair {
488        public_key: b64::encode(&public_key),
489        secret_key: b64::encode(&secret_key),
490    };
491    ml_seed_bytes.zeroize();
492    secret_key.zeroize();
493    pair
494}
495
496fn generate_matched_cat3_keypair() -> HybridSignatureKeyPair {
497    let mut ed_seed = [0u8; ED448_SEED_LEN];
498    random_bytes(&mut ed_seed);
499    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
500    random_bytes(&mut ml_seed_bytes);
501
502    let (ed_pk, _) = ed448_keypair(&ed_seed).expect("freshly generated Ed448 seed");
503    let ml_seed: B32 = ml_seed_bytes.into();
504    let ml_pk = mldsa_public_key::<MlDsa65>(&ml_seed);
505
506    let mut public_key = Vec::with_capacity(1 + ED448_PK_LEN + ml_pk.len());
507    public_key.push(VERSION_SIG_MATCHED_CAT3);
508    public_key.extend_from_slice(&ed_pk);
509    public_key.extend_from_slice(&ml_pk);
510
511    let mut secret_key = Vec::with_capacity(1 + ED448_SEED_LEN + MLDSA_SEED_LEN);
512    secret_key.push(VERSION_SIG_MATCHED_CAT3);
513    secret_key.extend_from_slice(&ed_seed);
514    secret_key.extend_from_slice(&ml_seed_bytes);
515
516    let pair = HybridSignatureKeyPair {
517        public_key: b64::encode(&public_key),
518        secret_key: b64::encode(&secret_key),
519    };
520    ed_seed.zeroize();
521    ml_seed_bytes.zeroize();
522    secret_key.zeroize();
523    pair
524}
525
526fn generate_matched_cat5_keypair() -> HybridSignatureKeyPair {
527    let mut ec_seed = [0u8; P521_SK_LEN];
528    random_bytes(&mut ec_seed);
529    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
530    random_bytes(&mut ml_seed_bytes);
531
532    let signing = p521_signing_key_from_bytes(&ec_seed);
533    let ec_pk: [u8; P521_PK_LEN] = signing
534        .verifying_key()
535        .to_sec1_point(false)
536        .as_bytes()
537        .try_into()
538        .expect("uncompressed P-521 public key is 133 bytes");
539    let ml_seed: B32 = ml_seed_bytes.into();
540    let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
541
542    let mut public_key = Vec::with_capacity(1 + P521_PK_LEN + ml_pk.len());
543    public_key.push(VERSION_SIG_MATCHED_CAT5);
544    public_key.extend_from_slice(&ec_pk);
545    public_key.extend_from_slice(&ml_pk);
546
547    let mut secret_key = Vec::with_capacity(1 + P521_SK_LEN + MLDSA_SEED_LEN);
548    secret_key.push(VERSION_SIG_MATCHED_CAT5);
549    secret_key.extend_from_slice(&ec_seed);
550    secret_key.extend_from_slice(&ml_seed_bytes);
551
552    let pair = HybridSignatureKeyPair {
553        public_key: b64::encode(&public_key),
554        secret_key: b64::encode(&secret_key),
555    };
556    ec_seed.zeroize();
557    ml_seed_bytes.zeroize();
558    secret_key.zeroize();
559    pair
560}
561
562// === Public API: keygen ===
563
564/// Generate a hybrid ML-DSA-65 + Ed25519 signing keypair (Cat-3, default).
565pub fn generate_signing_keypair() -> HybridSignatureKeyPair {
566    generate_signing_keypair_with_level(SignatureLevel::Cat3)
567}
568
569/// Generate a hybrid ML-DSA-44 + Ed25519 signing keypair (Cat-2).
570pub fn generate_signing_keypair_44() -> HybridSignatureKeyPair {
571    generate_signing_keypair_with_level(SignatureLevel::Cat2)
572}
573
574/// Generate a hybrid ML-DSA-87 + Ed25519 signing keypair (Cat-5).
575pub fn generate_signing_keypair_87() -> HybridSignatureKeyPair {
576    generate_signing_keypair_with_level(SignatureLevel::Cat5)
577}
578
579/// Generate a hybrid signing keypair at the specified security level.
580///
581/// Returns base64 `public_key` and `secret_key` using the documented byte
582/// layout. The two algorithms are seeded from independent OS randomness.
583pub fn generate_signing_keypair_with_level(level: SignatureLevel) -> HybridSignatureKeyPair {
584    let mut ed_seed = [0u8; ED25519_SEED_LEN];
585    random_bytes(&mut ed_seed);
586    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
587    random_bytes(&mut ml_seed_bytes);
588
589    let ed_sk = EdSigningKey::from_bytes(&ed_seed);
590    let ed_pk = ed_sk.verifying_key().to_bytes();
591
592    let ml_seed: B32 = ml_seed_bytes.into();
593    let ml_pk = match level {
594        SignatureLevel::Cat2 => mldsa_public_key::<MlDsa44>(&ml_seed),
595        SignatureLevel::Cat3 => mldsa_public_key::<MlDsa65>(&ml_seed),
596        SignatureLevel::Cat5 => mldsa_public_key::<MlDsa87>(&ml_seed),
597    };
598
599    let tag = level.version_tag();
600
601    let mut public_key = Vec::with_capacity(1 + ED25519_PK_LEN + ml_pk.len());
602    public_key.push(tag);
603    public_key.extend_from_slice(&ed_pk);
604    public_key.extend_from_slice(&ml_pk);
605
606    let mut secret_key = Vec::with_capacity(SECRET_KEY_LEN);
607    secret_key.push(tag);
608    secret_key.extend_from_slice(&ed_seed);
609    secret_key.extend_from_slice(&ml_seed_bytes);
610
611    let pair = HybridSignatureKeyPair {
612        public_key: b64::encode(&public_key),
613        secret_key: b64::encode(&secret_key),
614    };
615
616    ed_seed.zeroize();
617    ml_seed_bytes.zeroize();
618    secret_key.zeroize();
619    pair
620}
621
622// === Public API: sign / verify ===
623
624/// Re-derive the base64 public key from a base64 hybrid secret key.
625///
626/// Both component public keys are a deterministic function of the secret seeds,
627/// so this reproduces exactly the `public_key` returned by keygen. Useful for
628/// recovering a public key from a backed-up secret, or for verifying that a
629/// secret/public pair belong together. The security level is read from the
630/// secret key's version tag.
631pub fn derive_public_key(secret_key_b64: &str) -> Result<String, CryptoError> {
632    let mut sk_bytes = b64::decode(secret_key_b64)?;
633    if let Some(&tag) = sk_bytes.first() {
634        if is_new_suite_tag(tag) {
635            let out = derive_public_key_new_suite(&sk_bytes);
636            sk_bytes.zeroize();
637            return out;
638        }
639    }
640    let level = level_from_tag(sk_bytes.first())?;
641
642    if sk_bytes.len() != SECRET_KEY_LEN {
643        sk_bytes.zeroize();
644        return Err(CryptoError::InvalidLength {
645            expected: SECRET_KEY_LEN,
646            got: sk_bytes.len(),
647        });
648    }
649
650    let mut ed_seed = [0u8; ED25519_SEED_LEN];
651    ed_seed.copy_from_slice(&sk_bytes[1..1 + ED25519_SEED_LEN]);
652    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
653    ml_seed_bytes.copy_from_slice(&sk_bytes[1 + ED25519_SEED_LEN..SECRET_KEY_LEN]);
654    sk_bytes.zeroize();
655
656    let ed_pk = EdSigningKey::from_bytes(&ed_seed)
657        .verifying_key()
658        .to_bytes();
659    ed_seed.zeroize();
660
661    let ml_seed: B32 = ml_seed_bytes.into();
662    let ml_pk = match level {
663        SignatureLevel::Cat2 => mldsa_public_key::<MlDsa44>(&ml_seed),
664        SignatureLevel::Cat3 => mldsa_public_key::<MlDsa65>(&ml_seed),
665        SignatureLevel::Cat5 => mldsa_public_key::<MlDsa87>(&ml_seed),
666    };
667    ml_seed_bytes.zeroize();
668
669    let mut public_key = Vec::with_capacity(1 + ED25519_PK_LEN + ml_pk.len());
670    public_key.push(level.version_tag());
671    public_key.extend_from_slice(&ed_pk);
672    public_key.extend_from_slice(&ml_pk);
673
674    Ok(b64::encode(&public_key))
675}
676/// Sign `message` under `context` with a base64 hybrid `secret_key`.
677///
678/// Produces a composite signature: a hedged (randomized) ML-DSA signature and a
679/// deterministic Ed25519 signature, both over the domain-separated
680/// `frame(context, message)`. The security level is read from the secret key's
681/// version tag. Returns the base64 signature `tag || ed25519_sig || ml_dsa_sig`.
682///
683/// Because ML-DSA signing is randomized, signing the same message twice yields
684/// different (both-valid) signatures. Use [`SIGN_CONTEXT_V1`] (or another
685/// versioned label) for `context`.
686pub fn sign(message: &[u8], context: &str, secret_key_b64: &str) -> Result<String, CryptoError> {
687    let mut sk_bytes = b64::decode(secret_key_b64)?;
688    if let Some(&tag) = sk_bytes.first() {
689        if is_new_suite_tag(tag) {
690            let out = sign_new_suite(message, context, &sk_bytes);
691            sk_bytes.zeroize();
692            return out;
693        }
694    }
695    let level = level_from_tag(sk_bytes.first())?;
696
697    if sk_bytes.len() != SECRET_KEY_LEN {
698        sk_bytes.zeroize();
699        return Err(CryptoError::InvalidLength {
700            expected: SECRET_KEY_LEN,
701            got: sk_bytes.len(),
702        });
703    }
704
705    let mut ed_seed = [0u8; ED25519_SEED_LEN];
706    ed_seed.copy_from_slice(&sk_bytes[1..1 + ED25519_SEED_LEN]);
707    let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
708    ml_seed_bytes.copy_from_slice(&sk_bytes[1 + ED25519_SEED_LEN..SECRET_KEY_LEN]);
709    sk_bytes.zeroize();
710
711    let framed = frame(context, message);
712
713    let ed_sk = EdSigningKey::from_bytes(&ed_seed);
714    let ed_sig = ed_sk.sign(&framed).to_bytes();
715    ed_seed.zeroize();
716
717    let ml_seed: B32 = ml_seed_bytes.into();
718    let ml_sig = match level {
719        SignatureLevel::Cat2 => mldsa_sign::<MlDsa44>(&ml_seed, &framed),
720        SignatureLevel::Cat3 => mldsa_sign::<MlDsa65>(&ml_seed, &framed),
721        SignatureLevel::Cat5 => mldsa_sign::<MlDsa87>(&ml_seed, &framed),
722    };
723    ml_seed_bytes.zeroize();
724
725    let mut out = Vec::with_capacity(1 + ED25519_SIG_LEN + ml_sig.len());
726    out.push(level.version_tag());
727    out.extend_from_slice(&ed_sig);
728    out.extend_from_slice(&ml_sig);
729
730    Ok(b64::encode(&out))
731}
732
733/// Verify a composite `signature_b64` over `message`/`context` against `public_key_b64`.
734///
735/// Returns `Ok(true)` **only if both** the Ed25519 and ML-DSA component
736/// signatures verify (strict AND). Returns `Ok(false)` for any cryptographic
737/// failure, including a tampered message/context, a wrong key, a tampered
738/// half-signature, or a signature/public-key whose version tags or lengths do
739/// not match (cross-level or stripped). Returns `Err` only when an input cannot
740/// be decoded as base64 or carries an unknown version tag.
741pub fn verify(
742    message: &[u8],
743    context: &str,
744    signature_b64: &str,
745    public_key_b64: &str,
746) -> Result<bool, CryptoError> {
747    let sig = b64::decode(signature_b64)?;
748    let pk = b64::decode(public_key_b64)?;
749
750    // CNSA-2.0 suites route on their own tags (no cross-family confusion).
751    match (sig.first(), pk.first()) {
752        (Some(&s), _) if is_new_suite_tag(s) => {
753            return verify_new_suite(message, context, &sig, &pk);
754        }
755        (_, Some(&p)) if is_new_suite_tag(p) => return Ok(false),
756        _ => {}
757    }
758
759    let sig_level = level_from_tag(sig.first())?;
760    let pk_level = level_from_tag(pk.first())?;
761    // Mismatched levels => verification fails (no cross-level confusion).
762    if sig_level != pk_level {
763        return Ok(false);
764    }
765    let level = sig_level;
766
767    if sig.len() != 1 + ED25519_SIG_LEN + level.mldsa_sig_len()
768        || pk.len() != 1 + ED25519_PK_LEN + level.mldsa_pk_len()
769    {
770        return Ok(false);
771    }
772
773    let framed = frame(context, message);
774
775    let ed_pk_bytes: [u8; ED25519_PK_LEN] = pk[1..1 + ED25519_PK_LEN].try_into().unwrap();
776    let ed_sig_bytes: [u8; ED25519_SIG_LEN] = sig[1..1 + ED25519_SIG_LEN].try_into().unwrap();
777    let ml_pk = &pk[1 + ED25519_PK_LEN..];
778    let ml_sig = &sig[1 + ED25519_SIG_LEN..];
779
780    let ed_ok = match EdVerifyingKey::from_bytes(&ed_pk_bytes) {
781        Ok(vk) => vk
782            .verify(&framed, &EdSignature::from_bytes(&ed_sig_bytes))
783            .is_ok(),
784        Err(_) => false,
785    };
786
787    let ml_ok = match level {
788        SignatureLevel::Cat2 => mldsa_verify::<MlDsa44>(ml_pk, &framed, ml_sig),
789        SignatureLevel::Cat3 => mldsa_verify::<MlDsa65>(ml_pk, &framed, ml_sig),
790        SignatureLevel::Cat5 => mldsa_verify::<MlDsa87>(ml_pk, &framed, ml_sig),
791    };
792
793    // Strict AND: both component signatures must verify.
794    Ok(ed_ok && ml_ok)
795}
796
797// === Public API: posture introspection ===
798
799/// Report the `(Suite, SignatureLevel)` posture declared by a base64 hybrid
800/// **public key**, without exposing the raw wire tag.
801///
802/// Composite artifacts produced by this crate are *self-describing*: their
803/// leading version tag encodes which suite and security level produced them.
804/// This is the typed, opaque decode half of that contract — it lets any
805/// verifier (Rust core, WASM, NIF) learn the posture of a key it was handed and
806/// check it against an independently declared expectation (a "declared ==
807/// observed" check), without re-deriving the private wire tags itself.
808///
809/// The full decoded blob length is validated against the expected length for
810/// the decoded posture (mirroring [`verify`]'s length checks), so a truncated,
811/// over-long, or otherwise malformed key is rejected with a [`CryptoError`]
812/// rather than silently misreporting a posture. An unknown or missing leading
813/// tag, or a base64 decode failure, is likewise a [`CryptoError`].
814///
815/// This is purely read-only: it touches no secret material, allocates no
816/// secrets, and never panics on malformed input.
817///
818/// # Cat-2 aliasing
819///
820/// `Suite::Hybrid` and `Suite::HybridMatched` are byte-identical at Cat-2 (both
821/// tag `0x01`, since `HybridMatched` delegates to `Hybrid` at the lowest shared
822/// rung), so a Cat-2 key canonically decodes to
823/// `(Suite::Hybrid, SignatureLevel::Cat2)`.
824///
825/// # Honest framing
826///
827/// This reports the *declared format posture* read from the artifact's tag and
828/// validated for length. It is **not itself a cryptographic guarantee** that a
829/// signature verifies, nor a FIPS-validation claim — pair it with [`verify`]
830/// for authenticity.
831///
832/// # Example
833///
834/// ```
835/// use metamorphic_crypto::{
836///     generate_signing_keypair_suite, signature_posture, SignatureLevel, Suite,
837/// };
838///
839/// let kp = generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat5).unwrap();
840/// assert_eq!(
841///     signature_posture(&kp.public_key).unwrap(),
842///     (Suite::PureCnsa2, SignatureLevel::Cat5)
843/// );
844/// ```
845pub fn signature_posture(public_key_b64: &str) -> Result<(Suite, SignatureLevel), CryptoError> {
846    let pk = b64::decode(public_key_b64)?;
847    let tag = pk
848        .first()
849        .copied()
850        .ok_or_else(|| CryptoError::Signature("unknown or missing signature version tag".into()))?;
851    let posture = posture_from_tag(tag)
852        .ok_or_else(|| CryptoError::Signature("unknown or missing signature version tag".into()))?;
853    let expected =
854        expected_public_key_len(tag).expect("known tag always has an expected public-key length");
855    if pk.len() != expected {
856        return Err(CryptoError::InvalidLength {
857            expected,
858            got: pk.len(),
859        });
860    }
861    Ok(posture)
862}
863
864/// Report the `(Suite, SignatureLevel)` posture declared by a base64 composite
865/// **signature**, without exposing the raw wire tag.
866///
867/// The signature counterpart to [`signature_posture`]; see that function for
868/// the self-describing-artifact contract, the Cat-2 aliasing note
869/// (`0x01` → `(Suite::Hybrid, SignatureLevel::Cat2)`), and the honest framing
870/// (declared posture, not a verification result). The full decoded blob length
871/// is validated against the expected signature length for the decoded posture,
872/// so a truncated/garbage/wrong-length signature is rejected with a
873/// [`CryptoError`] rather than misreported. Read-only; no secrets; no panics.
874///
875/// # Example
876///
877/// ```
878/// use metamorphic_crypto::{
879///     generate_signing_keypair_suite, sign, signature_posture_from_signature,
880///     SignatureLevel, Suite, SIGN_CONTEXT_V1,
881/// };
882///
883/// let kp = generate_signing_keypair_suite(Suite::Hybrid, SignatureLevel::Cat3).unwrap();
884/// let sig = sign(b"checkpoint", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
885/// assert_eq!(
886///     signature_posture_from_signature(&sig).unwrap(),
887///     (Suite::Hybrid, SignatureLevel::Cat3)
888/// );
889/// ```
890pub fn signature_posture_from_signature(
891    signature_b64: &str,
892) -> Result<(Suite, SignatureLevel), CryptoError> {
893    let sig = b64::decode(signature_b64)?;
894    let tag = sig
895        .first()
896        .copied()
897        .ok_or_else(|| CryptoError::Signature("unknown or missing signature version tag".into()))?;
898    let posture = posture_from_tag(tag)
899        .ok_or_else(|| CryptoError::Signature("unknown or missing signature version tag".into()))?;
900    let expected =
901        expected_signature_len(tag).expect("known tag always has an expected signature length");
902    if sig.len() != expected {
903        return Err(CryptoError::InvalidLength {
904            expected,
905            got: sig.len(),
906        });
907    }
908    Ok(posture)
909}
910
911// === CNSA 2.0 suites: sign / verify / derive ===
912
913/// Returns `true` if `tag` is one of the new CNSA-2.0 signature suite tags.
914fn is_new_suite_tag(tag: u8) -> bool {
915    matches!(
916        tag,
917        VERSION_SIG_PURE_CNSA2 | VERSION_SIG_MATCHED_CAT3 | VERSION_SIG_MATCHED_CAT5
918    )
919}
920
921fn sign_new_suite(message: &[u8], context: &str, sk: &[u8]) -> Result<String, CryptoError> {
922    let framed = frame(context, message);
923    match sk[0] {
924        VERSION_SIG_PURE_CNSA2 => {
925            if sk.len() != 1 + MLDSA_SEED_LEN {
926                return Err(CryptoError::InvalidLength {
927                    expected: 1 + MLDSA_SEED_LEN,
928                    got: sk.len(),
929                });
930            }
931            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
932            ml_seed_bytes.copy_from_slice(&sk[1..]);
933            let ml_seed: B32 = ml_seed_bytes.into();
934            let ml_sig = mldsa_sign::<MlDsa87>(&ml_seed, &framed);
935            ml_seed_bytes.zeroize();
936            let mut out = Vec::with_capacity(1 + ml_sig.len());
937            out.push(VERSION_SIG_PURE_CNSA2);
938            out.extend_from_slice(&ml_sig);
939            Ok(b64::encode(&out))
940        }
941        VERSION_SIG_MATCHED_CAT3 => {
942            if sk.len() != 1 + ED448_SEED_LEN + MLDSA_SEED_LEN {
943                return Err(CryptoError::InvalidLength {
944                    expected: 1 + ED448_SEED_LEN + MLDSA_SEED_LEN,
945                    got: sk.len(),
946                });
947            }
948            let mut ed_seed = [0u8; ED448_SEED_LEN];
949            ed_seed.copy_from_slice(&sk[1..1 + ED448_SEED_LEN]);
950            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
951            ml_seed_bytes.copy_from_slice(&sk[1 + ED448_SEED_LEN..]);
952
953            let (_, ed_sk) = ed448_keypair(&ed_seed)?;
954            let ed_sig = ed_sk.sign_raw(&framed).to_bytes();
955            ed_seed.zeroize();
956            let ml_seed: B32 = ml_seed_bytes.into();
957            let ml_sig = mldsa_sign::<MlDsa65>(&ml_seed, &framed);
958            ml_seed_bytes.zeroize();
959
960            let mut out = Vec::with_capacity(1 + ED448_SIG_LEN + ml_sig.len());
961            out.push(VERSION_SIG_MATCHED_CAT3);
962            out.extend_from_slice(&ed_sig);
963            out.extend_from_slice(&ml_sig);
964            Ok(b64::encode(&out))
965        }
966        VERSION_SIG_MATCHED_CAT5 => {
967            if sk.len() != 1 + P521_SK_LEN + MLDSA_SEED_LEN {
968                return Err(CryptoError::InvalidLength {
969                    expected: 1 + P521_SK_LEN + MLDSA_SEED_LEN,
970                    got: sk.len(),
971                });
972            }
973            let mut ec_seed = [0u8; P521_SK_LEN];
974            ec_seed.copy_from_slice(&sk[1..1 + P521_SK_LEN]);
975            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
976            ml_seed_bytes.copy_from_slice(&sk[1 + P521_SK_LEN..]);
977
978            // Hedged RFC 6979 ECDSA: deterministic nonce + added OS entropy.
979            let signing = p521_signing_key_from_bytes(&ec_seed);
980            let ec_sig: P521Signature = signing
981                .try_sign_with_rng(&mut OsCsprng, &framed)
982                .map_err(|_| CryptoError::Signature("ECDSA-P-521 signing failed".into()))?;
983            let ec_sig_bytes = ec_sig.to_bytes();
984            ec_seed.zeroize();
985            let ml_seed: B32 = ml_seed_bytes.into();
986            let ml_sig = mldsa_sign::<MlDsa87>(&ml_seed, &framed);
987            ml_seed_bytes.zeroize();
988
989            let mut out = Vec::with_capacity(1 + P521_SIG_LEN + ml_sig.len());
990            out.push(VERSION_SIG_MATCHED_CAT5);
991            out.extend_from_slice(ec_sig_bytes.as_slice());
992            out.extend_from_slice(&ml_sig);
993            Ok(b64::encode(&out))
994        }
995        _ => Err(CryptoError::Signature("not a CNSA-2.0 suite tag".into())),
996    }
997}
998
999fn ed448_verify(pk: &[u8], framed: &[u8], sig: &[u8]) -> bool {
1000    let Ok(pk_arr): Result<[u8; ED448_PK_LEN], _> = pk.try_into() else {
1001        return false;
1002    };
1003    let Ok(vk) = Ed448VerifyingKey::from_bytes(&pk_arr) else {
1004        return false;
1005    };
1006    let Ok(signature) = Ed448Signature::try_from(sig) else {
1007        return false;
1008    };
1009    vk.verify_raw(&signature, framed).is_ok()
1010}
1011
1012fn p521_ecdsa_verify(pk: &[u8], framed: &[u8], sig: &[u8]) -> bool {
1013    let Ok(vk) = P521VerifyingKey::from_sec1_bytes(pk) else {
1014        return false;
1015    };
1016    let Ok(signature) = P521Signature::from_slice(sig) else {
1017        return false;
1018    };
1019    vk.verify(framed, &signature).is_ok()
1020}
1021
1022fn verify_new_suite(
1023    message: &[u8],
1024    context: &str,
1025    sig: &[u8],
1026    pk: &[u8],
1027) -> Result<bool, CryptoError> {
1028    // Mismatched suite tags between signature and public key => fail (no
1029    // cross-suite confusion / downgrade).
1030    if sig[0] != pk[0] {
1031        return Ok(false);
1032    }
1033    let framed = frame(context, message);
1034    match sig[0] {
1035        VERSION_SIG_PURE_CNSA2 => {
1036            if sig.len() != 1 + MLDSA87_SIG_LEN || pk.len() != 1 + MLDSA87_PK_LEN {
1037                return Ok(false);
1038            }
1039            Ok(mldsa_verify::<MlDsa87>(&pk[1..], &framed, &sig[1..]))
1040        }
1041        VERSION_SIG_MATCHED_CAT3 => {
1042            if sig.len() != 1 + ED448_SIG_LEN + MLDSA65_SIG_LEN
1043                || pk.len() != 1 + ED448_PK_LEN + MLDSA65_PK_LEN
1044            {
1045                return Ok(false);
1046            }
1047            let ed_ok = ed448_verify(
1048                &pk[1..1 + ED448_PK_LEN],
1049                &framed,
1050                &sig[1..1 + ED448_SIG_LEN],
1051            );
1052            let ml_ok = mldsa_verify::<MlDsa65>(
1053                &pk[1 + ED448_PK_LEN..],
1054                &framed,
1055                &sig[1 + ED448_SIG_LEN..],
1056            );
1057            Ok(ed_ok && ml_ok)
1058        }
1059        VERSION_SIG_MATCHED_CAT5 => {
1060            if sig.len() != 1 + P521_SIG_LEN + MLDSA87_SIG_LEN
1061                || pk.len() != 1 + P521_PK_LEN + MLDSA87_PK_LEN
1062            {
1063                return Ok(false);
1064            }
1065            let ec_ok =
1066                p521_ecdsa_verify(&pk[1..1 + P521_PK_LEN], &framed, &sig[1..1 + P521_SIG_LEN]);
1067            let ml_ok =
1068                mldsa_verify::<MlDsa87>(&pk[1 + P521_PK_LEN..], &framed, &sig[1 + P521_SIG_LEN..]);
1069            Ok(ec_ok && ml_ok)
1070        }
1071        _ => Err(CryptoError::Signature("not a CNSA-2.0 suite tag".into())),
1072    }
1073}
1074
1075fn derive_public_key_new_suite(sk: &[u8]) -> Result<String, CryptoError> {
1076    match sk[0] {
1077        VERSION_SIG_PURE_CNSA2 => {
1078            if sk.len() != 1 + MLDSA_SEED_LEN {
1079                return Err(CryptoError::InvalidLength {
1080                    expected: 1 + MLDSA_SEED_LEN,
1081                    got: sk.len(),
1082                });
1083            }
1084            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
1085            ml_seed_bytes.copy_from_slice(&sk[1..]);
1086            let ml_seed: B32 = ml_seed_bytes.into();
1087            let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
1088            ml_seed_bytes.zeroize();
1089            let mut pk = Vec::with_capacity(1 + ml_pk.len());
1090            pk.push(VERSION_SIG_PURE_CNSA2);
1091            pk.extend_from_slice(&ml_pk);
1092            Ok(b64::encode(&pk))
1093        }
1094        VERSION_SIG_MATCHED_CAT3 => {
1095            if sk.len() != 1 + ED448_SEED_LEN + MLDSA_SEED_LEN {
1096                return Err(CryptoError::InvalidLength {
1097                    expected: 1 + ED448_SEED_LEN + MLDSA_SEED_LEN,
1098                    got: sk.len(),
1099                });
1100            }
1101            let mut ed_seed = [0u8; ED448_SEED_LEN];
1102            ed_seed.copy_from_slice(&sk[1..1 + ED448_SEED_LEN]);
1103            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
1104            ml_seed_bytes.copy_from_slice(&sk[1 + ED448_SEED_LEN..]);
1105            let (ed_pk, _) = ed448_keypair(&ed_seed)?;
1106            ed_seed.zeroize();
1107            let ml_seed: B32 = ml_seed_bytes.into();
1108            let ml_pk = mldsa_public_key::<MlDsa65>(&ml_seed);
1109            ml_seed_bytes.zeroize();
1110            let mut pk = Vec::with_capacity(1 + ED448_PK_LEN + ml_pk.len());
1111            pk.push(VERSION_SIG_MATCHED_CAT3);
1112            pk.extend_from_slice(&ed_pk);
1113            pk.extend_from_slice(&ml_pk);
1114            Ok(b64::encode(&pk))
1115        }
1116        VERSION_SIG_MATCHED_CAT5 => {
1117            if sk.len() != 1 + P521_SK_LEN + MLDSA_SEED_LEN {
1118                return Err(CryptoError::InvalidLength {
1119                    expected: 1 + P521_SK_LEN + MLDSA_SEED_LEN,
1120                    got: sk.len(),
1121                });
1122            }
1123            let mut ec_seed = [0u8; P521_SK_LEN];
1124            ec_seed.copy_from_slice(&sk[1..1 + P521_SK_LEN]);
1125            let mut ml_seed_bytes = [0u8; MLDSA_SEED_LEN];
1126            ml_seed_bytes.copy_from_slice(&sk[1 + P521_SK_LEN..]);
1127            let signing = p521_signing_key_from_bytes(&ec_seed);
1128            let ec_pk: [u8; P521_PK_LEN] = signing
1129                .verifying_key()
1130                .to_sec1_point(false)
1131                .as_bytes()
1132                .try_into()
1133                .expect("uncompressed P-521 public key is 133 bytes");
1134            ec_seed.zeroize();
1135            let ml_seed: B32 = ml_seed_bytes.into();
1136            let ml_pk = mldsa_public_key::<MlDsa87>(&ml_seed);
1137            ml_seed_bytes.zeroize();
1138            let mut pk = Vec::with_capacity(1 + P521_PK_LEN + ml_pk.len());
1139            pk.push(VERSION_SIG_MATCHED_CAT5);
1140            pk.extend_from_slice(&ec_pk);
1141            pk.extend_from_slice(&ml_pk);
1142            Ok(b64::encode(&pk))
1143        }
1144        _ => Err(CryptoError::Signature("not a CNSA-2.0 suite tag".into())),
1145    }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151
1152    fn roundtrip(level: SignatureLevel) {
1153        let kp = generate_signing_keypair_with_level(level);
1154        let sig = sign(b"hello transparency log", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1155        assert!(
1156            verify(
1157                b"hello transparency log",
1158                SIGN_CONTEXT_V1,
1159                &sig,
1160                &kp.public_key
1161            )
1162            .unwrap()
1163        );
1164    }
1165
1166    #[test]
1167    fn cat2_roundtrip() {
1168        roundtrip(SignatureLevel::Cat2);
1169    }
1170
1171    #[test]
1172    fn cat3_roundtrip() {
1173        roundtrip(SignatureLevel::Cat3);
1174    }
1175
1176    #[test]
1177    fn cat5_roundtrip() {
1178        roundtrip(SignatureLevel::Cat5);
1179    }
1180
1181    #[test]
1182    fn default_is_cat3() {
1183        assert_eq!(SignatureLevel::default(), SignatureLevel::Cat3);
1184        let kp = generate_signing_keypair();
1185        let raw = b64::decode(&kp.public_key).unwrap();
1186        assert_eq!(raw[0], VERSION_CAT3);
1187    }
1188
1189    #[test]
1190    fn version_tags() {
1191        for (level, tag) in [
1192            (SignatureLevel::Cat2, VERSION_CAT2),
1193            (SignatureLevel::Cat3, VERSION_CAT3),
1194            (SignatureLevel::Cat5, VERSION_CAT5),
1195        ] {
1196            let kp = generate_signing_keypair_with_level(level);
1197            let sig = sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1198            assert_eq!(b64::decode(&kp.public_key).unwrap()[0], tag);
1199            assert_eq!(b64::decode(&kp.secret_key).unwrap()[0], tag);
1200            assert_eq!(b64::decode(&sig).unwrap()[0], tag);
1201        }
1202    }
1203
1204    #[test]
1205    fn key_and_signature_sizes() {
1206        for (level, pk_len, sig_len) in [
1207            (SignatureLevel::Cat2, MLDSA44_PK_LEN, MLDSA44_SIG_LEN),
1208            (SignatureLevel::Cat3, MLDSA65_PK_LEN, MLDSA65_SIG_LEN),
1209            (SignatureLevel::Cat5, MLDSA87_PK_LEN, MLDSA87_SIG_LEN),
1210        ] {
1211            let kp = generate_signing_keypair_with_level(level);
1212            let pk = b64::decode(&kp.public_key).unwrap();
1213            let sk = b64::decode(&kp.secret_key).unwrap();
1214            let sig = b64::decode(&sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
1215            assert_eq!(pk.len(), 1 + ED25519_PK_LEN + pk_len);
1216            assert_eq!(sk.len(), SECRET_KEY_LEN);
1217            assert_eq!(sig.len(), 1 + ED25519_SIG_LEN + sig_len);
1218        }
1219    }
1220
1221    #[test]
1222    fn wrong_key_fails() {
1223        let kp1 = generate_signing_keypair();
1224        let kp2 = generate_signing_keypair();
1225        let sig = sign(b"msg", SIGN_CONTEXT_V1, &kp1.secret_key).unwrap();
1226        assert!(!verify(b"msg", SIGN_CONTEXT_V1, &sig, &kp2.public_key).unwrap());
1227    }
1228
1229    #[test]
1230    fn tampered_message_fails() {
1231        let kp = generate_signing_keypair();
1232        let sig = sign(b"original", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1233        assert!(!verify(b"tampered", SIGN_CONTEXT_V1, &sig, &kp.public_key).unwrap());
1234    }
1235
1236    #[test]
1237    fn context_separation() {
1238        let kp = generate_signing_keypair();
1239        let sig = sign(b"msg", "metamorphic/sign/v1", &kp.secret_key).unwrap();
1240        // Same message, different verification context => fails.
1241        assert!(!verify(b"msg", "metamorphic/other/v1", &sig, &kp.public_key).unwrap());
1242    }
1243
1244    #[test]
1245    fn empty_message_and_context() {
1246        let kp = generate_signing_keypair();
1247        let sig = sign(b"", "", &kp.secret_key).unwrap();
1248        assert!(verify(b"", "", &sig, &kp.public_key).unwrap());
1249    }
1250
1251    #[test]
1252    fn nondeterministic_but_both_valid() {
1253        let kp = generate_signing_keypair();
1254        let s1 = sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1255        let s2 = sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1256        // Hedged ML-DSA => signatures differ, but both verify.
1257        assert_ne!(s1, s2);
1258        assert!(verify(b"msg", SIGN_CONTEXT_V1, &s1, &kp.public_key).unwrap());
1259        assert!(verify(b"msg", SIGN_CONTEXT_V1, &s2, &kp.public_key).unwrap());
1260    }
1261
1262    /// Strict AND: tampering with *either* component signature must fail
1263    /// verification, proving neither algorithm can be stripped or forged alone.
1264    #[test]
1265    fn strict_and_requires_both() {
1266        let kp = generate_signing_keypair();
1267        let good = b64::decode(&sign(b"msg", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
1268
1269        // Corrupt a byte inside the Ed25519 component (ML-DSA still valid).
1270        let mut bad_ed = good.clone();
1271        bad_ed[1] ^= 0xFF;
1272        assert!(
1273            !verify(
1274                b"msg",
1275                SIGN_CONTEXT_V1,
1276                &b64::encode(&bad_ed),
1277                &kp.public_key
1278            )
1279            .unwrap()
1280        );
1281
1282        // Corrupt a byte inside the ML-DSA component (Ed25519 still valid).
1283        let mut bad_ml = good.clone();
1284        let i = 1 + ED25519_SIG_LEN + 10;
1285        bad_ml[i] ^= 0xFF;
1286        assert!(
1287            !verify(
1288                b"msg",
1289                SIGN_CONTEXT_V1,
1290                &b64::encode(&bad_ml),
1291                &kp.public_key
1292            )
1293            .unwrap()
1294        );
1295    }
1296
1297    #[test]
1298    fn cross_level_fails() {
1299        let kp3 = generate_signing_keypair_with_level(SignatureLevel::Cat3);
1300        let kp5 = generate_signing_keypair_with_level(SignatureLevel::Cat5);
1301        let sig3 = sign(b"msg", SIGN_CONTEXT_V1, &kp3.secret_key).unwrap();
1302        // Cat-3 signature against a Cat-5 public key => fails (tag mismatch).
1303        assert!(!verify(b"msg", SIGN_CONTEXT_V1, &sig3, &kp5.public_key).unwrap());
1304    }
1305
1306    #[test]
1307    fn unknown_tag_errors() {
1308        let bad = b64::encode(&[0x09u8; SECRET_KEY_LEN]);
1309        assert!(sign(b"x", SIGN_CONTEXT_V1, &bad).is_err());
1310        let pk = b64::encode(&[0x09u8; 100]);
1311        let sig = b64::encode(&[0x09u8; 100]);
1312        assert!(verify(b"x", SIGN_CONTEXT_V1, &sig, &pk).is_err());
1313    }
1314
1315    #[test]
1316    fn bad_base64_errors() {
1317        assert!(sign(b"x", SIGN_CONTEXT_V1, "not!base64!").is_err());
1318        assert!(verify(b"x", SIGN_CONTEXT_V1, "not!base64!", "also!bad!").is_err());
1319    }
1320
1321    #[test]
1322    fn frame_matches_manual() {
1323        let ctx = "metamorphic/sign/v1";
1324        let msg = b"payload";
1325        let mut expected = Vec::new();
1326        expected.extend_from_slice(&(ctx.len() as u64).to_be_bytes());
1327        expected.extend_from_slice(ctx.as_bytes());
1328        expected.extend_from_slice(msg);
1329        assert_eq!(frame(ctx, msg), expected);
1330    }
1331
1332    #[test]
1333    fn frame_no_boundary_confusion() {
1334        assert_ne!(frame("ab", b"c"), frame("a", b"bc"));
1335    }
1336
1337    #[test]
1338    fn secret_key_debug_is_redacted() {
1339        let kp = generate_signing_keypair();
1340        let shown = format!("{kp:?}");
1341        assert!(shown.contains("<redacted>"));
1342        assert!(!shown.contains(&kp.secret_key));
1343    }
1344
1345    #[test]
1346    fn keygen_public_key_matches_derived() {
1347        for level in [
1348            SignatureLevel::Cat2,
1349            SignatureLevel::Cat3,
1350            SignatureLevel::Cat5,
1351        ] {
1352            let kp = generate_signing_keypair_with_level(level);
1353            assert_eq!(derive_public_key(&kp.secret_key).unwrap(), kp.public_key);
1354        }
1355    }
1356
1357    use proptest::prelude::*;
1358
1359    proptest! {
1360        #[test]
1361        fn roundtrip_arbitrary_message(msg: Vec<u8>, ctx in "[a-z/]{0,32}") {
1362            let kp = generate_signing_keypair();
1363            let sig = sign(&msg, &ctx, &kp.secret_key).unwrap();
1364            prop_assert!(verify(&msg, &ctx, &sig, &kp.public_key).unwrap());
1365        }
1366    }
1367
1368    // === CNSA 2.0 signature suites (v0.7.0) ===
1369
1370    fn suite_roundtrip(suite: Suite, level: SignatureLevel, tag: u8) {
1371        let kp = generate_signing_keypair_suite(suite, level).unwrap();
1372        assert_eq!(b64::decode(&kp.public_key).unwrap()[0], tag);
1373        assert_eq!(b64::decode(&kp.secret_key).unwrap()[0], tag);
1374        let sig = sign(
1375            b"transparency-log checkpoint",
1376            SIGN_CONTEXT_V1,
1377            &kp.secret_key,
1378        )
1379        .unwrap();
1380        assert_eq!(b64::decode(&sig).unwrap()[0], tag);
1381        assert!(
1382            verify(
1383                b"transparency-log checkpoint",
1384                SIGN_CONTEXT_V1,
1385                &sig,
1386                &kp.public_key
1387            )
1388            .unwrap()
1389        );
1390        // Tampered message fails.
1391        assert!(!verify(b"tampered", SIGN_CONTEXT_V1, &sig, &kp.public_key).unwrap());
1392        // derive_public_key reproduces the keygen public key.
1393        assert_eq!(derive_public_key(&kp.secret_key).unwrap(), kp.public_key);
1394    }
1395
1396    #[test]
1397    fn pure_cnsa2_sign_roundtrip() {
1398        suite_roundtrip(
1399            Suite::PureCnsa2,
1400            SignatureLevel::Cat5,
1401            VERSION_SIG_PURE_CNSA2,
1402        );
1403    }
1404
1405    #[test]
1406    fn matched_cat3_sign_roundtrip() {
1407        suite_roundtrip(
1408            Suite::HybridMatched,
1409            SignatureLevel::Cat3,
1410            VERSION_SIG_MATCHED_CAT3,
1411        );
1412    }
1413
1414    #[test]
1415    fn matched_cat5_sign_roundtrip() {
1416        suite_roundtrip(
1417            Suite::HybridMatched,
1418            SignatureLevel::Cat5,
1419            VERSION_SIG_MATCHED_CAT5,
1420        );
1421    }
1422
1423    #[test]
1424    fn pure_cnsa2_sign_only_cat5() {
1425        assert!(generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat3).is_err());
1426        assert!(generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat2).is_err());
1427    }
1428
1429    #[test]
1430    fn matched_cat2_is_plain_hybrid() {
1431        // HybridMatched at Cat-2 reuses the existing Ed25519 construction (0x01).
1432        let kp =
1433            generate_signing_keypair_suite(Suite::HybridMatched, SignatureLevel::Cat2).unwrap();
1434        assert_eq!(b64::decode(&kp.public_key).unwrap()[0], VERSION_CAT2);
1435        let sig = sign(b"x", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1436        assert!(verify(b"x", SIGN_CONTEXT_V1, &sig, &kp.public_key).unwrap());
1437    }
1438
1439    #[test]
1440    fn matched_sign_strict_and_requires_both_halves() {
1441        // Corrupting either the classical or the ML-DSA half must fail (strict AND).
1442        for (suite, level, classical_offset) in [
1443            (Suite::HybridMatched, SignatureLevel::Cat3, 1usize), // inside Ed448 sig
1444            (Suite::HybridMatched, SignatureLevel::Cat5, 1usize), // inside ECDSA sig
1445        ] {
1446            let kp = generate_signing_keypair_suite(suite, level).unwrap();
1447            let good = b64::decode(&sign(b"m", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
1448            // Corrupt classical half.
1449            let mut bad_c = good.clone();
1450            bad_c[classical_offset] ^= 0xFF;
1451            assert!(!verify(b"m", SIGN_CONTEXT_V1, &b64::encode(&bad_c), &kp.public_key).unwrap());
1452            // Corrupt ML-DSA tail (last byte).
1453            let mut bad_ml = good.clone();
1454            let last = bad_ml.len() - 1;
1455            bad_ml[last] ^= 0xFF;
1456            assert!(!verify(b"m", SIGN_CONTEXT_V1, &b64::encode(&bad_ml), &kp.public_key).unwrap());
1457        }
1458    }
1459
1460    #[test]
1461    fn pure_cnsa2_nondeterministic_but_valid() {
1462        let kp = generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat5).unwrap();
1463        let s1 = sign(b"m", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1464        let s2 = sign(b"m", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1465        assert_ne!(s1, s2, "hedged ML-DSA => non-reproducible");
1466        assert!(verify(b"m", SIGN_CONTEXT_V1, &s1, &kp.public_key).unwrap());
1467        assert!(verify(b"m", SIGN_CONTEXT_V1, &s2, &kp.public_key).unwrap());
1468    }
1469
1470    #[test]
1471    fn sign_suites_context_separation_and_cross_key() {
1472        for (suite, level) in [
1473            (Suite::PureCnsa2, SignatureLevel::Cat5),
1474            (Suite::HybridMatched, SignatureLevel::Cat3),
1475            (Suite::HybridMatched, SignatureLevel::Cat5),
1476        ] {
1477            let kp = generate_signing_keypair_suite(suite, level).unwrap();
1478            let kp2 = generate_signing_keypair_suite(suite, level).unwrap();
1479            let sig = sign(b"m", "metamorphic/sign/v1", &kp.secret_key).unwrap();
1480            // Different context fails.
1481            assert!(!verify(b"m", "metamorphic/other/v1", &sig, &kp.public_key).unwrap());
1482            // Wrong key fails.
1483            assert!(!verify(b"m", "metamorphic/sign/v1", &sig, &kp2.public_key).unwrap());
1484        }
1485    }
1486
1487    #[test]
1488    fn sign_cross_suite_pk_rejected() {
1489        // A new-suite signature verified against a legacy public key (and vice
1490        // versa) must fail rather than error-route into the wrong family.
1491        let pure = generate_signing_keypair_suite(Suite::PureCnsa2, SignatureLevel::Cat5).unwrap();
1492        let legacy = generate_signing_keypair(); // Cat-3 hybrid (0x02)
1493        let sig_pure = sign(b"m", SIGN_CONTEXT_V1, &pure.secret_key).unwrap();
1494        assert!(!verify(b"m", SIGN_CONTEXT_V1, &sig_pure, &legacy.public_key).unwrap());
1495        let sig_legacy = sign(b"m", SIGN_CONTEXT_V1, &legacy.secret_key).unwrap();
1496        assert!(!verify(b"m", SIGN_CONTEXT_V1, &sig_legacy, &pure.public_key).unwrap());
1497    }
1498
1499    // === Posture introspection (v0.8.1) ===
1500
1501    /// All six postures: a freshly generated key and a fresh signature both
1502    /// decode to the expected `(Suite, SignatureLevel)`, and the public-key and
1503    /// signature postures agree for the same keypair. Per the Cat-2 aliasing
1504    /// rule, `HybridMatched`/Cat-2 is observed as `(Hybrid, Cat2)`.
1505    #[test]
1506    fn posture_all_six_and_pk_sig_agree() {
1507        let cases = [
1508            (Suite::Hybrid, SignatureLevel::Cat2, Suite::Hybrid),
1509            (Suite::Hybrid, SignatureLevel::Cat3, Suite::Hybrid),
1510            (Suite::Hybrid, SignatureLevel::Cat5, Suite::Hybrid),
1511            (Suite::PureCnsa2, SignatureLevel::Cat5, Suite::PureCnsa2),
1512            (
1513                Suite::HybridMatched,
1514                SignatureLevel::Cat3,
1515                Suite::HybridMatched,
1516            ),
1517            (
1518                Suite::HybridMatched,
1519                SignatureLevel::Cat5,
1520                Suite::HybridMatched,
1521            ),
1522            // Cat-2 HybridMatched aliases to plain Hybrid (byte-identical, 0x01).
1523            (Suite::HybridMatched, SignatureLevel::Cat2, Suite::Hybrid),
1524        ];
1525        for (suite, level, observed_suite) in cases {
1526            let kp = generate_signing_keypair_suite(suite, level).unwrap();
1527            assert_eq!(
1528                signature_posture(&kp.public_key).unwrap(),
1529                (observed_suite, level),
1530                "public-key posture for {suite:?}/{level:?}"
1531            );
1532            let sig = sign(b"checkpoint", SIGN_CONTEXT_V1, &kp.secret_key).unwrap();
1533            assert_eq!(
1534                signature_posture_from_signature(&sig).unwrap(),
1535                (observed_suite, level),
1536                "signature posture for {suite:?}/{level:?}"
1537            );
1538            // Public-key and signature postures must agree.
1539            assert_eq!(
1540                signature_posture(&kp.public_key).unwrap(),
1541                signature_posture_from_signature(&sig).unwrap(),
1542                "pk/sig posture agreement for {suite:?}/{level:?}"
1543            );
1544        }
1545    }
1546
1547    #[test]
1548    fn posture_invalid_base64_errors() {
1549        assert!(signature_posture("not!base64!").is_err());
1550        assert!(signature_posture_from_signature("also!bad!").is_err());
1551    }
1552
1553    #[test]
1554    fn posture_empty_input_errors() {
1555        let empty = b64::encode(&[]);
1556        assert!(signature_posture(&empty).is_err());
1557        assert!(signature_posture_from_signature(&empty).is_err());
1558    }
1559
1560    #[test]
1561    fn posture_unknown_tag_errors() {
1562        // 0x7f is not a known suite/level tag.
1563        let blob = b64::encode(&[0x7fu8; 128]);
1564        assert!(signature_posture(&blob).is_err());
1565        assert!(signature_posture_from_signature(&blob).is_err());
1566    }
1567
1568    /// A blob with a correct leading tag but a short body must error (length
1569    /// validation) rather than misreport a posture.
1570    #[test]
1571    fn posture_truncated_blob_errors() {
1572        let kp = generate_signing_keypair_with_level(SignatureLevel::Cat3);
1573        let mut pk = b64::decode(&kp.public_key).unwrap();
1574        pk.truncate(pk.len() - 1);
1575        assert!(signature_posture(&b64::encode(&pk)).is_err());
1576
1577        let sig = b64::decode(&sign(b"m", SIGN_CONTEXT_V1, &kp.secret_key).unwrap()).unwrap();
1578        let mut short = sig.clone();
1579        short.truncate(short.len() - 1);
1580        assert!(signature_posture_from_signature(&b64::encode(&short)).is_err());
1581
1582        // Over-long (correct tag, extra trailing byte) is also rejected.
1583        let mut long = sig;
1584        long.push(0u8);
1585        assert!(signature_posture_from_signature(&b64::encode(&long)).is_err());
1586    }
1587}