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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user