fix(mesh): authenticate !ai over LoRa via federation-twin binding + signed Text

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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-19 13:25:24 -04:00
parent 839da80e0b
commit a0b80dd27d
2 changed files with 90 additions and 1 deletions

View File

@ -397,6 +397,14 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
};
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() {

View File

@ -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<u32, MeshPeer>) {
// 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<String>, Option<[u8; 32]>);
let mut fed_by_name: std::collections::HashMap<String, Option<FedIdentity>> =
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