feat: BIP-39 master seed for unified key derivation
Replace fragmented random key generation with a single 24-word BIP-39 mnemonic that deterministically derives all node keys: Ed25519 (DID), secp256k1 (Nostr/Bitcoin), BIP-84 xprv (Bitcoin Core), and LND aezeed entropy. New onboarding flow: seed generate → word verification → identity naming. Restore path enabled via 24-word entry. Includes seed RPC handlers, mock backend support, LND/Bitcoin Core wallet-from-seed integration, and UI polish across settings and discover views. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5da9e217e6
commit
19dcfd4f31
89
core/Cargo.lock
generated
89
core/Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
@ -54,6 +54,10 @@ hex = "0.4"
|
||||
bs58 = "0.5"
|
||||
chrono = "0.4"
|
||||
|
||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||
bip39 = { version = "=2.1.0", features = ["rand"] }
|
||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||
|
||||
# Configuration
|
||||
toml = "0.8"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BitcoinInfo {
|
||||
@ -106,4 +107,117 @@ impl RpcHandler {
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
|
||||
}
|
||||
|
||||
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
||||
/// Creates a blank wallet and imports BIP-84 (native segwit) descriptors.
|
||||
/// Requires: password re-verification, encrypted seed on disk.
|
||||
pub(super) async fn handle_bitcoin_init_wallet_from_seed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||
let wallet_name = params.get("wallet_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("archipelago");
|
||||
|
||||
// Verify user password.
|
||||
self.auth_manager.verify_password(password).await
|
||||
.context("Password verification failed")?;
|
||||
|
||||
// Load encrypted seed.
|
||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
|
||||
.context("Failed to load encrypted seed")?;
|
||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
|
||||
// Derive BIP-84 account xprv.
|
||||
let xprv = crate::seed::derive_bitcoin_xprv(&seed)?;
|
||||
let mut xprv_str = xprv.to_string();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
// Step 1: Create a blank descriptor wallet.
|
||||
let create_result = self.bitcoin_rpc_call::<serde_json::Value>(
|
||||
&client,
|
||||
"createwallet",
|
||||
&[
|
||||
serde_json::json!(wallet_name), // wallet_name
|
||||
serde_json::json!(false), // disable_private_keys
|
||||
serde_json::json!(true), // blank
|
||||
serde_json::json!(""), // passphrase
|
||||
serde_json::json!(false), // avoid_reuse
|
||||
serde_json::json!(true), // descriptors
|
||||
],
|
||||
).await;
|
||||
|
||||
match create_result {
|
||||
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("already exists") {
|
||||
tracing::info!("Wallet '{}' already exists, importing descriptors", wallet_name);
|
||||
} else {
|
||||
xprv_str.zeroize();
|
||||
return Err(e.context("Failed to create wallet"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Import BIP-84 descriptors (external + internal/change).
|
||||
// Format: wpkh(xprv/0/*) for receive, wpkh(xprv/1/*) for change.
|
||||
let external_desc = format!("wpkh({}/0/*)", xprv_str);
|
||||
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
|
||||
|
||||
// Get checksums from Bitcoin Core.
|
||||
let ext_info: serde_json::Value = self.bitcoin_rpc_call(
|
||||
&client, "getdescriptorinfo", &[serde_json::json!(external_desc)],
|
||||
).await.context("getdescriptorinfo failed for external descriptor")?;
|
||||
|
||||
let int_info: serde_json::Value = self.bitcoin_rpc_call(
|
||||
&client, "getdescriptorinfo", &[serde_json::json!(internal_desc)],
|
||||
).await.context("getdescriptorinfo failed for internal descriptor")?;
|
||||
|
||||
let ext_desc_with_checksum = ext_info.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
let int_desc_with_checksum = int_info.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
|
||||
let import_params = serde_json::json!([
|
||||
{
|
||||
"desc": ext_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": false,
|
||||
"range": [0, 1000],
|
||||
},
|
||||
{
|
||||
"desc": int_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": true,
|
||||
"range": [0, 1000],
|
||||
}
|
||||
]);
|
||||
|
||||
let _import_result: serde_json::Value = self.bitcoin_rpc_call(
|
||||
&client, "importdescriptors", &[import_params],
|
||||
).await.context("importdescriptors failed")?;
|
||||
|
||||
// Zeroize the xprv string from memory.
|
||||
xprv_str.zeroize();
|
||||
|
||||
tracing::info!("Bitcoin Core wallet '{}' initialized from master seed (BIP-84)", wallet_name);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"initialized": true,
|
||||
"wallet_name": wallet_name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,13 @@ impl RpcHandler {
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
|
||||
// Seed management (BIP-39 mnemonic)
|
||||
"seed.generate" => self.handle_seed_generate().await,
|
||||
"seed.verify" => self.handle_seed_verify(params).await,
|
||||
"seed.restore" => self.handle_seed_restore(params).await,
|
||||
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
|
||||
"seed.status" => self.handle_seed_status().await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
"container-start" => self.handle_container_start(params).await,
|
||||
@ -78,6 +85,7 @@ impl RpcHandler {
|
||||
|
||||
// Bitcoin & Lightning deep data
|
||||
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
||||
"bitcoin.init-wallet-from-seed" => self.handle_bitcoin_init_wallet_from_seed(params).await,
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
@ -92,6 +100,7 @@ impl RpcHandler {
|
||||
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
|
||||
"lnd.connect-info" => self.handle_lnd_connect_info().await,
|
||||
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
|
||||
"lnd.init-wallet-from-seed" => self.handle_lnd_init_wallet_from_seed(params).await,
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
|
||||
@ -2,6 +2,7 @@ use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use tracing::info;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
@ -381,4 +382,71 @@ impl RpcHandler {
|
||||
"broadcast": false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Initialize LND wallet with entropy derived from the node's BIP-39 master seed.
|
||||
/// The 16-byte entropy deterministically produces an aezeed mnemonic inside LND.
|
||||
/// Requires: password re-verification via params.password, encrypted seed on disk.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_init_wallet_from_seed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||
let wallet_password = params.get("wallet_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'wallet_password' for LND"))?;
|
||||
|
||||
// Verify user password before granting seed access.
|
||||
self.auth_manager.verify_password(password).await
|
||||
.context("Password verification failed")?;
|
||||
|
||||
// Load encrypted seed from disk.
|
||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
|
||||
.context("Failed to load encrypted seed. Was a seed phrase saved during onboarding?")?;
|
||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
|
||||
// Derive 16 bytes of LND entropy.
|
||||
let mut entropy = crate::seed::derive_lnd_entropy(&seed)?;
|
||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(&entropy);
|
||||
entropy.zeroize();
|
||||
|
||||
let wallet_password_b64 = base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let init_body = serde_json::json!({
|
||||
"wallet_password": wallet_password_b64,
|
||||
"seed_entropy": entropy_b64,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/initwallet")
|
||||
.json(&init_body)
|
||||
.send()
|
||||
.await
|
||||
.context("LND initwallet request failed — is LND running and uninitialized?")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await
|
||||
.context("Failed to parse initwallet response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
|
||||
}
|
||||
|
||||
info!("LND wallet initialized from master seed entropy");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"initialized": true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,11 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||
"identity.create",
|
||||
"identity.verify",
|
||||
"identity.resolve-did",
|
||||
// Seed management (onboarding — before user has a session)
|
||||
"seed.generate",
|
||||
"seed.verify",
|
||||
"seed.restore",
|
||||
"seed.save-encrypted",
|
||||
// Onboarding restore (before user account exists)
|
||||
"backup.restore-identity",
|
||||
// Inter-node RPC: called by federated peers over Tor, no session cookies
|
||||
|
||||
@ -24,6 +24,7 @@ mod package;
|
||||
mod peers;
|
||||
mod response;
|
||||
mod router;
|
||||
mod seed_rpc;
|
||||
mod security;
|
||||
mod tor;
|
||||
mod transport;
|
||||
|
||||
237
core/archipelago/src/api/rpc/seed_rpc.rs
Normal file
237
core/archipelago/src/api/rpc/seed_rpc.rs
Normal file
@ -0,0 +1,237 @@
|
||||
//! RPC handlers for BIP-39 seed management.
|
||||
//! Endpoints: seed.generate, seed.verify, seed.restore, seed.save-encrypted, seed.status
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::ToBech32;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// In-memory storage for the mnemonic between generate and verify steps.
|
||||
/// Auto-cleared after 10 minutes.
|
||||
static ONBOARDING_MNEMONIC: std::sync::LazyLock<Arc<Mutex<Option<OnboardingMnemonicState>>>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
struct OnboardingMnemonicState {
|
||||
words: String,
|
||||
created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Drop for OnboardingMnemonicState {
|
||||
fn drop(&mut self) {
|
||||
self.words.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_secret_path = identity_dir.join("nostr_secret");
|
||||
let nostr_pub_path = identity_dir.join("nostr_pubkey");
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(&nostr_secret_path, secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(&nostr_pub_path, pubkey_hex.as_bytes()).await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||
}
|
||||
|
||||
// Initialize identity index at 0.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
let words: Vec<&str> = mnemonic.words().collect();
|
||||
|
||||
// Hold mnemonic in memory for the verify step.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: mnemonic.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"words": words,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Verify the user wrote down their seed correctly.
|
||||
/// Also confirms the mnemonic by re-deriving and returning DID + npub.
|
||||
pub(in crate::api::rpc) async fn handle_seed_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let submitted_words: Vec<String> = serde_json::from_value(
|
||||
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||
).context("Invalid words array")?;
|
||||
|
||||
// Validate against the held mnemonic.
|
||||
let mnemonic_str = {
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
match state.as_ref() {
|
||||
Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(),
|
||||
_ => {
|
||||
*state = None;
|
||||
anyhow::bail!("No pending seed generation or session expired. Please regenerate.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let expected_words: Vec<&str> = mnemonic_str.split_whitespace().collect();
|
||||
let submitted: Vec<&str> = submitted_words.iter().map(|s| s.as_str()).collect();
|
||||
if expected_words != submitted {
|
||||
anyhow::bail!("Submitted words do not match generated seed");
|
||||
}
|
||||
|
||||
// Re-derive to get DID and npub.
|
||||
let (mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&mnemonic_str)?;
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Clear mnemonic from memory now that it's verified.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = None;
|
||||
}
|
||||
|
||||
// Save the encrypted seed for convenience backup.
|
||||
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
|
||||
// For now we just confirm the mnemonic was valid.
|
||||
drop(mnemonic);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"verified": true,
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restore node identity from a 24-word seed phrase.
|
||||
pub(in crate::api::rpc) async fn handle_seed_restore(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let words: Vec<String> = serde_json::from_value(
|
||||
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||
).context("Invalid words array")?;
|
||||
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(identity_dir.join("nostr_pubkey"), pubkey_hex_nostr.as_bytes()).await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
identity_dir.join("nostr_secret"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
).await?;
|
||||
}
|
||||
|
||||
// Initialize identity index.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// Create default identity from seed.
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager.create_from_seed(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
&seed,
|
||||
&self.config.data_dir,
|
||||
).await?;
|
||||
|
||||
// Get DID and npub for the response.
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
"restored": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Encrypt and save the mnemonic to disk for convenience backup.
|
||||
pub(in crate::api::rpc) async fn handle_seed_save_encrypted(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let passphrase = params.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
||||
|
||||
// Try to get mnemonic from in-memory state first.
|
||||
let mnemonic_str = {
|
||||
let state = ONBOARDING_MNEMONIC.lock().await;
|
||||
state.as_ref()
|
||||
.filter(|s| s.created_at.elapsed() < MNEMONIC_TTL)
|
||||
.map(|s| s.words.clone())
|
||||
};
|
||||
|
||||
let mnemonic: bip39::Mnemonic = if let Some(words) = mnemonic_str {
|
||||
words.parse().context("Invalid mnemonic in memory")?
|
||||
} else {
|
||||
anyhow::bail!("No mnemonic available. Generate or restore a seed first.");
|
||||
};
|
||||
|
||||
crate::seed::save_seed_encrypted(&self.config.data_dir, &mnemonic, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({ "saved": true }))
|
||||
}
|
||||
|
||||
/// Return seed status information.
|
||||
pub(in crate::api::rpc) async fn handle_seed_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let has_seed = crate::seed::seed_exists(&self.config.data_dir);
|
||||
let has_node_key = crate::identity::NodeIdentity::key_exists(
|
||||
&self.config.data_dir.join("identity"),
|
||||
);
|
||||
let is_legacy = has_node_key && !has_seed;
|
||||
let next_index = crate::seed::load_identity_index(&self.config.data_dir).await.unwrap_or(0);
|
||||
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
let (identities, _) = manager.list().await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"has_seed": has_seed,
|
||||
"is_legacy": is_legacy,
|
||||
"identity_count": identities.len(),
|
||||
"next_index": next_index,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -156,6 +156,7 @@ pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
|
||||
|
||||
/// Read disk usage via `df` for the root filesystem.
|
||||
/// Returns (used_bytes, total_bytes).
|
||||
#[allow(dead_code)]
|
||||
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
read_disk_usage_path("/").await
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ impl AuthManager {
|
||||
|
||||
/// Ensure a default user exists on first boot.
|
||||
/// Called once at startup — creates user with default password if none exists.
|
||||
#[allow(dead_code)]
|
||||
pub async fn ensure_default_user(&self) -> Result<()> {
|
||||
if self.is_setup().await? {
|
||||
return Ok(());
|
||||
|
||||
@ -69,7 +69,6 @@ impl DevDataManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_map_volume_path() {
|
||||
|
||||
@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU32, Ordering}};
|
||||
|
||||
/// Container state matching podman's real states.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum MockContainerState {
|
||||
Created,
|
||||
Running,
|
||||
@ -28,6 +29,7 @@ impl MockContainerState {
|
||||
|
||||
/// A simulated container.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MockContainer {
|
||||
pub name: String,
|
||||
pub image: String,
|
||||
@ -36,6 +38,7 @@ pub struct MockContainer {
|
||||
}
|
||||
|
||||
/// Mock podman runtime for testing orchestration logic without real containers.
|
||||
#[allow(dead_code)]
|
||||
pub struct MockPodman {
|
||||
containers: Arc<Mutex<HashMap<String, MockContainer>>>,
|
||||
/// When true, `podman pull` will fail (simulates registry down).
|
||||
@ -50,6 +53,7 @@ pub struct MockPodman {
|
||||
images: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MockPodman {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
||||
@ -56,6 +56,26 @@ pub struct CredentialStatusEntry {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Status of a verifiable credential.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CredentialStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Active => write!(f, "active"),
|
||||
Self::Revoked => write!(f, "revoked"),
|
||||
Self::Expired => write!(f, "expired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored credentials index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CredentialStore {
|
||||
|
||||
@ -54,6 +54,9 @@ pub struct ServerInfo {
|
||||
pub wifi_ssids: Vec<String>,
|
||||
#[serde(rename = "zram-enabled")]
|
||||
pub zram_enabled: bool,
|
||||
/// True if this node's keys are derived from a BIP-39 seed.
|
||||
#[serde(rename = "seed-backed", default)]
|
||||
pub seed_backed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@ -269,6 +272,7 @@ impl DataModel {
|
||||
unread: 0,
|
||||
wifi_ssids: vec![],
|
||||
zram_enabled: false,
|
||||
seed_backed: false,
|
||||
},
|
||||
package_data: HashMap::new(),
|
||||
peer_health: HashMap::new(),
|
||||
|
||||
@ -64,6 +64,45 @@ impl NodeIdentity {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create node identity from a BIP-39 master seed (deterministic derivation).
|
||||
/// Writes derived key to disk in the same format as load_or_create.
|
||||
pub async fn from_seed(identity_dir: &Path, seed: &crate::seed::MasterSeed) -> Result<Self> {
|
||||
fs::create_dir_all(identity_dir)
|
||||
.await
|
||||
.context("Failed to create identity directory")?;
|
||||
|
||||
let signing_key = crate::seed::derive_node_ed25519(seed)?;
|
||||
let key_path = identity_dir.join(NODE_KEY_FILE);
|
||||
let pub_path = identity_dir.join(NODE_KEY_PUB_FILE);
|
||||
|
||||
fs::write(&key_path, signing_key.to_bytes())
|
||||
.await
|
||||
.context("Failed to write node key")?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to set key permissions")?;
|
||||
}
|
||||
fs::write(&pub_path, signing_key.verifying_key().as_bytes())
|
||||
.await
|
||||
.context("Failed to write node public key")?;
|
||||
|
||||
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
|
||||
tracing::info!("Derived node identity from seed (pubkey: {}...)", &pubkey_hex[..16]);
|
||||
|
||||
Ok(Self {
|
||||
signing_key,
|
||||
_identity_dir: identity_dir.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a node key already exists on disk.
|
||||
pub fn key_exists(identity_dir: &Path) -> bool {
|
||||
identity_dir.join(NODE_KEY_FILE).exists()
|
||||
}
|
||||
|
||||
/// Access the signing key (for key derivation, e.g. mesh encryption).
|
||||
pub fn signing_key(&self) -> &SigningKey {
|
||||
&self.signing_key
|
||||
@ -115,6 +154,11 @@ impl NodeIdentity {
|
||||
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))
|
||||
}
|
||||
|
||||
/// Generate a W3C DID Document for this identity.
|
||||
#[allow(dead_code)]
|
||||
pub fn did_document(&self) -> Result<serde_json::Value> {
|
||||
did_document_from_pubkey_hex(&self.pubkey_hex())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Ed25519 pubkey (hex) to did:key format.
|
||||
|
||||
@ -90,6 +90,9 @@ struct IdentityFile {
|
||||
/// Nostr profile metadata
|
||||
#[serde(default)]
|
||||
profile: Option<IdentityProfile>,
|
||||
/// BIP-39 seed derivation index (if created from seed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
derivation_index: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct IdentityManager {
|
||||
@ -150,6 +153,7 @@ impl IdentityManager {
|
||||
nostr_secret_hex: None,
|
||||
nostr_pubkey_hex: None,
|
||||
profile: None,
|
||||
derivation_index: None,
|
||||
};
|
||||
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
@ -173,7 +177,7 @@ impl IdentityManager {
|
||||
self.set_default(&id).await?;
|
||||
}
|
||||
|
||||
// Auto-generate Nostr keypair so every identity has both key types
|
||||
// Auto-generate Nostr keypair so every identity has both key types (legacy path)
|
||||
let _ = self.create_nostr_key(&id).await;
|
||||
|
||||
// Re-read to pick up the Nostr keys
|
||||
@ -184,6 +188,72 @@ impl IdentityManager {
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Create a new identity with keys derived from a BIP-39 master seed.
|
||||
/// The derivation index is auto-incremented and persisted.
|
||||
pub async fn create_from_seed(
|
||||
&self,
|
||||
name: String,
|
||||
purpose: IdentityPurpose,
|
||||
seed: &crate::seed::MasterSeed,
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<IdentityRecord> {
|
||||
let index = crate::seed::load_identity_index(data_dir).await?;
|
||||
|
||||
let signing_key = crate::seed::derive_identity_ed25519(seed, index)?;
|
||||
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
|
||||
let did = did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let created_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Derive Nostr key from the same seed via BIP-32.
|
||||
let nostr_keys = crate::seed::derive_nostr_identity_key(seed, index)?;
|
||||
let nostr_secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let nostr_pubkey_hex = nostr_keys.public_key().to_hex();
|
||||
|
||||
let identity_file = IdentityFile {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
purpose: purpose.clone(),
|
||||
secret_key: signing_key.to_bytes().to_vec(),
|
||||
pubkey_hex: pubkey_hex.clone(),
|
||||
did: did.clone(),
|
||||
created_at: created_at.clone(),
|
||||
nostr_secret_hex: Some(nostr_secret_hex),
|
||||
nostr_pubkey_hex: Some(nostr_pubkey_hex),
|
||||
profile: None,
|
||||
derivation_index: Some(index),
|
||||
};
|
||||
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
let json = serde_json::to_string_pretty(&identity_file)
|
||||
.context("Failed to serialize identity")?;
|
||||
fs::write(&file_path, json.as_bytes())
|
||||
.await
|
||||
.context("Failed to write identity file")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to set identity file permissions")?;
|
||||
}
|
||||
|
||||
// Increment the derivation index for next identity.
|
||||
crate::seed::save_identity_index(data_dir, index + 1).await?;
|
||||
|
||||
// If first identity, make it the default.
|
||||
let (existing, _) = self.list().await?;
|
||||
if existing.len() <= 1 {
|
||||
self.set_default(&id).await?;
|
||||
}
|
||||
|
||||
let record = self.get(&id).await?;
|
||||
tracing::info!("Created seed-derived identity '{}' ({}) at index {}", name, purpose, index);
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Get a single identity by ID (without secret key).
|
||||
pub async fn get(&self, id: &str) -> Result<IdentityRecord> {
|
||||
let file_path = self.identities_dir.join(format!("{}.json", id));
|
||||
|
||||
@ -40,18 +40,15 @@ mod totp;
|
||||
mod wallet;
|
||||
mod names;
|
||||
mod network;
|
||||
pub mod seed;
|
||||
mod nostr_relays;
|
||||
mod update;
|
||||
mod vpn;
|
||||
mod webhooks;
|
||||
|
||||
use auth::AuthManager;
|
||||
use config::Config;
|
||||
use server::Server;
|
||||
|
||||
/// Default dev password when auto-creating a user (matches mock-backend).
|
||||
const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let startup_start = std::time::Instant::now();
|
||||
|
||||
@ -138,6 +138,16 @@ pub enum ManifestDescription {
|
||||
Detailed { short: String, long: String },
|
||||
}
|
||||
|
||||
impl ManifestDescription {
|
||||
/// Return the short description regardless of variant.
|
||||
#[allow(dead_code)]
|
||||
pub fn short(&self) -> &str {
|
||||
match self {
|
||||
Self::Simple(s) => s,
|
||||
Self::Detailed { short, .. } => short,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A discovered marketplace app with trust scoring.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@ -634,7 +634,7 @@ mod tests {
|
||||
let frame = build_app_start("Archipelago");
|
||||
assert_eq!(frame[3], CMD_APP_START);
|
||||
let name = &frame[4..];
|
||||
assert_eq!(std::str::from_utf8(name).ok_or_else(|| anyhow::anyhow!("invalid UTF-8 in app name"))?, "Archipelago");
|
||||
assert_eq!(std::str::from_utf8(name).map_err(|e| anyhow::anyhow!("invalid UTF-8 in app name: {}", e))?, "Archipelago");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -158,6 +158,29 @@ impl SessionManager {
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Store a ratchet session for a peer (in memory and on disk).
|
||||
#[allow(dead_code)]
|
||||
pub async fn store_session(&self, did: &str, state: RatchetState) -> Result<()> {
|
||||
self.save_session_to_disk(did, &state).await?;
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(did.to_string(), state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a ratchet session for a peer (from memory and disk).
|
||||
#[allow(dead_code)]
|
||||
pub async fn remove_session(&self, did: &str) -> Result<()> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(did);
|
||||
let path = self.session_path(did);
|
||||
if path.exists() {
|
||||
tokio::fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to remove ratchet session file")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session info for a peer (for RPC status endpoint).
|
||||
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
|
||||
let sessions = self.sessions.read().await;
|
||||
@ -205,7 +228,7 @@ mod tests {
|
||||
let mgr = SessionManager::new(dir.path());
|
||||
|
||||
let root_key = [42u8; 32];
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
let (_spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
let state = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
|
||||
let did = "did:key:z6MkTestSession";
|
||||
|
||||
@ -157,49 +157,49 @@ mod tests {
|
||||
assert!(allocs.allocations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_allocator_from_empty_dir() {
|
||||
#[tokio::test]
|
||||
async fn test_new_allocator_from_empty_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
assert!(alloc.allocations.allocations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_preferred_port_when_available() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_preferred_port_when_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port = alloc.allocate("my-app", 8500, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port = alloc.allocate("my-app", 8500, 80).await.unwrap();
|
||||
assert_eq!(port, 8500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_fallback_when_preferred_is_reserved() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_fallback_when_preferred_is_reserved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
// Port 80 is in RESERVED_PORTS, so it should allocate from the range instead
|
||||
let port = alloc.allocate("web-app", 80, 80).unwrap();
|
||||
let port = alloc.allocate("web-app", 80, 80).await.unwrap();
|
||||
assert_ne!(port, 80);
|
||||
assert!(port >= WEB_PORT_RANGE_START && port <= WEB_PORT_RANGE_END);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_fallback_when_preferred_is_taken() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_fallback_when_preferred_is_taken() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port1 = alloc.allocate("app-1", 8500, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port1 = alloc.allocate("app-1", 8500, 80).await.unwrap();
|
||||
assert_eq!(port1, 8500);
|
||||
|
||||
// Second app requesting the same preferred port gets a different one
|
||||
let port2 = alloc.allocate("app-2", 8500, 80).unwrap();
|
||||
let port2 = alloc.allocate("app-2", 8500, 80).await.unwrap();
|
||||
assert_ne!(port2, 8500);
|
||||
assert!(port2 >= WEB_PORT_RANGE_START && port2 <= WEB_PORT_RANGE_END);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_existing_allocation() {
|
||||
#[tokio::test]
|
||||
async fn test_get_returns_existing_allocation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("test-app", 8600, 3000).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("test-app", 8600, 3000).await.unwrap();
|
||||
|
||||
let result = alloc.get("test-app");
|
||||
assert!(result.is_some());
|
||||
@ -208,78 +208,78 @@ mod tests {
|
||||
assert_eq!(container, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_none_for_unknown_app() {
|
||||
#[tokio::test]
|
||||
async fn test_get_returns_none_for_unknown_app() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
assert!(alloc.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_or_get_returns_existing() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_or_get_returns_existing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port1 = alloc.allocate("my-app", 8700, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port1 = alloc.allocate("my-app", 8700, 80).await.unwrap();
|
||||
|
||||
// Calling allocate_or_get with a different preferred port should return the existing one
|
||||
let port2 = alloc.allocate_or_get("my-app", 9999, 80).unwrap();
|
||||
let port2 = alloc.allocate_or_get("my-app", 9999, 80).await.unwrap();
|
||||
assert_eq!(port1, port2);
|
||||
assert_eq!(port2, 8700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_or_get_allocates_when_new() {
|
||||
#[tokio::test]
|
||||
async fn test_allocate_or_get_allocates_when_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let port = alloc.allocate_or_get("new-app", 8800, 443).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let port = alloc.allocate_or_get("new-app", 8800, 443).await.unwrap();
|
||||
assert_eq!(port, 8800);
|
||||
|
||||
// Verify it's now stored
|
||||
assert!(alloc.get("new-app").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_removes_allocation() {
|
||||
#[tokio::test]
|
||||
async fn test_release_removes_allocation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("removable", 8900, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("removable", 8900, 80).await.unwrap();
|
||||
assert!(alloc.get("removable").is_some());
|
||||
|
||||
alloc.release("removable").unwrap();
|
||||
alloc.release("removable").await.unwrap();
|
||||
assert!(alloc.get("removable").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_released_port_becomes_available() {
|
||||
#[tokio::test]
|
||||
async fn test_released_port_becomes_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("app-a", 8500, 80).unwrap();
|
||||
alloc.release("app-a").unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("app-a", 8500, 80).await.unwrap();
|
||||
alloc.release("app-a").await.unwrap();
|
||||
|
||||
// Port 8500 should now be available again
|
||||
let port = alloc.allocate("app-b", 8500, 80).unwrap();
|
||||
let port = alloc.allocate("app-b", 8500, 80).await.unwrap();
|
||||
assert_eq!(port, 8500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_ports_are_never_allocated() {
|
||||
#[tokio::test]
|
||||
async fn test_reserved_ports_are_never_allocated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
for &port in RESERVED_PORTS {
|
||||
assert!(alloc.is_reserved(port), "Port {} should be reserved", port);
|
||||
assert!(!alloc.is_available(port), "Port {} should not be available", port);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_across_instances() {
|
||||
#[tokio::test]
|
||||
async fn test_persistence_across_instances() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let mut alloc = PortAllocator::new(dir.path()).unwrap();
|
||||
alloc.allocate("persistent-app", 8555, 80).unwrap();
|
||||
let mut alloc = PortAllocator::new(dir.path()).await.unwrap();
|
||||
alloc.allocate("persistent-app", 8555, 80).await.unwrap();
|
||||
}
|
||||
// Create a new allocator from the same directory
|
||||
let alloc2 = PortAllocator::new(dir.path()).unwrap();
|
||||
let alloc2 = PortAllocator::new(dir.path()).await.unwrap();
|
||||
let result = alloc2.get("persistent-app");
|
||||
assert!(result.is_some());
|
||||
let (host, container) = result.unwrap();
|
||||
|
||||
490
core/archipelago/src/seed.rs
Normal file
490
core/archipelago/src/seed.rs
Normal file
@ -0,0 +1,490 @@
|
||||
//! BIP-39 master seed: generation, storage, and deterministic key derivation.
|
||||
//!
|
||||
//! One 24-word mnemonic derives ALL Archipelago keys:
|
||||
//!
|
||||
//! BIP-39 Mnemonic (24 words, 256-bit entropy)
|
||||
//! → PBKDF2-HMAC-SHA512 (2048 rounds, empty passphrase)
|
||||
//! → Master Seed (64 bytes)
|
||||
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
|
||||
//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key
|
||||
//! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519
|
||||
//! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06)
|
||||
//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet
|
||||
//! └── HKDF(seed, "archipelago/lnd/entropy/v1") → LND aezeed entropy
|
||||
//!
|
||||
//! SECURITY: Never log mnemonic or seed material at any level.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const SEED_LEN: usize = 64;
|
||||
const IDENTITY_INDEX_FILE: &str = "identity_index";
|
||||
const ENCRYPTED_SEED_FILE: &str = "master_seed.enc";
|
||||
|
||||
// HKDF info strings for domain-separated key derivation.
|
||||
const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
|
||||
const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
|
||||
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
||||
|
||||
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 64-byte master seed derived from a BIP-39 mnemonic.
|
||||
/// Implements ZeroizeOnDrop to clear memory when dropped.
|
||||
#[derive(Zeroize, ZeroizeOnDrop)]
|
||||
pub struct MasterSeed {
|
||||
bytes: [u8; SEED_LEN],
|
||||
}
|
||||
|
||||
impl MasterSeed {
|
||||
/// Generate a new 24-word BIP-39 mnemonic and derive the master seed.
|
||||
pub fn generate() -> Result<(bip39::Mnemonic, Self)> {
|
||||
let mnemonic = bip39::Mnemonic::generate(24)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))?;
|
||||
let seed = Self::from_mnemonic(&mnemonic);
|
||||
Ok((mnemonic, seed))
|
||||
}
|
||||
|
||||
/// Derive master seed from an existing mnemonic (empty BIP-39 passphrase).
|
||||
pub fn from_mnemonic(mnemonic: &bip39::Mnemonic) -> Self {
|
||||
let seed_bytes = mnemonic.to_seed("");
|
||||
let mut bytes = [0u8; SEED_LEN];
|
||||
bytes.copy_from_slice(&seed_bytes);
|
||||
Self { bytes }
|
||||
}
|
||||
|
||||
/// Parse a space-separated word string, validate checksum, and derive seed.
|
||||
pub fn from_mnemonic_words(words: &str) -> Result<(bip39::Mnemonic, Self)> {
|
||||
let mnemonic: bip39::Mnemonic = words.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid mnemonic: {}", e))?;
|
||||
let word_count = mnemonic.word_count();
|
||||
if word_count != 24 {
|
||||
anyhow::bail!("Expected 24 words, got {}", word_count);
|
||||
}
|
||||
let seed = Self::from_mnemonic(&mnemonic);
|
||||
Ok((mnemonic, seed))
|
||||
}
|
||||
|
||||
/// Access raw seed bytes (for HKDF input).
|
||||
fn as_bytes(&self) -> &[u8; SEED_LEN] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ed25519 Derivation (HKDF) ─────────────────────────────────────────
|
||||
|
||||
/// Derive the node's persistent Ed25519 signing key.
|
||||
pub fn derive_node_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
|
||||
let derived = hkdf_derive_32(seed.as_bytes(), NODE_ED25519_INFO)?;
|
||||
Ok(SigningKey::from_bytes(&derived))
|
||||
}
|
||||
|
||||
/// Derive an identity's Ed25519 signing key by index.
|
||||
pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result<SigningKey> {
|
||||
let info = format!("archipelago/identity/{}/ed25519/v1", index);
|
||||
let derived = hkdf_derive_32(seed.as_bytes(), info.as_bytes())?;
|
||||
Ok(SigningKey::from_bytes(&derived))
|
||||
}
|
||||
|
||||
// ─── Secp256k1 / Nostr Derivation (BIP-32 + HKDF) ──────────────────────
|
||||
|
||||
/// Derive the node-level Nostr secp256k1 key (not per-identity).
|
||||
pub fn derive_node_nostr_key(seed: &MasterSeed) -> Result<nostr_sdk::Keys> {
|
||||
let derived = hkdf_derive_32(seed.as_bytes(), NODE_NOSTR_INFO)?;
|
||||
let secret = nostr_sdk::SecretKey::from_slice(&derived)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid secp256k1 key from HKDF: {}", e))?;
|
||||
Ok(nostr_sdk::Keys::new(secret))
|
||||
}
|
||||
|
||||
/// Derive an identity's Nostr secp256k1 key via BIP-32.
|
||||
/// Path: m/44'/1237'/0'/0/{index} (NIP-06 compliant).
|
||||
pub fn derive_nostr_identity_key(seed: &MasterSeed, index: u32) -> Result<nostr_sdk::Keys> {
|
||||
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
|
||||
use bitcoin::Network;
|
||||
|
||||
let master = Xpriv::new_master(Network::Bitcoin, seed.as_bytes())
|
||||
.context("Failed to derive BIP-32 master key")?;
|
||||
|
||||
let path = DerivationPath::from(vec![
|
||||
ChildNumber::from_hardened_idx(44).expect("valid"),
|
||||
ChildNumber::from_hardened_idx(1237).expect("valid"),
|
||||
ChildNumber::from_hardened_idx(0).expect("valid"),
|
||||
ChildNumber::from_normal_idx(0).expect("valid"),
|
||||
ChildNumber::from_normal_idx(index).expect("valid index"),
|
||||
]);
|
||||
|
||||
let secp = bitcoin::secp256k1::Secp256k1::new();
|
||||
let child = master.derive_priv(&secp, &path)
|
||||
.context("BIP-32 derivation failed")?;
|
||||
|
||||
let secret_bytes = child.private_key.secret_bytes();
|
||||
let secret = nostr_sdk::SecretKey::from_slice(&secret_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid Nostr key from BIP-32: {}", e))?;
|
||||
Ok(nostr_sdk::Keys::new(secret))
|
||||
}
|
||||
|
||||
// ─── Bitcoin / LND Derivation ───────────────────────────────────────────
|
||||
|
||||
/// Derive the BIP-84 account-level extended private key for Bitcoin Core.
|
||||
/// Path: m/84'/0'/0' (native segwit, mainnet).
|
||||
pub fn derive_bitcoin_xprv(seed: &MasterSeed) -> Result<bitcoin::bip32::Xpriv> {
|
||||
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
|
||||
use bitcoin::Network;
|
||||
|
||||
let master = Xpriv::new_master(Network::Bitcoin, seed.as_bytes())
|
||||
.context("Failed to derive BIP-32 master key")?;
|
||||
|
||||
let path = DerivationPath::from(vec![
|
||||
ChildNumber::from_hardened_idx(84).expect("valid"),
|
||||
ChildNumber::from_hardened_idx(0).expect("valid"),
|
||||
ChildNumber::from_hardened_idx(0).expect("valid"),
|
||||
]);
|
||||
|
||||
let secp = bitcoin::secp256k1::Secp256k1::new();
|
||||
master.derive_priv(&secp, &path)
|
||||
.context("BIP-84 derivation failed")
|
||||
}
|
||||
|
||||
/// Derive 16 bytes of entropy for LND aezeed wallet initialization.
|
||||
pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
|
||||
let derived = hkdf_derive(seed.as_bytes(), LND_ENTROPY_INFO, 16)?;
|
||||
let mut entropy = [0u8; 16];
|
||||
entropy.copy_from_slice(&derived);
|
||||
Ok(entropy)
|
||||
}
|
||||
|
||||
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
|
||||
|
||||
/// Encrypt and save the mnemonic words to disk (convenience backup).
|
||||
/// Uses Argon2 key derivation + ChaCha20-Poly1305 AEAD.
|
||||
pub async fn save_seed_encrypted(
|
||||
data_dir: &std::path::Path,
|
||||
mnemonic: &bip39::Mnemonic,
|
||||
passphrase: &str,
|
||||
) -> Result<()> {
|
||||
use argon2::Argon2;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use rand::RngCore;
|
||||
|
||||
let identity_dir = data_dir.join("identity");
|
||||
tokio::fs::create_dir_all(&identity_dir).await
|
||||
.context("Failed to create identity directory")?;
|
||||
|
||||
let plaintext = mnemonic.to_string();
|
||||
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
let mut nonce = [0u8; NONCE_LEN];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||
rand::rngs::OsRng.fill_bytes(&mut nonce);
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase.as_bytes(), &salt, &mut key)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
|
||||
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce),
|
||||
plaintext.as_bytes(),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
// Zeroize the plaintext and key from memory.
|
||||
key.zeroize();
|
||||
|
||||
// Format: salt || nonce || ciphertext
|
||||
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
||||
blob.extend_from_slice(&salt);
|
||||
blob.extend_from_slice(&nonce);
|
||||
blob.extend_from_slice(&ciphertext);
|
||||
|
||||
let path = identity_dir.join(ENCRYPTED_SEED_FILE);
|
||||
tokio::fs::write(&path, &blob).await
|
||||
.context("Failed to write encrypted seed")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await
|
||||
.context("Failed to set seed file permissions")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load and decrypt the mnemonic from disk.
|
||||
pub async fn load_seed_encrypted(
|
||||
data_dir: &std::path::Path,
|
||||
passphrase: &str,
|
||||
) -> Result<bip39::Mnemonic> {
|
||||
use argon2::Argon2;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
|
||||
let path = data_dir.join("identity").join(ENCRYPTED_SEED_FILE);
|
||||
let blob = tokio::fs::read(&path).await
|
||||
.context("Failed to read encrypted seed file")?;
|
||||
|
||||
if blob.len() < SALT_LEN + NONCE_LEN {
|
||||
anyhow::bail!("Encrypted seed file too short");
|
||||
}
|
||||
|
||||
let salt = &blob[..SALT_LEN];
|
||||
let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
|
||||
let ciphertext = &blob[SALT_LEN + NONCE_LEN..];
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
|
||||
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
|
||||
key.zeroize();
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ciphertext,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase"))?;
|
||||
|
||||
let words = String::from_utf8(plaintext)
|
||||
.context("Decrypted seed is not valid UTF-8")?;
|
||||
let mnemonic: bip39::Mnemonic = words.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Decrypted data is not a valid mnemonic: {}", e))?;
|
||||
|
||||
Ok(mnemonic)
|
||||
}
|
||||
|
||||
/// Check if an encrypted seed file exists.
|
||||
pub fn seed_exists(data_dir: &std::path::Path) -> bool {
|
||||
data_dir.join("identity").join(ENCRYPTED_SEED_FILE).exists()
|
||||
}
|
||||
|
||||
// ─── Identity Index Tracking ────────────────────────────────────────────
|
||||
|
||||
/// Save the next unused identity derivation index.
|
||||
pub async fn save_identity_index(data_dir: &std::path::Path, next_index: u32) -> Result<()> {
|
||||
let path = data_dir.join("identity").join(IDENTITY_INDEX_FILE);
|
||||
tokio::fs::write(&path, next_index.to_string().as_bytes()).await
|
||||
.context("Failed to write identity index")
|
||||
}
|
||||
|
||||
/// Load the next unused identity derivation index (0 if none saved).
|
||||
pub async fn load_identity_index(data_dir: &std::path::Path) -> Result<u32> {
|
||||
let path = data_dir.join("identity").join(IDENTITY_INDEX_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(s) => s.trim().parse::<u32>().context("Invalid identity index"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||
Err(e) => Err(e).context("Failed to read identity index"),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// HKDF-SHA256 derivation with no salt, returns `len` bytes.
|
||||
fn hkdf_derive(ikm: &[u8], info: &[u8], len: usize) -> Result<Vec<u8>> {
|
||||
let hk = Hkdf::<Sha256>::new(None, ikm);
|
||||
let mut okm = vec![0u8; len];
|
||||
hk.expand(info, &mut okm)
|
||||
.map_err(|_| anyhow::anyhow!("HKDF expand failed"))?;
|
||||
Ok(okm)
|
||||
}
|
||||
|
||||
/// HKDF-SHA256 derivation with no salt, returns exactly 32 bytes.
|
||||
fn hkdf_derive_32(ikm: &[u8], info: &[u8]) -> Result<[u8; 32]> {
|
||||
let bytes = hkdf_derive(ikm, info, 32)?;
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&bytes);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_node_key() {
|
||||
let (_, seed1) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let (_, seed2) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let key1 = derive_node_ed25519(&seed1).unwrap();
|
||||
let key2 = derive_node_ed25519(&seed2).unwrap();
|
||||
assert_eq!(
|
||||
key1.verifying_key().as_bytes(),
|
||||
key2.verifying_key().as_bytes(),
|
||||
"Same mnemonic must produce same node key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_identity_keys() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let key_a = derive_identity_ed25519(&seed, 0).unwrap();
|
||||
let key_b = derive_identity_ed25519(&seed, 1).unwrap();
|
||||
assert_ne!(
|
||||
key_a.verifying_key().as_bytes(),
|
||||
key_b.verifying_key().as_bytes(),
|
||||
"Different indices must produce different keys"
|
||||
);
|
||||
|
||||
// Same index is deterministic.
|
||||
let key_a2 = derive_identity_ed25519(&seed, 0).unwrap();
|
||||
assert_eq!(
|
||||
key_a.verifying_key().as_bytes(),
|
||||
key_a2.verifying_key().as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_key_differs_from_identity() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let node = derive_node_ed25519(&seed).unwrap();
|
||||
let identity = derive_identity_ed25519(&seed, 0).unwrap();
|
||||
assert_ne!(
|
||||
node.verifying_key().as_bytes(),
|
||||
identity.verifying_key().as_bytes(),
|
||||
"Node key and identity key must differ"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_nostr_keys() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let keys1 = derive_nostr_identity_key(&seed, 0).unwrap();
|
||||
let keys2 = derive_nostr_identity_key(&seed, 0).unwrap();
|
||||
assert_eq!(
|
||||
keys1.public_key().to_hex(),
|
||||
keys2.public_key().to_hex(),
|
||||
"Same mnemonic + index must produce same Nostr key"
|
||||
);
|
||||
|
||||
let keys3 = derive_nostr_identity_key(&seed, 1).unwrap();
|
||||
assert_ne!(
|
||||
keys1.public_key().to_hex(),
|
||||
keys3.public_key().to_hex(),
|
||||
"Different indices must produce different Nostr keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_nostr_key() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let keys1 = derive_node_nostr_key(&seed).unwrap();
|
||||
let keys2 = derive_node_nostr_key(&seed).unwrap();
|
||||
assert_eq!(keys1.public_key().to_hex(), keys2.public_key().to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitcoin_xprv_deterministic() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let xprv1 = derive_bitcoin_xprv(&seed).unwrap();
|
||||
let xprv2 = derive_bitcoin_xprv(&seed).unwrap();
|
||||
assert_eq!(xprv1, xprv2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lnd_entropy_deterministic() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let e1 = derive_lnd_entropy(&seed).unwrap();
|
||||
let e2 = derive_lnd_entropy(&seed).unwrap();
|
||||
assert_eq!(e1, e2);
|
||||
assert_eq!(e1.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_produces_24_words() {
|
||||
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
||||
assert_eq!(mnemonic.word_count(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_mnemonic_rejected() {
|
||||
let result = MasterSeed::from_mnemonic_words("not a valid mnemonic");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_word_count_rejected() {
|
||||
// 12 words (valid BIP-39 but we require 24)
|
||||
let result = MasterSeed::from_mnemonic_words(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_encrypted_storage_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
||||
let words = mnemonic.to_string();
|
||||
|
||||
save_seed_encrypted(dir.path(), &mnemonic, "test-passphrase").await.unwrap();
|
||||
assert!(seed_exists(dir.path()));
|
||||
|
||||
let restored = load_seed_encrypted(dir.path(), "test-passphrase").await.unwrap();
|
||||
assert_eq!(restored.to_string(), words);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_encrypted_storage_wrong_passphrase() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
||||
|
||||
save_seed_encrypted(dir.path(), &mnemonic, "correct").await.unwrap();
|
||||
let result = load_seed_encrypted(dir.path(), "wrong").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_identity_index_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Create identity subdirectory (required by the path).
|
||||
tokio::fs::create_dir_all(dir.path().join("identity")).await.unwrap();
|
||||
|
||||
assert_eq!(load_identity_index(dir.path()).await.unwrap(), 0);
|
||||
save_identity_index(dir.path(), 5).await.unwrap();
|
||||
assert_eq!(load_identity_index(dir.path()).await.unwrap(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_derivation_from_known_mnemonic() {
|
||||
// Verify all derivation paths produce valid, distinct keys from a known mnemonic.
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
|
||||
let node_ed = derive_node_ed25519(&seed).unwrap();
|
||||
let node_nostr = derive_node_nostr_key(&seed).unwrap();
|
||||
let id0_ed = derive_identity_ed25519(&seed, 0).unwrap();
|
||||
let id0_nostr = derive_nostr_identity_key(&seed, 0).unwrap();
|
||||
let _btc = derive_bitcoin_xprv(&seed).unwrap();
|
||||
let lnd = derive_lnd_entropy(&seed).unwrap();
|
||||
|
||||
// All keys should be distinct (comparing hex representations).
|
||||
let node_ed_hex = hex::encode(node_ed.verifying_key().as_bytes());
|
||||
let id0_ed_hex = hex::encode(id0_ed.verifying_key().as_bytes());
|
||||
let node_nostr_hex = node_nostr.public_key().to_hex();
|
||||
let id0_nostr_hex = id0_nostr.public_key().to_hex();
|
||||
let lnd_hex = hex::encode(lnd);
|
||||
|
||||
let all = [&node_ed_hex, &id0_ed_hex, &node_nostr_hex, &id0_nostr_hex, &lnd_hex];
|
||||
for (i, a) in all.iter().enumerate() {
|
||||
for (j, b) in all.iter().enumerate() {
|
||||
if i != j {
|
||||
assert_ne!(a, b, "Keys at positions {} and {} should differ", i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -436,7 +436,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_create_and_validate() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
assert!(store.validate(&token).await);
|
||||
@ -444,13 +444,13 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_invalid_token() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
assert!(!store.validate("nonexistent_token").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_remove() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
assert!(store.validate(&token).await);
|
||||
@ -460,7 +460,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_session_upgrade() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let secret = vec![1, 2, 3, 4];
|
||||
let token = store.create_pending(secret.clone()).await;
|
||||
|
||||
@ -484,7 +484,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_session_max_attempts() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let secret = vec![1, 2, 3];
|
||||
let token = store.create_pending(secret).await;
|
||||
|
||||
@ -513,7 +513,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_activity_updates_on_validate() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
// First validation should succeed and touch last_activity
|
||||
@ -525,7 +525,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalidate_all_except() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token1 = store.create().await;
|
||||
let token2 = store.create().await;
|
||||
let token3 = store.create().await;
|
||||
@ -540,7 +540,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_rotate() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let old_token = store.create().await;
|
||||
|
||||
assert!(store.validate(&old_token).await);
|
||||
@ -555,7 +555,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_max_concurrent_sessions() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let mut tokens = Vec::new();
|
||||
|
||||
// Create MAX_CONCURRENT_SESSIONS sessions
|
||||
@ -583,7 +583,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_session_count() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
assert_eq!(store.active_session_count().await, 0);
|
||||
|
||||
let token1 = store.create().await;
|
||||
@ -598,7 +598,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_expired_removes_stale() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
|
||||
assert!(store.validate(&token).await);
|
||||
@ -611,7 +611,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_preserves_session_count() {
|
||||
let store = SessionStore::new();
|
||||
let store = SessionStore::new().await;
|
||||
let token = store.create().await;
|
||||
assert_eq!(store.active_session_count().await, 1);
|
||||
|
||||
|
||||
@ -326,7 +326,7 @@ mod tests {
|
||||
let chunks = encode_chunked(&data).unwrap();
|
||||
|
||||
let data_chunks: Vec<_> = chunks.iter().filter(|c| !c.is_parity).collect();
|
||||
let parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect();
|
||||
let _parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect();
|
||||
assert_eq!(data_chunks.len(), 4); // ceil(500/124) = 5... wait
|
||||
// Actually: ceil(500/124) = ceil(4.03) = 5 data shards
|
||||
// But the first shard has 4 bytes of length header embedded, so
|
||||
|
||||
@ -53,6 +53,42 @@ pub async fn load_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Save profits summary to disk.
|
||||
#[allow(dead_code)]
|
||||
pub async fn save_profits(data_dir: &Path, summary: &ProfitsSummary) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet directory")?;
|
||||
let path = data_dir.join(PROFITS_FILE);
|
||||
let content = serde_json::to_string_pretty(summary)
|
||||
.context("Failed to serialize profits")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write profits file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a single content sale, updating totals and the recent entries list.
|
||||
#[allow(dead_code)]
|
||||
pub async fn record_content_sale(data_dir: &Path, amount_sats: u64, description: &str) -> Result<()> {
|
||||
let mut summary = load_profits(data_dir).await?;
|
||||
let entry = ProfitEntry {
|
||||
source: ProfitSource::ContentSale,
|
||||
amount_sats,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
description: description.to_string(),
|
||||
};
|
||||
summary.recent.insert(0, entry);
|
||||
if summary.recent.len() > 100 {
|
||||
summary.recent.truncate(100);
|
||||
}
|
||||
summary.content_sales_sats += amount_sats;
|
||||
summary.total_sats = summary.content_sales_sats + summary.routing_fees_sats;
|
||||
save_profits(data_dir, &summary).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute a full profits summary including ecash receive transactions.
|
||||
pub async fn get_networking_profits(data_dir: &Path) -> Result<ProfitsSummary> {
|
||||
let mut summary = load_profits(data_dir).await?;
|
||||
|
||||
@ -259,7 +259,7 @@ mod restart_tracker {
|
||||
// ── Failsafe Install ──────────────────────────────────────────────────
|
||||
|
||||
mod failsafe_install {
|
||||
use crate::mock_podman::{MockPodman, MockContainerState};
|
||||
use crate::mock_podman::MockPodman;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[test]
|
||||
@ -302,7 +302,6 @@ mod failsafe_install {
|
||||
// ── Health Monitor Logic ──────────────────────────────────────────────
|
||||
|
||||
mod health_monitor_logic {
|
||||
use crate::mock_podman::{MockPodman, MockContainerState};
|
||||
|
||||
/// Mirrors the tier ordering from health_monitor.rs
|
||||
fn container_tier(name: &str) -> u8 {
|
||||
|
||||
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -136,6 +136,7 @@ export const useSyncStore = defineStore('sync', () => {
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
'seed-backed': false,
|
||||
},
|
||||
'package-data': {},
|
||||
ui: {
|
||||
|
||||
@ -29,6 +29,7 @@ export interface ServerInfo {
|
||||
unread: number
|
||||
'wifi-ssids': string[]
|
||||
'zram-enabled': boolean
|
||||
'seed-backed': boolean
|
||||
}
|
||||
|
||||
export interface StatusInfo {
|
||||
|
||||
@ -115,13 +115,13 @@
|
||||
<span class="discover-terminal-tag text-orange-400/80">manifesto</span>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<blockquote class="text-white/60 text-sm leading-relaxed italic max-w-3xl">
|
||||
<blockquote class="text-white/80 text-xl leading-relaxed italic max-w-3xl">
|
||||
"Privacy is not about having something to hide. Privacy is about having the right to choose
|
||||
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."
|
||||
</blockquote>
|
||||
<p class="text-white/30 text-xs mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
||||
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
||||
</div>
|
||||
|
||||
<FilterModal
|
||||
|
||||
@ -119,7 +119,7 @@ async function createIdentity() {
|
||||
}
|
||||
})
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/backup').catch(() => {})
|
||||
router.push('/onboarding/done').catch(() => {})
|
||||
} catch (err) {
|
||||
if (isServerStartingError(err)) {
|
||||
serverStarting.value = true
|
||||
|
||||
@ -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
|
||||
</a>
|
||||
|
||||
<!-- Restore Panel -->
|
||||
<div v-if="showRestore" class="mt-6 glass-card px-6 py-6 text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Restore Identity from Backup</h3>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="block w-full text-sm text-white/60 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white/80 hover:file:bg-white/20 mb-3"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
<input
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
placeholder="Backup passphrase"
|
||||
class="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm focus:outline-none focus:border-white/40 mb-3"
|
||||
/>
|
||||
<p v-if="restoreError" class="text-red-400 text-xs mb-2">{{ restoreError }}</p>
|
||||
<p v-if="restoreSuccess" class="text-green-400 text-xs mb-2">Identity restored successfully!</p>
|
||||
<div class="flex gap-3">
|
||||
<button class="glass-button text-sm px-4 py-2" @click="showRestore = false">Cancel</button>
|
||||
<button
|
||||
class="glass-button text-sm px-4 py-2"
|
||||
:disabled="!restoreFile || !passphrase || restoreLoading"
|
||||
@click="performRestore"
|
||||
>
|
||||
{{ restoreLoading ? 'Restoring...' : 'Restore' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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<Record<string, unknown> | 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(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -33,8 +33,12 @@
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Restore Backup (Coming Soon) -->
|
||||
<div class="path-option-card text-center opacity-40 cursor-not-allowed">
|
||||
<!-- Restore from Seed -->
|
||||
<button
|
||||
@click="selectOption('restore')"
|
||||
class="path-option-card text-center"
|
||||
:class="{ 'path-option-card--selected': selected === 'restore' }"
|
||||
>
|
||||
<div class="mb-3 sm:mb-4">
|
||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -42,12 +46,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore Backup</h3>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore from Seed</h3>
|
||||
<p class="text-white/70 text-xs sm:text-sm">
|
||||
Restore from a previous backup
|
||||
Enter your 24-word recovery phrase
|
||||
</p>
|
||||
<span class="text-xs text-white/50 mt-1 block">(Coming Soon)</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Connect Existing (Coming Soon) -->
|
||||
<div class="path-option-card text-center opacity-40 cursor-not-allowed">
|
||||
@ -81,7 +84,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
@ -96,12 +98,11 @@ function selectOption(option: string) {
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
try {
|
||||
await completeOnboarding()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('completeOnboarding failed, localStorage fallback ensures onboarding is marked complete', e)
|
||||
}
|
||||
playNavSound('action')
|
||||
router.push('/login').catch(() => {})
|
||||
if (selected.value === 'restore') {
|
||||
router.push('/onboarding/seed-restore').catch(() => {})
|
||||
} else {
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -109,6 +109,6 @@ onMounted(() => {
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/did').catch(() => {})
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
164
neode-ui/src/views/OnboardingSeedGenerate.vue
Normal file
164
neode-ui/src/views/OnboardingSeedGenerate.vue
Normal file
@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Your Recovery Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Write down these 24 words in order. They are the only way to recover your node.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
|
||||
<p class="text-lg text-white/80">Server starting up</p>
|
||||
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
|
||||
</div>
|
||||
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1-3 minutes after first boot</p>
|
||||
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Word Grid -->
|
||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 gap-1.5 sm:gap-2">
|
||||
<div
|
||||
v-for="(word, i) in words"
|
||||
:key="i"
|
||||
class="bg-black/60 rounded-lg px-3 py-1.5 sm:py-2 border border-white/10"
|
||||
>
|
||||
<span class="text-white/40 text-[1rem] font-mono mr-1.5">{{ i + 1 }}.</span>
|
||||
<span class="text-white/95 text-[1.2rem] font-mono">{{ word }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
|
||||
<p class="text-xs sm:text-sm text-orange-300/90">
|
||||
Never share these words. Anyone with them controls your node, identities, and Bitcoin wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Checkbox -->
|
||||
<label class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<input
|
||||
v-model="confirmed"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400"
|
||||
/>
|
||||
<span class="text-xs sm:text-sm text-white/80">I have written down these 24 words in a safe place</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div v-if="words.length > 0" class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
:disabled="!confirmed"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
const confirmed = ref(false)
|
||||
const loading = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
||||
}
|
||||
|
||||
async function generateSeed() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate' })
|
||||
stopTimers()
|
||||
words.value = res.words
|
||||
loading.value = false
|
||||
waitingForServer.value = false
|
||||
} catch {
|
||||
loading.value = false
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
}
|
||||
retryTimer = setTimeout(generateSeed, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
watch(confirmed, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => { generateSeed() })
|
||||
onUnmounted(() => { stopTimers() })
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
sessionStorage.setItem('_seed_words', JSON.stringify(words.value))
|
||||
router.push('/onboarding/seed-verify').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-lock-spin {
|
||||
animation: onb-lock-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-lock-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
183
neode-ui/src/views/OnboardingSeedRestore.vue
Normal file
183
neode-ui/src/views/OnboardingSeedRestore.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Restore from Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Enter your 24-word recovery seed to restore your node identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
|
||||
<!-- Restore Success -->
|
||||
<div v-if="restored" class="w-full max-w-[600px]">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Identity restored successfully
|
||||
</p>
|
||||
</div>
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ restoredDid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word Input Grid -->
|
||||
<div v-if="!restored" class="w-full max-w-[600px]">
|
||||
<div class="path-option-card cursor-default px-3 py-3 sm:px-5 sm:py-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5 sm:gap-2">
|
||||
<div v-for="i in 24" :key="i" class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30 text-[1rem] font-mono pointer-events-none">{{ i }}.</span>
|
||||
<input
|
||||
:ref="el => { if (el) wordInputs[i - 1] = el as HTMLInputElement }"
|
||||
v-model="seedWords[i - 1]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-[1.2rem] text-white/95 font-mono placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all"
|
||||
:placeholder="`word ${i}`"
|
||||
@keydown.enter="i < 24 ? wordInputs[i]?.focus() : restore()"
|
||||
@input="onWordInput(i - 1)"
|
||||
@paste="i === 1 ? onPaste($event) : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2 text-center">
|
||||
Paste all 24 words into the first field to auto-fill
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
|
||||
<button
|
||||
v-if="!restored"
|
||||
@click="restore"
|
||||
:disabled="isRestoring || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isRestoring" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Restoring...
|
||||
</span>
|
||||
<span v-else>Restore Identity</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const wordInputs = ref<HTMLInputElement[]>([])
|
||||
const seedWords = ref<string[]>(Array(24).fill(''))
|
||||
const restored = ref(false)
|
||||
const isRestoring = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
const restoredDid = ref('')
|
||||
|
||||
const allFilled = computed(() => seedWords.value.every(w => w.trim().length > 0))
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => wordInputs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function onWordInput(index: number) {
|
||||
seedWords.value[index] = (seedWords.value[index] ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text')?.trim()
|
||||
if (!text) return
|
||||
|
||||
const pastedWords = text.split(/\s+/)
|
||||
if (pastedWords.length === 24) {
|
||||
event.preventDefault()
|
||||
for (let i = 0; i < 24; i++) {
|
||||
seedWords.value[i] = (pastedWords[i] ?? '').toLowerCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!allFilled.value) return
|
||||
isRestoring.value = true
|
||||
errorMessage.value = ''
|
||||
serverStarting.value = false
|
||||
|
||||
try {
|
||||
const words = seedWords.value.map(w => w.trim().toLowerCase())
|
||||
const res = await rpcClient.call<{ did: string; nostr_npub: string; restored: boolean }>({
|
||||
method: 'seed.restore',
|
||||
params: { words },
|
||||
})
|
||||
|
||||
if (res.restored) {
|
||||
restored.value = true
|
||||
restoredDid.value = res.did
|
||||
if (res.did) localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Restore failed. Check your seed words and try again.'
|
||||
}
|
||||
} finally {
|
||||
isRestoring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
254
neode-ui/src/views/OnboardingSeedVerify.vue
Normal file
254
neode-ui/src/views/OnboardingSeedVerify.vue
Normal file
@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
|
||||
<!-- Header (hidden after verification) -->
|
||||
<div v-if="!verified" class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Verify Your Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Confirm you wrote down your seed correctly by entering the requested words.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0 px-6 sm:px-8 py-4">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Verification Success -->
|
||||
<div v-if="verified" class="w-full max-w-[600px] pt-2">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Seed verified successfully
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DID -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5 mb-3">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your DID</h3>
|
||||
<button @click="copyText(did)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'did' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ did }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Web5, federation, and verifiable credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr npub -->
|
||||
<div v-if="nostrNpub" class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<button @click="copyText(nostrNpub)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'npub' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ nostrNpub }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Word Input Fields -->
|
||||
<div v-if="!verified" class="w-full max-w-[600px] space-y-2 sm:space-y-3">
|
||||
<div
|
||||
v-for="(idx, i) in challengeIndices"
|
||||
:key="idx"
|
||||
class="path-option-card cursor-default px-3 py-3 sm:px-5 sm:py-4"
|
||||
>
|
||||
<label class="block text-xs font-semibold text-white/80 mb-1.5 sm:mb-2 uppercase tracking-wide">
|
||||
Word #{{ idx + 1 }}
|
||||
</label>
|
||||
<input
|
||||
:ref="el => { if (el) inputRefs[i] = el as HTMLInputElement }"
|
||||
v-model="answers[i]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:placeholder="`Enter word #${idx + 1}`"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white/95 placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all font-mono text-[1.2rem]"
|
||||
@keydown.enter.prevent="i < challengeIndices.length - 1 ? inputRefs[i + 1]?.focus() : verify()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
|
||||
<span
|
||||
v-if="!verified"
|
||||
@click="goBack"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<button
|
||||
v-if="!verified"
|
||||
@click="verify"
|
||||
type="button"
|
||||
:disabled="isVerifying || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isVerifying">Verifying...</span>
|
||||
<span v-else>Verify</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="verified"
|
||||
@click="downloadIdentity"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</span>
|
||||
<button
|
||||
v-if="verified"
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const inputRefs = ref<HTMLInputElement[]>([])
|
||||
const words = ref<string[]>([])
|
||||
const challengeIndices = ref<number[]>([])
|
||||
const answers = ref<string[]>(['', '', '', ''])
|
||||
const verified = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const did = ref('')
|
||||
const nostrNpub = ref('')
|
||||
const copiedField = ref('')
|
||||
|
||||
const allFilled = computed(() => answers.value.every(a => a.trim().length > 0))
|
||||
|
||||
function pickRandomIndices(count: number, max: number): number[] {
|
||||
const indices = new Set<number>()
|
||||
while (indices.size < count) {
|
||||
indices.add(Math.floor(Math.random() * max))
|
||||
}
|
||||
return Array.from(indices).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const stored = sessionStorage.getItem('_seed_words')
|
||||
if (!stored) {
|
||||
router.replace('/onboarding/seed').catch(() => {})
|
||||
return
|
||||
}
|
||||
words.value = JSON.parse(stored)
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => inputRefs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
copiedField.value = text === did.value ? 'did' : 'npub'
|
||||
setTimeout(() => { copiedField.value = '' }, 2000)
|
||||
}
|
||||
|
||||
function downloadIdentity() {
|
||||
const data = {
|
||||
did: did.value,
|
||||
nostr_npub: nostrNpub.value || undefined,
|
||||
created: new Date().toISOString(),
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago-identity.json'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url) }, 1000)
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (!allFilled.value) return
|
||||
isVerifying.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const correct = challengeIndices.value.every(
|
||||
(wordIdx, i) => (answers.value[i] ?? '').trim().toLowerCase() === (words.value[wordIdx] ?? '')
|
||||
)
|
||||
|
||||
if (!correct) {
|
||||
isVerifying.value = false
|
||||
errorMessage.value = 'One or more words are incorrect. Please check your written seed and try again.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{ verified: boolean; did: string; nostr_npub: string }>({
|
||||
method: 'seed.verify',
|
||||
params: { words: words.value, indices: challengeIndices.value },
|
||||
})
|
||||
|
||||
if (res.verified) {
|
||||
verified.value = true
|
||||
did.value = res.did
|
||||
nostrNpub.value = res.nostr_npub || ''
|
||||
localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
sessionStorage.removeItem('_seed_words')
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
} else {
|
||||
errorMessage.value = 'Verification failed. Please try again.'
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Verification failed'
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@ -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<string, string> = {
|
||||
'/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',
|
||||
|
||||
@ -3,32 +3,37 @@
|
||||
<!-- Hero Section -->
|
||||
<div class="discover-hero glass-card p-8 md:p-12 mb-8 relative overflow-hidden">
|
||||
<div class="discover-hero-scanline" aria-hidden="true"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag">~ $</span>
|
||||
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span>
|
||||
<div class="discover-hero-layout relative z-10">
|
||||
<div class="discover-hero-content">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag">~ $</span>
|
||||
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago">
|
||||
Reclaim Your<br />
|
||||
<span class="discover-hero-accent">Digital Sovereignty</span>
|
||||
</h1>
|
||||
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
|
||||
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
|
||||
No cloud. No custodians. No permission needed.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ totalApps }}</span>
|
||||
<span class="text-white/50">apps available</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ installedCount }}</span>
|
||||
<span class="text-white/50">installed</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">100%</span>
|
||||
<span class="text-white/50">self-hosted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago">
|
||||
Reclaim Your<br />
|
||||
<span class="discover-hero-accent">Digital Sovereignty</span>
|
||||
</h1>
|
||||
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
|
||||
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
|
||||
No cloud. No custodians. No permission needed.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ totalApps }}</span>
|
||||
<span class="text-white/50">apps available</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ installedCount }}</span>
|
||||
<span class="text-white/50">installed</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">100%</span>
|
||||
<span class="text-white/50">self-hosted</span>
|
||||
</div>
|
||||
<div class="discover-hero-face">
|
||||
<BitcoinFaceAscii />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -39,35 +44,37 @@
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">Privacy First</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">Privacy First</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">Verify, Don't Trust</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">Verify, Don't Trust</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">Open Source</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">Open Source</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">No Permission Needed</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">No Permission Needed</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BitcoinFaceAscii from './BitcoinFaceAscii.vue'
|
||||
|
||||
defineProps<{
|
||||
totalApps: number
|
||||
installedCount: number
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<svg class="w-4 h-4 text-orange-400/70" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-white/40 text-xs font-mono">{{ app.privacyTag }}</span>
|
||||
<span class="text-white/60 text-sm font-mono">{{ app.privacyTag }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
|
||||
|
||||
@ -114,9 +114,9 @@ init()
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Server Name Card (editable) -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Server Name Card (editable) — container: Enter to edit, Enter to save, Escape to exit -->
|
||||
<div data-controller-container tabindex="0" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
|
||||
@ -50,7 +50,7 @@ checkClaudeStatus()
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4">
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4" data-controller-ignore>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@ -42,7 +42,7 @@ async function changeLocale(code: string) {
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="m in interfaceModes"
|
||||
:key="m.id"
|
||||
|
||||
@ -48,7 +48,7 @@ async function performFactoryReset() {
|
||||
|
||||
<template>
|
||||
<!-- Network Diagnostics Link -->
|
||||
<div data-controller-container tabindex="0" class="glass-card px-6 py-6 mb-6">
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
|
||||
@ -64,7 +64,7 @@ async function performFactoryReset() {
|
||||
</div>
|
||||
|
||||
<!-- Reboot Section -->
|
||||
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6">
|
||||
<div class="path-option-card px-6 py-6 mt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2>
|
||||
@ -109,7 +109,7 @@ async function performFactoryReset() {
|
||||
</Teleport>
|
||||
|
||||
<!-- Factory Reset Section -->
|
||||
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6 border-red-500/30">
|
||||
<div class="path-option-card px-6 py-6 mt-6 border-red-500/30">
|
||||
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
|
||||
|
||||
@ -128,7 +128,7 @@ loadTotpStatus()
|
||||
|
||||
<template>
|
||||
<!-- Two-Factor Authentication -->
|
||||
<div data-controller-container tabindex="0" class="mb-6">
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user