Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { ErrorDetails, ErrorTypes } from '../errors';
import { ChunkMetadata } from '../types/transmuxer';
import { appendUint8Array } from '../utils/mp4-tools';
import { alignStream } from '../utils/discontinuities';
import {
isFullSegmentEncryption,
getAesModeFromFullSegmentMethod,
} from '../utils/encryption-methods-util';
import {
findFragmentByPDT,
findFragmentByPTS,
Expand Down Expand Up @@ -481,7 +485,7 @@ export default class BaseStreamController
payload.byteLength > 0 &&
decryptData?.key &&
decryptData.iv &&
decryptData.method === 'AES-128'
isFullSegmentEncryption(decryptData.method)
) {
const startTime = self.performance.now();
// decrypt init segment data
Expand All @@ -490,6 +494,7 @@ export default class BaseStreamController
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
)
.catch((err) => {
hls.trigger(Events.ERROR, {
Expand Down
7 changes: 6 additions & 1 deletion src/controller/subtitle-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { PlaylistLevelType } from '../types/loader';
import { Level } from '../types/level';
import { subtitleOptionsIdentical } from '../utils/media-option-attributes';
import { ErrorDetails, ErrorTypes } from '../errors';
import {
isFullSegmentEncryption,
getAesModeFromFullSegmentMethod,
} from '../utils/encryption-methods-util';
import type { NetworkComponentAPI } from '../types/component-api';
import type Hls from '../hls';
import type { FragmentTracker } from './fragment-tracker';
Expand Down Expand Up @@ -360,7 +364,7 @@ export class SubtitleStreamController
payload.byteLength > 0 &&
decryptData?.key &&
decryptData.iv &&
decryptData.method === 'AES-128'
isFullSegmentEncryption(decryptData.method)
) {
const startTime = performance.now();
// decrypt the subtitles
Expand All @@ -369,6 +373,7 @@ export class SubtitleStreamController
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
)
.catch((err) => {
hls.trigger(Events.ERROR, {
Expand Down
23 changes: 21 additions & 2 deletions src/crypt/aes-crypto.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import { DecrypterAesMode } from './decrypter-aes-mode';

export default class AESCrypto {
private subtle: SubtleCrypto;
private aesIV: Uint8Array;
private aesMode: DecrypterAesMode;

constructor(subtle: SubtleCrypto, iv: Uint8Array) {
constructor(subtle: SubtleCrypto, iv: Uint8Array, aesMode: DecrypterAesMode) {
this.subtle = subtle;
this.aesIV = iv;
this.aesMode = aesMode;
}

decrypt(data: ArrayBuffer, key: CryptoKey) {
return this.subtle.decrypt({ name: 'AES-CBC', iv: this.aesIV }, key, data);
switch (this.aesMode) {
case DecrypterAesMode.cbc:
return this.subtle.decrypt(
{ name: 'AES-CBC', iv: this.aesIV },
key,
data,
);
case DecrypterAesMode.ctr:
return this.subtle.decrypt(
{ name: 'AES-CTR', counter: this.aesIV, length: 64 }, //64 : NIST SP800-38A standard suggests that the counter should occupy half of the counter block
key,
data,
);
default:
throw new Error(`[AESCrypto] invalid aes mode ${this.aesMode}`);
}
}
}
4 changes: 4 additions & 0 deletions src/crypt/decrypter-aes-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const enum DecrypterAesMode {
cbc = 0,
ctr = 1,
}
24 changes: 16 additions & 8 deletions src/crypt/decrypter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import AESDecryptor, { removePadding } from './aes-decryptor';
import { logger } from '../utils/logger';
import { appendUint8Array } from '../utils/mp4-tools';
import { sliceUint8 } from '../utils/typed-array';
import { DecrypterAesMode } from './decrypter-aes-mode';
import type { HlsConfig } from '../config';

const CHUNK_SIZE = 16; // 16 bytes, 128 bits
Expand Down Expand Up @@ -81,10 +82,11 @@ export default class Decrypter {
data: Uint8Array | ArrayBuffer,
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
): Promise<ArrayBuffer> {
if (this.useSoftware) {
return new Promise((resolve, reject) => {
this.softwareDecrypt(new Uint8Array(data), key, iv);
this.softwareDecrypt(new Uint8Array(data), key, iv, aesMode);
const decryptResult = this.flush();
if (decryptResult) {
resolve(decryptResult.buffer);
Expand All @@ -93,7 +95,7 @@ export default class Decrypter {
}
});
}
return this.webCryptoDecrypt(new Uint8Array(data), key, iv);
return this.webCryptoDecrypt(new Uint8Array(data), key, iv, aesMode);
}

// Software decryption is progressive. Progressive decryption may not return a result on each call. Any cached
Expand All @@ -102,8 +104,13 @@ export default class Decrypter {
data: Uint8Array,
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
): ArrayBuffer | null {
const { currentIV, currentResult, remainderData } = this;
if (aesMode !== DecrypterAesMode.cbc || key.byteLength !== 16) {
logger.warn('SoftwareDecrypt: can only handle AES-128-CBC');
return null;
}
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
Expand Down Expand Up @@ -146,38 +153,39 @@ export default class Decrypter {
data: Uint8Array,
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
): Promise<ArrayBuffer> {
const subtle = this.subtle;
if (this.key !== key || !this.fastAesKey) {
this.key = key;
this.fastAesKey = new FastAESKey(subtle, key);
this.fastAesKey = new FastAESKey(subtle, key, aesMode);
}
return this.fastAesKey
.expandKey()
.then((aesKey) => {
.then((aesKey: CryptoKey) => {
// 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));
const crypto = new AESCrypto(subtle, new Uint8Array(iv), aesMode);
return crypto.decrypt(data.buffer, aesKey);
})
.catch((err) => {
logger.warn(
`[decrypter]: WebCrypto Error, disable WebCrypto API, ${err.name}: ${err.message}`,
);

return this.onWebCryptoError(data, key, iv);
return this.onWebCryptoError(data, key, iv, aesMode);
});
}

private onWebCryptoError(data, key, iv): ArrayBuffer | never {
private onWebCryptoError(data, key, iv, aesMode): ArrayBuffer | never {
const enableSoftwareAES = this.enableSoftwareAES;
if (enableSoftwareAES) {
this.useSoftware = true;
this.logEnabled = true;
this.softwareDecrypt(data, key, iv);
this.softwareDecrypt(data, key, iv, aesMode);
const decryptResult = this.flush();
if (decryptResult) {
return decryptResult.buffer;
Expand Down
29 changes: 24 additions & 5 deletions src/crypt/fast-aes-key.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { DecrypterAesMode } from './decrypter-aes-mode';

export default class FastAESKey {
private subtle: any;
private key: ArrayBuffer;
private aesMode: DecrypterAesMode;

constructor(subtle, key) {
constructor(subtle, key, aesMode: DecrypterAesMode) {
this.subtle = subtle;
this.key = key;
this.aesMode = aesMode;
}

expandKey() {
return this.subtle.importKey('raw', this.key, { name: 'AES-CBC' }, false, [
'encrypt',
'decrypt',
]);
const subtleAlgoName = getSubtleAlgoName(this.aesMode);
return this.subtle.importKey(
'raw',
this.key,
{ name: subtleAlgoName },
false,
['encrypt', 'decrypt'],
);
}
}

function getSubtleAlgoName(aesMode: DecrypterAesMode) {
switch (aesMode) {
case DecrypterAesMode.cbc:
return 'AES-CBC';
case DecrypterAesMode.ctr:
return 'AES-CTR';
default:
throw new Error(`[FastAESKey] invalid aes mode ${aesMode}`);
}
}
2 changes: 2 additions & 0 deletions src/demux/sample-aes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { HlsConfig } from '../config';
import Decrypter from '../crypt/decrypter';
import { DecrypterAesMode } from '../crypt/decrypter-aes-mode';
import { HlsEventEmitter } from '../events';
import type {
AudioSample,
Expand All @@ -30,6 +31,7 @@ class SampleAesDecrypter {
encryptedData,
this.keyData.key.buffer,
this.keyData.iv.buffer,
DecrypterAesMode.cbc,
);
}

Expand Down
16 changes: 14 additions & 2 deletions src/demux/transmuxer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { AC3Demuxer } from './audio/ac3-demuxer';
import MP4Remuxer from '../remux/mp4-remuxer';
import PassThroughRemuxer from '../remux/passthrough-remuxer';
import { logger } from '../utils/logger';
import {
isFullSegmentEncryption,
getAesModeFromFullSegmentMethod,
} from '../utils/encryption-methods-util';
import type { Demuxer, DemuxerResult, KeyData } from '../types/demuxer';
import type { Remuxer } from '../types/remuxer';
import type { TransmuxerResult, ChunkMetadata } from '../types/transmuxer';
Expand Down Expand Up @@ -115,8 +119,10 @@ export default class Transmuxer {
} = transmuxConfig;

const keyData = getEncryptionType(uintData, decryptdata);
if (keyData && keyData.method === 'AES-128') {
if (keyData && isFullSegmentEncryption(keyData.method)) {
const decrypter = this.getDecrypter();
const aesMode = getAesModeFromFullSegmentMethod(keyData.method);

// Software decryption is synchronous; webCrypto is not
if (decrypter.isSync()) {
// Software decryption is progressive. Progressive decryption may not return a result on each call. Any cached
Expand All @@ -125,6 +131,7 @@ export default class Transmuxer {
uintData,
keyData.key.buffer,
keyData.iv.buffer,
aesMode,
);
// For Low-Latency HLS Parts, decrypt in place, since part parsing is expected on push progress
const loadingParts = chunkMeta.part > -1;
Expand All @@ -138,7 +145,12 @@ export default class Transmuxer {
uintData = new Uint8Array(decryptedData);
} else {
this.decryptionPromise = decrypter
.webCryptoDecrypt(uintData, keyData.key.buffer, keyData.iv.buffer)
.webCryptoDecrypt(
uintData,
keyData.key.buffer,
keyData.iv.buffer,
aesMode,
)
.then((decryptedData): TransmuxerResult => {
// Calling push here is important; if flush() is called while this is still resolving, this ensures that
// the decrypted data has been transmuxed
Expand Down
11 changes: 9 additions & 2 deletions src/loader/fragment-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,11 @@ function createLoaderContext(
if (Number.isFinite(start) && Number.isFinite(end)) {
let byteRangeStart = start;
let byteRangeEnd = end;
if (frag.sn === 'initSegment' && frag.decryptdata?.method === 'AES-128') {
// MAP segment encrypted with method 'AES-128', when served with HTTP Range,
if (
frag.sn === 'initSegment' &&
isMethodFullSegmentAesCbc(frag.decryptdata?.method)
) {
// MAP segment encrypted with method 'AES-128' or 'AES-256' (cbc), when served with HTTP Range,
// has the unencrypted size specified in the range.
// Ref: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-08#section-6.3.6
const fragmentLen = end - start;
Expand Down Expand Up @@ -372,6 +375,10 @@ function createGapLoadError(frag: Fragment, part?: Part): LoadError {
return new LoadError(errorData);
}

function isMethodFullSegmentAesCbc(method) {
return method === 'AES-128' || method === 'AES-256';
}

export class LoadError extends Error {
public readonly data: FragLoadFailResult;
constructor(data: FragLoadFailResult) {
Expand Down
2 changes: 2 additions & 0 deletions src/loader/key-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export default class KeyLoader implements ComponentAPI {
}
return this.loadKeyEME(keyInfo, frag);
case 'AES-128':
case 'AES-256':
case 'AES-256-CTR':
return this.loadKeyHTTP(keyInfo, frag);
default:
return Promise.reject(
Expand Down
19 changes: 10 additions & 9 deletions src/loader/level-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
changeEndianness,
convertDataUriToArrayBytes,
} from '../utils/keysystem-util';
import { isFullSegmentEncryption } from '../utils/encryption-methods-util';
import { KeySystemFormats } from '../utils/mediakeys-helper';
import { mp4pssh } from '../utils/mp4-tools';
import { logger } from '../utils/logger';
Expand Down Expand Up @@ -51,13 +52,14 @@ export class LevelKey implements DecryptData {
this.keyFormatVersions = formatversions;
this.iv = iv;
this.encrypted = method ? method !== 'NONE' : false;
this.isCommonEncryption = this.encrypted && method !== 'AES-128';
this.isCommonEncryption =
this.encrypted && !isFullSegmentEncryption(method);
}

public isSupported(): boolean {
// If it's Segment encryption or No encryption, just select that key system
if (this.method) {
if (this.method === 'AES-128' || this.method === 'NONE') {
if (isFullSegmentEncryption(this.method) || this.method === 'NONE') {
return true;
}
if (this.keyFormat === 'identity') {
Expand Down Expand Up @@ -88,16 +90,15 @@ export class LevelKey implements DecryptData {
return null;
}

if (this.method === 'AES-128' && this.uri && !this.iv) {
if (isFullSegmentEncryption(this.method) && this.uri && !this.iv) {
if (typeof sn !== 'number') {
// We are fetching decryption data for a initialization segment
// If the segment was encrypted with AES-128
// If the segment was encrypted with AES-128/256
// It must have an IV defined. We cannot substitute the Segment Number in.
if (this.method === 'AES-128' && !this.iv) {
Comment thread
jvary marked this conversation as resolved.
logger.warn(
`missing IV for initialization segment with method="${this.method}" - compliance issue`,
);
}
logger.warn(
`missing IV for initialization segment with method="${this.method}" - compliance issue`,
);

// Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation.
sn = 0;
}
Expand Down
21 changes: 21 additions & 0 deletions src/utils/encryption-methods-util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DecrypterAesMode } from '../crypt/decrypter-aes-mode';

export function isFullSegmentEncryption(method: string): boolean {
return (
method === 'AES-128' || method === 'AES-256' || method === 'AES-256-CTR'
);
}

export function getAesModeFromFullSegmentMethod(
method: string,
): DecrypterAesMode {
switch (method) {
case 'AES-128':
case 'AES-256':
return DecrypterAesMode.cbc;
case 'AES-256-CTR':
return DecrypterAesMode.ctr;
default:
throw new Error(`invalid full segment method ${method}`);
}
}
1 change: 1 addition & 0 deletions tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import './unit/controller/subtitle-track-controller';
import './unit/controller/timeline-controller-nonnative';
import './unit/controller/timeline-controller';
import './unit/crypt/aes-decryptor';
import './unit/crypt/decrypter';
import './unit/demuxer/adts';
import './unit/demuxer/base-audio-demuxer';
import './unit/demuxer/id3';
Expand Down
Loading