diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 21:39:57 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 21:39:57 -0500 |
| commit | e6b350a1d48ca0490b7a3447bb8d0a4235514b76 (patch) | |
| tree | 57ae2a9c5455f335e8c34b293f4f13975cb3d09e /crates/chat-server/tests | |
| parent | 0a4da3b86eb69f0f09b802a55e36fa9cf1bbf38c (diff) | |
Implement experimental file-based encrypted private chat
Add fixed-suite Rust/WASI identity files, signed challenge authentication, ciphertext-only SQLite storage, a minimal LibreJS-compatible interface, and consistent backup snapshots. Keep production unchanged and document the local workflow, limits, source distribution, and browser recovery tests.
Diffstat (limited to 'crates/chat-server/tests')
| -rw-r--r-- | crates/chat-server/tests/browser.mjs | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs new file mode 100644 index 0000000..a56c7cc --- /dev/null +++ b/crates/chat-server/tests/browser.mjs @@ -0,0 +1,265 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; + +const root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +const token = randomBytes(32).toString('hex'); +const origin = 'http://127.0.0.1:9080'; +const password = 'browser-test-only-unique-passphrase'; +await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); +for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); +await mkdir(join(root,'var/build'), {recursive:true}); +let server; +let serverErrors = ''; +async function launchServer() { + server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root], {stdio:['ignore','pipe','pipe']}); + server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); + await Promise.race([ + new Promise((ready, reject) => { server.stdout.once('data', ready); server.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()), + ]); +} +async function stopServer() { + if (server.exitCode !== null || server.signalCode !== null) return; + const stopped = new Promise(done => server.once('exit',done)); + server.kill('SIGINT'); + await stopped; +} +await launchServer(); +const libre = process.env.CHAT_LIBREJS === '1'; +let persistent; +let browser; +if (libre) { + const profile = join(root,'firefox-profile'); + await mkdir(join(profile,'extensions'),{recursive:true}); + await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),await readFile(resolve('.tools/librejs.xpi'))); + persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,firefoxUserPrefs:{ + 'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15, + 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, + 'browser.sessionstore.resume_from_crash':false, + }}); + browser = { close: () => persistent.close() }; +} else { browser = await chromium.launch({headless:true,channel:'chromium'}); } +const deadline = setTimeout(() => browser.close(), 300000); +const contexts = []; +const external = []; +const errors = []; +const bodies = []; +async function page() { + const context = persistent || await browser.newContext({acceptDownloads:true}); + contexts.push(context); + const page = await context.newPage(); + page.setDefaultTimeout(90000); + page.on('pageerror', error => errors.push(error.message)); + + page.on('request', request => { + if (!request.url().startsWith(origin + '/')) external.push(request.url()); + if (request.url() === origin + '/api') bodies.push(JSON.parse(request.postData())); + }); + return page; +} +async function waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); } +async function create(page, name, owner = false) { + if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); + await page.fill('#name', name); + await page.fill('#new-password', password); + await page.fill('#confirm-password', password); + const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status').textContent)).then(async () => { throw new Error(await page.locator('#status').textContent()); })]); + await page.click('#create-form button'); + const download = await downloaded; + const bytes = await readFile(await download.path()); + await waitStatus(page,'Reopen your downloaded'); + assert.equal(await page.locator('#chat').isVisible(), false); + assert.equal(bodies.some(body => body.op === 'authenticate'), owner ? false : true); + return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; +} +async function unlock(page, file, phrase = password) { + await page.setInputFiles('#file', file); + await page.fill('#password', phrase); + await page.click('#unlock-form button'); +} +async function storageEmpty(page) { + assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0); + assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]); + assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0); + assert.deepEqual(await page.context().cookies(),[]); +} +async function verifyLibreJS() { + let executed = false; + const controlServer = createServer((request,response) => { + response.setHeader('Cache-Control','no-store'); + if (request.url === '/control.js') { + response.setHeader('Content-Type','text/javascript'); + response.end("window.unlicensedControl = true; fetch('/executed');"); + } else if (request.url === '/executed') { executed = true; response.end('unexpected'); } + else { response.setHeader('Content-Type','text/html'); response.end('<!doctype html><script src="/control.js"></script><p>Unlicensed external-script rejection control.</p>'); } + }); + await new Promise(done => controlServer.listen(0,'127.0.0.1',done)); + const controlPage = await persistent.newPage(); + try { + await controlPage.goto(`http://127.0.0.1:${controlServer.address().port}/`); + await controlPage.waitForTimeout(1500); + assert.equal(await controlPage.evaluate(() => window.unlicensedControl),undefined,'LibreJS must reject the unlicensed control'); + assert.equal(executed,false); + } finally { await controlPage.close(); await new Promise(done => controlServer.close(done)); } +} +try { + if (libre) await verifyLibreJS(); + const owner = await page(); + const response = await owner.goto(origin + '/#setup'); + assert.match(response.headers()['cache-control'], /no-store/); + assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); + await waitStatus(owner,'Owner setup.'); + const ownerFile = await create(owner,'Doan',true); + await unlock(owner,ownerFile); + await waitStatus(owner,'Unlocked in memory only'); + assert.match(await owner.locator('#who').textContent(), /OWNER/); + const visitor = await page(); + await visitor.goto(origin); + await waitStatus(visitor,'Choose CREATE'); + const visitorFile = await create(visitor,'A friend'); + assert.ok(!visitorFile.buffer.toString().includes('A friend')); + await unlock(visitor,visitorFile,'wrong-but-long-password'); + await waitStatus(visitor,'Cryptographic operation failed'); + assert.equal(await visitor.locator('#chat').isVisible(),false); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.match(await visitor.locator('#counts').textContent(),/Total: 0/); + const malicious = '<script>window.chatInjection = true</script> hello λ'; + await visitor.fill('#text',malicious); + await visitor.click('#send-form button'); + await waitStatus(visitor,'Encrypted message stored'); + await owner.click('#refresh-inbox'); + await owner.locator('#conversations button').first().click(); + await waitStatus(owner,'Conversation opened'); + assert.match(await owner.locator('#thread-name').textContent(),/A friend/); + assert.equal(await owner.locator('#messages li').count(),0); + await owner.fill('#history-count','1'); + await owner.click('#history-form button'); + await waitStatus(owner,'Selected history decrypted'); + assert.equal(await owner.locator('#messages p').textContent(),malicious); + assert.equal(await owner.evaluate(() => window.chatInjection),undefined); + await owner.fill('#text','Reply from owner'); + await owner.click('#send-form button'); + await waitStatus(owner,'Encrypted message stored'); + await visitor.fill('#history-count','2'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),2); + assert.match(await visitor.locator('#counts').textContent(),/Total: 2 \| Sent: 1 \| Received: 1/); + let interrupted = false; + await visitor.route('**/api',async route => { + const body = route.request().postDataJSON(); + if (body.op === 'send' && !interrupted) { interrupted = true; await route.fetch(); await route.abort(); } + else await route.continue(); + }); + await visitor.fill('#text','Safe retry after a lost response'); + await visitor.click('#send-form button'); + await visitor.locator('#retry').waitFor({state:'visible'}); + await visitor.waitForFunction(() => !document.getElementById('retry').disabled); + await visitor.click('#retry'); + await waitStatus(visitor,'Encrypted message stored'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + await visitor.reload(); + await waitStatus(visitor,'Choose CREATE'); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.equal(await visitor.locator('#file').inputValue(),''); + assert.equal(await visitor.locator('#password').inputValue(),''); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + assert.equal(await visitor.locator('#messages li').count(),0); + await visitor.fill('#history-count','1'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.match(await visitor.locator('#messages p').textContent(),/Safe retry/); + await visitor.click('#older'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),2); + await visitor.fill('#history-count','0'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'No message history loaded'); + assert.equal(await visitor.locator('#messages li').count(),0); + const snapshot = join(root,'snapshot.sqlite'); + execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); + await stopServer(); + await rm(join(root,'var/data/chat/chat.sqlite')); + await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); + await launchServer(); + await visitor.reload(); + await waitStatus(visitor,'Choose CREATE'); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + await visitor.fill('#history-count','3'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),3); + assert.ok((await visitor.locator('#messages p').allTextContents()).includes(malicious)); + await owner.reload(); + await waitStatus(owner,'Choose CREATE'); + await owner.click('#open-choice'); + await unlock(owner,ownerFile); + await waitStatus(owner,'Unlocked in memory only'); + assert.match(await owner.locator('#who').textContent(),/OWNER/); + await visitor.fill('#history-count','3'); + await visitor.click('#history-form button'); + await visitor.click('#lock'); + await visitor.waitForTimeout(500); + assert.equal(await visitor.locator('#messages li').count(),0); + await visitor.goto(origin + '/LIMITATIONS.txt'); + await visitor.goBack(); + await visitor.waitForFunction(() => !document.getElementById('entry').hidden); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.equal(await visitor.locator('#password').inputValue(),''); + assert.equal(await visitor.locator('#chat').isVisible(),false); + await storageEmpty(visitor); + await storageEmpty(owner); + assert.deepEqual(external,[]); + assert.deepEqual(errors,[]); + for (const body of bodies) { + for (const forbidden of ['file','password','secret','name','text']) assert.ok(!(forbidden in body),`Leaked ${forbidden}`); + assert.ok(!JSON.stringify(body).includes(malicious)); + } + const database = await readFile(join(root,'var/data/chat/chat.sqlite')); + for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`); + const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); + assert.equal(blocked.status(),403); + assert.equal(serverErrors,''); + if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); + if (libre) { + const extensions = JSON.parse(await readFile(join(root,'firefox-profile','extensions.json'),'utf8')); + const addon = extensions.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack'); + assert.ok(addon?.active,'LibreJS must actually be active'); + console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); + } + console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, origin rejection, no browser storage or external requests, no private uploads or database plaintext.'); +} finally { + clearTimeout(deadline); + await browser.close(); + await stopServer(); + await rm(root,{recursive:true,force:true}); +} |
