import { test } from "node:test"; import assert from "node:assert/strict"; import { createCipheriv } from "node:crypto"; import { anonymousName, buildAesgcmUrl, decryptFile, encryptFile, parseAesgcmUrl, TAG_BYTES } from "../src/crypto/aesgcm.ts"; const KEY = Uint8Array.from({ length: 32 }, (_, i) => i + 1); const IV = Uint8Array.from({ length: 12 }, (_, i) => 0xa0 + i); const KEY_HEX = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; const IV_HEX = "a0a1a2a3a4a5a6a7a8a9aaab"; const GET = "https://upload.0ff.space/file_share/abc123/4f3c.png"; test("the fragment is hex(iv) then hex(key), 88 characters, Monal's layout", () => { const url = buildAesgcmUrl(GET, { key: KEY, iv: IV }); assert.equal(url, `aesgcm://upload.0ff.space/file_share/abc123/4f3c.png#${IV_HEX}${KEY_HEX}`); const fragment = url.slice(url.indexOf("#") + 1); assert.equal(fragment.length, 88); // Monal classifies an encrypted message as a file transfer only if the // whole decrypted body is one token with no whitespace (OMEMO-PLAN.md). assert.match(url, /^aesgcm:\/\/\S+$/); assert.equal(fragment, fragment.toLowerCase()); }); test("an upload URL that is not https is refused", () => { assert.throws(() => buildAesgcmUrl("http://upload.0ff.space/x/a.png", { key: KEY, iv: IV }), /non-HTTPS/); }); test("ciphertext from an independent AES-256-GCM implementation decrypts", async () => { // node:crypto rather than our own encryptFile, so this asserts the wire // layout (ciphertext with the tag appended) and not just self-consistency. const cipher = createCipheriv("aes-256-gcm", KEY, IV); const body = Buffer.concat([cipher.update(Buffer.from("hello attachment")), cipher.final()]); const sealed = new Uint8Array(Buffer.concat([body, cipher.getAuthTag()])); assert.equal(sealed.length, "hello attachment".length + TAG_BYTES); const plain = await decryptFile(sealed, { key: KEY, iv: IV }); assert.equal(Buffer.from(plain).toString(), "hello attachment"); }); test("our own round trip, and the tag's size overhead", async () => { const plaintext = new Uint8Array(1024).fill(7); const sealed = await encryptFile(plaintext); assert.equal(sealed.ciphertext.length, plaintext.length + TAG_BYTES); assert.equal(sealed.key.length, 32); assert.equal(sealed.iv.length, 12); const back = await decryptFile(sealed.ciphertext, sealed); assert.deepEqual(back, plaintext); }); test("a flipped byte, a truncated file and a wrong key all fail closed", async () => { const sealed = await encryptFile(new Uint8Array([1, 2, 3, 4])); const flipped = Uint8Array.from(sealed.ciphertext); flipped[0] ^= 0x01; await assert.rejects(decryptFile(flipped, sealed), /could not be decrypted/); await assert.rejects(decryptFile(sealed.ciphertext.slice(0, -1), sealed), /could not be decrypted/); await assert.rejects(decryptFile(sealed.ciphertext, { key: KEY, iv: sealed.iv }), /could not be decrypted/); }); test("all three fragment layouts in the wild are accepted", () => { // Ours and Monal's: iv(12) || key(32). const ours = parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#${IV_HEX}${KEY_HEX}`); assert.deepEqual(ours?.iv, IV); assert.deepEqual(ours?.key, KEY); // The older iv(16) || key(32). const iv16 = "000102030405060708090a0b0c0d0e0f"; const wide = parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#${iv16}${KEY_HEX}`); assert.equal(wide?.iv.length, 16); assert.deepEqual(wide?.key, KEY); // Key only, with the IV Conversations hardcoded for this shape. const bare = parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#${KEY_HEX}`); assert.deepEqual(bare?.key, KEY); assert.deepEqual(bare?.iv, Uint8Array.from({ length: 16 }, (_, i) => i)); }); test("the fetch URL is https and never carries the fragment", () => { const parsed = parseAesgcmUrl(` aesgcm://upload.0ff.space/f/a.png#${IV_HEX}${KEY_HEX} `); assert.equal(parsed?.url, "https://upload.0ff.space/f/a.png"); }); test("an unusable link is rejected rather than half-parsed", () => { assert.equal(parseAesgcmUrl(`https://upload.0ff.space/f/a.png#${IV_HEX}${KEY_HEX}`), null); assert.equal(parseAesgcmUrl("aesgcm://upload.0ff.space/f/a.png"), null); assert.equal(parseAesgcmUrl("aesgcm://upload.0ff.space/f/a.png#short"), null); assert.equal(parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#zz${IV_HEX}${KEY_HEX}`), null); assert.equal(parseAesgcmUrl("not a url at all"), null); }); test("the uploaded name keeps only its extension", () => { const name = anonymousName("Holiday in Rome.JPG"); assert.match(name, /^[0-9a-f-]{36}\.jpg$/); assert.doesNotMatch(name, /Rome/i); // No extension, or something too odd to be one, leaves just the uuid. assert.match(anonymousName("receipt"), /^[0-9a-f-]{36}$/); assert.match(anonymousName("archive.tar.gz"), /^[0-9a-f-]{36}\.gz$/); assert.match(anonymousName("weird.name with spaces"), /^[0-9a-f-]{36}$/); });