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
parent 919665fb16
commit 950549304d
7 changed files with 282 additions and 13 deletions

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,

View File

@ -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?;

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,

View File

@ -18,16 +18,22 @@ pub struct ParsedInvite {
pub token: String,
/// Inviter's FIPS npub if advertised in the code.
pub fips_npub: Option<String>,
/// Trust level the invite grants both sides. Absent in legacy codes,
/// which default to Trusted.
pub trust_level: TrustLevel,
}
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token, fips_npub?})>`.
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token, fips_npub?, trust})>`.
/// `fips_npub` is only included when the local node has a materialised FIPS key.
/// `trust_level` is the level BOTH sides assign to each other for this invite
/// ("Invite a Peer" = Observer, "Link Your Nodes" = Trusted).
pub async fn create_invite(
data_dir: &Path,
did: &str,
onion: &str,
pubkey: &str,
fips_npub: Option<&str>,
trust_level: TrustLevel,
) -> Result<String> {
use base64::Engine;
use rand::Rng;
@ -41,6 +47,7 @@ pub async fn create_invite(
"onion": onion,
"pubkey": pubkey,
"token": token,
"trust": trust_level.to_string(),
});
if let Some(npub) = fips_npub {
payload["fips_npub"] = serde_json::Value::String(npub.to_string());
@ -59,6 +66,7 @@ pub async fn create_invite(
created_at: chrono::Utc::now().to_rfc3339(),
accepted: false,
fips_npub: fips_npub.map(|s| s.to_string()),
trust_level,
};
let mut invites = load_invites(data_dir).await?;
@ -103,6 +111,13 @@ pub fn parse_invite(code: &str) -> Result<ParsedInvite> {
.get("fips_npub")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Legacy codes (pre trust-threading) carry no "trust" field → Trusted,
// which matches what both sides did before the field existed.
let trust_level = payload
.get("trust")
.and_then(|v| v.as_str())
.and_then(TrustLevel::parse)
.unwrap_or(TrustLevel::Trusted);
Ok(ParsedInvite {
did,
@ -110,6 +125,7 @@ pub fn parse_invite(code: &str) -> Result<ParsedInvite> {
pubkey,
token,
fips_npub,
trust_level,
})
}
@ -128,8 +144,9 @@ pub async fn accept_invite(
did,
onion,
pubkey,
token: _,
token,
fips_npub,
trust_level,
} = parse_invite(code)?;
// Refuse self-peering. If the invite's did / onion / pubkey matches
@ -173,7 +190,9 @@ pub async fn accept_invite(
pubkey,
onion,
name: None,
trust_level: TrustLevel::Trusted,
// The invite code itself says what this relationship is —
// Observer for "Invite a Peer", Trusted for "Link Your Nodes".
trust_level,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
@ -194,6 +213,7 @@ pub async fn accept_invite(
created_at: chrono::Utc::now().to_rfc3339(),
accepted: true,
fips_npub,
trust_level,
});
save_invites(data_dir, &invites).await?;
@ -206,6 +226,8 @@ pub async fn accept_invite(
local_pubkey,
local_fips_npub,
local_name,
Some(&token),
trust_level,
sign_fn,
)
.await;
@ -217,6 +239,7 @@ 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.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn notify_join(
remote_onion: &str,
remote_fips_npub: Option<&str>,
@ -225,6 +248,8 @@ pub(crate) async fn notify_join(
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
invite_token: Option<&str>,
trust_level: TrustLevel,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<()> {
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
@ -233,6 +258,11 @@ pub(crate) async fn notify_join(
// (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.
//
// invite_token + trust are also unsigned: the inviter treats the token
// as a lookup key into its own stored invites (authoritative for the
// granted level) and only ever accepts the trust claim as a DOWNGRADE,
// so neither field can escalate privileges.
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
let signature = sign_fn(sign_data.as_bytes());
@ -241,6 +271,7 @@ pub(crate) async fn notify_join(
"onion": local_onion,
"pubkey": local_pubkey,
"signature": signature,
"trust": trust_level.to_string(),
});
if let Some(npub) = local_fips_npub {
params["fips_npub"] = serde_json::Value::String(npub.to_string());
@ -248,6 +279,9 @@ pub(crate) async fn notify_join(
if let Some(name) = local_name {
params["name"] = serde_json::Value::String(name.to_string());
}
if let Some(token) = invite_token {
params["invite_token"] = serde_json::Value::String(token.to_string());
}
let body = serde_json::json!({
"method": "federation.peer-joined",
@ -318,7 +352,14 @@ mod tests {
#[tokio::test]
async fn test_create_and_parse_invite() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", None)
let code = create_invite(
dir.path(),
"did:key:z1",
"test.onion",
"aabbcc",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
assert!(code.starts_with("fed1:"));
@ -335,7 +376,14 @@ mod tests {
async fn test_invite_roundtrips_fips_npub() {
let dir = tempfile::tempdir().unwrap();
let fips = "npub1fipstest0000000000000000000000000000000000";
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", Some(fips))
let code = create_invite(
dir.path(),
"did:key:z1",
"test.onion",
"aabbcc",
Some(fips),
TrustLevel::Trusted,
)
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
@ -362,6 +410,126 @@ mod tests {
assert!(parsed.fips_npub.is_none());
}
#[tokio::test]
async fn test_observer_invite_threads_trust_level() {
// "Invite a Peer" mints an observer invite; the acceptor must land
// on Observer, not Trusted (the original bug).
let dir = tempfile::tempdir().unwrap();
let code = create_invite(
dir.path(),
"did:key:zRemote",
"remote.onion",
"remotepub",
None,
TrustLevel::Observer,
)
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Observer);
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
assert_eq!(node.trust_level, TrustLevel::Observer);
// The inviter's stored outgoing invite carries the level too — this
// is what peer-joined uses as the authoritative grant on the far side.
let invites = load_invites(dir.path()).await.unwrap();
assert_eq!(invites.outgoing.len(), 1);
assert_eq!(invites.outgoing[0].trust_level, TrustLevel::Observer);
}
#[test]
fn test_parse_legacy_invite_defaults_to_trusted() {
// Codes minted before the trust field existed must stay Trusted.
use base64::Engine;
let legacy = serde_json::json!({
"did": "did:key:zOld",
"onion": "old.onion",
"pubkey": "00",
"token": "aa",
});
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_string(&legacy).unwrap())
);
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
}
#[tokio::test]
async fn test_observer_invite_threads_trust_level() {
// "Invite a Peer" mints an observer invite; the acceptor must land
// on Observer, not Trusted (the original bug).
let dir = tempfile::tempdir().unwrap();
let code = create_invite(
dir.path(),
"did:key:zRemote",
"remote.onion",
"remotepub",
None,
TrustLevel::Observer,
)
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Observer);
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
assert_eq!(node.trust_level, TrustLevel::Observer);
// The inviter's stored outgoing invite carries the level too — this
// is what peer-joined uses as the authoritative grant on the far side.
let invites = load_invites(dir.path()).await.unwrap();
assert_eq!(invites.outgoing.len(), 1);
assert_eq!(invites.outgoing[0].trust_level, TrustLevel::Observer);
}
#[test]
fn test_parse_legacy_invite_defaults_to_trusted() {
// Codes minted before the trust field existed must stay Trusted.
use base64::Engine;
let legacy = serde_json::json!({
"did": "did:key:zOld",
"onion": "old.onion",
"pubkey": "00",
"token": "aa",
});
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_string(&legacy).unwrap())
);
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
}
#[test]
fn test_parse_invalid_invite() {
assert!(parse_invite("invalid").is_err());
@ -377,6 +545,7 @@ mod tests {
"remote.onion",
"remotepub",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
@ -413,6 +582,7 @@ mod tests {
"remote.onion",
"remotepub",
Some(fips),
TrustLevel::Trusted,
)
.await
.unwrap();
@ -447,6 +617,7 @@ mod tests {
"remote.onion",
"remotepub",
None,
TrustLevel::Trusted,
)
.await
.unwrap();

View File

@ -11,10 +11,13 @@ mod sync;
mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite};
pub use invites::{accept_invite, create_invite, parse_invite};
// Crate-internal: used by the periodic federation auto-sync to re-assert
// membership to peers that don't list us back (asymmetry self-heal).
pub(crate) use invites::notify_join;
// Crate-internal: peer-joined resolves the granted trust level by matching
// the acceptor's invite_token against our stored outgoing invites.
pub(crate) use storage::load_invites;
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,

View File

@ -21,6 +21,34 @@ impl std::fmt::Display for TrustLevel {
}
}
impl TrustLevel {
/// Parse the lowercase wire form ("trusted" | "observer" | "untrusted").
pub fn parse(s: &str) -> Option<Self> {
match s {
"trusted" => Some(TrustLevel::Trusted),
"observer" => Some(TrustLevel::Observer),
"untrusted" => Some(TrustLevel::Untrusted),
_ => None,
}
}
/// The lower (less privileged) of two levels: Untrusted < Observer < Trusted.
pub fn min(self, other: Self) -> Self {
fn rank(l: TrustLevel) -> u8 {
match l {
TrustLevel::Untrusted => 0,
TrustLevel::Observer => 1,
TrustLevel::Trusted => 2,
}
}
if rank(self) <= rank(other) {
self
} else {
other
}
}
}
/// A federated node in our cluster.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedNode {
@ -138,6 +166,15 @@ pub struct FederationInvite {
/// Inviter's FIPS mesh npub if advertised in the code.
#[serde(default)]
pub fips_npub: Option<String>,
/// Trust level this invite grants both sides ("Invite a Peer" mints
/// Observer invites, "Link Your Nodes" mints Trusted ones). Defaults
/// to Trusted for invites minted before this field existed.
#[serde(default = "default_invite_trust")]
pub trust_level: TrustLevel,
}
fn default_invite_trust() -> TrustLevel {
TrustLevel::Trusted
}
#[cfg(test)]

View File

@ -515,6 +515,10 @@ impl Server {
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
// Re-assert at the level WE hold for
// this peer; no invite token on heal.
None,
node.trust_level,
|b| node_identity.sign(b),
)
.await