fix(security): peers must not be able to grant themselves Trusted

Reported: "peers seem to be slipping into trusted status somehow which is
absolutely terrible for security". Two independent fail-open paths, both
granting Trusted with no operator decision anywhere in the loop.

1. federation.peer-joined is UNAUTHENTICATED (middleware's no-session
   list — federated peers call it over Tor without cookies) and reachable
   on /rpc/v1, which is peer-allowed. It does verify an ed25519 signature,
   but against THE PUBKEY THE CALLER SUPPLIED, so it proves the caller
   holds its own key and nothing about whether we ever invited it. A join
   presenting no invite_token fell through to

       None => TrustLevel::Trusted.min(claimed_trust)

   and claimed_trust itself defaults to Trusted when the field is absent.
   So anything able to reach the node could generate a keypair, omit the
   token, and be recorded as Trusted. Now capped at Observer: an invite
   WE minted is the only path to Trusted. `min` is kept so a peer's own
   lower claim is still honoured — this can only ever reduce trust.

2. merge_transitive_peers added every peer advertised by a Trusted source
   as Trusted. That makes trust viral rather than transitive-by-one-hop:
   the merged node is itself synced with, its peers merged in turn, so a
   single invite anywhere in the graph eventually marked the entire graph
   Trusted on every node. Now Observer — which is what this feature's own
   spec always said. NodeStateSnapshot.federated_peers is documented as
   "adds them as Observers on her side… doesn't auto-promote Observer-via-
   Bob to Trusted". The code contradicted the comment directly above it.

Observer is deliberate rather than Untrusted: the merge exists for
routing, and Observer still passes the `!= Untrusted` gates that
federation, DWN and messaging actually check, so a legacy peer degrades
instead of breaking. Per the operator's decision, existing peers are NOT
auto-demoted — silently rewriting live trust relationships across the
fleet would be worse than the bug.

Instead they are made auditable: FederatedNode.trust_source records WHY a
level was granted (invite | uninvited-join | transitive-merge | manual).
It deliberately has no default provenance — None means "recorded before
this existed", which is exactly the population worth reviewing.

The one failing test was asserting the vulnerable behaviour
(merge_transitive_peers_skips_source_and_local_node expected Trusted); it
now asserts the security property and says why, so the escalation cannot
be reintroduced by making a test go green.

Verified: 42/42 federation tests, cargo check --all-targets clean.

Still open, tracked in .planning/RELEASE-1.7.121-TASKS.md: surface
trust_source in the UI, and require the node password to grant Trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 10:31:16 -04:00
co-authored by Claude Opus 5
parent ff2cb5aa0e
commit c0cfc72a05
6 changed files with 101 additions and 8 deletions
@@ -565,9 +565,27 @@ impl RpcHandler {
}),
None => None,
};
let granted_trust = match invite_trust {
Some(level) => level,
None => TrustLevel::Trusted.min(claimed_trust),
// An invite WE minted is the only thing that may grant Trusted.
//
// This handler is unauthenticated (see middleware.rs: federated peers
// call it over Tor with no session) and reachable on /rpc/v1. Its
// signature check proves only that the caller holds the private key for
// the pubkey IT SUPPLIED — anyone can generate a keypair — so it
// establishes identity, never authorisation. Defaulting an unmatched
// join to Trusted therefore let any party that could reach the node
// self-grant Trusted by simply omitting `invite_token`.
//
// Capped at Observer instead: still recorded, still reachable, still
// passes the `!= Untrusted` gates that federation/DWN/messaging use, so
// a legacy peer re-joining degrades rather than breaks — but it cannot
// reach a level the operator never granted. `min` keeps a peer's own
// lower claim honoured, so this can only ever reduce trust.
let (granted_trust, trust_source) = match invite_trust {
Some(level) => (level, federation::TrustSource::Invite),
None => (
TrustLevel::Observer.min(claimed_trust),
federation::TrustSource::UninvitedJoin,
),
};
// Reject self-peering. If somehow our own did / onion / pubkey
@@ -671,10 +689,16 @@ impl RpcHandler {
last_transport_at: None,
last_sync_error: None,
last_sync_error_at: None,
trust_source: Some(trust_source),
};
federation::add_node(&self.config.data_dir, node).await?;
info!(peer_did = %did, trust = %granted_trust, "Peer joined our federation");
info!(
peer_did = %did,
trust = %granted_trust,
source = ?trust_source,
"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.