fix(#1864): decode ANON_REQ source pubkey instead of treating it like REQUEST (#1866)

Fixes #1864.

## Problem
`PAYLOAD_TYPE_ANON_REQ` was effectively treated like `REQUEST`. The two
differ on the wire:

```
REQUEST :  <dest hash 1B> <source hash 1B>          <hmac 2B> <encrypted>
ANON_REQ:  <dest hash 1B> <source pubkey 32B, full> <hmac 2B> <encrypted>
```

The decoders read the right bytes but surfaced the sender key as
`ephemeralPubKey`, which meant:
- `store.go`'s node indexer keys on `pubKey`/`destPubKey`/`srcPubKey`,
so ANON_REQ packets were **not** indexed — they didn't show up on a
node's packet view; and
- the packets list "details" rendered a bare `anon → <destHash>`,
throwing away the sender identity the packet actually carries.
- the detail side-view byte breakdown fell through the REQ catch-all,
mislabelling a nonexistent 1-byte "Src Hash" and placing
MAC/Encrypted-Data at the wrong offsets (`+2`/`+4` instead of
`+33`/`+35`).

## Fix
**Backend** (`cmd/ingestor` + `cmd/server` decoders)
- Surface the ANON_REQ sender key as `srcPubKey` (json) so it's indexed
and resolvable. The frontend keeps a legacy `ephemeralPubKey` reader so
packets decoded before this rename still resolve — no DB migration
needed.
- `TestDecodeAnonReqValid` now asserts the full 32-byte `srcPubKey`.

**Frontend**
- `hop-resolver.js`: new O(1) `nameForKey(pubkey)` using the existing
`pubkeyIdx` (all nodes).
- `getDetailPreview`: resolve the source pubkey to a node **name** when
known, else show the first 8 hex chars — no more bare `anon`.
- Detail side-view: explicit ANON_REQ breakdown — `Dest Hash (1B)` |
`Src Public Key (32B)` (node-linked) | `MAC @+33` | `Encrypted Data
@+35`.
- Detail header `srcLabel` falls back to the resolved ANON_REQ sender.
All rendered names are `escapeHtml`-wrapped.

## Testing
- `go test ./...` green for both `cmd/ingestor` and `cmd/server` (incl.
strengthened `TestDecodeAnonReqValid`).
- `node --check` on `packets.js`; brace/paren balance + markers verified
on `hop-resolver.js`.
- No HTML sink lines added → XSS preflight gate unaffected; every
interpolated name is escaped.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
SaarMesh-Bot
2026-09-02 10:27:56 +02:00
committed by GitHub
co-authored by SaarMesh-Bot Claude
parent a3454e7508
commit 0d6f59ab2d
5 changed files with 57 additions and 8 deletions
+7 -2
View File
@@ -143,7 +143,12 @@ type Payload struct {
Text string `json:"text,omitempty"`
Sender string `json:"sender,omitempty"`
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
// ANON_REQ carries the sender's FULL 32-byte public key (not a 1-byte
// srcHash like REQ) — MeshCore firmware/src/Mesh.cpp. Surfaced as
// srcPubKey so store.go's node indexer picks it up and it resolves to a
// node name; frontend also reads legacy ephemeralPubKey for pre-rename
// packets (#1864).
SrcPubKey string `json:"srcPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
SNRValues []float64 `json:"snrValues,omitempty"`
Tag uint32 `json:"tag,omitempty"`
@@ -857,7 +862,7 @@ func decodeAnonReq(buf []byte) Payload {
return Payload{
Type: "ANON_REQ",
DestHash: hex.EncodeToString(buf[0:1]),
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
SrcPubKey: hex.EncodeToString(buf[1:33]),
MAC: hex.EncodeToString(buf[33:35]),
EncryptedData: hex.EncodeToString(buf[35:]),
}
+6
View File
@@ -523,6 +523,12 @@ func TestDecodeAnonReqValid(t *testing.T) {
if p.MAC != "aabb" {
t.Errorf("mac=%s, want aabb", p.MAC)
}
// #1864: ANON_REQ must capture the FULL 32-byte source pubkey (buf[1:33]),
// not a 1-byte srcHash. buf[i]=i for i in 1..32 -> 0102..20.
wantPK := "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
if p.SrcPubKey != wantPK {
t.Errorf("srcPubKey=%s, want %s", p.SrcPubKey, wantPK)
}
}
func TestDecodePathPayloadShort(t *testing.T) {
+7 -2
View File
@@ -106,7 +106,12 @@ type Payload struct {
Lon *float64 `json:"lon,omitempty"`
Name string `json:"name,omitempty"`
ChannelHash int `json:"channelHash,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
// ANON_REQ carries the sender's FULL 32-byte public key (not a 1-byte
// srcHash like REQ) — MeshCore firmware/src/Mesh.cpp. Surfaced as
// srcPubKey so store.go's node indexer picks it up and it resolves to a
// node name; frontend also reads legacy ephemeralPubKey for pre-rename
// packets (#1864).
SrcPubKey string `json:"srcPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
@@ -468,7 +473,7 @@ func decodeAnonReq(buf []byte) Payload {
return Payload{
Type: "ANON_REQ",
DestHash: hex.EncodeToString(buf[0:1]),
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
SrcPubKey: hex.EncodeToString(buf[1:33]),
MAC: hex.EncodeToString(buf[33:35]),
EncryptedData: hex.EncodeToString(buf[35:]),
}
+10 -1
View File
@@ -338,6 +338,15 @@ window.HopResolver = (function() {
return affinityMap[pubkeyA][pubkeyB] || 0;
}
// #1864: O(1) node-name lookup by FULL pubkey (64-char hex). Returns the
// node name when a node with this exact public key is known, else null.
// Used to resolve ANON_REQ source pubkeys to a display name.
function nameForKey(pubkey) {
if (!pubkey) return null;
const n = pubkeyIdx[String(pubkey).toLowerCase()];
return n && n.name ? n.name : null;
}
/**
* Resolve hops using server-provided resolved_path (full pubkeys).
* Returns the same format as resolve() — { [hop]: { name, pubkey, ... } }.
@@ -363,5 +372,5 @@ window.HopResolver = (function() {
return result;
}
return { init: init, resolve: resolve, resolveFromServer: resolveFromServer, ready: ready, haversineKm: haversineKm, setAffinity: setAffinity, getAffinity: getAffinity };
return { init: init, resolve: resolve, resolveFromServer: resolveFromServer, ready: ready, haversineKm: haversineKm, setAffinity: setAffinity, getAffinity: getAffinity, nameForKey: nameForKey };
})();
+27 -3
View File
@@ -2929,8 +2929,16 @@
if (decoded.type === 'PATH') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-shuffle"/></svg> ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
// Requests/responses (encrypted)
if (decoded.type === 'REQ' || decoded.type === 'RESPONSE') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
// Anonymous requests
if (decoded.type === 'ANON_REQ') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> anon → ${decoded.destHash?.slice(0,8) || '?'}`;
// Anonymous requests (#1864). ANON_REQ carries the sender's FULL 32-byte
// pubkey (not a 1-byte srcHash) — resolve it to a node name if known,
// else show the first 8 hex chars. Legacy ephemeralPubKey fallback covers
// packets decoded before the backend field was renamed to srcPubKey.
if (decoded.type === 'ANON_REQ') {
const anonKey = decoded.srcPubKey || decoded.ephemeralPubKey || '';
const anonName = (anonKey && window.HopResolver && HopResolver.nameForKey) ? HopResolver.nameForKey(anonKey) : null;
const anonSrc = anonName ? escapeHtml(anonName) : (anonKey ? escapeHtml(anonKey.slice(0, 8)) : 'anon');
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> ${anonSrc}${decoded.destHash?.slice(0,8) || '?'}`;
}
// CONTROL packets (#1802) — DISCOVER_REQ / DISCOVER_RESP body fields,
// decoded by cmd/ingestor/decoder.go decodeControl(). Wire format:
// firmware/src/Mesh.cpp:69
@@ -3286,7 +3294,10 @@
// src→dst). Replaces the prior byte-count title that buried packet
// identity behind a byte counter (#1458 P0-A).
const semanticSummary = getDetailPreview(decoded);
const srcLabel = decoded.sender || decoded.name || (decoded.srcHash ? decoded.srcHash.slice(0,8) : null) || (decoded.pubKey ? decoded.pubKey.slice(0,8) + '…' : null);
// #1864: ANON_REQ has no srcHash — its sender is the full srcPubKey.
const _anonKey = decoded.srcPubKey || decoded.ephemeralPubKey || '';
const _anonName = (_anonKey && window.HopResolver && HopResolver.nameForKey) ? HopResolver.nameForKey(_anonKey) : null;
const srcLabel = decoded.sender || decoded.name || (decoded.srcHash ? decoded.srcHash.slice(0,8) : null) || _anonName || (_anonKey ? _anonKey.slice(0,8) + '…' : null) || (decoded.pubKey ? decoded.pubKey.slice(0,8) + '…' : null);
const dstLabel = decoded.recipient || (decoded.destHash ? decoded.destHash.slice(0,8) : null);
const srcDstHtml = (srcLabel || dstLabel)
? `<div class="detail-srcdst">${escapeHtml(srcLabel || '?')} <span class="arrow">→</span> ${escapeHtml(dstLabel || (decoded.channel ? '#' + decoded.channel : '?'))}</div>`
@@ -3604,6 +3615,19 @@
if (decoded.pathData) {
rows += fieldRow(off + 9, 'Route Hops', decoded.pathData.toUpperCase(), pathHops.length + ' hop(s)');
}
} else if (decoded.type === 'ANON_REQ') {
// #1864: ANON_REQ layout differs from REQ — the source is a FULL 32-byte
// pubkey, not a 1-byte srcHash, so MAC/data sit at off+33/off+35 (not
// off+2/off+4). Decode explicitly and resolve the key to a node link.
const anonKey = decoded.srcPubKey || decoded.ephemeralPubKey || '';
const anonName = (anonKey && window.HopResolver && HopResolver.nameForKey) ? HopResolver.nameForKey(anonKey) : null;
rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', '');
const anonKeyCell = anonKey
? `<a href="#/nodes/${encodeURIComponent(anonKey)}" class="hop-link ${anonName ? 'hop-named' : ''}" data-hop-link="true">${anonName ? escapeHtml(anonName) : truncate(anonKey, 24)}</a>`
: '—';
rows += fieldRow(off + 1, 'Src Public Key (32B)', anonKeyCell, anonName ? '' : 'sender pubkey (unresolved)');
rows += fieldRow(off + 33, 'MAC (2B)', decoded.mac || '', '');
rows += fieldRow(off + 35, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
} else if (decoded.destHash !== undefined) {
rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', '');
rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', '');