From 0d6f59ab2dd55a88cbfb2ef94c987305b2fb4177 Mon Sep 17 00:00:00 2001 From: SaarMesh-Bot Date: Wed, 2 Sep 2026 10:27:56 +0200 Subject: [PATCH] fix(#1864): decode ANON_REQ source pubkey instead of treating it like REQUEST (#1866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1864. ## Problem `PAYLOAD_TYPE_ANON_REQ` was effectively treated like `REQUEST`. The two differ on the wire: ``` REQUEST : ANON_REQ: ``` 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 → `, 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 --- cmd/ingestor/decoder.go | 9 +++++++-- cmd/ingestor/decoder_test.go | 6 ++++++ cmd/server/decoder.go | 9 +++++++-- public/hop-resolver.js | 11 ++++++++++- public/packets.js | 30 +++++++++++++++++++++++++++--- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index ca9932ef..66fdd594 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -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:]), } diff --git a/cmd/ingestor/decoder_test.go b/cmd/ingestor/decoder_test.go index 2de1768a..1f942366 100644 --- a/cmd/ingestor/decoder_test.go +++ b/cmd/ingestor/decoder_test.go @@ -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) { diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index a6ca6a32..21fd7eff 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -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:]), } diff --git a/public/hop-resolver.js b/public/hop-resolver.js index 7c78906c..be2437d8 100644 --- a/public/hop-resolver.js +++ b/public/hop-resolver.js @@ -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 }; })(); diff --git a/public/packets.js b/public/packets.js index 2b4ca265..36de3eed 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2929,8 +2929,16 @@ if (decoded.type === 'PATH') return ` ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`; // Requests/responses (encrypted) if (decoded.type === 'REQ' || decoded.type === 'RESPONSE') return ` ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`; - // Anonymous requests - if (decoded.type === 'ANON_REQ') return ` 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 ` ${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) ? `
${escapeHtml(srcLabel || '?')} ${escapeHtml(dstLabel || (decoded.channel ? '#' + decoded.channel : '?'))}
` @@ -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 + ? `${anonName ? escapeHtml(anonName) : truncate(anonKey, 24)}` + : '—'; + 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 || '', '');