montana/Montana-Protocol/Code/crates/montana-node/src/identity.rs

752 lines
30 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.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use mt_codec::domain;
use mt_crypto::{
keypair_from_seed, keypair_from_seed_mlkem, sha256_raw, CryptoError, MlkemPublicKey,
MlkemSecretKey, PublicKey, SecretKey, SuiteId, MLKEM_PUBLIC_KEY_SIZE, MLKEM_SECRET_KEY_SIZE,
PUBLIC_KEY_SIZE, SECRET_KEY_SIZE,
};
use mt_mnemonic::{
ed25519_seed_for_role, entropy_to_mnemonic, mldsa_seed_for_role, mlkem_seed_for_role,
mnemonic_to_master_seed, MnemonicError, ED25519_SEED_LEN, MASTER_SEED_LEN,
};
use mt_state::{derive_account_id, derive_node_id, AccountId, NodeId};
use zeroize::Zeroize;
// Identity file format magic — production-grade naming per [C-12].
// "montana1" = ASCII «montana» + версия 1; 8 байт fixed.
// Не использовать префикс "mt-" — он зарезервирован за domain registry
// (mt-codec::domain, 32 separator) и file format magic не относится к
// consensus hash compositions.
pub const IDENTITY_MAGIC: &[u8; 8] = b"montana1";
pub const IDENTITY_VERSION: u8 = 1;
// spec, раздел "Identity persistence modes" — Mode B (ephemeral, без master_seed)
pub const IDENTITY_VERSION_V2: u8 = 2;
const OFFSET_MAGIC: usize = 0;
const OFFSET_VERSION: usize = 8;
const OFFSET_SUITE: usize = 9;
const OFFSET_MASTER_SEED: usize = 11;
const OFFSET_ACCOUNT_PK: usize = OFFSET_MASTER_SEED + MASTER_SEED_LEN;
const OFFSET_ACCOUNT_SK: usize = OFFSET_ACCOUNT_PK + PUBLIC_KEY_SIZE;
const OFFSET_NODE_PK: usize = OFFSET_ACCOUNT_SK + SECRET_KEY_SIZE;
const OFFSET_NODE_SK: usize = OFFSET_NODE_PK + PUBLIC_KEY_SIZE;
const OFFSET_MLKEM_PK: usize = OFFSET_NODE_SK + SECRET_KEY_SIZE;
const OFFSET_MLKEM_SK: usize = OFFSET_MLKEM_PK + MLKEM_PUBLIC_KEY_SIZE;
pub const IDENTITY_FILE_SIZE: usize = OFFSET_MLKEM_SK + MLKEM_SECRET_KEY_SIZE;
// V2 layout (Mode B — ephemeral) без master_seed; libp2p Ed25519 secret хранится
// напрямую (т.к. derive из master_seed невозможен — он zeroized).
// magic[8] || version[1] || suite[2] || account_pk || account_sk || node_pk || node_sk
// || mlkem_pk || mlkem_sk || libp2p_secret[32]
const OFFSET_V2_ACCOUNT_PK: usize = OFFSET_MASTER_SEED;
const OFFSET_V2_ACCOUNT_SK: usize = OFFSET_V2_ACCOUNT_PK + PUBLIC_KEY_SIZE;
const OFFSET_V2_NODE_PK: usize = OFFSET_V2_ACCOUNT_SK + SECRET_KEY_SIZE;
const OFFSET_V2_NODE_SK: usize = OFFSET_V2_NODE_PK + PUBLIC_KEY_SIZE;
const OFFSET_V2_MLKEM_PK: usize = OFFSET_V2_NODE_SK + SECRET_KEY_SIZE;
const OFFSET_V2_MLKEM_SK: usize = OFFSET_V2_MLKEM_PK + MLKEM_PUBLIC_KEY_SIZE;
const OFFSET_V2_LIBP2P_SK: usize = OFFSET_V2_MLKEM_SK + MLKEM_SECRET_KEY_SIZE;
pub const IDENTITY_FILE_SIZE_V2: usize = OFFSET_V2_LIBP2P_SK + ED25519_SEED_LEN;
pub struct Identity {
pub suite_id: SuiteId,
/// В Mode A (recoverable) — реальный master_seed.
/// В Mode B (ephemeral) — `[0u8; 64]` placeholder (master_seed уничтожен).
pub master_seed: [u8; MASTER_SEED_LEN],
/// `true` — Mode B, identity.bin будет писаться без master_seed (v2 layout).
pub is_ephemeral: bool,
pub mnemonic: String,
pub account_pk: PublicKey,
pub account_sk: SecretKey,
pub node_pk: PublicKey,
pub node_sk: SecretKey,
pub mlkem_pk: MlkemPublicKey,
pub mlkem_sk: MlkemSecretKey,
/// libp2p Ed25519 transport secret (32 байта) — раздельная identity для
/// network уровня (PeerId), independent от ML-DSA consensus identity.
/// Mode A: derived из master_seed через ed25519_seed_for_role. Не хранится
/// в файле (rederivable). Mode B: сохраняется напрямую в V2 layout.
pub libp2p_secret: [u8; ED25519_SEED_LEN],
}
impl Identity {
/// Построить libp2p Keypair из стораного secret (Ed25519).
/// Используется для конструирования `libp2p::identity::Keypair` каждый
/// раз on-demand — Keypair не хранится в struct, чтобы избежать
/// drift между сериализованным состоянием и runtime-ом.
pub fn libp2p_keypair(&self) -> libp2p::identity::Keypair {
let mut bytes = self.libp2p_secret;
libp2p::identity::Keypair::ed25519_from_bytes(&mut bytes)
.expect("32-байтный Ed25519 seed всегда валиден")
}
/// PeerId узла на libp2p уровне (multiaddr `/p2p/<peer_id>`).
pub fn libp2p_peer_id(&self) -> libp2p::PeerId {
self.libp2p_keypair().public().to_peer_id()
}
}
impl Drop for Identity {
fn drop(&mut self) {
// Транспортный секрет — zeroize при drop. master_seed Mode A — также.
// ML-DSA / ML-KEM SK содержат собственный Drop через mt-crypto.
self.libp2p_secret.zeroize();
self.master_seed.zeroize();
}
}
impl Identity {
pub fn account_id(&self) -> AccountId {
derive_account_id(self.suite_id as u16, self.account_pk.as_bytes())
}
pub fn node_id(&self) -> NodeId {
derive_node_id(self.node_pk.as_bytes())
}
pub fn master_seed_fingerprint(&self) -> [u8; 8] {
let h = sha256_raw(&self.master_seed);
let mut out = [0u8; 8];
out.copy_from_slice(&h[..8]);
out
}
pub fn from_master_seed(master_seed: [u8; MASTER_SEED_LEN]) -> Result<Self, NodeError> {
let acc_seed = mldsa_seed_for_role(&master_seed, domain::ACCOUNT_KEY);
let node_seed = mldsa_seed_for_role(&master_seed, domain::NODE_KEY);
let mlkem_seed = mlkem_seed_for_role(&master_seed, domain::APP_ENCRYPTION_KEY);
let libp2p_secret = ed25519_seed_for_role(&master_seed, domain::LIBP2P_TRANSPORT_KEY);
let (account_pk, account_sk) = keypair_from_seed(&acc_seed).map_err(NodeError::Crypto)?;
let (node_pk, node_sk) = keypair_from_seed(&node_seed).map_err(NodeError::Crypto)?;
let (mlkem_pk, mlkem_sk) =
keypair_from_seed_mlkem(&mlkem_seed).map_err(NodeError::Crypto)?;
Ok(Self {
suite_id: SuiteId::Mldsa65,
master_seed,
is_ephemeral: false,
mnemonic: String::new(),
account_pk,
account_sk,
node_pk,
node_sk,
mlkem_pk,
mlkem_sk,
libp2p_secret,
})
}
/// spec, раздел "Identity persistence modes" — Mode B.
/// Derive все ключи + zeroize master_seed в RAM. Identity содержит
/// master_seed=[0;64] placeholder и is_ephemeral=true. При save
/// будет записан v2 layout без master_seed.
pub fn from_master_seed_ephemeral(
master_seed: [u8; MASTER_SEED_LEN],
) -> Result<Self, NodeError> {
let mut id = Self::from_master_seed(master_seed)?;
id.master_seed.zeroize();
id.is_ephemeral = true;
Ok(id)
}
pub fn from_mnemonic_ephemeral(mnemonic: &str) -> Result<Self, NodeError> {
let master_seed = mnemonic_to_master_seed(mnemonic).map_err(NodeError::Mnemonic)?;
let mut id = Self::from_master_seed_ephemeral(master_seed)?;
// Не сохраняем мнемонику в struct в Mode B — она для оператора-spec
// не должна оставаться в RAM узла после derivation.
id.mnemonic = String::new();
Ok(id)
}
pub fn from_entropy_ephemeral(entropy: &[u8; 32]) -> Result<Self, NodeError> {
let mnemonic = entropy_to_mnemonic(entropy);
Self::from_mnemonic_ephemeral(&mnemonic)
}
pub fn from_entropy(entropy: &[u8; 32]) -> Result<Self, NodeError> {
let mnemonic = entropy_to_mnemonic(entropy);
let mut id = Self::from_mnemonic(&mnemonic)?;
id.mnemonic = mnemonic;
Ok(id)
}
pub fn from_mnemonic(mnemonic: &str) -> Result<Self, NodeError> {
let master_seed = mnemonic_to_master_seed(mnemonic).map_err(NodeError::Mnemonic)?;
let mut id = Self::from_master_seed(master_seed)?;
id.mnemonic = mnemonic.to_string();
Ok(id)
}
}
#[derive(Debug)]
pub enum NodeError {
Io(io::Error),
Mnemonic(MnemonicError),
Crypto(CryptoError),
InvalidMagic,
UnsupportedVersion(u8),
UnsupportedSuite(u16),
CorruptedSize { expected: usize, actual: usize },
InvalidEntropyHex,
IdentityAlreadyExists(PathBuf),
InvalidArguments(String),
Network(String),
}
impl std::fmt::Display for NodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NodeError::Io(e) => write!(f, "ошибка ввода-вывода: {e}"),
NodeError::Mnemonic(e) => write!(f, "ошибка мнемоники: {e:?}"),
NodeError::Crypto(e) => write!(f, "ошибка криптографии: {e:?}"),
NodeError::InvalidMagic => write!(
f,
"файл identity.bin не принадлежит montana-node (неверный magic)"
),
NodeError::UnsupportedVersion(v) => write!(
f,
"версия формата identity.bin = {v} не поддерживается; ожидалась {IDENTITY_VERSION} или {IDENTITY_VERSION_V2}"
),
NodeError::UnsupportedSuite(s) => write!(
f,
"криптонабор {s} не поддерживается; ожидался ML-DSA-65 (1)"
),
NodeError::CorruptedSize { expected, actual } => write!(
f,
"размер identity.bin = {actual} байт, ожидался {expected}"
),
NodeError::InvalidEntropyHex => write!(
f,
"значение --entropy должно быть 64 hex-символа (32 байта энтропии)"
),
NodeError::IdentityAlreadyExists(p) => write!(
f,
"identity.bin уже существует ({}); используйте --force для перезаписи",
p.display()
),
NodeError::InvalidArguments(s) => write!(f, "неверные аргументы: {s}"),
NodeError::Network(s) => write!(f, "ошибка сети: {s}"),
}
}
}
impl std::error::Error for NodeError {}
impl From<io::Error> for NodeError {
fn from(e: io::Error) -> Self {
NodeError::Io(e)
}
}
pub fn default_data_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| String::from("."));
PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("Montana")
.join("node")
}
pub fn identity_path(data_dir: &Path) -> PathBuf {
data_dir.join("identity.bin")
}
pub fn save_identity(
data_dir: &Path,
identity: &Identity,
force: bool,
) -> Result<PathBuf, NodeError> {
fs::create_dir_all(data_dir)?;
let path = identity_path(data_dir);
if path.exists() && !force {
return Err(NodeError::IdentityAlreadyExists(path));
}
let buf = if identity.is_ephemeral {
// Mode B (v2) — без master_seed; libp2p_secret хранится напрямую
let mut b = vec![0u8; IDENTITY_FILE_SIZE_V2];
b[OFFSET_MAGIC..OFFSET_MAGIC + 8].copy_from_slice(IDENTITY_MAGIC);
b[OFFSET_VERSION] = IDENTITY_VERSION_V2;
b[OFFSET_SUITE..OFFSET_SUITE + 2]
.copy_from_slice(&(identity.suite_id as u16).to_le_bytes());
b[OFFSET_V2_ACCOUNT_PK..OFFSET_V2_ACCOUNT_SK]
.copy_from_slice(identity.account_pk.as_bytes());
b[OFFSET_V2_ACCOUNT_SK..OFFSET_V2_NODE_PK].copy_from_slice(identity.account_sk.as_bytes());
b[OFFSET_V2_NODE_PK..OFFSET_V2_NODE_SK].copy_from_slice(identity.node_pk.as_bytes());
b[OFFSET_V2_NODE_SK..OFFSET_V2_MLKEM_PK].copy_from_slice(identity.node_sk.as_bytes());
b[OFFSET_V2_MLKEM_PK..OFFSET_V2_MLKEM_SK].copy_from_slice(identity.mlkem_pk.as_bytes());
b[OFFSET_V2_MLKEM_SK..OFFSET_V2_LIBP2P_SK].copy_from_slice(identity.mlkem_sk.as_bytes());
b[OFFSET_V2_LIBP2P_SK..].copy_from_slice(&identity.libp2p_secret);
b
} else {
// Mode A (v1) — с master_seed
let mut b = vec![0u8; IDENTITY_FILE_SIZE];
b[OFFSET_MAGIC..OFFSET_MAGIC + 8].copy_from_slice(IDENTITY_MAGIC);
b[OFFSET_VERSION] = IDENTITY_VERSION;
b[OFFSET_SUITE..OFFSET_SUITE + 2]
.copy_from_slice(&(identity.suite_id as u16).to_le_bytes());
b[OFFSET_MASTER_SEED..OFFSET_ACCOUNT_PK].copy_from_slice(&identity.master_seed);
b[OFFSET_ACCOUNT_PK..OFFSET_ACCOUNT_SK].copy_from_slice(identity.account_pk.as_bytes());
b[OFFSET_ACCOUNT_SK..OFFSET_NODE_PK].copy_from_slice(identity.account_sk.as_bytes());
b[OFFSET_NODE_PK..OFFSET_NODE_SK].copy_from_slice(identity.node_pk.as_bytes());
b[OFFSET_NODE_SK..OFFSET_MLKEM_PK].copy_from_slice(identity.node_sk.as_bytes());
b[OFFSET_MLKEM_PK..OFFSET_MLKEM_SK].copy_from_slice(identity.mlkem_pk.as_bytes());
b[OFFSET_MLKEM_SK..].copy_from_slice(identity.mlkem_sk.as_bytes());
b
};
write_owner_only(&path, &buf)?;
Ok(path)
}
#[cfg(unix)]
fn write_owner_only(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
io::Write::write_all(&mut f, bytes)?;
f.sync_all()?;
Ok(())
}
#[cfg(not(unix))]
fn write_owner_only(path: &Path, bytes: &[u8]) -> io::Result<()> {
fs::write(path, bytes)
}
pub fn load_identity(data_dir: &Path) -> Result<Identity, NodeError> {
let path = identity_path(data_dir);
let bytes = fs::read(&path)?;
if bytes.len() < OFFSET_MASTER_SEED {
return Err(NodeError::CorruptedSize {
expected: OFFSET_MASTER_SEED,
actual: bytes.len(),
});
}
if &bytes[OFFSET_MAGIC..OFFSET_MAGIC + 8] != IDENTITY_MAGIC.as_slice() {
return Err(NodeError::InvalidMagic);
}
match bytes[OFFSET_VERSION] {
IDENTITY_VERSION => load_identity_v1(&bytes),
IDENTITY_VERSION_V2 => load_identity_v2(&bytes),
other => Err(NodeError::UnsupportedVersion(other)),
}
}
fn parse_suite(bytes: &[u8]) -> Result<SuiteId, NodeError> {
let suite_raw = u16::from_le_bytes([bytes[OFFSET_SUITE], bytes[OFFSET_SUITE + 1]]);
match suite_raw {
0x0001 => Ok(SuiteId::Mldsa65),
other => Err(NodeError::UnsupportedSuite(other)),
}
}
fn load_identity_v1(bytes: &[u8]) -> Result<Identity, NodeError> {
if bytes.len() != IDENTITY_FILE_SIZE {
return Err(NodeError::CorruptedSize {
expected: IDENTITY_FILE_SIZE,
actual: bytes.len(),
});
}
let suite_id = parse_suite(bytes)?;
let mut master_seed = [0u8; MASTER_SEED_LEN];
master_seed.copy_from_slice(&bytes[OFFSET_MASTER_SEED..OFFSET_ACCOUNT_PK]);
let account_pk = PublicKey::from_slice(&bytes[OFFSET_ACCOUNT_PK..OFFSET_ACCOUNT_SK]).ok_or(
NodeError::CorruptedSize {
expected: PUBLIC_KEY_SIZE,
actual: OFFSET_ACCOUNT_SK - OFFSET_ACCOUNT_PK,
},
)?;
let account_sk = SecretKey::from_slice(&bytes[OFFSET_ACCOUNT_SK..OFFSET_NODE_PK]).ok_or(
NodeError::CorruptedSize {
expected: SECRET_KEY_SIZE,
actual: OFFSET_NODE_PK - OFFSET_ACCOUNT_SK,
},
)?;
let node_pk = PublicKey::from_slice(&bytes[OFFSET_NODE_PK..OFFSET_NODE_SK]).ok_or(
NodeError::CorruptedSize {
expected: PUBLIC_KEY_SIZE,
actual: OFFSET_NODE_SK - OFFSET_NODE_PK,
},
)?;
let node_sk = SecretKey::from_slice(&bytes[OFFSET_NODE_SK..OFFSET_MLKEM_PK]).ok_or(
NodeError::CorruptedSize {
expected: SECRET_KEY_SIZE,
actual: OFFSET_MLKEM_PK - OFFSET_NODE_SK,
},
)?;
let mlkem_pk = MlkemPublicKey::from_slice(&bytes[OFFSET_MLKEM_PK..OFFSET_MLKEM_SK]).ok_or(
NodeError::CorruptedSize {
expected: MLKEM_PUBLIC_KEY_SIZE,
actual: OFFSET_MLKEM_SK - OFFSET_MLKEM_PK,
},
)?;
let mlkem_sk =
MlkemSecretKey::from_slice(&bytes[OFFSET_MLKEM_SK..]).ok_or(NodeError::CorruptedSize {
expected: MLKEM_SECRET_KEY_SIZE,
actual: bytes.len() - OFFSET_MLKEM_SK,
})?;
let libp2p_secret = ed25519_seed_for_role(&master_seed, domain::LIBP2P_TRANSPORT_KEY);
Ok(Identity {
suite_id,
master_seed,
is_ephemeral: false,
mnemonic: String::new(),
account_pk,
account_sk,
node_pk,
node_sk,
mlkem_pk,
mlkem_sk,
libp2p_secret,
})
}
fn load_identity_v2(bytes: &[u8]) -> Result<Identity, NodeError> {
if bytes.len() != IDENTITY_FILE_SIZE_V2 {
return Err(NodeError::CorruptedSize {
expected: IDENTITY_FILE_SIZE_V2,
actual: bytes.len(),
});
}
let suite_id = parse_suite(bytes)?;
let account_pk = PublicKey::from_slice(&bytes[OFFSET_V2_ACCOUNT_PK..OFFSET_V2_ACCOUNT_SK])
.ok_or(NodeError::CorruptedSize {
expected: PUBLIC_KEY_SIZE,
actual: OFFSET_V2_ACCOUNT_SK - OFFSET_V2_ACCOUNT_PK,
})?;
let account_sk = SecretKey::from_slice(&bytes[OFFSET_V2_ACCOUNT_SK..OFFSET_V2_NODE_PK]).ok_or(
NodeError::CorruptedSize {
expected: SECRET_KEY_SIZE,
actual: OFFSET_V2_NODE_PK - OFFSET_V2_ACCOUNT_SK,
},
)?;
let node_pk = PublicKey::from_slice(&bytes[OFFSET_V2_NODE_PK..OFFSET_V2_NODE_SK]).ok_or(
NodeError::CorruptedSize {
expected: PUBLIC_KEY_SIZE,
actual: OFFSET_V2_NODE_SK - OFFSET_V2_NODE_PK,
},
)?;
let node_sk = SecretKey::from_slice(&bytes[OFFSET_V2_NODE_SK..OFFSET_V2_MLKEM_PK]).ok_or(
NodeError::CorruptedSize {
expected: SECRET_KEY_SIZE,
actual: OFFSET_V2_MLKEM_PK - OFFSET_V2_NODE_SK,
},
)?;
let mlkem_pk = MlkemPublicKey::from_slice(&bytes[OFFSET_V2_MLKEM_PK..OFFSET_V2_MLKEM_SK])
.ok_or(NodeError::CorruptedSize {
expected: MLKEM_PUBLIC_KEY_SIZE,
actual: OFFSET_V2_MLKEM_SK - OFFSET_V2_MLKEM_PK,
})?;
let mlkem_sk = MlkemSecretKey::from_slice(&bytes[OFFSET_V2_MLKEM_SK..OFFSET_V2_LIBP2P_SK])
.ok_or(NodeError::CorruptedSize {
expected: MLKEM_SECRET_KEY_SIZE,
actual: OFFSET_V2_LIBP2P_SK - OFFSET_V2_MLKEM_SK,
})?;
let mut libp2p_secret = [0u8; ED25519_SEED_LEN];
libp2p_secret.copy_from_slice(&bytes[OFFSET_V2_LIBP2P_SK..]);
Ok(Identity {
suite_id,
master_seed: [0u8; MASTER_SEED_LEN],
is_ephemeral: true,
mnemonic: String::new(),
account_pk,
account_sk,
node_pk,
node_sk,
mlkem_pk,
mlkem_sk,
libp2p_secret,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_identity_from_zero_entropy() {
let entropy = [0u8; 32];
let a = Identity::from_entropy(&entropy).expect("identity 1");
let b = Identity::from_entropy(&entropy).expect("identity 2");
assert_eq!(a.master_seed, b.master_seed);
assert_eq!(a.account_id(), b.account_id());
assert_eq!(a.node_id(), b.node_id());
assert_eq!(a.account_pk.as_bytes(), b.account_pk.as_bytes());
assert_eq!(a.node_pk.as_bytes(), b.node_pk.as_bytes());
assert_eq!(a.mlkem_pk.as_bytes(), b.mlkem_pk.as_bytes());
}
#[test]
fn distinct_entropy_distinct_terminals() {
let mut e2 = [0u8; 32];
e2[31] = 1;
let a = Identity::from_entropy(&[0u8; 32]).unwrap();
let b = Identity::from_entropy(&e2).unwrap();
assert_ne!(a.account_id(), b.account_id());
assert_ne!(a.node_id(), b.node_id());
}
#[test]
fn account_and_node_ids_differ_for_same_master() {
let id = Identity::from_entropy(&[0u8; 32]).unwrap();
assert_ne!(
id.account_id(),
id.node_id(),
"разные роли HKDF должны давать разные seed → разные ключи → разные id"
);
}
#[test]
fn save_load_roundtrip_byte_exact() {
let dir = tempdir();
let original = Identity::from_entropy(&[7u8; 32]).unwrap();
let path = save_identity(&dir, &original, false).expect("save");
assert!(path.exists());
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.len() as usize, IDENTITY_FILE_SIZE);
let loaded = load_identity(&dir).expect("load");
assert_eq!(original.master_seed, loaded.master_seed);
assert_eq!(original.account_id(), loaded.account_id());
assert_eq!(original.node_id(), loaded.node_id());
assert_eq!(original.account_pk.as_bytes(), loaded.account_pk.as_bytes());
assert_eq!(original.account_sk.as_bytes(), loaded.account_sk.as_bytes());
assert_eq!(original.node_sk.as_bytes(), loaded.node_sk.as_bytes());
assert_eq!(original.mlkem_pk.as_bytes(), loaded.mlkem_pk.as_bytes());
assert_eq!(original.mlkem_sk.as_bytes(), loaded.mlkem_sk.as_bytes());
}
#[test]
fn save_refuses_overwrite_without_force() {
let dir = tempdir();
let id = Identity::from_entropy(&[0u8; 32]).unwrap();
save_identity(&dir, &id, false).unwrap();
let second = Identity::from_entropy(&[1u8; 32]).unwrap();
let err = save_identity(&dir, &second, false).unwrap_err();
matches!(err, NodeError::IdentityAlreadyExists(_));
}
#[test]
fn save_force_overwrites() {
let dir = tempdir();
let id1 = Identity::from_entropy(&[0u8; 32]).unwrap();
save_identity(&dir, &id1, false).unwrap();
let id2 = Identity::from_entropy(&[1u8; 32]).unwrap();
save_identity(&dir, &id2, true).expect("force overwrite");
let loaded = load_identity(&dir).unwrap();
assert_eq!(loaded.master_seed, id2.master_seed);
assert_ne!(loaded.master_seed, id1.master_seed);
}
#[test]
fn load_rejects_bad_magic() {
let dir = tempdir();
let id = Identity::from_entropy(&[0u8; 32]).unwrap();
save_identity(&dir, &id, false).unwrap();
let path = identity_path(&dir);
let mut bytes = fs::read(&path).unwrap();
bytes[0] = b'X';
fs::write(&path, &bytes).unwrap();
match load_identity(&dir) {
Err(NodeError::InvalidMagic) => (),
Err(other) => panic!("ожидался InvalidMagic, получили {other:?}"),
Ok(_) => panic!("ожидалась ошибка InvalidMagic"),
}
}
#[test]
fn load_rejects_truncated_file() {
let dir = tempdir();
let id = Identity::from_entropy(&[0u8; 32]).unwrap();
save_identity(&dir, &id, false).unwrap();
let path = identity_path(&dir);
let bytes = fs::read(&path).unwrap();
fs::write(&path, &bytes[..bytes.len() - 100]).unwrap();
match load_identity(&dir) {
Err(NodeError::CorruptedSize { .. }) => (),
Err(other) => panic!("ожидался CorruptedSize, получили {other:?}"),
Ok(_) => panic!("ожидалась ошибка CorruptedSize"),
}
}
#[test]
#[cfg(unix)]
fn saved_file_has_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir();
let id = Identity::from_entropy(&[0u8; 32]).unwrap();
let path = save_identity(&dir, &id, false).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "ожидался mode 0600, получили {mode:o}");
}
#[test]
fn ephemeral_master_seed_is_zeroized() {
// spec, раздел "Identity persistence modes" — Mode B
let entropy = [42u8; 32];
let id = Identity::from_entropy_ephemeral(&entropy).unwrap();
assert!(id.is_ephemeral);
assert_eq!(id.master_seed, [0u8; MASTER_SEED_LEN]);
// derived keys остаются valid (signing capability сохраняется)
assert!(!id.account_pk.as_bytes().is_empty());
}
#[test]
fn ephemeral_save_load_roundtrip_v2() {
let dir = tempdir();
let original = Identity::from_entropy_ephemeral(&[7u8; 32]).unwrap();
let path = save_identity(&dir, &original, false).expect("save v2");
let meta = fs::metadata(&path).unwrap();
assert_eq!(
meta.len() as usize,
IDENTITY_FILE_SIZE_V2,
"v2 layout без master_seed"
);
let loaded = load_identity(&dir).expect("load v2");
assert!(loaded.is_ephemeral);
assert_eq!(loaded.master_seed, [0u8; MASTER_SEED_LEN]);
// derived keys byte-exact
assert_eq!(original.account_pk.as_bytes(), loaded.account_pk.as_bytes());
assert_eq!(original.account_sk.as_bytes(), loaded.account_sk.as_bytes());
assert_eq!(original.node_pk.as_bytes(), loaded.node_pk.as_bytes());
assert_eq!(original.node_sk.as_bytes(), loaded.node_sk.as_bytes());
assert_eq!(original.mlkem_pk.as_bytes(), loaded.mlkem_pk.as_bytes());
assert_eq!(original.mlkem_sk.as_bytes(), loaded.mlkem_sk.as_bytes());
}
#[test]
fn ephemeral_and_recoverable_produce_byte_identical_derived_keys() {
// Тот же entropy → те же derived keys в обоих режимах
let entropy = [99u8; 32];
let recoverable = Identity::from_entropy(&entropy).unwrap();
let ephemeral = Identity::from_entropy_ephemeral(&entropy).unwrap();
assert_eq!(
recoverable.account_pk.as_bytes(),
ephemeral.account_pk.as_bytes()
);
assert_eq!(recoverable.node_pk.as_bytes(), ephemeral.node_pk.as_bytes());
assert_eq!(
recoverable.mlkem_pk.as_bytes(),
ephemeral.mlkem_pk.as_bytes()
);
}
#[test]
fn v1_backwards_compat() {
// V1 файл (с master_seed) продолжает корректно загружаться
let dir = tempdir();
let id = Identity::from_entropy(&[3u8; 32]).unwrap();
save_identity(&dir, &id, false).unwrap();
let path = identity_path(&dir);
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.len() as usize, IDENTITY_FILE_SIZE, "v1 layout");
let loaded = load_identity(&dir).unwrap();
assert!(!loaded.is_ephemeral);
assert_eq!(loaded.master_seed, id.master_seed);
}
#[test]
fn libp2p_secret_deterministic_from_master_seed() {
let entropy = [0x55u8; 32];
let a = Identity::from_entropy(&entropy).unwrap();
let b = Identity::from_entropy(&entropy).unwrap();
assert_eq!(a.libp2p_secret, b.libp2p_secret);
assert_eq!(a.libp2p_peer_id(), b.libp2p_peer_id());
}
#[test]
fn libp2p_secret_differs_from_node_seed_for_same_master() {
// libp2p transport identity ≠ ML-DSA node consensus identity
let id = Identity::from_entropy(&[0x77u8; 32]).unwrap();
assert_ne!(&id.libp2p_secret[..], id.node_pk.as_bytes());
assert_ne!(&id.libp2p_secret[..], id.account_pk.as_bytes());
}
#[test]
fn libp2p_v1_save_load_rederives_secret() {
// V1 не хранит libp2p_secret в файле — derive из master_seed при load
let dir = tempdir();
let original = Identity::from_entropy(&[0x88u8; 32]).unwrap();
save_identity(&dir, &original, false).unwrap();
let path = identity_path(&dir);
let meta = fs::metadata(&path).unwrap();
assert_eq!(
meta.len() as usize,
IDENTITY_FILE_SIZE,
"V1 file size unchanged after libp2p addition (Ed25519 derivable from master_seed)"
);
let loaded = load_identity(&dir).unwrap();
assert_eq!(original.libp2p_secret, loaded.libp2p_secret);
assert_eq!(original.libp2p_peer_id(), loaded.libp2p_peer_id());
}
#[test]
fn libp2p_v2_save_load_stores_secret() {
// V2 ephemeral: libp2p_secret хранится напрямую (master_seed недоступен)
let dir = tempdir();
let original = Identity::from_entropy_ephemeral(&[0x99u8; 32]).unwrap();
save_identity(&dir, &original, false).unwrap();
let path = identity_path(&dir);
let meta = fs::metadata(&path).unwrap();
assert_eq!(
meta.len() as usize,
IDENTITY_FILE_SIZE_V2,
"V2 file size = old + 32 байт Ed25519"
);
let loaded = load_identity(&dir).unwrap();
assert!(loaded.is_ephemeral);
assert_eq!(loaded.master_seed, [0u8; MASTER_SEED_LEN]);
assert_eq!(original.libp2p_secret, loaded.libp2p_secret);
assert_eq!(original.libp2p_peer_id(), loaded.libp2p_peer_id());
}
#[test]
fn libp2p_v1_v2_produce_byte_identical_libp2p_secrets() {
// тот же entropy → тот же libp2p_secret в обоих режимах
let entropy = [0xaau8; 32];
let v1 = Identity::from_entropy(&entropy).unwrap();
let v2 = Identity::from_entropy_ephemeral(&entropy).unwrap();
assert_eq!(v1.libp2p_secret, v2.libp2p_secret);
assert_eq!(v1.libp2p_peer_id(), v2.libp2p_peer_id());
}
#[test]
fn libp2p_keypair_constructs_valid_peer_id() {
// Sanity: PeerId не [0;...] и стабилен между вызовами
let id = Identity::from_entropy(&[0xbbu8; 32]).unwrap();
let p1 = id.libp2p_peer_id();
let p2 = id.libp2p_peer_id();
assert_eq!(p1, p2);
// PeerId — multihash; non-empty
assert!(!p1.to_bytes().is_empty());
}
fn tempdir() -> PathBuf {
let mut p = std::env::temp_dir();
let nonce: u64 = {
let mut buf = [0u8; 8];
getrandom::getrandom(&mut buf).unwrap();
u64::from_le_bytes(buf)
};
p.push(format!("montana-node-test-{nonce:016x}"));
fs::create_dir_all(&p).unwrap();
p
}
}