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:
Dorian
2026-03-19 19:27:16 +00:00
co-authored by Claude Opus 4.6
parent f8794791f3
commit 1a138c0409
5 changed files with 312 additions and 4 deletions
+223 -2
View File
@@ -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<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",
}))
}
}
}
}