Skip to main content

bittensor_core/keys/
mldsa.rs

1//! Descriptor-based post-quantum wallets with immutable recovery profiles.
2//! Account identities remain 32 bytes; signing keys have algorithm-specific widths.
3//! Historical ML-DSA method names remain available for client compatibility.
4
5use codec::Encode;
6use hkdf::Hkdf;
7use rand_core::{OsRng, RngCore};
8use sha2::Sha512;
9use subtensor_hashed::mldsa;
10use zeroize::Zeroizing;
11
12use super::{crypto_err, falcon, is_pq_crypto, Keypair, KeypairInner};
13#[cfg(test)]
14use super::{CRYPTO_MLDSA65, CRYPTO_MLDSA65_FIXED};
15use crate::error::CoreError;
16
17const DERIVATION_SALT: &[u8] = b"bittensor/ms/v2/derive";
18/// Bound wallet key generation independently of runtime transaction validation.
19const STATELESS_KEY_SEARCH_LIMIT: u64 = 256;
20pub const ROLE_TRANSACTION: u8 = 0;
21
22pub struct MldsaKeypair {
23    pub(super) descriptor: mldsa::Descriptor,
24    master_seed: Option<Zeroizing<[u8; 32]>>,
25    /// Recomputed from the master seed on recovery; never a mutable key counter.
26    transaction_index: u64,
27    pub(super) generation: u64,
28}
29
30impl MldsaKeypair {
31    pub(super) fn from_seed(seed: &[u8], crypto_type: u8) -> Result<Self, CoreError> {
32        let profile = super::pq_profile(crypto_type)
33            .ok_or_else(|| crypto_err("unsupported post-quantum account profile"))?;
34        let master = Zeroizing::new(
35            <[u8; 32]>::try_from(seed)
36                .map_err(|_| crypto_err("post-quantum master seed must be exactly 32 bytes"))?,
37        );
38        let (transaction_index, initial_commitment) = if super::is_fixed_pq_crypto(crypto_type) {
39            Self::stateless_transaction_key_for_profile(
40                &master,
41                profile,
42                STATELESS_KEY_SEARCH_LIMIT,
43            )?
44        } else {
45            let initial = Self::derive_seed_for_profile(&master, profile, ROLE_TRANSACTION, 0)?;
46            let public = falcon::public_key(profile, &initial)?;
47            (0, falcon::commitment(profile, &public)?)
48        };
49        Ok(Self {
50            descriptor: mldsa::Descriptor {
51                version: mldsa::VERSION,
52                algorithm: profile,
53                initial_commitment,
54            },
55            master_seed: Some(master),
56            transaction_index,
57            generation: 0,
58        })
59    }
60
61    #[cfg(test)]
62    fn stateless_transaction_key(
63        master: &[u8; 32],
64        limit: u64,
65    ) -> Result<(u64, mldsa::Commitment), CoreError> {
66        Self::stateless_transaction_key_for_profile(master, mldsa::FIXED_ALGORITHM, limit)
67    }
68
69    fn stateless_transaction_key_for_profile(
70        master: &[u8; 32],
71        profile: u8,
72        limit: u64,
73    ) -> Result<(u64, mldsa::Commitment), CoreError> {
74        for index in 0..limit {
75            let seed = Self::derive_seed_for_profile(master, profile, ROLE_TRANSACTION, index)?;
76            let public = falcon::public_key(profile, &seed)?;
77            let initial_commitment = falcon::commitment(profile, &public)?;
78            let descriptor = mldsa::Descriptor {
79                version: mldsa::VERSION,
80                algorithm: profile,
81                initial_commitment,
82            };
83            if initial_commitment != [0; mldsa::COMMITMENT_LEN]
84                && mldsa::rejects_legacy_public_keys(&mldsa::account_id(&descriptor))
85            {
86                return Ok((index, initial_commitment));
87            }
88        }
89        Err(crypto_err(
90            "post-quantum account derivation exhausted its bounded key search",
91        ))
92    }
93
94    #[cfg(test)]
95    fn derive_seed(
96        master: &[u8; 32],
97        role: u8,
98        index: u64,
99    ) -> Result<Zeroizing<[u8; 32]>, CoreError> {
100        Self::derive_seed_for_profile(master, mldsa::ALGORITHM, role, index)
101    }
102
103    fn derive_seed_for_profile(
104        master: &[u8; 32],
105        profile: u8,
106        role: u8,
107        index: u64,
108    ) -> Result<Zeroizing<[u8; 32]>, CoreError> {
109        if role != ROLE_TRANSACTION {
110            return Err(crypto_err("unsupported post-quantum key role"));
111        }
112        let salt = if subtensor_hashed::falcon::is_account_profile(profile) {
113            b"bittensor/falcon/v1/derive".as_slice()
114        } else {
115            DERIVATION_SALT
116        };
117        let kdf = Hkdf::<Sha512>::new(Some(salt), master);
118        let mut seed = Zeroizing::new([0; 32]);
119        kdf.expand(
120            &(mldsa::VERSION, profile, role, index).encode(),
121            seed.as_mut(),
122        )
123        .map_err(|_| crypto_err("post-quantum signing-key derivation failed"))?;
124        Ok(seed)
125    }
126
127    pub(super) fn seed_at(&self, role: u8, index: u64) -> Result<Zeroizing<[u8; 32]>, CoreError> {
128        Self::derive_seed_for_profile(
129            self.master_seed()
130                .ok_or_else(|| crypto_err("post-quantum derivation requires a private wallet"))?,
131            self.descriptor.algorithm,
132            role,
133            if role == ROLE_TRANSACTION && !self.descriptor.is_rotating() {
134                self.transaction_index
135            } else {
136                index
137            },
138        )
139    }
140
141    pub(super) fn master_seed(&self) -> Option<&[u8; 32]> {
142        self.master_seed.as_deref()
143    }
144    pub(super) fn account_id(&self) -> [u8; 32] {
145        mldsa::account_id(&self.descriptor)
146    }
147}
148
149pub(super) fn randomness() -> Result<Zeroizing<[u8; 32]>, CoreError> {
150    let mut random = Zeroizing::new([0; 32]);
151    OsRng
152        .try_fill_bytes(random.as_mut())
153        .map_err(|_| crypto_err("post-quantum signing randomness unavailable"))?;
154    Ok(random)
155}
156
157/// Verify a complete offline proof against independently resolved authority.
158/// This never accepts authority advertised only by the proof itself.
159pub fn verify_mldsa_authorization(
160    encoded: &[u8],
161    payload: &[u8],
162    descriptor: &[u8],
163    generation: u64,
164    expected_commitment: &[u8; 64],
165) -> Result<bool, CoreError> {
166    let descriptor = mldsa::Descriptor::from_bytes(descriptor)
167        .map_err(|_| crypto_err("invalid post-quantum descriptor"))?;
168    if subtensor_hashed::falcon::is_account_profile(descriptor.algorithm) {
169        let Ok(proof) = subtensor_hashed::falcon::SignerAddress::from_bytes(encoded) else {
170            return Ok(false);
171        };
172        return Ok(generation != u64::MAX
173            && (!descriptor.is_stateless() || generation == 0)
174            && proof.account == mldsa::account_id(&descriptor)
175            && proof.algorithm == descriptor.algorithm
176            && proof.generation == generation
177            && falcon::commitment(proof.algorithm, &proof.public_key)? == *expected_commitment
178            && descriptor.accepts_next_commitment(expected_commitment, &proof.next_commitment)
179            && proof.verify(payload));
180    }
181    let Ok(proof) = mldsa::SignerAddress::from_bytes(encoded) else {
182        return Ok(false);
183    };
184    Ok(generation != u64::MAX
185        && (!descriptor.is_stateless() || generation == 0)
186        && proof.account == mldsa::account_id(&descriptor)
187        && proof.algorithm == descriptor.algorithm
188        && proof.generation == generation
189        && mldsa::commitment(&proof.public_key) == *expected_commitment
190        && descriptor.accepts_next_commitment(expected_commitment, &proof.next_commitment)
191        && proof.verify(payload))
192}
193
194impl Keypair {
195    pub(super) fn mldsa(&self) -> Result<&MldsaKeypair, CoreError> {
196        match &self.inner {
197            KeypairInner::Mldsa(key) => Ok(key),
198            _ => Err(crypto_err(
199                "operation requires a post-quantum account descriptor",
200            )),
201        }
202    }
203
204    pub fn from_mldsa_descriptor(encoded: &[u8], ss58_format: u16) -> Result<Self, CoreError> {
205        let descriptor = mldsa::Descriptor::from_bytes(encoded)
206            .map_err(|_| crypto_err("invalid post-quantum account descriptor"))?;
207        if !descriptor.is_supported() {
208            return Err(crypto_err("unsupported post-quantum account descriptor"));
209        }
210        Ok(Self {
211            inner: KeypairInner::Mldsa(MldsaKeypair {
212                descriptor,
213                master_seed: None,
214                transaction_index: 0,
215                generation: 0,
216            }),
217            ss58_format,
218            mnemonic: None,
219            seed: None,
220        })
221    }
222
223    pub(super) fn mldsa_public_only(&self) -> Result<Self, CoreError> {
224        let key = self.mldsa()?;
225        Ok(Self {
226            inner: KeypairInner::Mldsa(MldsaKeypair {
227                descriptor: key.descriptor,
228                master_seed: None,
229                transaction_index: key.transaction_index,
230                generation: key.generation,
231            }),
232            ss58_format: self.ss58_format,
233            mnemonic: None,
234            seed: None,
235        })
236    }
237
238    pub(super) fn mldsa_at_generation(&self, generation: u64) -> Result<Self, CoreError> {
239        let key = self.mldsa()?;
240        if key.descriptor.is_stateless() && generation != 0 {
241            return Err(crypto_err(
242                "registration-free post-quantum accounts have no signing generation",
243            ));
244        }
245        if key.master_seed.is_none() {
246            return Err(crypto_err(
247                "post-quantum generation selection requires a private wallet",
248            ));
249        }
250        Ok(Self {
251            inner: KeypairInner::Mldsa(MldsaKeypair {
252                descriptor: key.descriptor,
253                master_seed: key.master_seed.clone(),
254                transaction_index: key.transaction_index,
255                generation,
256            }),
257            ss58_format: self.ss58_format,
258            mnemonic: self.mnemonic.clone(),
259            seed: self.seed.clone(),
260        })
261    }
262
263    pub fn mldsa_descriptor(&self) -> Result<Vec<u8>, CoreError> {
264        Ok(self.mldsa()?.descriptor.encode())
265    }
266    pub fn mldsa_profile(&self) -> Result<u8, CoreError> {
267        Ok(self.mldsa()?.descriptor.algorithm)
268    }
269    pub fn mldsa_generation(&self) -> Result<u64, CoreError> {
270        Ok(self.mldsa()?.generation)
271    }
272    pub fn mldsa_public_key(&self) -> Result<mldsa::PublicKeyBytes, CoreError> {
273        let key = self.mldsa()?;
274        if subtensor_hashed::falcon::is_account_profile(key.descriptor.algorithm) {
275            return Err(crypto_err("use pq_public_key for Falcon public keys"));
276        }
277        mldsa::public_key_from_seed(&*key.seed_at(ROLE_TRANSACTION, key.generation)?)
278            .map_err(crypto_err)
279    }
280    pub fn mldsa_commitment(&self, generation: u64) -> Result<[u8; 64], CoreError> {
281        let key = self.mldsa()?;
282        if generation == 0 || !key.descriptor.is_rotating() {
283            return Ok(key.descriptor.initial_commitment);
284        }
285        falcon::commitment(
286            key.descriptor.algorithm,
287            &falcon::public_key(
288                key.descriptor.algorithm,
289                &*key.seed_at(ROLE_TRANSACTION, generation)?,
290            )?,
291        )
292    }
293    pub fn mldsa_current_commitment(&self) -> Result<[u8; 64], CoreError> {
294        self.mldsa_commitment(self.mldsa_generation()?)
295    }
296    pub fn mldsa_next_commitment(&self) -> Result<[u8; 64], CoreError> {
297        self.mldsa_commitment(
298            self.mldsa_generation()?
299                .checked_add(1)
300                .ok_or_else(|| crypto_err("post-quantum signing generation exhausted"))?,
301        )
302    }
303
304    /// Returns the entire canonical MlDsaSignerAddress, not a MultiSignature payload.
305    /// `payload` is the full unshortened native call + extra + implicit data.
306    pub fn sign_mldsa(&self, payload: &[u8]) -> Result<Vec<u8>, CoreError> {
307        self.sign_mldsa_with_randomness(payload, &*randomness()?)
308    }
309
310    /// Explicit deterministic input supports offline devices and golden vectors.
311    pub fn sign_mldsa_with_randomness(
312        &self,
313        payload: &[u8],
314        random: &[u8; 32],
315    ) -> Result<Vec<u8>, CoreError> {
316        let key = self.mldsa()?;
317        if subtensor_hashed::falcon::is_account_profile(key.descriptor.algorithm) {
318            return self.sign_falcon(payload, random);
319        }
320        let seed = key.seed_at(ROLE_TRANSACTION, key.generation)?;
321        let header = mldsa::Header {
322            account: key.account_id(),
323            version: mldsa::VERSION,
324            algorithm: key.descriptor.algorithm,
325            generation: key.generation,
326            public_key: mldsa::public_key_from_seed(&seed).map_err(crypto_err)?,
327            next_commitment: self.mldsa_next_commitment()?,
328        };
329        Ok(header
330            .sign(&seed, payload, random)
331            .map_err(crypto_err)?
332            .encode())
333    }
334
335    /// Variable-width authorization APIs support classical and post-quantum profiles without changing
336    /// the historical fixed-width Rust v1 APIs.
337    pub fn authorization_descriptor(&self) -> Result<Vec<u8>, CoreError> {
338        if is_pq_crypto(self.crypto_type()) {
339            self.mldsa_descriptor()
340        } else {
341            self.hashed_descriptor()
342        }
343    }
344    pub fn authorization_generation(&self) -> Result<u64, CoreError> {
345        if is_pq_crypto(self.crypto_type()) {
346            self.mldsa_generation()
347        } else {
348            self.hashed_generation()
349        }
350    }
351    pub fn authorization_public_key(&self) -> Result<Vec<u8>, CoreError> {
352        if is_pq_crypto(self.crypto_type()) {
353            self.pq_public_key()
354        } else {
355            self.hashed_signing_public_key()
356        }
357    }
358    pub fn authorization_commitment(&self, generation: u64) -> Result<Vec<u8>, CoreError> {
359        if is_pq_crypto(self.crypto_type()) {
360            Ok(self.mldsa_commitment(generation)?.to_vec())
361        } else {
362            Ok(self.hashed_commitment(generation)?.to_vec())
363        }
364    }
365    pub fn authorization_current_commitment(&self) -> Result<Vec<u8>, CoreError> {
366        self.authorization_commitment(self.authorization_generation()?)
367    }
368    pub fn authorization_next_commitment(&self) -> Result<Vec<u8>, CoreError> {
369        self.authorization_commitment(
370            self.authorization_generation()?
371                .checked_add(1)
372                .ok_or_else(|| crypto_err("signing generation exhausted"))?,
373        )
374    }
375    pub fn sign_authorization(&self, payload: &[u8]) -> Result<Vec<u8>, CoreError> {
376        if is_pq_crypto(self.crypto_type()) {
377            self.sign_mldsa(payload)
378        } else {
379            self.sign_hashed(payload)
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    #![allow(clippy::unwrap_used, clippy::expect_used)]
387    use super::*;
388    use crate::keys::{
389        decode_hashed_receiving_address, encode_hashed_receiving_address, CRYPTO_HASHED,
390        CRYPTO_SR25519,
391    };
392
393    // PROTECTED_REGRESSION: hkdf sha512 transaction vectors preserve recovery; changes require explicit human approval.
394    #[test]
395    fn hkdf_sha512_transaction_vectors_preserve_recovery() {
396        // Independently computed with Python hashlib/hmac, not this KDF.
397        for (role, index, expected) in [
398            (
399                0,
400                0,
401                "ee5932d8d7f72ace8a35e6a7adaf0ddc3f247fb361e4a5002653af9fbc30675d",
402            ),
403            (
404                0,
405                41,
406                "226f863895c02488668a98d3bddf3796368b9616de8cea8143c65a9ee92ca379",
407            ),
408        ] {
409            assert_eq!(
410                hex::encode(*MldsaKeypair::derive_seed(&[7; 32], role, index).unwrap()),
411                expected
412            );
413        }
414        // Fixed-profile vector independently computed with Python HMAC-SHA512.
415        assert_eq!(
416            hex::encode(
417                *MldsaKeypair::derive_seed_for_profile(&[7; 32], mldsa::FIXED_ALGORITHM, 0, 0)
418                    .unwrap()
419            ),
420            "753832bab3583b97195c3878523e70d03a5695675a62c6527a004ef1957cb736"
421        );
422        for unsupported_role in [1, 2, 3, u8::MAX] {
423            assert!(MldsaKeypair::derive_seed(&[7; 32], unsupported_role, 0).is_err());
424        }
425    }
426
427    #[test]
428    fn mnemonic_convention_requires_256_bits_and_supports_passphrase_restore() {
429        let phrase = [vec!["abandon"; 23], vec!["art"]].concat().join(" ");
430        let wallet = Keypair::from_mnemonic(&phrase, CRYPTO_MLDSA65, Some("TREZOR")).unwrap();
431        assert_eq!(
432            hex::encode(wallet.seed_bytes().unwrap()),
433            "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd30971"
434        );
435        assert!(wallet.mnemonic().is_none());
436        let restored = Keypair::from_seed(wallet.seed_bytes().unwrap(), CRYPTO_MLDSA65).unwrap();
437        assert_eq!(
438            wallet.mldsa_descriptor().unwrap(),
439            restored.mldsa_descriptor().unwrap()
440        );
441        assert_ne!(
442            wallet.public_key_bytes(),
443            Keypair::from_mnemonic(&phrase, CRYPTO_MLDSA65, None)
444                .unwrap()
445                .public_key_bytes()
446        );
447        assert!(Keypair::from_mnemonic("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", CRYPTO_MLDSA65, None).is_err());
448    }
449
450    #[test]
451    fn rotation_restore_keeps_identity_and_signs_complete_payload() {
452        let wallet = Keypair::from_seed(&[7; 32], CRYPTO_MLDSA65).unwrap();
453        let active = wallet.at_generation(41).unwrap();
454        let restored = Keypair::from_seed(&[7; 32], CRYPTO_MLDSA65)
455            .unwrap()
456            .at_generation(41)
457            .unwrap();
458        assert_eq!(wallet.public_key_bytes(), active.public_key_bytes());
459        assert_eq!(
460            active.mldsa_current_commitment().unwrap(),
461            restored.mldsa_current_commitment().unwrap()
462        );
463        assert_ne!(
464            wallet.mldsa_public_key().unwrap(),
465            active.mldsa_public_key().unwrap()
466        );
467        let payload = vec![19; 500];
468        let encoded = active
469            .sign_mldsa_with_randomness(&payload, &[0; 32])
470            .unwrap();
471        assert_eq!(encoded.len(), 5367);
472        let proof = mldsa::SignerAddress::from_bytes(&encoded).unwrap();
473        assert!(proof.verify(&payload));
474        let descriptor = wallet.mldsa_descriptor().unwrap();
475        let commitment = active.mldsa_current_commitment().unwrap();
476        assert!(
477            verify_mldsa_authorization(&encoded, &payload, &descriptor, 41, &commitment).unwrap()
478        );
479        assert!(
480            !verify_mldsa_authorization(&encoded, &payload, &descriptor, 42, &commitment).unwrap()
481        );
482        assert!(
483            !verify_mldsa_authorization(&encoded, &payload, &descriptor, 41, &[0; 64]).unwrap()
484        );
485        assert!(!verify_mldsa_authorization(
486            &encoded[..5366],
487            &payload,
488            &descriptor,
489            41,
490            &commitment
491        )
492        .unwrap());
493        assert_eq!(proof.account, wallet.public_key_bytes());
494        assert_eq!(
495            proof.next_commitment,
496            restored.mldsa_commitment(42).unwrap()
497        );
498        assert!(!proof.verify(&sp_core::hashing::blake2_256(&payload)));
499        let mut wrong = proof.clone();
500        wrong.account[0] ^= 1;
501        assert!(!wrong.verify(&payload));
502        let mut wrong = proof.clone();
503        wrong.next_commitment[0] ^= 1;
504        assert!(!wrong.verify(&payload));
505        let mut wrong = proof;
506        wrong.generation += 1;
507        assert!(!wrong.verify(&payload));
508        assert_eq!(active.mldsa_generation().unwrap(), 41);
509        assert!(wallet
510            .at_generation(u64::MAX)
511            .unwrap()
512            .sign_mldsa(b"no wrap")
513            .is_err());
514    }
515
516    #[test]
517    fn offline_authorization_rejects_cryptographically_valid_unusable_rotations() {
518        let wallet = Keypair::from_seed(&[7; 32], CRYPTO_MLDSA65).unwrap();
519        let descriptor = wallet.mldsa_descriptor().unwrap();
520        let payload = vec![19; 500];
521        let current = wallet.mldsa_commitment(7).unwrap();
522        let next = wallet.mldsa_commitment(8).unwrap();
523        for (generation, next_commitment, accepted) in [
524            (7, next, true),
525            (7, [0; 64], false),
526            (7, current, false),
527            (u64::MAX, next, false),
528            (
529                u64::MAX - 1,
530                wallet.mldsa_commitment(u64::MAX).unwrap(),
531                true,
532            ),
533        ] {
534            let active = wallet.at_generation(generation).unwrap();
535            let commitment = active.mldsa_current_commitment().unwrap();
536            let signing_seed = active
537                .mldsa()
538                .unwrap()
539                .seed_at(ROLE_TRANSACTION, generation)
540                .unwrap();
541            // Model an external signer that does not enforce our wallet's
542            // rotation policy. Every malformed header is correctly re-signed;
543            // rejection must come from authorization guards, not bad crypto.
544            let proof = mldsa::Header {
545                account: wallet.public_key_bytes(),
546                version: mldsa::VERSION,
547                algorithm: mldsa::ALGORITHM,
548                generation,
549                public_key: active.mldsa_public_key().unwrap(),
550                next_commitment,
551            }
552            .sign(&signing_seed, &payload, &[0; 32])
553            .unwrap();
554            assert!(proof.verify(&payload));
555            if generation == 7 && next_commitment == [0; 64] {
556                // Shared with the Python offline-attachment regression. Keep
557                // the fixture cryptographically valid so a corrupted proof
558                // cannot accidentally make the policy rejection test pass.
559                if let Ok(path) = std::env::var("MLDSA_ZERO_NEXT_FIXTURE") {
560                    std::fs::write(path, hex::encode(proof.encode())).unwrap();
561                } else {
562                    assert_eq!(
563                        proof.encode(),
564                        hex::decode(
565                            include_str!(
566                                "../../../python/tests/fixtures/mldsa-zero-next-proof.hex"
567                            )
568                            .trim()
569                        )
570                        .unwrap(),
571                    );
572                }
573            }
574            assert_eq!(
575                verify_mldsa_authorization(
576                    &proof.encode(),
577                    &payload,
578                    &descriptor,
579                    generation,
580                    &commitment,
581                )
582                .unwrap(),
583                accepted,
584                "generation {generation}, next commitment {next_commitment:?}",
585            );
586        }
587    }
588
589    #[test]
590    fn watch_only_keeps_descriptor_but_never_signs_or_reveals_keys() {
591        let wallet = Keypair::from_seed(&[7; 32], CRYPTO_MLDSA65).unwrap();
592        let public = wallet.public_only().unwrap();
593        assert_eq!(
594            wallet.mldsa_descriptor().unwrap(),
595            public.mldsa_descriptor().unwrap()
596        );
597        assert_eq!(wallet.public_key_bytes(), public.public_key_bytes());
598        assert!(public.private_key_bytes().is_none());
599        assert!(public.mldsa_public_key().is_err());
600        assert!(public.sign_mldsa(b"no private material").is_err());
601        assert!(wallet.sign(b"arbitrary message").is_err());
602        assert!(Keypair::from_uri("//Alice", CRYPTO_MLDSA65).is_err());
603        for crypto in [CRYPTO_HASHED, CRYPTO_SR25519] {
604            assert_ne!(
605                wallet.public_key_bytes(),
606                Keypair::from_seed(&[7; 32], crypto)
607                    .unwrap()
608                    .public_key_bytes()
609            );
610        }
611    }
612
613    #[test]
614    fn receiving_mldsa_vector_checksum_and_canonical_spelling() {
615        let descriptor = mldsa::Descriptor {
616            version: 2,
617            algorithm: 4,
618            initial_commitment: [7; 64],
619        }
620        .encode();
621        let address = encode_hashed_receiving_address(&descriptor).unwrap();
622        assert_eq!(
623            address,
624            "tao_AgQHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHhs8-H4mAmmA"
625        );
626        assert_eq!(address.len(), 103);
627        for spelling in [address.clone(), address.replacen("tao_", "bth2_", 1)] {
628            let decoded = decode_hashed_receiving_address(&spelling).unwrap();
629            assert_eq!(decoded, descriptor);
630            assert_eq!(encode_hashed_receiving_address(&decoded).unwrap(), address);
631            for invalid in [
632                format!("{spelling}="),
633                format!(" {spelling}"),
634                format!("{spelling}\n"),
635                spelling.to_uppercase(),
636                spelling[..spelling.len() - 1].to_string(),
637            ] {
638                assert!(decode_hashed_receiving_address(&invalid).is_err());
639            }
640            let mut bytes = spelling.into_bytes();
641            bytes[30] = if bytes[30] == b'A' { b'B' } else { b'A' };
642            assert!(decode_hashed_receiving_address(std::str::from_utf8(&bytes).unwrap()).is_err());
643        }
644        assert!(decode_hashed_receiving_address(&address.replacen("tao_", "bth1_", 1)).is_err());
645    }
646
647    #[cfg(feature = "host")]
648    #[test]
649    fn versioned_private_and_public_keyfiles_preserve_mldsa() {
650        use crate::keyfiles::{
651            deserialize_keypair_from_keyfile_data, serialized_keypair_to_keyfile_data,
652        };
653        for crypto in [CRYPTO_MLDSA65, CRYPTO_MLDSA65_FIXED] {
654            let wallet = Keypair::from_seed(&[7; 32], crypto).unwrap();
655            for key in [wallet.public_only().unwrap(), wallet] {
656                let bytes = serialized_keypair_to_keyfile_data(&key).unwrap();
657                let restored = deserialize_keypair_from_keyfile_data(&bytes).unwrap();
658                assert_eq!(
659                    key.mldsa_descriptor().unwrap(),
660                    restored.mldsa_descriptor().unwrap()
661                );
662                assert_eq!(
663                    key.private_key_bytes().is_some(),
664                    restored.private_key_bytes().is_some()
665                );
666                let mut json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
667                json["cryptoType"] = serde_json::json!(if crypto == CRYPTO_MLDSA65 {
668                    CRYPTO_MLDSA65_FIXED
669                } else {
670                    CRYPTO_MLDSA65
671                });
672                assert!(
673                    deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&json).unwrap())
674                        .is_err()
675                );
676            }
677        }
678    }
679    #[test]
680    fn standard_mldsa_creation_uses_a_recoverable_stateless_key_and_ordinary_address() {
681        for value in [0, 1, 7, 19, 42, 255] {
682            let seed = [value; 32];
683            let key = Keypair::from_seed(&seed, CRYPTO_MLDSA65_FIXED).unwrap();
684            assert_eq!(key.mldsa_profile().unwrap(), mldsa::FIXED_ALGORITHM);
685            assert!(mldsa::rejects_legacy_public_keys(&key.public_key_bytes()));
686            assert_eq!(key.hashed_receiving_address().unwrap(), key.ss58_address());
687            assert_eq!(
688                encode_hashed_receiving_address(&key.mldsa_descriptor().unwrap()).unwrap(),
689                key.ss58_address()
690            );
691            let restored = Keypair::from_seed(&seed, CRYPTO_MLDSA65_FIXED).unwrap();
692            assert_eq!(
693                restored.mldsa_descriptor().unwrap(),
694                key.mldsa_descriptor().unwrap()
695            );
696            assert_eq!(
697                restored.mldsa_public_key().unwrap(),
698                key.mldsa_public_key().unwrap()
699            );
700            assert_eq!(key.mldsa_generation().unwrap(), 0);
701            assert!(key.at_generation(1).is_err());
702            assert!(key.at_generation(u64::MAX).is_err());
703            let proof = key
704                .sign_mldsa_with_randomness(b"call/nonce/era/genesis", &[0; 32])
705                .unwrap();
706            let commitment = key.mldsa_current_commitment().unwrap();
707            assert_eq!(key.authorization_next_commitment().unwrap(), commitment);
708            assert!(verify_mldsa_authorization(
709                &proof,
710                b"call/nonce/era/genesis",
711                &key.mldsa_descriptor().unwrap(),
712                0,
713                &commitment
714            )
715            .unwrap());
716            assert!(!verify_mldsa_authorization(
717                &proof,
718                b"call/nonce/era/genesis",
719                &key.mldsa_descriptor().unwrap(),
720                1,
721                &commitment
722            )
723            .unwrap());
724            assert!(!verify_mldsa_authorization(
725                &proof,
726                b"other call/nonce/era/genesis",
727                &key.mldsa_descriptor().unwrap(),
728                0,
729                &commitment
730            )
731            .unwrap());
732            let rotating = Keypair::from_seed(&seed, CRYPTO_MLDSA65).unwrap();
733            assert_ne!(key.public_key_bytes(), rotating.public_key_bytes());
734            assert!(!verify_mldsa_authorization(
735                &proof,
736                b"call/nonce/era/genesis",
737                &rotating.mldsa_descriptor().unwrap(),
738                0,
739                &commitment
740            )
741            .unwrap());
742            let watch =
743                Keypair::new(Some(&key.ss58_address()), None, CRYPTO_MLDSA65_FIXED, 42).unwrap();
744            assert_eq!(
745                watch.hashed_receiving_address().unwrap(),
746                key.ss58_address()
747            );
748            assert!(watch.mldsa_profile().is_err()); // Hash bytes cannot identify a profile.
749            assert!(watch.sign_mldsa(b"call").is_err());
750            let descriptor_watch = key.public_only().unwrap();
751            assert_eq!(
752                descriptor_watch.hashed_receiving_address().unwrap(),
753                key.ss58_address()
754            );
755            assert_eq!(
756                descriptor_watch.mldsa_profile().unwrap(),
757                mldsa::FIXED_ALGORITHM
758            );
759            assert!(descriptor_watch.sign_mldsa(b"call").is_err());
760        }
761        assert!(MldsaKeypair::stateless_transaction_key(&[7; 32], 0).is_err());
762    }
763
764    #[test]
765    fn fixed_account_derivation_and_recovery_vector_is_stable() {
766        let key = Keypair::from_seed(&[7; 32], CRYPTO_MLDSA65_FIXED).unwrap();
767        assert_eq!(
768            hex::encode(key.public_key_bytes()),
769            "aa4018475de4922453c911cc9b469f6c5b0c85135e77a3db98d8f6fe325064ef"
770        );
771        assert_eq!(hex::encode(key.mldsa_descriptor().unwrap()), "020553f8916ec9b0b73b929d66357198a2ca70b33070e3b740e00c35cfb310c1c03e7c6ec79aaa8fad9714f94504759de1bbe3bf504f81725e0c348466b05996f49b");
772        assert_eq!(
773            key.ss58_address(),
774            "5Fuw396EmC877oPz39aopWXWdEwdJ3KkmYVzpWsDcPTsoy8d"
775        );
776        let index = key.mldsa().unwrap().transaction_index;
777        assert!(index < STATELESS_KEY_SEARCH_LIMIT);
778        // Exhausting before the chosen index must fail closed, rather than
779        // selecting a different identity or returning an unsafe candidate.
780        assert!(MldsaKeypair::stateless_transaction_key(&[7; 32], index).is_err());
781        assert_eq!(
782            MldsaKeypair::stateless_transaction_key(&[7; 32], index + 1).unwrap(),
783            (index, key.mldsa_current_commitment().unwrap())
784        );
785    }
786
787    #[cfg(feature = "host")]
788    #[test]
789    fn every_mldsa_profile_restores_its_recorded_identity_from_seed_and_mnemonic_keyfiles() {
790        use crate::keyfiles::{
791            deserialize_keypair_from_keyfile_data, serialized_keypair_to_keyfile_data,
792        };
793        let phrase = [vec!["abandon"; 23], vec!["art"]].concat().join(" ");
794        for (profile, crypto) in [
795            (mldsa::ALGORITHM, CRYPTO_MLDSA65),
796            (mldsa::FIXED_ALGORITHM, CRYPTO_MLDSA65_FIXED),
797        ] {
798            for key in [
799                Keypair::from_seed(&[7; 32], crypto).unwrap(),
800                Keypair::from_mnemonic(&phrase, crypto, None).unwrap(),
801            ] {
802                for original in [key.public_only().unwrap(), key] {
803                    let serialized = serialized_keypair_to_keyfile_data(&original).unwrap();
804                    let restored = deserialize_keypair_from_keyfile_data(&serialized).unwrap();
805                    assert_eq!(restored.mldsa_profile().unwrap(), profile);
806                    assert_eq!(
807                        restored.mldsa_descriptor().unwrap(),
808                        original.mldsa_descriptor().unwrap()
809                    );
810                    assert_eq!(restored.ss58_address(), original.ss58_address());
811                    if original.private_key_bytes().is_some() {
812                        assert_eq!(
813                            restored.mldsa_public_key().unwrap(),
814                            original.mldsa_public_key().unwrap()
815                        );
816                    }
817                    let mut corrupt: serde_json::Value =
818                        serde_json::from_slice(&serialized).unwrap();
819                    let mut descriptor = original.mldsa_descriptor().unwrap();
820                    descriptor[1] = if profile == mldsa::FIXED_ALGORITHM {
821                        mldsa::ALGORITHM
822                    } else {
823                        mldsa::FIXED_ALGORITHM
824                    };
825                    corrupt["mldsaDescriptor"] =
826                        serde_json::json!(format!("0x{}", hex::encode(descriptor)));
827                    assert!(deserialize_keypair_from_keyfile_data(
828                        &serde_json::to_vec(&corrupt).unwrap()
829                    )
830                    .is_err());
831                }
832            }
833        }
834    }
835
836    #[cfg(feature = "host")]
837    #[test]
838    fn fixed_address_only_watch_keyfiles_restore_without_claiming_private_authority() {
839        use crate::keyfiles::{
840            deserialize_keypair_from_keyfile_data, serialized_keypair_to_keyfile_data,
841        };
842        let wallet = Keypair::from_seed(&[7; 32], CRYPTO_MLDSA65_FIXED).unwrap();
843        let watch =
844            Keypair::new(Some(&wallet.ss58_address()), None, CRYPTO_MLDSA65_FIXED, 42).unwrap();
845        let serialized = serialized_keypair_to_keyfile_data(&watch).unwrap();
846        let mut json: serde_json::Value = serde_json::from_slice(&serialized).unwrap();
847        assert_eq!(json["walletVersion"], 2);
848        assert!(json.get("mldsaDescriptor").is_none());
849        let restored = deserialize_keypair_from_keyfile_data(&serialized).unwrap();
850        assert_eq!(restored.ss58_address(), wallet.ss58_address());
851        assert_eq!(restored.crypto_type(), CRYPTO_MLDSA65_FIXED);
852        assert_eq!(restored.account_type(), "standard");
853        assert_eq!(
854            restored.hashed_receiving_address().unwrap(),
855            wallet.ss58_address()
856        );
857        assert!(restored.private_key_bytes().is_none());
858        assert!(restored.mldsa_descriptor().is_err());
859        assert!(restored.mldsa_profile().is_err());
860        assert!(restored.sign_mldsa(b"call").is_err());
861        json["secretSeed"] = serde_json::json!(format!("0x{}", hex::encode([7; 32])));
862        assert!(
863            deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&json).unwrap()).is_err()
864        );
865    }
866
867    #[cfg(feature = "host")]
868    // PROTECTED_REGRESSION: unsafe pre release fixed descriptor cannot be reinterpreted during recovery; changes require explicit human approval.
869    #[test]
870    fn unsafe_pre_release_fixed_descriptor_cannot_be_reinterpreted_during_recovery() {
871        use crate::keyfiles::{
872            deserialize_keypair_from_keyfile_data, serialized_keypair_to_keyfile_data,
873        };
874        for value in 0u8..=16 {
875            let master = [value; 32];
876            let original_seed = MldsaKeypair::derive_seed_for_profile(
877                &master,
878                mldsa::FIXED_ALGORITHM,
879                ROLE_TRANSACTION,
880                0,
881            )
882            .unwrap();
883            let public_key = mldsa::public_key_from_seed(&original_seed).unwrap();
884            let descriptor = mldsa::Descriptor {
885                version: mldsa::VERSION,
886                algorithm: mldsa::FIXED_ALGORITHM,
887                initial_commitment: mldsa::commitment(&public_key),
888            };
889            let unsafe_account = mldsa::account_id(&descriptor);
890            if mldsa::rejects_legacy_public_keys(&unsafe_account) {
891                continue;
892            }
893            let current = Keypair::from_seed(&master, CRYPTO_MLDSA65_FIXED).unwrap();
894            assert_ne!(current.public_key_bytes(), unsafe_account);
895            assert!(Keypair::from_mldsa_descriptor(&descriptor.encode(), 42).is_err());
896            for key in [current.public_only().unwrap(), current] {
897                let encoded = serialized_keypair_to_keyfile_data(&key).unwrap();
898                let mut json: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
899                json["mldsaDescriptor"] =
900                    serde_json::json!(format!("0x{}", hex::encode(descriptor.encode())));
901                json["ss58Address"] =
902                    serde_json::json!(super::super::ss58_from_public(unsafe_account, 42));
903                for field in ["accountId", "publicKey"] {
904                    json[field] = serde_json::json!(format!("0x{}", hex::encode(unsafe_account)));
905                }
906                assert!(
907                    deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&json).unwrap())
908                        .is_err()
909                );
910            }
911            return;
912        }
913        panic!("test seeds did not contain a legacy-curve account");
914    }
915}