feat: BIP-39 master seed for unified key derivation
Replace fragmented random key generation with a single 24-word BIP-39 mnemonic that deterministically derives all node keys: Ed25519 (DID), secp256k1 (Nostr/Bitcoin), BIP-84 xprv (Bitcoin Core), and LND aezeed entropy. New onboarding flow: seed generate → word verification → identity naming. Restore path enabled via 24-word entry. Includes seed RPC handlers, mock backend support, LND/Bitcoin Core wallet-from-seed integration, and UI polish across settings and discover views. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5da9e217e6
commit
19dcfd4f31
@@ -54,6 +54,10 @@ hex = "0.4"
|
||||
bs58 = "0.5"
|
||||
chrono = "0.4"
|
||||
|
||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||
bip39 = { version = "=2.1.0", features = ["rand"] }
|
||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||
|
||||
# Configuration
|
||||
toml = "0.8"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BitcoinInfo {
|
||||
@@ -106,4 +107,117 @@ impl RpcHandler {
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
|
||||
}
|
||||
|
||||
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
||||
/// Creates a blank wallet and imports BIP-84 (native segwit) descriptors.
|
||||
/// Requires: password re-verification, encrypted seed on disk.
|
||||
pub(super) async fn handle_bitcoin_init_wallet_from_seed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||
let wallet_name = params.get("wallet_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("archipelago");
|
||||
|
||||
// Verify user password.
|
||||
self.auth_manager.verify_password(password).await
|
||||
.context("Password verification failed")?;
|
||||
|
||||
// Load encrypted seed.
|
||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
|
||||
.context("Failed to load encrypted seed")?;
|
||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
|
||||
// Derive BIP-84 account xprv.
|
||||
let xprv = crate::seed::derive_bitcoin_xprv(&seed)?;
|
||||
let mut xprv_str = xprv.to_string();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
// Step 1: Create a blank descriptor wallet.
|
||||
let create_result = self.bitcoin_rpc_call::<serde_json::Value>(
|
||||
&client,
|
||||
"createwallet",
|
||||
&[
|
||||
serde_json::json!(wallet_name), // wallet_name
|
||||
serde_json::json!(false), // disable_private_keys
|
||||
serde_json::json!(true), // blank
|
||||
serde_json::json!(""), // passphrase
|
||||
serde_json::json!(false), // avoid_reuse
|
||||
serde_json::json!(true), // descriptors
|
||||
],
|
||||
).await;
|
||||
|
||||
match create_result {
|
||||
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("already exists") {
|
||||
tracing::info!("Wallet '{}' already exists, importing descriptors", wallet_name);
|
||||
} else {
|
||||
xprv_str.zeroize();
|
||||
return Err(e.context("Failed to create wallet"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Import BIP-84 descriptors (external + internal/change).
|
||||
// Format: wpkh(xprv/0/*) for receive, wpkh(xprv/1/*) for change.
|
||||
let external_desc = format!("wpkh({}/0/*)", xprv_str);
|
||||
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
|
||||
|
||||
// Get checksums from Bitcoin Core.
|
||||
let ext_info: serde_json::Value = self.bitcoin_rpc_call(
|
||||
&client, "getdescriptorinfo", &[serde_json::json!(external_desc)],
|
||||
).await.context("getdescriptorinfo failed for external descriptor")?;
|
||||
|
||||
let int_info: serde_json::Value = self.bitcoin_rpc_call(
|
||||
&client, "getdescriptorinfo", &[serde_json::json!(internal_desc)],
|
||||
).await.context("getdescriptorinfo failed for internal descriptor")?;
|
||||
|
||||
let ext_desc_with_checksum = ext_info.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
let int_desc_with_checksum = int_info.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
|
||||
let import_params = serde_json::json!([
|
||||
{
|
||||
"desc": ext_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": false,
|
||||
"range": [0, 1000],
|
||||
},
|
||||
{
|
||||
"desc": int_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": true,
|
||||
"range": [0, 1000],
|
||||
}
|
||||
]);
|
||||
|
||||
let _import_result: serde_json::Value = self.bitcoin_rpc_call(
|
||||
&client, "importdescriptors", &[import_params],
|
||||
).await.context("importdescriptors failed")?;
|
||||
|
||||
// Zeroize the xprv string from memory.
|
||||
xprv_str.zeroize();
|
||||
|
||||
tracing::info!("Bitcoin Core wallet '{}' initialized from master seed (BIP-84)", wallet_name);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"initialized": true,
|
||||
"wallet_name": wallet_name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ impl RpcHandler {
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
|
||||
// Seed management (BIP-39 mnemonic)
|
||||
"seed.generate" => self.handle_seed_generate().await,
|
||||
"seed.verify" => self.handle_seed_verify(params).await,
|
||||
"seed.restore" => self.handle_seed_restore(params).await,
|
||||
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
|
||||
"seed.status" => self.handle_seed_status().await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
"container-start" => self.handle_container_start(params).await,
|
||||
@@ -78,6 +85,7 @@ impl RpcHandler {
|
||||
|
||||
// Bitcoin & Lightning deep data
|
||||
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
||||
"bitcoin.init-wallet-from-seed" => self.handle_bitcoin_init_wallet_from_seed(params).await,
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
@@ -92,6 +100,7 @@ impl RpcHandler {
|
||||
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||
"lnd.connect-info" => self.handle_lnd_connect_info().await,
|
||||
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
|
||||
"lnd.init-wallet-from-seed" => self.handle_lnd_init_wallet_from_seed(params).await,
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use tracing::info;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
@@ -381,4 +382,71 @@ impl RpcHandler {
|
||||
"broadcast": false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Initialize LND wallet with entropy derived from the node's BIP-39 master seed.
|
||||
/// The 16-byte entropy deterministically produces an aezeed mnemonic inside LND.
|
||||
/// Requires: password re-verification via params.password, encrypted seed on disk.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_init_wallet_from_seed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||
let wallet_password = params.get("wallet_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'wallet_password' for LND"))?;
|
||||
|
||||
// Verify user password before granting seed access.
|
||||
self.auth_manager.verify_password(password).await
|
||||
.context("Password verification failed")?;
|
||||
|
||||
// Load encrypted seed from disk.
|
||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
|
||||
.context("Failed to load encrypted seed. Was a seed phrase saved during onboarding?")?;
|
||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
|
||||
// Derive 16 bytes of LND entropy.
|
||||
let mut entropy = crate::seed::derive_lnd_entropy(&seed)?;
|
||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(&entropy);
|
||||
entropy.zeroize();
|
||||
|
||||
let wallet_password_b64 = base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let init_body = serde_json::json!({
|
||||
"wallet_password": wallet_password_b64,
|
||||
"seed_entropy": entropy_b64,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/initwallet")
|
||||
.json(&init_body)
|
||||
.send()
|
||||
.await
|
||||
.context("LND initwallet request failed — is LND running and uninitialized?")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse initwallet response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
|
||||
}
|
||||
|
||||
info!("LND wallet initialized from master seed entropy");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"initialized": true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||
"identity.create",
|
||||
"identity.verify",
|
||||
"identity.resolve-did",
|
||||
// Seed management (onboarding — before user has a session)
|
||||
"seed.generate",
|
||||
"seed.verify",
|
||||
"seed.restore",
|
||||
"seed.save-encrypted",
|
||||
// Onboarding restore (before user account exists)
|
||||
"backup.restore-identity",
|
||||
// Inter-node RPC: called by federated peers over Tor, no session cookies
|
||||
|
||||
@@ -24,6 +24,7 @@ mod package;
|
||||
mod peers;
|
||||
mod response;
|
||||
mod router;
|
||||
mod seed_rpc;
|
||||
mod security;
|
||||
mod tor;
|
||||
mod transport;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! RPC handlers for BIP-39 seed management.
|
||||
//! Endpoints: seed.generate, seed.verify, seed.restore, seed.save-encrypted, seed.status
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// In-memory storage for the mnemonic between generate and verify steps.
|
||||
/// Auto-cleared after 10 minutes.
|
||||
static ONBOARDING_MNEMONIC: std::sync::LazyLock<Arc<Mutex<Option<OnboardingMnemonicState>>>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
struct OnboardingMnemonicState {
|
||||
words: String,
|
||||
created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Drop for OnboardingMnemonicState {
|
||||
fn drop(&mut self) {
|
||||
self.words.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_secret_path = identity_dir.join("nostr_secret");
|
||||
let nostr_pub_path = identity_dir.join("nostr_pubkey");
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(&nostr_secret_path, secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(&nostr_pub_path, pubkey_hex.as_bytes()).await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
}
|
||||
|
||||
// Initialize identity index at 0.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
let words: Vec<&str> = mnemonic.words().collect();
|
||||
|
||||
// Hold mnemonic in memory for the verify step.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: mnemonic.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"words": words,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Verify the user wrote down their seed correctly.
|
||||
/// Also confirms the mnemonic by re-deriving and returning DID + npub.
|
||||
pub(in crate::api::rpc) async fn handle_seed_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let submitted_words: Vec<String> = serde_json::from_value(
|
||||
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||
).context("Invalid words array")?;
|
||||
|
||||
// Validate against the held mnemonic.
|
||||
let mnemonic_str = {
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
match state.as_ref() {
|
||||
Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(),
|
||||
_ => {
|
||||
*state = None;
|
||||
anyhow::bail!("No pending seed generation or session expired. Please regenerate.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let expected_words: Vec<&str> = mnemonic_str.split_whitespace().collect();
|
||||
let submitted: Vec<&str> = submitted_words.iter().map(|s| s.as_str()).collect();
|
||||
if expected_words != submitted {
|
||||
anyhow::bail!("Submitted words do not match generated seed");
|
||||
}
|
||||
|
||||
// Re-derive to get DID and npub.
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&mnemonic_str)?;
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Clear mnemonic from memory now that it's verified.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = None;
|
||||
}
|
||||
|
||||
// Save the encrypted seed for convenience backup.
|
||||
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
|
||||
// For now we just confirm the mnemonic was valid.
|
||||
drop(mnemonic);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"verified": true,
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore node identity from a 24-word seed phrase.
|
||||
pub(in crate::api::rpc) async fn handle_seed_restore(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let words: Vec<String> = serde_json::from_value(
|
||||
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||
).context("Invalid words array")?;
|
||||
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(identity_dir.join("nostr_pubkey"), pubkey_hex_nostr.as_bytes()).await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
identity_dir.join("nostr_secret"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
).await?;
|
||||
}
|
||||
|
||||
// Initialize identity index.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// Create default identity from seed.
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.create_from_seed(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
&seed,
|
||||
&self.config.data_dir,
|
||||
).await?;
|
||||
|
||||
// Get DID and npub for the response.
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
"restored": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Encrypt and save the mnemonic to disk for convenience backup.
|
||||
pub(in crate::api::rpc) async fn handle_seed_save_encrypted(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let passphrase = params.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
||||
|
||||
// Try to get mnemonic from in-memory state first.
|
||||
let mnemonic_str = {
|
||||
let state = ONBOARDING_MNEMONIC.lock().await;
|
||||
state.as_ref()
|
||||
.filter(|s| s.created_at.elapsed() < MNEMONIC_TTL)
|
||||
.map(|s| s.words.clone())
|
||||
};
|
||||
|
||||
let mnemonic: bip39::Mnemonic = if let Some(words) = mnemonic_str {
|
||||
words.parse().context("Invalid mnemonic in memory")?
|
||||
} else {
|
||||
anyhow::bail!("No mnemonic available. Generate or restore a seed first.");
|
||||
};
|
||||
|
||||
crate::seed::save_seed_encrypted(&self.config.data_dir, &mnemonic, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({ "saved": true }))
|
||||
}
|
||||
|
||||
/// Return seed status information.
|
||||
pub(in crate::api::rpc) async fn handle_seed_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let has_seed = crate::seed::seed_exists(&self.config.data_dir);
|
||||
let has_node_key = crate::identity::NodeIdentity::key_exists(
|
||||
&self.config.data_dir.join("identity"),
|
||||
);
|
||||
let is_legacy = has_node_key && !has_seed;
|
||||
let next_index = crate::seed::load_identity_index(&self.config.data_dir).await.unwrap_or(0);
|
||||
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (identities, _) = manager.list().await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"has_seed": has_seed,
|
||||
"is_legacy": is_legacy,
|
||||
"identity_count": identities.len(),
|
||||
"next_index": next_index,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
||||
|
||||
/// Read disk usage via `df` for the root filesystem.
|
||||
/// Returns (used_bytes, total_bytes).
|
||||
#[allow(dead_code)]
|
||||
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
read_disk_usage_path("/").await
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ impl AuthManager {
|
||||
|
||||
/// Ensure a default user exists on first boot.
|
||||
/// Called once at startup — creates user with default password if none exists.
|
||||
#[allow(dead_code)]
|
||||
pub async fn ensure_default_user(&self) -> Result<()> {
|
||||
if self.is_setup().await? {
|
||||
return Ok(());
|
||||
|
||||
@@ -69,7 +69,6 @@ impl DevDataManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_map_volume_path() {
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU32, Ordering}};
|
||||
|
||||
/// Container state matching podman's real states.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum MockContainerState {
|
||||
Created,
|
||||
Running,
|
||||
@@ -28,6 +29,7 @@ impl MockContainerState {
|
||||
|
||||
/// A simulated container.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MockContainer {
|
||||
pub name: String,
|
||||
pub image: String,
|
||||
@@ -36,6 +38,7 @@ pub struct MockContainer {
|
||||
}
|
||||
|
||||
/// Mock podman runtime for testing orchestration logic without real containers.
|
||||
#[allow(dead_code)]
|
||||
pub struct MockPodman {
|
||||
containers: Arc<Mutex<HashMap<String, MockContainer>>>,
|
||||
/// When true, `podman pull` will fail (simulates registry down).
|
||||
@@ -50,6 +53,7 @@ pub struct MockPodman {
|
||||
images: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MockPodman {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -56,6 +56,26 @@ pub struct CredentialStatusEntry {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Status of a verifiable credential.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CredentialStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Active => write!(f, "active"),
|
||||
Self::Revoked => write!(f, "revoked"),
|
||||
Self::Expired => write!(f, "expired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored credentials index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CredentialStore {
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct ServerInfo {
|
||||
pub wifi_ssids: Vec<String>,
|
||||
#[serde(rename = "zram-enabled")]
|
||||
pub zram_enabled: bool,
|
||||
/// True if this node's keys are derived from a BIP-39 seed.
|
||||
#[serde(rename = "seed-backed", default)]
|
||||
pub seed_backed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -269,6 +272,7 @@ impl DataModel {
|
||||
unread: 0,
|
||||
wifi_ssids: vec![],
|
||||
zram_enabled: false,
|
||||
seed_backed: false,
|
||||
},
|
||||
package_data: HashMap::new(),
|
||||
peer_health: HashMap::new(),
|
||||
|
||||
@@ -64,6 +64,45 @@ impl NodeIdentity {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create node identity from a BIP-39 master seed (deterministic derivation).
|
||||
/// Writes derived key to disk in the same format as load_or_create.
|
||||
pub async fn from_seed(identity_dir: &Path, seed: &crate::seed::MasterSeed) -> Result<Self> {
|
||||
fs::create_dir_all(identity_dir)
|
||||
.await
|
||||
.context("Failed to create identity directory")?;
|
||||
|
||||
let signing_key = crate::seed::derive_node_ed25519(seed)?;
|
||||
let key_path = identity_dir.join(NODE_KEY_FILE);
|
||||
let pub_path = identity_dir.join(NODE_KEY_PUB_FILE);
|
||||
|
||||
fs::write(&key_path, signing_key.to_bytes())
|
||||
.await
|
||||
.context("Failed to write node key")?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to set key permissions")?;
|
||||
}
|
||||
fs::write(&pub_path, signing_key.verifying_key().as_bytes())
|
||||
.await
|
||||
.context("Failed to write node public key")?;
|
||||
|
||||
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
|
||||
tracing::info!("Derived node identity from seed (pubkey: {}...)", &pubkey_hex[..16]);
|
||||
|
||||
Ok(Self {
|
||||
signing_key,
|
||||
_identity_dir: identity_dir.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a node key already exists on disk.
|
||||
pub fn key_exists(identity_dir: &Path) -> bool {
|
||||
identity_dir.join(NODE_KEY_FILE).exists()
|
||||
}
|
||||
|
||||
/// Access the signing key (for key derivation, e.g. mesh encryption).
|
||||
pub fn signing_key(&self) -> &SigningKey {
|
||||
&self.signing_key
|
||||
@@ -115,6 +154,11 @@ impl NodeIdentity {
|
||||
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))
|
||||
}
|
||||
|
||||
/// Generate a W3C DID Document for this identity.
|
||||
#[allow(dead_code)]
|
||||
pub fn did_document(&self) -> Result<serde_json::Value> {
|
||||
did_document_from_pubkey_hex(&self.pubkey_hex())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Ed25519 pubkey (hex) to did:key format.
|
||||
|
||||
@@ -90,6 +90,9 @@ struct IdentityFile {
|
||||
/// Nostr profile metadata
|
||||
#[serde(default)]
|
||||
profile: Option<IdentityProfile>,
|
||||
/// BIP-39 seed derivation index (if created from seed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
derivation_index: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct IdentityManager {
|
||||
@@ -150,6 +153,7 @@ impl IdentityManager {
|
||||
nostr_secret_hex: None,
|
||||
nostr_pubkey_hex: None,
|
||||
profile: None,
|
||||
derivation_index: None,
|
||||
};
|
||||
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
@@ -173,7 +177,7 @@ impl IdentityManager {
|
||||
self.set_default(&id).await?;
|
||||
}
|
||||
|
||||
// Auto-generate Nostr keypair so every identity has both key types
|
||||
// Auto-generate Nostr keypair so every identity has both key types (legacy path)
|
||||
let _ = self.create_nostr_key(&id).await;
|
||||
|
||||
// Re-read to pick up the Nostr keys
|
||||
@@ -184,6 +188,72 @@ impl IdentityManager {
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Create a new identity with keys derived from a BIP-39 master seed.
|
||||
/// The derivation index is auto-incremented and persisted.
|
||||
pub async fn create_from_seed(
|
||||
&self,
|
||||
name: String,
|
||||
purpose: IdentityPurpose,
|
||||
seed: &crate::seed::MasterSeed,
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<IdentityRecord> {
|
||||
let index = crate::seed::load_identity_index(data_dir).await?;
|
||||
|
||||
let signing_key = crate::seed::derive_identity_ed25519(seed, index)?;
|
||||
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
|
||||
let did = did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Derive Nostr key from the same seed via BIP-32.
|
||||
let nostr_keys = crate::seed::derive_nostr_identity_key(seed, index)?;
|
||||
let nostr_secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let nostr_pubkey_hex = nostr_keys.public_key().to_hex();
|
||||
|
||||
let identity_file = IdentityFile {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
purpose: purpose.clone(),
|
||||
secret_key: signing_key.to_bytes().to_vec(),
|
||||
pubkey_hex: pubkey_hex.clone(),
|
||||
did: did.clone(),
|
||||
created_at: created_at.clone(),
|
||||
nostr_secret_hex: Some(nostr_secret_hex),
|
||||
nostr_pubkey_hex: Some(nostr_pubkey_hex),
|
||||
profile: None,
|
||||
derivation_index: Some(index),
|
||||
};
|
||||
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
let json = serde_json::to_string_pretty(&identity_file)
|
||||
.context("Failed to serialize identity")?;
|
||||
fs::write(&file_path, json.as_bytes())
|
||||
.await
|
||||
.context("Failed to write identity file")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to set identity file permissions")?;
|
||||
}
|
||||
|
||||
// Increment the derivation index for next identity.
|
||||
crate::seed::save_identity_index(data_dir, index + 1).await?;
|
||||
|
||||
// If first identity, make it the default.
|
||||
let (existing, _) = self.list().await?;
|
||||
if existing.len() <= 1 {
|
||||
self.set_default(&id).await?;
|
||||
}
|
||||
|
||||
let record = self.get(&id).await?;
|
||||
tracing::info!("Created seed-derived identity '{}' ({}) at index {}", name, purpose, index);
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Get a single identity by ID (without secret key).
|
||||
pub async fn get(&self, id: &str) -> Result<IdentityRecord> {
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
|
||||
@@ -40,18 +40,15 @@ mod totp;
|
||||
mod wallet;
|
||||
mod names;
|
||||
mod network;
|
||||
pub mod seed;
|
||||
mod nostr_relays;
|
||||
mod update;
|
||||
mod vpn;
|
||||
mod webhooks;
|
||||
|
||||
use auth::AuthManager;
|
||||
use config::Config;
|
||||
use server::Server;
|
||||
|
||||
/// Default dev password when auto-creating a user (matches mock-backend).
|
||||
const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let startup_start = std::time::Instant::now();
|
||||
|
||||
@@ -138,6 +138,16 @@ pub enum ManifestDescription {
|
||||
Detailed { short: String, long: String },
|
||||
}
|
||||
|
||||
impl ManifestDescription {
|
||||
/// Return the short description regardless of variant.
|
||||
#[allow(dead_code)]
|
||||
pub fn short(&self) -> &str {
|
||||
match self {
|
||||
Self::Simple(s) => s,
|
||||
Self::Detailed { short, .. } => short,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A discovered marketplace app with trust scoring.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -634,7 +634,7 @@ mod tests {
|
||||
let frame = build_app_start("Archipelago");
|
||||
assert_eq!(frame[3], CMD_APP_START);
|
||||
let name = &frame[4..];
|
||||
assert_eq!(std::str::from_utf8(name).ok_or_else(|| anyhow::anyhow!("invalid UTF-8 in app name"))?, "Archipelago");
|
||||
assert_eq!(std::str::from_utf8(name).map_err(|e| anyhow::anyhow!("invalid UTF-8 in app name: {}", e))?, "Archipelago");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,29 @@ impl SessionManager {
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Store a ratchet session for a peer (in memory and on disk).
|
||||
#[allow(dead_code)]
|
||||
pub async fn store_session(&self, did: &str, state: RatchetState) -> Result<()> {
|
||||
self.save_session_to_disk(did, &state).await?;
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(did.to_string(), state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a ratchet session for a peer (from memory and disk).
|
||||
#[allow(dead_code)]
|
||||
pub async fn remove_session(&self, did: &str) -> Result<()> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(did);
|
||||
let path = self.session_path(did);
|
||||
if path.exists() {
|
||||
tokio::fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to remove ratchet session file")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session info for a peer (for RPC status endpoint).
|
||||
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
|
||||
let sessions = self.sessions.read().await;
|
||||
@@ -205,7 +228,7 @@ mod tests {
|
||||
let mgr = SessionManager::new(dir.path());
|
||||
|
||||
let root_key = [42u8; 32];
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
let (_spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
let state = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
|
||||
let did = "did:key:z6MkTestSession";
|
||||
|
||||
@@ -157,49 +157,49 @@ mod tests {
|
||||
assert!(allocs.allocations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_allocator_from_empty_dir() {
|
||||
#[tokio::test]
|
||||
async fn test_new_allocator_from_empty_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
assert!(alloc.allocations.allocations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_preferred_port_when_available() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_preferred_port_when_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port = alloc.allocate("my-app", 8500, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port = alloc.allocate("my-app", 8500, 80).await.unwrap();
|
||||
assert_eq!(port, 8500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_fallback_when_preferred_is_reserved() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_fallback_when_preferred_is_reserved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
// Port 80 is in RESERVED_PORTS, so it should allocate from the range instead
|
||||
let port = alloc.allocate("web-app", 80, 80).unwrap();
|
||||
let port = alloc.allocate("web-app", 80, 80).await.unwrap();
|
||||
assert_ne!(port, 80);
|
||||
assert!(port >= WEB_PORT_RANGE_START && port <= WEB_PORT_RANGE_END);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_fallback_when_preferred_is_taken() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_fallback_when_preferred_is_taken() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port1 = alloc.allocate("app-1", 8500, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port1 = alloc.allocate("app-1", 8500, 80).await.unwrap();
|
||||
assert_eq!(port1, 8500);
|
||||
|
||||
// Second app requesting the same preferred port gets a different one
|
||||
let port2 = alloc.allocate("app-2", 8500, 80).unwrap();
|
||||
let port2 = alloc.allocate("app-2", 8500, 80).await.unwrap();
|
||||
assert_ne!(port2, 8500);
|
||||
assert!(port2 >= WEB_PORT_RANGE_START && port2 <= WEB_PORT_RANGE_END);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_existing_allocation() {
|
||||
#[tokio::test]
|
||||
async fn test_get_returns_existing_allocation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("test-app", 8600, 3000).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("test-app", 8600, 3000).await.unwrap();
|
||||
|
||||
let result = alloc.get("test-app");
|
||||
assert!(result.is_some());
|
||||
@@ -208,78 +208,78 @@ mod tests {
|
||||
assert_eq!(container, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_none_for_unknown_app() {
|
||||
#[tokio::test]
|
||||
async fn test_get_returns_none_for_unknown_app() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
assert!(alloc.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_or_get_returns_existing() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_or_get_returns_existing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port1 = alloc.allocate("my-app", 8700, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port1 = alloc.allocate("my-app", 8700, 80).await.unwrap();
|
||||
|
||||
// Calling allocate_or_get with a different preferred port should return the existing one
|
||||
let port2 = alloc.allocate_or_get("my-app", 9999, 80).unwrap();
|
||||
let port2 = alloc.allocate_or_get("my-app", 9999, 80).await.unwrap();
|
||||
assert_eq!(port1, port2);
|
||||
assert_eq!(port2, 8700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_or_get_allocates_when_new() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_or_get_allocates_when_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port = alloc.allocate_or_get("new-app", 8800, 443).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port = alloc.allocate_or_get("new-app", 8800, 443).await.unwrap();
|
||||
assert_eq!(port, 8800);
|
||||
|
||||
// Verify it's now stored
|
||||
assert!(alloc.get("new-app").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_removes_allocation() {
|
||||
#[tokio::test]
|
||||
async fn test_release_removes_allocation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("removable", 8900, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("removable", 8900, 80).await.unwrap();
|
||||
assert!(alloc.get("removable").is_some());
|
||||
|
||||
alloc.release("removable").unwrap();
|
||||
alloc.release("removable").await.unwrap();
|
||||
assert!(alloc.get("removable").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_released_port_becomes_available() {
|
||||
#[tokio::test]
|
||||
async fn test_released_port_becomes_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("app-a", 8500, 80).unwrap();
|
||||
alloc.release("app-a").unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("app-a", 8500, 80).await.unwrap();
|
||||
alloc.release("app-a").await.unwrap();
|
||||
|
||||
// Port 8500 should now be available again
|
||||
let port = alloc.allocate("app-b", 8500, 80).unwrap();
|
||||
let port = alloc.allocate("app-b", 8500, 80).await.unwrap();
|
||||
assert_eq!(port, 8500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_ports_are_never_allocated() {
|
||||
#[tokio::test]
|
||||
async fn test_reserved_ports_are_never_allocated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
for &port in RESERVED_PORTS {
|
||||
assert!(alloc.is_reserved(port), "Port {} should be reserved", port);
|
||||
assert!(!alloc.is_available(port), "Port {} should not be available", port);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_across_instances() {
|
||||
#[tokio::test]
|
||||
async fn test_persistence_across_instances() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("persistent-app", 8555, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("persistent-app", 8555, 80).await.unwrap();
|
||||
}
|
||||
// Create a new allocator from the same directory
|
||||
let alloc2 = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc2 = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let result = alloc2.get("persistent-app");
|
||||
assert!(result.is_some());
|
||||
let (host, container) = result.unwrap();
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
//! 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/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 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 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_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 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 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, &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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,7 +436,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_create_and_validate() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
assert!(store.validate(&token).await);
|
||||
@@ -444,13 +444,13 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_invalid_token() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
assert!(!store.validate("nonexistent_token").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_remove() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
assert!(store.validate(&token).await);
|
||||
@@ -460,7 +460,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_session_upgrade() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let secret = vec![1, 2, 3, 4];
|
||||
let token = store.create_pending(secret.clone()).await;
|
||||
|
||||
@@ -484,7 +484,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_session_max_attempts() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let secret = vec![1, 2, 3];
|
||||
let token = store.create_pending(secret).await;
|
||||
|
||||
@@ -513,7 +513,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_activity_updates_on_validate() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
// First validation should succeed and touch last_activity
|
||||
@@ -525,7 +525,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalidate_all_except() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token1 = store.create().await;
|
||||
let token2 = store.create().await;
|
||||
let token3 = store.create().await;
|
||||
@@ -540,7 +540,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_rotate() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let old_token = store.create().await;
|
||||
|
||||
assert!(store.validate(&old_token).await);
|
||||
@@ -555,7 +555,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_max_concurrent_sessions() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let mut tokens = Vec::new();
|
||||
|
||||
// Create MAX_CONCURRENT_SESSIONS sessions
|
||||
@@ -583,7 +583,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_session_count() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
assert_eq!(store.active_session_count().await, 0);
|
||||
|
||||
let token1 = store.create().await;
|
||||
@@ -598,7 +598,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_expired_removes_stale() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
assert!(store.validate(&token).await);
|
||||
@@ -611,7 +611,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_preserves_session_count() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
assert_eq!(store.active_session_count().await, 1);
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ mod tests {
|
||||
let chunks = encode_chunked(&data).unwrap();
|
||||
|
||||
let data_chunks: Vec<_> = chunks.iter().filter(|c| !c.is_parity).collect();
|
||||
let parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect();
|
||||
let _parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect();
|
||||
assert_eq!(data_chunks.len(), 4); // ceil(500/124) = 5... wait
|
||||
// Actually: ceil(500/124) = ceil(4.03) = 5 data shards
|
||||
// But the first shard has 4 bytes of length header embedded, so
|
||||
|
||||
@@ -53,6 +53,42 @@ pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Save profits summary to disk.
|
||||
#[allow(dead_code)]
|
||||
pub async fn save_profits(data_dir: &Path, summary: &ProfitsSummary) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet directory")?;
|
||||
let path = data_dir.join(PROFITS_FILE);
|
||||
let content = serde_json::to_string_pretty(summary)
|
||||
.context("Failed to serialize profits")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write profits file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a single content sale, updating totals and the recent entries list.
|
||||
#[allow(dead_code)]
|
||||
pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description: &str) -> Result<()> {
|
||||
let mut summary = load_profits(data_dir).await?;
|
||||
let entry = ProfitEntry {
|
||||
source: ProfitSource::ContentSale,
|
||||
amount_sats,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
description: description.to_string(),
|
||||
};
|
||||
summary.recent.insert(0, entry);
|
||||
if summary.recent.len() > 100 {
|
||||
summary.recent.truncate(100);
|
||||
}
|
||||
summary.content_sales_sats += amount_sats;
|
||||
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
|
||||
save_profits(data_dir, &summary).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute a full profits summary including ecash receive transactions.
|
||||
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
let mut summary = load_profits(data_dir).await?;
|
||||
|
||||
@@ -259,7 +259,7 @@ mod restart_tracker {
|
||||
// ── Failsafe Install ──────────────────────────────────────────────────
|
||||
|
||||
mod failsafe_install {
|
||||
use crate::mock_podman::{MockPodman, MockContainerState};
|
||||
use crate::mock_podman::MockPodman;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[test]
|
||||
@@ -302,7 +302,6 @@ mod failsafe_install {
|
||||
// ── Health Monitor Logic ──────────────────────────────────────────────
|
||||
|
||||
mod health_monitor_logic {
|
||||
use crate::mock_podman::{MockPodman, MockContainerState};
|
||||
|
||||
/// Mirrors the tier ordering from health_monitor.rs
|
||||
fn container_tier(name: &str) -> u8 {
|
||||
|
||||
Reference in New Issue
Block a user