mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 09:54:43 +00:00
fix: show lock message for encrypted channels without key on deep link (#783)
## Problem Deep-linking to an encrypted channel (e.g. `#/channels/42`) when the user has no client-side decryption key falls through to the plaintext API fetch, displaying gibberish base64/binary content instead of a meaningful message. ## Root Cause In `selectChannel()`, the encrypted channel key-matching loop iterates all stored keys. If none match, execution falls through to the normal plaintext message fetch — which returns raw encrypted data rendered as gibberish. ## Fix After the key-matching loop for encrypted channels, return early with the lock message instead of falling through. **3 lines added** in `public/channels.js`, **108 lines** regression test in `test-frontend-helpers.js`. ## Investigation: Sidebar Display The sidebar filtering is already correct: - DB path: SQL filters out `enc_` prefix channel hashes - In-memory path: Only returns `type: CHAN` (server-decrypted) channels, with `hasGarbageChars` validation - Server-side decryption: MAC verification (2-byte HMAC) + UTF-8 + non-printable character validation prevents false-positive decryptions - Encrypted channels only appear when the toggle is explicitly enabled ## Testing - All existing tests pass - New regression test verifies: lock message shown, messages API NOT called for encrypted channels without key Fixes #781 --------- Co-authored-by: you <you@example.com>
This commit is contained in:
@@ -1160,6 +1160,9 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
// #781: No matching key found — show lock message instead of fetching gibberish
|
||||
msgEl.innerHTML = '<div class="ch-empty">🔒 This channel is encrypted and no decryption key is configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
@@ -2700,6 +2700,114 @@ console.log('\n=== channels.js: WS batch + region snapshot integration ===');
|
||||
assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root');
|
||||
});
|
||||
}
|
||||
// ===== CHANNELS.JS: #781 encrypted channel without key shows lock message =====
|
||||
console.log('\n=== channels.js: encrypted channel without key shows lock message (#781) ===');
|
||||
{
|
||||
test('selectChannel shows lock message for encrypted channel with no matching key', async () => {
|
||||
const ctx = makeSandbox();
|
||||
const dom = {};
|
||||
function makeEl(id) {
|
||||
if (dom[id]) return dom[id];
|
||||
dom[id] = {
|
||||
id,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
value: '',
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
clientHeight: 80,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; },
|
||||
setAttribute() {},
|
||||
removeAttribute() {},
|
||||
focus() {},
|
||||
};
|
||||
return dom[id];
|
||||
}
|
||||
const headerText = { textContent: '' };
|
||||
makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null);
|
||||
makeEl('chMessages');
|
||||
makeEl('chList');
|
||||
makeEl('chScrollBtn');
|
||||
makeEl('chAriaLive');
|
||||
makeEl('chBackBtn');
|
||||
makeEl('chRegionFilter');
|
||||
const appEl = {
|
||||
innerHTML: '',
|
||||
querySelector(sel) {
|
||||
if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel);
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return makeEl(sel);
|
||||
},
|
||||
addEventListener() {},
|
||||
};
|
||||
let apiCallPaths = [];
|
||||
ctx.document.getElementById = makeEl;
|
||||
ctx.document.querySelector = (sel) => {
|
||||
if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } };
|
||||
return null;
|
||||
};
|
||||
ctx.document.querySelectorAll = () => [];
|
||||
ctx.document.addEventListener = () => {};
|
||||
ctx.document.removeEventListener = () => {};
|
||||
ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} };
|
||||
ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } };
|
||||
ctx.history = { replaceState() {} };
|
||||
ctx.matchMedia = () => ({ matches: false });
|
||||
ctx.window.matchMedia = ctx.matchMedia;
|
||||
ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; };
|
||||
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.api = (path) => {
|
||||
apiCallPaths.push(path);
|
||||
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
|
||||
// Return an encrypted channel in the list
|
||||
if (path.indexOf('/channels') === 0 && path.indexOf('/messages') === -1) {
|
||||
return Promise.resolve({ channels: [
|
||||
{ hash: '42', name: 'secret-chan', messageCount: 5, lastActivity: null, encrypted: true }
|
||||
] });
|
||||
}
|
||||
// This should NOT be called for encrypted channels without a key
|
||||
if (path.indexOf('/messages') !== -1) {
|
||||
return Promise.resolve({ messages: [{ sender: 'X', text: 'gibberish', timestamp: '2025-01-01T00:00:00Z' }] });
|
||||
}
|
||||
return Promise.resolve({});
|
||||
};
|
||||
ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 };
|
||||
ctx.ROLE_EMOJI = {};
|
||||
ctx.ROLE_LABELS = {};
|
||||
ctx.timeAgo = () => '1m ago';
|
||||
ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; };
|
||||
ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64');
|
||||
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8');
|
||||
|
||||
ctx.crypto = { subtle: require('crypto').webcrypto.subtle }; ctx.TextEncoder = TextEncoder; ctx.TextDecoder = TextDecoder; ctx.Uint8Array = Uint8Array;
|
||||
loadInCtx(ctx, 'public/channel-decrypt.js');
|
||||
loadInCtx(ctx, 'public/channels.js');
|
||||
ctx._pageHandlers.init(appEl);
|
||||
// Wait for loadChannels() to resolve (async in init)
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
|
||||
// Select the encrypted channel — no stored keys exist
|
||||
apiCallPaths = [];
|
||||
await ctx.window._channelsSelectChannelForTest('42');
|
||||
|
||||
// Should show lock message, NOT fetch messages API
|
||||
const msgEl = dom['chMessages'];
|
||||
assert.ok(msgEl.innerHTML.includes('🔒'), 'should show lock emoji for encrypted channel without key');
|
||||
assert.ok(msgEl.innerHTML.includes('no decryption key'), 'should mention no decryption key');
|
||||
const messageApiFetched = apiCallPaths.some(p => p.indexOf('/messages') !== -1);
|
||||
assert.ok(!messageApiFetched, 'should NOT fetch messages API for encrypted channel without key');
|
||||
});
|
||||
}
|
||||
// ===== PACKETS.JS: savedTimeWindowMin default guard =====
|
||||
console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user