From d809d86182ab802472ae7d5e74cab66ba7bfa397 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sat, 31 Oct 2026 07:39:18 -0500 Subject: Package private chat for HTTPS and verify portable encrypted recovery --- crates/chat-server/tests/browser.mjs | 128 ++++++++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 23 deletions(-) (limited to 'crates/chat-server/tests/browser.mjs') diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs index e12c3da..7a359e7 100644 --- a/crates/chat-server/tests/browser.mjs +++ b/crates/chat-server/tests/browser.mjs @@ -15,23 +15,88 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:fs/promises'; +import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { spawn, execFileSync, spawnSync } from 'node:child_process'; +import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; 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:${process.env.CHAT_TEST_PORT || '9081'}`; +const run = promisify(execFile); +const packaged = Boolean(process.env.CHAT_TEST_ROOT); +let root = packaged ? resolve(process.env.CHAT_TEST_ROOT) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +const initialRoot = root; +const token = packaged + ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN=')).slice('CHAT_SETUP_TOKEN='.length) + : randomBytes(32).toString('hex'); +const origin = process.env.CHAT_TEST_ORIGIN || `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; +if (packaged) { + const fixture = await readFile(join(root,'.env'),'utf8'); + const address = new URL(origin); + assert.equal(address.protocol,'https:'); + assert.ok(address.hostname.endsWith('.localhost'),'Packaged tests require a disposable .localhost origin'); + assert.ok(fixture.split('\n').includes('WEB_BIND=127.0.0.1'),'Packaged tests require loopback-only publishing'); +} 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}); +const testHome = await mkdtemp(join(tmpdir(),'stack-chat-test-')); +const gpgHome = join(testHome,'gnupg'); +let restorations = 0; +if (packaged) { + await mkdir(gpgHome,{mode:0o700}); + await run('gpg',['--no-options','--homedir',gpgHome,'--batch','--pinentry-mode','loopback','--passphrase','','--quick-generate-key','Stack disposable recovery fixture','rsa2048','encr','1d']); +} else { + 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}); +} +async function stack(...args) { + return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000}); +} +async function restorePackage() { + const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']); + const fingerprint = listed.stdout.split('\n').find(line => line.startsWith('fpr:')).split(':')[9]; + const key = join(testHome,'recipient.asc'); + const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]); + await writeFile(key,exported.stdout); + const backup = await stack('backup','--include-images','--recipient',fingerprint,'--key',key); + const archive = backup.stdout.match(/Encrypted export: (.+)/)?.[1]; + assert.ok(archive,backup.stdout); + assert.equal((await stat(archive)).mode & 0o777,0o600); + assert.equal((await stat(archive + '.sha256')).mode & 0o777,0o600); + await stopServer(); + const destination = `${initialRoot}-restored-${++restorations}`; + await stack('restore',archive,destination); + root = destination; + assert.equal(await readFile(join(root,'.env'),'utf8'),await readFile(join(initialRoot,'.env'),'utf8')); + for (const name of ['target','.tools']) assert.equal(spawnSync('test',['-e',join(root,name)]).status,1); + await launchServer(); + console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`); +} let server; let serverErrors = ''; +async function verifyGateway() { + const url = new URL(origin); + const args = ['--noproxy','*','--silent','--show-error','--fail']; + if (url.hostname.endsWith('.localhost')) { + const state = JSON.parse(await readFile(join(root,'var/state.json'),'utf8')); + const certificate = join(testHome,'gateway-ca.crt'); + await run('podman',['--root',join(root,'var/podman/storage'),'--runroot',join(root,'var/podman/run'),'cp',`${state.project}-caddy:/data/caddy/pki/authorities/local/root.crt`,certificate]); + args.push('--cacert',certificate,'--resolve',`${url.hostname}:${url.port}:127.0.0.1`); + } + const health = await run('curl',[...args,origin + '/healthz']); + assert.equal(health.stdout,'ok\n'); +} async function launchServer() { + if (packaged) { + await stack('up','--production'); + let failure; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { await verifyGateway(); return; } + catch (error) { failure = error; await new Promise(done => setTimeout(done,500)); } + } + throw failure; + } server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']}); server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); await Promise.race([ @@ -40,6 +105,7 @@ async function launchServer() { ]); } async function stopServer() { + if (packaged) { await stack('down'); return; } if (server.exitCode !== null || server.signalCode !== null) return; const stopped = new Promise(done => server.once('exit',done)); server.kill('SIGINT'); @@ -50,10 +116,10 @@ const libre = process.env.CHAT_LIBREJS === '1'; let persistent; let browser; if (libre) { - const profile = join(root,'firefox-profile'); + const profile = join(testHome,'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:{ + persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,ignoreHTTPSErrors:packaged,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, @@ -66,11 +132,18 @@ const external = []; const errors = []; const bodies = []; async function page() { - const context = persistent || await browser.newContext({acceptDownloads:true}); + const context = persistent || await browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}); contexts.push(context); const page = await context.newPage(); page.setDefaultTimeout(90000); page.on('pageerror', error => errors.push(error.message)); + const navigate = page.goto.bind(page); + page.goto = async (...args) => { + for (let attempt = 0; ; attempt += 1) { + try { return await navigate(...args); } + catch (error) { if (attempt >= 2 || !error.message.includes('ERR_NETWORK_CHANGED')) throw error; } + } + }; page.on('request', request => { if (!request.url().startsWith(origin + '/')) external.push(request.url()); @@ -129,7 +202,7 @@ try { if (libre) await verifyLibreJS(); const owner = await page(); const response = await owner.goto(origin); - await owner.locator('#owner-choice').waitFor({state:'visible'}); + await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); }); await owner.click('#owner-choice'); assert.match(response.headers()['cache-control'], /no-store/); assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); @@ -211,12 +284,15 @@ try { 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(); + if (packaged) await restorePackage(); + else { + 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 waitEntry(visitor); await visitor.click('#open-choice'); @@ -279,11 +355,15 @@ try { 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','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`); + for (const filename of (await readdir(join(root,'var/data/chat'))).filter(name => name.startsWith('chat.sqlite'))) { + const database = await readFile(join(root,'var/data/chat',filename)); + for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted in ${filename}`); + } const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); assert.equal(blocked.status(),403); - const administrative = (...args) => spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); + const administrative = (...args) => packaged + ? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'}) + : spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); const registered = administrative('--owner-fingerprint'); assert.equal(registered.status,0); const fingerprint = registered.stdout.trim(); @@ -317,7 +397,7 @@ try { 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 extensions = JSON.parse(await readFile(join(testHome,'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.`); @@ -327,5 +407,7 @@ try { clearTimeout(deadline); await browser.close(); await stopServer(); - await rm(root,{recursive:true,force:true}); + if (!packaged) await rm(root,{recursive:true,force:true}); + else await run('gpgconf',['--homedir',gpgHome,'--kill','all']); + await rm(testHome,{recursive:true,force:true}); } -- cgit v1.2.3