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 `