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