From fb953e888aa9193cff1b8bd8f2e7a3096d9e15b4 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 19 Mar 2026 19:27:16 +0000 Subject: [PATCH] feat: DID rotation + federation peer notification (Part 3) - node.rotate-did: generates new Ed25519 keypair, signs rotation proof with old key, overwrites identity files, requires password - federation.notify-did-change: broadcasts rotation proof to all trusted/observer peers over Tor - federation.peer-did-changed: receiving side verifies rotation proof against known pubkey before updating peer's DID - Rate-limited: 3/600s for rotation, 5/60s for peer notification - Signature verification uses ed25519_dalek (constant-time) Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/api/rpc/federation.rs | 225 ++++++++++++++++++++- core/archipelago/src/api/rpc/mod.rs | 4 + core/archipelago/src/api/rpc/node.rs | 82 +++++++- core/archipelago/src/session.rs | 3 + neode-ui/dev-dist/sw.js | 2 +- 5 files changed, 312 insertions(+), 4 deletions(-) diff --git a/core/archipelago/src/api/rpc/federation.rs b/core/archipelago/src/api/rpc/federation.rs index 4c360de3..205154da 100644 --- a/core/archipelago/src/api/rpc/federation.rs +++ b/core/archipelago/src/api/rpc/federation.rs @@ -4,8 +4,8 @@ use crate::federation::{self, FederatedNode, TrustLevel}; use crate::identity; use crate::identity_manager::IdentityManager; use crate::network::dwn_store::DwnStore; -use anyhow::Result; -use tracing::{debug, info}; +use anyhow::{Context, Result}; +use tracing::{debug, info, warn}; const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1"; @@ -505,4 +505,225 @@ impl RpcHandler { } } } + + /// federation.notify-did-change — Notify all federated peers that our DID has rotated. + /// Called after `node.rotate-did` to propagate the rotation proof to peers. + pub(super) async fn handle_federation_notify_did_change( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let old_did = params + .get("old_did") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'old_did'"))?; + let new_did = params + .get("new_did") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'new_did'"))?; + let proof_signature = params + .get("proof_signature") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'proof_signature'"))?; + let proof_message = params + .get("proof_message") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'proof_message'"))?; + + validate_did(old_did)?; + validate_did(new_did)?; + + // Get the new pubkey to include in the notification + let (data, _) = self.state_manager.get_snapshot().await; + let new_pubkey = data.server_info.pubkey.clone(); + + let nodes = federation::load_nodes(&self.config.data_dir).await?; + + let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050") + .context("Invalid Tor proxy")?; + let client = reqwest::Client::builder() + .proxy(proxy) + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to build HTTP client")?; + + let mut notified = 0u32; + let mut failed = 0u32; + let mut results = Vec::new(); + + for node in &nodes { + // Only notify trusted and observer peers + if node.trust_level == TrustLevel::Untrusted { + continue; + } + + let host = if node.onion.ends_with(".onion") { + node.onion.clone() + } else { + format!("{}.onion", node.onion) + }; + let url = format!("http://{}/rpc/v1", host); + + let body = serde_json::json!({ + "method": "federation.peer-did-changed", + "params": { + "old_did": old_did, + "new_did": new_did, + "new_pubkey": new_pubkey, + "signature": proof_signature, + "proof_message": proof_message, + } + }); + + match client.post(&url).json(&body).send().await { + Ok(resp) if resp.status().is_success() => { + notified += 1; + results.push(serde_json::json!({ + "did": node.did, + "status": "ok", + })); + info!(peer_did = %node.did, "Notified peer of DID rotation"); + } + Ok(resp) => { + failed += 1; + results.push(serde_json::json!({ + "did": node.did, + "status": "error", + "error": format!("Peer returned {}", resp.status()), + })); + warn!(peer_did = %node.did, status = %resp.status(), "Peer rejected DID rotation notification"); + } + Err(e) => { + failed += 1; + results.push(serde_json::json!({ + "did": node.did, + "status": "error", + "error": e.to_string(), + })); + warn!(peer_did = %node.did, error = %e, "Failed to notify peer of DID rotation"); + } + } + } + + Ok(serde_json::json!({ + "notified": notified, + "failed": failed, + "results": results, + })) + } + + /// federation.peer-did-changed — A peer notifies us that their DID has rotated. + /// Verifies the rotation proof against the peer's KNOWN pubkey before accepting. + pub(super) async fn handle_federation_peer_did_changed( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let old_did = params + .get("old_did") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'old_did'"))?; + let new_did = params + .get("new_did") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'new_did'"))?; + let new_pubkey = params + .get("new_pubkey") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'new_pubkey'"))?; + let signature = params + .get("signature") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'signature'"))?; + + validate_did(old_did)?; + validate_did(new_did)?; + + // Validate new_pubkey is a valid 32-byte hex-encoded Ed25519 public key + let pubkey_bytes = hex::decode(new_pubkey) + .map_err(|_| anyhow::anyhow!("Invalid new_pubkey: not valid hex"))?; + if pubkey_bytes.len() != 32 { + anyhow::bail!("Invalid new_pubkey: must be 32 bytes (64 hex chars)"); + } + + // Validate signature is valid hex of correct length (64 bytes = 128 hex chars) + let sig_bytes = hex::decode(signature) + .map_err(|_| anyhow::anyhow!("Invalid signature: not valid hex"))?; + if sig_bytes.len() != 64 { + anyhow::bail!("Invalid signature: must be 64 bytes (128 hex chars)"); + } + + // Verify the new_did matches the new_pubkey + let expected_new_did = identity::did_key_from_pubkey_hex(new_pubkey)?; + if expected_new_did != new_did { + anyhow::bail!("new_did does not match new_pubkey"); + } + + // Load existing nodes, find the peer by their OLD DID + let mut nodes = federation::load_nodes(&self.config.data_dir).await?; + let found = nodes.iter_mut().find(|n| n.did == old_did); + + match found { + Some(node) => { + // Verify the rotation proof: the old key signed + // "did-rotate:{old_did}:{new_did}:{timestamp}" and the sender + // forwards both the signature and the full proof_message. + let proof_message = params + .get("proof_message") + .and_then(|v| v.as_str()); + + let verified = if let Some(msg) = proof_message { + // Verify the proof_message starts with the expected prefix + let expected_prefix = format!("did-rotate:{}:{}:", old_did, new_did); + if !msg.starts_with(&expected_prefix) { + warn!(old_did = %old_did, "Rejected DID rotation: proof_message has wrong prefix"); + anyhow::bail!("Invalid proof_message format"); + } + // Verify signature against the full proof_message using the KNOWN pubkey + matches!( + identity::NodeIdentity::verify(&node.pubkey, msg.as_bytes(), signature), + Ok(true) + ) + } else { + // Fallback: verify without timestamp (backwards-compatible) + let fallback_msg = format!("did-rotate:{}:{}", old_did, new_did); + matches!( + identity::NodeIdentity::verify(&node.pubkey, fallback_msg.as_bytes(), signature), + Ok(true) + ) + }; + + if !verified { + warn!(old_did = %old_did, "Rejected DID rotation: invalid signature"); + anyhow::bail!("Invalid signature — DID rotation rejected"); + } + + let old_pubkey = node.pubkey.clone(); + node.did = new_did.to_string(); + node.pubkey = new_pubkey.to_string(); + node.last_seen = Some(chrono::Utc::now().to_rfc3339()); + federation::save_nodes(&self.config.data_dir, &nodes).await?; + + info!( + old_did = %old_did, + new_did = %new_did, + old_pubkey = %old_pubkey, + "Updated federated peer DID (rotation signature verified)" + ); + + Ok(serde_json::json!({ + "updated": true, + "old_did": old_did, + "new_did": new_did, + })) + } + None => { + info!(old_did = %old_did, "Received DID rotation from unknown peer — ignoring"); + Ok(serde_json::json!({ + "updated": false, + "reason": "Unknown peer DID", + })) + } + } + } } diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index c2d7fc76..78b899b2 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -116,6 +116,7 @@ const UNAUTHENTICATED_METHODS: &[&str] = &[ // Inter-node RPC: called by federated peers over Tor, no session cookies "federation.peer-joined", "federation.peer-address-changed", + "federation.peer-did-changed", "federation.get-state", // Fleet telemetry ingest: called by remote nodes posting reports "telemetry.ingest", @@ -483,6 +484,7 @@ impl RpcHandler { "node.nostr-pubkey" => self.handle_node_nostr_pubkey().await, "node.nostr-sign" => self.handle_node_nostr_sign(params).await, "node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await, + "node.rotate-did" => self.handle_node_rotate_did(params).await, // Encrypted peer handshake (NIP-44) "handshake.discover" => self.handle_handshake_discover().await, @@ -643,6 +645,8 @@ impl RpcHandler { "federation.peer-joined" => self.handle_federation_peer_joined(params).await, "federation.deploy-app" => self.handle_federation_deploy_app(params).await, "federation.peer-address-changed" => self.handle_federation_peer_address_changed(params).await, + "federation.notify-did-change" => self.handle_federation_notify_did_change(params).await, + "federation.peer-did-changed" => self.handle_federation_peer_did_changed(params).await, // VPN & Remote Access "vpn.status" => self.handle_vpn_status().await, diff --git a/core/archipelago/src/api/rpc/node.rs b/core/archipelago/src/api/rpc/node.rs index b6d90773..1fd68b23 100644 --- a/core/archipelago/src/api/rpc/node.rs +++ b/core/archipelago/src/api/rpc/node.rs @@ -1,8 +1,11 @@ use super::RpcHandler; use crate::{backup, identity, nostr_discovery}; use crate::container::docker_packages; -use anyhow::Result; +use anyhow::{Context, Result}; +use ed25519_dalek::SigningKey; use nostr_sdk::ToBech32; +use rand::rngs::OsRng; +use tokio::fs; impl RpcHandler { pub(super) async fn handle_node_did(&self) -> Result { @@ -145,4 +148,81 @@ impl RpcHandler { "error": status.error, })) } + + /// Rotate the node's Ed25519 identity keypair. + /// Requires password re-verification. Returns a signed proof that peers can + /// use to verify the rotation was authorized by the holder of the old key. + pub(super) async fn handle_node_rotate_did( + &self, + params: Option, + ) -> Result { + 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' parameter"))?; + + // Re-verify password before allowing key rotation + if !self.auth_manager.verify_password(password).await? { + anyhow::bail!("Password verification failed"); + } + + let identity_dir = self.config.data_dir.join("identity"); + + // Load the current identity to get old DID and signing key + let old_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?; + let old_pubkey_hex = old_identity.pubkey_hex(); + let old_did = identity::did_key_from_pubkey_hex(&old_pubkey_hex)?; + + // Generate a new Ed25519 keypair + let new_signing_key = SigningKey::generate(&mut OsRng); + let new_pubkey_hex = hex::encode(new_signing_key.verifying_key().as_bytes()); + let new_did = identity::did_key_from_pubkey_hex(&new_pubkey_hex)?; + + // Create a rotation proof signed by the OLD key: + // "did-rotate:{old_did}:{new_did}:{timestamp}" + let timestamp = chrono::Utc::now().to_rfc3339(); + let proof_message = format!("did-rotate:{}:{}:{}", old_did, new_did, timestamp); + let proof_signature = old_identity.sign(proof_message.as_bytes()); + + // Write the new key files, overwriting the old ones + let key_path = identity_dir.join("node_key"); + let pub_path = identity_dir.join("node_key.pub"); + + fs::write(&key_path, new_signing_key.to_bytes()) + .await + .context("Failed to write new 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, new_signing_key.verifying_key().as_bytes()) + .await + .context("Failed to write new node public key")?; + + // Update in-memory state so the new pubkey is reflected immediately + let (mut data, _) = self.state_manager.get_snapshot().await; + data.server_info.pubkey = new_pubkey_hex.clone(); + self.state_manager.update_data(data).await; + + tracing::info!( + old_did = %old_did, + new_did = %new_did, + "Node DID rotated successfully" + ); + + Ok(serde_json::json!({ + "old_did": old_did, + "new_did": new_did, + "new_pubkey": new_pubkey_hex, + "proof_signature": proof_signature, + "proof_message": proof_message, + "timestamp": timestamp, + })) + } } diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index e7a394ac..c86d653c 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -526,7 +526,10 @@ impl EndpointRateLimiter { // Inter-node federation RPCs (unauthenticated, need stricter limits) limits.insert("federation.peer-joined".to_string(), (10, 60)); limits.insert("federation.peer-address-changed".to_string(), (10, 60)); + limits.insert("federation.peer-did-changed".to_string(), (5, 60)); limits.insert("federation.get-state".to_string(), (30, 60)); + // DID rotation: sensitive identity operation + limits.insert("node.rotate-did".to_string(), (3, 600)); Self { requests: Arc::new(RwLock::new(HashMap::new())), diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index e492dc0f..25111093 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.13rknjqo28" + "revision": "0.fo87inn2rb8" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {