From 19dcfd4f31b008e3ecdc8b2c2890b4fee907b21f Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 31 Mar 2026 01:41:24 +0100 Subject: [PATCH] feat: BIP-39 master seed for unified key derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fragmented random key generation with a single 24-word BIP-39 mnemonic that deterministically derives all node keys: Ed25519 (DID), secp256k1 (Nostr/Bitcoin), BIP-84 xprv (Bitcoin Core), and LND aezeed entropy. New onboarding flow: seed generate → word verification → identity naming. Restore path enabled via 24-word entry. Includes seed RPC handlers, mock backend support, LND/Bitcoin Core wallet-from-seed integration, and UI polish across settings and discover views. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/Cargo.lock | 89 +++- core/archipelago/Cargo.toml | 4 + core/archipelago/src/api/rpc/bitcoin.rs | 114 ++++ core/archipelago/src/api/rpc/dispatcher.rs | 9 + core/archipelago/src/api/rpc/lnd/wallet.rs | 68 +++ core/archipelago/src/api/rpc/middleware.rs | 5 + core/archipelago/src/api/rpc/mod.rs | 1 + core/archipelago/src/api/rpc/seed_rpc.rs | 237 +++++++++ core/archipelago/src/api/rpc/system/mod.rs | 1 + core/archipelago/src/auth.rs | 1 + .../archipelago/src/container/data_manager.rs | 1 - core/archipelago/src/container/mock_podman.rs | 4 + core/archipelago/src/credentials/types.rs | 20 + core/archipelago/src/data_model.rs | 4 + core/archipelago/src/identity.rs | 44 ++ core/archipelago/src/identity_manager.rs | 72 ++- core/archipelago/src/main.rs | 5 +- core/archipelago/src/marketplace.rs | 10 + core/archipelago/src/mesh/protocol.rs | 2 +- core/archipelago/src/mesh/session.rs | 25 +- core/archipelago/src/port_allocator.rs | 102 ++-- core/archipelago/src/seed.rs | 490 ++++++++++++++++++ core/archipelago/src/session.rs | 24 +- core/archipelago/src/transport/chunking.rs | 2 +- core/archipelago/src/wallet/profits.rs | 36 ++ core/archipelago/tests/orchestration_tests.rs | 3 +- docs/user-walkthrough.md | 116 ++++- neode-ui/mock-backend.js | 25 + neode-ui/src/api/rpc-client.ts | 37 ++ neode-ui/src/components/ToggleSwitch.vue | 2 + neode-ui/src/composables/useControllerNav.ts | 69 +-- neode-ui/src/router/index.ts | 15 + neode-ui/src/stores/sync.ts | 1 + neode-ui/src/types/api.ts | 1 + neode-ui/src/views/Discover.vue | 4 +- neode-ui/src/views/OnboardingIdentity.vue | 2 +- neode-ui/src/views/OnboardingIntro.vue | 82 +-- neode-ui/src/views/OnboardingOptions.vue | 27 +- neode-ui/src/views/OnboardingPath.vue | 2 +- neode-ui/src/views/OnboardingSeedGenerate.vue | 164 ++++++ neode-ui/src/views/OnboardingSeedRestore.vue | 183 +++++++ neode-ui/src/views/OnboardingSeedVerify.vue | 254 +++++++++ neode-ui/src/views/OnboardingWrapper.vue | 8 +- neode-ui/src/views/discover/DiscoverHero.vue | 73 +-- neode-ui/src/views/discover/FeaturedApps.vue | 2 +- .../src/views/settings/AccountInfoSection.vue | 6 +- .../src/views/settings/ClaudeAuthSection.vue | 2 +- .../views/settings/InterfaceModeSection.vue | 2 +- .../src/views/settings/SystemDangerZone.vue | 6 +- .../src/views/settings/TwoFactorSection.vue | 2 +- 50 files changed, 2200 insertions(+), 258 deletions(-) create mode 100644 core/archipelago/src/api/rpc/seed_rpc.rs create mode 100644 core/archipelago/src/seed.rs create mode 100644 neode-ui/src/views/OnboardingSeedGenerate.vue create mode 100644 neode-ui/src/views/OnboardingSeedRestore.vue create mode 100644 neode-ui/src/views/OnboardingSeedVerify.vue diff --git a/core/Cargo.lock b/core/Cargo.lock index 5a963966..574292da 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -89,6 +89,8 @@ dependencies = [ "argon2", "base64 0.21.7", "bcrypt", + "bip39", + "bitcoin", "bs58", "bytes", "chacha20poly1305", @@ -275,6 +277,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.1", +] + [[package]] name = "base64" version = "0.21.7" @@ -314,21 +326,71 @@ checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" [[package]] name = "bip39" -version = "2.2.2" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.13.0", + "rand 0.8.5", + "rand_core 0.6.4", "serde", "unicode-normalization", ] +[[package]] +name = "bitcoin" +version = "0.32.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals 0.3.0", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + [[package]] name = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals 0.3.0", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals 0.2.0", + "hex-conservative 0.1.2", +] + [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -336,7 +398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", "serde", ] @@ -1037,6 +1099,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + [[package]] name = "hex-conservative" version = "0.2.2" @@ -1046,6 +1114,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hkdf" version = "0.12.4" @@ -1639,7 +1713,7 @@ dependencies = [ "base64 0.22.1", "bech32", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "cbc", "chacha20", "chacha20poly1305", @@ -2255,6 +2329,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ + "bitcoin_hashes 0.14.1", "rand 0.8.5", "secp256k1-sys", "serde", @@ -3062,9 +3137,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" dependencies = [ "tinyvec", ] diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 0965cdce..641974ed 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -54,6 +54,10 @@ hex = "0.4" bs58 = "0.5" chrono = "0.4" +# BIP-39 mnemonic seed generation + BIP-32 HD key derivation +bip39 = { version = "=2.1.0", features = ["rand"] } +bitcoin = { version = "=0.32.5", features = ["rand-std"] } + # Configuration toml = "0.8" serde_yaml = "0.9" diff --git a/core/archipelago/src/api/rpc/bitcoin.rs b/core/archipelago/src/api/rpc/bitcoin.rs index 8fdd48e9..a097aa6d 100644 --- a/core/archipelago/src/api/rpc/bitcoin.rs +++ b/core/archipelago/src/api/rpc/bitcoin.rs @@ -1,6 +1,7 @@ use super::RpcHandler; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; #[derive(Debug, Serialize)] struct BitcoinInfo { @@ -106,4 +107,117 @@ impl RpcHandler { .result .ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result")) } + + /// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed. + /// Creates a blank wallet and imports BIP-84 (native segwit) descriptors. + /// Requires: password re-verification, encrypted seed on disk. + pub(super) async fn handle_bitcoin_init_wallet_from_seed( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let password = params.get("password") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?; + let wallet_name = params.get("wallet_name") + .and_then(|v| v.as_str()) + .unwrap_or("archipelago"); + + // Verify user password. + self.auth_manager.verify_password(password).await + .context("Password verification failed")?; + + // Load encrypted seed. + let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await + .context("Failed to load encrypted seed")?; + let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic); + + // Derive BIP-84 account xprv. + let xprv = crate::seed::derive_bitcoin_xprv(&seed)?; + let mut xprv_str = xprv.to_string(); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + // Step 1: Create a blank descriptor wallet. + let create_result = self.bitcoin_rpc_call::( + &client, + "createwallet", + &[ + serde_json::json!(wallet_name), // wallet_name + serde_json::json!(false), // disable_private_keys + serde_json::json!(true), // blank + serde_json::json!(""), // passphrase + serde_json::json!(false), // avoid_reuse + serde_json::json!(true), // descriptors + ], + ).await; + + match create_result { + Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name), + Err(e) => { + let msg = e.to_string(); + if msg.contains("already exists") { + tracing::info!("Wallet '{}' already exists, importing descriptors", wallet_name); + } else { + xprv_str.zeroize(); + return Err(e.context("Failed to create wallet")); + } + } + } + + // Step 2: Import BIP-84 descriptors (external + internal/change). + // Format: wpkh(xprv/0/*) for receive, wpkh(xprv/1/*) for change. + let external_desc = format!("wpkh({}/0/*)", xprv_str); + let internal_desc = format!("wpkh({}/1/*)", xprv_str); + + // Get checksums from Bitcoin Core. + let ext_info: serde_json::Value = self.bitcoin_rpc_call( + &client, "getdescriptorinfo", &[serde_json::json!(external_desc)], + ).await.context("getdescriptorinfo failed for external descriptor")?; + + let int_info: serde_json::Value = self.bitcoin_rpc_call( + &client, "getdescriptorinfo", &[serde_json::json!(internal_desc)], + ).await.context("getdescriptorinfo failed for internal descriptor")?; + + let ext_desc_with_checksum = ext_info.get("descriptor") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?; + let int_desc_with_checksum = int_info.get("descriptor") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?; + + let import_params = serde_json::json!([ + { + "desc": ext_desc_with_checksum, + "timestamp": "now", + "active": true, + "internal": false, + "range": [0, 1000], + }, + { + "desc": int_desc_with_checksum, + "timestamp": "now", + "active": true, + "internal": true, + "range": [0, 1000], + } + ]); + + let _import_result: serde_json::Value = self.bitcoin_rpc_call( + &client, "importdescriptors", &[import_params], + ).await.context("importdescriptors failed")?; + + // Zeroize the xprv string from memory. + xprv_str.zeroize(); + + tracing::info!("Bitcoin Core wallet '{}' initialized from master seed (BIP-84)", wallet_name); + + Ok(serde_json::json!({ + "initialized": true, + "wallet_name": wallet_name, + })) + } } diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 70d04788..373113b5 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -22,6 +22,13 @@ impl RpcHandler { "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, "auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await, + // Seed management (BIP-39 mnemonic) + "seed.generate" => self.handle_seed_generate().await, + "seed.verify" => self.handle_seed_verify(params).await, + "seed.restore" => self.handle_seed_restore(params).await, + "seed.save-encrypted" => self.handle_seed_save_encrypted(params).await, + "seed.status" => self.handle_seed_status().await, + // Container orchestration (for Archipelago-managed containers) "container-install" => self.handle_container_install(params).await, "container-start" => self.handle_container_start(params).await, @@ -78,6 +85,7 @@ impl RpcHandler { // Bitcoin & Lightning deep data "bitcoin.getinfo" => self.handle_bitcoin_getinfo().await, + "bitcoin.init-wallet-from-seed" => self.handle_bitcoin_init_wallet_from_seed(params).await, "lnd.getinfo" => self.handle_lnd_getinfo().await, "lnd.listchannels" => self.handle_lnd_listchannels().await, "lnd.openchannel" => self.handle_lnd_openchannel(params).await, @@ -92,6 +100,7 @@ impl RpcHandler { "lnd.gettransactions" => self.handle_lnd_gettransactions().await, "lnd.connect-info" => self.handle_lnd_connect_info().await, "lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await, + "lnd.init-wallet-from-seed" => self.handle_lnd_init_wallet_from_seed(params).await, // Multi-identity management "identity.list" => self.handle_identity_list(params).await, diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 0dacbcce..4d90d1c0 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -2,6 +2,7 @@ use crate::api::rpc::RpcHandler; use anyhow::{Context, Result}; use base64::Engine; use tracing::info; +use zeroize::Zeroize; impl RpcHandler { /// Generate a new on-chain Bitcoin address. @@ -381,4 +382,71 @@ impl RpcHandler { "broadcast": false, })) } + + /// Initialize LND wallet with entropy derived from the node's BIP-39 master seed. + /// The 16-byte entropy deterministically produces an aezeed mnemonic inside LND. + /// Requires: password re-verification via params.password, encrypted seed on disk. + pub(in crate::api::rpc) async fn handle_lnd_init_wallet_from_seed( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let password = params.get("password") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?; + let wallet_password = params.get("wallet_password") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'wallet_password' for LND"))?; + + // Verify user password before granting seed access. + self.auth_manager.verify_password(password).await + .context("Password verification failed")?; + + // Load encrypted seed from disk. + let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await + .context("Failed to load encrypted seed. Was a seed phrase saved during onboarding?")?; + let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic); + + // Derive 16 bytes of LND entropy. + let mut entropy = crate::seed::derive_lnd_entropy(&seed)?; + let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(&entropy); + entropy.zeroize(); + + let wallet_password_b64 = base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes()); + + // Call LND REST API to initialize wallet with derived entropy. + // LND must be running but NOT yet initialized (no existing wallet). + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .danger_accept_invalid_certs(true) + .build() + .context("Failed to create HTTP client")?; + + let init_body = serde_json::json!({ + "wallet_password": wallet_password_b64, + "seed_entropy": entropy_b64, + }); + + let resp = client + .post("https://127.0.0.1:8080/v1/initwallet") + .json(&init_body) + .send() + .await + .context("LND initwallet request failed — is LND running and uninitialized?")?; + + let status = resp.status(); + let body: serde_json::Value = resp.json().await + .context("Failed to parse initwallet response")?; + + if !status.is_success() { + let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("LND wallet init failed: {}", msg)); + } + + info!("LND wallet initialized from master seed entropy"); + + Ok(serde_json::json!({ + "initialized": true, + })) + } } diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index 34090a15..2cafb444 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -21,6 +21,11 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[ "identity.create", "identity.verify", "identity.resolve-did", + // Seed management (onboarding — before user has a session) + "seed.generate", + "seed.verify", + "seed.restore", + "seed.save-encrypted", // Onboarding restore (before user account exists) "backup.restore-identity", // Inter-node RPC: called by federated peers over Tor, no session cookies diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 295bb155..ef07ff8c 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -24,6 +24,7 @@ mod package; mod peers; mod response; mod router; +mod seed_rpc; mod security; mod tor; mod transport; diff --git a/core/archipelago/src/api/rpc/seed_rpc.rs b/core/archipelago/src/api/rpc/seed_rpc.rs new file mode 100644 index 00000000..c98b7df2 --- /dev/null +++ b/core/archipelago/src/api/rpc/seed_rpc.rs @@ -0,0 +1,237 @@ +//! RPC handlers for BIP-39 seed management. +//! Endpoints: seed.generate, seed.verify, seed.restore, seed.save-encrypted, seed.status + +use super::RpcHandler; +use anyhow::{Context, Result}; +use nostr_sdk::ToBech32; +use std::sync::Arc; +use tokio::sync::Mutex; +use zeroize::Zeroize; + +/// In-memory storage for the mnemonic between generate and verify steps. +/// Auto-cleared after 10 minutes. +static ONBOARDING_MNEMONIC: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Arc::new(Mutex::new(None))); + +struct OnboardingMnemonicState { + words: String, + created_at: std::time::Instant, +} + +impl Drop for OnboardingMnemonicState { + fn drop(&mut self) { + self.words.zeroize(); + } +} + +const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes + +impl RpcHandler { + /// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys. + /// Returns the words for the user to write down. + pub(in crate::api::rpc) async fn handle_seed_generate( + &self, + ) -> Result { + let (mnemonic, seed) = crate::seed::MasterSeed::generate()?; + + // Derive and write node Ed25519 key. + let identity_dir = self.config.data_dir.join("identity"); + crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?; + + // Derive and write node-level Nostr key. + let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?; + let nostr_secret_path = identity_dir.join("nostr_secret"); + let nostr_pub_path = identity_dir.join("nostr_pubkey"); + let secret_hex = nostr_keys.secret_key().display_secret().to_string(); + let pubkey_hex = nostr_keys.public_key().to_hex(); + tokio::fs::write(&nostr_secret_path, secret_hex.as_bytes()).await?; + tokio::fs::write(&nostr_pub_path, pubkey_hex.as_bytes()).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600)).await?; + } + + // Initialize identity index at 0. + crate::seed::save_identity_index(&self.config.data_dir, 0).await?; + + let words: Vec<&str> = mnemonic.words().collect(); + + // Hold mnemonic in memory for the verify step. + { + let mut state = ONBOARDING_MNEMONIC.lock().await; + *state = Some(OnboardingMnemonicState { + words: mnemonic.to_string(), + created_at: std::time::Instant::now(), + }); + } + + Ok(serde_json::json!({ + "words": words, + })) + } + + /// Verify the user wrote down their seed correctly. + /// Also confirms the mnemonic by re-deriving and returning DID + npub. + pub(in crate::api::rpc) async fn handle_seed_verify( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let submitted_words: Vec = serde_json::from_value( + params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?, + ).context("Invalid words array")?; + + // Validate against the held mnemonic. + let mnemonic_str = { + let mut state = ONBOARDING_MNEMONIC.lock().await; + match state.as_ref() { + Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(), + _ => { + *state = None; + anyhow::bail!("No pending seed generation or session expired. Please regenerate."); + } + } + }; + + let expected_words: Vec<&str> = mnemonic_str.split_whitespace().collect(); + let submitted: Vec<&str> = submitted_words.iter().map(|s| s.as_str()).collect(); + if expected_words != submitted { + anyhow::bail!("Submitted words do not match generated seed"); + } + + // Re-derive to get DID and npub. + let (mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&mnemonic_str)?; + let node_key = crate::seed::derive_node_ed25519(&seed)?; + let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes()); + let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?; + + let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?; + let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default(); + + // Clear mnemonic from memory now that it's verified. + { + let mut state = ONBOARDING_MNEMONIC.lock().await; + *state = None; + } + + // Save the encrypted seed for convenience backup. + // Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted. + // For now we just confirm the mnemonic was valid. + drop(mnemonic); + + Ok(serde_json::json!({ + "verified": true, + "did": did, + "nostr_npub": nostr_npub, + })) + } + + /// Restore node identity from a 24-word seed phrase. + pub(in crate::api::rpc) async fn handle_seed_restore( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let words: Vec = serde_json::from_value( + params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?, + ).context("Invalid words array")?; + + let phrase = words.join(" "); + let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?; + + // Derive and write node Ed25519 key. + let identity_dir = self.config.data_dir.join("identity"); + crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?; + + // Derive and write node-level Nostr key. + let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?; + let secret_hex = nostr_keys.secret_key().display_secret().to_string(); + let pubkey_hex_nostr = nostr_keys.public_key().to_hex(); + tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?; + tokio::fs::write(identity_dir.join("nostr_pubkey"), pubkey_hex_nostr.as_bytes()).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions( + identity_dir.join("nostr_secret"), + std::fs::Permissions::from_mode(0o600), + ).await?; + } + + // Initialize identity index. + crate::seed::save_identity_index(&self.config.data_dir, 0).await?; + + // Create default identity from seed. + let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?; + manager.create_from_seed( + "Personal".to_string(), + crate::identity_manager::IdentityPurpose::Personal, + &seed, + &self.config.data_dir, + ).await?; + + // Get DID and npub for the response. + let node_key = crate::seed::derive_node_ed25519(&seed)?; + let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes()); + let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?; + let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default(); + + Ok(serde_json::json!({ + "did": did, + "nostr_npub": nostr_npub, + "restored": true, + })) + } + + /// Encrypt and save the mnemonic to disk for convenience backup. + pub(in crate::api::rpc) async fn handle_seed_save_encrypted( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let passphrase = params.get("passphrase") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?; + + // Try to get mnemonic from in-memory state first. + let mnemonic_str = { + let state = ONBOARDING_MNEMONIC.lock().await; + state.as_ref() + .filter(|s| s.created_at.elapsed() < MNEMONIC_TTL) + .map(|s| s.words.clone()) + }; + + let mnemonic: bip39::Mnemonic = if let Some(words) = mnemonic_str { + words.parse().context("Invalid mnemonic in memory")? + } else { + anyhow::bail!("No mnemonic available. Generate or restore a seed first."); + }; + + crate::seed::save_seed_encrypted(&self.config.data_dir, &mnemonic, passphrase).await?; + + Ok(serde_json::json!({ "saved": true })) + } + + /// Return seed status information. + pub(in crate::api::rpc) async fn handle_seed_status( + &self, + ) -> Result { + let has_seed = crate::seed::seed_exists(&self.config.data_dir); + let has_node_key = crate::identity::NodeIdentity::key_exists( + &self.config.data_dir.join("identity"), + ); + let is_legacy = has_node_key && !has_seed; + let next_index = crate::seed::load_identity_index(&self.config.data_dir).await.unwrap_or(0); + + let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?; + let (identities, _) = manager.list().await?; + + Ok(serde_json::json!({ + "has_seed": has_seed, + "is_legacy": is_legacy, + "identity_count": identities.len(), + "next_index": next_index, + })) + } +} diff --git a/core/archipelago/src/api/rpc/system/mod.rs b/core/archipelago/src/api/rpc/system/mod.rs index 5578b252..d7c43010 100644 --- a/core/archipelago/src/api/rpc/system/mod.rs +++ b/core/archipelago/src/api/rpc/system/mod.rs @@ -156,6 +156,7 @@ pub(super) fn parse_meminfo_kb(val: &str) -> Result { /// Read disk usage via `df` for the root filesystem. /// Returns (used_bytes, total_bytes). +#[allow(dead_code)] pub(super) async fn read_disk_usage() -> Result<(u64, u64)> { read_disk_usage_path("/").await } diff --git a/core/archipelago/src/auth.rs b/core/archipelago/src/auth.rs index cda7cd29..01578261 100644 --- a/core/archipelago/src/auth.rs +++ b/core/archipelago/src/auth.rs @@ -97,6 +97,7 @@ impl AuthManager { /// Ensure a default user exists on first boot. /// Called once at startup — creates user with default password if none exists. + #[allow(dead_code)] pub async fn ensure_default_user(&self) -> Result<()> { if self.is_setup().await? { return Ok(()); diff --git a/core/archipelago/src/container/data_manager.rs b/core/archipelago/src/container/data_manager.rs index 67d35676..03695d51 100644 --- a/core/archipelago/src/container/data_manager.rs +++ b/core/archipelago/src/container/data_manager.rs @@ -69,7 +69,6 @@ impl DevDataManager { #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; #[tokio::test] async fn test_map_volume_path() { diff --git a/core/archipelago/src/container/mock_podman.rs b/core/archipelago/src/container/mock_podman.rs index fcdb20df..21e0881d 100644 --- a/core/archipelago/src/container/mock_podman.rs +++ b/core/archipelago/src/container/mock_podman.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU32, Ordering}}; /// Container state matching podman's real states. #[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] pub enum MockContainerState { Created, Running, @@ -28,6 +29,7 @@ impl MockContainerState { /// A simulated container. #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct MockContainer { pub name: String, pub image: String, @@ -36,6 +38,7 @@ pub struct MockContainer { } /// Mock podman runtime for testing orchestration logic without real containers. +#[allow(dead_code)] pub struct MockPodman { containers: Arc>>, /// When true, `podman pull` will fail (simulates registry down). @@ -50,6 +53,7 @@ pub struct MockPodman { images: Arc>>, } +#[allow(dead_code)] impl MockPodman { pub fn new() -> Self { Self { diff --git a/core/archipelago/src/credentials/types.rs b/core/archipelago/src/credentials/types.rs index 91b7b5a8..931d82b0 100644 --- a/core/archipelago/src/credentials/types.rs +++ b/core/archipelago/src/credentials/types.rs @@ -56,6 +56,26 @@ pub struct CredentialStatusEntry { pub status: String, } +/// Status of a verifiable credential. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CredentialStatus { + Active, + Revoked, + Expired, +} + +impl std::fmt::Display for CredentialStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Active => write!(f, "active"), + Self::Revoked => write!(f, "revoked"), + Self::Expired => write!(f, "expired"), + } + } +} + /// Stored credentials index. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CredentialStore { diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs index c0365588..12388456 100644 --- a/core/archipelago/src/data_model.rs +++ b/core/archipelago/src/data_model.rs @@ -54,6 +54,9 @@ pub struct ServerInfo { pub wifi_ssids: Vec, #[serde(rename = "zram-enabled")] pub zram_enabled: bool, + /// True if this node's keys are derived from a BIP-39 seed. + #[serde(rename = "seed-backed", default)] + pub seed_backed: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -269,6 +272,7 @@ impl DataModel { unread: 0, wifi_ssids: vec![], zram_enabled: false, + seed_backed: false, }, package_data: HashMap::new(), peer_health: HashMap::new(), diff --git a/core/archipelago/src/identity.rs b/core/archipelago/src/identity.rs index cff94bd6..dd571277 100644 --- a/core/archipelago/src/identity.rs +++ b/core/archipelago/src/identity.rs @@ -64,6 +64,45 @@ impl NodeIdentity { }) } + /// Create node identity from a BIP-39 master seed (deterministic derivation). + /// Writes derived key to disk in the same format as load_or_create. + pub async fn from_seed(identity_dir: &Path, seed: &crate::seed::MasterSeed) -> Result { + fs::create_dir_all(identity_dir) + .await + .context("Failed to create identity directory")?; + + let signing_key = crate::seed::derive_node_ed25519(seed)?; + let key_path = identity_dir.join(NODE_KEY_FILE); + let pub_path = identity_dir.join(NODE_KEY_PUB_FILE); + + fs::write(&key_path, signing_key.to_bytes()) + .await + .context("Failed to write node key")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) + .await + .context("Failed to set key permissions")?; + } + fs::write(&pub_path, signing_key.verifying_key().as_bytes()) + .await + .context("Failed to write node public key")?; + + let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes()); + tracing::info!("Derived node identity from seed (pubkey: {}...)", &pubkey_hex[..16]); + + Ok(Self { + signing_key, + _identity_dir: identity_dir.to_path_buf(), + }) + } + + /// Check if a node key already exists on disk. + pub fn key_exists(identity_dir: &Path) -> bool { + identity_dir.join(NODE_KEY_FILE).exists() + } + /// Access the signing key (for key derivation, e.g. mesh encryption). pub fn signing_key(&self) -> &SigningKey { &self.signing_key @@ -115,6 +154,11 @@ impl NodeIdentity { .map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e)) } + /// Generate a W3C DID Document for this identity. + #[allow(dead_code)] + pub fn did_document(&self) -> Result { + did_document_from_pubkey_hex(&self.pubkey_hex()) + } } /// Convert Ed25519 pubkey (hex) to did:key format. diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index dacfa351..fff121e4 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -90,6 +90,9 @@ struct IdentityFile { /// Nostr profile metadata #[serde(default)] profile: Option, + /// BIP-39 seed derivation index (if created from seed). + #[serde(default, skip_serializing_if = "Option::is_none")] + derivation_index: Option, } pub struct IdentityManager { @@ -150,6 +153,7 @@ impl IdentityManager { nostr_secret_hex: None, nostr_pubkey_hex: None, profile: None, + derivation_index: None, }; let file_path = self.identities_dir.join(format!("{}.json", id)); @@ -173,7 +177,7 @@ impl IdentityManager { self.set_default(&id).await?; } - // Auto-generate Nostr keypair so every identity has both key types + // Auto-generate Nostr keypair so every identity has both key types (legacy path) let _ = self.create_nostr_key(&id).await; // Re-read to pick up the Nostr keys @@ -184,6 +188,72 @@ impl IdentityManager { Ok(record) } + /// Create a new identity with keys derived from a BIP-39 master seed. + /// The derivation index is auto-incremented and persisted. + pub async fn create_from_seed( + &self, + name: String, + purpose: IdentityPurpose, + seed: &crate::seed::MasterSeed, + data_dir: &std::path::Path, + ) -> Result { + let index = crate::seed::load_identity_index(data_dir).await?; + + let signing_key = crate::seed::derive_identity_ed25519(seed, index)?; + let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes()); + let did = did_key_from_pubkey_hex(&pubkey_hex)?; + let id = uuid::Uuid::new_v4().to_string(); + let created_at = chrono::Utc::now().to_rfc3339(); + + // Derive Nostr key from the same seed via BIP-32. + let nostr_keys = crate::seed::derive_nostr_identity_key(seed, index)?; + let nostr_secret_hex = nostr_keys.secret_key().display_secret().to_string(); + let nostr_pubkey_hex = nostr_keys.public_key().to_hex(); + + let identity_file = IdentityFile { + id: id.clone(), + name: name.clone(), + purpose: purpose.clone(), + secret_key: signing_key.to_bytes().to_vec(), + pubkey_hex: pubkey_hex.clone(), + did: did.clone(), + created_at: created_at.clone(), + nostr_secret_hex: Some(nostr_secret_hex), + nostr_pubkey_hex: Some(nostr_pubkey_hex), + profile: None, + derivation_index: Some(index), + }; + + let file_path = self.identities_dir.join(format!("{}.json", id)); + let json = serde_json::to_string_pretty(&identity_file) + .context("Failed to serialize identity")?; + fs::write(&file_path, json.as_bytes()) + .await + .context("Failed to write identity file")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600)) + .await + .context("Failed to set identity file permissions")?; + } + + // Increment the derivation index for next identity. + crate::seed::save_identity_index(data_dir, index + 1).await?; + + // If first identity, make it the default. + let (existing, _) = self.list().await?; + if existing.len() <= 1 { + self.set_default(&id).await?; + } + + let record = self.get(&id).await?; + tracing::info!("Created seed-derived identity '{}' ({}) at index {}", name, purpose, index); + + Ok(record) + } + /// Get a single identity by ID (without secret key). pub async fn get(&self, id: &str) -> Result { let file_path = self.identities_dir.join(format!("{}.json", id)); diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 0a299c25..b1d2135f 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -40,18 +40,15 @@ mod totp; mod wallet; mod names; mod network; +pub mod seed; mod nostr_relays; mod update; mod vpn; mod webhooks; -use auth::AuthManager; use config::Config; use server::Server; -/// Default dev password when auto-creating a user (matches mock-backend). -const DEV_DEFAULT_PASSWORD: &str = "password123"; - #[tokio::main] async fn main() -> Result<()> { let startup_start = std::time::Instant::now(); diff --git a/core/archipelago/src/marketplace.rs b/core/archipelago/src/marketplace.rs index ecfbae3b..cd4c3183 100644 --- a/core/archipelago/src/marketplace.rs +++ b/core/archipelago/src/marketplace.rs @@ -138,6 +138,16 @@ pub enum ManifestDescription { Detailed { short: String, long: String }, } +impl ManifestDescription { + /// Return the short description regardless of variant. + #[allow(dead_code)] + pub fn short(&self) -> &str { + match self { + Self::Simple(s) => s, + Self::Detailed { short, .. } => short, + } + } +} /// A discovered marketplace app with trust scoring. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 67b15395..7fdd87a4 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -634,7 +634,7 @@ mod tests { let frame = build_app_start("Archipelago"); assert_eq!(frame[3], CMD_APP_START); let name = &frame[4..]; - assert_eq!(std::str::from_utf8(name).ok_or_else(|| anyhow::anyhow!("invalid UTF-8 in app name"))?, "Archipelago"); + assert_eq!(std::str::from_utf8(name).map_err(|e| anyhow::anyhow!("invalid UTF-8 in app name: {}", e))?, "Archipelago"); Ok(()) } diff --git a/core/archipelago/src/mesh/session.rs b/core/archipelago/src/mesh/session.rs index 9aeedb46..34a58f5a 100644 --- a/core/archipelago/src/mesh/session.rs +++ b/core/archipelago/src/mesh/session.rs @@ -158,6 +158,29 @@ impl SessionManager { Ok(plaintext) } + /// Store a ratchet session for a peer (in memory and on disk). + #[allow(dead_code)] + pub async fn store_session(&self, did: &str, state: RatchetState) -> Result<()> { + self.save_session_to_disk(did, &state).await?; + let mut sessions = self.sessions.write().await; + sessions.insert(did.to_string(), state); + Ok(()) + } + + /// Remove a ratchet session for a peer (from memory and disk). + #[allow(dead_code)] + pub async fn remove_session(&self, did: &str) -> Result<()> { + let mut sessions = self.sessions.write().await; + sessions.remove(did); + let path = self.session_path(did); + if path.exists() { + tokio::fs::remove_file(&path) + .await + .context("Failed to remove ratchet session file")?; + } + Ok(()) + } + /// Get session info for a peer (for RPC status endpoint). pub async fn session_info(&self, did: &str) -> Option { let sessions = self.sessions.read().await; @@ -205,7 +228,7 @@ mod tests { let mgr = SessionManager::new(dir.path()); let root_key = [42u8; 32]; - let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral(); + let (_spk_secret, spk_public) = crypto::generate_x25519_ephemeral(); let state = RatchetState::init_as_sender(root_key, &spk_public).unwrap(); let did = "did:key:z6MkTestSession"; diff --git a/core/archipelago/src/port_allocator.rs b/core/archipelago/src/port_allocator.rs index abf87f13..2455b5d9 100644 --- a/core/archipelago/src/port_allocator.rs +++ b/core/archipelago/src/port_allocator.rs @@ -157,49 +157,49 @@ mod tests { assert!(allocs.allocations.is_empty()); } - #[test] - fn test_new_allocator_from_empty_dir() { + #[tokio::test] + async fn test_new_allocator_from_empty_dir() { let dir = tempfile::tempdir().unwrap(); - let alloc = PortAllocator::new(dir.path()).unwrap(); + let alloc = PortAllocator::new(dir.path()).await.unwrap(); assert!(alloc.allocations.allocations.is_empty()); } - #[test] - fn test_allocate_preferred_port_when_available() { + #[tokio::test] + async fn test_allocate_preferred_port_when_available() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - let port = alloc.allocate("my-app", 8500, 80).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + let port = alloc.allocate("my-app", 8500, 80).await.unwrap(); assert_eq!(port, 8500); } - #[test] - fn test_allocate_fallback_when_preferred_is_reserved() { + #[tokio::test] + async fn test_allocate_fallback_when_preferred_is_reserved() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); // Port 80 is in RESERVED_PORTS, so it should allocate from the range instead - let port = alloc.allocate("web-app", 80, 80).unwrap(); + let port = alloc.allocate("web-app", 80, 80).await.unwrap(); assert_ne!(port, 80); assert!(port >= WEB_PORT_RANGE_START && port <= WEB_PORT_RANGE_END); } - #[test] - fn test_allocate_fallback_when_preferred_is_taken() { + #[tokio::test] + async fn test_allocate_fallback_when_preferred_is_taken() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - let port1 = alloc.allocate("app-1", 8500, 80).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + let port1 = alloc.allocate("app-1", 8500, 80).await.unwrap(); assert_eq!(port1, 8500); // Second app requesting the same preferred port gets a different one - let port2 = alloc.allocate("app-2", 8500, 80).unwrap(); + let port2 = alloc.allocate("app-2", 8500, 80).await.unwrap(); assert_ne!(port2, 8500); assert!(port2 >= WEB_PORT_RANGE_START && port2 <= WEB_PORT_RANGE_END); } - #[test] - fn test_get_returns_existing_allocation() { + #[tokio::test] + async fn test_get_returns_existing_allocation() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - alloc.allocate("test-app", 8600, 3000).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + alloc.allocate("test-app", 8600, 3000).await.unwrap(); let result = alloc.get("test-app"); assert!(result.is_some()); @@ -208,78 +208,78 @@ mod tests { assert_eq!(container, 3000); } - #[test] - fn test_get_returns_none_for_unknown_app() { + #[tokio::test] + async fn test_get_returns_none_for_unknown_app() { let dir = tempfile::tempdir().unwrap(); - let alloc = PortAllocator::new(dir.path()).unwrap(); + let alloc = PortAllocator::new(dir.path()).await.unwrap(); assert!(alloc.get("nonexistent").is_none()); } - #[test] - fn test_allocate_or_get_returns_existing() { + #[tokio::test] + async fn test_allocate_or_get_returns_existing() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - let port1 = alloc.allocate("my-app", 8700, 80).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + let port1 = alloc.allocate("my-app", 8700, 80).await.unwrap(); // Calling allocate_or_get with a different preferred port should return the existing one - let port2 = alloc.allocate_or_get("my-app", 9999, 80).unwrap(); + let port2 = alloc.allocate_or_get("my-app", 9999, 80).await.unwrap(); assert_eq!(port1, port2); assert_eq!(port2, 8700); } - #[test] - fn test_allocate_or_get_allocates_when_new() { + #[tokio::test] + async fn test_allocate_or_get_allocates_when_new() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - let port = alloc.allocate_or_get("new-app", 8800, 443).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + let port = alloc.allocate_or_get("new-app", 8800, 443).await.unwrap(); assert_eq!(port, 8800); // Verify it's now stored assert!(alloc.get("new-app").is_some()); } - #[test] - fn test_release_removes_allocation() { + #[tokio::test] + async fn test_release_removes_allocation() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - alloc.allocate("removable", 8900, 80).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + alloc.allocate("removable", 8900, 80).await.unwrap(); assert!(alloc.get("removable").is_some()); - alloc.release("removable").unwrap(); + alloc.release("removable").await.unwrap(); assert!(alloc.get("removable").is_none()); } - #[test] - fn test_released_port_becomes_available() { + #[tokio::test] + async fn test_released_port_becomes_available() { let dir = tempfile::tempdir().unwrap(); - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - alloc.allocate("app-a", 8500, 80).unwrap(); - alloc.release("app-a").unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + alloc.allocate("app-a", 8500, 80).await.unwrap(); + alloc.release("app-a").await.unwrap(); // Port 8500 should now be available again - let port = alloc.allocate("app-b", 8500, 80).unwrap(); + let port = alloc.allocate("app-b", 8500, 80).await.unwrap(); assert_eq!(port, 8500); } - #[test] - fn test_reserved_ports_are_never_allocated() { + #[tokio::test] + async fn test_reserved_ports_are_never_allocated() { let dir = tempfile::tempdir().unwrap(); - let alloc = PortAllocator::new(dir.path()).unwrap(); + let alloc = PortAllocator::new(dir.path()).await.unwrap(); for &port in RESERVED_PORTS { assert!(alloc.is_reserved(port), "Port {} should be reserved", port); assert!(!alloc.is_available(port), "Port {} should not be available", port); } } - #[test] - fn test_persistence_across_instances() { + #[tokio::test] + async fn test_persistence_across_instances() { let dir = tempfile::tempdir().unwrap(); { - let mut alloc = PortAllocator::new(dir.path()).unwrap(); - alloc.allocate("persistent-app", 8555, 80).unwrap(); + let mut alloc = PortAllocator::new(dir.path()).await.unwrap(); + alloc.allocate("persistent-app", 8555, 80).await.unwrap(); } // Create a new allocator from the same directory - let alloc2 = PortAllocator::new(dir.path()).unwrap(); + let alloc2 = PortAllocator::new(dir.path()).await.unwrap(); let result = alloc2.get("persistent-app"); assert!(result.is_some()); let (host, container) = result.unwrap(); diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs new file mode 100644 index 00000000..cc34cf22 --- /dev/null +++ b/core/archipelago/src/seed.rs @@ -0,0 +1,490 @@ +//! BIP-39 master seed: generation, storage, and deterministic key derivation. +//! +//! One 24-word mnemonic derives ALL Archipelago keys: +//! +//! BIP-39 Mnemonic (24 words, 256-bit entropy) +//! → PBKDF2-HMAC-SHA512 (2048 rounds, empty passphrase) +//! → Master Seed (64 bytes) +//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key +//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key +//! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519 +//! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06) +//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet +//! └── HKDF(seed, "archipelago/lnd/entropy/v1") → LND aezeed entropy +//! +//! SECURITY: Never log mnemonic or seed material at any level. + +use anyhow::{Context, Result}; +use ed25519_dalek::SigningKey; +use hkdf::Hkdf; +use sha2::Sha256; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +// ─── Constants ────────────────────────────────────────────────────────── + +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const SEED_LEN: usize = 64; +const IDENTITY_INDEX_FILE: &str = "identity_index"; +const ENCRYPTED_SEED_FILE: &str = "master_seed.enc"; + +// HKDF info strings for domain-separated key derivation. +const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1"; +const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1"; +const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1"; + +// ─── MasterSeed ───────────────────────────────────────────────────────── + +/// 64-byte master seed derived from a BIP-39 mnemonic. +/// Implements ZeroizeOnDrop to clear memory when dropped. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct MasterSeed { + bytes: [u8; SEED_LEN], +} + +impl MasterSeed { + /// Generate a new 24-word BIP-39 mnemonic and derive the master seed. + pub fn generate() -> Result<(bip39::Mnemonic, Self)> { + let mnemonic = bip39::Mnemonic::generate(24) + .map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))?; + let seed = Self::from_mnemonic(&mnemonic); + Ok((mnemonic, seed)) + } + + /// Derive master seed from an existing mnemonic (empty BIP-39 passphrase). + pub fn from_mnemonic(mnemonic: &bip39::Mnemonic) -> Self { + let seed_bytes = mnemonic.to_seed(""); + let mut bytes = [0u8; SEED_LEN]; + bytes.copy_from_slice(&seed_bytes); + Self { bytes } + } + + /// Parse a space-separated word string, validate checksum, and derive seed. + pub fn from_mnemonic_words(words: &str) -> Result<(bip39::Mnemonic, Self)> { + let mnemonic: bip39::Mnemonic = words.parse() + .map_err(|e| anyhow::anyhow!("Invalid mnemonic: {}", e))?; + let word_count = mnemonic.word_count(); + if word_count != 24 { + anyhow::bail!("Expected 24 words, got {}", word_count); + } + let seed = Self::from_mnemonic(&mnemonic); + Ok((mnemonic, seed)) + } + + /// Access raw seed bytes (for HKDF input). + fn as_bytes(&self) -> &[u8; SEED_LEN] { + &self.bytes + } +} + +// ─── Ed25519 Derivation (HKDF) ───────────────────────────────────────── + +/// Derive the node's persistent Ed25519 signing key. +pub fn derive_node_ed25519(seed: &MasterSeed) -> Result { + let derived = hkdf_derive_32(seed.as_bytes(), NODE_ED25519_INFO)?; + Ok(SigningKey::from_bytes(&derived)) +} + +/// Derive an identity's Ed25519 signing key by index. +pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result { + let info = format!("archipelago/identity/{}/ed25519/v1", index); + let derived = hkdf_derive_32(seed.as_bytes(), info.as_bytes())?; + Ok(SigningKey::from_bytes(&derived)) +} + +// ─── Secp256k1 / Nostr Derivation (BIP-32 + HKDF) ────────────────────── + +/// Derive the node-level Nostr secp256k1 key (not per-identity). +pub fn derive_node_nostr_key(seed: &MasterSeed) -> Result { + let derived = hkdf_derive_32(seed.as_bytes(), NODE_NOSTR_INFO)?; + let secret = nostr_sdk::SecretKey::from_slice(&derived) + .map_err(|e| anyhow::anyhow!("Invalid secp256k1 key from HKDF: {}", e))?; + Ok(nostr_sdk::Keys::new(secret)) +} + +/// Derive an identity's Nostr secp256k1 key via BIP-32. +/// Path: m/44'/1237'/0'/0/{index} (NIP-06 compliant). +pub fn derive_nostr_identity_key(seed: &MasterSeed, index: u32) -> Result { + use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; + use bitcoin::Network; + + let master = Xpriv::new_master(Network::Bitcoin, seed.as_bytes()) + .context("Failed to derive BIP-32 master key")?; + + let path = DerivationPath::from(vec![ + ChildNumber::from_hardened_idx(44).expect("valid"), + ChildNumber::from_hardened_idx(1237).expect("valid"), + ChildNumber::from_hardened_idx(0).expect("valid"), + ChildNumber::from_normal_idx(0).expect("valid"), + ChildNumber::from_normal_idx(index).expect("valid index"), + ]); + + let secp = bitcoin::secp256k1::Secp256k1::new(); + let child = master.derive_priv(&secp, &path) + .context("BIP-32 derivation failed")?; + + let secret_bytes = child.private_key.secret_bytes(); + let secret = nostr_sdk::SecretKey::from_slice(&secret_bytes) + .map_err(|e| anyhow::anyhow!("Invalid Nostr key from BIP-32: {}", e))?; + Ok(nostr_sdk::Keys::new(secret)) +} + +// ─── Bitcoin / LND Derivation ─────────────────────────────────────────── + +/// Derive the BIP-84 account-level extended private key for Bitcoin Core. +/// Path: m/84'/0'/0' (native segwit, mainnet). +pub fn derive_bitcoin_xprv(seed: &MasterSeed) -> Result { + use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; + use bitcoin::Network; + + let master = Xpriv::new_master(Network::Bitcoin, seed.as_bytes()) + .context("Failed to derive BIP-32 master key")?; + + let path = DerivationPath::from(vec![ + ChildNumber::from_hardened_idx(84).expect("valid"), + ChildNumber::from_hardened_idx(0).expect("valid"), + ChildNumber::from_hardened_idx(0).expect("valid"), + ]); + + let secp = bitcoin::secp256k1::Secp256k1::new(); + master.derive_priv(&secp, &path) + .context("BIP-84 derivation failed") +} + +/// Derive 16 bytes of entropy for LND aezeed wallet initialization. +pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> { + let derived = hkdf_derive(seed.as_bytes(), LND_ENTROPY_INFO, 16)?; + let mut entropy = [0u8; 16]; + entropy.copy_from_slice(&derived); + Ok(entropy) +} + +// ─── Encrypted Seed Storage ───────────────────────────────────────────── + +/// Encrypt and save the mnemonic words to disk (convenience backup). +/// Uses Argon2 key derivation + ChaCha20-Poly1305 AEAD. +pub async fn save_seed_encrypted( + data_dir: &std::path::Path, + mnemonic: &bip39::Mnemonic, + passphrase: &str, +) -> Result<()> { + use argon2::Argon2; + use chacha20poly1305::aead::{Aead, KeyInit}; + use rand::RngCore; + + let identity_dir = data_dir.join("identity"); + tokio::fs::create_dir_all(&identity_dir).await + .context("Failed to create identity directory")?; + + let plaintext = mnemonic.to_string(); + + let mut salt = [0u8; SALT_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + rand::rngs::OsRng.fill_bytes(&mut nonce); + + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(passphrase.as_bytes(), &salt, &mut key) + .map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?; + + let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?; + let ciphertext = cipher + .encrypt( + chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce), + plaintext.as_bytes(), + ) + .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; + + // Zeroize the plaintext and key from memory. + key.zeroize(); + + // Format: salt || nonce || ciphertext + let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&salt); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + + let path = identity_dir.join(ENCRYPTED_SEED_FILE); + tokio::fs::write(&path, &blob).await + .context("Failed to write encrypted seed")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await + .context("Failed to set seed file permissions")?; + } + + Ok(()) +} + +/// Load and decrypt the mnemonic from disk. +pub async fn load_seed_encrypted( + data_dir: &std::path::Path, + passphrase: &str, +) -> Result { + use argon2::Argon2; + use chacha20poly1305::aead::{Aead, KeyInit}; + + let path = data_dir.join("identity").join(ENCRYPTED_SEED_FILE); + let blob = tokio::fs::read(&path).await + .context("Failed to read encrypted seed file")?; + + if blob.len() < SALT_LEN + NONCE_LEN { + anyhow::bail!("Encrypted seed file too short"); + } + + let salt = &blob[..SALT_LEN]; + let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN]; + let ciphertext = &blob[SALT_LEN + NONCE_LEN..]; + + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?; + + let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?; + + key.zeroize(); + + let plaintext = cipher + .decrypt( + chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce), + ciphertext, + ) + .map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase"))?; + + let words = String::from_utf8(plaintext) + .context("Decrypted seed is not valid UTF-8")?; + let mnemonic: bip39::Mnemonic = words.parse() + .map_err(|e| anyhow::anyhow!("Decrypted data is not a valid mnemonic: {}", e))?; + + Ok(mnemonic) +} + +/// Check if an encrypted seed file exists. +pub fn seed_exists(data_dir: &std::path::Path) -> bool { + data_dir.join("identity").join(ENCRYPTED_SEED_FILE).exists() +} + +// ─── Identity Index Tracking ──────────────────────────────────────────── + +/// Save the next unused identity derivation index. +pub async fn save_identity_index(data_dir: &std::path::Path, next_index: u32) -> Result<()> { + let path = data_dir.join("identity").join(IDENTITY_INDEX_FILE); + tokio::fs::write(&path, next_index.to_string().as_bytes()).await + .context("Failed to write identity index") +} + +/// Load the next unused identity derivation index (0 if none saved). +pub async fn load_identity_index(data_dir: &std::path::Path) -> Result { + let path = data_dir.join("identity").join(IDENTITY_INDEX_FILE); + match tokio::fs::read_to_string(&path).await { + Ok(s) => s.trim().parse::().context("Invalid identity index"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(e) => Err(e).context("Failed to read identity index"), + } +} + +// ─── Internal Helpers ─────────────────────────────────────────────────── + +/// HKDF-SHA256 derivation with no salt, returns `len` bytes. +fn hkdf_derive(ikm: &[u8], info: &[u8], len: usize) -> Result> { + let hk = Hkdf::::new(None, ikm); + let mut okm = vec![0u8; len]; + hk.expand(info, &mut okm) + .map_err(|_| anyhow::anyhow!("HKDF expand failed"))?; + Ok(okm) +} + +/// HKDF-SHA256 derivation with no salt, returns exactly 32 bytes. +fn hkdf_derive_32(ikm: &[u8], info: &[u8]) -> Result<[u8; 32]> { + let bytes = hkdf_derive(ikm, info, 32)?; + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + + #[test] + fn test_deterministic_node_key() { + let (_, seed1) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let (_, seed2) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let key1 = derive_node_ed25519(&seed1).unwrap(); + let key2 = derive_node_ed25519(&seed2).unwrap(); + assert_eq!( + key1.verifying_key().as_bytes(), + key2.verifying_key().as_bytes(), + "Same mnemonic must produce same node key" + ); + } + + #[test] + fn test_deterministic_identity_keys() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let key_a = derive_identity_ed25519(&seed, 0).unwrap(); + let key_b = derive_identity_ed25519(&seed, 1).unwrap(); + assert_ne!( + key_a.verifying_key().as_bytes(), + key_b.verifying_key().as_bytes(), + "Different indices must produce different keys" + ); + + // Same index is deterministic. + let key_a2 = derive_identity_ed25519(&seed, 0).unwrap(); + assert_eq!( + key_a.verifying_key().as_bytes(), + key_a2.verifying_key().as_bytes(), + ); + } + + #[test] + fn test_node_key_differs_from_identity() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let node = derive_node_ed25519(&seed).unwrap(); + let identity = derive_identity_ed25519(&seed, 0).unwrap(); + assert_ne!( + node.verifying_key().as_bytes(), + identity.verifying_key().as_bytes(), + "Node key and identity key must differ" + ); + } + + #[test] + fn test_deterministic_nostr_keys() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let keys1 = derive_nostr_identity_key(&seed, 0).unwrap(); + let keys2 = derive_nostr_identity_key(&seed, 0).unwrap(); + assert_eq!( + keys1.public_key().to_hex(), + keys2.public_key().to_hex(), + "Same mnemonic + index must produce same Nostr key" + ); + + let keys3 = derive_nostr_identity_key(&seed, 1).unwrap(); + assert_ne!( + keys1.public_key().to_hex(), + keys3.public_key().to_hex(), + "Different indices must produce different Nostr keys" + ); + } + + #[test] + fn test_node_nostr_key() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let keys1 = derive_node_nostr_key(&seed).unwrap(); + let keys2 = derive_node_nostr_key(&seed).unwrap(); + assert_eq!(keys1.public_key().to_hex(), keys2.public_key().to_hex()); + } + + #[test] + fn test_bitcoin_xprv_deterministic() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let xprv1 = derive_bitcoin_xprv(&seed).unwrap(); + let xprv2 = derive_bitcoin_xprv(&seed).unwrap(); + assert_eq!(xprv1, xprv2); + } + + #[test] + fn test_lnd_entropy_deterministic() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let e1 = derive_lnd_entropy(&seed).unwrap(); + let e2 = derive_lnd_entropy(&seed).unwrap(); + assert_eq!(e1, e2); + assert_eq!(e1.len(), 16); + } + + #[test] + fn test_generate_produces_24_words() { + let (mnemonic, _seed) = MasterSeed::generate().unwrap(); + assert_eq!(mnemonic.word_count(), 24); + } + + #[test] + fn test_invalid_mnemonic_rejected() { + let result = MasterSeed::from_mnemonic_words("not a valid mnemonic"); + assert!(result.is_err()); + } + + #[test] + fn test_wrong_word_count_rejected() { + // 12 words (valid BIP-39 but we require 24) + let result = MasterSeed::from_mnemonic_words( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_encrypted_storage_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let (mnemonic, _seed) = MasterSeed::generate().unwrap(); + let words = mnemonic.to_string(); + + save_seed_encrypted(dir.path(), &mnemonic, "test-passphrase").await.unwrap(); + assert!(seed_exists(dir.path())); + + let restored = load_seed_encrypted(dir.path(), "test-passphrase").await.unwrap(); + assert_eq!(restored.to_string(), words); + } + + #[tokio::test] + async fn test_encrypted_storage_wrong_passphrase() { + let dir = tempfile::tempdir().unwrap(); + let (mnemonic, _seed) = MasterSeed::generate().unwrap(); + + save_seed_encrypted(dir.path(), &mnemonic, "correct").await.unwrap(); + let result = load_seed_encrypted(dir.path(), "wrong").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_identity_index_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + // Create identity subdirectory (required by the path). + tokio::fs::create_dir_all(dir.path().join("identity")).await.unwrap(); + + assert_eq!(load_identity_index(dir.path()).await.unwrap(), 0); + save_identity_index(dir.path(), 5).await.unwrap(); + assert_eq!(load_identity_index(dir.path()).await.unwrap(), 5); + } + + #[test] + fn test_full_derivation_from_known_mnemonic() { + // Verify all derivation paths produce valid, distinct keys from a known mnemonic. + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + + let node_ed = derive_node_ed25519(&seed).unwrap(); + let node_nostr = derive_node_nostr_key(&seed).unwrap(); + let id0_ed = derive_identity_ed25519(&seed, 0).unwrap(); + let id0_nostr = derive_nostr_identity_key(&seed, 0).unwrap(); + let _btc = derive_bitcoin_xprv(&seed).unwrap(); + let lnd = derive_lnd_entropy(&seed).unwrap(); + + // All keys should be distinct (comparing hex representations). + let node_ed_hex = hex::encode(node_ed.verifying_key().as_bytes()); + let id0_ed_hex = hex::encode(id0_ed.verifying_key().as_bytes()); + let node_nostr_hex = node_nostr.public_key().to_hex(); + let id0_nostr_hex = id0_nostr.public_key().to_hex(); + let lnd_hex = hex::encode(lnd); + + let all = [&node_ed_hex, &id0_ed_hex, &node_nostr_hex, &id0_nostr_hex, &lnd_hex]; + for (i, a) in all.iter().enumerate() { + for (j, b) in all.iter().enumerate() { + if i != j { + assert_ne!(a, b, "Keys at positions {} and {} should differ", i, j); + } + } + } + } +} diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index 0f2802e9..3db051af 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -436,7 +436,7 @@ mod tests { #[tokio::test] async fn test_session_create_and_validate() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let token = store.create().await; assert!(store.validate(&token).await); @@ -444,13 +444,13 @@ mod tests { #[tokio::test] async fn test_session_invalid_token() { - let store = SessionStore::new(); + let store = SessionStore::new().await; assert!(!store.validate("nonexistent_token").await); } #[tokio::test] async fn test_session_remove() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let token = store.create().await; assert!(store.validate(&token).await); @@ -460,7 +460,7 @@ mod tests { #[tokio::test] async fn test_pending_session_upgrade() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let secret = vec![1, 2, 3, 4]; let token = store.create_pending(secret.clone()).await; @@ -484,7 +484,7 @@ mod tests { #[tokio::test] async fn test_pending_session_max_attempts() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let secret = vec![1, 2, 3]; let token = store.create_pending(secret).await; @@ -513,7 +513,7 @@ mod tests { #[tokio::test] async fn test_session_activity_updates_on_validate() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let token = store.create().await; // First validation should succeed and touch last_activity @@ -525,7 +525,7 @@ mod tests { #[tokio::test] async fn test_invalidate_all_except() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let token1 = store.create().await; let token2 = store.create().await; let token3 = store.create().await; @@ -540,7 +540,7 @@ mod tests { #[tokio::test] async fn test_session_rotate() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let old_token = store.create().await; assert!(store.validate(&old_token).await); @@ -555,7 +555,7 @@ mod tests { #[tokio::test] async fn test_max_concurrent_sessions() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let mut tokens = Vec::new(); // Create MAX_CONCURRENT_SESSIONS sessions @@ -583,7 +583,7 @@ mod tests { #[tokio::test] async fn test_active_session_count() { - let store = SessionStore::new(); + let store = SessionStore::new().await; assert_eq!(store.active_session_count().await, 0); let token1 = store.create().await; @@ -598,7 +598,7 @@ mod tests { #[tokio::test] async fn test_cleanup_expired_removes_stale() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let token = store.create().await; assert!(store.validate(&token).await); @@ -611,7 +611,7 @@ mod tests { #[tokio::test] async fn test_rotate_preserves_session_count() { - let store = SessionStore::new(); + let store = SessionStore::new().await; let token = store.create().await; assert_eq!(store.active_session_count().await, 1); diff --git a/core/archipelago/src/transport/chunking.rs b/core/archipelago/src/transport/chunking.rs index 1aa7b325..ebe494a6 100644 --- a/core/archipelago/src/transport/chunking.rs +++ b/core/archipelago/src/transport/chunking.rs @@ -326,7 +326,7 @@ mod tests { let chunks = encode_chunked(&data).unwrap(); let data_chunks: Vec<_> = chunks.iter().filter(|c| !c.is_parity).collect(); - let parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect(); + let _parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect(); assert_eq!(data_chunks.len(), 4); // ceil(500/124) = 5... wait // Actually: ceil(500/124) = ceil(4.03) = 5 data shards // But the first shard has 4 bytes of length header embedded, so diff --git a/core/archipelago/src/wallet/profits.rs b/core/archipelago/src/wallet/profits.rs index 21dd6c84..d2f04f37 100644 --- a/core/archipelago/src/wallet/profits.rs +++ b/core/archipelago/src/wallet/profits.rs @@ -53,6 +53,42 @@ pub async fn load_profits(data_dir: &Path) -> Result { Ok(summary) } +/// Save profits summary to disk. +#[allow(dead_code)] +pub async fn save_profits(data_dir: &Path, summary: &ProfitsSummary) -> Result<()> { + let dir = data_dir.join("wallet"); + fs::create_dir_all(&dir) + .await + .context("Failed to create wallet directory")?; + let path = data_dir.join(PROFITS_FILE); + let content = serde_json::to_string_pretty(summary) + .context("Failed to serialize profits")?; + fs::write(&path, content) + .await + .context("Failed to write profits file")?; + Ok(()) +} + +/// Record a single content sale, updating totals and the recent entries list. +#[allow(dead_code)] +pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description: &str) -> Result<()> { + let mut summary = load_profits(data_dir).await?; + let entry = ProfitEntry { + source: ProfitSource::ContentSale, + amount_sats, + timestamp: chrono::Utc::now().to_rfc3339(), + description: description.to_string(), + }; + summary.recent.insert(0, entry); + if summary.recent.len() > 100 { + summary.recent.truncate(100); + } + summary.content_sales_sats += amount_sats; + summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats; + save_profits(data_dir, &summary).await?; + Ok(()) +} + /// Compute a full profits summary including ecash receive transactions. pub async fn get_networking_profits(data_dir: &Path) -> Result { let mut summary = load_profits(data_dir).await?; diff --git a/core/archipelago/tests/orchestration_tests.rs b/core/archipelago/tests/orchestration_tests.rs index 86a4e5b6..891c869c 100644 --- a/core/archipelago/tests/orchestration_tests.rs +++ b/core/archipelago/tests/orchestration_tests.rs @@ -259,7 +259,7 @@ mod restart_tracker { // ── Failsafe Install ────────────────────────────────────────────────── mod failsafe_install { - use crate::mock_podman::{MockPodman, MockContainerState}; + use crate::mock_podman::MockPodman; use std::sync::atomic::Ordering; #[test] @@ -302,7 +302,6 @@ mod failsafe_install { // ── Health Monitor Logic ────────────────────────────────────────────── mod health_monitor_logic { - use crate::mock_podman::{MockPodman, MockContainerState}; /// Mirrors the tier ordering from health_monitor.rs fn container_tier(name: &str) -> u8 { diff --git a/docs/user-walkthrough.md b/docs/user-walkthrough.md index aafcb68d..502cce4f 100644 --- a/docs/user-walkthrough.md +++ b/docs/user-walkthrough.md @@ -247,14 +247,116 @@ Each app detail page shows: ### Controller / Gamepad Navigation -> **Screenshot**: Dashboard with visible focus indicators showing controller navigation in action. +Archipelago supports Xbox-style controller navigation throughout the UI. -Archipelago supports Xbox-style controller navigation: -- **D-pad / Arrow keys**: Navigate between elements -- **A / Enter**: Select / activate -- **B / Escape**: Go back -- **Bumpers**: Switch between pages -- Focus indicators show the current selection +#### Global Controls + +| Button | Action | +|--------|--------| +| D-pad Up/Down | Navigate between elements | +| D-pad Left/Right | Move between zones (sidebar ↔ content) | +| A / Enter | Select / activate / enter container | +| B / Escape | Go back / exit container / return to sidebar | + +#### Navigation Zones + +**Sidebar** (left column — always visible on desktop): +- Up/Down = move between items (wraps), auto-navigates page links +- Right = enter main content (first container, or first button on container-free pages) +- Left = nothing + +**Nav Bar** (mode-switcher tabs at top of content — e.g. My Apps / App Store / Services): +- Left/Right = move between tabs +- Down = jump to first card/container below (remembers tab for Up return) +- Up = nothing (Escape to sidebar) +- Left from leftmost = sidebar + +**Container Grid** (card tiles — Apps, Discover, Network, Home): +- Arrows = spatial navigation between cards +- Enter = primary action (Install, Launch, or enter inner controls) +- Escape = sidebar +- Left from leftmost card = sidebar +- Up from top row = return to remembered nav bar tab + +**Inside Container** (after Enter on a card — inner buttons/controls): +- Arrows = move between inner controls +- Escape = exit back to the card +- Cannot leave via arrows — must Escape first + +**Text Inputs** (search bars, form fields): +- Up/Down = exit field, navigate to nearest element +- Enter = submit (clicks the next button) +- Left/Right = cursor movement (exits field at edges) + +#### Per-Page Mapping + +**Home** (`/dashboard`) +- Right from sidebar → first status card +- D-pad navigates between status cards spatially +- Enter on card → navigates to that section + +**My Apps** (`/dashboard/apps`) +- Right from sidebar → first app card +- D-pad navigates app card grid spatially +- Enter on card → app details page +- Enter on focused card with Launch button → launches app + +**App Store / Discover** (`/dashboard/discover`) +- Right from sidebar → first featured card +- D-pad navigates card grid (Sovereignty Stack + All Applications) +- Down from nav tabs → first card below +- Up from top card → returns to last-focused tab +- Enter on card → app detail / install +- Cards lift on hover/focus (same as My Apps) + +**Network** (`/dashboard/server`) +- Right from sidebar → Quick Actions card +- D-pad navigates between cards: Quick Actions → Local Network / Web3 → Network Interfaces / Tor Services +- Enter on Quick Actions → enters inner buttons (Restart, Check Tor, View Logs) +- Escape from inner buttons → back to card +- All cards lift on hover/focus + +**Settings** (`/dashboard/settings`) — **Linear navigation, no containers** +- Right from sidebar → first button (server name row) +- D-pad Up/Down steps through ALL buttons/controls top-to-bottom: + 1. Server Name / What's New + 2. Copy DID + 3. Copy Onion Address + 4. Change Password + 5. Enable/Disable 2FA + 6. Logout + 7. Choose Language + 8. Login with Claude + 9. AI Data Access toggles (each enable/disable row) + 10. Manage Updates + 11. Webhook URL input + 12. Webhook Secret input + 13. Container Crash / Update Available toggles + 14. Disk Space Warning / Backup Complete toggles + 15. Save Configuration / Send Test Webhook + 16. Enable Beta Telemetry + 17. Create Backup + 18. Export Channel Backup + 19. Network Diagnostics + 20. Reboot + 21. Factory Reset +- Enter = activates the focused button/toggle +- Escape / Left = sidebar + +**Mesh** (`/dashboard/mesh`) +- Right from sidebar → Device status card (left column) +- D-pad navigates between left-column containers (Device, Actions, Peers) +- Enter on peer → opens chat, auto-focuses message input +- Type message + Enter = send +- Escape = close chat / back to sidebar + +**Cloud** (`/dashboard/cloud`) +- Right from sidebar → first folder/file card +- D-pad navigates file grid spatially +- Enter = open folder / file details + +**Detail Pages** (app details, marketplace app details): +- Escape / B = go back to previous page --- diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 06102784..2ffd8de6 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -917,6 +917,31 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { valid: true } }) } + // BIP-39 seed management (mock for dev mode) + case 'seed.generate': { + const mockWords = [ + 'abandon', 'ability', 'able', 'about', 'above', 'absent', + 'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident', + 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', + 'across', 'act', 'action', 'actor', 'actress', 'actual' + ] + return res.json({ result: { words: mockWords } }) + } + case 'seed.verify': { + const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH' + return res.json({ result: { verified: true, did: mockDid, nostr_npub: 'npub1mock...' } }) + } + case 'seed.restore': { + const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH' + return res.json({ result: { did: mockDid, nostr_npub: 'npub1mock...', restored: true } }) + } + case 'seed.save-encrypted': { + return res.json({ result: { saved: true } }) + } + case 'seed.status': { + return res.json({ result: { has_seed: true, is_legacy: false, identity_count: 1, next_index: 1 } }) + } + case 'node.createBackup': { const { passphrase } = params || {} if (!passphrase) { diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 41e20470..770e9a20 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -225,6 +225,43 @@ class RPCClient { }) } + // ─── Seed Management ─────────────────────────────────────────────── + + async generateSeed(): Promise<{ words: string[] }> { + return this.call({ method: 'seed.generate' }) + } + + async verifySeed(words: string[], indices: number[]): Promise<{ + verified: boolean + did: string + nostr_npub: string + }> { + return this.call({ method: 'seed.verify', params: { words, indices } }) + } + + async restoreSeed(words: string[]): Promise<{ + did: string + nostr_npub: string + restored: boolean + }> { + return this.call({ method: 'seed.restore', params: { words } }) + } + + async saveSeedEncrypted(passphrase: string): Promise<{ saved: boolean }> { + return this.call({ method: 'seed.save-encrypted', params: { passphrase } }) + } + + async seedStatus(): Promise<{ + has_seed: boolean + is_legacy: boolean + identity_count: number + next_index: number + }> { + return this.call({ method: 'seed.status' }) + } + + // ─── Node Identity ─────────────────────────────────────────────── + async getNodeDid(): Promise<{ did: string; pubkey: string }> { return this.call({ method: 'node.did', diff --git a/neode-ui/src/components/ToggleSwitch.vue b/neode-ui/src/components/ToggleSwitch.vue index e8b4e056..74199f5c 100644 --- a/neode-ui/src/components/ToggleSwitch.vue +++ b/neode-ui/src/components/ToggleSwitch.vue @@ -3,6 +3,8 @@ type="button" role="switch" :aria-checked="modelValue" + tabindex="-1" + data-controller-ignore class="w-10 h-6 rounded-full shrink-0 transition-colors relative" :class="modelValue ? 'bg-orange-500' : 'bg-white/15'" @click="$emit('update:modelValue', !modelValue)" diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index 41cd880c..7cfe05c4 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -84,9 +84,13 @@ function getNavBarItems(): HTMLElement[] { function isNavBarItem(el: HTMLElement | null): boolean { if (!el) return false - return isInZone(el, 'main') && - !el.hasAttribute('data-controller-container') && - !el.closest('[data-controller-container]') + if (!isInZone(el, 'main')) return false + if (el.hasAttribute('data-controller-container') || el.closest('[data-controller-container]')) return false + // On container-free pages (e.g. Settings), don't classify elements as nav bar items — + // let them fall through to the main zone handler which supports linear up/down/right nav. + const zone = document.querySelector('[data-controller-zone="main"]') + if (zone && !zone.querySelector('[data-controller-container]')) return false + return true } /** Inner focusables within a container (buttons, links — not the container itself) */ @@ -425,6 +429,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { if (dest) { focusEl(dest) } else { + // Check if this is a container-free page (e.g. Settings) — focus first button immediately + const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null + const hasAnyContainers = zone?.querySelector('[data-controller-container]') + if (!hasAnyContainers && zone) { + const focusable = getFocusableElements(zone) + if (focusable[0]) { focusEl(focusable[0]); return } + } // Containers not rendered yet (route transition / animation in progress) // Poll until they appear, up to 1s let attempts = 0 @@ -436,9 +447,8 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { focusEl(retryContainers[0]) } else if (attempts >= 10) { clearInterval(poll) - // No containers on this page (e.g. Settings) — focus first focusable element - const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null - if (z) { const f = getFocusableElements(z); if (f[0]) focusEl(f[0]) } + // Last resort: focus first focusable element + if (zone) { const f = getFocusableElements(zone); if (f[0]) focusEl(f[0]) } } }, 100) } @@ -477,31 +487,20 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { return } - if (dir === 'down') { - // Down from nav bar → jump to containers (remember tab for Up return) - rememberFocus('navBar', activeEl) - const containers = getContainers() - const nearest = findNearestInDirection(activeEl, containers, 'down') - if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return } - // Fallback: just focus first container - if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return } - // Containers not rendered yet — poll until they appear - let attempts = 0 - const poll = setInterval(() => { - attempts++ - const retryContainers = getContainers() - if (retryContainers[0]) { - clearInterval(poll) - rememberFocus('main', retryContainers[0]) - focusEl(retryContainers[0]) - } else if (attempts >= 10) { - clearInterval(poll) - } - }, 100) - return + if (dir === 'down' || dir === 'up') { + // Up/Down from standalone element → find nearest focusable (container or button) in direction. + // Searches containers + standalone elements together so mixed pages (Settings) don't jump. + if (dir === 'down') rememberFocus('navBar', activeEl) + const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null + if (zone) { + const allFocusable = getFocusableElements(zone).filter(el => + el.hasAttribute('data-controller-container') || + !el.closest('[data-controller-container]') + ) + const target = findNearestInDirection(activeEl, allFocusable, dir) + if (target) { focusEl(target); return } + } } - - // Up from nav bar → nothing (use Escape to go to sidebar) return } @@ -509,8 +508,14 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { if (isInZone(activeEl, 'main')) { const containers = getContainers() - // Try spatial nav to another container - const next = findNearestInDirection(activeEl, containers, dir) + // Try spatial nav to containers + standalone focusables (not inner buttons). + // This handles mixed pages (e.g. Settings) where containers and buttons coexist. + const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null + const navTargets = zone ? getFocusableElements(zone).filter(el => + el.hasAttribute('data-controller-container') || + !el.closest('[data-controller-container]') + ) : containers + const next = findNearestInDirection(activeEl, navTargets, dir) if (next) { rememberFocus('main', next) focusEl(next) diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 45cb7e61..d04b1c8c 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -35,6 +35,21 @@ const router = createRouter({ name: 'onboarding-path', component: () => import('../views/OnboardingPath.vue'), }, + { + path: 'onboarding/seed', + name: 'onboarding-seed', + component: () => import('../views/OnboardingSeedGenerate.vue'), + }, + { + path: 'onboarding/seed-verify', + name: 'onboarding-seed-verify', + component: () => import('../views/OnboardingSeedVerify.vue'), + }, + { + path: 'onboarding/seed-restore', + name: 'onboarding-seed-restore', + component: () => import('../views/OnboardingSeedRestore.vue'), + }, { path: 'onboarding/did', name: 'onboarding-did', diff --git a/neode-ui/src/stores/sync.ts b/neode-ui/src/stores/sync.ts index 4e2652be..75f992d2 100644 --- a/neode-ui/src/stores/sync.ts +++ b/neode-ui/src/stores/sync.ts @@ -136,6 +136,7 @@ export const useSyncStore = defineStore('sync', () => { unread: 0, 'wifi-ssids': [], 'zram-enabled': false, + 'seed-backed': false, }, 'package-data': {}, ui: { diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts index c4852ffd..428fcdf5 100644 --- a/neode-ui/src/types/api.ts +++ b/neode-ui/src/types/api.ts @@ -29,6 +29,7 @@ export interface ServerInfo { unread: number 'wifi-ssids': string[] 'zram-enabled': boolean + 'seed-backed': boolean } export interface StatusInfo { diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue index ca184c34..e7cb6701 100644 --- a/neode-ui/src/views/Discover.vue +++ b/neode-ui/src/views/Discover.vue @@ -115,13 +115,13 @@ manifesto
-
+
"Privacy is not about having something to hide. Privacy is about having the right to choose what to reveal. In a world of surveillance capitalism, self-hosting is an act of resistance. Every service you run on your own hardware is a vote for a future where individuals — not corporations — control their digital lives."
-

// Cypherpunks write code. We run nodes.

+

// Cypherpunks write code. We run nodes.

{}) + router.push('/onboarding/done').catch(() => {}) } catch (err) { if (isServerStartingError(err)) { serverStarting.value = true diff --git a/neode-ui/src/views/OnboardingIntro.vue b/neode-ui/src/views/OnboardingIntro.vue index 2d34b0ec..ffe2836c 100644 --- a/neode-ui/src/views/OnboardingIntro.vue +++ b/neode-ui/src/views/OnboardingIntro.vue @@ -29,40 +29,11 @@ tabindex="0" role="button" class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta" - @click="showRestore = true" - @keydown.enter="showRestore = true" + @click="goToRestore" + @keydown.enter="goToRestore" > - Restore from backup + Restore from seed phrase - - -
-

Restore Identity from Backup

- - -

{{ restoreError }}

-

Identity restored successfully!

-
- - -
-
@@ -72,7 +43,6 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import AnimatedLogo from '@/components/AnimatedLogo.vue' -import { rpcClient } from '@/api/rpc-client' import { playNavSound } from '@/composables/useNavSounds' const router = useRouter() @@ -90,49 +60,9 @@ function goToOptions() { router.push('/onboarding/path').catch(() => {}) } -// Restore from backup -const showRestore = ref(false) -const restoreFile = ref | null>(null) -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 - } +function goToRestore() { + playNavSound('action') + router.push('/onboarding/seed-restore').catch(() => {}) } diff --git a/neode-ui/src/views/OnboardingOptions.vue b/neode-ui/src/views/OnboardingOptions.vue index 79e61ba4..aa8f1a59 100644 --- a/neode-ui/src/views/OnboardingOptions.vue +++ b/neode-ui/src/views/OnboardingOptions.vue @@ -33,8 +33,12 @@

- -
+ +
+
@@ -81,7 +84,6 @@ diff --git a/neode-ui/src/views/OnboardingPath.vue b/neode-ui/src/views/OnboardingPath.vue index e72a0aa2..996f9e95 100644 --- a/neode-ui/src/views/OnboardingPath.vue +++ b/neode-ui/src/views/OnboardingPath.vue @@ -109,6 +109,6 @@ onMounted(() => { function proceed() { playNavSound('action') - router.push('/onboarding/did').catch(() => {}) + router.push('/onboarding/seed').catch(() => {}) } diff --git a/neode-ui/src/views/OnboardingSeedGenerate.vue b/neode-ui/src/views/OnboardingSeedGenerate.vue new file mode 100644 index 00000000..98bed44e --- /dev/null +++ b/neode-ui/src/views/OnboardingSeedGenerate.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/neode-ui/src/views/OnboardingSeedRestore.vue b/neode-ui/src/views/OnboardingSeedRestore.vue new file mode 100644 index 00000000..309b12b7 --- /dev/null +++ b/neode-ui/src/views/OnboardingSeedRestore.vue @@ -0,0 +1,183 @@ + + + diff --git a/neode-ui/src/views/OnboardingSeedVerify.vue b/neode-ui/src/views/OnboardingSeedVerify.vue new file mode 100644 index 00000000..d8540e4b --- /dev/null +++ b/neode-ui/src/views/OnboardingSeedVerify.vue @@ -0,0 +1,254 @@ + + + diff --git a/neode-ui/src/views/OnboardingWrapper.vue b/neode-ui/src/views/OnboardingWrapper.vue index 31f1eff0..c0780784 100644 --- a/neode-ui/src/views/OnboardingWrapper.vue +++ b/neode-ui/src/views/OnboardingWrapper.vue @@ -75,6 +75,7 @@ const transitionName = ref('depth-forward') // Ordered onboarding steps for direction detection const onboardingOrder = [ '/onboarding/intro', '/onboarding/path', '/onboarding/options', + '/onboarding/seed', '/onboarding/seed-verify', '/onboarding/seed-restore', '/onboarding/did', '/onboarding/identity', '/onboarding/backup', '/onboarding/verify', '/onboarding/done', '/login' ] @@ -96,8 +97,11 @@ const routeBackgrounds: Record = { '/onboarding/intro': 'bg-intro.jpg', // Video will be used instead '/onboarding/options': 'bg-intro-4.jpg', '/onboarding/path': 'bg-intro-3.jpg', - '/onboarding/did': 'bg-intro-5.jpg', - '/onboarding/identity': 'bg-intro-5.jpg', + '/onboarding/seed': '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/verify': 'bg-intro-2.jpg', '/onboarding/done': 'bg-intro-1.jpg', diff --git a/neode-ui/src/views/discover/DiscoverHero.vue b/neode-ui/src/views/discover/DiscoverHero.vue index 721bc5df..52f08c1d 100644 --- a/neode-ui/src/views/discover/DiscoverHero.vue +++ b/neode-ui/src/views/discover/DiscoverHero.vue @@ -3,32 +3,37 @@
-
-
- ~ $ - ARCHIPELAGO://DISCOVER +
+
+
+ ~ $ + ARCHIPELAGO://DISCOVER +
+

+ Reclaim Your
+ Digital Sovereignty +

+

+ Your node. Your rules. Every app runs on your hardware, verified by your Bitcoin node. + No cloud. No custodians. No permission needed. +

+
+
+ {{ totalApps }} + apps available +
+
+ {{ installedCount }} + installed +
+
+ 100% + self-hosted +
+
-

- Reclaim Your
- Digital Sovereignty -

-

- Your node. Your rules. Every app runs on your hardware, verified by your Bitcoin node. - No cloud. No custodians. No permission needed. -

-
-
- {{ totalApps }} - apps available -
-
- {{ installedCount }} - installed -
-
- 100% - self-hosted -
+
+
@@ -39,35 +44,37 @@ -

Privacy First

-

No telemetry. No tracking. Your data never leaves your hardware.

+

Privacy First

+

No telemetry. No tracking. Your data never leaves your hardware.

-

Verify, Don't Trust

-

Run your own node. Validate every transaction. Be your own bank.

+

Verify, Don't Trust

+

Run your own node. Validate every transaction. Be your own bank.

-

Open Source

-

Every app is open source. Audit the code. Trust the math, not the company.

+

Open Source

+

Every app is open source. Audit the code. Trust the math, not the company.

-

No Permission Needed

-

Permissionless commerce. Permissionless money. Permissionless freedom.

+

No Permission Needed

+

Permissionless commerce. Permissionless money. Permissionless freedom.