mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 13:54:43 +00:00
3290ff1ed5
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<channelHashByte, { channelName, keyBytes, keyHex }>` 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 <bot@corescope.local>
440 lines
15 KiB
JavaScript
440 lines
15 KiB
JavaScript
/**
|
|
* Client-side MeshCore channel decryption module.
|
|
*
|
|
* Implements the same crypto as internal/channel/channel.go:
|
|
* - Key derivation: SHA-256("#channelname")[:16]
|
|
* - Channel hash: SHA-256(key)[0]
|
|
* - MAC: HMAC-SHA256 with 32-byte secret (key + 16 zero bytes), truncated to 2 bytes
|
|
* - Encryption: AES-128-ECB (block-by-block)
|
|
* - Plaintext: timestamp(4 LE) + flags(1) + "sender: message\0"
|
|
*
|
|
* Keys NEVER leave the browser. No fetch/XHR/network calls in this module.
|
|
*/
|
|
/* eslint-disable no-var */
|
|
window.ChannelDecrypt = (function () {
|
|
'use strict';
|
|
|
|
var STORAGE_KEY = 'corescope_channel_keys';
|
|
var LABELS_KEY = 'corescope_channel_labels';
|
|
var CACHE_KEY = 'corescope_channel_cache';
|
|
|
|
// ---- Hex utilities ----
|
|
|
|
function bytesToHex(bytes) {
|
|
var hex = '';
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
hex += (bytes[i] < 16 ? '0' : '') + bytes[i].toString(16);
|
|
}
|
|
return hex;
|
|
}
|
|
|
|
function hexToBytes(hex) {
|
|
var bytes = new Uint8Array(hex.length / 2);
|
|
for (var i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
// ---- Key derivation ----
|
|
|
|
// Detect whether SubtleCrypto is available. SubtleCrypto is only exposed
|
|
// in **secure contexts** (HTTPS or localhost) — when CoreScope is served
|
|
// over plain HTTP, `crypto.subtle` is undefined and any digest/HMAC call
|
|
// throws. We fall back to the vendored pure-JS implementation in
|
|
// public/vendor/sha256-hmac.js. PR #1021 did the same for AES-ECB.
|
|
function hasSubtle() {
|
|
return typeof crypto !== 'undefined' && crypto && crypto.subtle && typeof crypto.subtle.digest === 'function';
|
|
}
|
|
|
|
function pureCryptoOrThrow() {
|
|
var host = (typeof window !== 'undefined') ? window
|
|
: (typeof self !== 'undefined') ? self : null;
|
|
if (!host || !host.PureCrypto || !host.PureCrypto.sha256 || !host.PureCrypto.hmacSha256) {
|
|
throw new Error('PureCrypto vendor module not loaded (public/vendor/sha256-hmac.js). ' +
|
|
'crypto.subtle is unavailable (HTTP context) and no fallback present.');
|
|
}
|
|
return host.PureCrypto;
|
|
}
|
|
|
|
/**
|
|
* Derive AES-128 key from channel name: SHA-256("#channelname")[:16].
|
|
* @param {string} channelName - e.g. "#LongFast"
|
|
* @returns {Promise<Uint8Array>} 16-byte key
|
|
*/
|
|
async function deriveKey(channelName) {
|
|
var enc = new TextEncoder();
|
|
var data = enc.encode(channelName);
|
|
if (hasSubtle()) {
|
|
var hash = await crypto.subtle.digest('SHA-256', data);
|
|
return new Uint8Array(hash).slice(0, 16);
|
|
}
|
|
return pureCryptoOrThrow().sha256(data).slice(0, 16);
|
|
}
|
|
|
|
/**
|
|
* Compute the 1-byte channel hash: SHA-256(key)[0].
|
|
* @param {Uint8Array} key - 16-byte key
|
|
* @returns {Promise<number>} single byte (0-255)
|
|
*/
|
|
async function computeChannelHash(key) {
|
|
if (hasSubtle()) {
|
|
var hash = await crypto.subtle.digest('SHA-256', key);
|
|
return new Uint8Array(hash)[0];
|
|
}
|
|
return pureCryptoOrThrow().sha256(key)[0];
|
|
}
|
|
|
|
// ---- AES-128-ECB via vendored pure-JS implementation ----
|
|
//
|
|
// Web Crypto exposes AES-CBC/CTR/GCM but NOT raw AES-ECB. The previous
|
|
// implementation simulated ECB with AES-CBC + zero IV + a dummy PKCS7
|
|
// padding block; that hack throws OperationError on real ciphertext
|
|
// because Web Crypto validates PKCS7 padding on the decrypted output
|
|
// and the dummy padding bytes rarely form a valid PKCS7 sequence
|
|
// after decryption. We use a pure-JS AES-128 ECB core
|
|
// (public/vendor/aes-ecb.js, MIT, derived from aes-js by Richard
|
|
// Moore) so decryption is deterministic across browsers and works in
|
|
// HTTP contexts.
|
|
|
|
/**
|
|
* Decrypt AES-128-ECB.
|
|
* @param {Uint8Array} key - 16-byte AES key
|
|
* @param {Uint8Array} ciphertext - must be a non-zero multiple of 16 bytes
|
|
* @returns {Promise<Uint8Array|null>} plaintext, or null on invalid input
|
|
*/
|
|
async function decryptECB(key, ciphertext) {
|
|
if (!ciphertext || ciphertext.length === 0 || ciphertext.length % 16 !== 0) {
|
|
return null;
|
|
}
|
|
var host = (typeof window !== 'undefined') ? window
|
|
: (typeof self !== 'undefined') ? self : null;
|
|
if (!host || !host.AES_ECB || !host.AES_ECB.decrypt) {
|
|
throw new Error('AES_ECB vendor module not loaded (public/vendor/aes-ecb.js)');
|
|
}
|
|
return host.AES_ECB.decrypt(key, ciphertext);
|
|
}
|
|
|
|
// ---- MAC verification ----
|
|
|
|
/**
|
|
* Verify HMAC-SHA256 MAC (first 2 bytes) using 32-byte secret (key + 16 zero bytes).
|
|
* @param {Uint8Array} key - 16-byte AES key
|
|
* @param {Uint8Array} ciphertext - encrypted data
|
|
* @param {string} macHex - 4-char hex string (2 bytes)
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async function verifyMAC(key, ciphertext, macHex) {
|
|
// Build 32-byte channel secret: key + 16 zero bytes
|
|
var secret = new Uint8Array(32);
|
|
secret.set(key, 0);
|
|
// remaining 16 bytes are already 0
|
|
|
|
var macBytes = hexToBytes(macHex);
|
|
var sigBytes;
|
|
if (hasSubtle() && typeof crypto.subtle.importKey === 'function' && typeof crypto.subtle.sign === 'function') {
|
|
var cryptoKey = await crypto.subtle.importKey(
|
|
'raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
|
);
|
|
var sig = await crypto.subtle.sign('HMAC', cryptoKey, ciphertext);
|
|
sigBytes = new Uint8Array(sig);
|
|
} else {
|
|
sigBytes = pureCryptoOrThrow().hmacSha256(secret, ciphertext);
|
|
}
|
|
return sigBytes[0] === macBytes[0] && sigBytes[1] === macBytes[1];
|
|
}
|
|
|
|
// ---- Plaintext parsing ----
|
|
|
|
/**
|
|
* Parse decrypted plaintext: timestamp(4 LE) + flags(1) + "sender: message\0..."
|
|
* @param {Uint8Array} plaintext
|
|
* @returns {{ timestamp: number, flags: number, sender: string, message: string } | null}
|
|
*/
|
|
function parsePlaintext(plaintext) {
|
|
if (!plaintext || plaintext.length < 5) return null;
|
|
|
|
var timestamp = plaintext[0] | (plaintext[1] << 8) | (plaintext[2] << 16) | ((plaintext[3] << 24) >>> 0);
|
|
var flags = plaintext[4];
|
|
|
|
// Extract text up to first null byte
|
|
var textBytes = plaintext.slice(5);
|
|
var nullIdx = -1;
|
|
for (var i = 0; i < textBytes.length; i++) {
|
|
if (textBytes[i] === 0) { nullIdx = i; break; }
|
|
}
|
|
var text = new TextDecoder().decode(nullIdx >= 0 ? textBytes.slice(0, nullIdx) : textBytes);
|
|
|
|
// Count non-printable characters
|
|
var nonPrintable = 0;
|
|
for (var c = 0; c < text.length; c++) {
|
|
var code = text.charCodeAt(c);
|
|
if (code < 32 && code !== 10 && code !== 13 && code !== 9) nonPrintable++;
|
|
}
|
|
if (nonPrintable > 2) return null;
|
|
|
|
// Parse "sender: message" format
|
|
var colonIdx = text.indexOf(': ');
|
|
if (colonIdx > 0 && colonIdx < 50) {
|
|
var potentialSender = text.substring(0, colonIdx);
|
|
if (potentialSender.indexOf(':') < 0 && potentialSender.indexOf('[') < 0 && potentialSender.indexOf(']') < 0) {
|
|
return { timestamp: timestamp, flags: flags, sender: potentialSender, message: text.substring(colonIdx + 2) };
|
|
}
|
|
}
|
|
|
|
return { timestamp: timestamp, flags: flags, sender: '', message: text };
|
|
}
|
|
|
|
// ---- Full decrypt pipeline ----
|
|
|
|
/**
|
|
* Verify MAC, decrypt, and parse a single packet.
|
|
* @param {Uint8Array} keyBytes - 16-byte key
|
|
* @param {string} macHex - 4-char hex MAC
|
|
* @param {string} encryptedHex - hex-encoded ciphertext
|
|
* @returns {Promise<{ sender: string, message: string, timestamp: number } | null>}
|
|
*/
|
|
async function decrypt(keyBytes, macHex, encryptedHex) {
|
|
var ciphertext = hexToBytes(encryptedHex);
|
|
if (ciphertext.length === 0 || ciphertext.length % 16 !== 0) return null;
|
|
|
|
var macOk = await verifyMAC(keyBytes, ciphertext, macHex);
|
|
if (!macOk) return null;
|
|
|
|
var plaintext = await decryptECB(keyBytes, ciphertext);
|
|
if (!plaintext) return null;
|
|
|
|
return parsePlaintext(plaintext);
|
|
}
|
|
|
|
// Alias used by channels.js
|
|
var decryptPacket = decrypt;
|
|
|
|
// ---- Live PSK decrypt (WS path) ----
|
|
//
|
|
// Build a Map<channelHashByte, { channelName, keyBytes, keyHex }> 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());
|
|
}
|
|
}
|
|
|
|
// Alias used by channels.js
|
|
var storeKey = saveKey;
|
|
|
|
function getKeys() {
|
|
try {
|
|
var raw = localStorage.getItem(STORAGE_KEY);
|
|
return raw ? JSON.parse(raw) : {};
|
|
} catch (e) { return {}; }
|
|
}
|
|
|
|
// Alias used by channels.js
|
|
var getStoredKeys = getKeys;
|
|
|
|
function removeKey(channelName) {
|
|
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();
|
|
if (labels[channelName]) {
|
|
delete labels[channelName];
|
|
try { localStorage.setItem(LABELS_KEY, JSON.stringify(labels)); } catch (e) { /* quota */ }
|
|
}
|
|
}
|
|
|
|
// ---- User-supplied display labels (#1020) ----
|
|
// Stored separately from keys so we can display friendly names instead of
|
|
// psk:<hex8> for user-added PSK channels.
|
|
function getLabels() {
|
|
try {
|
|
var raw = localStorage.getItem(LABELS_KEY);
|
|
return raw ? JSON.parse(raw) : {};
|
|
} catch (e) { return {}; }
|
|
}
|
|
|
|
function getLabel(channelName) {
|
|
var labels = getLabels();
|
|
return labels[channelName] || '';
|
|
}
|
|
|
|
function saveLabel(channelName, label) {
|
|
var labels = getLabels();
|
|
if (typeof label === 'string' && label.trim()) {
|
|
labels[channelName] = label.trim();
|
|
} else {
|
|
delete labels[channelName];
|
|
}
|
|
try { localStorage.setItem(LABELS_KEY, JSON.stringify(labels)); } catch (e) { /* quota */ }
|
|
}
|
|
|
|
/** Remove cached messages for a specific channel (by name or hash). */
|
|
function clearChannelCache(channelKey) {
|
|
try {
|
|
var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
|
|
delete cache[channelKey];
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
|
} catch (e) { /* quota */ }
|
|
}
|
|
|
|
// ---- Message cache (localStorage) ----
|
|
|
|
function cacheMessages(channelHash, messages) {
|
|
try {
|
|
var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
|
|
cache[channelHash] = { messages: messages, ts: Date.now() };
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
|
} catch (e) { /* quota */ }
|
|
}
|
|
|
|
function getCachedMessages(channelHash) {
|
|
try {
|
|
var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
|
|
var entry = cache[channelHash];
|
|
return entry ? entry.messages : null;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
// Cache with lastTimestamp and count (used by channels.js via getCache/setCache)
|
|
var MAX_CACHED_MESSAGES = 1000;
|
|
|
|
function setCache(key, messages, lastTimestamp, totalCount) {
|
|
try {
|
|
// Enforce cache size limit: only keep most recent MAX_CACHED_MESSAGES
|
|
var toStore = messages;
|
|
if (messages.length > MAX_CACHED_MESSAGES) {
|
|
toStore = messages.slice(messages.length - MAX_CACHED_MESSAGES);
|
|
}
|
|
var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
|
|
cache[key] = {
|
|
messages: toStore,
|
|
lastTimestamp: lastTimestamp,
|
|
count: totalCount || toStore.length,
|
|
ts: Date.now()
|
|
};
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
|
} catch (e) { /* quota */ }
|
|
}
|
|
|
|
function getCache(key) {
|
|
try {
|
|
var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
|
|
return cache[key] || null;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
return {
|
|
deriveKey: deriveKey,
|
|
decrypt: decrypt,
|
|
decryptPacket: decryptPacket,
|
|
decryptECB: decryptECB,
|
|
verifyMAC: verifyMAC,
|
|
parsePlaintext: parsePlaintext,
|
|
computeChannelHash: computeChannelHash,
|
|
bytesToHex: bytesToHex,
|
|
hexToBytes: hexToBytes,
|
|
saveKey: saveKey,
|
|
storeKey: storeKey,
|
|
getKeys: getKeys,
|
|
getStoredKeys: getStoredKeys,
|
|
removeKey: removeKey,
|
|
// #1020: optional user-friendly display labels for stored keys
|
|
saveLabel: saveLabel,
|
|
getLabel: getLabel,
|
|
getLabels: getLabels,
|
|
clearChannelCache: clearChannelCache,
|
|
cacheMessages: cacheMessages,
|
|
getCachedMessages: getCachedMessages,
|
|
setCache: setCache,
|
|
getCache: getCache,
|
|
buildKeyMap: buildKeyMap,
|
|
tryDecryptLive: tryDecryptLive
|
|
};
|
|
})();
|