Skip to main content

bittensor_core/keys/
hashed.rs

1//! Mnemonic-recoverable rotating keys. This module has no mutable signing
2//! counter: the chain selects a generation and a wallet derives it independently.
3
4use codec::{Decode, Encode};
5use ed25519_zebra::{SigningKey, VerificationKey};
6use hkdf::Hkdf;
7use sha2::Sha256;
8use sp_core::{crypto::Pair as _, ed25519, sr25519};
9use subtensor_hashed::{account_id, key_commitment, Descriptor, Proof, Scheme, SignerAddress};
10use zeroize::Zeroizing;
11
12use super::{crypto_err, Keypair, KeypairInner, CRYPTO_HASHED, CRYPTO_HASHED_ED25519};
13use crate::error::CoreError;
14
15const VERSION: u8 = 1;
16const DERIVATION_SALT: &[u8] = b"bittensor/hashed/v1/derive";
17
18/// Verify a device's complete signing result against independently trusted
19/// authority. The proof itself never chooses the account, scheme or current
20/// commitment. `payload` must be the full call, extra and implicit bytes, not
21/// the ordinary Substrate payload hash used above 256 bytes.
22pub fn verify_hashed_authorization(
23    encoded: &[u8],
24    payload: &[u8],
25    descriptor: &[u8],
26    generation: u64,
27    expected_commitment: &[u8; 32],
28) -> Result<bool, CoreError> {
29    let mut input = descriptor;
30    let descriptor = Descriptor::decode(&mut input)
31        .map_err(|_| crypto_err("invalid hashed account descriptor"))?;
32    if !input.is_empty() || !descriptor.is_supported() {
33        return Err(crypto_err("invalid hashed account descriptor"));
34    }
35    // Fixed-size SDK proof: generation, current key, next commitment, signature.
36    if encoded.len() != 8 + 32 + 32 + 64 || generation == u64::MAX {
37        return Ok(false);
38    }
39    let proof = Proof::decode(&mut &encoded[..])
40        .map_err(|_| crypto_err("invalid hashed authorization proof"))?;
41    if proof.generation != generation
42        || key_commitment(descriptor.scheme, &proof.public_key) != *expected_commitment
43        || proof.next_commitment == [0; 32]
44        || proof.next_commitment == *expected_commitment
45    {
46        return Ok(false);
47    }
48    let address = SignerAddress {
49        account: account_id(&descriptor),
50        version: descriptor.version,
51        scheme: descriptor.scheme,
52        generation,
53        public_key: proof.public_key,
54        next_commitment: proof.next_commitment,
55    };
56    let message = address.signing_hash(payload);
57    Ok(match descriptor.scheme {
58        Scheme::Sr25519 => sr25519::Pair::verify(
59            &sr25519::Signature::from_raw(proof.signature),
60            message,
61            &sr25519::Public::from_raw(proof.public_key),
62        ),
63        Scheme::Ed25519 => ed25519::Pair::verify(
64            &ed25519::Signature::from_raw(proof.signature),
65            message,
66            &ed25519::Public::from_raw(proof.public_key),
67        ),
68    })
69}
70
71/// Public metadata is sufficient to preserve an address in a watch-only wallet.
72/// Signing additionally requires the original, independent master seed.
73pub struct HashedKeypair {
74    descriptor: Descriptor,
75    master_seed: Option<Zeroizing<[u8; 32]>>,
76    generation: u64,
77}
78
79impl HashedKeypair {
80    pub(super) fn from_seed(seed: &[u8], crypto_type: u8) -> Result<Self, CoreError> {
81        if seed.len() != 32 {
82            return Err(crypto_err("hashed master seed must be exactly 32 bytes"));
83        }
84        // Copy straight into the wiping owner; an intermediate array would
85        // leave an unwiped copy of the master seed on the stack.
86        let mut master_seed = Zeroizing::new([0u8; 32]);
87        master_seed.copy_from_slice(seed);
88        let scheme = match crypto_type {
89            CRYPTO_HASHED => Scheme::Sr25519,
90            CRYPTO_HASHED_ED25519 => Scheme::Ed25519,
91            _ => return Err(crypto_err("unsupported hashed signing scheme")),
92        };
93        let version = VERSION;
94        let initial_commitment = match scheme {
95            Scheme::Sr25519 => key_commitment(scheme, &Self::derive(&master_seed, 0)?.public().0),
96            Scheme::Ed25519 => {
97                key_commitment(scheme, &ed25519_public(&Self::derive_ed(&master_seed, 0)?))
98            }
99        };
100        Ok(Self {
101            descriptor: Descriptor {
102                version,
103                scheme,
104                initial_commitment,
105            },
106            master_seed: Some(master_seed),
107            generation: 0,
108        })
109    }
110
111    fn derive(master: &[u8; 32], generation: u64) -> Result<sr25519::Pair, CoreError> {
112        // HKDF uses the secret master independently for every generation. In
113        // particular, the next seed is never derived from the current signer.
114        let kdf = Hkdf::<Sha256>::new(Some(DERIVATION_SALT), master);
115        let info = (VERSION, Scheme::Sr25519, generation).encode();
116        let mut seed = Zeroizing::new([0u8; 32]);
117        kdf.expand(&info, seed.as_mut())
118            .map_err(|_| crypto_err("hashed signing-key derivation failed"))?;
119        Ok(sr25519::Pair::from_seed(&seed))
120    }
121
122    /// The derived signing key is held only by `Zeroizing`, which erases its
123    /// seed, expanded scalar and nonce prefix when dropped. A
124    /// `sp_core::ed25519::Pair` owns the same Zebra key without a wiping
125    /// destructor, so it is not used to hold a hashed-account secret.
126    fn derive_ed(master: &[u8; 32], generation: u64) -> Result<Zeroizing<SigningKey>, CoreError> {
127        let kdf = Hkdf::<Sha256>::new(Some(DERIVATION_SALT), master);
128        let mut seed = Zeroizing::new([0u8; 32]);
129        kdf.expand(
130            &(VERSION, Scheme::Ed25519, generation).encode(),
131            seed.as_mut(),
132        )
133        .map_err(|_| crypto_err("Ed25519 signing-key derivation failed"))?;
134        // Same key as `ed25519::Pair::from_seed`, which also builds a Zebra
135        // `SigningKey` from the 32-byte seed.
136        let secret = SigningKey::try_from(&seed[..])
137            .map_err(|_| crypto_err("Ed25519 signing-key derivation failed"))?;
138        Ok(Zeroizing::new(secret))
139    }
140
141    fn ed_at(&self, generation: u64) -> Result<Zeroizing<SigningKey>, CoreError> {
142        let master = self
143            .master_seed()
144            .ok_or_else(|| crypto_err("signing requires a private wallet"))?;
145        Self::derive_ed(master, generation)
146    }
147
148    pub(super) fn crypto_type(&self) -> u8 {
149        match self.descriptor.scheme {
150            Scheme::Sr25519 => CRYPTO_HASHED,
151
152            Scheme::Ed25519 => CRYPTO_HASHED_ED25519,
153        }
154    }
155
156    pub(super) fn master_seed(&self) -> Option<&[u8; 32]> {
157        self.master_seed.as_deref()
158    }
159
160    pub(super) fn account_id(&self) -> [u8; 32] {
161        account_id(&self.descriptor)
162    }
163
164    fn pair_at(&self, generation: u64) -> Result<sr25519::Pair, CoreError> {
165        let master = self.master_seed().ok_or_else(|| {
166            crypto_err("hashed signing-key derivation requires the mnemonic or master seed")
167        })?;
168        Self::derive(master, generation)
169    }
170}
171
172impl Keypair {
173    fn hashed(&self) -> Result<&HashedKeypair, CoreError> {
174        match &self.inner {
175            KeypairInner::Hashed(hashed) => Ok(hashed),
176            _ => Err(crypto_err("operation requires a hashed key descriptor")),
177        }
178    }
179
180    /// Restore public account metadata without retaining any signing material.
181    pub fn from_hashed_descriptor(encoded: &[u8], ss58_format: u16) -> Result<Self, CoreError> {
182        if encoded.first() == Some(&subtensor_hashed::mldsa::VERSION) {
183            return Self::from_mldsa_descriptor(encoded, ss58_format);
184        }
185        let mut input = encoded;
186        let descriptor = Descriptor::decode(&mut input)
187            .map_err(|_| crypto_err("invalid or unsupported hashed account descriptor"))?;
188        if !input.is_empty() || !descriptor.is_supported() {
189            return Err(crypto_err(
190                "invalid or unsupported hashed account descriptor",
191            ));
192        }
193        Ok(Self {
194            inner: KeypairInner::Hashed(HashedKeypair {
195                descriptor,
196                master_seed: None,
197                generation: 0,
198            }),
199            ss58_format,
200            mnemonic: None,
201            seed: None,
202        })
203    }
204
205    /// Clone public metadata only, including the hashed genesis descriptor.
206    pub fn public_only(&self) -> Result<Self, CoreError> {
207        if matches!(&self.inner, KeypairInner::Mldsa(_)) {
208            return self.mldsa_public_only();
209        }
210        if let KeypairInner::Hashed(hashed) = &self.inner {
211            let mut public =
212                Self::from_hashed_descriptor(&hashed.descriptor.encode(), self.ss58_format)?;
213            if let KeypairInner::Hashed(copy) = &mut public.inner {
214                copy.generation = hashed.generation;
215            }
216            return Ok(public);
217        }
218        Self::new(
219            None,
220            Some(&self.public_key_bytes()),
221            self.crypto_type(),
222            self.ss58_format,
223        )
224    }
225
226    /// A new view of the same stable account at the generation read from chain.
227    /// Neither this operation nor signing mutates any persisted wallet state.
228    pub fn at_generation(&self, generation: u64) -> Result<Self, CoreError> {
229        if super::is_pq_crypto(self.crypto_type()) {
230            return self.mldsa_at_generation(generation);
231        }
232        let hashed = self.hashed()?;
233        if hashed.master_seed.is_none() {
234            return Err(crypto_err(
235                "hashed generation selection requires a private wallet",
236            ));
237        }
238        Ok(Self {
239            inner: KeypairInner::Hashed(HashedKeypair {
240                descriptor: hashed.descriptor,
241                master_seed: hashed.master_seed.clone(),
242                generation,
243            }),
244            ss58_format: self.ss58_format,
245            mnemonic: self.mnemonic.clone(),
246            seed: self.seed.clone(),
247        })
248    }
249
250    pub fn hashed_descriptor(&self) -> Result<Vec<u8>, CoreError> {
251        Ok(self.hashed()?.descriptor.encode())
252    }
253
254    pub fn hashed_generation(&self) -> Result<u64, CoreError> {
255        Ok(self.hashed()?.generation)
256    }
257
258    pub fn hashed_public_key(&self) -> Result<[u8; 32], CoreError> {
259        let hashed = self.hashed()?;
260        if hashed.descriptor.scheme == Scheme::Ed25519 {
261            return Ok(ed25519_public(&hashed.ed_at(hashed.generation)?));
262        }
263        Ok(hashed.pair_at(hashed.generation)?.public().0)
264    }
265
266    pub fn hashed_commitment(&self, generation: u64) -> Result<[u8; 32], CoreError> {
267        let hashed = self.hashed()?;
268        if generation == 0 {
269            return Ok(hashed.descriptor.initial_commitment);
270        }
271        Ok(match hashed.descriptor.scheme {
272            Scheme::Sr25519 => {
273                key_commitment(Scheme::Sr25519, &hashed.pair_at(generation)?.public().0)
274            }
275            Scheme::Ed25519 => {
276                key_commitment(Scheme::Ed25519, &ed25519_public(&hashed.ed_at(generation)?))
277            }
278        })
279    }
280
281    pub fn hashed_current_commitment(&self) -> Result<[u8; 32], CoreError> {
282        self.hashed_commitment(self.hashed_generation()?)
283    }
284
285    pub fn hashed_next_commitment(&self) -> Result<[u8; 32], CoreError> {
286        let next = self
287            .hashed_generation()?
288            .checked_add(1)
289            .ok_or_else(|| crypto_err("hashed signing generation exhausted"))?;
290        self.hashed_commitment(next)
291    }
292
293    /// Sign a direct call's complete, unshortened Substrate payload. Bind the
294    /// versioned raw signer address (account, scheme, generation, current key
295    /// and next commitment) under the transaction domain before signing.
296    pub fn sign_hashed(&self, payload: &[u8]) -> Result<Vec<u8>, CoreError> {
297        let hashed = self.hashed()?;
298        let address = SignerAddress {
299            version: VERSION,
300            scheme: hashed.descriptor.scheme,
301            account: self.public_key_bytes(),
302            generation: hashed.generation,
303            public_key: self.hashed_public_key()?,
304            next_commitment: self.hashed_next_commitment()?,
305        };
306        let payload = address.signing_hash(payload);
307        let signature = match hashed.descriptor.scheme {
308            Scheme::Sr25519 => hashed.pair_at(hashed.generation)?.sign(&payload).0,
309            Scheme::Ed25519 => hashed.ed_at(hashed.generation)?.sign(&payload).to_bytes(),
310        };
311        Ok(Proof {
312            generation: hashed.generation,
313            public_key: self.hashed_public_key()?,
314            next_commitment: self.hashed_next_commitment()?,
315            signature,
316        }
317        .encode())
318    }
319
320    pub fn hashed_signing_public_key(&self) -> Result<Vec<u8>, CoreError> {
321        let hashed = self.hashed()?;
322        Ok(match hashed.descriptor.scheme {
323            Scheme::Sr25519 => hashed.pair_at(hashed.generation)?.public().0.to_vec(),
324            Scheme::Ed25519 => ed25519_public(&hashed.ed_at(hashed.generation)?).to_vec(),
325        })
326    }
327
328    /// Sign with the original identity key, never the current rotating sr25519
329    /// spending key. For sr25519, the caller must select a generation obtained
330    /// from finalized chain state and must have retired generation zero first.
331    pub(super) fn sign_hashed_message(&self, message: &[u8]) -> Result<Vec<u8>, CoreError> {
332        let hashed = self.hashed()?;
333        let payload = message_payload(&hashed.account_id(), message);
334        let mut envelope = MESSAGE_MAGIC.to_vec();
335        envelope.extend(hashed.descriptor.encode());
336        match hashed.descriptor.scheme {
337            Scheme::Sr25519 => {
338                if hashed.generation == 0 {
339                    return Err(crypto_err("hashed message signing requires generation zero to be retired on chain first"));
340                }
341                let pair = hashed.pair_at(0)?;
342                envelope.extend_from_slice(&pair.public().0);
343                envelope.extend_from_slice(&pair.sign(&payload).0);
344            }
345            Scheme::Ed25519 => {
346                if hashed.generation == 0 {
347                    return Err(crypto_err("hashed message signing requires generation zero to be retired on chain first"));
348                }
349                let secret = hashed.ed_at(0)?;
350                envelope.extend_from_slice(&ed25519_public(&secret));
351                envelope.extend_from_slice(&secret.sign(&payload).to_bytes());
352            }
353        }
354        Ok(envelope)
355    }
356}
357
358const MESSAGE_MAGIC: &[u8] = b"BTHMSG01";
359
360fn ed25519_public(secret: &Zeroizing<SigningKey>) -> [u8; 32] {
361    let secret: &SigningKey = secret;
362    VerificationKey::from(secret).into()
363}
364
365fn message_payload(account: &[u8; 32], message: &[u8]) -> Vec<u8> {
366    let mut payload = b"bittensor/hashed/v1/message".to_vec();
367    (account, message).encode_to(&mut payload);
368    payload
369}
370
371pub(super) fn verify_message(
372    crypto_type: u8,
373    account: &[u8; 32],
374    message: &[u8],
375    envelope: &[u8],
376) -> bool {
377    let expected_len = 8 + 34 + 32 + 64;
378    if envelope.len() != expected_len || !envelope.starts_with(MESSAGE_MAGIC) {
379        return false;
380    }
381    let mut input = &envelope[8..];
382    let Ok(descriptor) = Descriptor::decode(&mut input) else {
383        return false;
384    };
385    let scheme = if crypto_type == CRYPTO_HASHED_ED25519 {
386        Scheme::Ed25519
387    } else {
388        Scheme::Sr25519
389    };
390    if !descriptor.is_supported()
391        || descriptor.scheme != scheme
392        || account_id(&descriptor) != *account
393    {
394        return false;
395    }
396    let payload = message_payload(account, message);
397    match scheme {
398        Scheme::Sr25519 => {
399            let (Ok(public), Ok(signature)) = (
400                <[u8; 32]>::try_from(&input[..32]),
401                <[u8; 64]>::try_from(&input[32..]),
402            ) else {
403                return false;
404            };
405            descriptor.initial_commitment == key_commitment(scheme, &public)
406                && sr25519::Pair::verify(
407                    &sr25519::Signature::from_raw(signature),
408                    &payload,
409                    &sr25519::Public::from_raw(public),
410                )
411        }
412        Scheme::Ed25519 => {
413            let (Ok(public), Ok(signature)) = (
414                <[u8; 32]>::try_from(&input[..32]),
415                <[u8; 64]>::try_from(&input[32..]),
416            ) else {
417                return false;
418            };
419            descriptor.initial_commitment == key_commitment(scheme, &public)
420                && ed25519::Pair::verify(
421                    &ed25519::Signature::from_raw(signature),
422                    &payload,
423                    &ed25519::Public::from_raw(public),
424                )
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    #![allow(clippy::unwrap_used, clippy::expect_used)]
432    use super::*;
433    use crate::keys::{CRYPTO_HASHED, DEFAULT_SS58_FORMAT};
434
435    fn transaction_payload(
436        account: &[u8; 32],
437        scheme: Scheme,
438        generation: u64,
439        next: &[u8; 32],
440        public: &[u8; 32],
441        call_and_extensions: &[u8],
442    ) -> Vec<u8> {
443        SignerAddress {
444            version: 1,
445            scheme,
446            account: *account,
447            generation,
448            public_key: *public,
449            next_commitment: *next,
450        }
451        .signing_hash(call_and_extensions)
452        .to_vec()
453    }
454    fn sign_test_transaction(
455        key: &Keypair,
456        call_and_extensions: &[u8],
457    ) -> Result<Vec<u8>, CoreError> {
458        key.sign_hashed(call_and_extensions)
459    }
460
461    #[test]
462    fn composable_schemes_preserve_mode_recovery_and_signatures() {
463        use crate::keys::{account_crypto_type, CRYPTO_ED25519, CRYPTO_SR25519};
464        let phrase = "bottom drive obey lake curtain smoke basket hold race lonely fit walk";
465        let mut addresses = std::collections::BTreeSet::new();
466        for scheme in [CRYPTO_SR25519, CRYPTO_ED25519] {
467            for mode in ["standard", "hashed"] {
468                let code = account_crypto_type(scheme, Some(mode)).unwrap();
469                let key = Keypair::from_mnemonic(phrase, code, None).unwrap();
470                assert_eq!(key.account_type(), mode);
471                assert!(addresses.insert(key.ss58_address()));
472                let recovered = Keypair::from_seed(key.seed_bytes().unwrap(), code).unwrap();
473                assert_eq!(recovered.public_key_bytes(), key.public_key_bytes());
474                if !super::super::is_hashed_crypto(code) {
475                    let signature = key.sign(b"message").unwrap();
476                    assert!(recovered.verify(b"message", &signature).unwrap());
477                    continue;
478                }
479                let public = key.public_only().unwrap();
480                assert_eq!(public.crypto_type(), code);
481                assert_eq!(
482                    key.hashed_descriptor().unwrap(),
483                    recovered.hashed_descriptor().unwrap()
484                );
485                assert!(public.sign_hashed(b"implication").is_err());
486                let initial = key.hashed_signing_public_key().unwrap();
487                let later = key.at_generation(42).unwrap();
488                assert_eq!(
489                    initial == later.hashed_signing_public_key().unwrap(),
490                    mode == "standard"
491                );
492                assert_eq!(
493                    later.hashed_current_commitment().unwrap()
494                        == later.hashed_next_commitment().unwrap(),
495                    mode == "standard"
496                );
497                assert_eq!(later.public_key_bytes(), key.public_key_bytes());
498                let signature = later.sign(b"message").unwrap();
499                assert!(public.verify(b"message", &signature).unwrap());
500                assert!(!public.verify(b"changed", &signature).unwrap());
501                let wrong_mode = account_crypto_type(
502                    scheme,
503                    Some(if mode == "standard" {
504                        "hashed"
505                    } else {
506                        "standard"
507                    }),
508                )
509                .unwrap();
510                assert!(!super::super::verify_with_crypto(
511                    wrong_mode,
512                    &key.public_key_bytes(),
513                    b"message",
514                    &signature
515                )
516                .unwrap_or(false));
517            }
518        }
519    }
520
521    #[test]
522    fn hashed_ed_proof_is_scheme_bound_and_retires_message_key() {
523        let key = Keypair::from_seed(&[31; 32], CRYPTO_HASHED_ED25519).unwrap();
524        assert!(key.sign(b"message").is_err());
525        let signer = key.at_generation(8).unwrap();
526        let bytes = sign_test_transaction(&signer, b"implication").unwrap();
527        let proof = Proof::decode(&mut bytes.as_slice()).unwrap();
528        assert_eq!(proof.generation, 8);
529        let payload = transaction_payload(
530            &key.public_key_bytes(),
531            Scheme::Ed25519,
532            8,
533            &proof.next_commitment,
534            &proof.public_key,
535            b"implication",
536        );
537        assert!(ed25519::Pair::verify(
538            &ed25519::Signature::from_raw(proof.signature),
539            payload,
540            &ed25519::Public::from_raw(proof.public_key)
541        ));
542        let wrong = transaction_payload(
543            &key.public_key_bytes(),
544            Scheme::Sr25519,
545            8,
546            &proof.next_commitment,
547            &proof.public_key,
548            b"implication",
549        );
550        assert!(!ed25519::Pair::verify(
551            &ed25519::Signature::from_raw(proof.signature),
552            wrong,
553            &ed25519::Public::from_raw(proof.public_key)
554        ));
555    }
556
557    #[test]
558    fn retired_sr_message_key_never_reveals_current_spending_key() {
559        let key = Keypair::from_seed(&[43; 32], CRYPTO_HASHED).unwrap();
560        assert!(key.sign(b"challenge").is_err());
561        let current = key.at_generation(1).unwrap();
562        let envelope = current.sign(b"challenge").unwrap();
563        assert_eq!(&envelope[42..74], &key.hashed_public_key().unwrap());
564        assert_ne!(&envelope[42..74], &current.hashed_public_key().unwrap());
565        assert!(key
566            .public_only()
567            .unwrap()
568            .verify(b"challenge", &envelope)
569            .unwrap());
570        assert!(!key.verify(b"changed challenge", &envelope).unwrap());
571        assert!(!Keypair::from_seed(&[44; 32], CRYPTO_HASHED)
572            .unwrap()
573            .verify(b"challenge", &envelope)
574            .unwrap());
575    }
576
577    const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
578
579    #[test]
580    fn recovery_derivation_v1_is_pinned() {
581        // Independently reproduced with RFC5869 HMAC-SHA256 extract/expand
582        // and the ordinary sr25519 from_seed API. These values are a backup
583        // compatibility contract, not a signature (sr25519 signing is randomized).
584        let key = Keypair::from_seed(&[3; 32], CRYPTO_HASHED).unwrap();
585        assert_eq!(
586            key.ss58_address(),
587            "5Du5D4jstmEn3QPytQLqgowz2huycvq7XvUFpY8u5PJTx6Dd"
588        );
589        assert_eq!(
590            hex::encode(key.hashed_descriptor().unwrap()),
591            "010123c2153ab6fc4d0a3f3cf4b2ffcb1bc0ecc5d6737ecb32addbc1f20fadb3223a"
592        );
593        assert_eq!(
594            hex::encode(key.hashed_public_key().unwrap()),
595            "4220dab7c75d2b0a2088639dc3d314d22f82493cfc86a0efa36e194bc5443612"
596        );
597        assert_eq!(
598            hex::encode(key.at_generation(872).unwrap().hashed_public_key().unwrap()),
599            "18885ee3441c25bd1c0ac0986959fcf33169ec2a6300e7a7a4572010463a7366"
600        );
601    }
602
603    #[test]
604    fn restore_any_generation_from_mnemonic_without_signing_history() {
605        let wallet = Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, None).unwrap();
606        assert_eq!(
607            wallet.ss58_address(),
608            "5ELvzckdbaShVHktHtSa7gH77AWawQSyfeRLXT1sbvquNjha"
609        );
610        let advanced = wallet.at_generation(872).unwrap();
611        let restored = Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, None)
612            .unwrap()
613            .at_generation(872)
614            .unwrap();
615        assert_eq!(wallet.public_key_bytes(), advanced.public_key_bytes());
616        assert_eq!(advanced.ss58_address(), restored.ss58_address());
617        assert_eq!(
618            advanced.hashed_public_key().unwrap(),
619            restored.hashed_public_key().unwrap()
620        );
621        assert_eq!(
622            advanced.hashed_next_commitment().unwrap(),
623            restored.hashed_next_commitment().unwrap()
624        );
625        assert_eq!(wallet.hashed_generation().unwrap(), 0);
626        assert_ne!(
627            wallet.hashed_public_key().unwrap(),
628            advanced.hashed_public_key().unwrap()
629        );
630        assert_ne!(
631            wallet.hashed_current_commitment().unwrap(),
632            advanced.hashed_current_commitment().unwrap()
633        );
634    }
635
636    #[test]
637    fn proof_binds_next_generation_and_full_transaction() {
638        let key = Keypair::from_seed(&[9; 32], CRYPTO_HASHED)
639            .unwrap()
640            .at_generation(4)
641            .unwrap();
642        let implication = b"\x01call extensions genesis runtime versions";
643        let encoded = sign_test_transaction(&key, implication).unwrap();
644        assert_eq!(encoded.len(), 136);
645        let proof = Proof::decode(&mut &encoded[..]).unwrap();
646        let payload = transaction_payload(
647            &key.public_key_bytes(),
648            Scheme::Sr25519,
649            4,
650            &proof.next_commitment,
651            &proof.public_key,
652            implication,
653        );
654        assert!(sr25519::Pair::verify(
655            &sr25519::Signature::from_raw(proof.signature),
656            payload,
657            &sr25519::Public::from_raw(proof.public_key)
658        ));
659        for altered in [
660            transaction_payload(
661                &key.public_key_bytes(),
662                Scheme::Sr25519,
663                5,
664                &proof.next_commitment,
665                &proof.public_key,
666                implication,
667            ),
668            transaction_payload(
669                &key.public_key_bytes(),
670                Scheme::Sr25519,
671                4,
672                &[0; 32],
673                &proof.public_key,
674                implication,
675            ),
676            transaction_payload(
677                &[0; 32],
678                Scheme::Sr25519,
679                4,
680                &proof.next_commitment,
681                &proof.public_key,
682                implication,
683            ),
684            transaction_payload(
685                &key.public_key_bytes(),
686                Scheme::Sr25519,
687                4,
688                &proof.next_commitment,
689                &proof.public_key,
690                b"different call",
691            ),
692        ] {
693            assert!(!sr25519::Pair::verify(
694                &sr25519::Signature::from_raw(proof.signature),
695                altered,
696                &sr25519::Public::from_raw(proof.public_key)
697            ));
698        }
699    }
700
701    #[test]
702    fn restored_current_key_verifies_but_old_and_sibling_keys_do_not() {
703        let original = Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, None).unwrap();
704        let previous = original.at_generation(40).unwrap();
705        let current = original.at_generation(41).unwrap();
706        let recovered = Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, None)
707            .unwrap()
708            .at_generation(41)
709            .unwrap();
710        let implication = b"\x01restored device transaction";
711        let proof =
712            Proof::decode(&mut &sign_test_transaction(&recovered, implication).unwrap()[..])
713                .unwrap();
714        let payload = transaction_payload(
715            &current.public_key_bytes(),
716            Scheme::Sr25519,
717            proof.generation,
718            &proof.next_commitment,
719            &proof.public_key,
720            implication,
721        );
722        assert_eq!(proof.generation, 41);
723        assert_eq!(
724            previous.hashed_next_commitment().unwrap(),
725            current.hashed_current_commitment().unwrap()
726        );
727        assert_eq!(
728            proof.next_commitment,
729            original.hashed_commitment(42).unwrap()
730        );
731        let signature = sr25519::Signature::from_raw(proof.signature);
732        assert!(sr25519::Pair::verify(
733            &signature,
734            payload,
735            &sr25519::Public::from_raw(current.hashed_public_key().unwrap())
736        ));
737        let sibling = Keypair::from_seed(&[19; 32], CRYPTO_HASHED)
738            .unwrap()
739            .at_generation(41)
740            .unwrap();
741        for other in [previous, sibling] {
742            assert_ne!(
743                key_commitment(Scheme::Sr25519, &other.hashed_public_key().unwrap()),
744                current.hashed_current_commitment().unwrap()
745            );
746            let other_proof =
747                Proof::decode(&mut &sign_test_transaction(&other, implication).unwrap()[..])
748                    .unwrap();
749            let other_signature = sr25519::Signature::from_raw(other_proof.signature);
750            // These are valid signatures from the old/sibling wallet, not
751            // random invalid bytes. Neither controls the expected current key.
752            let other_payload = transaction_payload(
753                &other.public_key_bytes(),
754                Scheme::Sr25519,
755                other_proof.generation,
756                &other_proof.next_commitment,
757                &other_proof.public_key,
758                implication,
759            );
760            assert!(sr25519::Pair::verify(
761                &other_signature,
762                other_payload,
763                &sr25519::Public::from_raw(other_proof.public_key)
764            ));
765            let expected_payload = transaction_payload(
766                &current.public_key_bytes(),
767                Scheme::Sr25519,
768                41,
769                &other_proof.next_commitment,
770                &other_proof.public_key,
771                implication,
772            );
773            assert!(!sr25519::Pair::verify(
774                &other_signature,
775                expected_payload,
776                &sr25519::Public::from_raw(current.hashed_public_key().unwrap())
777            ));
778        }
779        // Signing alone does not claim chain acceptance or consume a counter.
780        assert_eq!(recovered.hashed_generation().unwrap(), 41);
781        let retry =
782            Proof::decode(&mut &recovered.sign_hashed(b"replacement transaction").unwrap()[..])
783                .unwrap();
784        assert_eq!(retry.generation, 41);
785        assert_eq!(retry.public_key, proof.public_key);
786        assert_eq!(retry.next_commitment, proof.next_commitment);
787    }
788
789    #[test]
790    fn recovery_requires_the_same_mnemonic_and_derivation_passphrase() {
791        const OTHER_PHRASE: &str =
792            "legal winner thank year wave sausage worth useful legal winner thank yellow";
793        let original = Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, Some("correct passphrase"))
794            .unwrap()
795            .at_generation(73)
796            .unwrap();
797        let recovered = Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, Some("correct passphrase"))
798            .unwrap()
799            .at_generation(73)
800            .unwrap();
801        assert_eq!(
802            recovered.hashed_descriptor().unwrap(),
803            original.hashed_descriptor().unwrap()
804        );
805        assert_eq!(
806            recovered.hashed_current_commitment().unwrap(),
807            original.hashed_current_commitment().unwrap()
808        );
809        for incorrect in [
810            Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, None).unwrap(),
811            Keypair::from_mnemonic(PHRASE, CRYPTO_HASHED, Some("wrong passphrase")).unwrap(),
812            Keypair::from_mnemonic(OTHER_PHRASE, CRYPTO_HASHED, Some("correct passphrase"))
813                .unwrap(),
814        ] {
815            // BIP39 passphrases and other valid phrases create different
816            // wallets; the chain commitment is what detects the wrong backup.
817            assert_ne!(incorrect.ss58_address(), original.ss58_address());
818            assert_ne!(
819                incorrect.hashed_commitment(73).unwrap(),
820                original.hashed_current_commitment().unwrap()
821            );
822        }
823        assert!(Keypair::from_mnemonic("not a valid mnemonic", CRYPTO_HASHED, None).is_err());
824    }
825
826    #[test]
827    fn final_usable_generation_commits_terminal_key_without_wrapping() {
828        let wallet = Keypair::from_seed(&[23; 32], CRYPTO_HASHED).unwrap();
829        let last = wallet.at_generation(u64::MAX - 1).unwrap();
830        let terminal = wallet.at_generation(u64::MAX).unwrap();
831        let proof =
832            Proof::decode(&mut &last.sign_hashed(b"last authorization").unwrap()[..]).unwrap();
833        assert_eq!(proof.generation, u64::MAX - 1);
834        assert_eq!(
835            proof.next_commitment,
836            terminal.hashed_current_commitment().unwrap()
837        );
838        assert_ne!(
839            proof.next_commitment,
840            wallet.hashed_current_commitment().unwrap()
841        );
842        assert!(terminal.hashed_next_commitment().is_err());
843        assert!(terminal
844            .sign_hashed(b"must never wrap to generation zero")
845            .is_err());
846        assert_eq!(wallet.hashed_generation().unwrap(), 0);
847    }
848
849    #[test]
850    fn public_descriptor_preserves_identity_without_exposing_signer() {
851        let key = Keypair::from_seed(&[3; 32], CRYPTO_HASHED).unwrap();
852        let public = key.public_only().unwrap();
853        assert_eq!(key.public_key_bytes(), public.public_key_bytes());
854        assert_eq!(
855            key.hashed_descriptor().unwrap(),
856            public.hashed_descriptor().unwrap()
857        );
858        assert_eq!(
859            key.hashed_current_commitment().unwrap(),
860            public.hashed_current_commitment().unwrap()
861        );
862        assert!(public.private_key_bytes().is_none());
863        assert!(public.hashed_public_key().is_err());
864        assert!(public.sign_hashed(b"message").is_err());
865        assert!(key.sign(b"message").is_err());
866    }
867
868    #[test]
869    fn descriptors_fail_closed_and_derivation_is_separated() {
870        let key = Keypair::from_seed(&[3; 32], CRYPTO_HASHED).unwrap();
871        let raw = Keypair::from_seed(&[3; 32], super::super::CRYPTO_SR25519).unwrap();
872        assert_ne!(key.hashed_public_key().unwrap(), raw.public_key_bytes());
873        for offset in [0, 1] {
874            let mut descriptor = key.hashed_descriptor().unwrap();
875            descriptor[offset] = 255;
876            assert!(Keypair::from_hashed_descriptor(&descriptor, DEFAULT_SS58_FORMAT).is_err());
877        }
878        let mut trailing = key.hashed_descriptor().unwrap();
879        trailing.push(0);
880        assert!(Keypair::from_hashed_descriptor(&trailing, DEFAULT_SS58_FORMAT).is_err());
881        assert!(Keypair::from_uri("//Alice", CRYPTO_HASHED).is_err());
882    }
883
884    #[test]
885    fn hashed_ed25519_signing_key_has_a_wiping_owner_and_keeps_vectors() {
886        // Fails to compile if the derivation returns a non-wiping pair again.
887        fn owner(secret: Zeroizing<SigningKey>) -> Zeroizing<SigningKey> {
888            secret
889        }
890        let secret = owner(HashedKeypair::derive_ed(&[31; 32], 8).unwrap());
891        // Recorded with the v488 derivation through `ed25519::Pair::from_seed`
892        // (Ed25519 signing is deterministic), so keys, proofs and message
893        // envelopes are unchanged.
894        assert_eq!(
895            hex::encode(ed25519_public(&secret)),
896            "8b9c4ae043290453eb92a40d48f5074178031bc6623e60544ae4a2f5ab6542a1"
897        );
898        let key = Keypair::from_seed(&[31; 32], CRYPTO_HASHED_ED25519).unwrap();
899        assert_eq!(
900            key.ss58_address(),
901            "5EPuttWmgUjN8Wq2qKELKso6WrfRpBpgZpuNRzAwyAgQTXzo"
902        );
903        assert_eq!(
904            hex::encode(key.hashed_signing_public_key().unwrap()),
905            "87ba9efaab5ed270bf9fa5e8a4101283cd6ba1541f9166177778f1ca47aa88c7"
906        );
907        let signer = key.at_generation(8).unwrap();
908        assert_eq!(
909            hex::encode(signer.sign_hashed(b"implication").unwrap()),
910            "08000000000000008b9c4ae043290453eb92a40d48f5074178031bc6623e60544ae4a2f5ab6542a17d11decb0793d6c942bd149f0111f6e89a809b9438a355647213877069580c7952715702d31e438a3af305cf648a1ca1d376a5e371ac711d0faa883810639d3df553bbdf37044ad2b656d94d56011574b6673aab2222d22f9750780117a54f07"
911        );
912        assert_eq!(
913            hex::encode(key.at_generation(1).unwrap().sign(b"challenge").unwrap()),
914            "4254484d534730310103d49382c67690fe7a07e595daedccb680629a6102d4c8c6986a7ea0fae84548d787ba9efaab5ed270bf9fa5e8a4101283cd6ba1541f9166177778f1ca47aa88c7b0c5ac55ee389545d3086578a4f4d91119b9080d6f36e239c8bc6e8f3bc49825212c1e8f06de0a4c9386c109ca233d0b7b6dd5c82858fe3beef01ea954503404"
915        );
916        assert!(HashedKeypair::from_seed(&[31; 31], CRYPTO_HASHED_ED25519).is_err());
917    }
918}