fix(federation): end the perpetual peer-joined "Invalid signature" storm

Root cause observed live 2026-08-16: onboarding/seed-restore rewrite
identity/node_key on disk but server_info.pubkey is only seeded at boot,
so until the next restart every peer-joined advertised the stale boot key
while signing with the new seed-derived key — deterministically rejected
by every receiver, once per 90s heal tick, forever.

- seed.generate / seed.restore now refresh server_info.pubkey in the live
  snapshot immediately (mirrors the DID-rotation handler).
- The 90s heal loop advertises the SAME key it signs with (disk identity,
  like federation sync already did) instead of the boot snapshot.
- notify_join no longer logs "delivered" for an HTTP-200 JSON-RPC
  rejection; in-band errors are terminal (identical signed bytes can
  never succeed on retry).
- The heal loop backs off per peer (doubling toward a daily re-assert)
  instead of re-notifying every 90s forever — Observer-held peers never
  appear in Trusted-only exported hints, so they_list_us could never
  become true for them.
- Receiver now binds the DID to the advertised pubkey (the old check was
  self-referential) and logs malformed signatures distinctly from
  genuine mismatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-16 04:45:39 -04:00
co-authored by Claude Fable 5
parent 809f7649a4
commit 519fa68c72
4 changed files with 134 additions and 26 deletions
@@ -696,6 +696,22 @@ impl RpcHandler {
anyhow::bail!("Refusing to peer with self");
}
// Bind the DID to the advertised pubkey. Without this the signature
// check below is self-referential (the caller signs over a pubkey it
// also supplies), so a consistent-but-unrelated keypair would pass.
// The DID-rotation handler already enforces the same invariant.
match identity::did_key_from_pubkey_hex(pubkey) {
Ok(derived) if derived == did => {}
Ok(derived) => {
tracing::warn!(peer_did = %did, derived_did = %derived, "Rejected peer-joined: DID does not match pubkey");
anyhow::bail!("DID does not match pubkey");
}
Err(e) => {
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: invalid pubkey");
anyhow::bail!("Invalid pubkey");
}
}
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
let signature = params.get("signature").and_then(|v| v.as_str());
match signature {
@@ -703,10 +719,16 @@ impl RpcHandler {
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
Ok(true) => {}
_ => {
Ok(false) => {
tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature");
anyhow::bail!("Invalid signature");
}
Err(e) => {
// Malformed hex / wrong length — distinguish from a
// genuine mismatch so the log tells us which it was.
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: malformed signature");
anyhow::bail!("Invalid signature");
}
}
}
None => {
+32 -1
View File
@@ -176,6 +176,29 @@ pub(in crate::api::rpc) async fn restore_node_identity_from_words(
}
impl RpcHandler {
/// Push the on-disk node identity's pubkey into the live `server_info`
/// snapshot. `seed.generate` / `seed.restore` rewrite `identity/node_key`,
/// but `server_info.pubkey` was only seeded at boot — until the next
/// restart every federation peer-joined advertised the stale boot key
/// while signing with the new seed-derived key, so receivers rejected it
/// ("Invalid signature") on every 90s heal tick (2026-08-16). Mirrors the
/// DID-rotation handler, which already does this refresh.
async fn refresh_server_pubkey_from_disk(&self) {
let identity_dir = self.config.data_dir.join("identity");
let identity = match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = %e, "Could not reload node identity after seed write — server_info.pubkey stays stale until restart");
return;
}
};
let (mut data, _) = self.state_manager.get_snapshot().await;
if data.server_info.pubkey != identity.pubkey_hex() {
data.server_info.pubkey = identity.pubkey_hex();
self.state_manager.update_data(data).await;
}
}
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
/// Returns the words for the user to write down.
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
@@ -242,6 +265,10 @@ impl RpcHandler {
// Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// The node key on disk just changed — keep the live snapshot's pubkey
// in lockstep (see refresh_server_pubkey_from_disk for why).
self.refresh_server_pubkey_from_disk().await;
// fips_key is now on disk — auto-activate FIPS so the user doesn't
// have to hit a manual Start button. Detached task;
// the onboarding RPC returns immediately.
@@ -350,7 +377,11 @@ impl RpcHandler {
)
.context("Invalid words array")?;
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words).await
let result =
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words)
.await?;
self.refresh_server_pubkey_from_disk().await;
Ok(result)
}
/// Encrypt and save the mnemonic to disk for convenience backup.