From d5ceb27334826f4c4ab062ad79d48feec434bc87 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Sat, 27 Jun 2026 19:17:27 -0700 Subject: [PATCH] fix(#1792): decode GRP_DATA channel hash + inner data_type/len/blob in details cell (#1796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red commit: 11d8c51e8ac72ea517c7d4e262a827eecf5b0778 (CI: https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1792) ## What Render GRP_DATA (PAYLOAD_TYPE 0x06) channel hash + (when decrypted) inner `data_type` / `data_len` / blob hex in the packets table "details" cell, mirroring the existing GRP_TXT branch in `public/packets.js getDetailPreview()`. Previously these packets showed only the opaque payload bytes — operators had no way to see channel distribution or recognize specific data_type values at a glance. ## How `public/packets.js` — one new branch right after the GRP_TXT branch: - Always renders `Ch 0xNN` (from `channelHashHex` or computed from `channelHash`). - `decryptionStatus = no_key | decryption_failed` → status label, same shape as GRP_TXT. - `decryptionStatus = decrypted` → adds `type=0xNNNN len=N` plus a `` block with the blob hex, truncated to 32 hex chars (16 bytes) with `…` when longer. Inner layout per `firmware/src/helpers/BaseChatMesh.cpp:382-385` (uint16 LE data_type, u8 data_len, blob). ## TDD - **Red commit** `11d8c51e`: 4 new assertion-shaped failures in `test-packets.js` getDetailPreview suite (`packets.js tests: 70 passed, 17 failed` → +4 vs baseline 13). - **Green commit** `3466ed09`: 17 → 13 failed (baseline emoji-vs-Phosphor drift, unrelated). All 4 GRP_DATA assertions pass. ## Optional check #2 — backend JSON parity Confirmed `cmd/server/decoder.go` and `cmd/ingestor/decoder.go` use identical JSON tags (`channelHashHex`, `decryptionStatus`, `dataType`, `dataLen`, `decryptedBlob`). No backend change needed — server emits envelope-only fields, ingestor adds the inner fields when a channel key matches; the frontend handles both shapes. ## Scope - 2 files: `public/packets.js` (+17 lines), `test-packets.js` (+48 lines test). - No public API change. No CSS. No migration. Preflight clean (PII, branch scope, red commit, CSS, LIKE-on-JSON, sync/async migration, XSS sinks — all pass). Fixes #1792 --------- Co-authored-by: Kpa-clawbot Co-authored-by: meshcore-bot Co-authored-by: Kpa-clawbot --- public/packets.js | 37 +++++++++++ test-packets.js | 161 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/public/packets.js b/public/packets.js index 924bf070..0eeeae05 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2829,6 +2829,43 @@ const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' : 'decryption failed'; return ` Ch 0x${hashHex} (${statusLabel})`; } + // Group data (binary datagrams over channels) — #1792. + // Envelope: channel_hash + MAC + ciphertext. When decrypted, inner is + // data_type(uint16 LE) + data_len(1) + blob (firmware BaseChatMesh.cpp:382-385). + if (decoded.type === 'GRP_DATA' && decoded.channelHash != null) { + const hashHex = decoded.channelHashHex || decoded.channelHash.toString(16).padStart(2, '0').toUpperCase(); + // #1796 r1 DRY: all three GRP_DATA branches below share the same + // database-icon + `Ch 0x${hashHex}` prefix. Extract once; behavior is + // byte-identical with the prior inline form. + const prefix = ` Ch 0x${hashHex}`; + // Happy path: decrypted with a parsed inner header (dataType present, no parse error). + // Per firmware/src/helpers/BaseChatMesh.cpp:387 a data_len of 0 is a LEGITIMATE + // empty datagram (the firmware only drops data_len > available_len). Backend + // cmd/ingestor/decoder.go:142 marshals DecryptedBlob with `omitempty`, so an + // empty blob is normal — render the block ONLY when blob is a non-empty + // string. The 'data_len exceeds buffer' malformed branch sets decoded.error, + // which falls through to the explicit malformed label below. + if (decoded.decryptionStatus === 'decrypted' && decoded.dataType != null && !decoded.error) { + const dt = Number(decoded.dataType).toString(16).padStart(4, '0').toUpperCase(); + const dl = decoded.dataLen != null ? Number(decoded.dataLen) : 0; + const blob = decoded.decryptedBlob || ''; + const blobBlock = blob ? ` ${escapeHtml(blob.length > 32 ? blob.slice(0, 32) + '…' : blob)}` : ''; + return `${prefix} type=0x${dt} len=${dl}${blobBlock}`; + } + // #1796 polish: decrypted-but-malformed branch. Backend leaves + // status='decrypted' for two failure modes (decoder.go:619-654): + // (a) inner too short → DataType=nil + // (b) inner data_len exceeds buffer → DataType set, blob empty, Error set + // Without this explicit branch we would either lie ('encrypted') or + // render a misleading "type=0xNN len=N" header with an empty . + if (decoded.decryptionStatus === 'decrypted') { + return `${prefix} (decrypted, malformed)`; + } + const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' + : decoded.decryptionStatus === 'decryption_failed' ? 'decryption failed' + : 'encrypted'; + return `${prefix} (${statusLabel})`; + } // Direct messages if (decoded.type === 'TXT_MSG') return ` ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`; // Path updates diff --git a/test-packets.js b/test-packets.js index e52b44b6..a2703feb 100644 --- a/test-packets.js +++ b/test-packets.js @@ -280,6 +280,167 @@ console.log('\n=== packets.js: getDetailPreview ==='); assert(result.includes('0xFF')); }); + // #1792: GRP_DATA detail preview parity with GRP_TXT. + test('getDetailPreview handles GRP_DATA with channelHash (no_key)', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', channelHash: 0xAB, channelHashHex: 'AB', decryptionStatus: 'no_key' + }); + assert(result.includes('Ch 0xAB'), 'should render channel hash hex with Ch prefix'); + assert(result.includes('no key'), 'should render no key status'); + }); + + test('getDetailPreview handles GRP_DATA decryption_failed', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', channelHash: 5, channelHashHex: '05', decryptionStatus: 'decryption_failed' + }); + assert(result.includes('Ch 0x05'), 'should render channel hash hex with Ch prefix'); + assert(result.includes('decryption failed'), 'should render failure status'); + }); + + // #1796 polish: explicit 'encrypted' fallback when decryptionStatus is absent/pending. + test('getDetailPreview handles GRP_DATA encrypted fallback (no decryptionStatus)', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', channelHash: 0xAB, channelHashHex: 'AB' + }); + assert(result.includes('Ch 0xAB'), 'should render channel hash hex with Ch prefix'); + assert(result.includes('encrypted'), 'should render encrypted fallback label'); + }); + + // #1796 polish: decrypted-but-malformed — status is 'decrypted' but dataType is null + // because inner payload was too short to parse (cmd/ingestor/decoder.go:619-654). + test('getDetailPreview handles GRP_DATA decrypted-but-malformed (dataType null)', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', + channelHash: 0xAB, + channelHashHex: 'AB', + decryptionStatus: 'decrypted', + dataType: null + }); + assert(result.includes('Ch 0xAB'), 'should render channel hash hex with Ch prefix'); + assert(result.includes('malformed'), 'should label decrypted-but-malformed inner explicitly'); + assert(!result.includes('encrypted'), 'must NOT mislabel decrypted packet as encrypted'); + }); + + // #1796 r1 adversarial — pin the `!decoded.error` guard on the happy-path branch + // (public/packets.js:2841). Backend cmd/ingestor/decoder.go:619-654 sets BOTH + // DataType=0xNN AND Error='data_len exceeds buffer' for the malformed inner case + // where data_len > available_len. Without the `!decoded.error` guard, this row + // would mis-render as `type=0x0001 len=0` (a confident-looking happy-path label) + // instead of falling through to the explicit `(decrypted, malformed)` branch. + // Regression pin: if a future refactor drops the `!decoded.error` clause, this + // test fails. (Verified locally: removing `&& !decoded.error` makes this fail.) + test('getDetailPreview routes decrypted+dataType+error through malformed branch (#1796 r1 adv)', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', + channelHash: 0x12, + channelHashHex: '12', + decryptionStatus: 'decrypted', + dataType: 0x0001, + dataLen: 0, + error: 'data_len exceeds buffer' + // decryptedBlob absent (omitempty); backend Error field set. + }); + assert(result.includes('Ch 0x12'), 'should still render channel hash hex'); + assert(result.includes('malformed'), + 'must label as malformed when Error is set, NOT confidently render type=0xNN'); + assert(!result.includes('type=0x0001'), + 'must NOT show a happy-path type=0xNN header when inner parse errored'); + assert(!result.includes('len=0'), + 'must NOT show a happy-path len=N header when inner parse errored'); + }); + + // #1796 r1 regression — data_len=0 is a LEGITIMATE empty datagram per firmware + // (BaseChatMesh.cpp:387: `data_len > available_len` is the only reject; 0 is allowed). + // Backend cmd/ingestor/decoder.go:142 marshals DecryptedBlob with `omitempty`, so a + // valid data_len=0 packet arrives with an empty/absent blob and no error. Frontend + // must render the header (type=...len=0) WITHOUT a block and MUST NOT label + // it 'malformed'. (Same assertion covers the round-0 'tightened gate' regression.) + test('getDetailPreview renders GRP_DATA data_len=0 empty datagram (no code block, not malformed)', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', + channelHash: 0x12, + channelHashHex: '12', + decryptionStatus: 'decrypted', + dataType: 0x0001, + dataLen: 0 + // decryptedBlob absent (backend `omitempty`); no error. + }); + assert(result.includes('Ch 0x12'), 'should render channel hash hex'); + assert(result.includes('type=0x0001'), 'should render data_type as hex'); + assert(result.includes('len=0'), 'should render data_len=0'); + assert(!result.includes(''), 'must NOT render any block when blob is empty'); + assert(!result.includes('malformed'), 'data_len=0 is a legitimate empty datagram, NOT malformed'); + }); + + test('getDetailPreview handles GRP_DATA decrypted with data_type and blob', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', + channelHash: 0x12, + channelHashHex: '12', + decryptionStatus: 'decrypted', + dataType: 0x0001, + dataLen: 4, + decryptedBlob: 'deadbeef' + }); + assert(result.includes('0x12'), 'should render channel hash hex'); + assert(result.includes('0x0001'), 'should render data_type as hex'); + assert(result.includes('len=4'), 'should render data_len with len= label'); + assert(result.includes('deadbeef'), 'should render blob hex'); + }); + + test('getDetailPreview handles GRP_DATA decrypted truncates long blob', () => { + const longBlob = 'ab'.repeat(64); // 128 hex chars = 64 bytes + const result = api.getDetailPreview({ + type: 'GRP_DATA', + channelHash: 0x12, + channelHashHex: '12', + decryptionStatus: 'decrypted', + dataType: 0, + dataLen: 64, + decryptedBlob: longBlob + }); + // Adversarial #3: assert EXACT rendered content via regex, not substring. + // Substring match would still pass at cutoff 40/48 because 'ab'.repeat(16)+'…' + // is a prefix of any longer rendered blob. Pin: exactly 32 hex chars + ellipsis, + // and nothing else inside the tag. + const codeMatch = result.match(/([^<]*)<\/code>/); + assert(codeMatch, 'should render a block'); + assert.strictEqual(codeMatch[1], 'ab'.repeat(16) + '…', + ` content must be exactly 32 hex chars + ellipsis, got: ${JSON.stringify(codeMatch[1])}`); + }); + + // Boundary: blob with EXACTLY 32 hex chars renders WITHOUT ellipsis (.length > 32 is strict). + test('getDetailPreview renders GRP_DATA blob of exactly 32 hex chars without ellipsis', () => { + const exactBlob = 'cd'.repeat(16); // 32 hex chars + const result = api.getDetailPreview({ + type: 'GRP_DATA', + channelHash: 0x12, + channelHashHex: '12', + decryptionStatus: 'decrypted', + dataType: 0, + dataLen: 16, + decryptedBlob: exactBlob + }); + const codeMatch = result.match(/([^<]*)<\/code>/); + assert(codeMatch, 'should render a block at the 32-char boundary'); + assert.strictEqual(codeMatch[1], exactBlob, + 'exactly-32-char blob must render verbatim, no ellipsis'); + assert(!result.includes('…'), 'must NOT append ellipsis at the boundary'); + }); + + // Item 6: channelHash=0 — confirms the `!= null` gate (not truthy check) so falsy 0 + // still enters the GRP_DATA branch and renders `Ch 0x00`. + test('getDetailPreview handles GRP_DATA channelHash=0 (falsy but valid)', () => { + const result = api.getDetailPreview({ + type: 'GRP_DATA', + channelHash: 0, + decryptionStatus: 'no_key' + }); + assert(result.includes('Ch 0x00'), + 'channelHash=0 must render Ch 0x00 (falsy 0 passes `!= null` gate)'); + assert(result.includes('no key'), 'should render no key status'); + }); + test('getDetailPreview handles TXT_MSG', () => { const result = api.getDetailPreview({ type: 'TXT_MSG', srcHash: 'abcdef01', destHash: '12345678'