atagen

malice

public
Star0
Watch0
4f91f89d99fa00315aff796b1ce189c0d9d4fc4e
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/media-decrypt.tsTypescript87 lines3.3 KB
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
}