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:
co-authored by
Claude Opus 5
parent
ff2cb5aa0e
commit
c0cfc72a05
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user