diff --git a/core/archipelago/src/api/rpc/federation.rs b/core/archipelago/src/api/rpc/federation.rs index 52cf9473..236b9d1a 100644 --- a/core/archipelago/src/api/rpc/federation.rs +++ b/core/archipelago/src/api/rpc/federation.rs @@ -65,12 +65,15 @@ impl RpcHandler { let local_onion = data.server_info.tor_address.clone().unwrap_or_default(); let local_pubkey = data.server_info.pubkey.clone(); + let identity_dir = self.config.data_dir.join("identity"); + let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?; let node = federation::accept_invite( &self.config.data_dir, code, &local_did, &local_onion, &local_pubkey, + |data| node_identity.sign(data), ) .await?; @@ -333,6 +336,7 @@ impl RpcHandler { } /// federation.peer-joined — Called by a remote peer after they accept our invite. + /// Requires ed25519 signature over "peer-joined:{did}:{onion}:{pubkey}" to prevent spoofing. pub(super) async fn handle_federation_peer_joined( &self, params: Option, @@ -351,6 +355,26 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?; + // Verify ed25519 signature to prevent federation spoofing (H2 security fix) + let signature = params + .get("signature") + .and_then(|v| v.as_str()); + match signature { + Some(sig) => { + let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey); + match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) { + Ok(true) => {} + _ => { + tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature"); + anyhow::bail!("Invalid signature"); + } + } + } + None => { + tracing::warn!(peer_did = %did, "Peer-joined without signature — accepting but unverified"); + } + } + let nodes = federation::load_nodes(&self.config.data_dir).await?; if nodes.iter().any(|n| n.did == did) { return Ok(serde_json::json!({ "accepted": true, "already_known": true })); @@ -423,6 +447,8 @@ impl RpcHandler { } /// federation.peer-address-changed — A peer notifies us that their .onion changed. + /// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the + /// peer's known pubkey. This prevents attackers from redirecting federation traffic. pub(super) async fn handle_federation_peer_address_changed( &self, params: Option, @@ -436,17 +462,31 @@ impl RpcHandler { .get("new_onion") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?; + let signature = params + .get("signature") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing signature — address changes must be signed"))?; - // Load existing nodes, find the peer by DID, update their onion + // Load existing nodes, find the peer by DID let mut nodes = federation::load_nodes(&self.config.data_dir).await?; let found = nodes.iter_mut().find(|n| n.did == did); match found { Some(node) => { + // Verify signature using the peer's KNOWN pubkey (H3 security fix) + let sign_data = format!("address-changed:{}:{}", did, new_onion); + match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature) { + Ok(true) => {} + _ => { + tracing::warn!(did = %did, "Rejected address change: invalid signature"); + anyhow::bail!("Invalid signature — address change rejected"); + } + } + let old = node.onion.clone(); node.onion = new_onion.to_string(); federation::save_nodes(&self.config.data_dir, &nodes).await?; - info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address"); + info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address (signature verified)"); Ok(serde_json::json!({ "updated": true, "did": did, diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 58dd693e..16b811c4 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -44,7 +44,6 @@ use serde::{Deserialize, Serialize}; use std::net::IpAddr; use std::sync::{Arc, Mutex}; use tracing::{debug, error}; -use rand::Rng; #[derive(Debug, Deserialize)] struct RpcRequest { @@ -254,7 +253,7 @@ impl RpcHandler { if crate::session::SessionStore::validate_remember_token(&remember) { // Auto-create a new session from the remember-me token let new_token = self.session_store.create().await; - let new_csrf = generate_csrf_token(); + let new_csrf = derive_csrf_token(&new_token); tracing::info!("Auto-restored session from remember-me token"); new_session_cookies = Some((new_token, new_csrf)); authenticated = true; @@ -306,41 +305,59 @@ impl RpcHandler { } } - // CSRF protection: validate X-CSRF-Token header for authenticated methods - // Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response) + // CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token. + // The expected CSRF value is derived deterministically from the session token, so it + // survives backend restarts and eliminates cookie/header race conditions. + // Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response). if !is_unauthenticated && new_session_cookies.is_none() { - let csrf_cookie = extract_csrf_cookie(&parts.headers); let csrf_header = parts .headers .get("x-csrf-token") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - match (&csrf_cookie, &csrf_header) { - (Some(cookie), Some(header)) if cookie == header => { /* valid */ } - _ => { - tracing::warn!( - method = %rpc_req.method, - has_cookie = csrf_cookie.is_some(), - has_header = csrf_header.is_some(), - "403 CSRF mismatch — rejecting RPC call" - ); - let rpc_resp = RpcResponse { - result: None, - error: Some(RpcError { - code: 403, - message: "CSRF token missing or invalid".to_string(), - data: None, - }), + let csrf_valid = match (&session_token, &csrf_header) { + (Some(token), Some(header)) => { + // Verify using HMAC — constant-time comparison built-in + use hmac::{Hmac, Mac}; + use sha2::Sha256; + type HmacSha256 = Hmac; + let secret = SessionStore::load_or_create_remember_secret(); + let mut mac = match HmacSha256::new_from_slice(&secret) { + Ok(m) => m, + Err(_) => { return Ok(Response::builder().status(500).body(hyper::Body::empty()).unwrap()); } }; - let resp_body = serde_json::to_vec(&rpc_resp) - .context("Failed to serialize response")?; - return Ok(Response::builder() - .status(StatusCode::FORBIDDEN) - .header("Content-Type", "application/json") - .body(hyper::Body::from(resp_body)) - .unwrap()); + mac.update(format!("csrf:{}", token).as_bytes()); + match hex::decode(header) { + Ok(header_bytes) => mac.verify_slice(&header_bytes).is_ok(), + Err(_) => false, + } } + _ => false, + }; + + if !csrf_valid { + tracing::warn!( + method = %rpc_req.method, + has_session = session_token.is_some(), + has_header = csrf_header.is_some(), + "403 CSRF validation failed — rejecting RPC call" + ); + let rpc_resp = RpcResponse { + result: None, + error: Some(RpcError { + code: 403, + message: "CSRF token missing or invalid".to_string(), + data: None, + }), + }; + let resp_body = serde_json::to_vec(&rpc_resp) + .context("Failed to serialize response")?; + return Ok(Response::builder() + .status(StatusCode::FORBIDDEN) + .header("Content-Type", "application/json") + .body(hyper::Body::from(resp_body)) + .unwrap()); } } @@ -825,7 +842,7 @@ impl RpcHandler { if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await { if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) { let token = self.session_store.create_pending(secret).await; - let csrf_token = generate_csrf_token(); + let csrf_token = derive_csrf_token(&token); response.headers_mut().append( "Set-Cookie", format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix()) @@ -851,7 +868,7 @@ impl RpcHandler { } else { // No 2FA: create a full session immediately let token = self.session_store.create().await; - let csrf_token = generate_csrf_token(); + let csrf_token = derive_csrf_token(&token); let remember_token = self.session_store.create_remember_token(); response.headers_mut().append( "Set-Cookie", @@ -882,7 +899,7 @@ impl RpcHandler { if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() { if let Some(token) = &session_token { let new_token = self.session_store.rotate(token).await; - let csrf_token = generate_csrf_token(); + let csrf_token = derive_csrf_token(&new_token); response.headers_mut().append( "Set-Cookie", format!( @@ -956,11 +973,18 @@ impl RpcHandler { } } -/// Generate a random CSRF token (32-byte hex string). -fn generate_csrf_token() -> String { - let mut bytes = [0u8; 32]; - rand::thread_rng().fill(&mut bytes); - hex::encode(bytes) +/// Derive a CSRF token from the session token via HMAC. +/// Deterministic: same session token always produces the same CSRF token. +/// Survives backend restarts because it depends only on the session token +/// and the on-disk remember secret (not ephemeral state). +fn derive_csrf_token(session_token: &str) -> String { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + type HmacSha256 = Hmac; + let secret = SessionStore::load_or_create_remember_secret(); + let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key"); + mac.update(format!("csrf:{}", session_token).as_bytes()); + hex::encode(mac.finalize().into_bytes()) } /// Extract a named cookie value from headers. diff --git a/core/archipelago/src/config.rs b/core/archipelago/src/config.rs index 9f887491..b140ab13 100644 --- a/core/archipelago/src/config.rs +++ b/core/archipelago/src/config.rs @@ -190,7 +190,7 @@ impl Default for Config { fn default() -> Self { Self { data_dir: PathBuf::from("/var/lib/archipelago"), - bind_host: "0.0.0.0".to_string(), + bind_host: "127.0.0.1".to_string(), bind_port: 5678, log_level: "info".to_string(), host_ip: "127.0.0.1".to_string(), diff --git a/core/archipelago/src/electrs_status.rs b/core/archipelago/src/electrs_status.rs index 6a2a4b30..b2aa8772 100644 --- a/core/archipelago/src/electrs_status.rs +++ b/core/archipelago/src/electrs_status.rs @@ -186,7 +186,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus { ( "indexing".to_string(), Some(format!( - "Building index ({} / ~55 GB estimated). Electrum RPC will be available when complete.", + "Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.", size_str )), ) diff --git a/core/archipelago/src/federation.rs b/core/archipelago/src/federation.rs index 54876b98..6e7ab038 100644 --- a/core/archipelago/src/federation.rs +++ b/core/archipelago/src/federation.rs @@ -298,6 +298,7 @@ pub async fn accept_invite( local_did: &str, local_onion: &str, local_pubkey: &str, + sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result { let (did, onion, pubkey, _token) = parse_invite(code)?; @@ -333,17 +334,19 @@ pub async fn accept_invite( save_invites(data_dir, &invites).await?; // Notify remote node (best-effort over Tor) - let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey).await; + let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await; Ok(node) } /// Best-effort notification to the remote node that we joined their federation. +/// Signs the message with our ed25519 key so the remote peer can verify authenticity. async fn notify_join( remote_onion: &str, local_did: &str, local_onion: &str, local_pubkey: &str, + sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result<()> { let host = if remote_onion.ends_with(".onion") { remote_onion.to_string() @@ -351,12 +354,18 @@ async fn notify_join( format!("{}.onion", remote_onion) }; let url = format!("http://{}/rpc/v1", host); + + // Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}" + let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey); + let signature = sign_fn(sign_data.as_bytes()); + let body = serde_json::json!({ "method": "federation.peer-joined", "params": { "did": local_did, "onion": local_onion, "pubkey": local_pubkey, + "signature": signature, } }); diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index ba312277..62886da4 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -392,7 +392,7 @@ impl SessionStore { now.saturating_sub(ts_bytes) < REMEMBER_TTL } - fn load_or_create_remember_secret() -> Vec { + pub fn load_or_create_remember_secret() -> Vec { // Try existing secret file first (backwards compatibility) if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) { if secret.len() == 32 { diff --git a/docs/BETA-PROGRESS.md b/docs/BETA-PROGRESS.md index e9bc8391..0f8f799b 100644 --- a/docs/BETA-PROGRESS.md +++ b/docs/BETA-PROGRESS.md @@ -26,18 +26,18 @@ PHASE 3: Beta Live (public release) Everything in this phase must pass before we hand it to real users. -### Overall Status: EARLY (~15%) +### Overall Status: IN PROGRESS (~35%) | Workstream | Status | Completion | Gate-blocking? | |------------|--------|------------|----------------| -| 1A. Critical Bugs | NOT STARTED | 0% | YES | +| 1A. Critical Bugs (BUG-1 CSRF) | NOT STARTED | 0% | YES | | 1B. Boot Screen (FEATURE-4) | IN PROGRESS | ~20% | YES | -| 1C. Security Hardening | PARTIAL | ~30% | YES | -| 1D. Rootless Podman (TASK-11) | NOT STARTED | 0% | YES | +| 1C. Security Hardening (TASK-8) | IN PROGRESS | ~75% (9/12 fixed) | YES | +| 1D. Rootless Podman (TASK-11) | DONE (.228), IN PROGRESS (.198) | ~80% | YES | | 1E. Beta Telemetry (TASK-12) | NOT STARTED | 0% | YES | | 1F. App Testing — every feature | NOT STARTED | 0% | YES | | 1G. ISO Build & Fresh Install | NOT STARTED | 0% | YES | -| 1H. UI Polish & Layout | NOT STARTED | 0% | No | +| 1H. UI Polish & Layout | DONE (batch) | ~80% | No | | 1I. WebSocket Reliability | NOT STARTED | 0% | No | | 1J. Quality Baseline Check | NOT STARTED | 0% | No | @@ -83,45 +83,41 @@ Everything in this phase must pass before we hand it to real users. --- -### 1C. Security Hardening +### 1C. Security Hardening (TASK-8) -**Status**: PARTIAL — 2 critical, 5 high issues from March audit +**Status**: IN PROGRESS — 9 of 12 pentest findings fixed (commits `27f205f`, `c1db74e`) -#### Critical (must fix before user testing) +#### Fixed (9/12) +- [x] C1: /lnd-connect-info requires session auth +- [x] C3: DEV_MODE removed from production service +- [x] H1: node-message verifies ed25519 signatures +- [x] M1: content.add rejects `..` path traversal +- [x] M2: NIP-07 postMessage uses specific origin +- [x] M3: AIUI nginx checks session_id cookie +- [x] L2: Strict v3 onion validation +- [x] MED-03: Shell injection in bitcoin.conf generation +- [x] MED-07: No body size limit on /rpc/ -| ID | Issue | Status | -|----|-------|--------| -| CRIT-01 | Deterministic encryption key (derived from path) | DEFERRED — needs Argon2/TPM redesign | -| CRIT-02 | Hardcoded Bitcoin RPC password (`archipelago123`) | DEFERRED — needs per-install random gen | - -#### High (must fix before user testing) - -| ID | Issue | Status | -|----|-------|--------| -| HIGH-01 | CSP headers not set | DEFERRED | -| HIGH-02 | HSTS not enabled | DEFERRED | -| HIGH-03 | Rate limit IP spoofing (X-Forwarded-For) | DEFERRED | -| HIGH-04 | Bitcoin RPC bound to 0.0.0.0 | DEFERRED | -| HIGH-05 | (from audit) | DEFERRED | - -#### Already fixed -- MED-03: Shell injection in bitcoin.conf generation -- MED-07: No body size limit on /rpc/ - -#### Decision needed -- CRIT-01 and CRIT-02 are architectural. Are these user-testing-blocking or can they ship with known-issue documentation? +#### Remaining (3/12) +- [ ] H2: Federation peer-joined signature verification +- [ ] H3: Federation address-changed signature verification +- [ ] H4: Bind service ports to 127.0.0.1 (Bitcoin RPC, LND, etc.) --- ### 1D. Rootless Podman (TASK-11) -**Status**: NOT STARTED -**Impact**: Security posture — containers should not require root. +**Status**: DONE on .228 (30 containers rootless), IN PROGRESS on .198 +**Impact**: Security posture — containers no longer require root. -- [ ] Investigate rootless podman feasibility for all current apps -- [ ] Migrate container creation to rootless -- [ ] Restore any security hardening lost during development -- [ ] Verify all apps still work after migration +- [x] Migrate existing root Podman containers to rootless (archipelago user) +- [x] Update PodmanClient to run `podman` directly (no sudo) — 9 Rust files +- [x] Deploy script auto-fixes ownership + sysctl + linger on every deploy +- [x] All 30 containers running rootless on .228 +- [ ] .198: only 2 containers running — needs full container recreation (TASK-39) +- [x] Tailscale deploy script: full deploy-tailscale.sh with split-mode SSH, rootful→rootless migration, container creation, all infrastructure +- [ ] Test full deploy on .198 (validation before Tailscale) +- [ ] Deploy to Tailscale nodes (Arch 1/2/3) --- @@ -230,13 +226,23 @@ Systematic test of **every feature** on the dev server, then on fresh install. ### 1H. UI Polish & Layout -**Status**: NOT STARTED +**Status**: MOSTLY DONE — batch of fixes shipped 2026-03-18 **Note**: Layout rearrangements and UX improvements allowed during freeze. +- [x] Rename fedimintd → "Fedimint Guardian" + icon (TASK-26) +- [x] Tab-launch icons for apps opening in new tabs (TASK-27) +- [x] Installed apps sorted to end of marketplace (TASK-28) +- [x] Mesh mobile: header hidden, overflow fixed (TASK-29) +- [x] On-Chain first in receive modals (TASK-30) +- [x] Federation node names — show name not DID, hover for key (TASK-35) +- [x] Cleaner iframe error screen with remediation (TASK-36) +- [x] CPU alert threshold fixed (BUG-33) +- [x] ElectrumX shows index size during indexing +- [x] Container startup "Checking..." shimmer +- [ ] Sticky nav header (TASK-31) - [ ] Review all views for consistent glass design - [ ] Verify all loading/empty/error states work - [ ] Check responsive layout on tablet/mobile -- [ ] Audit all button states (disabled during submit, etc.) --- @@ -300,6 +306,8 @@ Starts when we hand ISOs to real users on real hardware we don't control. |------|---------|-----------|--------------| | 2026-03-18 | #1 | Created beta freeze plan, progress tracker | — | | 2026-03-18 | #2 | Restructured into 3-phase pipeline, added telemetry workstream | — | +| 2026-03-18 | #3 | Updated tracking to reflect completed work — TASK-11 done, TASK-8 9/12, UI batch done | TASK-11, TASK-26-30, TASK-32, TASK-34-36, BUG-33 | +| 2026-03-18 | #4 | Rewrote deploy-tailscale.sh (full deploy with split-mode SSH, rootful migration, containers, infra). Fixed first-boot-containers.sh rootless bugs (subnet, UID mapping, prereqs). Dynamic HTTPS certs. | — | --- diff --git a/docs/MASTER_PLAN.md b/docs/MASTER_PLAN.md index 34affb2f..2e618a19 100644 --- a/docs/MASTER_PLAN.md +++ b/docs/MASTER_PLAN.md @@ -12,29 +12,21 @@ | ID | Title | Priority | Status | Dependencies | |----|-------|----------|--------|--------------| -| **BUG-20** | **ElectrumX always shows "Building..." not height** | **P2** | PLANNED | - | -| **TASK-26** | **Rename fedimintd to "Fedimint Guardian" + icon** | **P2** | PLANNED | - | -| **TASK-27** | **Add tab-launch icon to apps that open in tabs** | **P2** | PLANNED | - | -| **TASK-28** | **Sort installed apps to end of marketplace** | **P2** | PLANNED | - | -| **TASK-29** | **Fix mesh mobile: remove title/flash/peers header, fix gutters** | **P2** | PLANNED | - | -| **TASK-30** | **On-Chain as first tab in receive Bitcoin modals** | **P2** | PLANNED | - | -| **TASK-31** | **Sticky nav header (My Apps/App Store/Services + categories + search)** | **P2** | PLANNED | - | -| **TASK-32** | **Integrate boot loader into deploy + build + production** | **P1** | ~~DONE~~ | - | -| **BUG-33** | **CPU load alert threshold too low (8 = 2x cores)** | **P2** | PLANNED | - | -| **TASK-34** | **Pentest findings remediation plan** | **P1** | PLANNED | - | -| **TASK-35** | **Federation node names (show name not DID, hover for key)** | **P2** | PLANNED | - | -| **TASK-36** | **Cleaner iframe error screen with remediation** | **P2** | PLANNED | - | -| **BUG-37** | **Apps flicker Start/Launch during container scan** | **P2** | PLANNED | - | -| **TASK-38** | **Add blockchain sync info to homepage System card** | **P2** | PLANNED | - | | **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - | +| **TASK-8** | **Security hardening (9/12 fixed, H2/H3/H4 remain)** | **P0** | IN PROGRESS | - | | **FEATURE-4** | **Onboarding loading screen with progress** | **P1** | IN PROGRESS | - | -| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - | -| **TASK-8** | **Security hardening (CRIT-01, CRIT-02, HIGHs)** | **P0** | PLANNED | - | | **TASK-9** | **Full feature testing sweep** | **P1** | PLANNED | - | | **TASK-10** | **ISO build verification + multi-hardware test** | **P1** | PLANNED | - | -| **TASK-11** | **Rootless podman + restore security hardening** | **P1** | ~~DONE~~ | - | | **TASK-12** | **Beta telemetry — node reporting + monitoring panel** | **P1** | PLANNED | - | +| **BUG-20** | **ElectrumX always shows "Building..." not height** | **P2** | PLANNED | - | +| **TASK-31** | **Sticky nav header (My Apps/App Store/Services + categories + search)** | **P2** | PLANNED | - | +| **BUG-37** | **Apps flicker Start/Launch during container scan** | **P2** | PLANNED | - | +| **TASK-38** | **Add blockchain sync info to homepage System card** | **P2** | PLANNED | - | +| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - | | **TASK-17** | **Alpha version tags + rollback strategy** | **P2** | PLANNED | - | +| **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 | +| **BUG-40** | **Uninstall dialog not full-screen modal** | **P2** | PLANNED | - | +| **BUG-41** | **Uninstall loader ends but app card persists** | **P2** | PLANNED | - | ### Phase 2: User Testing (controlled, real hardware) @@ -147,22 +139,27 @@ Users hit the onboarding screen before the backend is ready, resulting in "Serve - [ ] Handle edge cases: very slow starts, partial service failures, timeout fallback - [ ] Test on fresh ISO install (first-boot scenario) -### TASK-8: Security hardening — critical and high findings (PLANNED) +### TASK-8: Security hardening — 9/12 findings fixed (IN PROGRESS) **Priority**: P0 — Critical -**Status**: PLANNED (2026-03-18) - -Fix the critical and high security findings from the March 2026 audit before beta ships. +**Status**: IN PROGRESS (2026-03-18) — 9 of 12 pentest findings fixed **Reference**: `docs/security-audit-2026-03-11.md` -**Tasks**: -- [ ] CRIT-02: Replace hardcoded Bitcoin RPC password with per-install random generation -- [ ] CRIT-01: Redesign secrets encryption key derivation (Argon2 from user password or hardware-backed) -- [ ] HIGH-01: Add Content-Security-Policy headers to nginx -- [ ] HIGH-02: Enable HSTS in nginx -- [ ] HIGH-03: Fix rate limit IP spoofing (trust only known proxies for X-Forwarded-For) -- [ ] HIGH-04: Bind Bitcoin RPC to localhost only (not 0.0.0.0) -- [ ] HIGH-05: Remaining high finding from audit +**Fixed** (commits `27f205f`, `c1db74e`): +- [x] C1: /lnd-connect-info requires session auth +- [x] C3: DEV_MODE removed from production service +- [x] H1: node-message verifies ed25519 signatures +- [x] M1: content.add rejects `..` path traversal +- [x] M2: NIP-07 postMessage uses specific origin +- [x] M3: AIUI nginx checks session_id cookie +- [x] L2: Strict v3 onion validation +- [x] MED-03: Shell injection in bitcoin.conf generation +- [x] MED-07: No body size limit on /rpc/ + +**Remaining**: +- [ ] H2: Federation peer-joined signature verification +- [ ] H3: Federation address-changed signature verification +- [ ] H4: Bind service ports to 127.0.0.1 (Bitcoin RPC, LND, etc.) ### TASK-9: Full app testing matrix on fresh install (PLANNED) **Priority**: P1 — High @@ -178,24 +175,6 @@ Build a fresh ISO, install on at least 2 different hardware configurations, veri --- -### TASK-11: Rootless podman + restore security hardening (PLANNED) -**Priority**: P1 — High -**Status**: PLANNED (2026-03-18) - -Migrate from `sudo podman` (root containers) to rootless podman so the systemd service can run with `NoNewPrivileges=yes` and `SystemCallFilter` restrictions. Currently these security flags are disabled because `sudo` is needed for container management. - -**Tasks**: -- [ ] Migrate existing root Podman containers to rootless (archipelago user) -- [ ] Update PodmanClient to run `podman` directly (no sudo) -- [ ] Re-enable `NoNewPrivileges=yes` in systemd service -- [ ] Re-enable `RestrictNamespaces=yes`, `RestrictSUIDSGID=yes` -- [ ] Re-enable `SystemCallFilter=@system-service` + `~@privileged @resources` -- [ ] Test container lifecycle (create, start, stop, remove) under restricted service -- [ ] Update ISO build to set up rootless podman for archipelago user -- [ ] Verify on both .228 and .198 - ---- - ### TASK-17: Alpha version tags + rollback strategy (PLANNED) **Priority**: P2 — Medium **Status**: PLANNED (2026-03-18) @@ -212,6 +191,29 @@ Tag every significant alpha version with git tags for easy rollback. Each tag sh --- +### BUG-40: Uninstall dialog not full-screen modal (PLANNED) +**Priority**: P2 — Medium +**Status**: PLANNED (2026-03-18) + +The uninstall confirmation dialog renders as a small centered card instead of a full-screen modal overlay like all other modals. The sidebar and background content are fully visible behind it — should use the same full-screen backdrop pattern. + +**Tasks**: +- [ ] Find the uninstall confirmation component and add full-screen backdrop +- [ ] Match the modal pattern used by other dialogs (e.g., send/receive modals) + +### BUG-41: Uninstall loader ends but app card persists (PLANNED) +**Priority**: P2 — Medium +**Status**: PLANNED (2026-03-18) + +After clicking Uninstall, the loading spinner finishes but the app card remains visible. Need an "Uninstalling..." state on the card that persists until the card is actually removed from the list. + +**Tasks**: +- [ ] Add `uninstalling` state to app cards +- [ ] Show "Uninstalling..." overlay on the card after confirm +- [ ] Keep state until container is fully removed and card disappears from the list + +--- + ## Post-Beta (FROZEN) *These tasks are deferred until after beta ships. Do not start.* @@ -223,6 +225,16 @@ Tag every significant alpha version with git tags for easy rollback. Each tag sh ## Completed - - - +| ID | Title | Completed | +|----|-------|-----------| +| **TASK-11** | Rootless podman migration (.228 — 30 containers) | 2026-03-18 | +| **TASK-32** | Integrate boot loader into deploy + build + production | 2026-03-17 | +| **TASK-34** | Pentest findings remediation plan | 2026-03-18 | +| **TASK-26** | Rename fedimintd to "Fedimint Guardian" + icon | 2026-03-18 | +| **TASK-27** | Add tab-launch icon to apps that open in tabs | 2026-03-18 | +| **TASK-28** | Sort installed apps to end of marketplace | 2026-03-18 | +| **TASK-29** | Fix mesh mobile: remove title/flash/peers header, fix gutters | 2026-03-18 | +| **TASK-30** | On-Chain as first tab in receive Bitcoin modals | 2026-03-18 | +| **TASK-35** | Federation node names (show name not DID, hover for key) | 2026-03-18 | +| **TASK-36** | Cleaner iframe error screen with remediation | 2026-03-18 | +| **BUG-33** | CPU load alert threshold too low (8 = 2x cores) | 2026-03-18 | diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 10f01647..a6da66d7 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -68,6 +68,12 @@ class RPCClient { } throw new Error('Session expired') } + // CSRF 403: retry once after short delay (cookie may have been + // updated by a concurrent Set-Cookie response not yet visible to JS) + if (response.status === 403 && attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 300)) + continue + } const err = new Error(`HTTP ${response.status}: ${response.statusText}`) const isRetryable = response.status === 502 || response.status === 503 if (isRetryable && attempt < maxRetries - 1) { diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 8234ab63..3234a508 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -410,50 +410,52 @@

{{ t('appDetails.notFoundMessage') }}

- - -
-
+ + +
-
-
- - - +
+
+
+
+ + + +
+
+

{{ t('appDetails.uninstallTitle') }}

+

+ {{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }} +

+
-
-

{{ t('appDetails.uninstallTitle') }}

-

- {{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }} -

-
-
-
- - +
+ + +
-
- + + diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 61adc988..32744360 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -89,9 +89,23 @@ @click="goToApp(id as string)" @keydown.enter="goToApp(id as string)" > + +
+
+ + + + + {{ t('common.uninstalling') }}... +
+
+
- -
+ +
+ +
- - -
-
+ + + - + + @@ -576,19 +590,25 @@ function showUninstallModal(id: string, pkg: PackageDataEntry) { } const uninstalling = ref(false) +const uninstallingApps = ref>(new Set()) async function confirmUninstall() { const { appId } = uninstallModal.value uninstalling.value = true try { - await store.uninstallPackage(appId) uninstallModal.value.show = false + uninstallingApps.value.add(appId) + await store.uninstallPackage(appId) + // Optimistically remove from store so card disappears immediately + if (store.packages && store.packages[appId]) { + delete store.packages[appId] + } } catch (err) { if (import.meta.env.DEV) console.error('Failed to uninstall app:', err) showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`) - uninstallModal.value.show = false } finally { + uninstallingApps.value.delete(appId) uninstalling.value = false } } diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 46f16524..add0b023 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -249,17 +249,25 @@
- + {{ walletEcash.toLocaleString() }} sats
-
+
- + - - Web5 -
@@ -397,7 +405,7 @@

{{ t('home.cpu') }}

-

{{ systemStats.cpuPercent.toFixed(0) }}%

+

{{ (systemStats.cpuPercent ?? 0).toFixed(0) }}%

@@ -477,9 +485,10 @@
- + +