// Candidate checks for package 9, encrypted attachments (XEP-0454). Runs // against the deployed bundle with the dedicated audit accounts; profiles, // credentials and reports stay outside Git. One engine at a time. // // What it can and cannot prove: two Linux browser profiles exercise the // whole wire format, but a round trip against real Monal and iPhone // recording/playback are separate, device-bound checks (OMEMO-PLAN.md). const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const {client, xml} = require('@xmpp/client'); const engine = process.argv[2] || 'chromium'; if (engine === 'webkit') { process.env.UX_WEBKIT_PATH ??= '/nix/store/kvbchbp55jd09wh2h1bv3xx9cz26k6vg-playwright-webkit/pw_run.sh'; process.env.LIBGL_ALWAYS_SOFTWARE ??= '1'; process.env.EGL_PLATFORM ??= 'surfaceless'; process.env.LIBGL_DRIVERS_PATH ??= '/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/lib/dri'; process.env.__EGL_VENDOR_LIBRARY_FILENAMES ??= '/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/share/glvnd/egl_vendor.d/50_mesa.json'; } const pw = require(process.env.UX_PLAYWRIGHT_MODULE || (engine === 'webkit' ? '/tmp/chat-ux-webkit/node_modules/playwright' : '/tmp/chat-ux-runner/node_modules/playwright')); const accounts = JSON.parse(fs.readFileSync(process.env.UX_ACCOUNTS_FILE || '/tmp/chat-ux-runner/accounts.json', 'utf8')); const option = name => process.argv.find(arg => arg.startsWith('--' + name + '='))?.slice(name.length + 3); const checks = process.env.UX_CHECKS || option('checks'); const origin = process.env.UX_BASE_URL || option('origin') || 'https://chat.0ff.space'; const profile = process.env.UX_PROFILE_DIR || `/tmp/chat-ux-runner/media-profiles/${engine}/a`; const out = process.env.UX_RESULTS_DIR || `/tmp/chat-ux-runner/results/media-${engine}-${Date.now()}`; fs.mkdirSync(out, {recursive: true, mode: 0o700}); const report = {engine, origin, checks: [], errors: [], simulation: 'Two Linux browser profiles against the deployed bundle, real IndexedDB, service worker and audit XMPP accounts. Not a Monal round trip and not iPhone media hardware.'}; const CHROMIUM = process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium'; // Observed on the wire and on the upload host, for the assertions below. const wire = {messages: [], slotRequests: []}; const puts = []; const foreignRequests = []; let holdPut = false, putGate; let corruptGet = false, missingGet = false; const delay = ms => new Promise(r => setTimeout(r, ms)); async function until(fn, timeout = 20000) { const end = Date.now() + timeout; do { if (await fn()) return; await delay(100); } while (Date.now() < end); throw Error('Condition timed out'); } function save() { fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2), {mode: 0o600}); } const redact = s => String(s).replaceAll(accounts[0].password, '[redacted]').replaceAll(accounts[1].password, '[redacted]'); let ctx, page, bctx, b; async function context(dir, extra = {}) { return pw[engine].launchPersistentContext(dir, { headless: true, viewport: {width: 402, height: 874}, isMobile: true, hasTouch: true, executablePath: engine === 'chromium' ? CHROMIUM : process.env.UX_WEBKIT_PATH, // WebKit has no microphone permission name; only Chromium grants it. ...(engine === 'chromium' ? {permissions: ['microphone']} : {}), ...(engine === 'chromium' ? {args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream']} : {}), ...extra, }); } // The upload host is a different origin, so routing it catches every PUT and // GET without touching the websocket. async function routeUploads(context) { // Registered first on purpose: Playwright runs the most recently added // matching handler first, so the upload-specific route below must come // after this catch-all or the PUTs would never be seen here. await context.route('https://**', async route => { const url = route.request().url(); if (url.startsWith(origin) || url.startsWith('https://upload.0ff.space')) return route.continue(); foreignRequests.push(url); return route.abort(); }); await context.route('https://upload.0ff.space/**', async route => { const request = route.request(); if (request.method() === 'PUT') { const body = request.postDataBuffer(); puts.push({url: request.url(), type: request.headers()['content-type'], bytes: body, size: body?.length}); if (holdPut) await putGate; // The client may have aborted the XHR while the gate was held. return route.continue().catch(() => {}); } if (missingGet) return route.fulfill({status: 404, body: 'gone'}); if (corruptGet) { const response = await route.fetch(); const buffer = Buffer.from(await response.body()); buffer[0] ^= 0x01; // one flipped byte: the GCM tag must reject it // Without this the corrupted body inherits Prosody's immutable caching // and the retry below would just read it back out of the cache. return route.fulfill({response, body: buffer, headers: {...response.headers(), 'cache-control': 'no-store'}}); } return route.continue(); }); } async function launch() { ctx = await context(profile); await routeUploads(ctx); await ctx.routeWebSocket('**/xmpp-websocket', ws => { const server = ws.connectToServer(); ws.onMessage(message => { const s = String(message); // Never trimmed: the checks index into this by send order. if (s.startsWith(' ws.send(m)); }); page = ctx.pages()[0] || await ctx.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', e => { report.errors.push(redact(e.message)); (report.errorDetails ??= []).push({source: 'pageerror', stack: redact(e.stack)}); }); page.on('requestfailed', r => { (report.networkFailures ??= []).push({url: r.url(), type: r.resourceType(), error: r.failure()?.errorText}); }); } async function login(p = page, account = accounts[0]) { await p.goto(origin); let submitted = false; await until(async () => { if (!submitted && await p.locator('[name=username]').isVisible()) { submitted = true; await p.locator('[name=username]').fill(account.jid); await p.locator('[name=password]').fill(account.password); await p.getByRole('checkbox', {name: 'Stay logged in'}).check(); await p.getByRole('button', {name: 'Log in', exact: true}).click(); } return await p.locator('.app').count() > 0 && !await p.locator('.connection').count(); }, 30000); } async function chat(jid = accounts[1].jid, p = page) { await p.evaluate(j => location.hash = '#/chat/' + encodeURIComponent(j), jid); await p.locator('.composer textarea:enabled').waitFor(); } async function check(id, fn) { if (checks && !checks.split(',').includes(id)) return; try { report.checks.push({id, status: 'passed', ...await fn()}); } catch (e) { report.checks.push({id, status: 'failed', detail: redact(e.message)}); await page.screenshot({path: path.join(out, id + '.png')}).catch(() => {}); } save(); console.log(JSON.stringify(report.checks.at(-1))); } // --- fixtures --------------------------------------------------------------- // A real 1x1 PNG, so an genuinely decodes what came back decrypted. const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', 'base64'); const DOC = Buffer.from('audit fixture, package 9, not secret\n'.repeat(8)); const rowFor = (p, selector) => p.locator('.row').filter({has: p.locator(selector)}); const lastBubble = p => p.locator('.bubble').last(); async function attach(p, name, mime, bytes) { await p.locator('input[type=file]').setInputFiles({name, mimeType: mime, buffer: bytes}); } /** The outgoing stanza that carried this send, by send order. */ function outgoing(since) { return wire.messages.slice(since).filter(s => s.includes('')); } function assertEnvelopeOnly(stanzas, label) { assert.ok(stanzas.length >= 1, label + ': no message went out'); for (const s of stanzas) { assert.ok(s.includes('eu.siacs.conversations.axolotl'), label + ': stanza is not OMEMO-encrypted'); assert.ok(!/jabber:x:oob/.test(s), label + ': stanza carries an oob child'); assert.ok(!/aesgcm:/i.test(s), label + ': the aesgcm URL appears in cleartext XML'); } } // --- checks ----------------------------------------------------------------- async function main() { await launch(); await login(); await chat(); await check('MEDIA-CAPABILITY', async () => { // The peer publishes devices from the earlier audits, so this chat is the // encrypted case. Before package 9 the attachment entry was disabled with // "Encrypted files and voice messages aren't supported yet." await until(async () => await page.locator('.lock, [aria-label="End-to-end encrypted"]').count() > 0, 30000); const picker = page.getByRole('button', {name: 'Attach a file', exact: true}); await until(async () => await picker.isEnabled()); const text = await page.locator('.composer').innerText(); assert.ok(!/aren.t supported yet/i.test(text), 'the old limitation text is still shown'); return {encryptedChat: true, attachmentEntryEnabled: true, limitationTextGone: true}; }); await check('MEDIA-PICKER-OPENS', async () => { // The path every other check skips by setting files on the input // directly: pressing the button must actually open the chooser. // // It does NOT guard the iOS restriction this path was fixed for. A // control run with the await restored passes here too, so neither // Chromium nor Linux WebKit enforces Safari's user-activation window for // a programmatic input click. Only the operator's iPhone can show that. const [chooser] = await Promise.all([ page.waitForEvent('filechooser', {timeout: 10000}), page.getByRole('button', {name: 'Attach a file', exact: true}).click(), ]); await chooser.setFiles([]); return {fileChooserOpenedFromTheButton: true}; }); await check('MEDIA-IMAGE-ROUNDTRIP', async () => { bctx = await context(path.join(path.dirname(profile), 'b')); await routeUploads(bctx); b = await bctx.newPage(); b.setDefaultTimeout(15000); await login(b, accounts[1]); await chat(accounts[0].jid, b); const since = wire.messages.length, slots = wire.slotRequests.length, putCount = puts.length; await attach(page, 'Holiday in Rome.png', 'image/png', PNG); // The sender's own bubble renders from a decrypted blob, not from the URL. // Counted, not just waited for: the profile is persistent, so an earlier // run's attachment is already on screen and a bare waitFor would pass // without anything having been sent. const mine = page.locator('.bubble img[src^="blob:"]'); const mineBefore = await mine.count(); await until(async () => await mine.count() > mineBefore, 30000); assertEnvelopeOnly(outgoing(since), 'image'); const slot = wire.slotRequests.slice(slots).at(-1); assert.ok(slot, 'no upload slot was requested'); assert.match(slot, /content-type="application\/octet-stream"/, 'the real media type was declared'); assert.match(slot, /filename="[0-9a-f-]{36}\.png"/, 'the real file name was declared'); assert.ok(!/Holiday/i.test(slot), 'the user file name reached the server'); const put = puts.slice(putCount).at(-1); assert.ok(put, 'no PUT was made'); assert.equal(put.type, 'application/octet-stream'); assert.ok(!put.bytes.equals(PNG), 'the plaintext file was uploaded'); assert.equal(put.size, PNG.length + 16, 'the upload is not plaintext+tag sized'); assert.equal(put.bytes.subarray(0, 8).toString('hex') === PNG.subarray(0, 8).toString('hex'), false, 'the PNG header is visible in the upload'); // The receiving profile decrypts it independently. const theirs = b.locator('.bubble img[src^="blob:"]'); await until(async () => await theirs.count() > 0, 30000); const decoded = await theirs.last().evaluate(el => ({w: el.naturalWidth, h: el.naturalHeight})); assert.deepEqual(decoded, {w: 1, h: 1}, 'the decrypted image did not decode'); // And the chat list shows a label, never a key-bearing URL. await b.evaluate(() => location.hash = ''); const preview = await b.locator('.preview').first().innerText(); assert.ok(!/aesgcm|#/.test(preview), 'the preview leaks the attachment URL: ' + preview); assert.match(preview, /Photo/); await chat(accounts[0].jid, b); return {senderRendersBlob: true, receiverDecrypted: true, uploadIsCiphertext: true, nameAndTypeAnonymised: true, previewLabelled: preview.trim()}; }); await check('MEDIA-FILE-ROUNDTRIP', async () => { const since = wire.messages.length, putCount = puts.length; await attach(page, 'quarterly.bin', 'application/octet-stream', DOC); const link = b.locator('.bubble a.file[href^="blob:"]'); const linksBefore = await link.count(); await until(async () => await link.count() > linksBefore, 30000); assertEnvelopeOnly(outgoing(since), 'file'); const put = puts.slice(putCount).at(-1); assert.ok(!put.bytes.equals(DOC), 'the plaintext file was uploaded'); assert.ok(!put.bytes.includes(Buffer.from('audit fixture')), 'plaintext is readable in the upload'); const label = await link.last().innerText(); assert.match(label, /\.bin$/); assert.ok(!/quarterly/i.test(label), 'the original name survived to the receiver'); // What the receiver saves must be the decrypted bytes. Checked through a // real download, not an in-page fetch of the blob URL: `connect-src` does // not list blob:, and the app never fetches its own blob either — it only // assigns it to src/href. const [download] = await Promise.all([b.waitForEvent('download', {timeout: 15000}), link.last().click()]); const saved = await download.path(); assert.deepEqual(fs.readFileSync(saved), DOC, 'the downloaded file is not the plaintext'); assert.match(download.suggestedFilename(), /^[0-9a-f-]{36}\.bin$/); return {receiverDecryptedFile: true, uploadIsCiphertext: true, savedLabel: label.trim(), downloadedPlaintextBytes: DOC.length}; }); await check('MEDIA-VOICE', async () => { if (engine !== 'chromium') return {skipped: 'no fake audio device driver for this engine', recorded: false}; const since = wire.messages.length, putCount = puts.length; await page.getByRole('button', {name: 'Record a voice message', exact: true}).click(); await page.getByRole('button', {name: 'Send voice message', exact: true}).waitFor(); await delay(1200); await page.getByRole('button', {name: 'Send voice message', exact: true}).click(); const audio = b.locator('.bubble audio[src^="blob:"]'); const audioBefore = await audio.count(); await until(async () => await audio.count() > audioBefore, 30000); assertEnvelopeOnly(outgoing(since), 'voice'); const put = puts.slice(putCount).at(-1); assert.equal(put.type, 'application/octet-stream'); const slot = wire.slotRequests.at(-1); assert.ok(!/voice-/.test(slot), 'the recording name reached the server'); return {recorded: true, receiverDecryptedAudio: true, note: 'Synthetic Chromium audio device; real iPhone recording and playback remain a device check.'}; }); await check('MEDIA-CANCEL', async () => { const since = wire.messages.length, putCount = puts.length; let release; putGate = new Promise(r => release = r); holdPut = true; await attach(page, 'cancelled.bin', 'application/octet-stream', Buffer.alloc(256 * 1024, 7)); const upload = page.locator('.upload').last(); await upload.waitFor(); await upload.getByRole('button', {name: 'Cancel upload', exact: true}).click(); await until(async () => await page.locator('.upload').count() === 0); holdPut = false; release(); await delay(1500); assert.equal(outgoing(since).length, 0, 'a cancelled upload still sent a message'); assert.ok(puts.length - putCount <= 1, 'a cancelled upload made extra PUTs'); return {noMessageSent: true, uploadRowCleared: true, putsMade: puts.length - putCount}; }); // Each of the next two sends a *fresh* attachment on purpose. Prosody serves // every upload with "cache-control: max-age=31556952, immutable", so an // already-fetched ciphertext comes from the browser cache and a reload never // reaches the network — correct behaviour in the app, but it would make a // re-reading check assert nothing. await check('MEDIA-GONE', async () => { // The upload expires after 30 days on this server; a 404 must read as that // and not as a decryption problem. missingGet = true; let shown; try { await attach(page, 'expired.png', 'image/png', PNG); // Matched on the element, not with an exact string: the retry button sits // inside the same span, so its label is part of the text content. const problem = b.locator('.bubble').last().locator('.media-problem'); await until(async () => /No longer available on the server\./.test(await problem.innerText().catch(() => '')), 30000); shown = (await problem.innerText()).trim(); assert.equal(await b.locator('.bubble').last().locator('img[src^="blob:"]').count(), 0, 'a missing file still rendered'); assert.ok(!/could not be decrypted/.test(shown), 'a 404 was reported as a decryption failure'); } finally { missingGet = false; } return {expiredUploadExplained: true, distinctFromIntegrityFailure: true, shown}; }); await check('MEDIA-CORRUPT', async () => { corruptGet = true; let shown; try { await attach(page, 'flipped.png', 'image/png', PNG); const latest = b.locator('.bubble').last(); const problem = latest.locator('.media-problem'); await until(async () => /could not be decrypted/.test(await problem.innerText().catch(() => '')), 30000); shown = (await problem.innerText()).trim(); assert.equal(await latest.locator('img[src^="blob:"]').count(), 0, 'a corrupted file still rendered'); assert.ok(!/No longer available/.test(shown), 'an integrity failure was reported as a missing file'); assert.ok(await latest.getByRole('button', {name: 'Try again', exact: true}).isVisible()); // The same bytes, intact, decrypt on retry: the failure was the tag, not // lost state. The corrupted response is fulfilled with no-store above, so // this really goes back to the network. corruptGet = false; await latest.getByRole('button', {name: 'Try again', exact: true}).click(); await latest.locator('img[src^="blob:"]').waitFor({timeout: 30000}); } finally { corruptGet = false; } return {integrityFailureShown: true, retryRecovers: true, shown}; }); await check('MEDIA-FOREIGN-HOST', async () => { // A federated contact's attachment sits on their own server, which the CSP // does not allow fetching. Seeded locally: no foreign account is involved. const key = 'UX-media-foreign-' + Date.now(); const before = foreignRequests.length; await page.evaluate(async ({me, peer, key}) => { const db = await new Promise(res => { const r = indexedDB.open('chat:' + me); r.onsuccess = () => res(r.result); }); const tx = db.transaction('messages', 'readwrite'); tx.objectStore('messages').put({ key, peer, type: 'chat', dir: 'in', from: peer, ts: Date.now(), id: key, encrypted: true, body: 'aesgcm://files.example.invalid/f/a.png#' + 'ab'.repeat(44), }); await new Promise(res => tx.oncomplete = res); db.close(); }, {me: accounts[0].jid, peer: accounts[1].jid, key}); try { await page.reload(); await chat(); await page.getByText(/Encrypted files can only be opened from/).first().waitFor({timeout: 30000}); await delay(1000); assert.deepEqual(foreignRequests.slice(before).filter(u => u.includes('files.example.invalid')), [], 'a request went to the foreign host'); } finally { await page.evaluate(async ({me, key}) => { const db = await new Promise(res => { const r = indexedDB.open('chat:' + me); r.onsuccess = () => res(r.result); }); const tx = db.transaction('messages', 'readwrite'); tx.objectStore('messages').delete(key); await new Promise(res => tx.oncomplete = res); db.close(); }, {me: accounts[0].jid, key}).catch(() => {}); } return {explainedWithoutFetching: true}; }); await check('MEDIA-GROUP-PLAINTEXT', async () => { // Rooms are not encrypted here, so they must keep the plaintext OOB path // exactly as before — the regression this package could most easily cause. const room = `ux-media-${engine}-${Date.now()}@muc.0ff.space`; report.groupFixture = room; await chat(room); const join = page.getByRole('button', {name: 'Join', exact: true}); if (await join.count()) await join.click(); await page.locator('textarea[placeholder="Message"]').waitFor({timeout: 30000}); // Wait for the room to actually be joined, not just for a composer: the // previous conversation's enabled textarea can still satisfy the waits // above during the switch, and attaching then makes the client refuse the // upload — correctly — with "Join the room before sending files". await until(async () => /participant/.test(await page.locator('.title .sub').innerText().catch(() => '')), 30000); const since = wire.messages.length, putCount = puts.length; await attach(page, 'group.png', 'image/png', PNG); const img = page.locator('.bubble img[src^="https://upload.0ff.space/"]'); await img.waitFor({timeout: 30000}); const stanzas = wire.messages.slice(since).filter(s => s.includes('')); assert.ok(stanzas.some(s => /jabber:x:oob/.test(s)), 'the group upload lost its oob child'); assert.ok(!stanzas.some(s => /aesgcm:/i.test(s)), 'a group upload was shared as aesgcm'); const put = puts.slice(putCount).at(-1); assert.ok(put.bytes.equals(PNG), 'the group upload was altered'); assert.equal(put.type, 'image/png', 'the group upload lost its real media type'); assert.match(wire.slotRequests.at(-1), /filename="group\.png"/); return {plaintextOobPathIntact: true, realNameAndTypeKept: true}; }); await check('MEDIA-NO-STRAY-REQUESTS', async () => { assert.deepEqual(foreignRequests, [], 'the client fetched a host outside its own origin and upload host'); const plaintextPuts = puts.filter(p => p.bytes && (p.bytes.equals(PNG) || p.bytes.equals(DOC))); // Exactly one: the group upload, which is plaintext by design. assert.equal(plaintextPuts.length, 1, 'plaintext PUTs: ' + plaintextPuts.length); assert.equal(plaintextPuts[0].type, 'image/png'); report.uploads = puts.map(p => ({type: p.type, size: p.size})); return {totalPuts: puts.length, plaintextPuts: 1, plaintextPutIsTheGroupOne: true}; }); } main() .catch(e => { report.errors.push(redact(e.message)); (report.errorDetails ??= []).push({source: 'runner', stack: redact(e.stack)}); }) .finally(async () => { if (report.groupFixture) { const xmpp = client({service: 'wss://chat.0ff.space/xmpp-websocket', domain: '0ff.space', username: accounts[0].id, password: accounts[0].password, resource: 'ux-media-cleanup-' + Date.now()}); xmpp.on('error', () => {}); try { await xmpp.start(); await xmpp.iqCaller.set(xml('query', {xmlns: 'http://jabber.org/protocol/muc#owner'}, xml('destroy')), report.groupFixture, 10000); report.groupFixtureDestroyed = true; } catch (e) { report.groupFixtureDestroyed = redact(e.condition || e.message); } finally { await xmpp.stop().catch(() => {}); } } await bctx?.close(); await ctx?.close(); save(); console.log(JSON.stringify({report: path.join(out, 'report.json'), errors: report.errors, checks: report.checks.map(c => ({id: c.id, status: c.status}))})); if (report.errors.length || report.checks.some(c => c.status === 'failed')) process.exitCode = 1; });