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