Bakes the FIPS (Free Internetworking Peering System) mesh daemon into the node stack, supervised by archipelago alongside Tor. Runs as a system service, identity derives from the same BIP-39 master seed, and user-triggered updates track upstream main. Identity seed.rs: new HKDF label archipelago/fips/secp256k1/v1 → dedicated secp256k1 key, distinct from the Nostr-node key for crypto isolation but still seed-recoverable identity.rs: writes fips_key[.pub] to /data/identity on onboarding, chmod 0600; fips_key_exists / load_fips_keys / fips_npub accessors Transport TransportKind::Fips=3 inserted between LAN and Tor (Tor bumps to 4) → router prefers FIPS over Tor for all peer traffic PeerRecord gains fips_npub + last_fips fields (serde(default) for backward-compat with older nodes) transport/fips.rs: NodeTransport stub, reports unavailable until the daemon is live so router falls through to Tor cleanly Federation invites FederatedNode and FederationInvite carry optional fips_npub create_invite / accept_invite / peer-joined callback thread it end to end; signature domain deliberately unchanged — FIPS Noise does its own session auth, so the unsigned hint only affects path selection crate::fips config.rs: renders /etc/fips/fips.yaml and sudo-installs key material service.rs: systemctl status/activate/restart/mask wrappers update.rs: GitHub API check against upstream main; apply stubbed until per-commit .deb artefact source is decided RPC + dashboard fips.status / fips.check-update / fips.apply-update / fips.install / fips.restart registered in dispatcher HomeNetworkCard.vue shipped standalone (unmounted — place in Home.vue when ready); shows state pill, version, FIPS npub, update button, activate button when key is present but service is down ISO + systemd archipelago-fips.service: conditional on key presence, masked by default — backend unmasks after onboarding writes the key build-auto-installer-iso.sh: multi-stage Dockerfile builds the FIPS .deb from jmcorgan/fips main (fail-loud), COPYs it into rootfs, apt installs it so trixie resolves deps; unit copied + masked Version bump: 1.3.5 → 1.4.0 Tests: 33 new/updated passing (seed, identity, transport, federation, fips module, transport::fips). Known gaps: fips.apply-update returns a clear stub error until upstream publishes per-commit .deb artefacts; HomeNetworkCard is not mounted in Home.vue by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
547 lines
21 KiB
Rust
547 lines
21 KiB
Rust
//! BIP-39 master seed: generation, storage, and deterministic key derivation.
|
|
//!
|
|
//! One 24-word mnemonic derives ALL Archipelago keys:
|
|
//!
|
|
//! BIP-39 Mnemonic (24 words, 256-bit entropy)
|
|
//! → PBKDF2-HMAC-SHA512 (2048 rounds, empty passphrase)
|
|
//! → Master Seed (64 bytes)
|
|
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
|
|
//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key
|
|
//! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key
|
|
//! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519
|
|
//! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06)
|
|
//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet
|
|
//! └── HKDF(seed, "archipelago/lnd/entropy/v1") → LND aezeed entropy
|
|
//!
|
|
//! SECURITY: Never log mnemonic or seed material at any level.
|
|
|
|
use anyhow::{Context, Result};
|
|
use ed25519_dalek::SigningKey;
|
|
use hkdf::Hkdf;
|
|
use sha2::Sha256;
|
|
use zeroize::{Zeroize, ZeroizeOnDrop};
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────────
|
|
|
|
const SALT_LEN: usize = 16;
|
|
const NONCE_LEN: usize = 12;
|
|
const SEED_LEN: usize = 64;
|
|
const IDENTITY_INDEX_FILE: &str = "identity_index";
|
|
const ENCRYPTED_SEED_FILE: &str = "master_seed.enc";
|
|
|
|
// HKDF info strings for domain-separated key derivation.
|
|
const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
|
|
const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
|
|
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
|
|
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
|
|
|
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
|
|
|
/// 64-byte master seed derived from a BIP-39 mnemonic.
|
|
/// Implements ZeroizeOnDrop to clear memory when dropped.
|
|
#[derive(Zeroize, ZeroizeOnDrop)]
|
|
pub struct MasterSeed {
|
|
bytes: [u8; SEED_LEN],
|
|
}
|
|
|
|
impl MasterSeed {
|
|
/// Generate a new 24-word BIP-39 mnemonic and derive the master seed.
|
|
pub fn generate() -> Result<(bip39::Mnemonic, Self)> {
|
|
let mnemonic = bip39::Mnemonic::generate(24)
|
|
.map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))?;
|
|
let seed = Self::from_mnemonic(&mnemonic);
|
|
Ok((mnemonic, seed))
|
|
}
|
|
|
|
/// Derive master seed from an existing mnemonic (empty BIP-39 passphrase).
|
|
pub fn from_mnemonic(mnemonic: &bip39::Mnemonic) -> Self {
|
|
let seed_bytes = mnemonic.to_seed("");
|
|
let mut bytes = [0u8; SEED_LEN];
|
|
bytes.copy_from_slice(&seed_bytes);
|
|
Self { bytes }
|
|
}
|
|
|
|
/// Parse a space-separated word string, validate checksum, and derive seed.
|
|
pub fn from_mnemonic_words(words: &str) -> Result<(bip39::Mnemonic, Self)> {
|
|
let mnemonic: bip39::Mnemonic = words
|
|
.parse()
|
|
.map_err(|e| anyhow::anyhow!("Invalid mnemonic: {}", e))?;
|
|
let word_count = mnemonic.word_count();
|
|
if word_count != 24 {
|
|
anyhow::bail!("Expected 24 words, got {}", word_count);
|
|
}
|
|
let seed = Self::from_mnemonic(&mnemonic);
|
|
Ok((mnemonic, seed))
|
|
}
|
|
|
|
/// Access raw seed bytes (for HKDF input).
|
|
fn as_bytes(&self) -> &[u8; SEED_LEN] {
|
|
&self.bytes
|
|
}
|
|
}
|
|
|
|
// ─── Ed25519 Derivation (HKDF) ─────────────────────────────────────────
|
|
|
|
/// Derive the node's persistent Ed25519 signing key.
|
|
pub fn derive_node_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
|
|
let derived = hkdf_derive_32(seed.as_bytes(), NODE_ED25519_INFO)?;
|
|
Ok(SigningKey::from_bytes(&derived))
|
|
}
|
|
|
|
/// Derive an identity's Ed25519 signing key by index.
|
|
pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result<SigningKey> {
|
|
let info = format!("archipelago/identity/{}/ed25519/v1", index);
|
|
let derived = hkdf_derive_32(seed.as_bytes(), info.as_bytes())?;
|
|
Ok(SigningKey::from_bytes(&derived))
|
|
}
|
|
|
|
// ─── Secp256k1 / Nostr Derivation (BIP-32 + HKDF) ──────────────────────
|
|
|
|
/// Derive the node-level Nostr secp256k1 key (not per-identity).
|
|
pub fn derive_node_nostr_key(seed: &MasterSeed) -> Result<nostr_sdk::Keys> {
|
|
let derived = hkdf_derive_32(seed.as_bytes(), NODE_NOSTR_INFO)?;
|
|
let secret = nostr_sdk::SecretKey::from_slice(&derived)
|
|
.map_err(|e| anyhow::anyhow!("Invalid secp256k1 key from HKDF: {}", e))?;
|
|
Ok(nostr_sdk::Keys::new(secret))
|
|
}
|
|
|
|
/// Derive the FIPS mesh transport secp256k1 key.
|
|
/// Distinct from the Nostr-node key so compromise of one surface does not
|
|
/// impersonate on the other; still seed-recoverable.
|
|
pub fn derive_fips_key(seed: &MasterSeed) -> Result<nostr_sdk::Keys> {
|
|
let derived = hkdf_derive_32(seed.as_bytes(), FIPS_KEY_INFO)?;
|
|
let secret = nostr_sdk::SecretKey::from_slice(&derived)
|
|
.map_err(|e| anyhow::anyhow!("Invalid secp256k1 key from HKDF: {}", e))?;
|
|
Ok(nostr_sdk::Keys::new(secret))
|
|
}
|
|
|
|
/// Derive an identity's Nostr secp256k1 key via BIP-32.
|
|
/// Path: m/44'/1237'/0'/0/{index} (NIP-06 compliant).
|
|
pub fn derive_nostr_identity_key(seed: &MasterSeed, index: u32) -> Result<nostr_sdk::Keys> {
|
|
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
|
|
use bitcoin::Network;
|
|
|
|
let master = Xpriv::new_master(Network::Bitcoin, seed.as_bytes())
|
|
.context("Failed to derive BIP-32 master key")?;
|
|
|
|
let path = DerivationPath::from(vec![
|
|
ChildNumber::from_hardened_idx(44).expect("valid"),
|
|
ChildNumber::from_hardened_idx(1237).expect("valid"),
|
|
ChildNumber::from_hardened_idx(0).expect("valid"),
|
|
ChildNumber::from_normal_idx(0).expect("valid"),
|
|
ChildNumber::from_normal_idx(index).expect("valid index"),
|
|
]);
|
|
|
|
let secp = bitcoin::secp256k1::Secp256k1::new();
|
|
let child = master
|
|
.derive_priv(&secp, &path)
|
|
.context("BIP-32 derivation failed")?;
|
|
|
|
let secret_bytes = child.private_key.secret_bytes();
|
|
let secret = nostr_sdk::SecretKey::from_slice(&secret_bytes)
|
|
.map_err(|e| anyhow::anyhow!("Invalid Nostr key from BIP-32: {}", e))?;
|
|
Ok(nostr_sdk::Keys::new(secret))
|
|
}
|
|
|
|
// ─── Bitcoin / LND Derivation ───────────────────────────────────────────
|
|
|
|
/// Derive the BIP-84 account-level extended private key for Bitcoin Core.
|
|
/// Path: m/84'/0'/0' (native segwit, mainnet).
|
|
pub fn derive_bitcoin_xprv(seed: &MasterSeed) -> Result<bitcoin::bip32::Xpriv> {
|
|
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
|
|
use bitcoin::Network;
|
|
|
|
let master = Xpriv::new_master(Network::Bitcoin, seed.as_bytes())
|
|
.context("Failed to derive BIP-32 master key")?;
|
|
|
|
let path = DerivationPath::from(vec![
|
|
ChildNumber::from_hardened_idx(84).expect("valid"),
|
|
ChildNumber::from_hardened_idx(0).expect("valid"),
|
|
ChildNumber::from_hardened_idx(0).expect("valid"),
|
|
]);
|
|
|
|
let secp = bitcoin::secp256k1::Secp256k1::new();
|
|
master
|
|
.derive_priv(&secp, &path)
|
|
.context("BIP-84 derivation failed")
|
|
}
|
|
|
|
/// Derive 16 bytes of entropy for LND aezeed wallet initialization.
|
|
pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
|
|
let derived = hkdf_derive(seed.as_bytes(), LND_ENTROPY_INFO, 16)?;
|
|
let mut entropy = [0u8; 16];
|
|
entropy.copy_from_slice(&derived);
|
|
Ok(entropy)
|
|
}
|
|
|
|
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
|
|
|
|
/// Encrypt and save the mnemonic words to disk (convenience backup).
|
|
/// Uses Argon2 key derivation + ChaCha20-Poly1305 AEAD.
|
|
pub async fn save_seed_encrypted(
|
|
data_dir: &std::path::Path,
|
|
mnemonic: &bip39::Mnemonic,
|
|
passphrase: &str,
|
|
) -> Result<()> {
|
|
use argon2::Argon2;
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
use rand::RngCore;
|
|
|
|
let identity_dir = data_dir.join("identity");
|
|
tokio::fs::create_dir_all(&identity_dir)
|
|
.await
|
|
.context("Failed to create identity directory")?;
|
|
|
|
let plaintext = mnemonic.to_string();
|
|
|
|
let mut salt = [0u8; SALT_LEN];
|
|
let mut nonce = [0u8; NONCE_LEN];
|
|
rand::rngs::OsRng.fill_bytes(&mut salt);
|
|
rand::rngs::OsRng.fill_bytes(&mut nonce);
|
|
|
|
let mut key = [0u8; 32];
|
|
Argon2::default()
|
|
.hash_password_into(passphrase.as_bytes(), &salt, &mut key)
|
|
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
|
|
|
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key)
|
|
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
|
let ciphertext = cipher
|
|
.encrypt(
|
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce),
|
|
plaintext.as_bytes(),
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
|
|
|
// Zeroize the plaintext and key from memory.
|
|
key.zeroize();
|
|
|
|
// Format: salt || nonce || ciphertext
|
|
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
|
blob.extend_from_slice(&salt);
|
|
blob.extend_from_slice(&nonce);
|
|
blob.extend_from_slice(&ciphertext);
|
|
|
|
let path = identity_dir.join(ENCRYPTED_SEED_FILE);
|
|
tokio::fs::write(&path, &blob)
|
|
.await
|
|
.context("Failed to write encrypted seed")?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
|
.await
|
|
.context("Failed to set seed file permissions")?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load and decrypt the mnemonic from disk.
|
|
pub async fn load_seed_encrypted(
|
|
data_dir: &std::path::Path,
|
|
passphrase: &str,
|
|
) -> Result<bip39::Mnemonic> {
|
|
use argon2::Argon2;
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
|
|
let path = data_dir.join("identity").join(ENCRYPTED_SEED_FILE);
|
|
let blob = tokio::fs::read(&path)
|
|
.await
|
|
.context("Failed to read encrypted seed file")?;
|
|
|
|
if blob.len() < SALT_LEN + NONCE_LEN {
|
|
anyhow::bail!("Encrypted seed file too short");
|
|
}
|
|
|
|
let salt = &blob[..SALT_LEN];
|
|
let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
|
|
let ciphertext = &blob[SALT_LEN + NONCE_LEN..];
|
|
|
|
let mut key = [0u8; 32];
|
|
Argon2::default()
|
|
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
|
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
|
|
|
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key)
|
|
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
|
|
|
key.zeroize();
|
|
|
|
let plaintext = cipher
|
|
.decrypt(
|
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
|
ciphertext,
|
|
)
|
|
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase"))?;
|
|
|
|
let words = String::from_utf8(plaintext).context("Decrypted seed is not valid UTF-8")?;
|
|
let mnemonic: bip39::Mnemonic = words
|
|
.parse()
|
|
.map_err(|e| anyhow::anyhow!("Decrypted data is not a valid mnemonic: {}", e))?;
|
|
|
|
Ok(mnemonic)
|
|
}
|
|
|
|
/// Check if an encrypted seed file exists.
|
|
pub fn seed_exists(data_dir: &std::path::Path) -> bool {
|
|
data_dir.join("identity").join(ENCRYPTED_SEED_FILE).exists()
|
|
}
|
|
|
|
// ─── Identity Index Tracking ────────────────────────────────────────────
|
|
|
|
/// Save the next unused identity derivation index.
|
|
pub async fn save_identity_index(data_dir: &std::path::Path, next_index: u32) -> Result<()> {
|
|
let path = data_dir.join("identity").join(IDENTITY_INDEX_FILE);
|
|
tokio::fs::write(&path, next_index.to_string().as_bytes())
|
|
.await
|
|
.context("Failed to write identity index")
|
|
}
|
|
|
|
/// Load the next unused identity derivation index (0 if none saved).
|
|
pub async fn load_identity_index(data_dir: &std::path::Path) -> Result<u32> {
|
|
let path = data_dir.join("identity").join(IDENTITY_INDEX_FILE);
|
|
match tokio::fs::read_to_string(&path).await {
|
|
Ok(s) => s.trim().parse::<u32>().context("Invalid identity index"),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
|
Err(e) => Err(e).context("Failed to read identity index"),
|
|
}
|
|
}
|
|
|
|
// ─── Internal Helpers ───────────────────────────────────────────────────
|
|
|
|
/// HKDF-SHA256 derivation with no salt, returns `len` bytes.
|
|
fn hkdf_derive(ikm: &[u8], info: &[u8], len: usize) -> Result<Vec<u8>> {
|
|
let hk = Hkdf::<Sha256>::new(None, ikm);
|
|
let mut okm = vec![0u8; len];
|
|
hk.expand(info, &mut okm)
|
|
.map_err(|_| anyhow::anyhow!("HKDF expand failed"))?;
|
|
Ok(okm)
|
|
}
|
|
|
|
/// HKDF-SHA256 derivation with no salt, returns exactly 32 bytes.
|
|
fn hkdf_derive_32(ikm: &[u8], info: &[u8]) -> Result<[u8; 32]> {
|
|
let bytes = hkdf_derive(ikm, info, 32)?;
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(&bytes);
|
|
Ok(out)
|
|
}
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
|
|
|
|
#[test]
|
|
fn test_deterministic_node_key() {
|
|
let (_, seed1) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let (_, seed2) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let key1 = derive_node_ed25519(&seed1).unwrap();
|
|
let key2 = derive_node_ed25519(&seed2).unwrap();
|
|
assert_eq!(
|
|
key1.verifying_key().as_bytes(),
|
|
key2.verifying_key().as_bytes(),
|
|
"Same mnemonic must produce same node key"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deterministic_identity_keys() {
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let key_a = derive_identity_ed25519(&seed, 0).unwrap();
|
|
let key_b = derive_identity_ed25519(&seed, 1).unwrap();
|
|
assert_ne!(
|
|
key_a.verifying_key().as_bytes(),
|
|
key_b.verifying_key().as_bytes(),
|
|
"Different indices must produce different keys"
|
|
);
|
|
|
|
// Same index is deterministic.
|
|
let key_a2 = derive_identity_ed25519(&seed, 0).unwrap();
|
|
assert_eq!(
|
|
key_a.verifying_key().as_bytes(),
|
|
key_a2.verifying_key().as_bytes(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_key_differs_from_identity() {
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let node = derive_node_ed25519(&seed).unwrap();
|
|
let identity = derive_identity_ed25519(&seed, 0).unwrap();
|
|
assert_ne!(
|
|
node.verifying_key().as_bytes(),
|
|
identity.verifying_key().as_bytes(),
|
|
"Node key and identity key must differ"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deterministic_nostr_keys() {
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let keys1 = derive_nostr_identity_key(&seed, 0).unwrap();
|
|
let keys2 = derive_nostr_identity_key(&seed, 0).unwrap();
|
|
assert_eq!(
|
|
keys1.public_key().to_hex(),
|
|
keys2.public_key().to_hex(),
|
|
"Same mnemonic + index must produce same Nostr key"
|
|
);
|
|
|
|
let keys3 = derive_nostr_identity_key(&seed, 1).unwrap();
|
|
assert_ne!(
|
|
keys1.public_key().to_hex(),
|
|
keys3.public_key().to_hex(),
|
|
"Different indices must produce different Nostr keys"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_nostr_key() {
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let keys1 = derive_node_nostr_key(&seed).unwrap();
|
|
let keys2 = derive_node_nostr_key(&seed).unwrap();
|
|
assert_eq!(keys1.public_key().to_hex(), keys2.public_key().to_hex());
|
|
}
|
|
|
|
#[test]
|
|
fn test_fips_key_deterministic_and_distinct() {
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let fips1 = derive_fips_key(&seed).unwrap();
|
|
let fips2 = derive_fips_key(&seed).unwrap();
|
|
assert_eq!(
|
|
fips1.public_key().to_hex(),
|
|
fips2.public_key().to_hex(),
|
|
"FIPS key must be deterministic for a given seed"
|
|
);
|
|
|
|
let nostr = derive_node_nostr_key(&seed).unwrap();
|
|
assert_ne!(
|
|
fips1.public_key().to_hex(),
|
|
nostr.public_key().to_hex(),
|
|
"FIPS key must differ from the Nostr-node key"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bitcoin_xprv_deterministic() {
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let xprv1 = derive_bitcoin_xprv(&seed).unwrap();
|
|
let xprv2 = derive_bitcoin_xprv(&seed).unwrap();
|
|
assert_eq!(xprv1, xprv2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_lnd_entropy_deterministic() {
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
let e1 = derive_lnd_entropy(&seed).unwrap();
|
|
let e2 = derive_lnd_entropy(&seed).unwrap();
|
|
assert_eq!(e1, e2);
|
|
assert_eq!(e1.len(), 16);
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_produces_24_words() {
|
|
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
|
assert_eq!(mnemonic.word_count(), 24);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_mnemonic_rejected() {
|
|
let result = MasterSeed::from_mnemonic_words("not a valid mnemonic");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrong_word_count_rejected() {
|
|
// 12 words (valid BIP-39 but we require 24)
|
|
let result = MasterSeed::from_mnemonic_words(
|
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_encrypted_storage_roundtrip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
|
let words = mnemonic.to_string();
|
|
|
|
save_seed_encrypted(dir.path(), &mnemonic, "test-passphrase")
|
|
.await
|
|
.unwrap();
|
|
assert!(seed_exists(dir.path()));
|
|
|
|
let restored = load_seed_encrypted(dir.path(), "test-passphrase")
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(restored.to_string(), words);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_encrypted_storage_wrong_passphrase() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
|
|
|
save_seed_encrypted(dir.path(), &mnemonic, "correct")
|
|
.await
|
|
.unwrap();
|
|
let result = load_seed_encrypted(dir.path(), "wrong").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_identity_index_roundtrip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
// Create identity subdirectory (required by the path).
|
|
tokio::fs::create_dir_all(dir.path().join("identity"))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(load_identity_index(dir.path()).await.unwrap(), 0);
|
|
save_identity_index(dir.path(), 5).await.unwrap();
|
|
assert_eq!(load_identity_index(dir.path()).await.unwrap(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_full_derivation_from_known_mnemonic() {
|
|
// Verify all derivation paths produce valid, distinct keys from a known mnemonic.
|
|
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
|
|
|
let node_ed = derive_node_ed25519(&seed).unwrap();
|
|
let node_nostr = derive_node_nostr_key(&seed).unwrap();
|
|
let fips = derive_fips_key(&seed).unwrap();
|
|
let id0_ed = derive_identity_ed25519(&seed, 0).unwrap();
|
|
let id0_nostr = derive_nostr_identity_key(&seed, 0).unwrap();
|
|
let _btc = derive_bitcoin_xprv(&seed).unwrap();
|
|
let lnd = derive_lnd_entropy(&seed).unwrap();
|
|
|
|
// All keys should be distinct (comparing hex representations).
|
|
let node_ed_hex = hex::encode(node_ed.verifying_key().as_bytes());
|
|
let id0_ed_hex = hex::encode(id0_ed.verifying_key().as_bytes());
|
|
let node_nostr_hex = node_nostr.public_key().to_hex();
|
|
let fips_hex = fips.public_key().to_hex();
|
|
let id0_nostr_hex = id0_nostr.public_key().to_hex();
|
|
let lnd_hex = hex::encode(lnd);
|
|
|
|
let all = [
|
|
&node_ed_hex,
|
|
&id0_ed_hex,
|
|
&node_nostr_hex,
|
|
&fips_hex,
|
|
&id0_nostr_hex,
|
|
&lnd_hex,
|
|
];
|
|
for (i, a) in all.iter().enumerate() {
|
|
for (j, b) in all.iter().enumerate() {
|
|
if i != j {
|
|
assert_ne!(a, b, "Keys at positions {} and {} should differ", i, j);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|