Skip to main content

pubhubs/common/
dsa.rs

1//! Hybrid post-quantum digital signature
2//! combining ed25519 and ML-DSA-65, for signing JWTs ([`crate::misc::jwt`]) and
3//! [`Signed`](crate::api::Signed) messages.
4//!
5//! A signature consists of *both* an ML-DSA-65 and an ed25519 signature, and verification requires
6//! *both* to be valid.
7//!
8//! # Relation to the standard, and the one deviation
9//!
10//! This tracks [`draft-ietf-jose-pq-composite-sigs-01`] (built on
11//! [`draft-ietf-lamps-pq-composite-sigs-19`]), which standardises this `ML-DSA-65-Ed25519`
12//! composite.  We follow it rather than an ad-hoc combiner so that, should Yivi adopt it for
13//! signing its JWTs in the future, conforming (see below) would let us verify Yivi's signatures
14//! directly.
15//!
16//! We replicate that combiner **except for one detail**: the standard signs the ML-DSA component
17//! with a non-empty context (`mldsa_ctx = LABEL`), but [`aws_lc_rs`]'s ML-DSA API (still `unstable`
18//! as of 1.17) exposes no context parameter, so we use the *empty* context (see `ML_DSA_CTX`).
19//! This makes our signatures **non-conformant** with the standard, and means the official test
20//! vectors (which use `mldsa_ctx=LABEL`) cannot be verified here yet.
21//!
22//! Everything else — `M'`, the SHA-512 prehash, the concatenation order, the key/signature byte
23//! formats, and the `AKP` JWK — is standard-correct.  When [`aws_lc_rs`] exposes an ML-DSA context
24//! parameter (its C core already supports it), conforming is small and local:
25//! 1. set `ML_DSA_CTX` to `LABEL` and thread it into `ml_dsa_sign`/`ml_dsa_verify`;
26//! 2. rename [`ALG`] to `"ML-DSA-65-Ed25519"`;
27//! 3. add the official `MLDSA65-Ed25519` test-vector check.
28//!
29//! [`draft-ietf-jose-pq-composite-sigs-01`]: https://datatracker.ietf.org/doc/html/draft-ietf-jose-pq-composite-sigs-01
30//! [`draft-ietf-lamps-pq-composite-sigs-19`]: https://datatracker.ietf.org/doc/html/draft-ietf-lamps-pq-composite-sigs-19
31
32use aws_lc_rs::signature::{KeyPair as _, ParsedPublicKey};
33use aws_lc_rs::unstable::signature::{ML_DSA_65, ML_DSA_65_SIGNING, PqdsaKeyPair};
34use base64ct::{Base64UrlUnpadded, Encoding as _};
35use sha2::{Digest as _, Sha256, Sha512};
36
37use crate::misc::error::{OPAQUE, Opaque};
38use crate::misc::jwt;
39use crate::misc::serde_ext::bytes_wrapper::B64;
40
41/// Value for the `alg` JWT header.  Interim, non-conformant name; the standard reserves
42/// `"ML-DSA-65-Ed25519"` for the conformant variant (see module docs).
43pub const ALG: &str = "ph-ML-DSA-65-Ed25519";
44
45/// `Prefix` from the composite-signatures combiner — the fixed domain-separation string defined in
46/// [draft-ietf-lamps-pq-composite-sigs-19 §2.2 "Prefix, Label, and CTX"](https://www.ietf.org/archive/id/draft-ietf-lamps-pq-composite-sigs-19.html#name-prefix-label-and-ctx).
47const PREFIX: &[u8] = b"CompositeAlgorithmSignatures2025";
48
49/// `Label` for the `ML-DSA-65-Ed25519` combination, from
50/// [draft-ietf-lamps-pq-composite-sigs-19 §6 "Algorithm Identifiers and Parameters"](https://www.ietf.org/archive/id/draft-ietf-lamps-pq-composite-sigs-19.html#name-algorithm-identifiers-and-p).
51/// It is the per-algorithm domain separator in the message representative
52/// `M' = PREFIX ‖ LABEL ‖ … ‖ SHA-512(message)` built by [`message_representative`] — and, in the
53/// conformant variant, the ML-DSA context (see [`ML_DSA_CTX`]).
54const LABEL: &[u8] = b"COMPSIG-MLDSA65-Ed25519-SHA512";
55
56/// The composite *application* context `ctx`, encoded in `M'` as `len(ctx) ‖ ctx` — empty for our
57/// JWTs.  Distinct from [`ML_DSA_CTX`], the context of the underlying ML-DSA primitive.
58const CTX: &[u8] = b"";
59
60/// `len(ctx)` occupies a single byte of `M'`, so the standard caps `ctx` at 255 bytes.
61const _: () = assert!(CTX.len() <= 255);
62
63/// Context passed to the underlying ML-DSA primitive.
64///
65/// **This is the sole deviation from the standard**, which uses [`LABEL`] here.  [`aws_lc_rs`]'s
66/// ML-DSA API currently exposes no context parameter, so we use the empty context.  Setting this to
67/// [`LABEL`] (once a context-capable backend exists) is what makes signatures standard-conformant.
68const ML_DSA_CTX: &[u8] = b"";
69
70/// Compile-time guard for the deviation above: [`ML_DSA_CTX`] must stay empty, because
71/// [`ml_dsa_sign`]/[`ml_dsa_verify`] cannot pass a context to [`aws_lc_rs`].  Setting it to [`LABEL`]
72/// for conformance must go together with threading the context through those functions — this
73/// assertion fails the build until then.
74const _: () = assert!(ML_DSA_CTX.is_empty());
75
76/// ed25519 signature length, in bytes.
77const ED25519_SIG_LEN: usize = ed25519_dalek::SIGNATURE_LENGTH;
78
79/// Length of the message representative `M'` (see [`message_representative`]): `PREFIX ‖ LABEL ‖
80/// len(ctx) ‖ ctx ‖ SHA-512(..)`, a fixed size (the `+ 1` is the `len(ctx)` byte).
81const M_PRIME_LEN: usize = PREFIX.len() + LABEL.len() + 1 + CTX.len() + 64;
82
83/// Hybrid signing key, the 'private key'.  Generate using [`SigningKey::generate`].
84#[derive(Debug)]
85pub struct SigningKey {
86    ed: ed25519_dalek::SigningKey,
87    ml: PqdsaKeyPair,
88
89    /// The 32-byte ML-DSA seed.  Kept because [`PqdsaKeyPair`] does not retain it, yet we need it to
90    /// [`encode`](SigningKey::encode) the key compactly.
91    ml_seed: zeroize::Zeroizing<[u8; 32]>,
92
93    /// Precomputed [`VerifyingKey`].  Constructing one parses the ML-DSA public key, so we do that
94    /// once here and hand out shared references via [`verifying_key`](Self::verifying_key).
95    vk: VerifyingKey,
96}
97
98/// Hybrid verifying key, the 'public key'.  Obtain via [`SigningKey::verifying_key`].
99#[derive(Clone, Debug)]
100pub struct VerifyingKey {
101    ed: ed25519_dalek::VerifyingKey,
102
103    /// Pre-parsed ML-DSA public key, so verification avoids re-parsing the ~1952-byte key each time.
104    ml: ParsedPublicKey,
105}
106
107/// Encoded form of [`SigningKey`], for storage.
108#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, zeroize::ZeroizeOnDrop)]
109pub struct SigningKeyBytes {
110    /// ed25519 signing-key bytes (32).
111    ed: B64,
112
113    /// ML-DSA seed bytes (32); see [`SigningKey::ml_seed`].
114    ml: B64,
115}
116
117/// Encoded form of [`VerifyingKey`], for the wire.
118#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
119pub struct VerifyingKeyBytes {
120    /// ed25519 verifying-key bytes (32).
121    pub ed: B64,
122
123    /// ML-DSA-65 public-key octets (~1952).
124    pub ml: B64,
125}
126
127impl SigningKey {
128    /// Generates a [`SigningKey`].  Expensive.
129    pub fn generate() -> Result<Self, Opaque> {
130        Self::from_parts(
131            crate::misc::crypto::random_32_bytes(),
132            crate::misc::crypto::random_32_bytes(),
133        )
134    }
135
136    /// Returns the associated [`VerifyingKey`].  Cheap: it is precomputed at construction, and we
137    /// return a shared reference rather than cloning — cloning an ML-DSA key calls `EVP_PKEY_up_ref`
138    /// under a global lock, whereas verifying through `&` is lock-free and safe to share across
139    /// threads.
140    pub fn verifying_key(&self) -> &VerifyingKey {
141        &self.vk
142    }
143
144    /// Encodes for storage.  Cheap.
145    pub fn encode(&self) -> SigningKeyBytes {
146        SigningKeyBytes {
147            ed: B64::from_bytes(self.ed.to_bytes()),
148            ml: B64::from_bytes(*self.ml_seed),
149        }
150    }
151
152    /// The ed25519 component, for producing a *classical* EdDSA signature (`alg: "EdDSA"`).
153    ///
154    /// Used only for the backwards-compatible HHPP that pre-hybrid hubs verify with the
155    /// constellation's `phc_jwt_key` (= [`VerifyingKey::ed25519_bytes`]).  This signs the raw JWS
156    /// input.
157    pub fn ed25519_signing_key(&self) -> &ed25519_dalek::SigningKey {
158        &self.ed
159    }
160
161    /// Assembles a [`SigningKey`] from the two 32-byte seeds — the ed25519 secret key and the ML-DSA
162    /// seed — deriving both component keys (so neither call site repeats that) and precomputing the
163    /// [`VerifyingKey`] (parsing the ML-DSA public key once here, not on every
164    /// [`verifying_key`](Self::verifying_key)).
165    fn from_parts(ed_seed: [u8; 32], ml_seed: [u8; 32]) -> Result<Self, Opaque> {
166        let ed = ed25519_dalek::SigningKey::from_bytes(&ed_seed);
167        let ml = PqdsaKeyPair::from_seed(&ML_DSA_65_SIGNING, &ml_seed).map_err(|_| OPAQUE)?;
168        let vk = VerifyingKey {
169            ed: ed.verifying_key(),
170            ml: ParsedPublicKey::new(&ML_DSA_65, ml.public_key().as_ref()).map_err(|_| OPAQUE)?,
171        };
172        Ok(Self {
173            ed,
174            ml,
175            ml_seed: zeroize::Zeroizing::new(ml_seed),
176            vk,
177        })
178    }
179}
180
181impl SigningKeyBytes {
182    /// Decodes into a [`SigningKey`].  Expensive.
183    pub fn decode(&self) -> Result<SigningKey, Opaque> {
184        let ed_seed: [u8; 32] = (&self.ed[..]).try_into()?;
185        let ml_seed: [u8; 32] = (&self.ml[..]).try_into()?;
186        SigningKey::from_parts(ed_seed, ml_seed)
187    }
188}
189
190impl VerifyingKey {
191    /// Encodes for the wire.  Cheap.
192    pub fn encode(&self) -> VerifyingKeyBytes {
193        VerifyingKeyBytes {
194            ed: B64::from_bytes(self.ed.to_bytes()),
195            ml: B64::from_bytes(self.ml.as_ref()),
196        }
197    }
198
199    /// The ed25519 component's 32 public-key bytes — published as the constellation's `phc_jwt_key`
200    /// so pre-hybrid hubs can verify the classical EdDSA HHPP.
201    pub fn ed25519_bytes(&self) -> [u8; 32] {
202        self.ed.to_bytes()
203    }
204}
205
206impl VerifyingKeyBytes {
207    /// Decodes into a [`VerifyingKey`].
208    pub fn decode(&self) -> Result<VerifyingKey, Opaque> {
209        let ed_bytes: [u8; 32] = (&self.ed[..]).try_into()?;
210        Ok(VerifyingKey {
211            ed: ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes)?,
212            ml: ParsedPublicKey::new(&ML_DSA_65, &self.ml[..]).map_err(|_| OPAQUE)?,
213        })
214    }
215}
216
217/// [`ParsedPublicKey`] does not implement [`PartialEq`], so we compare the underlying public-key
218/// octets (which fully determine the key).
219impl PartialEq for VerifyingKey {
220    fn eq(&self, other: &Self) -> bool {
221        self.ed == other.ed && self.ml.as_ref() == other.ml.as_ref()
222    }
223}
224
225impl Eq for VerifyingKey {}
226
227impl jwt::Key for SigningKey {
228    const ALG: &'static str = ALG;
229}
230
231impl jwt::Key for VerifyingKey {
232    const ALG: &'static str = ALG;
233}
234
235impl jwt::SigningKey for SigningKey {
236    type Signature = Vec<u8>;
237
238    fn sign(&self, message: &[u8]) -> anyhow::Result<Vec<u8>> {
239        let m_prime = message_representative(message);
240
241        // signature = ML-DSA-65 sig ‖ ed25519 sig, written into one buffer to avoid a realloc.
242        let ml_sig_len = ML_DSA_65_SIGNING.signature_len();
243        let mut signature = vec![0u8; ml_sig_len + ED25519_SIG_LEN];
244        ml_dsa_sign(&self.ml, &m_prime, &mut signature[..ml_sig_len])
245            .map_err(|_| anyhow::anyhow!("ML-DSA signing failed"))?;
246        signature[ml_sig_len..]
247            .copy_from_slice(&ed25519_dalek::Signer::sign(&self.ed, &m_prime).to_bytes());
248        Ok(signature)
249    }
250
251    fn jwk(&self) -> serde_json::Value {
252        // AKP ("Algorithm Key Pair") JWK; the composite public key is ML-DSA-65 pk ‖ ed25519 pk.
253        let mut pk = self.ml.public_key().as_ref().to_vec();
254        pk.extend_from_slice(self.ed.verifying_key().as_bytes());
255
256        serde_json::json!({
257            "kty": "AKP",
258            "alg": ALG,
259            "pub": Base64UrlUnpadded::encode_string(&pk),
260            "kid": jwk_thumbprint(ALG, &pk),
261            "use": "sig",
262        })
263    }
264}
265
266impl jwt::VerifyingKey for VerifyingKey {
267    fn is_valid_signature(&self, message: &[u8], signature: Vec<u8>) -> bool {
268        let ml_sig_len = ML_DSA_65_SIGNING.signature_len();
269        if signature.len() != ml_sig_len + ED25519_SIG_LEN {
270            return false;
271        }
272        let (ml_sig, ed_sig) = signature.split_at(ml_sig_len);
273
274        let m_prime = message_representative(message);
275
276        // both components must verify
277        if !ml_dsa_verify(&self.ml, &m_prime, ml_sig) {
278            return false;
279        }
280        let Ok(ed_sig) = ed25519_dalek::Signature::from_slice(ed_sig) else {
281            return false;
282        };
283        ed25519_dalek::Verifier::verify(&self.ed, &m_prime, &ed_sig).is_ok()
284    }
285
286    fn describe(&self) -> String {
287        // Identify the key by its RFC 7638 JWK thumbprint (the prescribed method, and the `kid`)
288        // rather than hex-dumping the ~1952-byte ML-DSA public key into a log line.
289        let mut pubkey = self.ml.as_ref().to_vec();
290        pubkey.extend_from_slice(self.ed.as_bytes());
291        format!("{ALG} key #{}", jwk_thumbprint(ALG, &pubkey))
292    }
293}
294
295/// [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK thumbprint of the AKP public key:
296/// `base64url(SHA-256(canonical JSON of the required members alg, kty, pub, in lexicographic
297/// order))`.  `pub_bytes` is the composite public key `ML-DSA-65 pk ‖ ed25519 pk` (matching the JWK
298/// `pub` member); all member values are ASCII, so the canonical JSON needs no escaping.  This value
299/// is the JWK `kid`.  AKP key type and required members per
300/// [RFC 9964 §6](https://www.rfc-editor.org/rfc/rfc9964.html#section-6).
301fn jwk_thumbprint(alg: &str, pub_bytes: &[u8]) -> String {
302    let canonical = format!(
303        r#"{{"alg":"{alg}","kty":"AKP","pub":"{}"}}"#,
304        Base64UrlUnpadded::encode_string(pub_bytes)
305    );
306    Base64UrlUnpadded::encode_string(Sha256::digest(canonical.as_bytes()).as_slice())
307}
308
309/// The composite message representative `M' = PREFIX ‖ LABEL ‖ len(ctx) ‖ ctx ‖ SHA-512(message)`.
310/// `M'` is not a hash we take but the *message* both components sign (each hashes it internally), so
311/// it must be passed whole.  The application context is empty, so `len(ctx) ‖ ctx` is a single
312/// `0x00`, making `M'` a fixed [`M_PRIME_LEN`] bytes.  See
313/// [draft-ietf-lamps-pq-composite-sigs-19 §2.2](https://www.ietf.org/archive/id/draft-ietf-lamps-pq-composite-sigs-19.html#name-prefix-label-and-ctx).
314fn message_representative(message: &[u8]) -> [u8; M_PRIME_LEN] {
315    let mut m_prime = [0u8; M_PRIME_LEN];
316    let mut at = 0;
317    m_prime[at..at + PREFIX.len()].copy_from_slice(PREFIX);
318    at += PREFIX.len();
319    m_prime[at..at + LABEL.len()].copy_from_slice(LABEL);
320    at += LABEL.len();
321    m_prime[at] = CTX.len() as u8; // len(ctx)
322    at += 1;
323    m_prime[at..at + CTX.len()].copy_from_slice(CTX);
324    at += CTX.len();
325    m_prime[at..].copy_from_slice(Sha512::digest(message).as_slice());
326    m_prime
327}
328
329/// Signs `m_prime` with ML-DSA-65 into `out`, which must hold at least
330/// [`ML_DSA_65_SIGNING`]`.signature_len()` bytes.
331///
332/// STANDARD-DEVIATION: the standard signs with `mldsa_ctx = LABEL`, but [`aws_lc_rs`] exposes no
333/// ML-DSA context, so the empty [`ML_DSA_CTX`] is used (guarded by its compile-time assertion).  See
334/// the module docs for the conformance path.
335fn ml_dsa_sign(keypair: &PqdsaKeyPair, m_prime: &[u8], out: &mut [u8]) -> Result<(), Opaque> {
336    // ML-DSA relies on an RNG that can fail.
337    keypair.sign(m_prime, out).map_err(|_| OPAQUE)?;
338    Ok(())
339}
340
341/// Verifies an ML-DSA-65 `signature` on `m_prime`.  See [`ml_dsa_sign`] for the context caveat.
342fn ml_dsa_verify(public_key: &ParsedPublicKey, m_prime: &[u8], signature: &[u8]) -> bool {
343    public_key.verify_sig(m_prime, signature).is_ok()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::misc::jwt::{self, Claims, SigningKey as _, VerifyingKey as _};
350    use base64ct::Base64UrlUnpadded;
351
352    /// Our [`jwk_thumbprint`] reproduces the published `kid` of the ML-DSA-44 `AKP` JWK in
353    /// [RFC 9964 Appendix A.1](https://www.rfc-editor.org/rfc/rfc9964.html), pinning our
354    /// canonicalization to the standard.  (We can only borrow the *thumbprint* vector: there is no
355    /// vector for our interim composite signature, which deviates on the ML-DSA context.)
356    #[test]
357    fn jwk_thumbprint_matches_rfc9964() {
358        const PUB: &str = "unH59k4RuutY-pxvu24U5h8YZD2rSVtHU5qRZsoBmBMcRPgmu9VuNOVdteXi1zNIXjnqJg_GAAxepLqA00Vc3lO0bzRIKu39VFD8Lhuk8l0V-cFEJC-zm7UihxiQMMUEmOFxe3x1ixkKZ0jqmqP3rKryx8tSbtcXyfea64QhT6XNje2SoMP6FViBDxLHBQo2dwjRls0k5a-XSQSu2OTOiHLoaWsLe8pQ5FLNfTDqmkrawDEdZyxr3oSWJAsHQxRjcIiVzZuvwxYy1zl2STiP2vy_fTBaPemkleynQzqPg7oPCyXEE8bjnJbrfWkbNNN8438e6tHPIX4l7zTuzz98YPhLjt_d6EBdT4MldsYe-Y4KLyjaGHcAlTkk9oa5RhRwW89T0z_t1DSO3dvfKLUGXh8gd1BD6Fz5MfgpF5NjoafnQEqDjsAAhrCXY4b-Y3yYJEdX4_dp3dRGdHG_rWcPmgX4JG7lCnser4f8QGnDriqiAzJYEXeS8LzUngg_0bx0lqv_KcyU5IaLISFO0xZSU5mmEPvdSoDnyAcV8pV44qhLtAvd29n0ehG259oRihtljTWeiu9V60a1N2tbZVl5mEqSK-6_xZvNYA1TCdzNctvweH24unV7U3wer9XA9Q6kvJWDVJ4oKaQsKMrCSMlteBJMRxWbGK7ddUq6F7GdQw-3j2M-qdJvVKm9UPjY9rc1lPgol25-oJxTu7nxGlbJUH-4m5pevAN6NyZ6lfhbjWTKlxkrEKZvQXs_Yf6cpXEwpI_ZJeriq1UC1XHIpRkDwdOY9MH3an4RdDl2r9vGl_IwlKPNdh_5aF3jLgn7PCit1FNJAwC8fIncAXgAlgcXIpRXdfJk4bBiO89GGccSyDh2EgXYdpG3XvNgGWy7npuSoNTE7WIyblAk13UQuO4sdCbMIuriCdyfE73mvwj15xgb07RZRQtFGlFTmnFcIdZ90zDrWXDbANntv7KCKwNvoTuv64bY3HiGbj-NQ-U9eMylWVpvr4hrXcES8c9K3PqHWADZC0iIOvlzFv4VBoc_wVflcOrL_SIoaNFCNBAZZq-2v5lAgpJTqVOtqJ_HVraoSfcKy5g45p-qULunXj6Jwq21fobQiKubBKKOZwcJFyJD7F4ACKXOrz-HIvSHMCWW_9dVrRuCpJw0s0aVFbRqopDNhu446nqb4_EDYQM1tTHMozPd_jKxRRD0sH75X8ZoToxFSpLBDbtdWcenxj-zBf6IGWfZnmaetjKEBYJWC7QDQx1A91pJVJCEgieCkoIfTqkeQuePpIyu48g2FG3P1zjRF-kumhUTfSjo5qS0YiZQy0E1BMs6M11EvuxXRsHClLHoy5nLYI2Sj4zjVjYyxSHyPRPGGo9hwB34yWxzYNtPPGiqXS_dNCpi_zRZwRY4lCGrQ-hYTEWIK1Dm5OlttvC4_eiQ1dv63NiGkLRJ5kJA3bICN0fzCDY-MBqnd1cWn8YVBijVkgtaoascjL9EywDgJdeHnXK0eeOvUxHHhXJVkNqcibn8O4RQdpVU60TSA-uiu675ytIjcBHC6kTv8A8pmkj_4oypPd-F92YIJC741swkYQoeIHj8rE-ThcMUkF7KqC5VORbZTRp8HsZSqgiJcIPaouuxd1-8Rxrid3fXkE6p8bkrysPYoxWEJgh7ZFsRCPDWX-yTeJwFN0PKFP1j0F6YtlLfK5wv-c4F8ZQHA_-yc_gODicy7KmWDZgbTP07e7gEWzw4MFRrndjbDQ";
359        const KID: &str = "T4xl70S7MT6Zeq6r9V9fPJGVn76wfnXJ21-gyo0Gu6o";
360
361        let pub_bytes = Base64UrlUnpadded::decode_vec(PUB).unwrap();
362        assert_eq!(jwk_thumbprint("ML-DSA-44", &pub_bytes), KID);
363    }
364
365    /// The `kid` is the thumbprint of the composite public key, which `jwk()` (sign side, built from
366    /// the keypair via `self.ml.public_key()`) and `describe()` (verify side, built from the parsed
367    /// `self.ml`) assemble independently.  Pin that the two agree, so a future change to one
368    /// derivation path — e.g. the conformance migration that renames `ALG` and reorders the
369    /// concatenation — that isn't mirrored in the other is caught here rather than silently breaking
370    /// kid-based key lookup across signer and verifier.
371    #[test]
372    fn jwk_kid_matches_describe() {
373        let sk = SigningKey::generate().unwrap();
374        let vk = sk.verifying_key();
375
376        let jwk_kid = sk.jwk()["kid"].as_str().unwrap().to_string();
377        // `describe()` is "<ALG> key #<kid>"
378        let describe_kid = vk.describe().rsplit_once('#').unwrap().1.to_string();
379
380        assert_eq!(jwk_kid, describe_kid);
381    }
382
383    #[test]
384    fn sign_verify_and_tamper() {
385        let sk = SigningKey::generate().unwrap();
386        let vk = sk.verifying_key();
387        let message = b"the message to be signed";
388
389        let signature = sk.sign(message).unwrap();
390        assert!(vk.is_valid_signature(message, signature.clone()));
391        // a different message does not verify
392        assert!(!vk.is_valid_signature(b"other message", signature.clone()));
393
394        // flipping a bit in the ML-DSA half (front) breaks verification ...
395        let mut ml_tampered = signature.clone();
396        ml_tampered[0] ^= 1;
397        assert!(!vk.is_valid_signature(message, ml_tampered));
398
399        // ... and so does flipping a bit in the ed25519 half (back): both halves are required.
400        let mut ed_tampered = signature;
401        *ed_tampered.last_mut().unwrap() ^= 1;
402        assert!(!vk.is_valid_signature(message, ed_tampered));
403    }
404
405    #[test]
406    fn malformed_signature_rejected() {
407        let sk = SigningKey::generate().unwrap();
408        let vk = sk.verifying_key();
409        let message = b"msg";
410        let valid = sk.sign(message).unwrap();
411
412        // empty, too short, and too long are all rejected without panicking
413        assert!(!vk.is_valid_signature(message, vec![]));
414        assert!(!vk.is_valid_signature(message, valid[..valid.len() - 1].to_vec()));
415        let mut too_long = valid.clone();
416        too_long.push(0);
417        assert!(!vk.is_valid_signature(message, too_long));
418    }
419
420    #[test]
421    fn jwt_roundtrip() {
422        let sk = SigningKey::generate().unwrap();
423        let vk = sk.verifying_key();
424
425        let token = Claims::new()
426            .claim("foo", "bar")
427            .unwrap()
428            .sign(&sk)
429            .unwrap();
430        let mut claims = token.open(vk).unwrap();
431        assert_eq!(
432            claims.extract::<String>("foo").unwrap(),
433            Some("bar".to_string())
434        );
435
436        // a JWT signed under a different `alg` is rejected by check_alg
437        let hs_token = Claims::new().sign(&jwt::HS256(vec![0u8; 32])).unwrap();
438        assert!(matches!(
439            hs_token.open(vk),
440            Err(jwt::Error::UnexpectedAlgorithm { .. })
441        ));
442    }
443
444    #[test]
445    fn signed_roundtrip() {
446        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq, Debug)]
447        struct TestMsg {
448            hello: String,
449        }
450        crate::api::having_message_code! { TestMsg, Example }
451
452        let sk = SigningKey::generate().unwrap();
453        let vk = sk.verifying_key();
454        let message = TestMsg {
455            hello: "world".to_string(),
456        };
457
458        let signed =
459            crate::api::Signed::<TestMsg>::new(&sk, &message, std::time::Duration::from_secs(60))
460                .unwrap();
461        assert_eq!(signed.open(vk, None).unwrap(), message);
462    }
463
464    #[test]
465    fn encode_decode_roundtrip() {
466        let sk = SigningKey::generate().unwrap();
467        let vk = sk.verifying_key();
468
469        // SigningKeyBytes through serde, then decode → same verifying key (seeds are deterministic).
470        let skb: SigningKeyBytes =
471            serde_json::from_str(&serde_json::to_string(&sk.encode()).unwrap()).unwrap();
472        let sk2 = skb.decode().unwrap();
473        assert_eq!(sk2.verifying_key(), vk);
474
475        // VerifyingKeyBytes through serde, then decode → same verifying key.
476        let vkb: VerifyingKeyBytes =
477            serde_json::from_str(&serde_json::to_string(&vk.encode()).unwrap()).unwrap();
478        assert_eq!(vkb, vk.encode());
479        let vk2 = vkb.decode().unwrap();
480        assert_eq!(&vk2, vk);
481    }
482
483    /// Emits a bespoke (`ph-ML-DSA-65-Ed25519`, empty ML-DSA context) test vector — a real compact
484    /// JWS plus the verifying key — for the Python hub's cross-implementation verification test
485    /// (`pubhubs_hub/test/hhpp_test.py`).  No official vector exists for
486    /// our interim empty-context variant, so we generate one here.  ML-DSA signing is randomised, so
487    /// the signature is not reproducible, but the fixed seeds make the verifying key deterministic
488    /// and any valid signature verifies.  Ignored by default; regenerate with:
489    ///   cargo test --lib dsa::tests::emit_bespoke_test_vector -- --ignored --nocapture
490    #[test]
491    #[ignore = "regenerates the Python hub fixture; see the doc comment"]
492    fn emit_bespoke_test_vector() {
493        let sk = SigningKey::from_parts([0x11u8; 32], [0x22u8; 32]).unwrap();
494        let vk = sk.verifying_key();
495
496        let jws: String = Claims::new()
497            .claim("msg", "bespoke composite test vector")
498            .unwrap()
499            .sign(&sk)
500            .unwrap()
501            .into();
502
503        let vector = serde_json::json!({
504            "alg": ALG,
505            "verifying_key": serde_json::to_value(vk.encode()).unwrap(),
506            "jws": jws,
507        });
508        println!(
509            "BESPOKE_VECTOR_BEGIN\n{}\nBESPOKE_VECTOR_END",
510            serde_json::to_string_pretty(&vector).unwrap()
511        );
512    }
513}