mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 12:25:13 +00:00
26daa760cd
## Problem PR #1030 added live PSK decrypt for GRP_TXT WS packets, but in production it still didn't work for **user-added** PSK channels. New messages never appeared in real time on a channel added via the sidebar key form — users had to refresh the page to see them via the REST fetch path (regression #1029). ## Root cause `decryptLivePSKBatch` rewrites the payload with the raw channel name: ```js payload.channel = dec.channelName; // e.g. "medusa" ``` But user-added channels live in `channels[]` under the key produced by `addUserChannel`: ```js hash: 'user:' + name, // e.g. "user:medusa" ``` `selectedHash` also uses the `user:`-prefixed key while a user-added channel is open. Downstream in `processWSBatch`: | Line | Check | Result | |---|---|---| | 962 | `c.hash === channelName` | `"medusa" !== "user:medusa"` → user channel never matched | | 982 | `channelName === selectedHash` | `"medusa" !== "user:medusa"` → message never appended to open chat | | 974 | `channels.push({ hash: channelName, ... })` | duplicate plain `"medusa"` entry pushed into sidebar | The unread bumper (`channels.js:1086`) compared `chName === prior` with the same mismatch, so it bumped an unread badge on the channel currently being viewed. Verified end to end against staging WS traffic (live `decryption_status: "decrypted"` packets observed; user-added channel never updated, duplicate entry created). ## Fix `decryptLivePSKBatch` now also stamps a canonical sidebar key on the payload: ```js payload.channelKey = hasUserCh ? ('user:' + dec.channelName) : dec.channelName; ``` `processWSBatch` and the unread bumper route on `payload.channelKey` (falling back to `payload.channel` for server-known CHAN packets — no behavior change there). After the fix: - ✅ live message appends to the open user-added chat - ✅ sidebar row's `lastMessage` / `messageCount` / `lastActivityMs` update - ✅ no duplicate non-prefixed sidebar entry - ✅ unread bumped only on channels NOT being viewed ## TDD Red commit `f1719a8` — `test-channel-live-decrypt-userprefix.js`, fails 6/9 on assertions (NOT build error) on pristine `channels.js`. Green commit `da87018` — minimal fix in `channels.js`, all 9/9 pass. Verified red gates the change: stashed `public/channels.js`, re-ran test on red commit alone → 6 assertion failures (open channel got 0 messages, duplicate sidebar entry, unread bumped on viewed channel). ## Files changed - `public/channels.js` — stamp/route on `channelKey` - `test-channel-live-decrypt-userprefix.js` (new) — red-then-green regression test --------- Co-authored-by: corescope-bot <bot@corescope>
287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
/**
|
|
* Regression test: live PSK decrypt for user-added channels (#1029 follow-up).
|
|
*
|
|
* PR #1030 added decryptLivePSKBatch() which rewrites encrypted GRP_TXT
|
|
* WS packets in place when a stored PSK key matches. It sets
|
|
* payload.channel = dec.channelName (e.g. "medusa")
|
|
* but user-added channels are stored in channels[] with hash:
|
|
* "user:medusa"
|
|
* (and selectedHash is also "user:medusa" when viewing).
|
|
*
|
|
* Symptoms in production:
|
|
* - selectedHash === "user:medusa" but processWSBatch compares
|
|
* `channelName === selectedHash` ("medusa" !== "user:medusa") so a live
|
|
* packet for the open channel is NEVER appended to the message list.
|
|
* - channels.find(c => c.hash === channelName) misses the user channel and
|
|
* a duplicate plain entry "medusa" is pushed into the sidebar; the real
|
|
* user-added channel's lastMessage / messageCount / lastActivityMs never
|
|
* update.
|
|
* - The unread bumper guards with `chName === prior` (raw name vs prefixed
|
|
* selectedHash), so an unread badge is added even when the user IS
|
|
* actively viewing that channel.
|
|
*
|
|
* Fix: have the live decrypt rewrite annotate the payload with the
|
|
* canonical channel hash that channels[] / selectedHash use. A simple,
|
|
* non-breaking shape: keep payload.channel = name (so the rest of
|
|
* processWSBatch keeps working for non-user channels), AND also set
|
|
* payload.channelKey = "user:" + name when a user-added channel exists for
|
|
* that name. processWSBatch then uses channelKey when present for the
|
|
* lookup + selectedHash comparison.
|
|
*
|
|
* This test loads the real channels.js in a vm sandbox, primes a
|
|
* user-added channel, drives an encrypted GRP_TXT through the WS handler
|
|
* and asserts:
|
|
* 1. the open channel's message list grows by 1 (text is decrypted-locally
|
|
* and visible in the messages array)
|
|
* 2. the user-added channel's messageCount / lastMessage update
|
|
* 3. NO duplicate plain "medusa" entry is added to channels[]
|
|
* 4. unread is NOT bumped on the channel currently being viewed
|
|
*/
|
|
'use strict';
|
|
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { createCipheriv, createHmac, createHash, webcrypto } = require('crypto');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
function assert(cond, msg) {
|
|
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
|
else { failed++; console.error(' ✗ ' + msg); }
|
|
}
|
|
|
|
function buildEncryptedGrpTxt(channelName, sender, message) {
|
|
const key = createHash('sha256').update(channelName).digest().slice(0, 16);
|
|
const channelHash = createHash('sha256').update(key).digest()[0];
|
|
const text = `${sender}: ${message}`;
|
|
const inner = 5 + Buffer.byteLength(text, 'utf8') + 1;
|
|
const padded = Math.ceil(inner / 16) * 16;
|
|
const pt = Buffer.alloc(padded);
|
|
pt.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
|
|
pt[4] = 0;
|
|
pt.write(text, 5, 'utf8');
|
|
const cipher = createCipheriv('aes-128-ecb', key, null);
|
|
cipher.setAutoPadding(false);
|
|
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
const secret = Buffer.concat([key, Buffer.alloc(16)]);
|
|
const mac = createHmac('sha256', secret).update(ct).digest().slice(0, 2);
|
|
return {
|
|
payload: {
|
|
type: 'GRP_TXT',
|
|
channelHash,
|
|
channelHashHex: channelHash.toString(16).padStart(2, '0'),
|
|
mac: mac.toString('hex'),
|
|
encryptedData: ct.toString('hex'),
|
|
decryptionStatus: 'no_key',
|
|
},
|
|
keyHex: key.toString('hex'),
|
|
};
|
|
}
|
|
|
|
function makeBrowserLikeSandbox() {
|
|
const storage = {};
|
|
const elements = {};
|
|
function makeFakeEl(id) {
|
|
return {
|
|
id: id || '', innerHTML: '', textContent: '', value: '', scrollTop: 0,
|
|
scrollHeight: 0,
|
|
style: {}, dataset: {},
|
|
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
|
addEventListener() {}, removeEventListener() {},
|
|
querySelector() { return makeFakeEl(); },
|
|
querySelectorAll() { return []; },
|
|
getAttribute() { return null; }, setAttribute() {},
|
|
getBoundingClientRect() { return { width: 240, height: 0, top: 0, left: 0, right: 0, bottom: 0 }; },
|
|
appendChild() {}, removeChild() {},
|
|
focus() {}, blur() {},
|
|
checked: false,
|
|
};
|
|
}
|
|
function el(id) {
|
|
if (!elements[id]) elements[id] = makeFakeEl(id);
|
|
return elements[id];
|
|
}
|
|
const ctx = {
|
|
window: {},
|
|
document: {
|
|
readyState: 'complete',
|
|
documentElement: { getAttribute: () => null, setAttribute() {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } } },
|
|
createElement: () => ({ id: '', textContent: '', innerHTML: '', style: {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, addEventListener() {}, appendChild() {}, querySelector() { return null; }, querySelectorAll() { return []; } }),
|
|
head: { appendChild() {} },
|
|
body: { appendChild() {} },
|
|
getElementById: el,
|
|
addEventListener() {}, removeEventListener() {},
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
},
|
|
console,
|
|
Date, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError, Set, Map, Promise,
|
|
parseInt, parseFloat, isNaN, isFinite,
|
|
encodeURIComponent, decodeURIComponent,
|
|
setTimeout: (fn) => { Promise.resolve().then(fn); return 0; },
|
|
clearTimeout: () => {},
|
|
setInterval: () => 0,
|
|
clearInterval: () => {},
|
|
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
|
performance: { now: () => Date.now() },
|
|
localStorage: {
|
|
getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null,
|
|
setItem: (k, v) => { storage[k] = String(v); },
|
|
removeItem: (k) => { delete storage[k]; },
|
|
},
|
|
location: { hash: '' },
|
|
history: { replaceState() {}, pushState() {} },
|
|
crypto: webcrypto,
|
|
TextEncoder, TextDecoder,
|
|
Uint8Array, Uint16Array, Uint32Array, Int8Array, Int16Array, Int32Array, ArrayBuffer,
|
|
URLSearchParams,
|
|
CustomEvent: class CustomEvent {},
|
|
MutationObserver: class MutationObserver { observe() {} disconnect() {} },
|
|
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
|
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
|
addEventListener() {}, dispatchEvent() {},
|
|
getHashParams: () => new URLSearchParams(),
|
|
};
|
|
ctx.self = ctx;
|
|
ctx.globalThis = ctx;
|
|
vm.createContext(ctx);
|
|
return ctx;
|
|
}
|
|
|
|
function loadInCtx(ctx, file) {
|
|
const src = fs.readFileSync(path.join(__dirname, file), 'utf8');
|
|
vm.runInContext(src, ctx, { filename: file });
|
|
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
|
}
|
|
|
|
async function run() {
|
|
console.log('\n=== Live PSK decrypt: user-added channel (user:* prefix) routing ===');
|
|
|
|
const ctx = makeBrowserLikeSandbox();
|
|
ctx.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
|
|
ctx.window.addEventListener = () => {};
|
|
ctx.btoa = (s) => Buffer.from(String(s), 'binary').toString('base64');
|
|
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('binary');
|
|
|
|
// App.js stubs: provide debouncedOnWS / onWS / offWS / api / debounce /
|
|
// invalidateApiCache / registerPage so channels.js loads cleanly.
|
|
let wsListeners = [];
|
|
ctx.onWS = (fn) => { wsListeners.push(fn); };
|
|
ctx.offWS = (fn) => { wsListeners = wsListeners.filter(f => f !== fn); };
|
|
ctx.debouncedOnWS = function (fn) {
|
|
function handler(msg) { fn([msg]); }
|
|
wsListeners.push(handler);
|
|
return handler;
|
|
};
|
|
ctx.debounce = (fn) => fn;
|
|
ctx.api = () => Promise.resolve({ channels: [], observers: [] });
|
|
ctx.invalidateApiCache = () => {};
|
|
ctx.CLIENT_TTL = { channels: 60000, observers: 600000 };
|
|
ctx.escapeHtml = (s) => String(s == null ? '' : s);
|
|
ctx.truncate = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n) : s; };
|
|
ctx.formatHashHex = (h) => String(h);
|
|
ctx.formatSecondsAgo = () => '';
|
|
ctx.payloadTypeName = () => 'GRP_TXT';
|
|
ctx.RegionFilter = {
|
|
init() {},
|
|
onChange(fn) { return () => {}; },
|
|
offChange() {},
|
|
getRegionParam() { return ''; },
|
|
getSelected() { return null; },
|
|
};
|
|
ctx.ChannelColors = { get() { return null; }, remove() {} };
|
|
ctx.ChannelColorPicker = { open() {} };
|
|
ctx.normalizeObserverNameKey = (s) => String(s || '').toLowerCase();
|
|
let pageMod = null;
|
|
ctx.registerPage = (name, mod) => { if (name === 'channels') pageMod = mod; };
|
|
|
|
// Load AES + ChannelDecrypt + channels.js
|
|
loadInCtx(ctx, 'public/vendor/aes-ecb.js');
|
|
loadInCtx(ctx, 'public/channel-decrypt.js');
|
|
loadInCtx(ctx, 'public/channels.js');
|
|
|
|
const CD = ctx.window.ChannelDecrypt;
|
|
assert(typeof CD.tryDecryptLive === 'function', 'ChannelDecrypt.tryDecryptLive available');
|
|
|
|
const channelName = 'medusa';
|
|
const fixture = buildEncryptedGrpTxt(channelName, 'Alice', 'hello darkness');
|
|
CD.storeKey(channelName, fixture.keyHex);
|
|
|
|
// Initialize the channels page so wsHandler is wired up
|
|
const appEl = ctx.document.getElementById('page');
|
|
appEl.innerHTML = '';
|
|
await pageMod.init(appEl, null);
|
|
// pump microtasks
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
ctx.window._channelsSetStateForTest({
|
|
channels: [{
|
|
hash: 'user:' + channelName,
|
|
name: channelName,
|
|
messageCount: 0,
|
|
lastActivityMs: 0,
|
|
lastSender: '',
|
|
lastMessage: 'Encrypted — click to decrypt',
|
|
encrypted: true,
|
|
userAdded: true,
|
|
}],
|
|
messages: [],
|
|
selectedHash: 'user:' + channelName,
|
|
});
|
|
|
|
// Drive the WS path — same shape the Go server broadcasts
|
|
const wsMsg = {
|
|
type: 'packet',
|
|
data: {
|
|
id: 12345,
|
|
hash: 'deadbeef',
|
|
observer_name: 'TestObserver',
|
|
packet: { observer_name: 'TestObserver' },
|
|
decoded: {
|
|
header: { payloadTypeName: 'GRP_TXT' },
|
|
payload: fixture.payload,
|
|
},
|
|
},
|
|
};
|
|
for (const fn of wsListeners) fn(wsMsg);
|
|
// Allow async decryptLivePSKBatch + setTimeout chain to settle
|
|
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
|
|
|
|
const state = ctx.window._channelsGetStateForTest();
|
|
|
|
// (1) Message list for the open channel grew
|
|
assert(state.messages.length === 1,
|
|
'open user-added channel receives the live-decrypted message (got ' + state.messages.length + ')');
|
|
if (state.messages[0]) {
|
|
assert(state.messages[0].text === 'hello darkness',
|
|
'decrypted text is rendered (got ' + JSON.stringify(state.messages[0].text) + ')');
|
|
assert(state.messages[0].sender === 'Alice',
|
|
'decrypted sender is rendered (got ' + JSON.stringify(state.messages[0].sender) + ')');
|
|
}
|
|
|
|
// (2) The user-added channel's metadata updated
|
|
const userCh = state.channels.find((c) => c.hash === 'user:' + channelName);
|
|
assert(userCh && userCh.messageCount === 1,
|
|
'user-added channel messageCount incremented (got ' + (userCh && userCh.messageCount) + ')');
|
|
assert(userCh && userCh.lastMessage && userCh.lastMessage.indexOf('hello') !== -1,
|
|
'user-added channel lastMessage updated (got ' + (userCh && userCh.lastMessage) + ')');
|
|
|
|
// (3) No duplicate plain "medusa" entry was created in the sidebar
|
|
const dupes = state.channels.filter((c) => c.hash === channelName);
|
|
assert(dupes.length === 0,
|
|
'no duplicate non-prefixed channel entry created (got ' + dupes.length + ')');
|
|
assert(state.channels.length === 1,
|
|
'sidebar still has exactly the one user-added channel (got ' + state.channels.length + ')');
|
|
|
|
// (4) Unread NOT bumped on the channel actively being viewed
|
|
assert(!userCh || !userCh.unread,
|
|
'unread NOT bumped on the actively-viewed channel (got ' + (userCh && userCh.unread) + ')');
|
|
|
|
console.log('\n=== Results ===');
|
|
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
run().catch((e) => { console.error(e); process.exit(1); });
|