1#![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
117pub 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 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 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 }
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
219fn 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
228fn 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
264fn 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
274fn 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 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 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 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 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 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 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}