973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 1 | import assert from 'node:assert/strict'; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 2 | import { test } from 'node:test'; |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 3 | import { addBlockOffset, importAttachmentKey, RangeDecryptor } from '../src/lib/media-decrypt.ts'; |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 4 | import { attachmentContentType, imageContentType } from '../src/lib/media-sniff.ts'; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 5 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 6 | function randomBytes(length: number): Uint8Array<ArrayBuffer> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 7 | const bytes = new Uint8Array(length); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 8 | crypto.getRandomValues(bytes); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 9 | return bytes; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 10 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 11 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 12 | function textBytes(text: string): Uint8Array<ArrayBuffer> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 13 | return new TextEncoder().encode(text); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 14 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 15 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 16 | function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 17 | const total = parts.reduce((sum, part) => sum + part.length, 0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 18 | const out = new Uint8Array(total); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 19 | let offset = 0; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 20 | for (const part of parts) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 21 | out.set(part, offset); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 22 | offset += part.length; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 23 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 24 | return out; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 25 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 26 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 27 | function base64url(bytes: Uint8Array): string { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 28 | let binary = ''; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 29 | for (const byte of bytes) binary += String.fromCharCode(byte); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 30 | return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 31 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 32 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 33 | async function encryptWhole( |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 34 | rawKey: Uint8Array<ArrayBuffer>, |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 35 | iv: Uint8Array, |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 36 | plaintext: Uint8Array<ArrayBuffer> |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 37 | ): Promise<Uint8Array<ArrayBuffer>> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 38 | const encryptKey = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-CTR' }, false, ['encrypt']); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 39 | const counter = addBlockOffset(iv, 0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 40 | const cipher = await crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, encryptKey, plaintext); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 41 | return new Uint8Array(cipher); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 42 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 43 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 44 | async function decryptRange( |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 45 | key: CryptoKey, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 46 | iv: Uint8Array, |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 47 | ciphertext: Uint8Array<ArrayBuffer>, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 48 | start: number, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 49 | end: number, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 50 | chunkSize: number |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 51 | ): Promise<Uint8Array> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 52 | const alignedStart = start - (start % 16); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 53 | const slice = ciphertext.subarray(alignedStart, end + 1); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 54 | const decryptor = new RangeDecryptor(key, iv, alignedStart / 16); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 55 | const parts: Uint8Array[] = []; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 56 | for (let offset = 0; offset < slice.length; offset += chunkSize) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 57 | const chunk = slice.subarray(offset, Math.min(offset + chunkSize, slice.length)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 58 | const plain = await decryptor.push(chunk); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 59 | if (plain.length > 0) parts.push(plain); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 60 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 61 | const last = await decryptor.finish(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 62 | if (last.length > 0) parts.push(last); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 63 | return concatBytes(parts).subarray(start - alignedStart); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 64 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 65 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 66 | async function setup(plaintextLength: number) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 67 | const rawKey = randomBytes(32); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 68 | const iv = randomBytes(16); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 69 | const key = await importAttachmentKey(base64url(rawKey)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 70 | const plaintext = randomBytes(plaintextLength); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 71 | const ciphertext = await encryptWhole(rawKey, iv, plaintext); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 72 | return { key, iv, plaintext, ciphertext }; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 73 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 74 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 75 | test('range decryptor: whole read matches the plaintext', async () => { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 76 | const { key, iv, plaintext, ciphertext } = await setup(1000); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 77 | const decryptor = new RangeDecryptor(key, iv, 0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 78 | const decrypted = concatBytes([await decryptor.push(ciphertext), await decryptor.finish()]); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 79 | assert.deepEqual(decrypted, plaintext); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 80 | }); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 81 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 82 | test('range decryptor: unaligned start and end', async () => { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 83 | const { key, iv, plaintext, ciphertext } = await setup(1000); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 84 | const start = 37; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 85 | const end = 601; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 86 | const got = await decryptRange(key, iv, ciphertext, start, end, 512); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 87 | assert.deepEqual(got, plaintext.subarray(start, end + 1)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 88 | }); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 89 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 90 | test('range decryptor: chunk sizes that split blocks', async () => { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 91 | const { key, iv, plaintext, ciphertext } = await setup(1000); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 92 | const start = 100; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 93 | const end = 900; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 94 | const got = await decryptRange(key, iv, ciphertext, start, end, 7); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 95 | assert.deepEqual(got, plaintext.subarray(start, end + 1)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 96 | }); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 97 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 98 | test('range decryptor: range starting past the first block', async () => { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 99 | const { key, iv, plaintext, ciphertext } = await setup(2000); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 100 | const start = 1040; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 101 | const end = 1500; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 102 | const got = await decryptRange(key, iv, ciphertext, start, end, 33); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 103 | assert.deepEqual(got, plaintext.subarray(start, end + 1)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 104 | }); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 105 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 106 | test('sniff: recognises a few magic numbers', () => { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 107 | assert.equal(imageContentType(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'image/png'); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 108 | assert.equal(imageContentType(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0])), 'image/jpeg'); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 109 | assert.equal(attachmentContentType(textBytes('OggS')), 'audio/ogg'); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 110 | assert.equal(attachmentContentType(Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3])), 'video/webm'); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 111 | const mp4 = concatBytes([new Uint8Array(4), textBytes('ftyp'), textBytes('isom')]); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 112 | assert.equal(attachmentContentType(mp4), 'video/mp4'); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 113 | assert.equal(attachmentContentType(textBytes('not a media file')), null); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 114 | }); |