From 3290ff1ed5cd3055e5d8daa40e17507b5ddd7741 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Sun, 3 May 2026 21:56:43 -0700 Subject: [PATCH] fix(channels): auto-decrypt PSK channels on WebSocket live feed (#1029) (#1030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1029. ## Problem PSK-decrypted channels show new messages only after a full page refresh. The WebSocket live feed delivers `GRP_TXT` packets as encrypted blobs and the channel UI has no hook to auto-decrypt them with stored keys. The REST fetch path (used on initial load + on `selectChannel`) already decrypts; the WS path silently dropped on the floor. ## Fix Two new helpers in `public/channel-decrypt.js`: - `buildKeyMap()` → `Map` built from `getStoredKeys()`. Cached and invalidated on `saveKey` / `removeKey`, so the WS hot path is O(1) per packet after the first build. - `tryDecryptLive(payload, keyMap)` → returns `{ sender, text, channelName, channelHashByte }` when the payload is an encrypted `GRP_TXT` whose channel hash matches a stored key and whose MAC verifies; `null` otherwise. `public/channels.js` wraps `debouncedOnWS` with an async pre-pass (`decryptLivePSKBatch`) that: 1. Skips the work entirely when no encrypted `GRP_TXT` is in the batch or no PSK keys are stored. 2. For each match, rewrites `payload.channel`, `payload.sender`, and `payload.text` so the existing `processWSBatch` consumes the packet exactly the same way it consumes a server-decrypted `CHAN`. 3. Bumps a per-channel `unread` counter for any decrypted message whose channel is not currently selected. The badge renders in the sidebar (`.ch-unread-badge`) and resets on `selectChannel`. `processWSBatch` itself is untouched, so the existing channel-view behavior, dedup-by-packet-hash, region filtering, and timestamp ticker all continue to work as before. ## TDD - **Red** (`2e1ff05`): `test-channel-live-decrypt.js` asserts the new helpers + the channels.js integration contract. With stub `buildKeyMap`/`tryDecryptLive` returning empty/null, the test compiles and runs to completion with **8/14 assertion failures** (no crashes, no missing-symbol errors). - **Green** (`1783658`): real implementation lands; **14/14 pass**. ## Verification (Rule 18) - `node test-channel-live-decrypt.js` → 14/14 pass - All other channel tests still pass: - `test-channel-decrypt-ecb.js` 7/7 - `test-channel-decrypt-insecure-context.js` 8/8 - `test-channel-decrypt-m345.js` 24/24 - `test-channel-psk-ux.js` 19/19 - `cd cmd/server && go build ./...` clean - Booted the server against the fixture DB and curled `/channel-decrypt.js`, `/channels.js`, `/style.css` — all three serve the new code with the auto-injected `__BUST__` cache buster. ## Performance The WS pre-pass is gated by a quick scan: zero-cost when no encrypted `GRP_TXT` is present in the batch. When PSK keys exist, the key map is cached (sig-keyed on the stored-keys snapshot) so `crypto.subtle.digest` runs once per stored key per change, not per packet. Each match costs one MAC verify + one ECB decrypt — the same work `fetchAndDecryptChannel` already does, just amortized over time instead of in a single batch. ## Out of scope - Decoupling the badge from the live feed (server should ideally tag packets with `decryptionStatus` before broadcast). Tracked separately. - Persisting the `unread` counter across reloads (currently in-memory). --------- Co-authored-by: clawbot --- public/channel-decrypt.js | 86 ++++++++++++++++++- public/channels.js | 71 +++++++++++++++- public/style.css | 13 +++ test-channel-live-decrypt.js | 159 +++++++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 test-channel-live-decrypt.js diff --git a/public/channel-decrypt.js b/public/channel-decrypt.js index d73af665..b7456062 100644 --- a/public/channel-decrypt.js +++ b/public/channel-decrypt.js @@ -210,12 +210,93 @@ window.ChannelDecrypt = (function () { // Alias used by channels.js var decryptPacket = decrypt; + // ---- Live PSK decrypt (WS path) ---- + // + // Build a Map from all + // stored PSK keys so the WebSocket handler can do an O(1) lookup on each + // incoming GRP_TXT packet. Hash byte derivation is async, so we cache the + // map between calls and only rebuild when the stored-keys set changes. + var _keyMapCache = null; + var _keyMapSig = ''; + + function _keysSignature(keys) { + var names = Object.keys(keys).sort(); + var sig = ''; + for (var i = 0; i < names.length; i++) { + sig += names[i] + '=' + keys[names[i]] + ';'; + } + return sig; + } + + async function buildKeyMap() { + var keys = getKeys(); + var sig = _keysSignature(keys); + if (_keyMapCache && _keyMapSig === sig) return _keyMapCache; + var map = new Map(); + var names = Object.keys(keys); + for (var i = 0; i < names.length; i++) { + var channelName = names[i]; + var keyHex = keys[channelName]; + if (!keyHex || typeof keyHex !== 'string') continue; + var keyBytes; + try { keyBytes = hexToBytes(keyHex); } catch (e) { continue; } + if (keyBytes.length !== 16) continue; + var hashByte; + try { hashByte = await computeChannelHash(keyBytes); } catch (e) { continue; } + // First-write-wins on collision (rare): different channel names can + // hash to the same byte. The downstream MAC check still gates rendering. + if (!map.has(hashByte)) { + map.set(hashByte, { channelName: channelName, keyBytes: keyBytes, keyHex: keyHex }); + } + } + _keyMapCache = map; + _keyMapSig = sig; + return map; + } + + /** + * Attempt to decrypt a live GRP_TXT payload using a prebuilt key map. + * Returns { sender, text, channelName, channelHashByte } on success, + * or null when no key matches, MAC verification fails, or the payload + * is not an encrypted GRP_TXT. + */ + async function tryDecryptLive(payload, keyMap) { + if (!payload || payload.type !== 'GRP_TXT') return null; + if (!payload.encryptedData || !payload.mac) return null; + if (!keyMap || typeof keyMap.get !== 'function') return null; + var hashByte = payload.channelHash; + // channelHash arrives as either a number or a hex string in some paths; + // normalize to number so Map.get hits. + if (typeof hashByte === 'string') { + var n = parseInt(hashByte, 16); + if (!isFinite(n)) return null; + hashByte = n; + } + if (typeof hashByte !== 'number') return null; + var entry = keyMap.get(hashByte); + if (!entry) return null; + var result; + try { + result = await decrypt(entry.keyBytes, payload.mac, payload.encryptedData); + } catch (e) { return null; } + if (!result) return null; + return { + sender: result.sender || 'Unknown', + text: result.message || '', + channelName: entry.channelName, + channelHashByte: hashByte, + timestamp: result.timestamp || null + }; + } + + // ---- Key storage (localStorage) ---- function saveKey(channelName, keyHex, label) { var keys = getKeys(); keys[channelName] = keyHex; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ } + _keyMapCache = null; // invalidate live-decrypt index if (typeof label === 'string' && label.trim()) { saveLabel(channelName, label.trim()); } @@ -238,6 +319,7 @@ window.ChannelDecrypt = (function () { var keys = getKeys(); delete keys[channelName]; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ } + _keyMapCache = null; // invalidate live-decrypt index // Also clear cached messages and any label for this channel (#1020) clearChannelCache(channelName); var labels = getLabels(); @@ -350,6 +432,8 @@ window.ChannelDecrypt = (function () { cacheMessages: cacheMessages, getCachedMessages: getCachedMessages, setCache: setCache, - getCache: getCache + getCache: getCache, + buildKeyMap: buildKeyMap, + tryDecryptLive: tryDecryptLive }; })(); diff --git a/public/channels.js b/public/channels.js index 105cc660..f9c8980e 100644 --- a/public/channels.js +++ b/public/channels.js @@ -1032,8 +1032,68 @@ processWSBatch(msgs, selectedRegions); } + // Pre-pass: rewrite encrypted GRP_TXT live packets into decrypted form + // when a stored PSK key matches their channel hash byte (#1029 — live + // PSK decrypt). Without this, users viewing a PSK-decrypted channel + // had to refresh the page to see new messages. + async function decryptLivePSKBatch(msgs) { + if (typeof ChannelDecrypt === 'undefined' || + typeof ChannelDecrypt.tryDecryptLive !== 'function') { + return; + } + // Quick scan: do any messages look like encrypted GRP_TXT? + var anyEncrypted = false; + for (var i = 0; i < msgs.length; i++) { + var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload; + if (p && p.type === 'GRP_TXT' && p.encryptedData && p.mac) { anyEncrypted = true; break; } + } + if (!anyEncrypted) return; + var keyMap; + try { keyMap = await ChannelDecrypt.buildKeyMap(); } catch (e) { return; } + if (!keyMap || keyMap.size === 0) return; + for (var j = 0; j < msgs.length; j++) { + var m = msgs[j]; + var payload = m && m.data && m.data.decoded && m.data.decoded.payload; + if (!payload || payload.type !== 'GRP_TXT' || !payload.encryptedData || !payload.mac) continue; + var dec; + try { dec = await ChannelDecrypt.tryDecryptLive(payload, keyMap); } catch (e) { dec = null; } + if (!dec) continue; + // Rewrite payload into a CHAN-like shape so processWSBatch picks it + // up as a real message instead of an encrypted blob. Keep the original + // hash byte for any downstream consumer that wants it. + payload.channel = dec.channelName; + payload.sender = dec.sender; + payload.text = dec.sender ? (dec.sender + ': ' + dec.text) : dec.text; + payload.decryptedLocally = true; + if (m.data.decoded.header) { + // Leave payloadTypeName as GRP_TXT — processWSBatch already + // accepts both 'message' and GRP_TXT-typed packet messages. + } + } + } + wsHandler = debouncedOnWS(function (msgs) { - handleWSBatch(msgs); + var selectedRegions = getSelectedRegionsSnapshot(); + var prior = selectedHash; + decryptLivePSKBatch(msgs).then(function () { + // Bump unread for live-decrypted channels the user is NOT viewing. + // Done here (not inside processWSBatch) so the count reflects ONLY + // newly-decrypted live packets, not historical-fetch path. + var bumped = false; + for (var i = 0; i < msgs.length; i++) { + var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload; + if (!p || !p.decryptedLocally) continue; + var chName = p.channel; + if (!chName || chName === prior) continue; + var ch = channels.find(function (c) { return c.hash === chName || c.name === chName || c.hash === ('user:' + chName); }); + if (ch) { + ch.unread = (ch.unread || 0) + 1; + bumped = true; + } + } + processWSBatch(msgs, selectedRegions); + if (bumped) renderChannelList(); + }); }); window._channelsHandleWSBatchForTest = handleWSBatch; window._channelsProcessWSBatchForTest = processWSBatch; @@ -1137,12 +1197,16 @@ // #1020: explicit badge marker for "your key" so it's distinguishable // from server-known encrypted rows at a glance and for screen readers. const userBadge = isUserAdded ? ' 🔑' : ''; + // #1029 Unread badge — bumped by live PSK decrypt for channels not currently selected. + const unreadBadge = (ch.unread && ch.unread > 0) + ? ' ' + (ch.unread > 99 ? '99+' : ch.unread) + '' + : ''; return `