montana/Монтана-Протокол/Код/crates/mt-mnemonic/tests/e2e_recovery.rs

162 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// spec, раздел "Ключи → Мнемоника и seed". End-to-end recovery flow per [C-4]:
// от entropy через mnemonic + master_seed + per-role HKDF до **terminal observable
// outputs**: account_id (32B), node_id (32B), а также все 6 keypair частей
// (account pk/sk, node pk/sk, mlkem pk/sk).
//
// Идемпотентность: повторный прогон ВСЕЙ цепочки на той же entropy производит
// byte-identical terminal IDs и keypair bytes — это гарантия recovery flow
// (восстановление identity на новом устройстве из 24 слов).
use mt_codec::domain;
use mt_crypto::{
keypair_from_seed, keypair_from_seed_mlkem, sha256_raw, MlkemPublicKey, MlkemSecretKey,
PublicKey, SecretKey, SuiteId,
};
use mt_mnemonic::{
entropy_to_mnemonic, mldsa_seed_for_role, mlkem_seed_for_role, mnemonic_to_master_seed,
};
// Terminal observable IDs (per spec):
//
// account_id = SHA-256("mt-account" || suite_id_bytes (LE u16) || pk_acc)
// node_id = SHA-256("mt-node" || pk_node)
fn account_id(pk: &PublicKey) -> [u8; 32] {
let mut buf = Vec::with_capacity(domain::ACCOUNT.len() + 2 + pk.as_bytes().len());
buf.extend_from_slice(domain::ACCOUNT);
let suite_id = (SuiteId::Mldsa65 as u16).to_le_bytes();
buf.extend_from_slice(&suite_id);
buf.extend_from_slice(pk.as_bytes());
sha256_raw(&buf)
}
fn node_id(pk: &PublicKey) -> [u8; 32] {
let mut buf = Vec::with_capacity(domain::NODE.len() + pk.as_bytes().len());
buf.extend_from_slice(domain::NODE);
buf.extend_from_slice(pk.as_bytes());
sha256_raw(&buf)
}
struct Identity {
pk_acc: PublicKey,
sk_acc: SecretKey,
pk_node: PublicKey,
sk_node: SecretKey,
pk_mlkem: MlkemPublicKey,
sk_mlkem: MlkemSecretKey,
account_id: [u8; 32],
node_id: [u8; 32],
}
fn derive_identity(entropy: &[u8; 32]) -> Identity {
// 1-2: entropy → mnemonic
let mnemonic = entropy_to_mnemonic(entropy);
// 3: mnemonic → master_seed (PBKDF2-HMAC-SHA-256, 2^20 iter)
let master_seed = mnemonic_to_master_seed(&mnemonic).expect("valid mnemonic");
// 4-5: account keypair
let acc_seed = mldsa_seed_for_role(&master_seed, domain::ACCOUNT_KEY);
let (pk_acc, sk_acc) = keypair_from_seed(&acc_seed).expect("account keygen");
// 6: account_id terminal
let aid = account_id(&pk_acc);
// 7-8: node keypair
let node_seed = mldsa_seed_for_role(&master_seed, domain::NODE_KEY);
let (pk_node, sk_node) = keypair_from_seed(&node_seed).expect("node keygen");
// 9: node_id terminal
let nid = node_id(&pk_node);
// 10-11: app encryption keypair (ML-KEM-768)
let mlkem_seed = mlkem_seed_for_role(&master_seed, domain::APP_ENCRYPTION_KEY);
let (pk_mlkem, sk_mlkem) = keypair_from_seed_mlkem(&mlkem_seed).expect("mlkem keygen");
Identity {
pk_acc,
sk_acc,
pk_node,
sk_node,
pk_mlkem,
sk_mlkem,
account_id: aid,
node_id: nid,
}
}
#[test]
fn e2e_recovery_terminal_observable_byte_exact() {
// Step 1: entropy = [0xAB; 32] — фиксированный test input
let entropy = [0xABu8; 32];
// Steps 2-11: первое derivation
let id1 = derive_identity(&entropy);
// Step 12: ПОВТОРИТЬ шаги 2-11 → второе derivation
let id2 = derive_identity(&entropy);
// Steps 13-14: terminal IDs byte-exact
assert_eq!(
id1.account_id, id2.account_id,
"account_id terminal mismatch"
);
assert_eq!(id1.node_id, id2.node_id, "node_id terminal mismatch");
// Step 15: все 6 key parts byte-exact
assert_eq!(
id1.pk_acc.as_bytes(),
id2.pk_acc.as_bytes(),
"account pk mismatch"
);
assert_eq!(
id1.sk_acc.as_bytes(),
id2.sk_acc.as_bytes(),
"account sk mismatch"
);
assert_eq!(
id1.pk_node.as_bytes(),
id2.pk_node.as_bytes(),
"node pk mismatch"
);
assert_eq!(
id1.sk_node.as_bytes(),
id2.sk_node.as_bytes(),
"node sk mismatch"
);
assert_eq!(
id1.pk_mlkem.as_bytes(),
id2.pk_mlkem.as_bytes(),
"mlkem pk mismatch"
);
assert_eq!(
id1.sk_mlkem.as_bytes(),
id2.sk_mlkem.as_bytes(),
"mlkem sk mismatch"
);
}
#[test]
fn e2e_recovery_distinct_entropies_produce_distinct_terminals() {
let id1 = derive_identity(&[0x00u8; 32]);
let id2 = derive_identity(&[0xFFu8; 32]);
assert_ne!(id1.account_id, id2.account_id);
assert_ne!(id1.node_id, id2.node_id);
assert_ne!(id1.pk_acc.as_bytes(), id2.pk_acc.as_bytes());
assert_ne!(id1.pk_node.as_bytes(), id2.pk_node.as_bytes());
assert_ne!(id1.pk_mlkem.as_bytes(), id2.pk_mlkem.as_bytes());
}
#[test]
fn e2e_recovery_account_node_keys_differ_from_same_master() {
// Domain separation per HKDF info: ACCOUNT_KEY vs NODE_KEY → разные ML-DSA seeds
// → разные pk → разные account_id / node_id даже на одной мнемонике.
let id = derive_identity(&[0x42u8; 32]);
assert_ne!(
id.pk_acc.as_bytes(),
id.pk_node.as_bytes(),
"account и node pk должны различаться (HKDF domain separation)"
);
assert_ne!(
id.account_id, id.node_id,
"account_id и node_id должны различаться"
);
}