752 lines
30 KiB
Rust
752 lines
30 KiB
Rust
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
|
||
}
|
||
}
|