ts帧加密案例(二)
一、前言
原本还有两篇案例的,基本都写完了,但考虑快过年了,加上最近...,所这篇主要综合两篇案例,讲讲怎么去生成一个加密的视频,并在线播放(web)。
二、前文回顾
2.1.bug修复
上一篇文章中第一次调用导出函数解密失败了,主要是这里长度忘记修改了,导致环境被检测了,修改一下就行了。当然都用c了,直接调用非导出函数也可以的。
2.2.nal Payload
在上一篇案例中,解密函数(f60 jeb)中有这样一段代码
含义很简单,就是判断是否存在000003,然后替换为0000。之所以这样的原因是,之前的文章中已经说明了,提取出来的帧,是由很多不同的nalu组成,而分割它们的是000001,那么如果nal原始的负载中出现000001,那么就会导致出错,所以引入了EBSP(Encapsulated Byte Sequence Payload)
-
0x000000-->0x00000300
-
0x000001-->0x00000301
-
0x000002-->0x00000302
-
0x000003-->0x00000303
所以需要注意这里和标准的代码并不完全一样,下面是直播部分(ida),这里和标准文档就一样了
三、基本环境配置
3.1.hls下载
使用前温馨提示
- 调试web的时候刷新的时候请按住 Ctrl+F5 键,不要只按F5
- 有时候代码没问题,但就是播放失败,甚至封面都解析出来了,拖中间就不行,需要在设置中清除缓存,然后重启浏览器
浏览器地址输入 edge://settings/clearBrowserData 或者 chrome://settings/clearBrowserData
只需要清除缓存的图像和文件就行了
下载地址 :https://github.com/video-dev/hls.js
git clone https://github.com/video-dev/hls.js.git
cd hls.js
# After cloning or pulling from the repository, make sure all dependencies are up-to-date
npm install ci
# Run dev-server for demo page (recompiles on file-watch, but doesn't write to actual dist fs artifacts)
npm run dev
# After making changes run the sanity-check task to verify all checks before committing changes
npm run sanity-check
克隆下来以后先不要运行,改一下cdn,以及log替换为静态的,不然很卡,或者挂梯子。
同时修改一下这里,把worker关闭,方便调试一点
然后运行,播放最后一个视频,或者直接点击,能正常播放并且配置文件没问题就行
3.2.bento4下载
下载网站:https://www.bento4.com/downloads/
下载源码因为需要简单分析修改: Source Snapshot (all platforms)
下载完成后,不出意外的话人家配置好了的,可以打开项目,修改一下工作目录,改成之前下载的hls项目中
然后简单修改一下开头代码,不用解析命令行部分,方便调试,手动或者自己用程序创建存放ts,key,m3u8位置,然后直接运行
Options.verbose = false;
Options.hls_version = 5;
Options.pmt_pid = 0x100;
Options.audio_pid = 0x101;
Options.video_pid = 0x102;
Options.audio_track_id = -1;
Options.video_track_id = -1;
Options.audio_format = AUDIO_FORMAT_TS;
Options.output_single_file = false;
Options.show_info = false;
Options.segment_duration = 10;
Options.segment_duration_threshold = DefaultSegmentDurationThreshold;
Options.pcr_offset = AP4_MPEG2_TS_DEFAULT_PCR_OFFSET;
AP4_SetMemory(Options.encryption_key, 0, sizeof(Options.encryption_key));
AP4_SetMemory(Options.encryption_iv, 0, sizeof(Options.encryption_iv));
AP4_SetMemory(&Stats, 0, sizeof(Stats));
AP4_Result result;
Options.allow_cache="YES";
Options.encryption_key_hex="000102030405060708090a0b0c0d0e0f";
AP4_ParseHex(Options.encryption_key_hex, Options.encryption_key, 16);
Options.encryption_iv_mode = ENCRYPTION_IV_MODE_RANDOM;
AP4_System_GenerateRandomBytes(Options.encryption_iv, 16);
Options.index_filename="test.m3u8";
Options.encryption_key_uri = "./key/test.key";
Options.segment_filename_template="./ts/test-%d.ts";
Options.segment_url_template="./ts/test-%d.ts";
Options.encryption_mode=ENCRYPTION_MODE_SAMPLE_AES;
Options.input="10编译FFmpeg(WebAssembly版)库.mp4";
Options.hls_version = 3;
FILE* key_file = fopen(Options.encryption_key_uri, "wb");
if (key_file == NULL) {
fprintf(stderr, "ERROR: cannot open key file for writing\n");
return 1;
}
unsigned char key[16];
AP4_ParseHex(Options.encryption_key_hex, key, 16);
fwrite(key, 1, 16, key_file);
fclose(key_file);
AP4_ByteStream* input = NULL;
result = AP4_FileByteStream::Create(Options.input, AP4_FileByteStream::STREAM_MODE_READ, input);
if (AP4_FAILED(result)) {
fprintf(stderr, "ERROR: cannot open input (%d)\n", result);
return 1;
}
// open the file
AP4_File* input_file = new AP4_File(*input, true);
然后用hls去播放视频,能正常播放说明没有问题
四、源码简要分析
4.1.Mp42Hls加密流程
这里主要分析和加解密相关的,解复用之类的就不过多介绍。
- 首先是这里,设置了key以及随机的iv,然后设置了加密方式,目录支持不加密,整体aes加密以及SAMPLE_AES
Options.encryption_key_hex="000102030405060708090a0b0c0d0e0f";
AP4_ParseHex(Options.encryption_key_hex, Options.encryption_key, 16);
Options.encryption_iv_mode = ENCRYPTION_IV_MODE_RANDOM;
AP4_System_GenerateRandomBytes(Options.encryption_iv, 16);
Options.encryption_mode=ENCRYPTION_MODE_SAMPLE_AES;
SAMPLE_AES是苹果提出来的一种对帧加密的方法,不仅加密视频帧,也加密了音频帧,所以之前生成ts文件,你直接播放没有声音也没有画面,后续我们也主要修改这来实现自己的帧加密。
- 然后就是解复用那一套,获取对应的码流,看不懂不重要,直接看怎么写入文件
- 根据不同加密方式设置加密函数,本质上就是一个aes-cbc
- 选择不同轨道来分别加密,主要看看视频加密部分
- nal加密部分,只加密了nal=1或者5的部分数据,至于为啥不知道苹果这样规定的
- 在后面就是写入文件了,先判断是h264还是h265,然后写入了sps/pps
-
写入了加密后的nalu
-
组装pes
WritePES(pes_data.GetData(), pes_data.GetDataSize(), dts, true, pts, with_pcr, output)
-
生成pes头
unsigned int pes_header_size = 14+(with_dts?5:0);
AP4_BitWriter pes_header(pes_header_size);
.....................................................
pes_header.Write(1, 1); // market_bit
-
切分nal按188字节组装ts,然后就写入文件
- 一直循环上面操作,完成后就是生成m3u8文件,后面不重要了不分析了。
4.2.hls解密流程
- 请求加载文件,一次加载m3u8文件,key文件,ts文件
其中key加载部分,loadKeyHTTP函数,请求成功后回调函数中把,key赋值给了keyInfo.decryptdata.key = frag.decryptdata.key,
如果key有加密,大部分都在这里解密了,好处是只需要解密一次key,不需要额外修改函数逻辑,坏处就是一眼就看出来了
loadKeyHTTP = function loadKeyHTTP(keyInfo, frag) {
..................
return keyInfo.keyLoadPromise = new Promise(function (resolve, reject) {
var loaderCallbacks = {
onSuccess: function onSuccess(response, stats, context, networkDetails) {
......
keyInfo.decryptdata.key = frag.decryptdata.key = new Uint8Array(response.data);
frag.keyLoader = null;
keyInfo.loader = null;
resolve({
frag: frag,
keyInfo: keyInfo
});
}
.....
-
然后是ts回调函数,完全看不懂,唯一看起来眼熟的frag,key就存在这里面,然后就进入了transmuxer
function _handleFragmentLoadProgress(data) {
var _frag$initSegment;
var frag = data.frag,
.........
transmuxer.push(payload, initSegmentData, audioCodec, videoCodec, frag, part, details.totalduration, accurateTimeOffset, chunkMeta, initPTS);
};
-
后面又是一大堆看不懂的,把key赋值给了decryptdata,然后判断是否开启了worker,就开始解复用了
unction push(data, initSegmentData, audioCodec, videoCodec, frag, part, duration, accurateTimeOffset, chunkMeta, defaultInitPTS) {
................
var decryptdata = frag.decryptdata;
this.frag = frag;
this.part = part;
if (this.workerContext) {
// post fragment payload as transferable objects for ArrayBuffer (no copy)
this.workerContext.worker.postMessage({
cmd: 'demux',
data: data,
decryptdata: decryptdata,
chunkMeta: chunkMeta,
state: state
}, data instanceof ArrayBuffer ? [data] : []);
} else if (transmuxer) {
var _transmuxResult = transmuxer.push(data, decryptdata, chunkMeta, state);
if (isPromise(_transmuxResult)) {
transmuxer.async = true;
_transmuxResult.then(function (data) {
_this2.handleTransmuxComplete(data);
}).catch(function (error) {
_this2.transmuxerError(error, chunkMeta, 'transmuxer-interface push error');
});
} else {
transmuxer.async = false;
this.handleTransmuxComplete(_transmuxResult);
}
}
-
解复用之前,首先判断是否是ts整体加密
var keyData = getEncryptionType(uintData, decryptdata);
if (keyData && keyData.method === 'AES-128') {
var decrypter = this.getDecrypter();
// Software decryption is synchronous; webCrypto is not
if (decrypter.isSync()) {
var decryptedData = decrypter.softwareDecrypt(uintData, keyData.key.buffer, keyData.iv.buffer);
uintData = new Uint8Array(decryptedData);
-
然后判断是否是SAMPLE,进入不同解析流程,解复完成后再进行解密内容
function transmux(data, keyData, timeOffset, accurateTimeOffset, chunkMeta) {
var result;
if (keyData && keyData.method === 'SAMPLE-AES') {
result = this.transmuxSampleAes(data, keyData, timeOffset, accurateTimeOffset, chunkMeta);
} else {
result = this.transmuxUnencrypted(data, timeOffset, accurateTimeOffset, chunkMeta);
}
return result;
};
function demuxSampleAes(data, keyData, timeOffset) {
var demuxResult = this.demux(data, timeOffset, true, !this.config.progressive);
var sampleAes = this.sampleAes = new SampleAesDecrypter(this.observer, this.config, keyData);
return this.decrypt(demuxResult, sampleAes);
};
-
解复用基本流程就不说了,主要看看这两个函数
if (videoData && (pes = this.parsePES(videoData))) {
this.videoParser.parseAVCPES(videoTrack, textTrack, pes, false, this._duration);
}
-
首先是parsePES,这里传入的videoData包含了pes头,返回的数据才是纯nal内容
return {
data: pesData,
pts: pesPts,
dts: pesDts,
len: pesLen
};
-
parseAVCPES中,先拆分了nal,var units = this.parseAVCNALu(track, pes.data);
然后根据不同nalu类型进行处理,主要关注一下等于7的时候,也就是sps,这里设置了分辨率,时长,以及解码器。
if (!track.sps || track.width !== config.width || track.height !== config.height || ((_track$pixelRatio = track.pixelRatio) == null ? void 0 : _track$pixelRatio[0]) !== config.pixelRatio[0] || ((_track$pixelRatio2 = track.pixelRatio) == null ? void 0 : _track$pixelRatio2[1]) !== config.pixelRatio[1]) {
track.width = config.width;
track.height = config.height;
track.pixelRatio = config.pixelRatio;
track.sps = [sps];
track.duration = duration;
var codecarray = sps.subarray(1, 4);
var codecstring = 'avc1.';
for (var _i = 0; _i < 3; _i++) {
var h = codecarray[_i].toString(16);
if (h.length < 2) {
h = '0' + h;
}
codecstring += h;
}
track.codec = codecstring;
}
break;
-
后面就是解密函数,基本就和c一样了,唯一区别就是,js直接把加密的数据单独拿出来getAvcEncryptedData
,解密后重新写入getAvcEncryptedData
-
后面的就和普通视频一样了,就不看了,看了也看不懂
五、实现帧加密
5.1 pes整体加密
前面已经说过了,加密时在WritePES这里传入了不含头的pes,解密时parsePES返回了不含头的pes,所以直接修改对应代码。
Options.encryption_mode=ENCRYPTION_MODE_NONE;//修改为不加密
func WritePES
unsigned int pes_header_size = 14+(with_dts?5:0)+1;//增加一字节,存放是否加密
.......
pes_header.Write(0x10, 8); // is_enc
bool first_packet = true;
简单写个异或,需要注意加密可能存在000001,所以需要填充03,不填充问题应该也不大,就怕运气好,所以还是填一下
unsigned int nalUnescape(uint8_t* nal, unsigned int nalSize, uint8_t* buffer)
{
unsigned int i = 0;
unsigned int j = 0;
while (i < nalSize) {
if (i < nalSize - 2 && nal[i] == 0x00 && nal[i + 1] == 0x00 && nal[i + 2] <= 0x03) {
buffer[j++] = nal[i++];
buffer[j++] = nal[i++];
buffer[j++] = 0x03;
} else {
buffer[j++] = nal[i++];
}
}
return j;
}
uint8_t * escapedBuffer = (uint8_t *)calloc(1, data_size * 2);
char xor_key[16]={53,50,112,111,106,105,101,119,115,98,104,103,109,100,104,104};
for (unsigned int i=0; i<data_size; i++) {
escapedBuffer[i] = data[i] ^ xor_key[i%16];
}
unsigned int escapedSize = nalUnescape(escapedBuffer, data_size,escapedBuffer);
data_size = escapedSize;
data = escapedBuffer;
if (with_dts && (dts == pts)) {
with_dts = false;
}
可以看出来生成以后的文件,可以识别到pes头,但是负载全加密了
然后修改js中的parsePES函数
播放视频,可以看到和预期结果一样,视频也能正常播放。
5.2nal内容加密
上面已经提到了内置了一种帧加密模式所以直接在这基础上修改
首先把音频加密部分注释掉,不太熟悉音频
前面已经提到过了,加密nal时,只加密了1和5,那么简单看下为啥加密1和5,修改代码,分别只加密1或5
只加密1
只加密5
可以看出来,只加密5时,画面基本是完整的,但是并不连续,而只加密1时,画面基本不完整。具体涉及到了解码部分,主要是和i,p,b帧有关的,感兴趣可以去了解一下。这里只是展示一下有什么区别,方便快速定位是哪一帧出问题了。type=5就可以当做是i帧(idr),type=1就可以看做p或b帧。简单地讲,I 帧是一个完整的画面,而 P 帧和 B 帧记录的是相对于 I 帧的变化。
那么除了1和5,上面还提到了7,sps里面包含了分辨率以及解码器信息,尝试只加密一下7。这里需要修改一下加密长度,不然加密不了
AP4_UI08 nalu_type = nalu[nalu_length_size] & 0x1F;
if (nalu_length > 16 && (nalu_type==7)) {
AP4_Size encrypted_size = 16*((nalu_length-1)/16);
if ((nalu_length%16) == 0) {
encrypted_size -= 16;
}
然后就离谱了,居然还能正常播放。
然后用软件去分析,发现多了一个sps和pps,第一个能正常解析出分辨率,第二个不行
回想起写入文件的时候先写入了sps/pps,在写入了加密后的nal,说明这里写入的sps是加密前的,或者是直接生成的,那么最简单方法直接注释掉。
这次视频分辨率就识别不出来了,画面也没有
然后尝试用hls去播放
首先修改一下src/demux/sample-aes.ts->decryptAvcSamples函数
if (
curUnit.data.length <= 16 ||
(curUnit.type !== 5||curUnit.type !== 1||curUnit.type !== 7)
) {
continue;
}
然后修改一下getAvcDecryptedUnit以及getAvcEncryptedData
然后,不出意外的出意外了,主要提示解码器不支持
之前的分析中,它是先解复用,在解密,而在解复用过程中就已经设置了解码器,所以在这里修改代码是不行的。
那就先修改c
if (nalu_length > 16 && (nalu_type ==5 ||nalu_type==7||nalu_type==1)) {
AP4_Size encrypted_size = 16*((nalu_length-1)/16);
if ((nalu_length%16) == 0) {
encrypted_size -= 16;
}
if (nalu_type==5){
m_StreamCipher->SetIV(m_IV);
for (unsigned int i=0; i<encrypted_size; i += 10*16) {
AP4_Size one_block_size = 16;
m_StreamCipher->ProcessBuffer(nalu+nalu_length_size+1+i, one_block_size,
nalu+nalu_length_size+1+i, &one_block_size);
}
}else if (nalu_type==7){
encrypted_size=nalu_length-3;
AP4_UI08 xor_mask[16]={53,50,112,111,106,105,101,119,115,98,104,103,109,100,104,104};
for (unsigned int i=0; i<encrypted_size; i ++) {
nalu[nalu_length_size+1+i] ^= xor_mask[i%16];
}
}
然后修改js src/demux/video/avc-video-parser.ts parseAVCPES
case 7:
.....
const stack = new Error().stack;
if (stack.includes('SampleAes')) {
const xor_mask = new Uint8Array([53, 50, 112, 111, 106, 105, 101, 119, 115, 98, 104, 103, 109, 100, 104, 104]);
const encrypted_size = sps.length - 3;
for (let i = 0; i < encrypted_size; i++) {
sps[i + 1] ^= xor_mask[i % 16];
}
}
const expGolombDecoder = new ExpGolomb(sps);
..............
刷新网页,能正常播放视频了
六、补充知识
6.1.drm系统组成
看了上面内容,发现其实帧加密也不难,那么为什么drm那么难以破解,那么就不得不提整个流程了,下面是一张很完整的图,详细介绍在w3.org上
从图上可以看出来,我写的仅仅涉及了最右下角小小的一部分。而真正复杂的,生成请求信息,以及解密响应,以及它内部的解密帧算法,基本全是白盒算法,请求一般是rsa,解密帧是白盒的aes,而不是上面我们自己实现时,用的很明确的aes,在cdm中几乎不会直接出现key。而cdm分为软件和硬件层实现,又完全不是一个东西。所以任重而道远,我能力有限,只能写一点没什么用的东西。
6.2.sdt
在wv以及pr(谷歌和微软的drm),只需要key就能解密,因为他的iv隐藏在第一个文件中,而它们的格式是mp4,那么ts如果想要隐藏iv可以尝试放哪里。
在查看ts生成源码时发现,在写入pat,pmt之前,还有一个可能存在一个sdt表,但是无论是加密还是解密过程中,均没发现,说明没啥用,既然没啥用那我们就可以用一用
首先是sdt主要组成,可以看到字段很多,但没什么合适的,只有一个free_CA_mode可以来判断是否加密了
然后就是Descriptor,Descriptor也分很多种具体在官方文档可以找到,比较常见的是Service descriptor,下面就是其主要组成
而刚好从这张表的描述中可以看到,存在两个可以自定义长度的字符串,那么我们就可以把iv写入这里,前面又提到了这是写在ts开头的,那么还可以每个ts文件一个iv,还可以设置不同加密算法,cbc或者ctr模式切换用,除此之外加密的过程中,160字节只加密了,16字节,剩下的什么都不做太可惜了,还可以让他们和iv进行异或。甚至可以随机设置加密和异或块大小。当然上面只是吹牛,具体的可以尝试自行实现。
7、附件
附件没有代码,只是本地版文档,仅供备份。
https://nicaicai.lanzouo.com/i49kw1llmklc