Fix AES CBC byterange addressed I-Frame decryption (for non-16byte boundary m2ts content) (#7892)

This commit is contained in:
Rob Walch
2026-06-04 13:58:13 -07:00
committed by GitHub
parent ed68f686d2
commit ed533a9b00
7 changed files with 81 additions and 35 deletions
+13 -2
View File
@@ -974,12 +974,20 @@ export class CaptionScreen {
//
// @public (undocumented)
export class ChunkMetadata {
constructor(level: number, sn: number, id: number, size?: number, part?: number, partial?: boolean, duration?: number, iframe?: boolean);
constructor(level: number, sn: number, id: number, size?: number, part?: number, partial?: boolean, duration?: number, iframe?: boolean, decryptRange?: {
start: number;
end: number;
});
// (undocumented)
readonly buffering: {
[key in SourceBufferName]: HlsChunkPerformanceTiming;
};
// (undocumented)
readonly decryptRange?: {
start: number;
end: number;
};
// (undocumented)
readonly duration: number;
// (undocumented)
readonly id: number;
@@ -1191,7 +1199,10 @@ export interface DecryptData {
export class Decrypter {
constructor(config: HlsConfig, useSoftware?: boolean);
// (undocumented)
decrypt(data: Uint8Array | ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer, aesMode: DecrypterAesMode, plainTextLength?: number): Promise<ArrayBuffer>;
decrypt(data: Uint8Array | ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer, aesMode: DecrypterAesMode, decryptRange?: {
start: number;
end: number;
}): Promise<ArrayBuffer>;
// (undocumented)
destroy(): void;
// (undocumented)
+9 -6
View File
@@ -16,7 +16,7 @@ import {
mediaFragmentsAreEqual,
type Part,
} from '../loader/fragment';
import FragmentLoader from '../loader/fragment-loader';
import FragmentLoader, { getAESAdjustments } from '../loader/fragment-loader';
import TaskLoop from '../task-loop';
import { PlaylistLevelType } from '../types/loader';
import { ChunkMetadata } from '../types/transmuxer';
@@ -736,17 +736,18 @@ export default class BaseStreamController
) {
const startTime = self.performance.now();
// decrypt init segment data
const byteRange = frag.byteRange;
const plainTextLength = byteRange.length
? byteRange[1] - byteRange[0]
: 0;
const { decryptRange } = getAESAdjustments(
frag,
null,
this.iframesOnly,
);
return this.decrypter
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
plainTextLength,
decryptRange,
)
.catch((err) => {
hls.trigger(Events.ERROR, {
@@ -906,6 +907,7 @@ export default class BaseStreamController
!partsLoaded ||
partsLoaded.length === 0 ||
partsLoaded.some((fragLoaded) => !fragLoaded);
const { decryptRange } = getAESAdjustments(frag, part);
const chunkMeta = new ChunkMetadata(
frag.level,
frag.sn,
@@ -915,6 +917,7 @@ export default class BaseStreamController
!complete,
frag.duration,
this.iframesOnly,
decryptRange,
);
this.log(
`load complete ${frag.type} sn: ${frag.sn}${
+8 -2
View File
@@ -7,6 +7,7 @@ import { ErrorDetails } from '../errors';
import { Events } from '../events';
import { changeTypeSupported } from '../is-supported';
import { ElementaryStreamTypes, isMediaFragment } from '../loader/fragment';
import { getAESAdjustments } from '../loader/fragment-loader';
import { LoaderContextType, PlaylistLevelType } from '../types/loader';
import { ChunkMetadata } from '../types/transmuxer';
import { BufferHelper } from '../utils/buffer-helper';
@@ -808,16 +809,21 @@ export default class StreamController
));
const partIndex = part ? part.index : -1;
const partial = partIndex !== -1;
const byteRange = frag.byteRange;
const { decryptRange, byteRange } = getAESAdjustments(
frag,
part,
this.iframesOnly,
);
const chunkMeta = new ChunkMetadata(
frag.level,
frag.sn,
frag.stats.chunkCount,
byteRange.length ? byteRange[1] - byteRange[0] : payload.byteLength,
byteRange ? byteRange.end - byteRange.start : payload.byteLength,
partIndex,
partial,
frag.duration,
this.iframesOnly,
decryptRange,
);
const initPTS = this.initPTS[frag.cc];
+6 -6
View File
@@ -10,7 +10,7 @@ const CHUNK_SIZE = 16; // 16 bytes, 128 bits
export default class Decrypter {
private logEnabled: boolean = true;
private plainTextLength: number = 0;
private decryptRange?: { start: number; end: number };
private subtle: SubtleCrypto | null = null;
private softwareDecrypter: AESDecryptor | null = null;
private key: ArrayBuffer | null = null;
@@ -60,8 +60,8 @@ export default class Decrypter {
}
const data = new Uint8Array(currentResult);
this.reset();
if (this.plainTextLength) {
return data.slice(0, this.plainTextLength);
if (this.decryptRange) {
return data.slice(this.decryptRange.start, this.decryptRange.end);
}
return removePadding(data);
}
@@ -80,10 +80,10 @@ export default class Decrypter {
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
plainTextLength: number = 0,
decryptRange?: { start: number; end: number },
): Promise<ArrayBuffer> {
this.plainTextLength = plainTextLength;
if (this.useSoftware || plainTextLength) {
this.decryptRange = decryptRange;
if (this.useSoftware || decryptRange) {
return new Promise((resolve, reject) => {
const dataView = ArrayBuffer.isView(data) ? data : new Uint8Array(data);
this.softwareDecrypt(dataView, key, iv, aesMode);
+1 -7
View File
@@ -124,12 +124,6 @@ export default class Transmuxer {
if (keyData && isFullSegmentEncryption(keyData.method)) {
const decrypter = this.getDecrypter();
const aesMode = getAesModeFromFullSegmentMethod(keyData.method);
const plainTextLength =
chunkMeta.size !== data.byteLength && chunkMeta.iframe
? chunkMeta.size
: 0;
this.asyncResult = true;
this.decryptionPromise = decrypter
.decrypt(
@@ -137,7 +131,7 @@ export default class Transmuxer {
keyData.key.buffer,
keyData.iv.buffer,
aesMode,
plainTextLength,
chunkMeta.decryptRange,
)
.then((decryptedData) => {
// Calling push here is important; if flush() is called while this is still resolving, this ensures that
+41 -12
View File
@@ -369,11 +369,31 @@ function createLoaderContext(
rangeStart: 0,
rangeEnd: 0,
};
const aesAdjustments = getAESAdjustments(frag, part, isIFrame);
if (aesAdjustments.resetIV) {
loaderContext.resetIV = true;
}
if (aesAdjustments.byteRange) {
loaderContext.rangeStart = aesAdjustments.byteRange.start;
loaderContext.rangeEnd = aesAdjustments.byteRange.end;
}
return loaderContext;
}
export function getAESAdjustments(
frag: Fragment,
part: Part | null = null,
isIFrame?: boolean,
) {
const result: {
resetIV?: boolean;
byteRange?: { start: number; end: number };
decryptRange?: { start: number; end: number };
} = {};
const segment: BaseSegment = part || frag;
const start = segment.byteRangeStartOffset as number;
const end = segment.byteRangeEndOffset as number;
if (Number.isFinite(start) && Number.isFinite(end)) {
let byteRangeStart = start;
let byteRangeEnd = end;
if (
(frag.sn === 'initSegment' || isIFrame) &&
isMethodFullSegmentAesCbc(frag.decryptdata?.method)
@@ -381,19 +401,28 @@ function createLoaderContext(
// 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;
if (fragmentLen % 16) {
byteRangeEnd = end + (16 - (fragmentLen % 16));
}
if (start !== 0) {
loaderContext.resetIV = true;
byteRangeStart = start - 16;
let byteRangeStart = Math.floor(start / 16) * 16; // round down to 16-byte boundary;
const byteRangeEnd = Math.ceil(end / 16) * 16; // round up to 16-byte boundary
result.decryptRange = {
start: start - byteRangeStart,
end: end - byteRangeStart,
};
if (byteRangeStart >= 16) {
result.resetIV = true;
byteRangeStart = byteRangeStart - 16;
}
result.byteRange = {
start: byteRangeStart,
end: byteRangeEnd,
};
} else {
result.byteRange = {
start,
end,
};
}
loaderContext.rangeStart = byteRangeStart;
loaderContext.rangeEnd = byteRangeEnd;
}
return loaderContext;
return result;
}
function createGapLoadError(frag: Fragment, part?: Part): LoadError {
+3
View File
@@ -16,6 +16,7 @@ export class ChunkMetadata {
public readonly partial: boolean;
public readonly iframe: boolean;
public readonly duration: number;
public readonly decryptRange?: { start: number; end: number };
public readonly transmuxing: HlsChunkPerformanceTiming =
getNewPerformanceTiming();
public readonly buffering: {
@@ -35,6 +36,7 @@ export class ChunkMetadata {
partial = false,
duration?: number,
iframe?: boolean,
decryptRange?: { start: number; end: number },
) {
this.level = level;
this.sn = sn;
@@ -43,6 +45,7 @@ export class ChunkMetadata {
this.part = part;
this.partial = partial;
this.duration = duration || 0;
this.decryptRange = decryptRange;
this.iframe = iframe || false;
}
}