973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 1 | // AES-256-CTR range decryption for Matrix attachments (the only cipher Matrix |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 2 | // has, per vendor/macltok's AttachmentEncryption). WebCrypto's AES-CTR handles |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 3 | // a partial final block within one call but not a resumed one, so a block that |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 4 | // isn't a multiple of 16 bytes has to wait for more ciphertext before it can be |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 5 | // decrypted; see RangeDecryptor.push. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 6 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 7 | const BLOCK_SIZE = 16; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 8 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 9 | function decodeBase64(value: string): Uint8Array<ArrayBuffer> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 10 | const normalised = value.replace(/-/g, '+').replace(/_/g, '/'); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 11 | const padded = normalised + '='.repeat((4 - (normalised.length % 4)) % 4); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 12 | const binary = atob(padded); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 13 | const bytes = new Uint8Array(binary.length); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 14 | for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 15 | return bytes; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 16 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 17 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 18 | export async function importAttachmentKey(k: string): Promise<CryptoKey> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 19 | return crypto.subtle.importKey('raw', decodeBase64(k), { name: 'AES-CTR' }, false, ['decrypt']); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 20 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 21 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 22 | export function decodeAttachmentIv(iv: string): Uint8Array<ArrayBuffer> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 23 | const bytes = decodeBase64(iv); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 24 | if (bytes.length >= BLOCK_SIZE) return bytes.subarray(0, BLOCK_SIZE); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 25 | const padded = new Uint8Array(BLOCK_SIZE); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 26 | padded.set(bytes); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 27 | return padded; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 28 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 29 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 30 | /** iv + blocks, added as a big-endian 64-bit value into the low 8 bytes only, per the Matrix attachment convention. */ |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 31 | export function addBlockOffset(iv: Uint8Array, blocks: number): Uint8Array<ArrayBuffer> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 32 | const counter = iv.slice(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 33 | const view = new DataView(counter.buffer, counter.byteOffset, counter.byteLength); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 34 | const low = view.getBigUint64(8); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 35 | view.setBigUint64(8, (low + BigInt(blocks)) & 0xffffffffffffffffn); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 36 | return counter; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 37 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 38 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 39 | function concat(a: Uint8Array<ArrayBuffer>, b: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 40 | if (a.length === 0) return b; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 41 | if (b.length === 0) return a; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 42 | const out = new Uint8Array(a.length + b.length); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 43 | out.set(a, 0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 44 | out.set(b, a.length); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 45 | return out; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 46 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 47 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 48 | /** |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 49 | * Decrypts a ciphertext range that starts `startBlock` 16-byte blocks into the |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 50 | * attachment. Feed it ciphertext in arrival order via `push`, then `finish` once |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 51 | * the source is exhausted to flush the trailing partial block. |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 52 | */ |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 53 | export class RangeDecryptor { |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 54 | private buffered: Uint8Array<ArrayBuffer> = new Uint8Array(0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 55 | private blocksConsumed = 0; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 56 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 57 | constructor( |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 58 | private readonly key: CryptoKey, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 59 | private readonly iv: Uint8Array, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 60 | private readonly startBlock: number |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 61 | ) {} |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 62 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 63 | async push(chunk: Uint8Array<ArrayBuffer>): Promise<Uint8Array> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 64 | const data = concat(this.buffered, chunk); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 65 | const wholeLen = data.length - (data.length % BLOCK_SIZE); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 66 | if (wholeLen === 0) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 67 | this.buffered = data; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 68 | return new Uint8Array(0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 69 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 70 | this.buffered = data.subarray(wholeLen); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 71 | return this.decrypt(data.subarray(0, wholeLen)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 72 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 73 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 74 | async finish(): Promise<Uint8Array> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 75 | if (this.buffered.length === 0) return new Uint8Array(0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 76 | const plain = await this.decrypt(this.buffered); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 77 | this.buffered = new Uint8Array(0); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 78 | return plain; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 79 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 80 | |
dffd63b58af4 | atagen | 2026-09-28 09:10 UTC | 81 | private async decrypt(ciphertext: Uint8Array<ArrayBuffer>): Promise<Uint8Array> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 82 | const counter = addBlockOffset(this.iv, this.startBlock + this.blocksConsumed); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 83 | this.blocksConsumed += Math.ceil(ciphertext.length / BLOCK_SIZE); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 84 | const plain = await crypto.subtle.decrypt({ name: 'AES-CTR', counter, length: 64 }, this.key, ciphertext); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 85 | return new Uint8Array(plain); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 86 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 87 | } |