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:
Dorian 2026-03-31 01:41:24 +01:00
parent 5da9e217e6
commit 19dcfd4f31
50 changed files with 2200 additions and 258 deletions

89
core/Cargo.lock generated
View File

@ -89,6 +89,8 @@ dependencies = [
"argon2", "argon2",
"base64 0.21.7", "base64 0.21.7",
"bcrypt", "bcrypt",
"bip39",
"bitcoin",
"bs58", "bs58",
"bytes", "bytes",
"chacha20poly1305", "chacha20poly1305",
@ -275,6 +277,16 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" 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]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -314,21 +326,71 @@ checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
[[package]] [[package]]
name = "bip39" name = "bip39"
version = "2.2.2" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
dependencies = [ dependencies = [
"bitcoin_hashes", "bitcoin_hashes 0.13.0",
"rand 0.8.5",
"rand_core 0.6.4",
"serde", "serde",
"unicode-normalization", "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]] [[package]]
name = "bitcoin-io" name = "bitcoin-io"
version = "0.1.4" version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" 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]] [[package]]
name = "bitcoin_hashes" name = "bitcoin_hashes"
version = "0.14.1" version = "0.14.1"
@ -336,7 +398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [ dependencies = [
"bitcoin-io", "bitcoin-io",
"hex-conservative", "hex-conservative 0.2.2",
"serde", "serde",
] ]
@ -1037,6 +1099,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
[[package]] [[package]]
name = "hex-conservative" name = "hex-conservative"
version = "0.2.2" version = "0.2.2"
@ -1046,6 +1114,12 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "hex_lit"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.12.4" version = "0.12.4"
@ -1639,7 +1713,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bech32", "bech32",
"bip39", "bip39",
"bitcoin_hashes", "bitcoin_hashes 0.14.1",
"cbc", "cbc",
"chacha20", "chacha20",
"chacha20poly1305", "chacha20poly1305",
@ -2255,6 +2329,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [ dependencies = [
"bitcoin_hashes 0.14.1",
"rand 0.8.5", "rand 0.8.5",
"secp256k1-sys", "secp256k1-sys",
"serde", "serde",
@ -3062,9 +3137,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
version = "0.1.25" version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [ dependencies = [
"tinyvec", "tinyvec",
] ]

View File

@ -54,6 +54,10 @@ hex = "0.4"
bs58 = "0.5" bs58 = "0.5"
chrono = "0.4" 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 # Configuration
toml = "0.8" toml = "0.8"
serde_yaml = "0.9" serde_yaml = "0.9"

View File

@ -1,6 +1,7 @@
use super::RpcHandler; use super::RpcHandler;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct BitcoinInfo { struct BitcoinInfo {
@ -106,4 +107,117 @@ impl RpcHandler {
.result .result
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null 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,
}))
}
} }

View File

@ -22,6 +22,13 @@ impl RpcHandler {
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).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 orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(params).await, "container-install" => self.handle_container_install(params).await,
"container-start" => self.handle_container_start(params).await, "container-start" => self.handle_container_start(params).await,
@ -78,6 +85,7 @@ impl RpcHandler {
// Bitcoin & Lightning deep data // Bitcoin & Lightning deep data
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await, "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.getinfo" => self.handle_lnd_getinfo().await,
"lnd.listchannels" => self.handle_lnd_listchannels().await, "lnd.listchannels" => self.handle_lnd_listchannels().await,
"lnd.openchannel" => self.handle_lnd_openchannel(params).await, "lnd.openchannel" => self.handle_lnd_openchannel(params).await,
@ -92,6 +100,7 @@ impl RpcHandler {
"lnd.gettransactions" => self.handle_lnd_gettransactions().await, "lnd.gettransactions" => self.handle_lnd_gettransactions().await,
"lnd.connect-info" => self.handle_lnd_connect_info().await, "lnd.connect-info" => self.handle_lnd_connect_info().await,
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().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 // Multi-identity management
"identity.list" => self.handle_identity_list(params).await, "identity.list" => self.handle_identity_list(params).await,

View File

@ -2,6 +2,7 @@ use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use base64::Engine; use base64::Engine;
use tracing::info; use tracing::info;
use zeroize::Zeroize;
impl RpcHandler { impl RpcHandler {
/// Generate a new on-chain Bitcoin address. /// Generate a new on-chain Bitcoin address.
@ -381,4 +382,71 @@ impl RpcHandler {
"broadcast": false, "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,
}))
}
} }

View File

@ -21,6 +21,11 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
"identity.create", "identity.create",
"identity.verify", "identity.verify",
"identity.resolve-did", "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) // Onboarding restore (before user account exists)
"backup.restore-identity", "backup.restore-identity",
// Inter-node RPC: called by federated peers over Tor, no session cookies // Inter-node RPC: called by federated peers over Tor, no session cookies

View File

@ -24,6 +24,7 @@ mod package;
mod peers; mod peers;
mod response; mod response;
mod router; mod router;
mod seed_rpc;
mod security; mod security;
mod tor; mod tor;
mod transport; mod transport;

View File

@ -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,
}))
}
}

View File

@ -156,6 +156,7 @@ pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
/// Read disk usage via `df` for the root filesystem. /// Read disk usage via `df` for the root filesystem.
/// Returns (used_bytes, total_bytes). /// Returns (used_bytes, total_bytes).
#[allow(dead_code)]
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> { pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
read_disk_usage_path("/").await read_disk_usage_path("/").await
} }

View File

@ -97,6 +97,7 @@ impl AuthManager {
/// Ensure a default user exists on first boot. /// Ensure a default user exists on first boot.
/// Called once at startup — creates user with default password if none exists. /// Called once at startup — creates user with default password if none exists.
#[allow(dead_code)]
pub async fn ensure_default_user(&self) -> Result<()> { pub async fn ensure_default_user(&self) -> Result<()> {
if self.is_setup().await? { if self.is_setup().await? {
return Ok(()); return Ok(());

View File

@ -69,7 +69,6 @@ impl DevDataManager {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::path::PathBuf;
#[tokio::test] #[tokio::test]
async fn test_map_volume_path() { async fn test_map_volume_path() {

View File

@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU32, Ordering}};
/// Container state matching podman's real states. /// Container state matching podman's real states.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum MockContainerState { pub enum MockContainerState {
Created, Created,
Running, Running,
@ -28,6 +29,7 @@ impl MockContainerState {
/// A simulated container. /// A simulated container.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct MockContainer { pub struct MockContainer {
pub name: String, pub name: String,
pub image: String, pub image: String,
@ -36,6 +38,7 @@ pub struct MockContainer {
} }
/// Mock podman runtime for testing orchestration logic without real containers. /// Mock podman runtime for testing orchestration logic without real containers.
#[allow(dead_code)]
pub struct MockPodman { pub struct MockPodman {
containers: Arc<Mutex<HashMap<String, MockContainer>>>, containers: Arc<Mutex<HashMap<String, MockContainer>>>,
/// When true, `podman pull` will fail (simulates registry down). /// When true, `podman pull` will fail (simulates registry down).
@ -50,6 +53,7 @@ pub struct MockPodman {
images: Arc<Mutex<Vec<String>>>, images: Arc<Mutex<Vec<String>>>,
} }
#[allow(dead_code)]
impl MockPodman { impl MockPodman {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {

View File

@ -56,6 +56,26 @@ pub struct CredentialStatusEntry {
pub status: String, 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. /// Stored credentials index.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CredentialStore { pub struct CredentialStore {

View File

@ -54,6 +54,9 @@ pub struct ServerInfo {
pub wifi_ssids: Vec<String>, pub wifi_ssids: Vec<String>,
#[serde(rename = "zram-enabled")] #[serde(rename = "zram-enabled")]
pub zram_enabled: bool, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -269,6 +272,7 @@ impl DataModel {
unread: 0, unread: 0,
wifi_ssids: vec![], wifi_ssids: vec![],
zram_enabled: false, zram_enabled: false,
seed_backed: false,
}, },
package_data: HashMap::new(), package_data: HashMap::new(),
peer_health: HashMap::new(), peer_health: HashMap::new(),

View File

@ -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). /// Access the signing key (for key derivation, e.g. mesh encryption).
pub fn signing_key(&self) -> &SigningKey { pub fn signing_key(&self) -> &SigningKey {
&self.signing_key &self.signing_key
@ -115,6 +154,11 @@ impl NodeIdentity {
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e)) .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. /// Convert Ed25519 pubkey (hex) to did:key format.

View File

@ -90,6 +90,9 @@ struct IdentityFile {
/// Nostr profile metadata /// Nostr profile metadata
#[serde(default)] #[serde(default)]
profile: Option<IdentityProfile>, 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 { pub struct IdentityManager {
@ -150,6 +153,7 @@ impl IdentityManager {
nostr_secret_hex: None, nostr_secret_hex: None,
nostr_pubkey_hex: None, nostr_pubkey_hex: None,
profile: None, profile: None,
derivation_index: None,
}; };
let file_path = self.identities_dir.join(format!("{}.json", id)); let file_path = self.identities_dir.join(format!("{}.json", id));
@ -173,7 +177,7 @@ impl IdentityManager {
self.set_default(&id).await?; 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; let _ = self.create_nostr_key(&id).await;
// Re-read to pick up the Nostr keys // Re-read to pick up the Nostr keys
@ -184,6 +188,72 @@ impl IdentityManager {
Ok(record) 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). /// Get a single identity by ID (without secret key).
pub async fn get(&self, id: &str) -> Result<IdentityRecord> { pub async fn get(&self, id: &str) -> Result<IdentityRecord> {
let file_path = self.identities_dir.join(format!("{}.json", id)); let file_path = self.identities_dir.join(format!("{}.json", id));

View File

@ -40,18 +40,15 @@ mod totp;
mod wallet; mod wallet;
mod names; mod names;
mod network; mod network;
pub mod seed;
mod nostr_relays; mod nostr_relays;
mod update; mod update;
mod vpn; mod vpn;
mod webhooks; mod webhooks;
use auth::AuthManager;
use config::Config; use config::Config;
use server::Server; use server::Server;
/// Default dev password when auto-creating a user (matches mock-backend).
const DEV_DEFAULT_PASSWORD: &str = "password123";
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let startup_start = std::time::Instant::now(); let startup_start = std::time::Instant::now();

View File

@ -138,6 +138,16 @@ pub enum ManifestDescription {
Detailed { short: String, long: String }, 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. /// A discovered marketplace app with trust scoring.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -634,7 +634,7 @@ mod tests {
let frame = build_app_start("Archipelago"); let frame = build_app_start("Archipelago");
assert_eq!(frame[3], CMD_APP_START); assert_eq!(frame[3], CMD_APP_START);
let name = &frame[4..]; 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(()) Ok(())
} }

View File

@ -158,6 +158,29 @@ impl SessionManager {
Ok(plaintext) 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). /// Get session info for a peer (for RPC status endpoint).
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> { pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
let sessions = self.sessions.read().await; let sessions = self.sessions.read().await;
@ -205,7 +228,7 @@ mod tests {
let mgr = SessionManager::new(dir.path()); let mgr = SessionManager::new(dir.path());
let root_key = [42u8; 32]; 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 state = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
let did = "did:key:z6MkTestSession"; let did = "did:key:z6MkTestSession";

View File

@ -157,49 +157,49 @@ mod tests {
assert!(allocs.allocations.is_empty()); assert!(allocs.allocations.is_empty());
} }
#[test] #[tokio::test]
fn test_new_allocator_from_empty_dir() { async fn test_new_allocator_from_empty_dir() {
let dir = tempfile::tempdir().unwrap(); 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()); assert!(alloc.allocations.allocations.is_empty());
} }
#[test] #[tokio::test]
fn test_allocate_preferred_port_when_available() { async fn test_allocate_preferred_port_when_available() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
let port = alloc.allocate("my-app", 8500, 80).unwrap(); let port = alloc.allocate("my-app", 8500, 80).await.unwrap();
assert_eq!(port, 8500); assert_eq!(port, 8500);
} }
#[test] #[tokio::test]
fn test_allocate_fallback_when_preferred_is_reserved() { async fn test_allocate_fallback_when_preferred_is_reserved() {
let dir = tempfile::tempdir().unwrap(); 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 // 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_ne!(port, 80);
assert!(port >= WEB_PORT_RANGE_START && port <= WEB_PORT_RANGE_END); assert!(port >= WEB_PORT_RANGE_START && port <= WEB_PORT_RANGE_END);
} }
#[test] #[tokio::test]
fn test_allocate_fallback_when_preferred_is_taken() { async fn test_allocate_fallback_when_preferred_is_taken() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
let port1 = alloc.allocate("app-1", 8500, 80).unwrap(); let port1 = alloc.allocate("app-1", 8500, 80).await.unwrap();
assert_eq!(port1, 8500); assert_eq!(port1, 8500);
// Second app requesting the same preferred port gets a different one // 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_ne!(port2, 8500);
assert!(port2 >= WEB_PORT_RANGE_START && port2 <= WEB_PORT_RANGE_END); assert!(port2 >= WEB_PORT_RANGE_START && port2 <= WEB_PORT_RANGE_END);
} }
#[test] #[tokio::test]
fn test_get_returns_existing_allocation() { async fn test_get_returns_existing_allocation() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
alloc.allocate("test-app", 8600, 3000).unwrap(); alloc.allocate("test-app", 8600, 3000).await.unwrap();
let result = alloc.get("test-app"); let result = alloc.get("test-app");
assert!(result.is_some()); assert!(result.is_some());
@ -208,78 +208,78 @@ mod tests {
assert_eq!(container, 3000); assert_eq!(container, 3000);
} }
#[test] #[tokio::test]
fn test_get_returns_none_for_unknown_app() { async fn test_get_returns_none_for_unknown_app() {
let dir = tempfile::tempdir().unwrap(); 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()); assert!(alloc.get("nonexistent").is_none());
} }
#[test] #[tokio::test]
fn test_allocate_or_get_returns_existing() { async fn test_allocate_or_get_returns_existing() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
let port1 = alloc.allocate("my-app", 8700, 80).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 // 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!(port1, port2);
assert_eq!(port2, 8700); assert_eq!(port2, 8700);
} }
#[test] #[tokio::test]
fn test_allocate_or_get_allocates_when_new() { async fn test_allocate_or_get_allocates_when_new() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
let port = alloc.allocate_or_get("new-app", 8800, 443).unwrap(); let port = alloc.allocate_or_get("new-app", 8800, 443).await.unwrap();
assert_eq!(port, 8800); assert_eq!(port, 8800);
// Verify it's now stored // Verify it's now stored
assert!(alloc.get("new-app").is_some()); assert!(alloc.get("new-app").is_some());
} }
#[test] #[tokio::test]
fn test_release_removes_allocation() { async fn test_release_removes_allocation() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
alloc.allocate("removable", 8900, 80).unwrap(); alloc.allocate("removable", 8900, 80).await.unwrap();
assert!(alloc.get("removable").is_some()); assert!(alloc.get("removable").is_some());
alloc.release("removable").unwrap(); alloc.release("removable").await.unwrap();
assert!(alloc.get("removable").is_none()); assert!(alloc.get("removable").is_none());
} }
#[test] #[tokio::test]
fn test_released_port_becomes_available() { async fn test_released_port_becomes_available() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
alloc.allocate("app-a", 8500, 80).unwrap(); alloc.allocate("app-a", 8500, 80).await.unwrap();
alloc.release("app-a").unwrap(); alloc.release("app-a").await.unwrap();
// Port 8500 should now be available again // 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); assert_eq!(port, 8500);
} }
#[test] #[tokio::test]
fn test_reserved_ports_are_never_allocated() { async fn test_reserved_ports_are_never_allocated() {
let dir = tempfile::tempdir().unwrap(); 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 { for &port in RESERVED_PORTS {
assert!(alloc.is_reserved(port), "Port {} should be reserved", port); assert!(alloc.is_reserved(port), "Port {} should be reserved", port);
assert!(!alloc.is_available(port), "Port {} should not be available", port); assert!(!alloc.is_available(port), "Port {} should not be available", port);
} }
} }
#[test] #[tokio::test]
fn test_persistence_across_instances() { async fn test_persistence_across_instances() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
{ {
let mut alloc = PortAllocator::new(dir.path()).unwrap(); let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
alloc.allocate("persistent-app", 8555, 80).unwrap(); alloc.allocate("persistent-app", 8555, 80).await.unwrap();
} }
// Create a new allocator from the same directory // 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"); let result = alloc2.get("persistent-app");
assert!(result.is_some()); assert!(result.is_some());
let (host, container) = result.unwrap(); let (host, container) = result.unwrap();

View File

@ -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);
}
}
}
}
}

View File

@ -436,7 +436,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_session_create_and_validate() { async fn test_session_create_and_validate() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let token = store.create().await; let token = store.create().await;
assert!(store.validate(&token).await); assert!(store.validate(&token).await);
@ -444,13 +444,13 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_session_invalid_token() { async fn test_session_invalid_token() {
let store = SessionStore::new(); let store = SessionStore::new().await;
assert!(!store.validate("nonexistent_token").await); assert!(!store.validate("nonexistent_token").await);
} }
#[tokio::test] #[tokio::test]
async fn test_session_remove() { async fn test_session_remove() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let token = store.create().await; let token = store.create().await;
assert!(store.validate(&token).await); assert!(store.validate(&token).await);
@ -460,7 +460,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_pending_session_upgrade() { async fn test_pending_session_upgrade() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let secret = vec![1, 2, 3, 4]; let secret = vec![1, 2, 3, 4];
let token = store.create_pending(secret.clone()).await; let token = store.create_pending(secret.clone()).await;
@ -484,7 +484,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_pending_session_max_attempts() { async fn test_pending_session_max_attempts() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let secret = vec![1, 2, 3]; let secret = vec![1, 2, 3];
let token = store.create_pending(secret).await; let token = store.create_pending(secret).await;
@ -513,7 +513,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_session_activity_updates_on_validate() { async fn test_session_activity_updates_on_validate() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let token = store.create().await; let token = store.create().await;
// First validation should succeed and touch last_activity // First validation should succeed and touch last_activity
@ -525,7 +525,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_invalidate_all_except() { async fn test_invalidate_all_except() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let token1 = store.create().await; let token1 = store.create().await;
let token2 = store.create().await; let token2 = store.create().await;
let token3 = store.create().await; let token3 = store.create().await;
@ -540,7 +540,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_session_rotate() { async fn test_session_rotate() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let old_token = store.create().await; let old_token = store.create().await;
assert!(store.validate(&old_token).await); assert!(store.validate(&old_token).await);
@ -555,7 +555,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_max_concurrent_sessions() { async fn test_max_concurrent_sessions() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let mut tokens = Vec::new(); let mut tokens = Vec::new();
// Create MAX_CONCURRENT_SESSIONS sessions // Create MAX_CONCURRENT_SESSIONS sessions
@ -583,7 +583,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_active_session_count() { async fn test_active_session_count() {
let store = SessionStore::new(); let store = SessionStore::new().await;
assert_eq!(store.active_session_count().await, 0); assert_eq!(store.active_session_count().await, 0);
let token1 = store.create().await; let token1 = store.create().await;
@ -598,7 +598,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_cleanup_expired_removes_stale() { async fn test_cleanup_expired_removes_stale() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let token = store.create().await; let token = store.create().await;
assert!(store.validate(&token).await); assert!(store.validate(&token).await);
@ -611,7 +611,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_rotate_preserves_session_count() { async fn test_rotate_preserves_session_count() {
let store = SessionStore::new(); let store = SessionStore::new().await;
let token = store.create().await; let token = store.create().await;
assert_eq!(store.active_session_count().await, 1); assert_eq!(store.active_session_count().await, 1);

View File

@ -326,7 +326,7 @@ mod tests {
let chunks = encode_chunked(&data).unwrap(); let chunks = encode_chunked(&data).unwrap();
let data_chunks: Vec<_> = chunks.iter().filter(|c| !c.is_parity).collect(); 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 assert_eq!(data_chunks.len(), 4); // ceil(500/124) = 5... wait
// Actually: ceil(500/124) = ceil(4.03) = 5 data shards // Actually: ceil(500/124) = ceil(4.03) = 5 data shards
// But the first shard has 4 bytes of length header embedded, so // But the first shard has 4 bytes of length header embedded, so

View File

@ -53,6 +53,42 @@ pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
Ok(summary) 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. /// Compute a full profits summary including ecash receive transactions.
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> { pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
let mut summary = load_profits(data_dir).await?; let mut summary = load_profits(data_dir).await?;

View File

@ -259,7 +259,7 @@ mod restart_tracker {
// ── Failsafe Install ────────────────────────────────────────────────── // ── Failsafe Install ──────────────────────────────────────────────────
mod failsafe_install { mod failsafe_install {
use crate::mock_podman::{MockPodman, MockContainerState}; use crate::mock_podman::MockPodman;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
#[test] #[test]
@ -302,7 +302,6 @@ mod failsafe_install {
// ── Health Monitor Logic ────────────────────────────────────────────── // ── Health Monitor Logic ──────────────────────────────────────────────
mod health_monitor_logic { mod health_monitor_logic {
use crate::mock_podman::{MockPodman, MockContainerState};
/// Mirrors the tier ordering from health_monitor.rs /// Mirrors the tier ordering from health_monitor.rs
fn container_tier(name: &str) -> u8 { fn container_tier(name: &str) -> u8 {

View File

@ -247,14 +247,116 @@ Each app detail page shows:
### Controller / Gamepad Navigation ### 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: #### Global Controls
- **D-pad / Arrow keys**: Navigate between elements
- **A / Enter**: Select / activate | Button | Action |
- **B / Escape**: Go back |--------|--------|
- **Bumpers**: Switch between pages | D-pad Up/Down | Navigate between elements |
- Focus indicators show the current selection | 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
--- ---

View File

@ -917,6 +917,31 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: { valid: true } }) 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': { case 'node.createBackup': {
const { passphrase } = params || {} const { passphrase } = params || {}
if (!passphrase) { if (!passphrase) {

View File

@ -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 }> { async getNodeDid(): Promise<{ did: string; pubkey: string }> {
return this.call({ return this.call({
method: 'node.did', method: 'node.did',

View File

@ -3,6 +3,8 @@
type="button" type="button"
role="switch" role="switch"
:aria-checked="modelValue" :aria-checked="modelValue"
tabindex="-1"
data-controller-ignore
class="w-10 h-6 rounded-full shrink-0 transition-colors relative" class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'" :class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
@click="$emit('update:modelValue', !modelValue)" @click="$emit('update:modelValue', !modelValue)"

View File

@ -84,9 +84,13 @@ function getNavBarItems(): HTMLElement[] {
function isNavBarItem(el: HTMLElement | null): boolean { function isNavBarItem(el: HTMLElement | null): boolean {
if (!el) return false if (!el) return false
return isInZone(el, 'main') && if (!isInZone(el, 'main')) return false
!el.hasAttribute('data-controller-container') && if (el.hasAttribute('data-controller-container') || el.closest('[data-controller-container]')) return false
!el.closest('[data-controller-container]') // 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) */ /** Inner focusables within a container (buttons, links — not the container itself) */
@ -425,6 +429,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
if (dest) { if (dest) {
focusEl(dest) focusEl(dest)
} else { } 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) // Containers not rendered yet (route transition / animation in progress)
// Poll until they appear, up to 1s // Poll until they appear, up to 1s
let attempts = 0 let attempts = 0
@ -436,9 +447,8 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
focusEl(retryContainers[0]) focusEl(retryContainers[0])
} else if (attempts >= 10) { } else if (attempts >= 10) {
clearInterval(poll) clearInterval(poll)
// No containers on this page (e.g. Settings) — focus first focusable element // Last resort: focus first focusable element
const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null if (zone) { const f = getFocusableElements(zone); if (f[0]) focusEl(f[0]) }
if (z) { const f = getFocusableElements(z); if (f[0]) focusEl(f[0]) }
} }
}, 100) }, 100)
} }
@ -477,31 +487,20 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return return
} }
if (dir === 'down') { if (dir === 'down' || dir === 'up') {
// Down from nav bar → jump to containers (remember tab for Up return) // Up/Down from standalone element → find nearest focusable (container or button) in direction.
rememberFocus('navBar', activeEl) // Searches containers + standalone elements together so mixed pages (Settings) don't jump.
const containers = getContainers() if (dir === 'down') rememberFocus('navBar', activeEl)
const nearest = findNearestInDirection(activeEl, containers, 'down') const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return } if (zone) {
// Fallback: just focus first container const allFocusable = getFocusableElements(zone).filter(el =>
if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return } el.hasAttribute('data-controller-container') ||
// Containers not rendered yet — poll until they appear !el.closest('[data-controller-container]')
let attempts = 0 )
const poll = setInterval(() => { const target = findNearestInDirection(activeEl, allFocusable, dir)
attempts++ if (target) { focusEl(target); return }
const retryContainers = getContainers() }
if (retryContainers[0]) {
clearInterval(poll)
rememberFocus('main', retryContainers[0])
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
}
}, 100)
return
} }
// Up from nav bar → nothing (use Escape to go to sidebar)
return return
} }
@ -509,8 +508,14 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
if (isInZone(activeEl, 'main')) { if (isInZone(activeEl, 'main')) {
const containers = getContainers() const containers = getContainers()
// Try spatial nav to another container // Try spatial nav to containers + standalone focusables (not inner buttons).
const next = findNearestInDirection(activeEl, containers, dir) // 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) { if (next) {
rememberFocus('main', next) rememberFocus('main', next)
focusEl(next) focusEl(next)

View File

@ -35,6 +35,21 @@ const router = createRouter({
name: 'onboarding-path', name: 'onboarding-path',
component: () => import('../views/OnboardingPath.vue'), 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', path: 'onboarding/did',
name: 'onboarding-did', name: 'onboarding-did',

View File

@ -136,6 +136,7 @@ export const useSyncStore = defineStore('sync', () => {
unread: 0, unread: 0,
'wifi-ssids': [], 'wifi-ssids': [],
'zram-enabled': false, 'zram-enabled': false,
'seed-backed': false,
}, },
'package-data': {}, 'package-data': {},
ui: { ui: {

View File

@ -29,6 +29,7 @@ export interface ServerInfo {
unread: number unread: number
'wifi-ssids': string[] 'wifi-ssids': string[]
'zram-enabled': boolean 'zram-enabled': boolean
'seed-backed': boolean
} }
export interface StatusInfo { export interface StatusInfo {

View File

@ -115,13 +115,13 @@
<span class="discover-terminal-tag text-orange-400/80">manifesto</span> <span class="discover-terminal-tag text-orange-400/80">manifesto</span>
<div class="flex-1 h-px bg-white/10"></div> <div class="flex-1 h-px bg-white/10"></div>
</div> </div>
<blockquote class="text-white/60 text-sm leading-relaxed italic max-w-3xl"> <blockquote class="text-white/80 text-xl leading-relaxed italic max-w-3xl">
"Privacy is not about having something to hide. Privacy is about having the right to choose "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. 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 &mdash; not Every service you run on your own hardware is a vote for a future where individuals &mdash; not
corporations &mdash; control their digital lives." corporations &mdash; control their digital lives."
</blockquote> </blockquote>
<p class="text-white/30 text-xs mt-4 font-mono">// Cypherpunks write code. We run nodes.</p> <p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
</div> </div>
<FilterModal <FilterModal

View File

@ -119,7 +119,7 @@ async function createIdentity() {
} }
}) })
playNavSound('action') playNavSound('action')
router.push('/onboarding/backup').catch(() => {}) router.push('/onboarding/done').catch(() => {})
} catch (err) { } catch (err) {
if (isServerStartingError(err)) { if (isServerStartingError(err)) {
serverStarting.value = true serverStarting.value = true

View File

@ -29,40 +29,11 @@
tabindex="0" tabindex="0"
role="button" role="button"
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta" class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
@click="showRestore = true" @click="goToRestore"
@keydown.enter="showRestore = true" @keydown.enter="goToRestore"
> >
Restore from backup Restore from seed phrase
</a> </a>
<!-- Restore Panel -->
<div v-if="showRestore" class="mt-6 glass-card px-6 py-6 text-left">
<h3 class="text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Restore Identity from Backup</h3>
<input
type="file"
accept=".json"
class="block w-full text-sm text-white/60 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white/80 hover:file:bg-white/20 mb-3"
@change="onFileSelect"
/>
<input
v-model="passphrase"
type="password"
placeholder="Backup passphrase"
class="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm focus:outline-none focus:border-white/40 mb-3"
/>
<p v-if="restoreError" class="text-red-400 text-xs mb-2">{{ restoreError }}</p>
<p v-if="restoreSuccess" class="text-green-400 text-xs mb-2">Identity restored successfully!</p>
<div class="flex gap-3">
<button class="glass-button text-sm px-4 py-2" @click="showRestore = false">Cancel</button>
<button
class="glass-button text-sm px-4 py-2"
:disabled="!restoreFile || !passphrase || restoreLoading"
@click="performRestore"
>
{{ restoreLoading ? 'Restoring...' : 'Restore' }}
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -72,7 +43,6 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue' import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds' import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter() const router = useRouter()
@ -90,49 +60,9 @@ function goToOptions() {
router.push('/onboarding/path').catch(() => {}) router.push('/onboarding/path').catch(() => {})
} }
// Restore from backup function goToRestore() {
const showRestore = ref(false) playNavSound('action')
const restoreFile = ref<Record<string, unknown> | null>(null) router.push('/onboarding/seed-restore').catch(() => {})
const passphrase = ref('')
const restoreLoading = ref(false)
const restoreError = ref('')
const restoreSuccess = ref(false)
function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
restoreFile.value = JSON.parse(reader.result as string)
restoreError.value = ''
} catch {
restoreError.value = 'Invalid backup file format'
restoreFile.value = null
}
}
reader.readAsText(file)
}
async function performRestore() {
if (!restoreFile.value || !passphrase.value) return
restoreLoading.value = true
restoreError.value = ''
try {
await rpcClient.call({
method: 'backup.restore-identity',
params: { backup: restoreFile.value, passphrase: passphrase.value },
})
restoreSuccess.value = true
setTimeout(() => {
router.push('/onboarding/did')
}, 1500)
} catch (err) {
restoreError.value = err instanceof Error ? err.message : 'Restore failed'
} finally {
restoreLoading.value = false
}
} }
</script> </script>

View File

@ -33,8 +33,12 @@
</p> </p>
</button> </button>
<!-- Restore Backup (Coming Soon) --> <!-- Restore from Seed -->
<div class="path-option-card text-center opacity-40 cursor-not-allowed"> <button
@click="selectOption('restore')"
class="path-option-card text-center"
:class="{ 'path-option-card--selected': selected === 'restore' }"
>
<div class="mb-3 sm:mb-4"> <div class="mb-3 sm:mb-4">
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center"> <div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -42,12 +46,11 @@
</svg> </svg>
</div> </div>
</div> </div>
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore Backup</h3> <h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore from Seed</h3>
<p class="text-white/70 text-xs sm:text-sm"> <p class="text-white/70 text-xs sm:text-sm">
Restore from a previous backup Enter your 24-word recovery phrase
</p> </p>
<span class="text-xs text-white/50 mt-1 block">(Coming Soon)</span> </button>
</div>
<!-- Connect Existing (Coming Soon) --> <!-- Connect Existing (Coming Soon) -->
<div class="path-option-card text-center opacity-40 cursor-not-allowed"> <div class="path-option-card text-center opacity-40 cursor-not-allowed">
@ -81,7 +84,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { completeOnboarding } from '@/composables/useOnboarding'
import { playNavSound } from '@/composables/useNavSounds' import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter() const router = useRouter()
@ -96,12 +98,11 @@ function selectOption(option: string) {
} }
async function proceed() { async function proceed() {
try {
await completeOnboarding()
} catch (e) {
if (import.meta.env.DEV) console.warn('completeOnboarding failed, localStorage fallback ensures onboarding is marked complete', e)
}
playNavSound('action') playNavSound('action')
router.push('/login').catch(() => {}) if (selected.value === 'restore') {
router.push('/onboarding/seed-restore').catch(() => {})
} else {
router.push('/onboarding/seed').catch(() => {})
}
} }
</script> </script>

View File

@ -109,6 +109,6 @@ onMounted(() => {
function proceed() { function proceed() {
playNavSound('action') playNavSound('action')
router.push('/onboarding/did').catch(() => {}) router.push('/onboarding/seed').catch(() => {})
} }
</script> </script>

View File

@ -0,0 +1,164 @@
<template>
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
<!-- Header -->
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
Your Recovery Seed
</h1>
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
Write down these 24 words in order. They are the only way to recover your node.
</p>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
<!-- Loading State -->
<div v-if="loading" class="text-center py-8">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
<p class="text-lg text-white/80">Server starting up</p>
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
</div>
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1-3 minutes after first boot</p>
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
</div>
<!-- Error -->
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
<!-- Word Grid -->
<div v-if="words.length > 0" class="w-full max-w-[600px]">
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 gap-1.5 sm:gap-2">
<div
v-for="(word, i) in words"
:key="i"
class="bg-black/60 rounded-lg px-3 py-1.5 sm:py-2 border border-white/10"
>
<span class="text-white/40 text-[1rem] font-mono mr-1.5">{{ i + 1 }}.</span>
<span class="text-white/95 text-[1.2rem] font-mono">{{ word }}</span>
</div>
</div>
<!-- Warning -->
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
<p class="text-xs sm:text-sm text-orange-300/90">
Never share these words. Anyone with them controls your node, identities, and Bitcoin wallet.
</p>
</div>
<!-- Confirmation Checkbox -->
<label class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
<input
v-model="confirmed"
type="checkbox"
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400"
/>
<span class="text-xs sm:text-sm text-white/80">I have written down these 24 words in a safe place</span>
</label>
</div>
</div>
</div>
<!-- Fixed Footer -->
<div v-if="words.length > 0" class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
<button
ref="continueButton"
@click="proceed"
:disabled="!confirmed"
class="path-action-button path-action-button--continue disabled:opacity-50"
>
Continue
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const words = ref<string[]>([])
const confirmed = ref(false)
const loading = ref(false)
const waitingForServer = ref(false)
const errorMessage = ref('')
const elapsedDisplay = ref('0:00')
let retryTimer: ReturnType<typeof setTimeout> | null = null
let elapsedTimer: ReturnType<typeof setInterval> | null = null
let startTime = 0
function startElapsedTimer() {
startTime = Date.now()
elapsedTimer = setInterval(() => {
const secs = Math.floor((Date.now() - startTime) / 1000)
const m = Math.floor(secs / 60)
const s = secs % 60
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
}, 1000)
}
function stopTimers() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
}
async function generateSeed() {
loading.value = true
errorMessage.value = ''
try {
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate' })
stopTimers()
words.value = res.words
loading.value = false
waitingForServer.value = false
} catch {
loading.value = false
if (!waitingForServer.value) {
waitingForServer.value = true
startElapsedTimer()
}
retryTimer = setTimeout(generateSeed, 4000)
}
}
watch(confirmed, (val) => {
if (val) {
nextTick(() => {
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
})
}
})
onMounted(() => { generateSeed() })
onUnmounted(() => { stopTimers() })
function proceed() {
playNavSound('action')
sessionStorage.setItem('_seed_words', JSON.stringify(words.value))
router.push('/onboarding/seed-verify').catch(() => {})
}
</script>
<style scoped>
.onb-lock-spin {
animation: onb-lock-pulse 1.2s ease-in-out infinite;
}
@keyframes onb-lock-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.7; }
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
<!-- Header -->
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
Restore from Seed
</h1>
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
Enter your 24-word recovery seed to restore your node identity.
</p>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
<!-- Restore Success -->
<div v-if="restored" class="w-full max-w-[600px]">
<div class="text-center mb-4">
<div class="flex justify-center mb-4">
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
Identity restored successfully
</p>
</div>
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
<div class="text-left">
<h3 class="text-xs sm:text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ restoredDid }}</p>
</div>
</div>
</div>
</div>
<!-- Word Input Grid -->
<div v-if="!restored" class="w-full max-w-[600px]">
<div class="path-option-card cursor-default px-3 py-3 sm:px-5 sm:py-4">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5 sm:gap-2">
<div v-for="i in 24" :key="i" class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30 text-[1rem] font-mono pointer-events-none">{{ i }}.</span>
<input
:ref="el => { if (el) wordInputs[i - 1] = el as HTMLInputElement }"
v-model="seedWords[i - 1]"
type="text"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
class="w-full bg-black/40 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-[1.2rem] text-white/95 font-mono placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all"
:placeholder="`word ${i}`"
@keydown.enter="i < 24 ? wordInputs[i]?.focus() : restore()"
@input="onWordInput(i - 1)"
@paste="i === 1 ? onPaste($event) : undefined"
/>
</div>
</div>
</div>
<p class="text-xs text-white/40 mt-2 text-center">
Paste all 24 words into the first field to auto-fill
</p>
</div>
</div>
</div>
<!-- Fixed Footer -->
<div class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
<button
v-if="!restored"
@click="restore"
:disabled="isRestoring || !allFilled"
class="path-action-button path-action-button--continue disabled:opacity-50"
>
<span v-if="isRestoring" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Restoring...
</span>
<span v-else>Restore Identity</span>
</button>
<button
v-else
ref="continueButton"
@click="proceed"
class="path-action-button path-action-button--continue"
>
Continue
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const wordInputs = ref<HTMLInputElement[]>([])
const seedWords = ref<string[]>(Array(24).fill(''))
const restored = ref(false)
const isRestoring = ref(false)
const errorMessage = ref('')
const serverStarting = ref(false)
const restoredDid = ref('')
const allFilled = computed(() => seedWords.value.every(w => w.trim().length > 0))
onMounted(() => {
nextTick(() => {
setTimeout(() => wordInputs.value[0]?.focus({ preventScroll: true }), 300)
})
})
function onWordInput(index: number) {
seedWords.value[index] = (seedWords.value[index] ?? '').trim().toLowerCase()
}
function onPaste(event: ClipboardEvent) {
const text = event.clipboardData?.getData('text')?.trim()
if (!text) return
const pastedWords = text.split(/\s+/)
if (pastedWords.length === 24) {
event.preventDefault()
for (let i = 0; i < 24; i++) {
seedWords.value[i] = (pastedWords[i] ?? '').toLowerCase()
}
}
}
async function restore() {
if (!allFilled.value) return
isRestoring.value = true
errorMessage.value = ''
serverStarting.value = false
try {
const words = seedWords.value.map(w => w.trim().toLowerCase())
const res = await rpcClient.call<{ did: string; nostr_npub: string; restored: boolean }>({
method: 'seed.restore',
params: { words },
})
if (res.restored) {
restored.value = true
restoredDid.value = res.did
if (res.did) localStorage.setItem('neode_did', res.did)
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
nextTick(() => {
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
})
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
serverStarting.value = true
} else {
errorMessage.value = msg || 'Restore failed. Check your seed words and try again.'
}
} finally {
isRestoring.value = false
}
}
function proceed() {
playNavSound('action')
router.push('/onboarding/identity').catch(() => {})
}
</script>

View File

@ -0,0 +1,254 @@
<template>
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
<!-- Header (hidden after verification) -->
<div v-if="!verified" class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
Verify Your Seed
</h1>
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
Confirm you wrote down your seed correctly by entering the requested words.
</p>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0 px-6 sm:px-8 py-4">
<div class="flex flex-col items-center gap-3 sm:gap-4">
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
<!-- Verification Success -->
<div v-if="verified" class="w-full max-w-[600px] pt-2">
<div class="text-center mb-4">
<div class="flex justify-center mb-4">
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
Seed verified successfully
</p>
</div>
<!-- DID -->
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5 mb-3">
<div class="text-left w-full">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your DID</h3>
<button @click="copyText(did)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
{{ copiedField === 'did' ? 'Copied!' : 'Copy' }}
</button>
</div>
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ did }}</p>
</div>
<p class="text-xs text-white/50 mt-2">For Web5, federation, and verifiable credentials</p>
</div>
</div>
<!-- Nostr npub -->
<div v-if="nostrNpub" class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
<div class="text-left w-full">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your Nostr ID</h3>
<button @click="copyText(nostrNpub)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
{{ copiedField === 'npub' ? 'Copied!' : 'Copy' }}
</button>
</div>
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ nostrNpub }}</p>
</div>
<p class="text-xs text-white/50 mt-2">For Nostr social apps and NIP-07 signing</p>
</div>
</div>
</div>
<!-- Word Input Fields -->
<div v-if="!verified" class="w-full max-w-[600px] space-y-2 sm:space-y-3">
<div
v-for="(idx, i) in challengeIndices"
:key="idx"
class="path-option-card cursor-default px-3 py-3 sm:px-5 sm:py-4"
>
<label class="block text-xs font-semibold text-white/80 mb-1.5 sm:mb-2 uppercase tracking-wide">
Word #{{ idx + 1 }}
</label>
<input
:ref="el => { if (el) inputRefs[i] = el as HTMLInputElement }"
v-model="answers[i]"
type="text"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
:placeholder="`Enter word #${idx + 1}`"
class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white/95 placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all font-mono text-[1.2rem]"
@keydown.enter.prevent="i < challengeIndices.length - 1 ? inputRefs[i + 1]?.focus() : verify()"
/>
</div>
</div>
</div>
</div>
<!-- Fixed Footer -->
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
<span
v-if="!verified"
@click="goBack"
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
>
Back
</span>
<button
v-if="!verified"
@click="verify"
type="button"
:disabled="isVerifying || !allFilled"
class="path-action-button path-action-button--continue disabled:opacity-50"
>
<span v-if="isVerifying">Verifying...</span>
<span v-else>Verify</span>
</button>
<span
v-if="verified"
@click="downloadIdentity"
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</span>
<button
v-if="verified"
ref="continueButton"
@click="proceed"
class="path-action-button path-action-button--continue"
>
Continue
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const inputRefs = ref<HTMLInputElement[]>([])
const words = ref<string[]>([])
const challengeIndices = ref<number[]>([])
const answers = ref<string[]>(['', '', '', ''])
const verified = ref(false)
const isVerifying = ref(false)
const errorMessage = ref('')
const did = ref('')
const nostrNpub = ref('')
const copiedField = ref('')
const allFilled = computed(() => answers.value.every(a => a.trim().length > 0))
function pickRandomIndices(count: number, max: number): number[] {
const indices = new Set<number>()
while (indices.size < count) {
indices.add(Math.floor(Math.random() * max))
}
return Array.from(indices).sort((a, b) => a - b)
}
onMounted(() => {
const stored = sessionStorage.getItem('_seed_words')
if (!stored) {
router.replace('/onboarding/seed').catch(() => {})
return
}
words.value = JSON.parse(stored)
challengeIndices.value = pickRandomIndices(4, 24)
nextTick(() => {
setTimeout(() => inputRefs.value[0]?.focus({ preventScroll: true }), 300)
})
})
function goBack() {
playNavSound('action')
router.push('/onboarding/seed').catch(() => {})
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
copiedField.value = text === did.value ? 'did' : 'npub'
setTimeout(() => { copiedField.value = '' }, 2000)
}
function downloadIdentity() {
const data = {
did: did.value,
nostr_npub: nostrNpub.value || undefined,
created: new Date().toISOString(),
}
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'archipelago-identity.json'
a.style.display = 'none'
document.body.appendChild(a)
a.click()
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url) }, 1000)
}
async function verify() {
if (!allFilled.value) return
isVerifying.value = true
errorMessage.value = ''
const correct = challengeIndices.value.every(
(wordIdx, i) => (answers.value[i] ?? '').trim().toLowerCase() === (words.value[wordIdx] ?? '')
)
if (!correct) {
isVerifying.value = false
errorMessage.value = 'One or more words are incorrect. Please check your written seed and try again.'
return
}
try {
const res = await rpcClient.call<{ verified: boolean; did: string; nostr_npub: string }>({
method: 'seed.verify',
params: { words: words.value, indices: challengeIndices.value },
})
if (res.verified) {
verified.value = true
did.value = res.did
nostrNpub.value = res.nostr_npub || ''
localStorage.setItem('neode_did', res.did)
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
sessionStorage.removeItem('_seed_words')
nextTick(() => {
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
})
} else {
errorMessage.value = 'Verification failed. Please try again.'
}
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Verification failed'
} finally {
isVerifying.value = false
}
}
function proceed() {
playNavSound('action')
router.push('/onboarding/identity').catch(() => {})
}
</script>

View File

@ -75,6 +75,7 @@ const transitionName = ref('depth-forward')
// Ordered onboarding steps for direction detection // Ordered onboarding steps for direction detection
const onboardingOrder = [ const onboardingOrder = [
'/onboarding/intro', '/onboarding/path', '/onboarding/options', '/onboarding/intro', '/onboarding/path', '/onboarding/options',
'/onboarding/seed', '/onboarding/seed-verify', '/onboarding/seed-restore',
'/onboarding/did', '/onboarding/identity', '/onboarding/backup', '/onboarding/did', '/onboarding/identity', '/onboarding/backup',
'/onboarding/verify', '/onboarding/done', '/login' '/onboarding/verify', '/onboarding/done', '/login'
] ]
@ -96,8 +97,11 @@ const routeBackgrounds: Record<string, string> = {
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead '/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
'/onboarding/options': 'bg-intro-4.jpg', '/onboarding/options': 'bg-intro-4.jpg',
'/onboarding/path': 'bg-intro-3.jpg', '/onboarding/path': 'bg-intro-3.jpg',
'/onboarding/did': 'bg-intro-5.jpg', '/onboarding/seed': 'bg-intro-5.jpg',
'/onboarding/identity': 'bg-intro-5.jpg', '/onboarding/seed-verify': 'bg-intro-6.jpg',
'/onboarding/seed-restore': 'bg-intro-2.jpg',
'/onboarding/did': 'bg-intro-4.jpg',
'/onboarding/identity': 'bg-intro-1.jpg',
'/onboarding/backup': 'bg-intro-6.jpg', '/onboarding/backup': 'bg-intro-6.jpg',
'/onboarding/verify': 'bg-intro-2.jpg', '/onboarding/verify': 'bg-intro-2.jpg',
'/onboarding/done': 'bg-intro-1.jpg', '/onboarding/done': 'bg-intro-1.jpg',

View File

@ -3,32 +3,37 @@
<!-- Hero Section --> <!-- Hero Section -->
<div class="discover-hero glass-card p-8 md:p-12 mb-8 relative overflow-hidden"> <div class="discover-hero glass-card p-8 md:p-12 mb-8 relative overflow-hidden">
<div class="discover-hero-scanline" aria-hidden="true"></div> <div class="discover-hero-scanline" aria-hidden="true"></div>
<div class="relative z-10"> <div class="discover-hero-layout relative z-10">
<div class="flex items-center gap-3 mb-4"> <div class="discover-hero-content">
<span class="discover-terminal-tag">~ $</span> <div class="flex items-center gap-3 mb-4">
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span> <span class="discover-terminal-tag">~ $</span>
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span>
</div>
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago">
Reclaim Your<br />
<span class="discover-hero-accent">Digital Sovereignty</span>
</h1>
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
No cloud. No custodians. No permission needed.
</p>
<div class="flex flex-wrap gap-4 text-sm">
<div class="discover-stat-pill">
<span class="text-white font-bold">{{ totalApps }}</span>
<span class="text-white/50">apps available</span>
</div>
<div class="discover-stat-pill">
<span class="text-white font-bold">{{ installedCount }}</span>
<span class="text-white/50">installed</span>
</div>
<div class="discover-stat-pill">
<span class="text-white font-bold">100%</span>
<span class="text-white/50">self-hosted</span>
</div>
</div>
</div> </div>
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago"> <div class="discover-hero-face">
Reclaim Your<br /> <BitcoinFaceAscii />
<span class="discover-hero-accent">Digital Sovereignty</span>
</h1>
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
No cloud. No custodians. No permission needed.
</p>
<div class="flex flex-wrap gap-4 text-sm">
<div class="discover-stat-pill">
<span class="text-white font-bold">{{ totalApps }}</span>
<span class="text-white/50">apps available</span>
</div>
<div class="discover-stat-pill">
<span class="text-white font-bold">{{ installedCount }}</span>
<span class="text-white/50">installed</span>
</div>
<div class="discover-stat-pill">
<span class="text-white font-bold">100%</span>
<span class="text-white/50">self-hosted</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -39,35 +44,37 @@
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg> </svg>
<h3 class="text-white text-sm font-bold mb-1">Privacy First</h3> <h3 class="text-white text-base font-bold mb-1">Privacy First</h3>
<p class="text-white/40 text-xs leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p> <p class="text-white/50 text-sm leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p>
</div> </div>
<div class="discover-principle-card"> <div class="discover-principle-card">
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg> </svg>
<h3 class="text-white text-sm font-bold mb-1">Verify, Don't Trust</h3> <h3 class="text-white text-base font-bold mb-1">Verify, Don't Trust</h3>
<p class="text-white/40 text-xs leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p> <p class="text-white/50 text-sm leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p>
</div> </div>
<div class="discover-principle-card"> <div class="discover-principle-card">
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg> </svg>
<h3 class="text-white text-sm font-bold mb-1">Open Source</h3> <h3 class="text-white text-base font-bold mb-1">Open Source</h3>
<p class="text-white/40 text-xs leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p> <p class="text-white/50 text-sm leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p>
</div> </div>
<div class="discover-principle-card"> <div class="discover-principle-card">
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<h3 class="text-white text-sm font-bold mb-1">No Permission Needed</h3> <h3 class="text-white text-base font-bold mb-1">No Permission Needed</h3>
<p class="text-white/40 text-xs leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p> <p class="text-white/50 text-sm leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import BitcoinFaceAscii from './BitcoinFaceAscii.vue'
defineProps<{ defineProps<{
totalApps: number totalApps: number
installedCount: number installedCount: number

View File

@ -46,7 +46,7 @@
<svg class="w-4 h-4 text-orange-400/70" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-orange-400/70" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg> </svg>
<span class="text-white/40 text-xs font-mono">{{ app.privacyTag }}</span> <span class="text-white/60 text-sm font-mono">{{ app.privacyTag }}</span>
</div> </div>
<button <button
v-if="isInstalled(app.id) && !isStartingUp(app.id)" v-if="isInstalled(app.id) && !isStartingUp(app.id)"

View File

@ -114,9 +114,9 @@ init()
</div> </div>
<!-- Info Grid --> <!-- Info Grid -->
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- Server Name Card (editable) --> <!-- Server Name Card (editable) container: Enter to edit, Enter to save, Escape to exit -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10"> <div data-controller-container tabindex="0" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 transition-all hover:-translate-y-1">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />

View File

@ -50,7 +50,7 @@ checkClaudeStatus()
<div class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2> <h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p> <p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4"> <div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4" data-controller-ignore>
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />

View File

@ -42,7 +42,7 @@ async function changeLocale(code: string) {
<div class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2> <h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p> <p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button <button
v-for="m in interfaceModes" v-for="m in interfaceModes"
:key="m.id" :key="m.id"

View File

@ -48,7 +48,7 @@ async function performFactoryReset() {
<template> <template>
<!-- Network Diagnostics Link --> <!-- Network Diagnostics Link -->
<div data-controller-container tabindex="0" class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2> <h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
@ -64,7 +64,7 @@ async function performFactoryReset() {
</div> </div>
<!-- Reboot Section --> <!-- Reboot Section -->
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6"> <div class="path-option-card px-6 py-6 mt-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2> <h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2>
@ -109,7 +109,7 @@ async function performFactoryReset() {
</Teleport> </Teleport>
<!-- Factory Reset Section --> <!-- Factory Reset Section -->
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6 border-red-500/30"> <div class="path-option-card px-6 py-6 mt-6 border-red-500/30">
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2> <h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2>
<p class="text-sm text-white/60 mb-4"> <p class="text-sm text-white/60 mb-4">
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen. Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.

View File

@ -128,7 +128,7 @@ loadTotpStatus()
<template> <template>
<!-- Two-Factor Authentication --> <!-- Two-Factor Authentication -->
<div data-controller-container tabindex="0" class="mb-6"> <div class="mb-6">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">