release(v1.7.16-alpha): bidirectional + transitive federation, no self-peering
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 23m32s

Federation join flow now notifies the inviter with the joiner's name and
immediately bumps state so the Federation UI reloads without a manual
Sync click. Accepting an invite that points back at the local node is
rejected up front (DID/pubkey/onion match). After a peer joins, we spawn
a transitive sync that pulls the new peer's federated peer hints so all
nodes in the federation learn about each other as Observer entries.
Federation.vue polls every 5s while mounted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-20 18:12:02 -04:00
parent 0fad7ee431
commit 3cbfcabedf
12 changed files with 357 additions and 28 deletions

2
core/Cargo.lock generated
View File

@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.15-alpha"
version = "1.7.16-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.15-alpha"
version = "1.7.16-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@@ -79,6 +79,7 @@ impl RpcHandler {
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
let local_pubkey = data.server_info.pubkey.clone();
let local_name = data.server_info.name.clone();
let identity_dir = self.config.data_dir.join("identity");
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
@@ -90,6 +91,7 @@ impl RpcHandler {
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
|data| node_identity.sign(data),
)
.await?;
@@ -447,6 +449,38 @@ impl RpcHandler {
.get("fips_npub")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Optional, unsigned: peer's display name. Display-only — identity
// claims are anchored on the signed did/pubkey below.
let incoming_name = params
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Reject self-peering. If somehow our own did / onion / pubkey
// comes back at us (misconfigured invite, gossip loop), adding
// the entry causes sync loops where the node syncs with itself
// forever. Drop it quietly — no useful recovery path.
let (own_data, _) = self.state_manager.get_snapshot().await;
let own_did_result =
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_onion_trim = own_data
.server_info
.tor_address
.as_deref()
.unwrap_or("")
.trim_end_matches(".onion")
.to_string();
let incoming_onion_trim = onion.trim_end_matches(".onion");
if own_did_result.as_deref() == Some(did)
|| pubkey == own_data.server_info.pubkey
|| (!own_onion_trim.is_empty() && own_onion_trim == incoming_onion_trim)
{
tracing::warn!(
peer_did = %did,
"Rejected peer-joined: inbound identity matches this node"
);
anyhow::bail!("Refusing to peer with self");
}
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
let signature = params.get("signature").and_then(|v| v.as_str());
@@ -471,11 +505,12 @@ impl RpcHandler {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
// If already known but missing onion/pubkey/fips_npub, update them
// If already known but missing onion/pubkey/fips_npub/name, update them
let needs_onion = existing.onion.is_empty();
let needs_pubkey = existing.pubkey.is_empty();
let needs_fips = existing.fips_npub.is_none() && fips_npub.is_some();
if needs_onion || needs_pubkey || needs_fips {
let needs_name = existing.name.is_none() && incoming_name.is_some();
if needs_onion || needs_pubkey || needs_fips || needs_name {
let mut updated = existing.clone();
if needs_onion && !onion.is_empty() {
updated.onion = onion.to_string();
@@ -486,6 +521,9 @@ impl RpcHandler {
if needs_fips {
updated.fips_npub = fips_npub.clone();
}
if needs_name {
updated.name = incoming_name.clone();
}
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
federation::update_node(&self.config.data_dir, &updated).await?;
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields");
@@ -497,7 +535,7 @@ impl RpcHandler {
did: did.to_string(),
pubkey: pubkey.to_string(),
onion: onion.to_string(),
name: None,
name: incoming_name.clone(),
trust_level: TrustLevel::Trusted,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
@@ -512,9 +550,38 @@ impl RpcHandler {
// Mirror into mesh state so the inbound peer is addressable from
// the chat UI without waiting for the next mesh restart.
self.register_federation_peer_in_mesh(pubkey, did, None)
self.register_federation_peer_in_mesh(pubkey, did, incoming_name.as_deref())
.await;
// Bump the data-model revision so any Federation view with an
// open WebSocket reloads its node list without waiting for the
// user to click Sync.
let (data, _) = self.state_manager.get_snapshot().await;
self.state_manager.update_data(data).await;
// Transitive discovery: spawn a task that pulls the new peer's
// state (its own federated peers end up as Observer entries on
// our side) so after a join every existing peer in our list is
// aware of the newcomer via the next pair of syncs, without the
// user clicking anything. Best-effort; errors are logged only.
let data_dir = self.config.data_dir.clone();
let new_peer_did = did.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if let Err(e) = crate::federation::sync_with_peer_by_did(
&data_dir,
&new_peer_did,
)
.await
{
tracing::debug!(
peer_did = %new_peer_did,
error = %e,
"Transitive sync on peer-joined failed (non-fatal)"
);
}
});
Ok(serde_json::json!({ "accepted": true }))
}

View File

@@ -282,6 +282,7 @@ impl RpcHandler {
let local_fips_npub = crate::identity::fips_npub(&identity_dir2)
.await
.unwrap_or(None);
let local_name = data.server_info.name.clone();
match crate::federation::accept_invite(
&self.config.data_dir,
invite_code,
@@ -289,6 +290,7 @@ impl RpcHandler {
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
|bytes| node_identity.sign(bytes),
)
.await

View File

@@ -121,6 +121,7 @@ pub async fn accept_invite(
local_onion: &str,
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<FederatedNode> {
let ParsedInvite {
@@ -131,6 +132,20 @@ pub async fn accept_invite(
fips_npub,
} = parse_invite(code)?;
// Refuse self-peering. If the invite's did / onion / pubkey matches
// our own, adding it pollutes the federation list with a node that
// sees itself as its own peer and causes sync loops. The user
// almost certainly pasted the wrong invite.
if did == local_did || pubkey == local_pubkey || {
let a = onion.trim_end_matches(".onion");
let b = local_onion.trim_end_matches(".onion");
!a.is_empty() && a == b
} {
anyhow::bail!(
"Refusing to federate with self — invite points at this node's own did / onion / pubkey"
);
}
// Make accept idempotent: drop any existing entry that conflicts with
// this invite — same DID (same node, refreshing the link), same onion
// (node rotated identity but kept its hidden service), or same pubkey
@@ -190,6 +205,7 @@ pub async fn accept_invite(
local_onion,
local_pubkey,
local_fips_npub,
local_name,
sign_fn,
)
.await;
@@ -201,20 +217,22 @@ pub async fn accept_invite(
/// Prefers FIPS (if the remote advertised an npub in their invite) and
/// falls back to Tor. Signs the message with our ed25519 key so the
/// remote peer can verify authenticity regardless of transport.
async fn notify_join(
pub(crate) async fn notify_join(
remote_onion: &str,
remote_fips_npub: Option<&str>,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<()> {
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
// Signature domain intentionally unchanged — fips_npub is carried
// as an unsigned informational field. The FIPS daemon's own Noise
// handshake authenticates the actual transport session, so a
// stripped/substituted npub here merely downgrades the path to Tor.
// Signature domain intentionally unchanged — fips_npub + name are
// carried as unsigned informational fields. Name is display-only
// (any identity claim is anchored on the signed did/pubkey); the
// FIPS daemon's own Noise handshake authenticates the transport
// session regardless of the advertised npub.
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
let signature = sign_fn(sign_data.as_bytes());
@@ -227,6 +245,9 @@ async fn notify_join(
if let Some(npub) = local_fips_npub {
params["fips_npub"] = serde_json::Value::String(npub.to_string());
}
if let Some(name) = local_name {
params["name"] = serde_json::Value::String(name.to_string());
}
let body = serde_json::json!({
"method": "federation.peer-joined",

View File

@@ -17,5 +17,5 @@ pub use storage::{
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};

View File

@@ -82,6 +82,30 @@ pub async fn sync_with_peer(
Ok(state)
}
/// Convenience wrapper: look up a federated peer by DID, derive our
/// own local_did / signing context from the node identity on disk, and
/// call sync_with_peer. Used by transitive-discovery code paths where
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
/// follow-up task).
pub async fn sync_with_peer_by_did(
data_dir: &Path,
peer_did: &str,
) -> Result<NodeStateSnapshot> {
let nodes = super::storage::load_nodes(data_dir).await?;
let peer = nodes
.into_iter()
.find(|n| n.did == peer_did)
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
let identity_dir = data_dir.join("identity");
let node_identity =
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let local_pubkey_hex = node_identity.pubkey_hex();
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
sync_with_peer(data_dir, &peer, &local_did, |data| node_identity.sign(data)).await
}
/// Merge peers advertised by a Trusted federated node into our own
/// federation list. New peers are added at `Observer` trust (not
/// Trusted — that requires a direct invite). Existing peers get their