diff --git a/core/Cargo.lock b/core/Cargo.lock index cb76691c..13a6a90a 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -45,8 +45,11 @@ dependencies = [ "archipelago-parmanode", "archipelago-performance", "archipelago-security", + "argon2", + "base64 0.21.7", "bcrypt", "bs58", + "chacha20poly1305", "chrono", "ed25519-dalek", "futures-util", @@ -134,6 +137,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -277,6 +292,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 5c8b3cb9..7676788d 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -60,5 +60,10 @@ reqwest = { version = "0.11", features = ["json", "socks"] } # Nostr (node discovery) nostr-sdk = "0.44" +# Backup encryption (DID identity export) +argon2 = "0.5" +chacha20poly1305 = "0.10" +base64 = "0.21" + [dev-dependencies] tokio-test = "0.4" diff --git a/core/archipelago/src/api/rpc.rs b/core/archipelago/src/api/rpc.rs index e6513f64..db48999a 100644 --- a/core/archipelago/src/api/rpc.rs +++ b/core/archipelago/src/api/rpc.rs @@ -1,4 +1,5 @@ use crate::auth::AuthManager; +use crate::backup; use crate::config::Config; use crate::container::docker_packages; use crate::container::DevContainerOrchestrator; @@ -88,6 +89,7 @@ impl RpcHandler { "auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await, "auth.onboardingComplete" => self.handle_auth_onboarding_complete().await, "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, + "auth.resetOnboarding" => self.handle_auth_reset_onboarding().await, // Container orchestration (for Archipelago-managed containers) "container-install" => self.handle_container_install(rpc_req.params).await, @@ -119,6 +121,8 @@ impl RpcHandler { "node-messages-received" => self.handle_node_messages_received().await, "node-nostr-discover" => self.handle_node_nostr_discover().await, "node.did" => self.handle_node_did().await, + "node.signChallenge" => self.handle_node_sign_challenge(rpc_req.params).await, + "node.createBackup" => self.handle_node_create_backup(rpc_req.params).await, "node.tor-address" => self.handle_node_tor_address().await, "node.nostr-publish" => self.handle_node_nostr_publish().await, "node.nostr-pubkey" => self.handle_node_nostr_pubkey().await, @@ -239,12 +243,61 @@ impl RpcHandler { Ok(serde_json::json!(complete)) } + async fn handle_auth_reset_onboarding(&self) -> Result { + self.auth_manager.reset_onboarding().await?; + Ok(serde_json::json!(true)) + } + async fn handle_node_did(&self) -> Result { let (data, _) = self.state_manager.get_snapshot().await; let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey })) } + /// Sign a challenge to prove control of the node DID (proof-of-control for onboarding). + async fn handle_node_sign_challenge( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let challenge = params + .get("challenge") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing challenge string"))?; + + let identity_dir = self.config.data_dir.join("identity"); + let identity = identity::NodeIdentity::load_or_create(&identity_dir).await?; + let signature = identity.sign(challenge.as_bytes()); + + Ok(serde_json::json!({ "signature": signature })) + } + + /// Create an encrypted backup of the node identity (for onboarding). + async fn handle_node_create_backup( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let passphrase = params + .get("passphrase") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?; + + let (data, _) = self.state_manager.get_snapshot().await; + let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let identity_dir = self.config.data_dir.join("identity"); + + let backup = backup::create_encrypted_backup( + &identity_dir, + passphrase, + &did, + &data.server_info.pubkey, + ) + .await?; + + Ok(backup) + } + async fn handle_node_tor_address(&self) -> Result { let tor_address = docker_packages::read_tor_address("archipelago"); Ok(serde_json::json!({ "tor_address": tor_address })) diff --git a/core/archipelago/src/auth.rs b/core/archipelago/src/auth.rs index 96bd2850..20b6cadf 100644 --- a/core/archipelago/src/auth.rs +++ b/core/archipelago/src/auth.rs @@ -86,6 +86,24 @@ impl AuthManager { Ok(()) } + /// Reset onboarding state so the user can go through onboarding again (dev/testing). + pub async fn reset_onboarding(&self) -> Result<()> { + let onboarding_file = self.data_dir.join("onboarding.json"); + let state = OnboardingState { complete: false }; + fs::write( + &onboarding_file, + serde_json::to_string_pretty(&state)?, + ) + .await?; + if let Some(mut user) = self.get_user().await? { + user.onboarding_complete = false; + let user_file = self.data_dir.join("user.json"); + let content = serde_json::to_string_pretty(&user)?; + fs::write(&user_file, content).await?; + } + Ok(()) + } + pub async fn is_onboarding_complete(&self) -> Result { // Check onboarding.json first (persisted before user setup) let onboarding_file = self.data_dir.join("onboarding.json"); diff --git a/core/archipelago/src/backup.rs b/core/archipelago/src/backup.rs new file mode 100644 index 00000000..d3f28ec8 --- /dev/null +++ b/core/archipelago/src/backup.rs @@ -0,0 +1,68 @@ +//! Encrypted DID identity backup for onboarding. +//! Uses Argon2 for key derivation and ChaCha20-Poly1305 for encryption. + +use anyhow::{Context, Result}; +use argon2::Argon2; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chacha20poly1305::aead::{Aead, KeyInit}; +use rand::RngCore; +use std::path::Path; +use tokio::fs; + +const BACKUP_VERSION: u32 = 1; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; + +/// Create an encrypted backup of the node identity key. +/// Returns JSON-serializable backup metadata + encrypted blob (base64). +pub async fn create_encrypted_backup( + identity_dir: &Path, + passphrase: &str, + did: &str, + pubkey_hex: &str, +) -> Result { + let key_path = identity_dir.join("node_key"); + let key_bytes = fs::read(&key_path) + .await + .context("Failed to read node key")?; + if key_bytes.len() != 32 { + anyhow::bail!("Invalid node key length"); + } + + 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 argon2 = Argon2::default(); + let mut key = [0u8; KEY_LEN]; + argon2 + .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), + key_bytes.as_ref(), + ) + .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; + + 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 blob_b64 = BASE64.encode(&blob); + + Ok(serde_json::json!({ + "version": BACKUP_VERSION, + "did": did, + "pubkey": pubkey_hex, + "kid": format!("{}#key-1", did), + "encrypted": true, + "blob": blob_b64, + "timestamp": chrono::Utc::now().to_rfc3339(), + })) +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index df2420a6..64370d15 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -7,6 +7,7 @@ use tracing::info; mod api; mod auth; +mod backup; mod config; mod electrs_status; mod container; diff --git a/docs/DID_ONBOARDING_ASSESSMENT.md b/docs/DID_ONBOARDING_ASSESSMENT.md new file mode 100644 index 00000000..9e555ac4 --- /dev/null +++ b/docs/DID_ONBOARDING_ASSESSMENT.md @@ -0,0 +1,189 @@ +# DID Onboarding Flow: Assessment & Implementation Plan + +## Executive Summary + +The current onboarding DID flow is **partially implemented** and has several significant gaps compared to the W3C DID protocol and Web5 expectations. The core `did:key` format is **correct**, but the user-facing flow includes mock/fake behavior and the backup/verify steps don't actually use the DID infrastructure. + +--- + +## What We Have (Current State) + +### ✅ Correct Implementation + +| Component | Status | Notes | +|-----------|--------|-------| +| **did:key format** | ✅ Correct | Ed25519 multicodec `0xed 0x01`, base58btc encoding, `z` prefix | +| **Key generation** | ✅ Correct | Ed25519 via `ed25519_dalek`, persisted at `/var/lib/archipelago/identity/` | +| **node.did RPC** | ✅ Correct | Returns `{ did, pubkey }` from server state | +| **Identity persistence** | ✅ Correct | Key survives reboots, 0o600 permissions on Unix | +| **Sign/verify primitives** | ✅ Present | `NodeIdentity::sign()`, `NodeIdentity::verify()` exist in Rust | + +### ⚠️ Partial / Misleading Implementation + +| Component | Status | Issue | +|-----------|--------|-------| +| **OnboardingDid.vue** | ⚠️ Misleading copy | Says "Generate DID" but we *fetch* from server; key is created at first boot, not during onboarding | +| **OnboardingVerify.vue** | ❌ Fake | Uses `generateMockSignature()` – random chars, no backend call. Doesn't prove DID control | +| **OnboardingBackup.vue** | ❌ Non-functional | Backup is mock JSON with `{ did, kid }`; no encrypted key material; **restore is impossible** | +| **kid usage** | ⚠️ Non-standard | We store `pubkey` as `kid`; proper did:key uses fragment like `#key-1` or `did:key:z...#key-1` | + +### ❌ Missing + +| Component | Status | +|-----------|--------| +| **node.sign RPC** | Not exposed – backend can sign but no API | +| **Challenge-sign flow** | No backend support for proof-of-control | +| **Encrypted backup** | No real backup with key material or recovery path | +| **DID Document endpoint** | Not exposed (optional for did:key – can be derived client-side) | +| **keyAgreement / X25519** | Not derived – full DID Document would need Ed25519→X25519 for encryption | + +--- + +## DID Protocol Requirements (W3C / Web5) + +### did:key Method (W3C CCG) + +1. **Format**: `did:key:z` ✅ We do this +2. **DID Document**: Can be derived from the DID string; no registry. Libraries like `@digitalcredentials/did-method-key` expand it. +3. **Verification methods**: `verificationMethod`, `authentication`, `assertionMethod`, `keyAgreement` (X25519 derived), `capabilityDelegation`, `capabilityInvocation` +4. **Key ID (kid)**: Typically `{did}#key-1` or similar fragment + +### Proof of Control + +To prove control of a DID, you must **sign a challenge** with the private key. The verifier checks the signature against the public key in the DID. Our OnboardingVerify step claims to do this but **does not**. + +### Backup / Recovery + +A proper identity backup for recovery would: + +- Include the private key (or encrypted key material) +- Be encrypted with a user passphrase +- Allow restore on a new device + +Our backup has none of this – it's display-only. + +--- + +## Recommended Implementation Plan + +### Phase 1: Fix Verify Step (Proof of Control) + +**Goal**: Replace the fake "Sign Challenge" with a real cryptographic proof. + +1. **Backend**: Add `node.signChallenge` RPC + - Input: `{ challenge: string }` (nonce from frontend) + - Output: `{ signature: string }` (hex-encoded Ed25519 signature) + - Uses `NodeIdentity::sign()` with `challenge.as_bytes()` + +2. **Frontend (OnboardingVerify.vue)**: + - Generate a random nonce (e.g. 32 bytes, base64) + - Call `node.signChallenge({ challenge })` + - Verify signature locally using the pubkey from `node.did` (optional – or trust server) + - Display the real signature; remove `generateMockSignature()` + +**Effort**: ~2–4 hours + +--- + +### Phase 2: Improve UX and Terminology + +**Goal**: Align copy and flow with actual behavior. + +1. **OnboardingDid.vue**: + - Change "Generate DID" → "Get your node's identity" or "Retrieve DID" + - Clarify that the DID is created when the node first starts (not on button click) + - Optionally auto-fetch on mount if identity exists (no button needed for returning state) + +2. **kid / Key ID**: + - Use `#key-1` or full `{did}#key-1` in backup and state + - Or follow [did:key key IDs](https://www.w3.org/TR/did-core/#relative-did-urls) + +**Effort**: ~1–2 hours + +--- + +### Phase 3: Real Backup (Encrypted Export) + +**Goal**: Backup that can actually be used for recovery. + +**Design choice**: The private key lives on the **server**. Two options: + +- **Option A (simpler)**: Backup is a signed, encrypted blob containing the key material. Restore requires: + - Upload backup file + - Enter passphrase + - Server imports key and replaces current identity (or restores to same node) + +- **Option B (more self-sovereign)**: User can export key to their own wallet. Higher complexity and key-handling risk. + +**Recommended: Option A** + +1. **Backend**: Add `node.createBackup` RPC + - Input: `{ passphrase: string }` + - Encrypt the raw key bytes (e.g. XChaCha20-Poly1305 or AES-256-GCM) with a key derived from passphrase (Argon2) + - Return JSON: `{ version, did, backupBlob (base64), salt, ... }` or trigger download + +2. **Backend**: Add `node.restoreBackup` RPC (for restore flow) + - Input: `{ backupBlob, passphrase }` + - Decrypt, validate, write to identity dir + - Restart or reload identity + +3. **Frontend (OnboardingBackup.vue)**: + - Call `node.createBackup` instead of building mock JSON locally + - Download the real backup file + +4. **Restore flow**: Add a restore path (e.g. from login or onboarding options) that accepts backup file + passphrase and calls `node.restoreBackup` + +**Effort**: ~1–2 days (crypto, testing, edge cases) + +--- + +### Phase 4: DID Document & Web5 Interop (Optional) + +**Goal**: Full compatibility with Web5 resolvers and DWN. + +1. **DID Document endpoint**: `GET /.well-known/did.json` or `/did/{did}` + - Resolve did:key to a full DID Document + - Include `verificationMethod`, `authentication`, `keyAgreement` (X25519 from Ed25519) + - Reference: [did:key expansion](https://github.com/digitalbazaar/did-method-key) + +2. **X25519 derivation**: Add `curve25519-dalek` or equivalent; derive X25519 pubkey from Ed25519 for `keyAgreement` + +3. **Web5/DWN**: Ensure `web5-dwn` and `did-wallet` use our node DID correctly for resolution and operations + +**Effort**: ~2–3 days + +--- + +### Phase 5: DID as Authentication (Future) + +**Goal**: Use DID + proof instead of (or in addition to) password. + +- DID Auth / SIOP flow: prove control of DID via challenge-response +- Could reduce or replace password for API access +- Larger design and security review required + +**Effort**: TBD + +--- + +## Priority Recommendation + +| Priority | Phase | Reason | +|----------|-------|--------| +| **P0** | Phase 1 (Verify) | Removes fake crypto; proves DID control | +| **P1** | Phase 2 (UX) | Quick wins; honest representation of flow | +| **P2** | Phase 3 (Backup) | Makes backup/restore actually useful | +| **P3** | Phase 4 (DID Doc) | Needed for full Web5 interop | +| **P4** | Phase 5 (DID Auth) | Longer-term identity architecture | + +--- + +## Quick Reference: Current vs. Target + +| Step | Current | Target | +|------|---------|--------| +| DID fetch | `node.did` ✅ | Same, better UX | +| Prove control | Fake random "signature" ❌ | Real `node.signChallenge` | +| Backup | Mock JSON, no key ❌ | Encrypted key material + restore | +| kid | Raw pubkey | `#key-1` or standard fragment | +| Restore | Not possible | `node.restoreBackup` | diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index 90c13824..bd8afe8f 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.dbmr9lf4rv4" + "revision": "0.00fear1bobk" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 44173582..35b6331b 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -693,6 +693,12 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: userState.onboardingComplete }) } + case 'auth.resetOnboarding': { + userState.onboardingComplete = false + console.log('[Auth] Onboarding reset') + return res.json({ result: true }) + } + case 'node.did': { const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH' const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' @@ -705,6 +711,32 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { nostr_pubkey: 'mock-nostr-pubkey-hex' } }) } + case 'node.signChallenge': { + const { challenge } = params || {} + const mockSig = Buffer.from(`mock-sig-${challenge || 'challenge'}`).toString('hex') + return res.json({ result: { signature: mockSig } }) + } + + case 'node.createBackup': { + const { passphrase } = params || {} + if (!passphrase) { + return res.json({ error: { code: -32602, message: 'Missing passphrase' } }) + } + const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH' + const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' + return res.json({ + result: { + version: 1, + did: mockDid, + pubkey: mockPubkey, + kid: `${mockDid}#key-1`, + encrypted: true, + blob: Buffer.from(`mock-encrypted-backup-${passphrase}`).toString('base64'), + timestamp: new Date().toISOString(), + }, + }) + } + case 'auth.login': { const { password } = params diff --git a/neode-ui/public/assets/img/bg-intro-1.jpg b/neode-ui/public/assets/img/bg-intro-1.jpg index eed5fda7..2a72ee59 100644 Binary files a/neode-ui/public/assets/img/bg-intro-1.jpg and b/neode-ui/public/assets/img/bg-intro-1.jpg differ diff --git a/neode-ui/public/assets/img/bg-intro-2.jpg b/neode-ui/public/assets/img/bg-intro-2.jpg index eed5fda7..f474fa1f 100644 Binary files a/neode-ui/public/assets/img/bg-intro-2.jpg and b/neode-ui/public/assets/img/bg-intro-2.jpg differ diff --git a/neode-ui/public/assets/img/bg-intro-3.jpg b/neode-ui/public/assets/img/bg-intro-3.jpg index a690cf5c..b6cbf4f5 100644 Binary files a/neode-ui/public/assets/img/bg-intro-3.jpg and b/neode-ui/public/assets/img/bg-intro-3.jpg differ diff --git a/neode-ui/public/assets/img/bg-intro-4.jpg b/neode-ui/public/assets/img/bg-intro-4.jpg index eed5fda7..a55d28f4 100644 Binary files a/neode-ui/public/assets/img/bg-intro-4.jpg and b/neode-ui/public/assets/img/bg-intro-4.jpg differ diff --git a/neode-ui/public/assets/img/bg-intro-5.jpg b/neode-ui/public/assets/img/bg-intro-5.jpg index eb87bd74..5482336b 100644 Binary files a/neode-ui/public/assets/img/bg-intro-5.jpg and b/neode-ui/public/assets/img/bg-intro-5.jpg differ diff --git a/neode-ui/public/assets/img/bg-intro-6.jpg b/neode-ui/public/assets/img/bg-intro-6.jpg index 35ddb34f..fa9f4bc7 100644 Binary files a/neode-ui/public/assets/img/bg-intro-6.jpg and b/neode-ui/public/assets/img/bg-intro-6.jpg differ diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 0c56aae4..c2fe7b2e 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -111,16 +111,16 @@ function onUserActivity() { function onKeyDown(e: KeyboardEvent) { const isMac = navigator.platform.toUpperCase().includes('MAC') const mod = isMac ? e.metaKey : e.ctrlKey - // Cmd+K / Ctrl+K or plain K (when not typing in input) + // Cmd+K / Ctrl+K only (modifier required - avoids accidental trigger when typing) const target = e.target as HTMLElement const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable - if ((mod && e.key === 'k') || ((e.key === 'k' || e.key === 'K') && !isInput)) { + if (mod && e.key === 'k') { e.preventDefault() spotlightStore.toggle() return } - // Cmd+Shift+` / Ctrl+Shift+` or plain C - CLI popup - if ((mod && e.shiftKey && e.key === '`') || ((e.key === 'c' || e.key === 'C') && !isInput)) { + // Cmd+Shift+` / Ctrl+Shift+` or Cmd+Shift+C / Ctrl+Shift+C - CLI popup (modifier required) + if ((mod && e.shiftKey && e.key === '`') || (mod && e.shiftKey && (e.key === 'c' || e.key === 'C'))) { e.preventDefault() cliStore.toggle() return diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 67b40bba..69d94b15 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -135,6 +135,13 @@ class RPCClient { }) } + async resetOnboarding(): Promise { + return this.call({ + method: 'auth.resetOnboarding', + params: {}, + }) + } + async getNodeDid(): Promise<{ did: string; pubkey: string }> { return this.call({ method: 'node.did', @@ -142,6 +149,28 @@ class RPCClient { }) } + async signChallenge(challenge: string): Promise<{ signature: string }> { + return this.call({ + method: 'node.signChallenge', + params: { challenge }, + }) + } + + async createBackup(passphrase: string): Promise<{ + version: number + did: string + pubkey: string + kid: string + encrypted: boolean + blob: string + timestamp: string + }> { + return this.call({ + method: 'node.createBackup', + params: { passphrase }, + }) + } + async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> { return this.call({ method: 'node.nostr-publish', diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index a2675d70..dcb2b2c0 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -124,6 +124,22 @@ const router = createRouter({ ], }) +// Session check with timeout - avoids endless spinner on mobile/slow networks +const SESSION_CHECK_TIMEOUT_MS = 8000 + +async function checkSessionWithTimeout(store: ReturnType): Promise { + try { + return await Promise.race([ + store.checkSession(), + new Promise((resolve) => + setTimeout(() => resolve(false), SESSION_CHECK_TIMEOUT_MS) + ), + ]) + } catch { + return false + } +} + /** * Navigation Guard * Handles authentication and onboarding flow routing @@ -134,16 +150,16 @@ router.beforeEach(async (to, _from, next) => { // Allow all public routes (login, onboarding) without auth check if (isPublic) { - // If authenticated and visiting /login, validate session first + // If authenticated and visiting /login: show login immediately, validate in background. + // This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network). if (to.path === '/login' && store.isAuthenticated) { if (store.needsSessionValidation()) { - const valid = await store.checkSession() - if (valid) { - next({ name: 'home' }) - return - } - // Session invalid, allow login page next() + checkSessionWithTimeout(store).then((valid) => { + if (valid) { + router.replace({ name: 'home' }).catch(() => {}) + } + }) return } next({ name: 'home' }) @@ -153,9 +169,9 @@ router.beforeEach(async (to, _from, next) => { return } - // Protected routes: validate session if stale auth from localStorage + // Protected routes: validate session if stale auth from localStorage (with timeout) if (store.needsSessionValidation()) { - const valid = await store.checkSession() + const valid = await checkSessionWithTimeout(store) if (!valid) { next('/login') return @@ -164,9 +180,9 @@ router.beforeEach(async (to, _from, next) => { return } - // Not authenticated at all + // Not authenticated at all (with timeout to avoid endless spinner on mobile) if (!store.isAuthenticated) { - const hasSession = await store.checkSession() + const hasSession = await checkSessionWithTimeout(store) if (hasSession) { next() return diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index 7f8d9b81..6700e210 100644 --- a/neode-ui/src/views/Login.vue +++ b/neode-ui/src/views/Login.vue @@ -116,14 +116,22 @@ - -
+ +
+ | +
@@ -286,6 +294,27 @@ function replayIntro() { // Navigate to root to trigger splash screen window.location.href = '/' } + +const isResettingOnboarding = ref(false) + +async function restartOnboarding() { + if (isResettingOnboarding.value) return + isResettingOnboarding.value = true + try { + await rpcClient.resetOnboarding() + localStorage.removeItem('neode_onboarding_complete') + localStorage.removeItem('neode_did') + localStorage.removeItem('neode_did_state') + localStorage.removeItem('neode_backup_created') + await router.push('/onboarding/intro') + window.location.reload() + } catch (err) { + console.error('Failed to reset onboarding:', err) + error.value = err instanceof Error ? err.message : 'Failed to reset onboarding' + } finally { + isResettingOnboarding.value = false + } +}