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