fix(federation): thread invite trust level end-to-end — 'Invite a Peer' now federates as Observer

Invites carried no trust level, and both accept_invite and the
peer-joined handler hardcoded TrustLevel::Trusted — so 'Invite a Peer'
(Observer) federated both sides as fully Trusted.

- invite codes now carry a 'trust' field (legacy codes parse as Trusted)
- federation.invite accepts trust_level: trusted|observer
- accept_invite assigns the invite's level on the acceptor side
- peer-joined resolves the granted level authoritatively by matching the
  acceptor's echoed invite token against our stored outgoing invites;
  the peer's own unsigned claim is honored only as a downgrade, so no
  escalation is possible
- discovery/connection-request approvals mint Observer invites directly
  (the post-hoc demotion in handshake.rs stays as a legacy safety net)
- asymmetry self-heal re-asserts at the locally-held level

Tests: observer threading + legacy default-trusted parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-14 06:24:38 -04:00
co-authored by Claude Fable 5
parent 919665fb16
commit 950549304d
7 changed files with 282 additions and 13 deletions
+1 -1
View File
@@ -326,7 +326,7 @@ impl RpcHandler {
}
// Federation
"federation.invite" => self.handle_federation_invite().await,
"federation.invite" => self.handle_federation_invite(params).await,
"federation.join" => self.handle_federation_join(params).await,
"federation.list-nodes" => self.handle_federation_list_nodes().await,
"federation.remove-node" => self.handle_federation_remove_node(params).await,
@@ -53,7 +53,23 @@ impl RpcHandler {
impl RpcHandler {
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
/// Optional param `trust_level`: "trusted" (default, "Link Your Nodes") or
/// "observer" ("Invite a Peer") — the level BOTH sides assign for this invite.
pub(in crate::api::rpc) async fn handle_federation_invite(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let trust_level = params
.as_ref()
.and_then(|p| p.get("trust_level"))
.and_then(|v| v.as_str())
.map(|s| {
TrustLevel::parse(s)
.ok_or_else(|| anyhow::anyhow!("Invalid trust_level: {s} (expected trusted|observer)"))
})
.transpose()?
.unwrap_or(TrustLevel::Trusted);
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let onion = data.server_info.tor_address.clone().unwrap_or_default();
@@ -72,14 +88,16 @@ impl RpcHandler {
&onion,
&pubkey,
fips_npub.as_deref(),
trust_level,
)
.await?;
info!(did = %did, fips_advertised = fips_npub.is_some(), "Generated federation invite");
info!(did = %did, trust = %trust_level, fips_advertised = fips_npub.is_some(), "Generated federation invite");
Ok(serde_json::json!({
"code": code,
"did": did,
"onion": onion,
"trust_level": trust_level.to_string(),
}))
}
@@ -511,6 +529,36 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Resolve the trust level granted by this join. Authoritative source:
// the acceptor echoes the invite's random token, which we match against
// OUR stored outgoing invites — the level we minted the code with wins.
// Fallback: the peer's (unsigned) "trust" claim, honored only as a
// DOWNGRADE from Trusted so it can never escalate. Legacy peers send
// neither → Trusted, matching pre-threading behavior.
let claimed_trust = params
.get("trust")
.and_then(|v| v.as_str())
.and_then(TrustLevel::parse)
.unwrap_or(TrustLevel::Trusted);
let invite_trust = match params.get("invite_token").and_then(|v| v.as_str()) {
Some(token) => federation::load_invites(&self.config.data_dir)
.await
.ok()
.and_then(|invites| {
invites.outgoing.iter().find_map(|inv| {
federation::parse_invite(&inv.code)
.ok()
.filter(|p| p.token == token)
.map(|_| inv.trust_level)
})
}),
None => None,
};
let granted_trust = match invite_trust {
Some(level) => level,
None => TrustLevel::Trusted.min(claimed_trust),
};
// 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
@@ -603,7 +651,7 @@ impl RpcHandler {
pubkey: pubkey.to_string(),
onion: onion.to_string(),
name: incoming_name.clone(),
trust_level: TrustLevel::Trusted,
trust_level: granted_trust,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
@@ -613,7 +661,7 @@ impl RpcHandler {
};
federation::add_node(&self.config.data_dir, node).await?;
info!(peer_did = %did, "Peer joined our federation");
info!(peer_did = %did, trust = %granted_trust, "Peer joined our federation");
// Mirror into mesh state so the inbound peer is addressable from
// the chat UI without waiting for the next mesh restart.
@@ -1046,12 +1094,16 @@ impl RpcHandler {
// ciphertext below.
let identity_dir = self.config.data_dir.join("identity");
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
// Discovery/connection-request approvals admit the requester as
// Observer — the invite itself now carries that level, so both
// sides converge on Observer without post-hoc demotion.
let invite_code = federation::create_invite(
&self.config.data_dir,
&local_did,
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
TrustLevel::Observer,
)
.await?;
+4 -2
View File
@@ -298,8 +298,10 @@ impl RpcHandler {
Ok(node) => {
// Approved-by-them: their box already has us as Observer
// (their approval handler added us under that trust level
// before sending the invite). Demote our local entry to
// Observer too — accept_invite hardcodes Trusted, but the
// before sending the invite). Discovery invites are now
// minted with trust=observer, so accept_invite already
// lands on Observer; keep this explicit demotion as a
// safety net for legacy Trusted-only invite codes — the
// discovery flow should never auto-trust.
let _ = crate::federation::set_trust_level(
&self.config.data_dir,