mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
256 lines (255 loc) • 10.9 kB
JavaScript
/*!
* Copyright (c) 2026-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { assert } from './misc.js';
import { readBytes } from './reader.js';
// Inspired in part by https://github.com/halloweeks/AES-128-CBC/blob/main/AES_128_CBC.h
export const AES_128_BLOCK_SIZE = 16;
const Te4 = new Uint32Array(256);
const Td0 = new Uint32Array(256);
const Td1 = new Uint32Array(256);
const Td2 = new Uint32Array(256);
const Td3 = new Uint32Array(256);
const Td4 = new Uint32Array(256);
const rcon = new Uint32Array(10);
let tablesGenerated = false;
// Generating the tables once is much more bundle size-efficient than shipping them in the bundle (entropy ftw)
const generateAesTables = () => {
const sbox = new Uint8Array(256);
const log = new Uint8Array(256);
const pow = new Uint8Array(256);
// 1. Generate GF(2^8) log/exp tables
// Primitive polynomial: x^8 + x^4 + x^3 + x + 1 (0x11B)
for (let i = 0, p = 1; i < 256; i++) {
pow[i] = p;
log[p] = i;
p = p ^ (p << 1) ^ (p & 0x80 ? 0x11B : 0);
}
// Helper: GF(2^8) multiplication
const mul = (a, b) => (a && b) ? pow[(log[a] + log[b]) % 255] : 0;
// 2. Generate S-Box and Inverse S-Box
sbox[0] = 0x63; // Special case for 0
// Loop for inverse (using log/exp) and Affine Transform
for (let i = 1; i < 256; i++) {
const x = pow[255 - log[i]]; // Multiplicative inverse
let s = x ^ (x << 1) ^ (x << 2) ^ (x << 3) ^ (x << 4);
s = (s >>> 8) ^ (s & 0xFF) ^ 0x63; // Affine transform
sbox[i] = s;
}
// 3. Fill Tables
for (let i = 0; i < 256; i++) {
const s = sbox[i]; // Forward S-Box value
const is = sbox.indexOf(i); // Inverse S-Box value
// Te4: Forward S-Box packed
Te4[i] = (s << 24) | (s << 16) | (s << 8) | s;
// Td4: Inverse S-Box packed
Td4[i] = (is << 24) | (is << 16) | (is << 8) | is;
// Td0-Td3: Inverse MixColumns applied to Inverse S-Box
// Coefficients: 0x0E, 0x09, 0x0D, 0x0B (Order specific to Td0 structure)
const b0 = mul(is, 0x0E);
const b1 = mul(is, 0x09);
const b2 = mul(is, 0x0D);
const b3 = mul(is, 0x0B);
const w = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
Td0[i] = w;
Td1[i] = (w >>> 8) | (w << 24); // Rotate right 8
Td2[i] = (w >>> 16) | (w << 16); // Rotate right 16
Td3[i] = (w >>> 24) | (w << 8); // Rotate right 24
}
// 4. Generate Rcon
let r = 1;
for (let i = 0; i < 10; i++) {
rcon[i] = r << 24;
r = (r << 1) ^ (r & 0x80 ? 0x11B : 0);
}
tablesGenerated = true;
};
/** A context for doing AES-128-CBC operations. Better than the Web Crypto API since we can stream it. */
export class Aes128CbcContext {
constructor() {
this.roundkey = new Uint32Array(44);
this.iv = new Uint32Array(AES_128_BLOCK_SIZE / Uint32Array.BYTES_PER_ELEMENT);
this.in = new Uint8Array(AES_128_BLOCK_SIZE);
this.out = new Uint8Array(AES_128_BLOCK_SIZE);
this.inView = new DataView(this.in.buffer);
this.outView = new DataView(this.out.buffer);
}
init({ key, iv }) {
assert(key.byteLength === 16);
assert(iv.byteLength === 16);
if (!tablesGenerated) {
generateAesTables();
}
const keyView = new DataView(key.buffer, key.byteOffset, key.byteLength);
const ivView = new DataView(iv.buffer, iv.byteOffset, iv.byteLength);
this.roundkey[0] = keyView.getUint32(0, false);
this.roundkey[1] = keyView.getUint32(4, false);
this.roundkey[2] = keyView.getUint32(8, false);
this.roundkey[3] = keyView.getUint32(12, false);
this.iv[0] = ivView.getUint32(0, false);
this.iv[1] = ivView.getUint32(4, false);
this.iv[2] = ivView.getUint32(8, false);
this.iv[3] = ivView.getUint32(12, false);
for (let index = 4; index < 44; index += 4) {
const temp = this.roundkey[index - 1];
this.roundkey[index] = this.roundkey[index - 4]
^ (Te4[(temp >>> 16) & 0xff] & 0xff000000)
^ (Te4[(temp >>> 8) & 0xff] & 0x00ff0000)
^ (Te4[(temp >>> 0) & 0xff] & 0x0000ff00)
^ (Te4[(temp >>> 24) & 0xff] & 0x000000ff)
^ rcon[(index / 4) - 1];
this.roundkey[index + 1] = this.roundkey[index - 3] ^ this.roundkey[index];
this.roundkey[index + 2] = this.roundkey[index - 2] ^ this.roundkey[index + 1];
this.roundkey[index + 3] = this.roundkey[index - 1] ^ this.roundkey[index + 2];
}
// Invert the order of the round keys
for (let i = 0, j = 40; i < j; i += 4, j -= 4) {
for (let k = 0; k < 4; k++) {
const temp = this.roundkey[i + k];
this.roundkey[i + k] = this.roundkey[j + k];
this.roundkey[j + k] = temp;
}
}
// Apply Inverse MixColumn transform to all round keys except first and last
for (let index = 4; index < 40; index += 4) {
for (let k = 0; k < 4; k++) {
const rk = this.roundkey[index + k];
this.roundkey[index + k]
= Td0[Te4[(rk >>> 24) & 0xff] & 0xff]
^ Td1[Te4[(rk >>> 16) & 0xff] & 0xff]
^ Td2[Te4[(rk >>> 8) & 0xff] & 0xff]
^ Td3[Te4[(rk >>> 0) & 0xff] & 0xff];
}
}
}
decrypt() {
let s0 = this.inView.getUint32(0, false) ^ this.roundkey[0];
let s1 = this.inView.getUint32(4, false) ^ this.roundkey[1];
let s2 = this.inView.getUint32(8, false) ^ this.roundkey[2];
let s3 = this.inView.getUint32(12, false) ^ this.roundkey[3];
// Store input for CBC XOR later
const temp0 = this.inView.getUint32(0, false);
const temp1 = this.inView.getUint32(4, false);
const temp2 = this.inView.getUint32(8, false);
const temp3 = this.inView.getUint32(12, false);
let t0, t1, t2, t3;
// Rounds 1-9
for (let round = 1; round < 10; round++) {
const offset = round * 4;
t0 = Td0[s0 >>> 24]
^ Td1[(s3 >>> 16) & 0xff]
^ Td2[(s2 >>> 8) & 0xff]
^ Td3[s1 & 0xff]
^ this.roundkey[offset];
t1 = Td0[s1 >>> 24]
^ Td1[(s0 >>> 16) & 0xff]
^ Td2[(s3 >>> 8) & 0xff]
^ Td3[s2 & 0xff]
^ this.roundkey[offset + 1];
t2 = Td0[s2 >>> 24]
^ Td1[(s1 >>> 16) & 0xff]
^ Td2[(s0 >>> 8) & 0xff]
^ Td3[s3 & 0xff]
^ this.roundkey[offset + 2];
t3 = Td0[s3 >>> 24]
^ Td1[(s2 >>> 16) & 0xff]
^ Td2[(s1 >>> 8) & 0xff]
^ Td3[s0 & 0xff]
^ this.roundkey[offset + 3];
s0 = t0;
s1 = t1;
s2 = t2;
s3 = t3;
}
// Final Round (10)
const f0 = (Td4[(s0 >>> 24) & 0xff] & 0xff000000)
^ (Td4[(s3 >>> 16) & 0xff] & 0x00ff0000)
^ (Td4[(s2 >>> 8) & 0xff] & 0x0000ff00)
^ (Td4[(s1 >>> 0) & 0xff] & 0x000000ff)
^ this.roundkey[40];
const f1 = (Td4[(s1 >>> 24) & 0xff] & 0xff000000)
^ (Td4[(s0 >>> 16) & 0xff] & 0x00ff0000)
^ (Td4[(s3 >>> 8) & 0xff] & 0x0000ff00)
^ (Td4[(s2 >>> 0) & 0xff] & 0x000000ff)
^ this.roundkey[41];
const f2 = (Td4[(s2 >>> 24) & 0xff] & 0xff000000)
^ (Td4[(s1 >>> 16) & 0xff] & 0x00ff0000)
^ (Td4[(s0 >>> 8) & 0xff] & 0x0000ff00)
^ (Td4[(s3 >>> 0) & 0xff] & 0x000000ff)
^ this.roundkey[42];
const f3 = (Td4[(s3 >>> 24) & 0xff] & 0xff000000)
^ (Td4[(s2 >>> 16) & 0xff] & 0x00ff0000)
^ (Td4[(s1 >>> 8) & 0xff] & 0x0000ff00)
^ (Td4[(s0 >>> 0) & 0xff] & 0x000000ff)
^ this.roundkey[43];
// CBC XOR and output
this.outView.setUint32(0, f0 ^ this.iv[0], false);
this.outView.setUint32(4, f1 ^ this.iv[1], false);
this.outView.setUint32(8, f2 ^ this.iv[2], false);
this.outView.setUint32(12, f3 ^ this.iv[3], false);
// Update IV for next block
this.iv[0] = temp0;
this.iv[1] = temp1;
this.iv[2] = temp2;
this.iv[3] = temp3;
}
}
export const createAes128CbcDecryptStream = (reader, getInit, close) => {
let initted = false;
let pos = 0;
const CHUNK_SIZE = 2 ** 16;
const BLOCK_SIZE = 16;
const aesContext = new Aes128CbcContext();
return new ReadableStream({
pull: async (controller) => {
if (!initted) {
aesContext.init(await getInit());
initted = true;
}
const requestedLength = CHUNK_SIZE + BLOCK_SIZE;
let nextSlice = reader.requestSliceRange(pos, 0, requestedLength);
if (nextSlice instanceof Promise)
nextSlice = await nextSlice;
if (!nextSlice || nextSlice.length === 0) {
// Due to padding, this should never happen
throw new Error('Invalid ciphertext.');
}
const sliceLength = nextSlice.length;
if (sliceLength % 16 !== 0) {
throw new Error('Invalid ciphertext.');
}
const bytesToRead = sliceLength === requestedLength
? sliceLength - BLOCK_SIZE // Don't read the last block
: sliceLength;
const input = readBytes(nextSlice, bytesToRead);
const output = new Uint8Array(bytesToRead);
for (let i = 0; i < bytesToRead; i += 16) {
aesContext.in.set(input.subarray(i, i + 16));
aesContext.decrypt();
output.set(aesContext.out, i);
}
if (bytesToRead < sliceLength) {
controller.enqueue(output);
pos += bytesToRead;
}
else {
// This is the last chunk
const paddingLength = output[bytesToRead - 1];
if (paddingLength === 0 || paddingLength > 16) {
throw new Error('Invalid PKCS#7 padding. Incorrect key or corrupted data.');
}
const trimmedOutput = output.subarray(0, bytesToRead - paddingLength); // PKCS#7 padding
controller.enqueue(trimmedOutput);
controller.close();
close();
}
},
cancel: () => {
close();
},
});
};