src/crypt/decrypter.ts
import AESCrypto from './aes-crypto';
import FastAESKey from './fast-aes-key';
import AESDecryptor, { removePadding } from './aes-decryptor';
import { logger } from '../utils/logger';
import { appendUint8Array } from '../utils/mp4-tools';
import { sliceUint8 } from '../utils/typed-array';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
const CHUNK_SIZE = 16; // 16 bytes, 128 bits
export default class Decrypter {
private logEnabled: boolean = true;
private observer: HlsEventEmitter;
private config: HlsConfig;
private removePKCS7Padding: boolean;
private subtle: SubtleCrypto | null = null;
private softwareDecrypter: AESDecryptor | null = null;
private key: ArrayBuffer | null = null;
private fastAesKey: FastAESKey | null = null;
private remainderData: Uint8Array | null = null;
private currentIV: ArrayBuffer | null = null;
private currentResult: ArrayBuffer | null = null;
constructor(
observer: HlsEventEmitter,
config: HlsConfig,
{ removePKCS7Padding = true } = {}
) {
this.observer = observer;
this.config = config;
this.removePKCS7Padding = removePKCS7Padding;
// built in decryptor expects PKCS7 padding
if (removePKCS7Padding) {
try {
const browserCrypto = self.crypto;
if (browserCrypto) {
this.subtle =
browserCrypto.subtle ||
((browserCrypto as any).webkitSubtle as SubtleCrypto);
}
} catch (e) {
/* no-op */
}
}
if (this.subtle === null) {
this.config.enableSoftwareAES = true;
}
}
destroy() {
// @ts-ignore
this.observer = null;
}
public isSync() {
return this.config.enableSoftwareAES;
}
public flush(): Uint8Array | void {
const { currentResult } = this;
if (!currentResult) {
this.reset();
return;
}
const data = new Uint8Array(currentResult);
this.reset();
if (this.removePKCS7Padding) {
return removePadding(data);
}
return data;
}
public reset() {
this.currentResult = null;
this.currentIV = null;
this.remainderData = null;
if (this.softwareDecrypter) {
this.softwareDecrypter = null;
}
}
public decrypt(
data: Uint8Array | ArrayBuffer,
key: ArrayBuffer,
iv: ArrayBuffer,
callback: (decryptedData: ArrayBuffer) => void
) {
if (this.config.enableSoftwareAES) {
this.softwareDecrypt(new Uint8Array(data), key, iv);
const decryptResult = this.flush();
if (decryptResult) {
callback(decryptResult.buffer);
}
} else {
this.webCryptoDecrypt(new Uint8Array(data), key, iv).then(callback);
}
}
public softwareDecrypt(
data: Uint8Array,
key: ArrayBuffer,
iv: ArrayBuffer
): ArrayBuffer | null {
const { currentIV, currentResult, remainderData } = this;
this.logOnce('JS AES decrypt');
// The output is staggered during progressive parsing - the current result is cached, and emitted on the next call
// This is done in order to strip PKCS7 padding, which is found at the end of each segment. We only know we've reached
// the end on flush(), but by that time we have already received all bytes for the segment.
// Progressive decryption does not work with WebCrypto
if (remainderData) {
data = appendUint8Array(remainderData, data);
this.remainderData = null;
}
// Byte length must be a multiple of 16 (AES-128 = 128 bit blocks = 16 bytes)
const currentChunk = this.getValidChunk(data);
if (!currentChunk.length) {
return null;
}
if (currentIV) {
iv = currentIV;
}
let softwareDecrypter = this.softwareDecrypter;
if (!softwareDecrypter) {
softwareDecrypter = this.softwareDecrypter = new AESDecryptor();
}
softwareDecrypter.expandKey(key);
const result = currentResult;
this.currentResult = softwareDecrypter.decrypt(currentChunk.buffer, 0, iv);
this.currentIV = sliceUint8(currentChunk, -16).buffer;
if (!result) {
return null;
}
return result;
}
public webCryptoDecrypt(
data: Uint8Array,
key: ArrayBuffer,
iv: ArrayBuffer
): Promise<ArrayBuffer> {
const subtle = this.subtle;
if (this.key !== key || !this.fastAesKey) {
this.key = key;
this.fastAesKey = new FastAESKey(subtle, key);
}
return this.fastAesKey
.expandKey()
.then((aesKey) => {
// decrypt using web crypto
if (!subtle) {
return Promise.reject(new Error('web crypto not initialized'));
}
this.logOnce('WebCrypto AES decrypt');
const crypto = new AESCrypto(subtle, new Uint8Array(iv));
return crypto.decrypt(data.buffer, aesKey);
})
.catch((err) => {
return this.onWebCryptoError(err, data, key, iv) as ArrayBuffer;
});
}
private onWebCryptoError(err, data, key, iv): ArrayBuffer | null {
logger.warn('[decrypter.ts]: WebCrypto Error, disable WebCrypto API:', err);
this.config.enableSoftwareAES = true;
this.logEnabled = true;
return this.softwareDecrypt(data, key, iv);
}
private getValidChunk(data: Uint8Array): Uint8Array {
let currentChunk = data;
const splitPoint = data.length - (data.length % CHUNK_SIZE);
if (splitPoint !== data.length) {
currentChunk = sliceUint8(data, 0, splitPoint);
this.remainderData = sliceUint8(data, splitPoint);
}
return currentChunk;
}
private logOnce(msg: string) {
if (!this.logEnabled) {
return;
}
logger.log(`[decrypter.ts]: ${msg}`);
this.logEnabled = false;
}
}