From 19dcfd4f31b008e3ecdc8b2c2890b4fee907b21f Mon Sep 17 00:00:00 2001
From: Dorian
Date: Tue, 31 Mar 2026 01:41:24 +0100
Subject: [PATCH] feat: BIP-39 master seed for unified key derivation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
core/Cargo.lock | 89 +++-
core/archipelago/Cargo.toml | 4 +
core/archipelago/src/api/rpc/bitcoin.rs | 114 ++++
core/archipelago/src/api/rpc/dispatcher.rs | 9 +
core/archipelago/src/api/rpc/lnd/wallet.rs | 68 +++
core/archipelago/src/api/rpc/middleware.rs | 5 +
core/archipelago/src/api/rpc/mod.rs | 1 +
core/archipelago/src/api/rpc/seed_rpc.rs | 237 +++++++++
core/archipelago/src/api/rpc/system/mod.rs | 1 +
core/archipelago/src/auth.rs | 1 +
.../archipelago/src/container/data_manager.rs | 1 -
core/archipelago/src/container/mock_podman.rs | 4 +
core/archipelago/src/credentials/types.rs | 20 +
core/archipelago/src/data_model.rs | 4 +
core/archipelago/src/identity.rs | 44 ++
core/archipelago/src/identity_manager.rs | 72 ++-
core/archipelago/src/main.rs | 5 +-
core/archipelago/src/marketplace.rs | 10 +
core/archipelago/src/mesh/protocol.rs | 2 +-
core/archipelago/src/mesh/session.rs | 25 +-
core/archipelago/src/port_allocator.rs | 102 ++--
core/archipelago/src/seed.rs | 490 ++++++++++++++++++
core/archipelago/src/session.rs | 24 +-
core/archipelago/src/transport/chunking.rs | 2 +-
core/archipelago/src/wallet/profits.rs | 36 ++
core/archipelago/tests/orchestration_tests.rs | 3 +-
docs/user-walkthrough.md | 116 ++++-
neode-ui/mock-backend.js | 25 +
neode-ui/src/api/rpc-client.ts | 37 ++
neode-ui/src/components/ToggleSwitch.vue | 2 +
neode-ui/src/composables/useControllerNav.ts | 69 +--
neode-ui/src/router/index.ts | 15 +
neode-ui/src/stores/sync.ts | 1 +
neode-ui/src/types/api.ts | 1 +
neode-ui/src/views/Discover.vue | 4 +-
neode-ui/src/views/OnboardingIdentity.vue | 2 +-
neode-ui/src/views/OnboardingIntro.vue | 82 +--
neode-ui/src/views/OnboardingOptions.vue | 27 +-
neode-ui/src/views/OnboardingPath.vue | 2 +-
neode-ui/src/views/OnboardingSeedGenerate.vue | 164 ++++++
neode-ui/src/views/OnboardingSeedRestore.vue | 183 +++++++
neode-ui/src/views/OnboardingSeedVerify.vue | 254 +++++++++
neode-ui/src/views/OnboardingWrapper.vue | 8 +-
neode-ui/src/views/discover/DiscoverHero.vue | 73 +--
neode-ui/src/views/discover/FeaturedApps.vue | 2 +-
.../src/views/settings/AccountInfoSection.vue | 6 +-
.../src/views/settings/ClaudeAuthSection.vue | 2 +-
.../views/settings/InterfaceModeSection.vue | 2 +-
.../src/views/settings/SystemDangerZone.vue | 6 +-
.../src/views/settings/TwoFactorSection.vue | 2 +-
50 files changed, 2200 insertions(+), 258 deletions(-)
create mode 100644 core/archipelago/src/api/rpc/seed_rpc.rs
create mode 100644 core/archipelago/src/seed.rs
create mode 100644 neode-ui/src/views/OnboardingSeedGenerate.vue
create mode 100644 neode-ui/src/views/OnboardingSeedRestore.vue
create mode 100644 neode-ui/src/views/OnboardingSeedVerify.vue
diff --git a/core/Cargo.lock b/core/Cargo.lock
index 5a963966..574292da 100644
--- a/core/Cargo.lock
+++ b/core/Cargo.lock
@@ -89,6 +89,8 @@ dependencies = [
"argon2",
"base64 0.21.7",
"bcrypt",
+ "bip39",
+ "bitcoin",
"bs58",
"bytes",
"chacha20poly1305",
@@ -275,6 +277,16 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
+[[package]]
+name = "base58ck"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
+dependencies = [
+ "bitcoin-internals 0.3.0",
+ "bitcoin_hashes 0.14.1",
+]
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -314,21 +326,71 @@ checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
[[package]]
name = "bip39"
-version = "2.2.2"
+version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
+checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
dependencies = [
- "bitcoin_hashes",
+ "bitcoin_hashes 0.13.0",
+ "rand 0.8.5",
+ "rand_core 0.6.4",
"serde",
"unicode-normalization",
]
+[[package]]
+name = "bitcoin"
+version = "0.32.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026"
+dependencies = [
+ "base58ck",
+ "bech32",
+ "bitcoin-internals 0.3.0",
+ "bitcoin-io",
+ "bitcoin-units",
+ "bitcoin_hashes 0.14.1",
+ "hex-conservative 0.2.2",
+ "hex_lit",
+ "secp256k1",
+]
+
+[[package]]
+name = "bitcoin-internals"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
+
+[[package]]
+name = "bitcoin-internals"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
+
[[package]]
name = "bitcoin-io"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
+[[package]]
+name = "bitcoin-units"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
+dependencies = [
+ "bitcoin-internals 0.3.0",
+]
+
+[[package]]
+name = "bitcoin_hashes"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
+dependencies = [
+ "bitcoin-internals 0.2.0",
+ "hex-conservative 0.1.2",
+]
+
[[package]]
name = "bitcoin_hashes"
version = "0.14.1"
@@ -336,7 +398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [
"bitcoin-io",
- "hex-conservative",
+ "hex-conservative 0.2.2",
"serde",
]
@@ -1037,6 +1099,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hex-conservative"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
+
[[package]]
name = "hex-conservative"
version = "0.2.2"
@@ -1046,6 +1114,12 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "hex_lit"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -1639,7 +1713,7 @@ dependencies = [
"base64 0.22.1",
"bech32",
"bip39",
- "bitcoin_hashes",
+ "bitcoin_hashes 0.14.1",
"cbc",
"chacha20",
"chacha20poly1305",
@@ -2255,6 +2329,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
+ "bitcoin_hashes 0.14.1",
"rand 0.8.5",
"secp256k1-sys",
"serde",
@@ -3062,9 +3137,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-normalization"
-version = "0.1.25"
+version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [
"tinyvec",
]
diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml
index 0965cdce..641974ed 100644
--- a/core/archipelago/Cargo.toml
+++ b/core/archipelago/Cargo.toml
@@ -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"
diff --git a/core/archipelago/src/api/rpc/bitcoin.rs b/core/archipelago/src/api/rpc/bitcoin.rs
index 8fdd48e9..a097aa6d 100644
--- a/core/archipelago/src/api/rpc/bitcoin.rs
+++ b/core/archipelago/src/api/rpc/bitcoin.rs
@@ -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,
+ ) -> Result {
+ 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::(
+ &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,
+ }))
+ }
}
diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs
index 70d04788..373113b5 100644
--- a/core/archipelago/src/api/rpc/dispatcher.rs
+++ b/core/archipelago/src/api/rpc/dispatcher.rs
@@ -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,
diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs
index 0dacbcce..4d90d1c0 100644
--- a/core/archipelago/src/api/rpc/lnd/wallet.rs
+++ b/core/archipelago/src/api/rpc/lnd/wallet.rs
@@ -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,
+ ) -> Result {
+ 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,
+ }))
+ }
}
diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs
index 34090a15..2cafb444 100644
--- a/core/archipelago/src/api/rpc/middleware.rs
+++ b/core/archipelago/src/api/rpc/middleware.rs
@@ -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
diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs
index 295bb155..ef07ff8c 100644
--- a/core/archipelago/src/api/rpc/mod.rs
+++ b/core/archipelago/src/api/rpc/mod.rs
@@ -24,6 +24,7 @@ mod package;
mod peers;
mod response;
mod router;
+mod seed_rpc;
mod security;
mod tor;
mod transport;
diff --git a/core/archipelago/src/api/rpc/seed_rpc.rs b/core/archipelago/src/api/rpc/seed_rpc.rs
new file mode 100644
index 00000000..c98b7df2
--- /dev/null
+++ b/core/archipelago/src/api/rpc/seed_rpc.rs
@@ -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>>> =
+ 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 {
+ 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,
+ ) -> Result {
+ let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
+ let submitted_words: Vec = 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,
+ ) -> Result {
+ let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
+ let words: Vec = 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,
+ ) -> Result {
+ 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 {
+ 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,
+ }))
+ }
+}
diff --git a/core/archipelago/src/api/rpc/system/mod.rs b/core/archipelago/src/api/rpc/system/mod.rs
index 5578b252..d7c43010 100644
--- a/core/archipelago/src/api/rpc/system/mod.rs
+++ b/core/archipelago/src/api/rpc/system/mod.rs
@@ -156,6 +156,7 @@ pub(super) fn parse_meminfo_kb(val: &str) -> Result {
/// 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
}
diff --git a/core/archipelago/src/auth.rs b/core/archipelago/src/auth.rs
index cda7cd29..01578261 100644
--- a/core/archipelago/src/auth.rs
+++ b/core/archipelago/src/auth.rs
@@ -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(());
diff --git a/core/archipelago/src/container/data_manager.rs b/core/archipelago/src/container/data_manager.rs
index 67d35676..03695d51 100644
--- a/core/archipelago/src/container/data_manager.rs
+++ b/core/archipelago/src/container/data_manager.rs
@@ -69,7 +69,6 @@ impl DevDataManager {
#[cfg(test)]
mod tests {
use super::*;
- use std::path::PathBuf;
#[tokio::test]
async fn test_map_volume_path() {
diff --git a/core/archipelago/src/container/mock_podman.rs b/core/archipelago/src/container/mock_podman.rs
index fcdb20df..21e0881d 100644
--- a/core/archipelago/src/container/mock_podman.rs
+++ b/core/archipelago/src/container/mock_podman.rs
@@ -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>>,
/// When true, `podman pull` will fail (simulates registry down).
@@ -50,6 +53,7 @@ pub struct MockPodman {
images: Arc>>,
}
+#[allow(dead_code)]
impl MockPodman {
pub fn new() -> Self {
Self {
diff --git a/core/archipelago/src/credentials/types.rs b/core/archipelago/src/credentials/types.rs
index 91b7b5a8..931d82b0 100644
--- a/core/archipelago/src/credentials/types.rs
+++ b/core/archipelago/src/credentials/types.rs
@@ -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 {
diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs
index c0365588..12388456 100644
--- a/core/archipelago/src/data_model.rs
+++ b/core/archipelago/src/data_model.rs
@@ -54,6 +54,9 @@ pub struct ServerInfo {
pub wifi_ssids: Vec,
#[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(),
diff --git a/core/archipelago/src/identity.rs b/core/archipelago/src/identity.rs
index cff94bd6..dd571277 100644
--- a/core/archipelago/src/identity.rs
+++ b/core/archipelago/src/identity.rs
@@ -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 {
+ 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 {
+ did_document_from_pubkey_hex(&self.pubkey_hex())
+ }
}
/// Convert Ed25519 pubkey (hex) to did:key format.
diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs
index dacfa351..fff121e4 100644
--- a/core/archipelago/src/identity_manager.rs
+++ b/core/archipelago/src/identity_manager.rs
@@ -90,6 +90,9 @@ struct IdentityFile {
/// Nostr profile metadata
#[serde(default)]
profile: Option,
+ /// BIP-39 seed derivation index (if created from seed).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ derivation_index: Option,
}
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 {
+ 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 {
let file_path = self.identities_dir.join(format!("{}.json", id));
diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs
index 0a299c25..b1d2135f 100644
--- a/core/archipelago/src/main.rs
+++ b/core/archipelago/src/main.rs
@@ -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();
diff --git a/core/archipelago/src/marketplace.rs b/core/archipelago/src/marketplace.rs
index ecfbae3b..cd4c3183 100644
--- a/core/archipelago/src/marketplace.rs
+++ b/core/archipelago/src/marketplace.rs
@@ -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)]
diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs
index 67b15395..7fdd87a4 100644
--- a/core/archipelago/src/mesh/protocol.rs
+++ b/core/archipelago/src/mesh/protocol.rs
@@ -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(())
}
diff --git a/core/archipelago/src/mesh/session.rs b/core/archipelago/src/mesh/session.rs
index 9aeedb46..34a58f5a 100644
--- a/core/archipelago/src/mesh/session.rs
+++ b/core/archipelago/src/mesh/session.rs
@@ -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 {
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";
diff --git a/core/archipelago/src/port_allocator.rs b/core/archipelago/src/port_allocator.rs
index abf87f13..2455b5d9 100644
--- a/core/archipelago/src/port_allocator.rs
+++ b/core/archipelago/src/port_allocator.rs
@@ -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();
diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs
new file mode 100644
index 00000000..cc34cf22
--- /dev/null
+++ b/core/archipelago/src/seed.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ let path = data_dir.join("identity").join(IDENTITY_INDEX_FILE);
+ match tokio::fs::read_to_string(&path).await {
+ Ok(s) => s.trim().parse::().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> {
+ let hk = Hkdf::::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);
+ }
+ }
+ }
+ }
+}
diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs
index 0f2802e9..3db051af 100644
--- a/core/archipelago/src/session.rs
+++ b/core/archipelago/src/session.rs
@@ -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);
diff --git a/core/archipelago/src/transport/chunking.rs b/core/archipelago/src/transport/chunking.rs
index 1aa7b325..ebe494a6 100644
--- a/core/archipelago/src/transport/chunking.rs
+++ b/core/archipelago/src/transport/chunking.rs
@@ -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
diff --git a/core/archipelago/src/wallet/profits.rs b/core/archipelago/src/wallet/profits.rs
index 21dd6c84..d2f04f37 100644
--- a/core/archipelago/src/wallet/profits.rs
+++ b/core/archipelago/src/wallet/profits.rs
@@ -53,6 +53,42 @@ pub async fn load_profits(data_dir: &Path) -> Result {
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 {
let mut summary = load_profits(data_dir).await?;
diff --git a/core/archipelago/tests/orchestration_tests.rs b/core/archipelago/tests/orchestration_tests.rs
index 86a4e5b6..891c869c 100644
--- a/core/archipelago/tests/orchestration_tests.rs
+++ b/core/archipelago/tests/orchestration_tests.rs
@@ -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 {
diff --git a/docs/user-walkthrough.md b/docs/user-walkthrough.md
index aafcb68d..502cce4f 100644
--- a/docs/user-walkthrough.md
+++ b/docs/user-walkthrough.md
@@ -247,14 +247,116 @@ Each app detail page shows:
### Controller / Gamepad Navigation
-> **Screenshot**: Dashboard with visible focus indicators showing controller navigation in action.
+Archipelago supports Xbox-style controller navigation throughout the UI.
-Archipelago supports Xbox-style controller navigation:
-- **D-pad / Arrow keys**: Navigate between elements
-- **A / Enter**: Select / activate
-- **B / Escape**: Go back
-- **Bumpers**: Switch between pages
-- Focus indicators show the current selection
+#### Global Controls
+
+| Button | Action |
+|--------|--------|
+| D-pad Up/Down | Navigate between elements |
+| D-pad Left/Right | Move between zones (sidebar ↔ content) |
+| A / Enter | Select / activate / enter container |
+| B / Escape | Go back / exit container / return to sidebar |
+
+#### Navigation Zones
+
+**Sidebar** (left column — always visible on desktop):
+- Up/Down = move between items (wraps), auto-navigates page links
+- Right = enter main content (first container, or first button on container-free pages)
+- Left = nothing
+
+**Nav Bar** (mode-switcher tabs at top of content — e.g. My Apps / App Store / Services):
+- Left/Right = move between tabs
+- Down = jump to first card/container below (remembers tab for Up return)
+- Up = nothing (Escape to sidebar)
+- Left from leftmost = sidebar
+
+**Container Grid** (card tiles — Apps, Discover, Network, Home):
+- Arrows = spatial navigation between cards
+- Enter = primary action (Install, Launch, or enter inner controls)
+- Escape = sidebar
+- Left from leftmost card = sidebar
+- Up from top row = return to remembered nav bar tab
+
+**Inside Container** (after Enter on a card — inner buttons/controls):
+- Arrows = move between inner controls
+- Escape = exit back to the card
+- Cannot leave via arrows — must Escape first
+
+**Text Inputs** (search bars, form fields):
+- Up/Down = exit field, navigate to nearest element
+- Enter = submit (clicks the next button)
+- Left/Right = cursor movement (exits field at edges)
+
+#### Per-Page Mapping
+
+**Home** (`/dashboard`)
+- Right from sidebar → first status card
+- D-pad navigates between status cards spatially
+- Enter on card → navigates to that section
+
+**My Apps** (`/dashboard/apps`)
+- Right from sidebar → first app card
+- D-pad navigates app card grid spatially
+- Enter on card → app details page
+- Enter on focused card with Launch button → launches app
+
+**App Store / Discover** (`/dashboard/discover`)
+- Right from sidebar → first featured card
+- D-pad navigates card grid (Sovereignty Stack + All Applications)
+- Down from nav tabs → first card below
+- Up from top card → returns to last-focused tab
+- Enter on card → app detail / install
+- Cards lift on hover/focus (same as My Apps)
+
+**Network** (`/dashboard/server`)
+- Right from sidebar → Quick Actions card
+- D-pad navigates between cards: Quick Actions → Local Network / Web3 → Network Interfaces / Tor Services
+- Enter on Quick Actions → enters inner buttons (Restart, Check Tor, View Logs)
+- Escape from inner buttons → back to card
+- All cards lift on hover/focus
+
+**Settings** (`/dashboard/settings`) — **Linear navigation, no containers**
+- Right from sidebar → first button (server name row)
+- D-pad Up/Down steps through ALL buttons/controls top-to-bottom:
+ 1. Server Name / What's New
+ 2. Copy DID
+ 3. Copy Onion Address
+ 4. Change Password
+ 5. Enable/Disable 2FA
+ 6. Logout
+ 7. Choose Language
+ 8. Login with Claude
+ 9. AI Data Access toggles (each enable/disable row)
+ 10. Manage Updates
+ 11. Webhook URL input
+ 12. Webhook Secret input
+ 13. Container Crash / Update Available toggles
+ 14. Disk Space Warning / Backup Complete toggles
+ 15. Save Configuration / Send Test Webhook
+ 16. Enable Beta Telemetry
+ 17. Create Backup
+ 18. Export Channel Backup
+ 19. Network Diagnostics
+ 20. Reboot
+ 21. Factory Reset
+- Enter = activates the focused button/toggle
+- Escape / Left = sidebar
+
+**Mesh** (`/dashboard/mesh`)
+- Right from sidebar → Device status card (left column)
+- D-pad navigates between left-column containers (Device, Actions, Peers)
+- Enter on peer → opens chat, auto-focuses message input
+- Type message + Enter = send
+- Escape = close chat / back to sidebar
+
+**Cloud** (`/dashboard/cloud`)
+- Right from sidebar → first folder/file card
+- D-pad navigates file grid spatially
+- Enter = open folder / file details
+
+**Detail Pages** (app details, marketplace app details):
+- Escape / B = go back to previous page
---
diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js
index 06102784..2ffd8de6 100755
--- a/neode-ui/mock-backend.js
+++ b/neode-ui/mock-backend.js
@@ -917,6 +917,31 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: { valid: true } })
}
+ // BIP-39 seed management (mock for dev mode)
+ case 'seed.generate': {
+ const mockWords = [
+ 'abandon', 'ability', 'able', 'about', 'above', 'absent',
+ 'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident',
+ 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire',
+ 'across', 'act', 'action', 'actor', 'actress', 'actual'
+ ]
+ return res.json({ result: { words: mockWords } })
+ }
+ case 'seed.verify': {
+ const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
+ return res.json({ result: { verified: true, did: mockDid, nostr_npub: 'npub1mock...' } })
+ }
+ case 'seed.restore': {
+ const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
+ return res.json({ result: { did: mockDid, nostr_npub: 'npub1mock...', restored: true } })
+ }
+ case 'seed.save-encrypted': {
+ return res.json({ result: { saved: true } })
+ }
+ case 'seed.status': {
+ return res.json({ result: { has_seed: true, is_legacy: false, identity_count: 1, next_index: 1 } })
+ }
+
case 'node.createBackup': {
const { passphrase } = params || {}
if (!passphrase) {
diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts
index 41e20470..770e9a20 100644
--- a/neode-ui/src/api/rpc-client.ts
+++ b/neode-ui/src/api/rpc-client.ts
@@ -225,6 +225,43 @@ class RPCClient {
})
}
+ // ─── Seed Management ───────────────────────────────────────────────
+
+ async generateSeed(): Promise<{ words: string[] }> {
+ return this.call({ method: 'seed.generate' })
+ }
+
+ async verifySeed(words: string[], indices: number[]): Promise<{
+ verified: boolean
+ did: string
+ nostr_npub: string
+ }> {
+ return this.call({ method: 'seed.verify', params: { words, indices } })
+ }
+
+ async restoreSeed(words: string[]): Promise<{
+ did: string
+ nostr_npub: string
+ restored: boolean
+ }> {
+ return this.call({ method: 'seed.restore', params: { words } })
+ }
+
+ async saveSeedEncrypted(passphrase: string): Promise<{ saved: boolean }> {
+ return this.call({ method: 'seed.save-encrypted', params: { passphrase } })
+ }
+
+ async seedStatus(): Promise<{
+ has_seed: boolean
+ is_legacy: boolean
+ identity_count: number
+ next_index: number
+ }> {
+ return this.call({ method: 'seed.status' })
+ }
+
+ // ─── Node Identity ───────────────────────────────────────────────
+
async getNodeDid(): Promise<{ did: string; pubkey: string }> {
return this.call({
method: 'node.did',
diff --git a/neode-ui/src/components/ToggleSwitch.vue b/neode-ui/src/components/ToggleSwitch.vue
index e8b4e056..74199f5c 100644
--- a/neode-ui/src/components/ToggleSwitch.vue
+++ b/neode-ui/src/components/ToggleSwitch.vue
@@ -3,6 +3,8 @@
type="button"
role="switch"
:aria-checked="modelValue"
+ tabindex="-1"
+ data-controller-ignore
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
@click="$emit('update:modelValue', !modelValue)"
diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts
index 41cd880c..7cfe05c4 100644
--- a/neode-ui/src/composables/useControllerNav.ts
+++ b/neode-ui/src/composables/useControllerNav.ts
@@ -84,9 +84,13 @@ function getNavBarItems(): HTMLElement[] {
function isNavBarItem(el: HTMLElement | null): boolean {
if (!el) return false
- return isInZone(el, 'main') &&
- !el.hasAttribute('data-controller-container') &&
- !el.closest('[data-controller-container]')
+ if (!isInZone(el, 'main')) return false
+ if (el.hasAttribute('data-controller-container') || el.closest('[data-controller-container]')) return false
+ // On container-free pages (e.g. Settings), don't classify elements as nav bar items —
+ // let them fall through to the main zone handler which supports linear up/down/right nav.
+ const zone = document.querySelector('[data-controller-zone="main"]')
+ if (zone && !zone.querySelector('[data-controller-container]')) return false
+ return true
}
/** Inner focusables within a container (buttons, links — not the container itself) */
@@ -425,6 +429,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
if (dest) {
focusEl(dest)
} else {
+ // Check if this is a container-free page (e.g. Settings) — focus first button immediately
+ const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
+ const hasAnyContainers = zone?.querySelector('[data-controller-container]')
+ if (!hasAnyContainers && zone) {
+ const focusable = getFocusableElements(zone)
+ if (focusable[0]) { focusEl(focusable[0]); return }
+ }
// Containers not rendered yet (route transition / animation in progress)
// Poll until they appear, up to 1s
let attempts = 0
@@ -436,9 +447,8 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
- // No containers on this page (e.g. Settings) — focus first focusable element
- const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
- if (z) { const f = getFocusableElements(z); if (f[0]) focusEl(f[0]) }
+ // Last resort: focus first focusable element
+ if (zone) { const f = getFocusableElements(zone); if (f[0]) focusEl(f[0]) }
}
}, 100)
}
@@ -477,31 +487,20 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return
}
- if (dir === 'down') {
- // Down from nav bar → jump to containers (remember tab for Up return)
- rememberFocus('navBar', activeEl)
- const containers = getContainers()
- const nearest = findNearestInDirection(activeEl, containers, 'down')
- if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
- // Fallback: just focus first container
- if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return }
- // Containers not rendered yet — poll until they appear
- let attempts = 0
- const poll = setInterval(() => {
- attempts++
- const retryContainers = getContainers()
- if (retryContainers[0]) {
- clearInterval(poll)
- rememberFocus('main', retryContainers[0])
- focusEl(retryContainers[0])
- } else if (attempts >= 10) {
- clearInterval(poll)
- }
- }, 100)
- return
+ if (dir === 'down' || dir === 'up') {
+ // Up/Down from standalone element → find nearest focusable (container or button) in direction.
+ // Searches containers + standalone elements together so mixed pages (Settings) don't jump.
+ if (dir === 'down') rememberFocus('navBar', activeEl)
+ const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
+ if (zone) {
+ const allFocusable = getFocusableElements(zone).filter(el =>
+ el.hasAttribute('data-controller-container') ||
+ !el.closest('[data-controller-container]')
+ )
+ const target = findNearestInDirection(activeEl, allFocusable, dir)
+ if (target) { focusEl(target); return }
+ }
}
-
- // Up from nav bar → nothing (use Escape to go to sidebar)
return
}
@@ -509,8 +508,14 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
if (isInZone(activeEl, 'main')) {
const containers = getContainers()
- // Try spatial nav to another container
- const next = findNearestInDirection(activeEl, containers, dir)
+ // Try spatial nav to containers + standalone focusables (not inner buttons).
+ // This handles mixed pages (e.g. Settings) where containers and buttons coexist.
+ const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
+ const navTargets = zone ? getFocusableElements(zone).filter(el =>
+ el.hasAttribute('data-controller-container') ||
+ !el.closest('[data-controller-container]')
+ ) : containers
+ const next = findNearestInDirection(activeEl, navTargets, dir)
if (next) {
rememberFocus('main', next)
focusEl(next)
diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts
index 45cb7e61..d04b1c8c 100644
--- a/neode-ui/src/router/index.ts
+++ b/neode-ui/src/router/index.ts
@@ -35,6 +35,21 @@ const router = createRouter({
name: 'onboarding-path',
component: () => import('../views/OnboardingPath.vue'),
},
+ {
+ path: 'onboarding/seed',
+ name: 'onboarding-seed',
+ component: () => import('../views/OnboardingSeedGenerate.vue'),
+ },
+ {
+ path: 'onboarding/seed-verify',
+ name: 'onboarding-seed-verify',
+ component: () => import('../views/OnboardingSeedVerify.vue'),
+ },
+ {
+ path: 'onboarding/seed-restore',
+ name: 'onboarding-seed-restore',
+ component: () => import('../views/OnboardingSeedRestore.vue'),
+ },
{
path: 'onboarding/did',
name: 'onboarding-did',
diff --git a/neode-ui/src/stores/sync.ts b/neode-ui/src/stores/sync.ts
index 4e2652be..75f992d2 100644
--- a/neode-ui/src/stores/sync.ts
+++ b/neode-ui/src/stores/sync.ts
@@ -136,6 +136,7 @@ export const useSyncStore = defineStore('sync', () => {
unread: 0,
'wifi-ssids': [],
'zram-enabled': false,
+ 'seed-backed': false,
},
'package-data': {},
ui: {
diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts
index c4852ffd..428fcdf5 100644
--- a/neode-ui/src/types/api.ts
+++ b/neode-ui/src/types/api.ts
@@ -29,6 +29,7 @@ export interface ServerInfo {
unread: number
'wifi-ssids': string[]
'zram-enabled': boolean
+ 'seed-backed': boolean
}
export interface StatusInfo {
diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue
index ca184c34..e7cb6701 100644
--- a/neode-ui/src/views/Discover.vue
+++ b/neode-ui/src/views/Discover.vue
@@ -115,13 +115,13 @@
manifesto
-
+
"Privacy is not about having something to hide. Privacy is about having the right to choose
what to reveal. In a world of surveillance capitalism, self-hosting is an act of resistance.
Every service you run on your own hardware is a vote for a future where individuals — not
corporations — control their digital lives."