From a0b80dd27d0b7e11831afb5b8336d01eaf072299 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 19 Jun 2026 13:25:24 -0400 Subject: [PATCH] fix(mesh): authenticate !ai over LoRa via federation-twin binding + signed Text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A !ai (or any typed message) from a trusted, federated node was denied when it arrived over the radio. The radio half of a node that is also a federation peer carried no archipelago identity (identity adverts are no longer broadcast on the public channel), so the trusted_only gate and signature verification had no key to check the asker against — and the same node showed up as two contacts (a radio twin + a federation twin). - bind_federation_twins(): correlate a radio contact with its federation twin by exact, case-insensitive advert_name and copy the federation peer's arch_pubkey_hex/did/x25519 onto the radio record. Called from upsert_federation_peer and refresh_contacts. Ambiguous names (held by >1 federation peer) are skipped. This is only a CANDIDATE key — security is unchanged: the inbound envelope signature must still verify against it. - send_message now signs the typed Text envelope (new_signed) so a radio !ai authenticates against the bound key. A meshcore node merely named like a trusted node cannot forge the signature, so it is still denied. Receiver-side verification (handle_typed_envelope_direct) and federation-trust matching (is_sender_allowed) already existed; this supplies the missing key binding and signature. Also resolves the radio/federation duplicate-contact display for same-named nodes. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/mesh/listener/session.rs | 8 ++ core/archipelago/src/mesh/mod.rs | 83 ++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index ea02a47b..49e28662 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -397,6 +397,14 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) }; peers.insert(contact_id, peer); } + // A radio contact that shares an exact advert_name with a known + // federation peer is the same physical node — bind the federation + // peer's archipelago identity onto the radio record so a signed + // `!ai`/typed message over LoRa authenticates (and the contact stops + // showing as a radio/federation duplicate). Security is unchanged: + // the bound key is only a candidate the inbound signature must still + // verify against. See `bind_federation_twins`. + super::super::bind_federation_twins(&mut peers); drop(peers); state.update_peer_count().await; if !contacts.is_empty() { diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 5c29f823..f78406d2 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -61,6 +61,72 @@ pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 { 0x8000_0000 | (low & 0x7FFF_FFFF) } +/// Bind radio (LoRa) contacts to their federation twin's archipelago identity. +/// +/// The same physical node commonly appears twice in the peer table: a radio +/// contact (low `contact_id`, firmware routing key only, `arch_pubkey_hex == +/// None`) and a federation peer (high `contact_id`, `arch_pubkey_hex` set). The +/// radio half carries no archipelago identity because identity adverts are no +/// longer broadcast on the public channel (anti-spam), so the `!ai` trust gate +/// and envelope signature verification have no key to check a radio asker +/// against — a `!ai` from a trusted node over LoRa is therefore denied, and the +/// node shows up as two separate contacts. +/// +/// We correlate the two halves by exact, case-insensitive `advert_name` and copy +/// the federation peer's `arch_pubkey_hex`/`did`/`x25519` onto the radio peer. +/// This only supplies a CANDIDATE identity key; it does NOT bypass +/// authentication. A radio envelope must still carry an Ed25519 signature that +/// verifies against this bound key (see `handle_typed_envelope_direct`), so a +/// meshcore node merely *named* like a trusted node cannot impersonate it — it +/// cannot produce the signature. The candidate key comes from the authenticated +/// federation handshake (`nodes.json`), never from anything the radio packet +/// claims. Names held by more than one federation peer are treated as ambiguous +/// and skipped so a duplicate name can't bind the wrong identity. +pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap) { + // name (lowercased) -> federation identity; `None` marks an ambiguous name + // (seen on more than one federation peer) which we must not bind. + type FedIdentity = (String, Option, Option<[u8; 32]>); + let mut fed_by_name: std::collections::HashMap> = + std::collections::HashMap::new(); + for p in peers.values() { + if p.contact_id < FEDERATION_CONTACT_ID_BASE { + continue; + } + let Some(arch) = p.arch_pubkey_hex.clone() else { + continue; + }; + let name = p.advert_name.trim().to_ascii_lowercase(); + if name.is_empty() { + continue; + } + fed_by_name + .entry(name) + .and_modify(|e| *e = None) // a second federation peer with this name → ambiguous + .or_insert(Some((arch, p.did.clone(), p.x25519_pubkey))); + } + if fed_by_name.is_empty() { + return; + } + for p in peers.values_mut() { + if p.contact_id >= FEDERATION_CONTACT_ID_BASE || p.arch_pubkey_hex.is_some() { + continue; + } + let name = p.advert_name.trim().to_ascii_lowercase(); + if name.is_empty() { + continue; + } + if let Some(Some((arch, did, x25519))) = fed_by_name.get(&name) { + p.arch_pubkey_hex = Some(arch.clone()); + if p.did.is_none() { + p.did = did.clone(); + } + if p.x25519_pubkey.is_none() { + p.x25519_pubkey = *x25519; + } + } + } +} + /// Upsert a mesh peer record representing a federation node so the UI can /// address it as a chat and `mesh.send-content` can route ContentRef to it. /// Existing entries (same contact_id) are updated in place, preserving any @@ -96,6 +162,11 @@ pub(crate) async fn upsert_federation_peer( reachable: true, }; peers.insert(contact_id, peer); + // A radio twin of this node (same advert_name, no arch identity yet) can now + // inherit this federation peer's archipelago key — so a signed `!ai`/typed + // message arriving over LoRa from it authenticates and the duplicate radio + // contact resolves to the same identity. + bind_federation_twins(&mut peers); drop(peers); state.update_peer_count().await; contact_id @@ -1332,8 +1403,18 @@ impl MeshService { .record_sent_typed(contact_id, "text", text, None, seq) .await); } + // Sign the envelope with our archipelago identity key so the receiver + // can authenticate us over LoRa (it verifies against our bound + // `arch_pubkey_hex`). This is what lets a `!ai` typed in chat to a + // trusted node pass the receiver's `trusted_only` gate over the radio — + // an unsigned radio packet can never authenticate. The signature is + // optional on the wire and ignored by peers that don't know our key, so + // it stays backward compatible. (Federation/Tor sends already sign in + // `send_typed_wire_via_federation`.) `with_seq` is applied after signing + // — seq is not covered by the signature. let envelope = - TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq); + TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key) + .with_seq(seq); let wire = envelope.to_wire()?; self.send_typed_wire(contact_id, wire, "text", text, None, seq) .await