Skip to main content

huddle_protocol/crypto/
mldsa.rs

1//! huddle 2.0.6 (WS2-a): ML-DSA-65 (FIPS 204) signatures for **hybrid
2//! post-quantum authentication**.
3//!
4//! Identity/authority envelopes are signed classically with Ed25519 *and*
5//! (when the composite path is used) with ML-DSA-65, so a forgery requires
6//! breaking BOTH — a quantum adversary that breaks Ed25519 still can't forge.
7//! The ML-DSA keypair is **deterministically derived from the same 32-byte
8//! Ed25519 identity seed** (via an HKDF with a distinct domain label), so every
9//! identity gains a PQ signing key for free with no new on-disk material —
10//! exactly the model `pqc` (ML-KEM) uses for confidentiality.
11//!
12//! ML-DSA signatures are large (3309 bytes) and public keys are 1952 bytes, so
13//! the composite is applied to **low-frequency identity/authority messages**
14//! (announces, invites, owner/ban grants), never to every chat line.
15
16use hkdf::Hkdf;
17use ml_dsa::{
18    EncodedSignature, EncodedVerifyingKey, Keypair, MlDsa65, Signature, Signer, SigningKey,
19    VerifyingKey, B32,
20};
21use sha2::Sha256;
22use zeroize::Zeroizing;
23
24/// Serialized length of an ML-DSA-65 verifying (public) key.
25pub const MLDSA_PK_LEN: usize = 1952;
26/// Serialized length of an ML-DSA-65 signature.
27pub const MLDSA_SIG_LEN: usize = 3309;
28
29/// HKDF label expanding an identity's Ed25519 seed into the 32-byte ML-DSA seed.
30/// Distinct from the ML-KEM and DM labels so the PQ-auth key is cryptographically
31/// independent of every other key derived from the same root seed.
32const MLDSA_SEED_LABEL: &[u8] = b"huddle-mldsa-65-seed-v1";
33
34/// A deterministically-derived ML-DSA-65 keypair bound to a huddle identity.
35pub struct MlDsaKeypair {
36    sk: SigningKey<MlDsa65>,
37}
38
39impl MlDsaKeypair {
40    /// Derive the identity's ML-DSA-65 keypair from its 32-byte Ed25519 secret
41    /// seed. Deterministic + domain-separated; reproduces the same keypair on
42    /// every call with zero extra storage.
43    pub fn from_identity_seed(ed25519_seed: &[u8; 32]) -> Self {
44        let mut seed = Zeroizing::new([0u8; 32]);
45        let hk = Hkdf::<Sha256>::new(Some(MLDSA_SEED_LABEL), ed25519_seed);
46        hk.expand(b"", seed.as_mut_slice())
47            .expect("HKDF expand to 32 bytes is within SHA-256's output limit");
48        let seed_arr: B32 = (*seed).into();
49        let sk = SigningKey::<MlDsa65>::from_seed(&seed_arr);
50        Self { sk }
51    }
52
53    /// The serialized ML-DSA-65 verifying (public) key, to publish + pin.
54    pub fn public_bytes(&self) -> [u8; MLDSA_PK_LEN] {
55        let enc: EncodedVerifyingKey<MlDsa65> = self.sk.verifying_key().encode();
56        let mut out = [0u8; MLDSA_PK_LEN];
57        out.copy_from_slice(enc.as_slice());
58        out
59    }
60
61    /// Sign `msg` (FIPS 204, empty context — the message bytes already carry
62    /// huddle's domain-separation tag).
63    pub fn sign(&self, msg: &[u8]) -> [u8; MLDSA_SIG_LEN] {
64        let sig: Signature<MlDsa65> = self.sk.sign(msg);
65        let enc: EncodedSignature<MlDsa65> = sig.encode();
66        let mut out = [0u8; MLDSA_SIG_LEN];
67        out.copy_from_slice(enc.as_slice());
68        out
69    }
70}
71
72/// Verify an ML-DSA-65 signature over `msg` against a serialized verifying key.
73/// Returns `false` on any malformed input or signature mismatch.
74pub fn verify(pubkey_bytes: &[u8], msg: &[u8], sig_bytes: &[u8]) -> bool {
75    let pk_enc = match EncodedVerifyingKey::<MlDsa65>::try_from(pubkey_bytes) {
76        Ok(a) => a,
77        Err(_) => return false,
78    };
79    let vk = VerifyingKey::<MlDsa65>::decode(&pk_enc);
80    let sig_enc = match EncodedSignature::<MlDsa65>::try_from(sig_bytes) {
81        Ok(a) => a,
82        Err(_) => return false,
83    };
84    let sig = match Signature::<MlDsa65>::decode(&sig_enc) {
85        Some(s) => s,
86        None => return false,
87    };
88    vk.verify_with_context(msg, &[], &sig)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn deterministic_keypair_and_sizes() {
97        let a = MlDsaKeypair::from_identity_seed(&[7u8; 32]);
98        let b = MlDsaKeypair::from_identity_seed(&[7u8; 32]);
99        assert_eq!(a.public_bytes(), b.public_bytes());
100        assert_eq!(a.public_bytes().len(), MLDSA_PK_LEN);
101        let c = MlDsaKeypair::from_identity_seed(&[8u8; 32]);
102        assert_ne!(a.public_bytes(), c.public_bytes());
103    }
104
105    #[test]
106    fn sign_verify_round_trip() {
107        let kp = MlDsaKeypair::from_identity_seed(&[1u8; 32]);
108        let pk = kp.public_bytes();
109        let sig = kp.sign(b"authority message");
110        assert_eq!(sig.len(), MLDSA_SIG_LEN);
111        assert!(verify(&pk, b"authority message", &sig));
112        // Wrong message, tampered signature, and wrong key all fail.
113        assert!(!verify(&pk, b"different message", &sig));
114        let mut bad = sig;
115        bad[0] ^= 1;
116        assert!(!verify(&pk, b"authority message", &bad));
117        let other = MlDsaKeypair::from_identity_seed(&[2u8; 32]).public_bytes();
118        assert!(!verify(&other, b"authority message", &sig));
119    }
120
121    #[test]
122    fn malformed_inputs_are_rejected() {
123        let kp = MlDsaKeypair::from_identity_seed(&[3u8; 32]);
124        let sig = kp.sign(b"m");
125        assert!(!verify(b"too short", b"m", &sig));
126        assert!(!verify(&kp.public_bytes(), b"m", b"short sig"));
127    }
128}