上个月在逛论坛时,看到了一个标题:“100u 有偿请前端老哥实现解密播放 m3u8 文件”,看着比较感兴趣,也正对自己专业,本着“当代人应多从助人为乐中体验幸福”的原则,从标题点了进去。
OP 的核心问题
简要概括一下 就是,使用 OpenSSL 命令对影片的明文
.m3u8
和 .ts
切片文件进行加密(AES-ECB),需要前端对文件进行解密,并把视频播放出来,能实现就给 100 美元的报酬。评论区
当我往下翻看评论的时候,有相当多的回复,都在表达对 OP 问题的安全性的看法,以及质疑需求是否合理,还有在嫌弃价格低云云;这些并没有直接帮助 OP 解决如何实现前端解密并播放视频的问题,更多的都是在吐槽问题。
关于问题
评论区的讨论当然有存在的价值,而作为一个前端开发人员,我首先会去思考以下几点:
这个需求是为了解决什么问题?
需求是通过 AES-ECB 加密技术保护视频流的
.m3u8
和 .ts
文件,这样可以在网络传输中增加一层保护,但同时要保证视频能在浏览器端解密播放。最终目的或许是为了解决视频版权或数据安全问题,最常见的也就是数字版权管理(DRM)了。前端能否实现?
前端当然可以实现,正如 op 提到的
crypto-js
可以用来解密,hls.js
用来播放 .m3u8
视频流文件,关键点在于如何解决如何解密和播放的配合。翻看文档,发现 hls.js
的配置项 (HlsConfig type) 中可以自定义 Loader
,这大大降低了解决问题的难度,只需要在 Loader
(Loader interface) 内部去处理解密,解密后的文件返回给播放器播放就好了。思考这种解决方式有哪些弊端?
密钥的问题,在浏览器端解密始终有密钥暴露的风险,当然通过服务端下放动态密钥可以很大程度上解决这个问题;其次就是每个视频片段的解密都会在用户设备上进行,对于性能差的设备可能会造成卡顿。
代码实现
有了上一步中实现的思路,接下来就可以写代码了。
处理 hls.js
的 loader
配置项
// 创建一个自定义的加载器类 CustomLoader,继承自 Hls.DefaultConfig.loader class CustomLoader extends Hls.DefaultConfig.loader { constructor(config) { // 调用父类构造函数,以确保继承父类的加载逻辑 super(config); // 绑定当前对象的 load 方法,以便在自定义的 load 函数内调用 const load = this.load.bind(this); // 重写 load 方法,实现自定义解密逻辑 this.load = async function (context, config, callbacks) { // 获取成功回调函数,用于在解密后继续后续操作 const onSuccess = callbacks.onSuccess; // 重写 onSuccess 回调,以便在成功加载资源后对内容进行解密 callbacks.onSuccess = async function (response, stats, context) { try { // 调用自定义解密函数 readAndDecryptFile 对加载的文件解密 const decryptedData = await readAndDecryptFile(context.url); // 将解密后的数据替换原始响应数据 response.data = decryptedData; // 调用原始的 onSuccess 回调,传递解密后的数据 onSuccess(response, stats, context, null); } catch (err) { // 捕获解密过程中的错误并输出到控制台 console.error(err); } }; // 调用原始的 load 方法,继续加载过程 load(context, config, callbacks); }; } } // 创建一个 Hls 实例,并使用自定义的 CustomLoader 进行解密处理 const hls = new Hls({ loader: CustomLoader });
实现 readAndDecryptFile
解密函数
// 将十六进制密钥转换为 CryptoJS 可用的格式 const key = CryptoJS.enc.Hex.parse(keyHex); // 通用 AES 解密函数 function decryptAES(encryptedData, outputType = "utf8") { // 将二进制数据转换为 WordArray const wordArray = CryptoJS.lib.WordArray.create(encryptedData); const decrypted = CryptoJS.AES.decrypt( wordArray.toString(CryptoJS.enc.Base64), key, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7, } ); // 根据输出类型返回相应格式的数据 return outputType === "utf8" ? decrypted.toString(CryptoJS.enc.Utf8) : new Uint8Array( decrypted.words .map((word) => [ (word >> 24) & 0xff, (word >> 16) & 0xff, (word >> 8) & 0xff, word & 0xff, ]) .flat() ); } // 读取并解密文件,根据文件类型选择解密方式 export async function readAndDecryptFile(file) { const response = await fetch(file); const encryptedData = await response.arrayBuffer(); // 以二进制格式读取 // 解密为 UTF-8 字符串(m3u8)或 Uint8Array(二进制 ts 文件) return file.endsWith(".m3u8") ? decryptAES(encryptedData, "utf8") : decryptAES(encryptedData, "binary"); }
通过自定义
Loader
和结合 CryptoJS
解密,实现了前端对 m3u8
视频流的解密和播放,至此完成。源码地址
最后附上完整源码:
decipher-m3u8
vsme • Updated Jan 2, 2025