fix(#1792): decode GRP_DATA channel hash + inner data_type/len/blob in details cell (#1796)

Red commit: 11d8c51e8a (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
`<code>` 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 <bot@example.com>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-06-28 02:17:27 +00:00
committed by GitHub
co-authored by Kpa-clawbot meshcore-bot Kpa-clawbot
parent 770749a8ce
commit d5ceb27334
2 changed files with 198 additions and 0 deletions
+37
View File
@@ -2829,6 +2829,43 @@
const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key' : 'decryption failed';
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> Ch 0x${hashHex} <span class="muted">(${statusLabel})</span>`;
}
// 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 = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-database"/></svg> 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 <code> 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 ? ` <code>${escapeHtml(blob.length > 32 ? blob.slice(0, 32) + '…' : blob)}</code>` : '';
return `${prefix} <span class="muted">type=0x${dt} len=${dl}</span>${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 <code></code>.
if (decoded.decryptionStatus === 'decrypted') {
return `${prefix} <span class="muted">(decrypted, malformed)</span>`;
}
const statusLabel = decoded.decryptionStatus === 'no_key' ? 'no key'
: decoded.decryptionStatus === 'decryption_failed' ? 'decryption failed'
: 'encrypted';
return `${prefix} <span class="muted">(${statusLabel})</span>`;
}
// Direct messages
if (decoded.type === 'TXT_MSG') return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope"/></svg> ${decoded.srcHash?.slice(0,8) || '?'}${decoded.destHash?.slice(0,8) || '?'}`;
// Path updates
+161
View File
@@ -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 <code> 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('<code>'), 'must NOT render any <code> 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 <code> 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 <code> tag.
const codeMatch = result.match(/<code>([^<]*)<\/code>/);
assert(codeMatch, 'should render a <code> block');
assert.strictEqual(codeMatch[1], 'ab'.repeat(16) + '…',
`<code> 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>([^<]*)<\/code>/);
assert(codeMatch, 'should render a <code> 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'