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) <noreply@anthropic.com>
This commit is contained in:
parent
9f5cad7de0
commit
fb953e888a
@ -4,8 +4,8 @@ use crate::federation::{self, FederatedNode, TrustLevel};
|
|||||||
use crate::identity;
|
use crate::identity;
|
||||||
use crate::identity_manager::IdentityManager;
|
use crate::identity_manager::IdentityManager;
|
||||||
use crate::network::dwn_store::DwnStore;
|
use crate::network::dwn_store::DwnStore;
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,6 +116,7 @@ const UNAUTHENTICATED_METHODS: &[&str] = &[
|
|||||||
// Inter-node RPC: called by federated peers over Tor, no session cookies
|
// Inter-node RPC: called by federated peers over Tor, no session cookies
|
||||||
"federation.peer-joined",
|
"federation.peer-joined",
|
||||||
"federation.peer-address-changed",
|
"federation.peer-address-changed",
|
||||||
|
"federation.peer-did-changed",
|
||||||
"federation.get-state",
|
"federation.get-state",
|
||||||
// Fleet telemetry ingest: called by remote nodes posting reports
|
// Fleet telemetry ingest: called by remote nodes posting reports
|
||||||
"telemetry.ingest",
|
"telemetry.ingest",
|
||||||
@ -483,6 +484,7 @@ impl RpcHandler {
|
|||||||
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||||
"node.nostr-sign" => self.handle_node_nostr_sign(params).await,
|
"node.nostr-sign" => self.handle_node_nostr_sign(params).await,
|
||||||
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().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)
|
// Encrypted peer handshake (NIP-44)
|
||||||
"handshake.discover" => self.handle_handshake_discover().await,
|
"handshake.discover" => self.handle_handshake_discover().await,
|
||||||
@ -643,6 +645,8 @@ impl RpcHandler {
|
|||||||
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
|
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
|
||||||
"federation.deploy-app" => self.handle_federation_deploy_app(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.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 & Remote Access
|
||||||
"vpn.status" => self.handle_vpn_status().await,
|
"vpn.status" => self.handle_vpn_status().await,
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::{backup, identity, nostr_discovery};
|
use crate::{backup, identity, nostr_discovery};
|
||||||
use crate::container::docker_packages;
|
use crate::container::docker_packages;
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
use nostr_sdk::ToBech32;
|
use nostr_sdk::ToBech32;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
||||||
@ -145,4 +148,81 @@ impl RpcHandler {
|
|||||||
"error": status.error,
|
"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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -526,7 +526,10 @@ impl EndpointRateLimiter {
|
|||||||
// Inter-node federation RPCs (unauthenticated, need stricter limits)
|
// Inter-node federation RPCs (unauthenticated, need stricter limits)
|
||||||
limits.insert("federation.peer-joined".to_string(), (10, 60));
|
limits.insert("federation.peer-joined".to_string(), (10, 60));
|
||||||
limits.insert("federation.peer-address-changed".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));
|
limits.insert("federation.get-state".to_string(), (30, 60));
|
||||||
|
// DID rotation: sensitive identity operation
|
||||||
|
limits.insert("node.rotate-did".to_string(), (3, 600));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
requests: Arc::new(RwLock::new(HashMap::new())),
|
requests: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
|||||||
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.13rknjqo28"
|
"revision": "0.fo87inn2rb8"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user