Skip to main content

bittensor_core/keyfiles/
mod.rs

1//! Bittensor wallet keyfile encryption and JSON codec — compatible with the
2//! ``bittensor-wallet`` on-disk format (absorbed from `py-sp-core`).
3
4// Client-side code: slicing and arithmetic on locally validated buffers is
5// the norm here, and this crate never runs inside the runtime.
6#![allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
7
8use std::collections::HashMap;
9
10use base64::{engine::general_purpose, Engine as _};
11use fernet::Fernet;
12use pbkdf2::pbkdf2_hmac;
13use serde_json::json;
14use sha2::Sha256;
15use sodiumoxide::crypto::pwhash;
16use sodiumoxide::crypto::secretbox;
17use zeroize::{Zeroize, Zeroizing};
18
19use crate::error::CoreError;
20use crate::keys::{
21    ensure_sodium, is_hashed_crypto, is_pq_crypto, Keypair, CRYPTO_ED25519, CRYPTO_SR25519,
22};
23
24pub use crate::keyfile_env::{decode_password_from_environment, encode_password_for_environment};
25
26const NACL_SALT: &[u8] = b"\x13q\x83\xdf\xf1Z\t\xbc\x9c\x90\xb5Q\x879\xe9\xb1";
27const LEGACY_SALT: &[u8] = b"Iguesscyborgslikemyselfhaveatendencytobeparanoidaboutourorigins";
28
29fn key_err(msg: impl Into<String>) -> CoreError {
30    CoreError::Keyfile(msg.into())
31}
32
33pub fn keyfile_data_is_encrypted_nacl(keyfile_data: &[u8]) -> bool {
34    keyfile_data.starts_with(b"$NACL")
35}
36
37pub fn keyfile_data_is_encrypted_ansible(keyfile_data: &[u8]) -> bool {
38    keyfile_data.starts_with(b"$ANSIBLE_VAULT")
39}
40
41pub fn keyfile_data_is_encrypted_legacy(keyfile_data: &[u8]) -> bool {
42    keyfile_data.starts_with(b"gAAAAA")
43}
44
45pub fn keyfile_data_is_encrypted(keyfile_data: &[u8]) -> bool {
46    keyfile_data_is_encrypted_nacl(keyfile_data)
47        || keyfile_data_is_encrypted_ansible(keyfile_data)
48        || keyfile_data_is_encrypted_legacy(keyfile_data)
49}
50
51pub fn keyfile_data_encryption_method(keyfile_data: &[u8]) -> &'static str {
52    if keyfile_data_is_encrypted_nacl(keyfile_data) {
53        "NaCl"
54    } else if keyfile_data_is_encrypted_ansible(keyfile_data) {
55        "Ansible Vault"
56    } else if keyfile_data_is_encrypted_legacy(keyfile_data) {
57        "legacy"
58    } else {
59        "unknown"
60    }
61}
62
63fn derive_key(password: &[u8]) -> Result<secretbox::Key, CoreError> {
64    let salt = pwhash::argon2i13::Salt::from_slice(NACL_SALT)
65        .ok_or_else(|| key_err("invalid NACL salt"))?;
66    let mut key = secretbox::Key([0; secretbox::KEYBYTES]);
67    pwhash::argon2i13::derive_key(
68        &mut key.0,
69        password,
70        &salt,
71        pwhash::argon2i13::OPSLIMIT_SENSITIVE,
72        pwhash::argon2i13::MEMLIMIT_SENSITIVE,
73    )
74    .map_err(|_| key_err("failed to derive NaCl key"))?;
75    Ok(key)
76}
77
78fn nacl_decrypt(keyfile_data: &[u8], key: &secretbox::Key) -> Result<Vec<u8>, CoreError> {
79    let data = &keyfile_data[5..];
80    if data.len() < secretbox::NONCEBYTES {
81        return Err(key_err("invalid NaCl keyfile: too short"));
82    }
83    let nonce = secretbox::Nonce::from_slice(&data[..secretbox::NONCEBYTES])
84        .ok_or_else(|| key_err("invalid NaCl nonce"))?;
85    let ciphertext = &data[secretbox::NONCEBYTES..];
86    secretbox::open(ciphertext, &nonce, key)
87        .map_err(|_| CoreError::WrongPassword("wrong password for NaCl decryption".into()))
88}
89
90pub fn encrypt_keyfile_data(keyfile_data: &[u8], password: &str) -> Result<Vec<u8>, CoreError> {
91    ensure_sodium()?;
92    let key = derive_key(password.as_bytes())?;
93    let nonce = secretbox::gen_nonce();
94    let encrypted_data = secretbox::seal(keyfile_data, &nonce, &key);
95    let mut result = b"$NACL".to_vec();
96    result.extend_from_slice(&nonce.0);
97    result.extend_from_slice(&encrypted_data);
98    Ok(result)
99}
100
101pub fn get_password_from_environment(env_var_name: &str) -> Result<Option<String>, CoreError> {
102    if env_var_name.is_empty() {
103        return Err(CoreError::Crypto("env var name must not be empty".into()));
104    }
105    match std::env::var(env_var_name) {
106        Ok(encrypted_password_base64) => {
107            let encrypted_password_base64 = Zeroizing::new(encrypted_password_base64);
108            Ok(Some(decode_password_from_environment(
109                env_var_name,
110                &encrypted_password_base64,
111            )?))
112        }
113        Err(_) => Ok(None),
114    }
115}
116
117/// Compatibility wrapper that stores the password in the process environment.
118///
119/// This retains the legacy mutation behavior. Prefer the pure
120/// [`encode_password_for_environment`] helper when managing process state
121/// externally; `set_var` can race with reads from other threads.
122pub fn save_password_to_environment(
123    env_var_name: &str,
124    password: &str,
125) -> Result<String, CoreError> {
126    let encrypted = Zeroizing::new(encode_password_for_environment(env_var_name, password)?);
127    // Inherited btwallet behavior: set_var is not thread-safe and can race with
128    // concurrent getenv calls from other (non-GIL-holding) threads.
129    std::env::set_var(env_var_name, encrypted.as_str());
130    Ok(env_var_name.to_string())
131}
132
133fn legacy_decrypt(password: &str, keyfile_data: &[u8]) -> Result<Vec<u8>, CoreError> {
134    let mut key = [0u8; 32];
135    pbkdf2_hmac::<Sha256>(password.as_bytes(), LEGACY_SALT, 10_000_000, &mut key);
136    let fernet_key = Zeroizing::new(general_purpose::URL_SAFE.encode(key));
137    key.zeroize();
138    let fernet = Fernet::new(&fernet_key).ok_or_else(|| key_err("invalid legacy fernet key"))?;
139    let keyfile_data_str = std::str::from_utf8(keyfile_data)
140        .map_err(|e| key_err(format!("legacy keyfile is not valid utf-8: {e}")))?;
141    fernet
142        .decrypt(keyfile_data_str)
143        .map_err(|_| CoreError::WrongPassword("wrong password for legacy decryption".into()))
144}
145
146pub fn decrypt_keyfile_data(
147    keyfile_data: &[u8],
148    password: Option<&str>,
149) -> Result<Vec<u8>, CoreError> {
150    ensure_sodium()?;
151    let password = password.ok_or_else(|| key_err("password required to decrypt keyfile"))?;
152
153    if keyfile_data_is_encrypted_nacl(keyfile_data) {
154        let key = derive_key(password.as_bytes())?;
155        return nacl_decrypt(keyfile_data, &key);
156    }
157
158    if keyfile_data_is_encrypted_ansible(keyfile_data) {
159        let decrypted = ansible_vault::decrypt_vault(keyfile_data, password).map_err(|_| {
160            CoreError::WrongPassword("wrong password for ansible vault decryption".into())
161        })?;
162        return Ok(decrypted);
163    }
164
165    if keyfile_data_is_encrypted_legacy(keyfile_data) {
166        return legacy_decrypt(password, keyfile_data);
167    }
168
169    Err(key_err("invalid or unknown keyfile encryption method"))
170}
171
172pub fn serialized_keypair_to_keyfile_data(keypair: &Keypair) -> Result<Vec<u8>, CoreError> {
173    let mut data: HashMap<&str, serde_json::Value> = HashMap::new();
174
175    let public_key = keypair.public_key_bytes();
176    let public_key_str = hex::encode(public_key);
177    data.insert("accountId", json!(format!("0x{public_key_str}")));
178    data.insert("publicKey", json!(format!("0x{public_key_str}")));
179
180    // Legacy btwallet keyfiles always carried secretPhrase/secretSeed, and
181    // third-party parsers (subnet tooling, struct-based Rust readers) can
182    // require them. Write them whenever the keypair retained them so files
183    // created here stay parseable by legacy readers.
184    if let Some(mnemonic) = keypair.mnemonic() {
185        data.insert("secretPhrase", json!(mnemonic));
186    }
187    if let Some(seed) = keypair.seed_bytes() {
188        data.insert("secretSeed", json!(format!("0x{}", hex::encode(seed))));
189    }
190
191    if let Some(private_key) = keypair.private_key_bytes() {
192        let private_key_str = hex::encode(private_key);
193        data.insert("privateKey", json!(format!("0x{private_key_str}")));
194    }
195
196    data.insert("ss58Address", json!(keypair.ss58_address()));
197    data.insert("cryptoType", json!(keypair.crypto_type()));
198    data.insert("accountType", json!(keypair.account_type()));
199    data.insert("signingScheme", json!(keypair.signing_scheme()));
200    if is_hashed_crypto(keypair.crypto_type()) || is_pq_crypto(keypair.crypto_type()) {
201        let field = if is_pq_crypto(keypair.crypto_type()) {
202            data.insert("walletVersion", json!(2));
203            "mldsaDescriptor"
204        } else {
205            "hashedDescriptor"
206        };
207        if let Ok(descriptor) = keypair.authorization_descriptor() {
208            data.insert(field, json!(format!("0x{}", hex::encode(descriptor))));
209        }
210        // A persisted rotating generation would be stale after use on another
211        // machine. Restore the identity; select rotating authority from chain.
212    }
213
214    serde_json::to_string(&data)
215        .map(|json_data| json_data.into_bytes())
216        .map_err(|error| key_err(format!("serialization error: {error}")))
217}
218
219/// Stored ss58Address, including the legacy leading-space `" ss58Address"`
220/// key some old btwallet files carry.
221fn stored_ss58(keyfile_dict: &serde_json::Value) -> Option<&str> {
222    keyfile_dict
223        .get("ss58Address")
224        .or_else(|| keyfile_dict.get(" ss58Address"))
225        .and_then(|value| value.as_str())
226}
227
228/// Derive a keypair and cross-check it against the keyfile's stored
229/// ss58Address. Legacy keyfiles sometimes omit or mislabel cryptoType, so on
230/// a mismatch the other crypto type is tried before giving up: the stored
231/// address is the ground truth for which key the file holds.
232fn resolve_checked<F>(
233    keyfile_dict: &serde_json::Value,
234    crypto_type: u8,
235    derived_from: &str,
236    derive: F,
237) -> Result<Keypair, CoreError>
238where
239    F: Fn(u8) -> Result<Keypair, CoreError>,
240{
241    let keypair = derive(crypto_type)?;
242    let Some(stored) = stored_ss58(keyfile_dict) else {
243        return Ok(keypair);
244    };
245    if keypair.ss58_address() == stored {
246        return Ok(keypair);
247    }
248    let alternate = if crypto_type == CRYPTO_SR25519 {
249        CRYPTO_ED25519
250    } else {
251        CRYPTO_SR25519
252    };
253    if let Ok(alternate_keypair) = derive(alternate) {
254        if alternate_keypair.ss58_address() == stored {
255            return Ok(alternate_keypair);
256        }
257    }
258    Err(key_err(format!(
259        "ss58Address in keyfile does not match the address derived from {derived_from} \
260         (check the keyfile's cryptoType)",
261    )))
262}
263
264/// Whether raw (non-JSON) keyfile content looks like a bare BIP39 phrase, as
265/// written by pre-JSON-era bittensor wallets.
266fn looks_like_mnemonic(text: &str) -> bool {
267    let words: Vec<&str> = text.split_whitespace().collect();
268    matches!(words.len(), 12 | 15 | 18 | 21 | 24)
269        && words
270            .iter()
271            .all(|word| word.chars().all(|c| c.is_ascii_lowercase()))
272}
273
274/// Fallback for raw (non-JSON) keyfile payloads: a bare hex seed/private key
275/// or a bare mnemonic, as written by pre-JSON-era bittensor wallets.
276fn keypair_from_raw_text(text: &str) -> Option<Keypair> {
277    let trimmed = text.trim();
278    let hex_body = trimmed.strip_prefix("0x").unwrap_or(trimmed);
279    if matches!(hex_body.len(), 64 | 128) && hex_body.chars().all(|c| c.is_ascii_hexdigit()) {
280        if hex_body.len() == 64 {
281            return hex::decode(hex_body)
282                .ok()
283                .and_then(|bytes| Keypair::from_seed(&bytes, CRYPTO_SR25519).ok());
284        }
285        return Keypair::from_private_key(trimmed, CRYPTO_SR25519).ok();
286    }
287    if looks_like_mnemonic(trimmed) {
288        if let Ok(keypair) = Keypair::from_mnemonic(trimmed, CRYPTO_SR25519, None) {
289            return Some(keypair);
290        }
291    }
292    None
293}
294
295fn deserialize_hashed(keyfile: &serde_json::Value, crypto_type: u8) -> Result<Keypair, CoreError> {
296    let descriptor_field = if is_pq_crypto(crypto_type) {
297        if keyfile
298            .get("walletVersion")
299            .and_then(serde_json::Value::as_u64)
300            != Some(2)
301            || keyfile.get("hashedDescriptor").is_some()
302        {
303            return Err(key_err(
304                "post-quantum keyfiles require walletVersion 2 and mldsaDescriptor",
305            ));
306        }
307        "mldsaDescriptor"
308    } else {
309        if keyfile.get("mldsaDescriptor").is_some() {
310            return Err(key_err("v2 descriptor requires post-quantum cryptoType"));
311        }
312        "hashedDescriptor"
313    };
314    let public = match keyfile.get(descriptor_field) {
315        Some(serde_json::Value::String(encoded)) => {
316            let descriptor = hex::decode(encoded.trim_start_matches("0x"))
317                .map_err(|_| key_err("invalid hashed descriptor encoding"))?;
318            Some(Keypair::from_hashed_descriptor(&descriptor, 42)?)
319        }
320        Some(_) => return Err(key_err("hashedDescriptor must be a hex string")),
321        None => None,
322    };
323    let private = if let Some(phrase) = keyfile.get("secretPhrase") {
324        let phrase = phrase
325            .as_str()
326            .ok_or_else(|| key_err("invalid hashed secretPhrase"))?;
327        Some(Keypair::from_mnemonic(phrase, crypto_type, None)?)
328    } else if let Some(seed) = keyfile
329        .get("secretSeed")
330        .or_else(|| keyfile.get("privateKey"))
331    {
332        let seed = seed
333            .as_str()
334            .ok_or_else(|| key_err("invalid hashed master seed"))?;
335        let seed = Zeroizing::new(
336            hex::decode(seed.trim_start_matches("0x"))
337                .map_err(|_| key_err("invalid hashed master seed encoding"))?,
338        );
339        Some(Keypair::from_seed(&seed, crypto_type)?)
340    } else {
341        None
342    };
343    if private.is_some() && public.is_none() {
344        return Err(key_err(
345            "hashed secret keyfiles require their versioned hashedDescriptor",
346        ));
347    }
348    let keypair = match (private, public) {
349        (Some(private), Some(public)) => {
350            if private.authorization_descriptor()? != public.authorization_descriptor()? {
351                return Err(key_err(
352                    "hashedDescriptor does not match the recovered master seed",
353                ));
354            }
355            private
356        }
357        (None, Some(public)) => public,
358        (None, None) => Keypair::new(stored_ss58(keyfile), None, crypto_type, 42)?,
359        (Some(_), None) => return Err(key_err("missing hashed descriptor")),
360    };
361    for (field, expected) in [
362        ("accountType", keypair.account_type()),
363        ("signingScheme", keypair.signing_scheme()),
364    ] {
365        if let Some(value) = keyfile.get(field) {
366            if value.as_str() != Some(expected) {
367                return Err(key_err(format!("{field} does not match hashedDescriptor")));
368            }
369        }
370    }
371    if keypair.crypto_type() != crypto_type {
372        return Err(key_err("cryptoType does not match hashedDescriptor"));
373    }
374    if let Some(address) = stored_ss58(keyfile) {
375        if keypair.ss58_address() != address {
376            return Err(key_err("ss58Address does not match hashedDescriptor"));
377        }
378    }
379    for field in ["accountId", "publicKey"] {
380        if let Some(stored) = keyfile.get(field) {
381            let encoded = stored
382                .as_str()
383                .ok_or_else(|| key_err("invalid hashed account ID"))?;
384            let bytes = hex::decode(encoded.trim_start_matches("0x"))
385                .map_err(|_| key_err("invalid hashed account ID encoding"))?;
386            if bytes != keypair.public_key_bytes() {
387                return Err(key_err("account ID does not match hashedDescriptor"));
388            }
389        }
390    }
391    Ok(keypair)
392}
393
394pub fn deserialize_keypair_from_keyfile_data(keyfile_data: &[u8]) -> Result<Keypair, CoreError> {
395    let decoded = std::str::from_utf8(keyfile_data).map_err(|_| {
396        if keyfile_data_is_encrypted(keyfile_data) {
397            key_err("keyfile is encrypted; decrypt it with its password first")
398        } else {
399            key_err("failed to decode keyfile data: not utf-8 text (unknown or corrupt format)")
400        }
401    })?;
402
403    let keyfile_dict: serde_json::Value = match serde_json::from_str(decoded) {
404        Ok(value) => value,
405        Err(_) => {
406            if let Some(keypair) = keypair_from_raw_text(decoded) {
407                return Ok(keypair);
408            }
409            return Err(key_err(
410                "failed to parse keyfile data: not keyfile JSON, a raw hex seed, or a mnemonic",
411            ));
412        }
413    };
414
415    // A polkadot.js / mobile-app keystore export is valid JSON but a wholly
416    // different (password-encrypted) format; name it instead of failing with
417    // a generic parse error.
418    if keyfile_dict.get("encoded").is_some() && keyfile_dict.get("encoding").is_some() {
419        return Err(key_err(
420            "this keyfile is a polkadot.js / mobile-app JSON export, not a btcli keyfile; \
421             import it with `btcli wallet regen-coldkey --json-path <file>`",
422        ));
423    }
424
425    // Historical writers disagree on the cryptoType JSON type: python btwallet
426    // wrote a number, some JS tooling wrote a numeric string.
427    let crypto_type = keyfile_dict
428        .get("cryptoType")
429        .and_then(|value| match value {
430            serde_json::Value::Number(number) => number.to_string().parse::<u8>().ok(),
431            serde_json::Value::String(text) => text.trim().parse::<u8>().ok(),
432            _ => None,
433        })
434        .unwrap_or(CRYPTO_SR25519);
435
436    if is_hashed_crypto(crypto_type) || is_pq_crypto(crypto_type) {
437        return deserialize_hashed(&keyfile_dict, crypto_type);
438    }
439    if keyfile_dict.get("hashedDescriptor").is_some()
440        || keyfile_dict.get("mldsaDescriptor").is_some()
441    {
442        return Err(key_err(
443            "wallet descriptors require a hashed or post-quantum cryptoType",
444        ));
445    }
446
447    if let Some(secret_phrase) = keyfile_dict
448        .get("secretPhrase")
449        .and_then(|value| value.as_str())
450    {
451        return resolve_checked(&keyfile_dict, crypto_type, "secretPhrase", |ct| {
452            Keypair::from_mnemonic(secret_phrase, ct, None)
453        });
454    }
455
456    if let Some(seed) = keyfile_dict
457        .get("secretSeed")
458        .and_then(|value| value.as_str())
459    {
460        let seed = seed.trim_start_matches("0x");
461        let seed_bytes =
462            hex::decode(seed).map_err(|error| key_err(format!("invalid secret seed: {error}")))?;
463        return resolve_checked(&keyfile_dict, crypto_type, "secretSeed", |ct| {
464            Keypair::from_seed(&seed_bytes, ct)
465        });
466    }
467
468    if let Some(private_key) = keyfile_dict
469        .get("privateKey")
470        .and_then(|value| value.as_str())
471    {
472        return resolve_checked(&keyfile_dict, crypto_type, "privateKey", |ct| {
473            Keypair::from_private_key(private_key, ct)
474        });
475    }
476
477    if let Some(ss58) = keyfile_dict
478        .get("ss58Address")
479        .and_then(|value| value.as_str())
480    {
481        return Keypair::new(Some(ss58), None, crypto_type, 42);
482    }
483
484    let found_fields = keyfile_dict
485        .as_object()
486        .map(|object| object.keys().cloned().collect::<Vec<_>>().join(", "))
487        .unwrap_or_else(|| "none".to_string());
488    Err(key_err(format!(
489        "keypair could not be created from keyfile data: no secretPhrase, secretSeed, \
490         privateKey, or ss58Address field (found: {found_fields})",
491    )))
492}
493
494#[cfg(test)]
495mod tests {
496    #![allow(clippy::unwrap_used, clippy::expect_used)]
497
498    use super::*;
499    use crate::keys::CRYPTO_ED25519;
500    use crate::keys::CRYPTO_HASHED;
501
502    #[test]
503    fn new_account_modes_roundtrip_and_reject_mislabeled_backups() {
504        for code in [crate::keys::CRYPTO_HASHED_ED25519] {
505            let key = Keypair::from_seed(&[37; 32], code).unwrap();
506            for original in [key.public_only().unwrap(), key] {
507                let bytes = serialized_keypair_to_keyfile_data(&original).unwrap();
508                let restored = deserialize_keypair_from_keyfile_data(&bytes).unwrap();
509                assert_eq!(restored.crypto_type(), code);
510                assert_eq!(
511                    restored.hashed_descriptor().unwrap(),
512                    original.hashed_descriptor().unwrap()
513                );
514                for field in ["accountType", "signingScheme"] {
515                    let mut data: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
516                    data[field] = json!("wrong");
517                    assert!(deserialize_keypair_from_keyfile_data(
518                        &serde_json::to_vec(&data).unwrap()
519                    )
520                    .is_err());
521                }
522                let mut legacy: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
523                legacy.as_object_mut().unwrap().remove("accountType");
524                legacy.as_object_mut().unwrap().remove("signingScheme");
525                assert_eq!(
526                    deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&legacy).unwrap())
527                        .unwrap()
528                        .crypto_type(),
529                    code
530                );
531            }
532        }
533    }
534
535    fn test_mnemonic() -> String {
536        "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
537            .to_string()
538    }
539
540    #[test]
541    fn nacl_roundtrip() {
542        let message = br#"{"ss58Address":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}"#;
543        let encrypted = encrypt_keyfile_data(message, "test-password").unwrap();
544        assert!(keyfile_data_is_encrypted_nacl(&encrypted));
545        let decrypted = decrypt_keyfile_data(&encrypted, Some("test-password")).unwrap();
546        assert_eq!(decrypted, message);
547    }
548
549    #[test]
550    fn env_password_roundtrip() {
551        let env_var = "BT_PW_TEST_WALLET_COLDKEY";
552        save_password_to_environment(env_var, "test-password").unwrap();
553        let recovered = get_password_from_environment(env_var).unwrap();
554        assert_eq!(recovered.as_deref(), Some("test-password"));
555        std::env::remove_var(env_var);
556    }
557
558    #[test]
559    fn ansible_vault_roundtrip() {
560        let original = br#"{"ss58Address":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}"#;
561        let encrypted =
562            ansible_vault::encrypt_vault(&original[..], "test-password").expect("ansible encrypt");
563        assert!(keyfile_data_is_encrypted_ansible(encrypted.as_bytes()));
564        let decrypted = decrypt_keyfile_data(encrypted.as_bytes(), Some("test-password"))
565            .expect("ansible decrypt");
566        assert_eq!(decrypted, original);
567    }
568
569    #[test]
570    fn legacy_fernet_roundtrip() {
571        let original = br#"{"ss58Address":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}"#;
572        let mut key = [0u8; 32];
573        pbkdf2_hmac::<Sha256>(b"test-password", LEGACY_SALT, 10_000_000, &mut key);
574        let fernet_key = general_purpose::URL_SAFE.encode(key);
575        let fernet = Fernet::new(&fernet_key).expect("fernet key");
576        let encrypted = fernet.encrypt(original);
577        assert!(keyfile_data_is_encrypted_legacy(encrypted.as_bytes()));
578        let decrypted = decrypt_keyfile_data(encrypted.as_bytes(), Some("test-password")).unwrap();
579        assert_eq!(decrypted, original);
580    }
581
582    #[test]
583    fn sr25519_keyfile_roundtrip() {
584        let original = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap();
585        let data = serialized_keypair_to_keyfile_data(&original).unwrap();
586        let restored = deserialize_keypair_from_keyfile_data(&data).unwrap();
587        assert_eq!(restored.crypto_type(), CRYPTO_SR25519);
588        assert_eq!(restored.ss58_address(), original.ss58_address());
589    }
590
591    #[test]
592    fn hashed_private_and_public_keyfiles_preserve_recovery_descriptor() {
593        let original = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_HASHED, None)
594            .unwrap()
595            .at_generation(72)
596            .unwrap();
597        let private = serialized_keypair_to_keyfile_data(&original).unwrap();
598        let recovered = deserialize_keypair_from_keyfile_data(&private)
599            .unwrap()
600            .at_generation(72)
601            .unwrap();
602        assert_eq!(
603            recovered.hashed_public_key().unwrap(),
604            original.hashed_public_key().unwrap()
605        );
606        assert_eq!(
607            recovered.hashed_next_commitment().unwrap(),
608            original.hashed_next_commitment().unwrap()
609        );
610        let public = original.public_only().unwrap();
611        let public_data = serialized_keypair_to_keyfile_data(&public).unwrap();
612        let public_recovered = deserialize_keypair_from_keyfile_data(&public_data).unwrap();
613        assert_eq!(public_recovered.ss58_address(), original.ss58_address());
614        assert_eq!(
615            public_recovered.hashed_descriptor().unwrap(),
616            original.hashed_descriptor().unwrap()
617        );
618        assert!(public_recovered.private_key_bytes().is_none());
619        assert!(!String::from_utf8(public_data).unwrap().contains("secret"));
620    }
621
622    #[test]
623    fn hashed_encrypted_original_backup_restores_after_later_rotations() {
624        use codec::Decode;
625        use sp_core::{sr25519, Pair};
626        use subtensor_hashed::Proof;
627
628        let original = Keypair::from_mnemonic(
629            &test_mnemonic(),
630            CRYPTO_HASHED,
631            Some("mnemonic derivation passphrase"),
632        )
633        .unwrap();
634        let backup = serialized_keypair_to_keyfile_data(&original).unwrap();
635        let json: serde_json::Value = serde_json::from_slice(&backup).unwrap();
636        // The encryption password and BIP39 derivation passphrase have
637        // separate purposes. A phrase alone cannot recover this wallet.
638        assert!(json.get("secretPhrase").is_none());
639        assert!(json.get("secretSeed").is_some());
640        drop(json);
641        let encrypted = encrypt_keyfile_data(&backup, "keyfile encryption password").unwrap();
642        let active = original.at_generation(9_001).unwrap();
643        let account = active.public_key_bytes();
644        let public_key = active.hashed_public_key().unwrap();
645        let commitment = active.hashed_current_commitment().unwrap();
646        let next_commitment = active.hashed_next_commitment().unwrap();
647        drop(active);
648        drop(original);
649        drop(backup);
650
651        assert!(matches!(
652            decrypt_keyfile_data(&encrypted, Some("wrong encryption password")),
653            Err(CoreError::WrongPassword(_)),
654        ));
655        let mut altered = encrypted.clone();
656        *altered.last_mut().unwrap() ^= 1;
657        assert!(matches!(
658            decrypt_keyfile_data(&altered, Some("keyfile encryption password")),
659            Err(CoreError::WrongPassword(_)),
660        ));
661        let decrypted =
662            decrypt_keyfile_data(&encrypted, Some("keyfile encryption password")).unwrap();
663        let restored = deserialize_keypair_from_keyfile_data(&decrypted).unwrap();
664        assert_eq!(restored.hashed_generation().unwrap(), 0);
665        // A new device needs only the original backup and the public chain
666        // generation; no locally remembered signatures or later backups.
667        let restored = restored.at_generation(9_001).unwrap();
668        assert_eq!(restored.public_key_bytes(), account);
669        assert_eq!(restored.hashed_public_key().unwrap(), public_key);
670        assert_eq!(restored.hashed_current_commitment().unwrap(), commitment);
671        assert_eq!(restored.hashed_next_commitment().unwrap(), next_commitment);
672        let implication = b"first transaction after restoring";
673        let encoded = restored.sign_hashed(implication).unwrap();
674        let proof = Proof::decode(&mut &encoded[..]).unwrap();
675        assert_eq!(proof.generation, 9_001);
676        assert_eq!(proof.public_key, public_key);
677        assert_eq!(proof.next_commitment, next_commitment);
678        assert!(sr25519::Pair::verify(
679            &sr25519::Signature::from_raw(proof.signature),
680            subtensor_hashed::SignerAddress {
681                version: 1,
682                scheme: subtensor_hashed::Scheme::Sr25519,
683                account,
684                generation: proof.generation,
685                public_key,
686                next_commitment,
687            }
688            .signing_hash(implication),
689            &sr25519::Public::from_raw(public_key)
690        ));
691    }
692
693    #[test]
694    fn hashed_keyfile_import_paths_check_the_same_descriptor() {
695        let original = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_HASHED, None).unwrap();
696        let encoded = serialized_keypair_to_keyfile_data(&original).unwrap();
697        let full: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
698        for secret_field in ["secretPhrase", "secretSeed", "privateKey"] {
699            let mut one_secret = full.clone();
700            for other in ["secretPhrase", "secretSeed", "privateKey"] {
701                if other != secret_field {
702                    one_secret.as_object_mut().unwrap().remove(other);
703                }
704            }
705            let restored =
706                deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&one_secret).unwrap())
707                    .unwrap();
708            assert_eq!(restored.ss58_address(), original.ss58_address());
709            assert_eq!(
710                restored.hashed_commitment(301).unwrap(),
711                original.hashed_commitment(301).unwrap()
712            );
713            one_secret[secret_field] = if secret_field == "secretPhrase" {
714                json!("legal winner thank year wave sausage worth useful legal winner thank yellow")
715            } else {
716                json!(format!("0x{}", "13".repeat(32)))
717            };
718            assert!(
719                deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&one_secret).unwrap())
720                    .is_err(),
721                "a mismatched {secret_field} must not silently select another account"
722            );
723        }
724    }
725
726    #[test]
727    fn hashed_keyfiles_never_fall_back_to_classical_or_unknown_profiles() {
728        let original = Keypair::from_seed(&[6; 32], CRYPTO_HASHED).unwrap();
729        let bytes = serialized_keypair_to_keyfile_data(&original).unwrap();
730        let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
731        let sibling = Keypair::from_seed(&[7; 32], CRYPTO_HASHED).unwrap();
732        for update in [
733            ("cryptoType", json!(CRYPTO_SR25519)),
734            ("hashedDescriptor", json!("0xff01")),
735            ("accountId", json!(format!("0x{}", "00".repeat(32)))),
736            ("secretSeed", json!(format!("0x{}", "01".repeat(32)))),
737            (
738                "hashedDescriptor",
739                json!(format!(
740                    "0x{}",
741                    hex::encode(sibling.hashed_descriptor().unwrap())
742                )),
743            ),
744            (
745                "publicKey",
746                json!(format!("0x{}", hex::encode(sibling.public_key_bytes()))),
747            ),
748            ("ss58Address", json!(sibling.ss58_address())),
749        ] {
750            let mut altered = value.clone();
751            altered[update.0] = update.1;
752            assert!(
753                deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&altered).unwrap())
754                    .is_err()
755            );
756        }
757        let mut missing = value;
758        missing.as_object_mut().unwrap().remove("hashedDescriptor");
759        assert!(
760            deserialize_keypair_from_keyfile_data(&serde_json::to_vec(&missing).unwrap()).is_err()
761        );
762    }
763
764    #[test]
765    fn legacy_keyfile_without_crypto_type_defaults_sr25519() {
766        let expected = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap();
767        let json = format!(
768            r#"{{"secretPhrase":"{}","ss58Address":"{}"}}"#,
769            test_mnemonic(),
770            expected.ss58_address()
771        );
772        let keypair = deserialize_keypair_from_keyfile_data(json.as_bytes()).unwrap();
773        assert_eq!(keypair.crypto_type(), CRYPTO_SR25519);
774        assert_eq!(keypair.ss58_address(), expected.ss58_address());
775    }
776
777    #[test]
778    fn ed25519_keyfile_roundtrip() {
779        let original = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_ED25519, None).unwrap();
780        let data = serialized_keypair_to_keyfile_data(&original).unwrap();
781        let restored = deserialize_keypair_from_keyfile_data(&data).unwrap();
782        assert_eq!(restored.crypto_type(), CRYPTO_ED25519);
783        assert_eq!(restored.ss58_address(), original.ss58_address());
784    }
785
786    #[test]
787    fn mnemonic_keypair_writes_legacy_secret_fields() {
788        let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap();
789        let data = serialized_keypair_to_keyfile_data(&keypair).unwrap();
790        let parsed: serde_json::Value = serde_json::from_slice(&data).unwrap();
791        assert_eq!(
792            parsed.get("secretPhrase").and_then(|v| v.as_str()),
793            Some(test_mnemonic().as_str())
794        );
795        let seed = parsed
796            .get("secretSeed")
797            .and_then(|v| v.as_str())
798            .expect("secretSeed present");
799        assert!(seed.starts_with("0x") && seed.len() == 66);
800        assert!(parsed.get("privateKey").is_some());
801        assert!(parsed.get("accountId").is_some());
802
803        // The seed alone re-derives the same key.
804        let seed_bytes = hex::decode(seed.trim_start_matches("0x")).unwrap();
805        let from_seed = Keypair::from_seed(&seed_bytes, CRYPTO_SR25519).unwrap();
806        assert_eq!(from_seed.ss58_address(), keypair.ss58_address());
807    }
808
809    #[test]
810    fn mnemonic_with_derivation_password_omits_phrase_keeps_seed() {
811        let keypair =
812            Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, Some("hunter2")).unwrap();
813        let data = serialized_keypair_to_keyfile_data(&keypair).unwrap();
814        let parsed: serde_json::Value = serde_json::from_slice(&data).unwrap();
815        assert!(parsed.get("secretPhrase").is_none());
816        assert!(parsed.get("secretSeed").is_some());
817        let restored = deserialize_keypair_from_keyfile_data(&data).unwrap();
818        assert_eq!(restored.ss58_address(), keypair.ss58_address());
819    }
820
821    #[test]
822    fn crypto_type_as_string_is_accepted() {
823        let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_ED25519, None).unwrap();
824        let json = format!(
825            r#"{{"secretPhrase":"{}","cryptoType":"{}","ss58Address":"{}"}}"#,
826            test_mnemonic(),
827            CRYPTO_ED25519,
828            keypair.ss58_address()
829        );
830        let restored = deserialize_keypair_from_keyfile_data(json.as_bytes()).unwrap();
831        assert_eq!(restored.crypto_type(), CRYPTO_ED25519);
832        assert_eq!(restored.ss58_address(), keypair.ss58_address());
833    }
834
835    #[test]
836    fn missing_crypto_type_recovered_from_stored_ss58() {
837        // A legacy ed25519 keyfile without cryptoType: the sr25519 default
838        // mismatches the stored address, so the reader retries as ed25519.
839        let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_ED25519, None).unwrap();
840        let json = format!(
841            r#"{{"secretPhrase":"{}","ss58Address":"{}"}}"#,
842            test_mnemonic(),
843            keypair.ss58_address()
844        );
845        let restored = deserialize_keypair_from_keyfile_data(json.as_bytes()).unwrap();
846        assert_eq!(restored.crypto_type(), CRYPTO_ED25519);
847        assert_eq!(restored.ss58_address(), keypair.ss58_address());
848    }
849
850    #[test]
851    fn stored_ss58_mismatch_is_rejected() {
852        let json = format!(
853            r#"{{"secretPhrase":"{}","ss58Address":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}}"#,
854            test_mnemonic()
855        );
856        let error = deserialize_keypair_from_keyfile_data(json.as_bytes())
857            .err()
858            .expect("mismatch must fail");
859        assert!(error.to_string().contains("does not match"));
860    }
861
862    #[test]
863    fn raw_hex_seed_fallback() {
864        let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap();
865        let seed_hex = format!("0x{}", hex::encode(keypair.seed_bytes().unwrap()));
866        let restored = deserialize_keypair_from_keyfile_data(seed_hex.as_bytes()).unwrap();
867        assert_eq!(restored.ss58_address(), keypair.ss58_address());
868    }
869
870    #[test]
871    fn raw_hex_private_key_fallback() {
872        let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap();
873        let private_key = keypair.private_key_bytes().unwrap();
874        assert_eq!(private_key.len(), 64);
875        let private_hex = format!("0x{}", hex::encode(private_key));
876        let restored = deserialize_keypair_from_keyfile_data(private_hex.as_bytes()).unwrap();
877        assert_eq!(restored.ss58_address(), keypair.ss58_address());
878    }
879
880    #[test]
881    fn raw_mnemonic_fallback() {
882        let keypair = Keypair::from_mnemonic(&test_mnemonic(), CRYPTO_SR25519, None).unwrap();
883        let restored = deserialize_keypair_from_keyfile_data(test_mnemonic().as_bytes()).unwrap();
884        assert_eq!(restored.ss58_address(), keypair.ss58_address());
885    }
886
887    #[test]
888    fn polkadotjs_export_gets_actionable_error() {
889        let json = r#"{"encoded":"abc","encoding":{"content":["pkcs8","sr25519"],"type":["scrypt","xsalsa20-poly1305"],"version":"3"},"address":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY","meta":{}}"#;
890        let error = deserialize_keypair_from_keyfile_data(json.as_bytes())
891            .err()
892            .expect("polkadotjs export must fail");
893        assert!(error.to_string().contains("regen-coldkey --json-path"));
894    }
895
896    #[test]
897    fn unknown_json_error_names_found_fields() {
898        let error = deserialize_keypair_from_keyfile_data(br#"{"foo":1}"#)
899            .err()
900            .expect("unknown fields must fail");
901        assert!(error.to_string().contains("foo"));
902    }
903}