fix: BUG-1 CSRF, TASK-8 H2/H3/H4, BUG-20/37/40/41 — 7 bugs fixed

BUG-1 (P0): CSRF tokens now HMAC-derived from session token instead of
random — survives backend restarts, eliminates cookie/header race conditions.
Frontend retries 403s as belt-and-suspenders.

TASK-8 H2: federation.peer-joined verifies ed25519 signature on join messages.
TASK-8 H3: federation.peer-address-changed requires signed proof from known peer.
TASK-8 H4: Rust backend default bind 0.0.0.0 → 127.0.0.1 (nginx proxies all).

BUG-20: ElectrumX index estimate string fixed from ~55GB to ~130GB.
BUG-37: App card Start/Stop buttons split into loading vs interactive states
        to prevent WebSocket state flicker during container scans.
BUG-40: Uninstall modal uses Teleport to body with z-[3000] for full overlay.
BUG-41: Uninstalling overlay on card + optimistic store removal.

Updated MASTER_PLAN.md and BETA-PROGRESS.md to reflect all completed work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 22:05:21 +00:00
parent 00bfd62393
commit 41ff1021ad
12 changed files with 404 additions and 271 deletions

View File

@@ -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<serde_json::Value>,
@@ -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<serde_json::Value>,
@@ -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,

View File

@@ -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<Sha256>;
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<Sha256>;
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.