From 950549304d363505763adb3d48096ef66d5f71ad Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 14 Jul 2026 06:24:38 -0400 Subject: [PATCH] =?UTF-8?q?fix(federation):=20thread=20invite=20trust=20le?= =?UTF-8?q?vel=20end-to-end=20=E2=80=94=20'Invite=20a=20Peer'=20now=20fede?= =?UTF-8?q?rates=20as=20Observer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/archipelago/src/api/rpc/dispatcher.rs | 2 +- .../src/api/rpc/federation/handlers.rs | 60 +++++- core/archipelago/src/api/rpc/handshake.rs | 6 +- core/archipelago/src/federation/invites.rs | 181 +++++++++++++++++- core/archipelago/src/federation/mod.rs | 5 +- core/archipelago/src/federation/types.rs | 37 ++++ core/archipelago/src/server.rs | 4 + 7 files changed, 282 insertions(+), 13 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index cb95a64d..3b96d200 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index e7a135b4..75666db1 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -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 { + /// 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, + ) -> Result { + 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?; diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index baebafa5..98e9fa31 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -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, diff --git a/core/archipelago/src/federation/invites.rs b/core/archipelago/src/federation/invites.rs index f97fd416..de2c69ca 100644 --- a/core/archipelago/src/federation/invites.rs +++ b/core/archipelago/src/federation/invites.rs @@ -18,16 +18,22 @@ pub struct ParsedInvite { pub token: String, /// Inviter's FIPS npub if advertised in the code. pub fips_npub: Option, + /// 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:`. +/// Generate an invite code. Format: `fed1:`. /// `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 { 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 { .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 { 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(); diff --git a/core/archipelago/src/federation/mod.rs b/core/archipelago/src/federation/mod.rs index 3e181fb4..cb99fcdc 100644 --- a/core/archipelago/src/federation/mod.rs +++ b/core/archipelago/src/federation/mod.rs @@ -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, diff --git a/core/archipelago/src/federation/types.rs b/core/archipelago/src/federation/types.rs index 8194bab9..0e861d92 100644 --- a/core/archipelago/src/federation/types.rs +++ b/core/archipelago/src/federation/types.rs @@ -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 { + 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, + /// 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)] diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 58fc5eda..991c4683 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -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