Package 9's code half, built against OMEMO-PLAN.md's format decision. An image, file or voice message to an OMEMO-capable contact is encrypted with AES-256-GCM before the XEP-0363 upload and shared as an aesgcm:// URL whose fragment carries iv||key, sent through the existing encrypted path with no oob child at all — the composer no longer refuses it. New src/crypto/aesgcm.ts on plain WebCrypto (no libsignal, so an attachment never depends on omemoSupported): encrypt, decrypt, build and parse, plus the anonymised <uuid>.<ext> upload name Monal also uses. Receive accepts all three fragment layouts in the wild. mediaCapability now reports encrypt instead of a limitation, isAttachment became attachmentUrl(body, oob, encrypted) so the chat-list preview and notification label show "Photo" rather than a URL containing a key, and Chat.svelte fetches, decrypts and renders through one object URL per message with a distinct reason per failure and a retry. Two defects found while building, both written up in OMEMO-PLAN.md: - url.protocol = "aesgcm:" silently does nothing — WHATWG URL refuses a non-special scheme over a special one — so build and parse both returned https URLs. The fixed-vector test caught it. Swapped textually now, as Monal and Conversations do. - A key leak in the design's own send path: send() encrypts only while the peer is currently capable, so an emptied device list between upload and send would have put the aesgcm body, which is the file key, on the wire in clear. The authorize callback now rejects a changed encryption decision rather than only a withdrawn one, and send() refuses an aesgcm body without an envelope outright, and in a room at all. npm run check clean, 86 tests pass (9 new, including a vector decrypted from an independent node:crypto ciphertext), npm run build succeeds. Not run: live browser, real Monal round trip, iPhone recording. Not deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48 lines
3 KiB
TypeScript
48 lines
3 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { xml } from '@xmpp/client';
|
|
import { checkSize, upload } from '../src/xmpp/upload.ts';
|
|
import { TAG_BYTES } from '../src/crypto/aesgcm.ts';
|
|
|
|
test('media rejection happens before any slot or PUT; revocation between slot and PUT also fails closed', async (t) => {
|
|
let slots = 0, puts = 0;
|
|
const xmpp: any = {iqCaller:{request:async () => {slots++; return xml('iq', null, xml('slot', {xmlns:'urn:xmpp:http:upload:0'}, xml('put', {url:'https://upload.example/put'}), xml('get', {url:'https://upload.example/get'})));}}};
|
|
class XHR {
|
|
status = 201; upload = {}; onload?: () => void; onloadend?: () => void; onabort?: () => void;
|
|
open() {} setRequestHeader() {} send() {puts++; this.onload?.(); this.onloadend?.();} abort() {this.onabort?.();this.onloadend?.();}
|
|
}
|
|
Object.defineProperty(globalThis, 'XMLHttpRequest', {value:XHR, configurable:true});
|
|
t.after(() => { delete (globalThis as any).XMLHttpRequest; });
|
|
const file = new Blob(['test'], {type:'text/plain'}), service = {jid:'upload.example'};
|
|
for (const reason of ['Encrypted media unsupported', 'Unknown capability', 'Offline']) {
|
|
await assert.rejects(upload(xmpp,service,file,'test',undefined,undefined,async () => {throw Error(reason);}), new RegExp(reason));
|
|
}
|
|
assert.equal(slots,0); assert.equal(puts,0);
|
|
let checks = 0;
|
|
await assert.rejects(upload(xmpp,service,file,'test',undefined,undefined,async () => {if (++checks === 2) throw Error('Capability changed');}), /Capability changed/);
|
|
assert.equal(slots,1); assert.equal(puts,0);
|
|
assert.equal(await upload(xmpp,service,file,'test',undefined,undefined,async () => {}), 'https://upload.example/get');
|
|
assert.equal(slots,2); assert.equal(puts,1);
|
|
const abort = new AbortController(); abort.abort();
|
|
await assert.rejects(upload(xmpp,service,file,'test',undefined,abort.signal,async () => {}), /cancelled/);
|
|
assert.equal(slots,2); assert.equal(puts,1);
|
|
const mid = new AbortController();
|
|
await assert.rejects(upload(xmpp,service,file,'test',undefined,mid.signal,async () => {mid.abort();}), /cancelled/);
|
|
assert.equal(slots,2); assert.equal(puts,1);
|
|
});
|
|
|
|
test("the server limit is checked before anything is uploaded, tag included", () => {
|
|
const service = {jid:'upload.example', maxSize:10485760};
|
|
// Prosody's own 10 MiB limit (blunix /etc/prosody/prosody.cfg.lua). A file
|
|
// within 16 bytes of it only fails the PUT unless the GCM tag is counted.
|
|
assert.doesNotThrow(() => checkSize(10485760, service));
|
|
assert.throws(() => checkSize(10485761, service), /File too large \(limit 10 MB\)/);
|
|
// A plaintext file that fits on its own but not once the tag is added:
|
|
// the encrypted path passes file.size + TAG_BYTES, so it is refused here
|
|
// instead of by the PUT.
|
|
const snug = 10485760 - TAG_BYTES + 1;
|
|
assert.doesNotThrow(() => checkSize(snug, service));
|
|
assert.throws(() => checkSize(snug + TAG_BYTES, service), /File too large/);
|
|
// No advertised limit: nothing to check against.
|
|
assert.doesNotThrow(() => checkSize(Number.MAX_SAFE_INTEGER, {jid:'upload.example'}));
|
|
});
|