fix(mesh): resolve ContentRef peer via DID + name-match fallback

Mesh peer pubkeys (LoRa advert ed25519) differ from federation node
pubkeys (archipelago identity), so matching on pubkey always missed
and attachments >160B had no transport. Match on master DID instead;
also accept an explicit peer_onion override from the frontend, which
resolves the peer by display name against federation.list-nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-13 14:13:36 -04:00
parent 06584a3821
commit 5f7ebf145e
4 changed files with 74 additions and 18 deletions

View File

@ -220,6 +220,12 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing cid"))?
.to_string();
let caption = params["caption"].as_str().map(|s| s.to_string());
// Optional override: frontend can resolve the peer's federation onion
// itself (by matching mesh peer name against federation.list-nodes)
// and pass it in directly. Bypasses the mesh-peer→DID→federation
// lookup, which currently fails because meshcore adverts don't carry
// the archipelago master DID.
let explicit_peer_onion = params["peer_onion"].as_str().map(|s| s.to_string());
// Pull the shared blob store (set by ApiHandler at startup).
let blob_store = {
@ -237,16 +243,21 @@ impl RpcHandler {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Resolve peer Ed25519 pubkey from contact_id so we can scope the cap.
// Resolve peer Ed25519 pubkey + DID from contact_id. The pubkey scopes
// the capability token; the DID is the stable cross-transport identity
// used to match the peer against our federation node list (mesh and
// federation use different ed25519 keys but the same master DID).
let state = svc.shared_state();
let peer_pubkey_hex = {
let (peer_pubkey_hex, peer_did) = {
let peers = state.peers.read().await;
let peer = peers
.get(&contact_id)
.ok_or_else(|| anyhow::anyhow!("Unknown mesh contact_id {}", contact_id))?;
peer.pubkey_hex
let pk = peer
.pubkey_hex
.clone()
.ok_or_else(|| anyhow::anyhow!("Peer has no pubkey yet"))?
.ok_or_else(|| anyhow::anyhow!("Peer has no pubkey yet"))?;
(pk, peer.did.clone())
};
// Our onion (stripped of scheme/trailing slash) for the URL the receiver will hit.
@ -291,14 +302,21 @@ impl RpcHandler {
// budget (cid alone is 64 hex chars, plus onion + cap). Route via
// federation when the peer has a known onion; fall back to LoRa
// only for tiny envelopes that could theoretically fit.
let federation_onion = {
// Match mesh peer → federation node by master DID, NOT by pubkey.
// Mesh adverts carry a LoRa-local ed25519 key that differs from the
// archipelago node's identity key in federation/nodes.json; the DID
// is the only stable identifier the two transports share.
let federation_onion = if let Some(onion) = explicit_peer_onion {
Some(onion)
} else {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
nodes
.into_iter()
.find(|n| n.pubkey == peer_pubkey_hex)
.map(|n| n.onion)
if let Some(did) = peer_did.as_ref() {
nodes.into_iter().find(|n| &n.did == did).map(|n| n.onion)
} else {
None
}
};
let msg = if let Some(onion) = federation_onion {
svc.send_typed_wire_via_federation(

View File

@ -601,17 +601,31 @@ impl MeshService {
wire: Vec<u8>,
) -> Result<()> {
let envelope = crate::mesh::message_types::TypedEnvelope::from_wire(&wire)?;
// Find the contact_id by pubkey match; fall back to synthetic.
// The sender's `from_pubkey_hex` is their archipelago identity key,
// which differs from the mesh peer's LoRa advert pubkey. Resolve
// identity → DID → mesh contact_id via federation/nodes.json (the
// DID is the only stable cross-transport key).
let federation_did = {
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
.unwrap_or_default();
nodes
.into_iter()
.find(|n| n.pubkey == from_pubkey_hex)
.map(|n| n.did)
};
let contact_id = {
let peers = self.state.peers.read().await;
peers
.iter()
.find_map(|(cid, p)| {
if p.pubkey_hex.as_deref() == Some(from_pubkey_hex) {
Some(*cid)
} else {
None
}
let did_match = federation_did
.as_ref()
.zip(p.did.as_ref())
.map(|(a, b)| a == b)
.unwrap_or(false);
let pk_match = p.pubkey_hex.as_deref() == Some(from_pubkey_hex);
if did_match || pk_match { Some(*cid) } else { None }
})
.unwrap_or_else(|| {
let bytes = hex::decode(from_pubkey_hex).unwrap_or_default();

View File

@ -382,13 +382,13 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function sendContent(contactId: number, cid: string, caption?: string) {
async function sendContent(contactId: number, cid: string, caption?: string, peerOnion?: string) {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({
method: 'mesh.send-content',
params: { contact_id: contactId, cid, caption },
params: { contact_id: contactId, cid, caption, peer_onion: peerOnion },
})
if (res.sent) await fetchMessages()
return res

View File

@ -639,7 +639,31 @@ async function handleAttachFile(ev: Event) {
return
}
const { cid } = (await up.json()) as { cid: string }
await mesh.sendContent(activeChatPeer.value.contact_id, cid, messageText.value.trim() || undefined)
// Resolve the federation onion for this mesh peer. Meshcore adverts
// don't carry an archipelago DID so the backend can't link them on its
// own we match on name (both sides use the node's display name).
// Falls back to undefined; the backend will try its own DID lookup or
// error out if no federation path exists.
let peerOnion: string | undefined
try {
const fed = await rpcClient.federationListNodes()
const peerName = activeChatPeer.value.advert_name
const hit = fed.nodes.find(
(n: { name?: string; onion?: string }) =>
(n.name ?? '').toLowerCase() === peerName.toLowerCase() ||
(n.name ?? '').toLowerCase().includes(peerName.toLowerCase()) ||
peerName.toLowerCase().includes((n.name ?? '').toLowerCase()),
)
peerOnion = hit?.onion ?? undefined
} catch {
/* non-fatal — backend will try its own lookup */
}
await mesh.sendContent(
activeChatPeer.value.contact_id,
cid,
messageText.value.trim() || undefined,
peerOnion,
)
messageText.value = ''
nextTick(() => scrollChatToBottom())
} catch (e) {