Skip to main content

sui_crypto/
mldsa65.rs

1//! ML-DSA-65 (FIPS 204) signing and verification.
2//!
3//! Pure ML-DSA-65 with an empty context over the 32-byte signing digest,
4//! hedged with 32 bytes of caller-supplied randomness.
5//!
6//! [`SuiRandomizedSigner`](crate::SuiRandomizedSigner) is the randomized
7//! counterpart of [`SuiSigner`](crate::SuiSigner).
8
9use crate::SignatureError;
10use mysten_mldsa_native_rs as mldsa;
11use signature::RandomizedSigner;
12use signature::Verifier;
13use sui_sdk_types::MlDsa65PublicKey;
14use sui_sdk_types::MlDsa65Signature;
15use sui_sdk_types::SignatureScheme;
16use sui_sdk_types::SimpleSignature;
17use sui_sdk_types::UserSignature;
18
19/// The 32-byte FIPS 204 seed plus the key expanded from it.
20///
21/// Only the seed is serialized (keystore, `suiprivkey`); the expanded key is
22/// kept so signing does not re-expand. Both zeroize on drop.
23pub struct MlDsa65PrivateKey {
24    seed: mldsa::SigningKeySeed,
25    signing_key: mldsa::SigningKey,
26    // Boxed to keep the enums carrying this type small.
27    verifying_key: Box<mldsa::VerifyingKey>,
28}
29
30impl Clone for MlDsa65PrivateKey {
31    fn clone(&self) -> Self {
32        Self::new(*self.seed.as_bytes())
33    }
34}
35
36impl std::fmt::Debug for MlDsa65PrivateKey {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_tuple("MlDsa65PrivateKey")
39            .field(&"__elided__")
40            .finish()
41    }
42}
43
44#[cfg(test)]
45impl proptest::arbitrary::Arbitrary for MlDsa65PrivateKey {
46    type Parameters = ();
47    type Strategy = proptest::strategy::BoxedStrategy<Self>;
48
49    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
50        use proptest::strategy::Strategy;
51
52        proptest::arbitrary::any::<[u8; Self::LENGTH]>()
53            .prop_map(Self::new)
54            .boxed()
55    }
56}
57
58impl MlDsa65PrivateKey {
59    /// The length of the seed in bytes.
60    pub const LENGTH: usize = mldsa::SEED_LENGTH;
61
62    pub fn new(seed: [u8; Self::LENGTH]) -> Self {
63        let seed = mldsa::SigningKeySeed::from(seed);
64        let (signing_key, verifying_key) = seed.expand();
65        Self {
66            seed,
67            signing_key,
68            verifying_key: Box::new(verifying_key),
69        }
70    }
71
72    pub fn scheme(&self) -> SignatureScheme {
73        SignatureScheme::MlDsa65
74    }
75
76    pub fn verifying_key(&self) -> MlDsa65VerifyingKey {
77        MlDsa65VerifyingKey {
78            key: self.verifying_key.clone(),
79        }
80    }
81
82    pub fn public_key(&self) -> MlDsa65PublicKey {
83        MlDsa65PublicKey::new(*self.verifying_key.as_bytes())
84    }
85
86    pub fn seed(&self) -> &[u8; Self::LENGTH] {
87        self.seed.as_bytes()
88    }
89
90    pub fn generate<R>(mut rng: R) -> Self
91    where
92        R: rand_core::RngCore + rand_core::CryptoRng,
93    {
94        let mut seed = [0; Self::LENGTH];
95        rng.fill_bytes(&mut seed);
96        Self::new(seed)
97    }
98
99    pub(crate) fn from_flagged_key_bytes(
100        scheme: SignatureScheme,
101        key: Vec<u8>,
102    ) -> Result<Self, SignatureError> {
103        if scheme != SignatureScheme::MlDsa65 {
104            return Err(SignatureError::from_source(format!(
105                "private key scheme flag is `{}`, expected `mldsa65`",
106                scheme.name(),
107            )));
108        }
109        let bytes: [u8; Self::LENGTH] = key.try_into().map_err(|_: Vec<u8>| {
110            SignatureError::from_source("private key has invalid length for mldsa65")
111        })?;
112        Ok(Self::new(bytes))
113    }
114
115    #[cfg(feature = "bech32")]
116    #[cfg_attr(doc_cfg, doc(cfg(feature = "bech32")))]
117    /// Decode a Bech32 `suiprivkey` string produced by the Sui CLI; the
118    /// payload is `0x07 || seed`.
119    pub fn from_suiprivkey(s: &str) -> Result<Self, SignatureError> {
120        let (scheme, key) = crate::suipriv::decode(s)?;
121        Self::from_flagged_key_bytes(scheme, key)
122    }
123
124    #[cfg(feature = "bech32")]
125    #[cfg_attr(doc_cfg, doc(cfg(feature = "bech32")))]
126    /// Encode this private key as a Bech32 `suiprivkey` string.
127    pub fn to_suiprivkey(&self) -> Result<String, SignatureError> {
128        crate::suipriv::encode(SignatureScheme::MlDsa65, self.seed())
129    }
130
131    /// Decode a Base64 `flag || seed` string, the legacy keystore format used
132    /// for entries of the Sui CLI's `sui.keystore` file.
133    pub fn from_base64(s: &str) -> Result<Self, SignatureError> {
134        let (scheme, key) = crate::suipriv::decode_base64(s)?;
135        Self::from_flagged_key_bytes(scheme, key)
136    }
137
138    /// Encode this private key as a Base64 `flag || seed` string, the legacy
139    /// keystore format used for entries of the Sui CLI's `sui.keystore` file.
140    pub fn to_base64(&self) -> String {
141        crate::suipriv::encode_base64(SignatureScheme::MlDsa65, self.seed())
142    }
143}
144
145impl RandomizedSigner<MlDsa65Signature> for MlDsa65PrivateKey {
146    /// Hedged per FIPS 204: 32 bytes from `rng` enter the commitment, so
147    /// repeated signatures differ. A weak `rng` only degrades to the
148    /// deterministic variant's security.
149    fn try_sign_with_rng(
150        &self,
151        rng: &mut impl rand_core::CryptoRngCore,
152        msg: &[u8],
153    ) -> Result<MlDsa65Signature, SignatureError> {
154        let mut rnd = [0; mldsa::RND_LENGTH];
155        rng.try_fill_bytes(&mut rnd)
156            .map_err(SignatureError::from_source)?;
157        self.signing_key
158            .sign(msg, b"", &rnd)
159            .map(|signature| MlDsa65Signature::new(*signature.as_bytes()))
160            .map_err(SignatureError::from_source)
161    }
162}
163
164impl RandomizedSigner<SimpleSignature> for MlDsa65PrivateKey {
165    fn try_sign_with_rng(
166        &self,
167        rng: &mut impl rand_core::CryptoRngCore,
168        msg: &[u8],
169    ) -> Result<SimpleSignature, SignatureError> {
170        <Self as RandomizedSigner<MlDsa65Signature>>::try_sign_with_rng(self, rng, msg).map(
171            |signature| SimpleSignature::MlDsa65 {
172                signature: Box::new(signature),
173                public_key: Box::new(self.public_key()),
174            },
175        )
176    }
177}
178
179impl RandomizedSigner<UserSignature> for MlDsa65PrivateKey {
180    fn try_sign_with_rng(
181        &self,
182        rng: &mut impl rand_core::CryptoRngCore,
183        msg: &[u8],
184    ) -> Result<UserSignature, SignatureError> {
185        <Self as RandomizedSigner<SimpleSignature>>::try_sign_with_rng(self, rng, msg)
186            .map(UserSignature::Simple)
187    }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct MlDsa65VerifyingKey {
192    // Boxed to keep the enums carrying this type small.
193    key: Box<mldsa::VerifyingKey>,
194}
195
196impl MlDsa65VerifyingKey {
197    pub fn new(public_key: &MlDsa65PublicKey) -> Result<Self, SignatureError> {
198        mldsa::VerifyingKey::from_bytes(public_key.inner())
199            .map(|key| Self { key: Box::new(key) })
200            .map_err(SignatureError::from_source)
201    }
202
203    pub fn public_key(&self) -> MlDsa65PublicKey {
204        MlDsa65PublicKey::new(*self.key.as_bytes())
205    }
206}
207
208impl Verifier<MlDsa65Signature> for MlDsa65VerifyingKey {
209    fn verify(&self, message: &[u8], signature: &MlDsa65Signature) -> Result<(), SignatureError> {
210        let signature =
211            mldsa::Signature::from_bytes(signature.inner()).map_err(SignatureError::from_source)?;
212        self.key
213            .verify(message, b"", &signature)
214            .map_err(|_| SignatureError::new())
215    }
216}
217
218impl Verifier<SimpleSignature> for MlDsa65VerifyingKey {
219    fn verify(&self, message: &[u8], signature: &SimpleSignature) -> Result<(), SignatureError> {
220        let SimpleSignature::MlDsa65 {
221            signature,
222            public_key,
223        } = signature
224        else {
225            return Err(SignatureError::from_source("not an mldsa65 signature"));
226        };
227        if public_key.inner() != self.key.as_bytes() {
228            return Err(SignatureError::from_source("public_key mismatch"));
229        }
230        <Self as Verifier<MlDsa65Signature>>::verify(self, message, signature)
231    }
232}
233
234impl Verifier<UserSignature> for MlDsa65VerifyingKey {
235    fn verify(&self, message: &[u8], signature: &UserSignature) -> Result<(), SignatureError> {
236        let UserSignature::Simple(signature) = signature else {
237            return Err(SignatureError::from_source("not a simple signature"));
238        };
239        <Self as Verifier<SimpleSignature>>::verify(self, message, signature)
240    }
241}
242
243#[cfg(test)]
244mod test {
245    use super::*;
246    use crate::SuiRandomizedSigner;
247    use crate::SuiVerifier;
248    use crate::test_util::TestRng;
249    use sui_sdk_types::PersonalMessage;
250    use test_strategy::proptest;
251
252    #[cfg(target_arch = "wasm32")]
253    use wasm_bindgen_test::wasm_bindgen_test as test;
254
255    /// Produced by Sui's signer (fastcrypto-pq, wrapping mldsa-native) over the
256    /// personal message "hello": seed `[2; 32]`, hedging randomness `[7; 32]`.
257    const SUI_SIGNATURE: &str = include_str!("fixtures/mldsa65-personal-message-signature");
258    const SUI_ADDRESS: &str = "0xa44576e02f83a9e1bddac6fd742a77931d1689d9a61122eb3125dee425f6dd36";
259
260    fn sui_fixture() -> (MlDsa65Signature, MlDsa65PublicKey) {
261        let UserSignature::Simple(SimpleSignature::MlDsa65 {
262            signature,
263            public_key,
264        }) = UserSignature::from_base64(SUI_SIGNATURE.trim()).unwrap()
265        else {
266            panic!("expected an mldsa65 signature");
267        };
268        (*signature, *public_key)
269    }
270
271    /// An rng whose entropy source is unavailable.
272    struct FailingRng;
273
274    impl rand_core::RngCore for FailingRng {
275        fn next_u32(&mut self) -> u32 {
276            unimplemented!("only try_fill_bytes is expected to be called")
277        }
278
279        fn next_u64(&mut self) -> u64 {
280            unimplemented!("only try_fill_bytes is expected to be called")
281        }
282
283        fn fill_bytes(&mut self, _dst: &mut [u8]) {
284            unimplemented!("only try_fill_bytes is expected to be called")
285        }
286
287        fn try_fill_bytes(&mut self, _dst: &mut [u8]) -> Result<(), rand_core::Error> {
288            Err(rand_core::Error::new("entropy source unavailable"))
289        }
290    }
291
292    impl rand_core::CryptoRng for FailingRng {}
293
294    /// A signature from Sui's signer verifies here under the same address.
295    /// The reverse direction is covered by Sui's e2e tests.
296    #[test]
297    fn verifies_sui_signature() {
298        let (sui_signature, sui_public_key) = sui_fixture();
299        let key = MlDsa65PrivateKey::new([2; 32]);
300        assert_eq!(key.public_key(), sui_public_key);
301        assert_eq!(key.public_key().derive_address().to_string(), SUI_ADDRESS);
302
303        let digest = PersonalMessage(b"hello".as_slice().into()).signing_digest();
304        key.verifying_key().verify(&digest, &sui_signature).unwrap();
305    }
306
307    /// Same key and message, different rng: two distinct valid signatures.
308    #[test]
309    fn hedging_varies_signatures() {
310        let key = MlDsa65PrivateKey::new([3; 32]);
311        let digest = PersonalMessage(b"hello".as_slice().into()).signing_digest();
312        let first: MlDsa65Signature = key.try_sign_with_rng(&mut TestRng(1), &digest).unwrap();
313        let second: MlDsa65Signature = key.try_sign_with_rng(&mut TestRng(2), &digest).unwrap();
314
315        assert_ne!(first, second);
316        key.verifying_key().verify(&digest, &first).unwrap();
317        key.verifying_key().verify(&digest, &second).unwrap();
318    }
319
320    /// An rng failure surfaces as an error instead of a panic.
321    #[test]
322    fn rng_failure_is_an_error() {
323        let key = MlDsa65PrivateKey::new([3; 32]);
324        let err = RandomizedSigner::<MlDsa65Signature>::try_sign_with_rng(
325            &key,
326            &mut FailingRng,
327            b"hello",
328        )
329        .unwrap_err();
330        let source = std::error::Error::source(&err).expect("rng error is the source");
331        assert_eq!(source.to_string(), "entropy source unavailable");
332    }
333
334    #[test]
335    fn personal_message_roundtrip() {
336        let key = MlDsa65PrivateKey::new([2; 32]);
337        let message = PersonalMessage(b"hello".as_slice().into());
338        let signature = key
339            .sign_personal_message_with_rng(&mut TestRng(1), &message)
340            .unwrap();
341        assert_eq!(signature.derive_address().to_string(), SUI_ADDRESS);
342        key.verifying_key()
343            .verify_personal_message(&message, &signature)
344            .unwrap();
345        crate::simple::SimpleVerifier
346            .verify_personal_message(&message, &signature)
347            .unwrap();
348
349        let other = PersonalMessage(b"bye".as_slice().into());
350        key.verifying_key()
351            .verify_personal_message(&other, &signature)
352            .unwrap_err();
353    }
354
355    /// Signers of different concrete types can be held behind one trait
356    /// object and driven by a type-erased rng.
357    #[test]
358    fn randomized_signer_is_dyn_compatible() {
359        let message = PersonalMessage(b"hello".as_slice().into());
360        let signers: Vec<Box<dyn SuiRandomizedSigner>> = vec![
361            Box::new(MlDsa65PrivateKey::new([2; 32])),
362            Box::new(crate::simple::SimpleKeypair::from(MlDsa65PrivateKey::new(
363                [2; 32],
364            ))),
365        ];
366        let rng: &mut dyn rand_core::CryptoRngCore = &mut TestRng(1);
367        for signer in &signers {
368            let signature = signer
369                .sign_personal_message_with_rng(rng, &message)
370                .unwrap();
371            assert_eq!(signature.derive_address().to_string(), SUI_ADDRESS);
372            crate::simple::SimpleVerifier
373                .verify_personal_message(&message, &signature)
374                .unwrap();
375        }
376    }
377
378    #[test]
379    fn tampered_signature_fails() {
380        let (sui_signature, sui_public_key) = sui_fixture();
381        let digest = PersonalMessage(b"hello".as_slice().into()).signing_digest();
382        let mut bytes = *sui_signature.inner();
383        bytes[100] ^= 1;
384        MlDsa65VerifyingKey::new(&sui_public_key)
385            .unwrap()
386            .verify(&digest, &MlDsa65Signature::new(bytes))
387            .unwrap_err();
388    }
389
390    #[cfg(feature = "bech32")]
391    #[test]
392    fn suiprivkey_matches_sui_keytool() {
393        // `sui keytool` encoding of the seed [2; 32], from Sui's keytool tests.
394        const SUIPRIVKEY: &str =
395            "suiprivkey1qupqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyghdug7";
396        let key = MlDsa65PrivateKey::from_suiprivkey(SUIPRIVKEY).unwrap();
397        assert_eq!(key.seed(), &[2; 32]);
398        assert_eq!(key.public_key().derive_address().to_string(), SUI_ADDRESS);
399        assert_eq!(key.to_suiprivkey().unwrap(), SUIPRIVKEY);
400        assert_eq!(
401            MlDsa65PrivateKey::from_base64(&key.to_base64())
402                .unwrap()
403                .seed(),
404            key.seed()
405        );
406    }
407
408    #[cfg(feature = "ed25519")]
409    #[test]
410    fn hybrid_multisig() {
411        use crate::SuiSigner;
412        use crate::multisig::MultisigAggregator;
413        use crate::multisig::UserSignatureVerifier;
414        use sui_sdk_types::MultisigCommittee;
415        use sui_sdk_types::MultisigMember;
416        use sui_sdk_types::MultisigMemberPublicKey;
417
418        let ed_key = crate::ed25519::Ed25519PrivateKey::new([1; 32]);
419        let key = MlDsa65PrivateKey::new([2; 32]);
420        // Weight 1 each, threshold 2: both schemes must sign.
421        let committee = MultisigCommittee::new(
422            vec![
423                MultisigMember::new(MultisigMemberPublicKey::Ed25519(ed_key.public_key()), 1),
424                MultisigMember::new(
425                    MultisigMemberPublicKey::MlDsa65(Box::new(key.public_key())),
426                    1,
427                ),
428            ],
429            2,
430        );
431        let message = PersonalMessage(b"hello".as_slice().into());
432
433        let mut aggregator = MultisigAggregator::new_with_message(committee, &message);
434        aggregator
435            .add_signature(ed_key.sign_personal_message(&message).unwrap())
436            .unwrap();
437        aggregator.finish().unwrap_err();
438        aggregator
439            .add_signature(
440                key.sign_personal_message_with_rng(&mut TestRng(1), &message)
441                    .unwrap(),
442            )
443            .unwrap();
444        let multisig = UserSignature::Multisig(aggregator.finish().unwrap());
445
446        UserSignatureVerifier::new()
447            .verify_personal_message(&message, &multisig)
448            .unwrap();
449        let bytes = multisig.to_bytes();
450        assert_eq!(multisig, UserSignature::from_bytes(&bytes).unwrap());
451    }
452
453    #[proptest]
454    fn roundtrip(key: MlDsa65PrivateKey, message: Vec<u8>) {
455        let signature: MlDsa65Signature = key.try_sign_with_rng(&mut TestRng(7), &message).unwrap();
456        key.verifying_key().verify(&message, &signature).unwrap();
457        let recovered = MlDsa65PrivateKey::new(*key.seed());
458        assert_eq!(recovered.public_key(), key.public_key());
459    }
460}