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:
co-authored by
Claude Fable 5
parent
919665fb16
commit
950549304d
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user