做视频加密这件事,我这两年陆续帮别人处理过不少。最常见的是在线教育类Demo项目、企业内部培训客户端、还有给自媒体素材做版权保护。需求本质都一样:视频文件一旦离开自己的服务器,就可能被下载、被转发、被二次剪辑。你想从源头上完全杜绝不可能,但让对方“没有key就打不开”是一条非常实用的底线。这个需求落到技术选型上,主流路线就是给视频做对称加密,而AES-CTR又是我个人比较偏爱的一种模式。这篇文章我把整套实操写清楚,包括FFmpeg自带的加密能力和配合代码手写AES-CTR的方案,源码放在后面,直接能抄。
1. 项目概述与AES-CTR原理
1.1 为什么要给视频加密
先想清楚一个事实:视频文件本身是二进制数据,你随便拿一个播放器打开它,如果文件没有加密,那它就是裸奔状态。市面上大量教程、课件、私域课程,被人扒下来之后换个平台继续卖,根源就在这里。你可能觉得“我不发原文件,用户只能在我的App里看”就安全了,但客户端缓存、Android/data目录、iOS沙盒,这些都是技术好一点的人能拿到的路径。
所以什么叫“给视频加密”?说人话就是:把正常的MP4或者TS文件的数据流,用一个key按某种算法变成一团乱码。播放器拿到乱码之后,必须用同一个key恢复成原始视频才能播放。没有key的播放器拿到文件,看到的只有噪声数据,连时长、分辨率这些基础信息都很难解析,更别提正常播放了。
这里有个容易被忽略的点:加密粒度很重要。你可以对整个文件一次性加密,也可以对每个分片单独加密。我的经验是,如果视频要支持进度拖动、倍速播放、断点续播,那么“分段加密”比“整文件加密”体验好得多。因为你拖动进度条时只需要解密那个时间附近的数据,不需要把整个文件先恢复出来。AES-CTR天然适合这个场景,后面会详细说。
1.2 AES-CTR的工作机制与优势
AES-CTR的全称是AES Counter Mode,中文叫计数器模式。它和我们经常听到的AES-CBC不一样,理解起来更简单。AES本身是一个分组密码,每次加密16字节(128位)数据。而在CTR模式下,它并不是直接拿你的视频数据去套AES,而是先用一个初始值(IV + 计数器)经过AES加密,生成一段“密钥流”,然后把这段密钥流和你的视频明文做XOR异或运算。
这个“先加密计数器、再异或数据”的设计非常巧妙。我打一个比方:AES-CTR就像是把一次性的密码本(One-Time Pad)搬到了流式场景里。加密时,数据流走到哪一段,就用那一段的计数器生成的密钥流去异或;解密时同样用那一段的计数器重现同一段密钥流,再异或一次,明文就回来了。
CTR模式相比CBC有几个很明显的优势:
- 可以并行处理。每一块的计数器是独立的,加密第100块不需要等前面的99块算完,所以CPU多核能直接发力,4K高码率视频也能扛得住。
- 支持随机访问。想看第50秒的画面,只需要算出那一时间段对应的计数器偏移,直接解密那几块数据就行。这对视频软件极其重要。
- 错误不扩散。如果某一块数据在传输过程中损坏了,最多影响那一块,不会像CBC那样把后面整串解出来都是花屏乱码。
- 加密和解密流程完全一致。同一套函数,传入明文输出密文,传入密文输出明文,代码实现特别清爽。
当然,CTR模式也有它的命门:它的安全性完全建立在“计数器+IV”不重复使用上。如果你用同一个key和同一个IV去加密两个不同的视频,那么把两段密文异或一下,就能干掉密钥直接恢复出部分明文。这个不是理论问题,是实际可利用的攻击,我的建议是每一条视频、每一个分片都要用独立的IV,绝对不能偷懒。
1.3 FFmpeg对AES-CTR的支持情况
很多人以为FFmpeg只能做转码,不能做加密,这是个误区。FFmpeg在容器层面对加密支持得很好,特别是对DASH和HLS的Common Encryption(CENC)。CENC标准里明确规定了两种加密方式:CENC AES-CTR和CENC AES-CBC。FFmpeg在movenc和movdec里都实现了这套逻辑,对应的命令行参数就是-encryption_scheme。
实际测下来,FFmpeg支持cenc-aes-ctr和cenc-aes-cbc两种scheme。我们这篇文章主推前者,因为AES-CTR的随机访问特性更适合视频播放场景。至于HLS原生的hls_key_info_file,走的是AES-128 CBC,也能用,但它其实属于另一套机制,后面我会作为对比方案讲一下。你只要记住:要在FFmpeg里实现AES-CTR加密,主流做法是走CENC这条线,配合-encryption_key和-encryption_iv参数。
2. 命令行实操:FFmpeg原生AES-CTR加密解密
2.1 环境准备与版本确认
虽然FFmpeg绝大多数功能已经内置了,但CENC加密模块依赖了mp4格式的movenc/movdec,为了保证命令能跑通,你最好先确认版本。我自己长期用的是某发行版打包的4.4以上版本,Windows下我一般直接下载官方编译的full版,macOS下我习惯用Homebrew的默认包。
查询版本很简单:
ffmpeg -version重点看编译配置里有没有--enable-encoders和--enable-protocols,这俩通常情况下都是默认开启的。如果编译参数被裁剪过,很有可能会缺少mp4 muxer或者cenc支持,加密命令会直接报Unknown option "encryption_scheme"这种错。遇到这种情况,别折腾静态包了,去下载一份完整功能的版本,或者自己编译一次,我们后面在常见问题部分再展开。
另外要有一个带视频轨道的MP4文件作为素材,我习惯管它叫source.mp4。它不需要重新编码,直接copy流就能加密,所以处理速度很快。
2.2 加密命令与参数详解
先把完整命令扔出来,再一个个拆参数:
ffmpeg -y -i source.mp4 \ -c copy \ -encryption_scheme cenc-aes-ctr \ -encryption_key 0123456789abcdef0123456789abcdef \ -encryption_iv 00112233445566778899aabbccddeeff \ encrypted.mp4这里几个关键参数含义如下:
-c copy:不对视频流和音频流重新编码。加密发生在封装层,也就是转封装的过程中,所以速度非常快,一个几百MB的文件通常是几秒就完成。-encryption_scheme:指定使用的加密方案。cenc-aes-ctr就是标准CENC里的AES-128-CTR。-encryption_key:128位密钥的十六进制字符串,长度必须是32个十六进制字符。注意它代表16字节的二进制key,不是随便一串ASCII字符。-encryption_iv:初始向量,十六进制字符串,长度一般是32个字符对应16字节。AES-CTR模式下,它的实际使用规则是:前面若干字节作为nonce,后面若干字节作为计数器初始值,具体长度由FFmpeg内部根据CENC规范处理。你给它完整的128位IV就行,不要只给一半。
跑完之后,用ls -l看一眼文件大小。你会注意到encrypted.mp4的大小和source.mp4几乎一模一样,最多差几个字节的box头信息。这是因为流数据本身没变,只是数据载荷被替换成了密文。
如果你想让播放器首帧快速显示,还可以加上-movflags +faststart,不过加密版我建议保持简单,快不快的后面再看。
2.3 解密命令与验证
解密一条命令就够了:
ffmpeg -y \ -encryption_scheme cenc-aes-ctr \ -encryption_key 0123456789abcdef0123456789abcdef \ -encryption_iv 00112233445566778899aabbccddeeff \ -i encrypted.mp4 \ -c copy decrypted.mp4注意参数的摆放位置。-encryption_scheme、-encryption_key、-encryption_iv必须放在-i encrypted.mp4之前,因为它们属于输入端的解密选项。如果你把这些参数放到-i后面,FFmpeg会认为你是想对输出文件再做一次加密,那就不是解密还原了。
解密完成后,对比一下source.mp4和decrypted.mp4的md5值:
md5sum source.mp4 decrypted.mp4正常情况下,这两个文件的md5应该完全一致,因为我们是-c copy,没有任何重编码,解密后得到的字节流就是原始字节流。如果md5不一致,说明你在加密或解密过程中动了不该动的数据,或者IV对不上,这是一个非常有用的自查手段。
2.4 配合DASH/HLS做分段加密的扩展
单文件加密平时够用,但如果你做的是一个正规的视频网站,希望用户边下边播、拖动条随便拉,那就得把视频切成多段分片,再对每个分片做加密。HLS和DASH是两大主流方案,FFmpeg都支持,但底层加密方式不太一样。
DASH结合CENC是最标准的“流媒体加密”姿势。命令大概是:
ffmpeg -y -i source.mp4 \ -c:v libx264 -preset veryfast -crf 23 \ -c:a aac -b:a 128k \ -f dash \ -seg_duration 4 \ -encryption_scheme cenc-aes-ctr \ -encryption_key 0123456789abcdef0123456789abcdef \ -encryption_iv 00112233445566778899aabbccddeeff \ output.mpd这条命令会生成MPD描述文件和一系列MP4或M4S分片。播放器拿到MPD之后,首先读取各分片的加密元信息,然后根据key去解密对应片段。这个过程对用户完全透明,播放器体验和看普通DASH流没有任何区别,但直接下载下来的分片文件是没法播放的。
至于HLS,FFmpeg原生走的是hls_key_info_file,它默认是AES-128 CBC。如果你想在HLS里面用CTR,就得看播放器支不支持对应的extra params,这一步往往比服务器端麻烦。我的经验是:如果项目从零开始选型,DASH+CENC-AES-CTR的兼容性和技术成熟度都很好,优先考虑它;如果必须做HLS,那跟着HLS标准用AES-CBC其实也够,不必硬凹CTR。
3. 代码实现:Python+C双方案解析
3.1 Python调用FFmpeg与OpenSSL实现AES-CTR
命令行方案适合快速验证和一次性处理,但如果你要把加密能力集成到自己的服务后台,最好还是用代码去驱动。我先给一个纯Python的版本,它依赖两个外部程序:FFmpeg和OpenSSL。核心思路是先让FFmpeg做层预处理,再用OpenSSL对最终文件做AES-CTR流加密。
举个实际例子:假设你已经有了一个转好的MP4文件source.mp4,现在要把它加密成encrypted.bin。注意这里我故意用.bin后缀,因为经过AES-CTR之后,文件不再是合法的MP4封装结构,你只能在拿到key之后才能还原成MP4再播放。
import subprocess import hashlib import os KEY_HEX = "0123456789abcdef0123456789abcdef" IV_HEX = "00112233445566778899aabbccddeeff" def aes_ctr_encrypt_file(input_path, output_path, key_hex, iv_hex): """用OpenSSL命令按AES-128-CTR方式加密整个文件""" cmd = [ "openssl", "enc", "-aes-128-ctr", "-K", key_hex, "-iv", iv_hex, "-in", input_path, "-out", output_path ] subprocess.run(cmd, check=True) def aes_ctr_decrypt_file(input_path, output_path, key_hex, iv_hex): """解密与加密是同一套函数,换名字便于阅读""" cmd = [ "openssl", "enc", "-d", "-aes-128-ctr", "-K", key_hex, "-iv", iv_hex, "-in", input_path, "-out", output_path ] subprocess.run(cmd, check=True) def md5(path): h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() if __name__ == "__main__": # 先转封装为合法MP4,再整体AES-CTR加密 subprocess.run([ "ffmpeg", "-y", "-i", "source.mp4", "-c", "copy", "plain.mp4" ], check=True) aes_ctr_encrypt_file("plain.mp4", "encrypted.bin", KEY_HEX, IV_HEX) print("加密完成,密文文件:encrypted.bin") aes_ctr_decrypt_file("encrypted.bin", "restored.mp4", KEY_HEX, IV_HEX) print("解密完成,恢复文件:restored.mp4") print("原始MD5:", md5("plain.mp4")) print("恢复MD5:", md5("restored.mp4"))这个方案的适用范围很清晰:它适合把整个视频文件当作一个黑盒去加密。优点是代码极短、逻辑一目了然、OpenSSL本身已经过大量安全审计;缺点是不支持随机访问,解密时必须整个文件都处理完,播放器的进度条会受到影响。所以我一般只在“文件陈旧了再统一加密归档”“保护仓库里的素材不被同事随手拷走”这些场景用这种整体加密。
如果你希望走DASH的CENC方式,保持文件可以边下边播,那就别自己调OpenSSL加密整个文件了,直接用FFmpeg的-encryption_scheme cenc-aes-ctr在封装层做,这样生成的是合法MP4分片,播放器能解析并自动请求解密。这两条路线要区分清楚,一个是“文件加密”,一个是“媒体加密”,虽然用到的密码学算法一样,但工程语义完全不同。
3.2 C语言结合libavformat与OpenSSL实现
做底层一点的集成时,C语言依然是绕不开的。好在FFmpeg的libavformat已经替你处理了CENC的加密和解密逻辑,你并不需要真的一帧一帧去加解密。这里我给出一个基于libavformat解码加密MP4的核心片段,以及一段结合OpenSSL EVP接口对数据做AES-CTR处理的最小实现。
先看libavformat解密部分,伪代码逻辑如下:
#include <libavformat/avformat.h> #include <libavutil/log.h> int open_decrypted(const char *path) { AVFormatContext *fmt = NULL; AVDictionary *opts = NULL; int ret; av_dict_set(&opts, "encryption_scheme", "cenc-aes-ctr", 0); av_dict_set(&opts, "encryption_key", "0123456789abcdef0123456789abcdef", 0); av_dict_set(&opts, "encryption_iv", "00112233445566778899aabbccddeeff", 0); ret = avformat_open_input(&fmt, path, NULL, &opts); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Failed to open encrypted input: %s\n", av_err2str(ret)); return ret; } ret = avformat_find_stream_info(fmt, NULL); if (ret < 0) { avformat_close_input(&fmt); return ret; } avformat_close_input(&fmt); return 0; }你看,核心就三行设置参数,FFmpeg会在解复用环节自动处理CENC数据。这里要注意两个细节:avformat_open_input的参数opts必须放在这里传,不能放到后面再设置,否则打不开加密流;另外key和iv都是十六进制字符串,和命令行完全一致,不要自行转换成二进制再传。
如果你想要一个完全自主控制的加密器,不依赖FFmpeg的movenc,那就要学会用OpenSSL EVP接口做AES-CTR。下面这个函数是我提炼的最小可运行版本:
#include <openssl/evp.h> #include <stdint.h> #include <stddef.h> int aes_ctr_crypt(const uint8_t *key, const uint8_t *iv, const uint8_t *in, uint8_t *out, size_t len) { EVP_CIPHER_CTX *ctx = NULL; int out_len = 0, tmp_len = 0; int ret = 0; ctx = EVP_CIPHER_CTX_new(); if (!ctx) return -1; if (EVP_EncryptInit_ex(ctx, EVP_aes_128_ctr(), NULL, key, iv) != 1) goto done; if (EVP_EncryptUpdate(ctx, out, &out_len, in, (int)len) != 1) goto done; if (EVP_EncryptFinal_ex(ctx, out + out_len, &tmp_len) != 1) goto done; out_len += tmp_len; ret = out_len; done: EVP_CIPHER_CTX_free(ctx); return ret; }一定要记住,在CTR模式下加密函数和解密函数其实可以共用同一套代码,你把EVP_EncryptInit_ex换成EVP_DecryptInit_ex也一样能工作,因为解密本质就是再异或一次。但为了代码可读性,我还是建议你区分封装两个函数,不然半年后自己回来看代码,容易怀疑是不是写错了。
3.3 源码关键点说明
上面这段C代码里最容易被忽略的是长度参数。EVP_EncryptUpdate的入参长度必须是int类型,所以我们做了一个强转。对于大文件,你不能一次性把所有数据都丢进去,因为int可能不够大,内存也不现实。正确做法是分块读取,例如每块4MB:
#define CHUNK_SIZE (4 * 1024 * 1024) FILE *fin = fopen("source.mp4", "rb"); FILE *fout = fopen("encrypted.bin", "wb"); uint8_t *inbuf = malloc(CHUNK_SIZE); uint8_t *outbuf = malloc(CHUNK_SIZE + EVP_MAX_BLOCK_LENGTH); size_t n; while ((n = fread(inbuf, 1, CHUNK_SIZE, fin)) > 0) { int outl = aes_ctr_crypt(key, iv, inbuf, outbuf, n); fwrite(outbuf, 1, outl, fout); // 注意:每块之间IV的计数器要手动递增, // 绝对不能在下一块又用同一个IV }增量计数器这件事,我多说两句。AES-CTR在内部会自己推进计数器,但如果你在代码里把一个大文件分多次调用加密函数,每次传的都是同一个IV,那么每一块都会生成相同的密钥流,加密出来的结果必然是一团乱麻。正确的做法是自己维护一个64位或128位的计数器状态,每处理一块就把IV里的计数器部分累加。FFmpeg之所以没让你管这些,因为它在解复用层面对分片做了内部状态管理;你自己写OpenSSL时,这口锅就只能自己背。
Python方案里还有一个容易踩的坑:openssl enc接受的-iv参数长度必须严格按照算法要求。AES-128的IV是16字节,十六进制就是32个字符。当你写00112233445566778899aabbccddeeff时没问题,但如果从某个配置文件里读出来带了一个换行符,OpenSSL会直接报错,而且报错信息还不太容易看懂。我建议在代码里先strip()再拼接命令,或者在调用前做一次正则校验:^[0-9a-fA-F]{32}$。
4. 常见问题排查与避坑实录
4.1 加密后播放器不识别
出现这个问题的概率在初学者里高得离谱。原因是很多人拿FFmpeg加密完的MP4直接拖进普通播放器,期望它弹出个输入框让你填key。但普通播放器根本不支持CENC,它认为这个文件要么损坏了,要么根本就不认识。加密后的MP4只适合配合支持CENC的解码器/播放器去播放,比如用FFmpeg命令、或者接入EME的Web播放器、以及部分定制播放器内核。
判断方法很简单:先用ffprobe查看加密文件能不能读出stream信息。
ffprobe encrypted.mp4如果ffprobe能列出视频流和音频流,说明加密只作用在sample payload,容器信息仍然正常。这时候用我们前面的解密命令还原,大概率能成功。如果ffprobe也报moov atom not found之类错误,那多半不是CENC加密,而是你用OpenSSL把整个文件都异或过了,容器层也乱了。
4.2 IV复用导致画面花屏
这是我在一个模拟点播项目里真实遇到过的。开发为了省事,让所有视频分片共用一个IV,结果用户播放到某些分片时,画面会出现马赛克感的花屏,但有些分片又正常。这其实就是CTR模式密钥流复用导致的信息泄漏和部分数据错乱。
解决思路非常朴素:给每个分片生成独立的随机IV。IV不需要保密,它甚至可以写在MPD或者M3U8索引里,播放器公开读取。你只需要保证“同一个key + 不同IV”或“同一个IV + 不同key”至少满足一个就行。我建议生成规则可以用视频文件ID加分片序号做哈希,再取前16字节,这样既能保证不重复,也方便调试时反推某一片用的哪个IV。
4.3 解密输出文件变大或变小
用-c copy加密再解密,文件大小理论上应该不变。我遇到过情况是,解密后的文件比原始文件大一点点,一般几十字节到几百字节,这多半是movenc在写MP4时重新生成了某些box,比如uuid box或者sample table的调整。不影响播放,但如果你做内容指纹比对,md5就会不一致。
解决办法是在加密前先把原文件的元数据规范一下:先用FFmpeg把source.mp4重新remux一次,然后再拿这个“干净版”去加密:
ffmpeg -y -i source.mp4 -c copy clean.mp4后续都以clean.mp4为基准做校验,这样加密、解密、比对都在同一个基准上,避免不同转封装工具产生的噪声。
4.4 随机访问和进度拖动失败
之前说过,CENC-AES-CTR是支持随机访问的,但前提是容器层面的sample index必须完整。如果你用DASH分片加密,每个分片自身要有关键帧,否则拖动时播放器找不到可解码的起始点。这个和加密算法无关,纯粹是编码参数问题。
我在实操中通常会配合-g设GOP大小,例如:
ffmpeg -y -i source.mp4 \ -c:v libx264 -g 48 -keyint_min 48 -sc_threshold 0 \ -encryption_scheme cenc-aes-ctr \ -encryption_key 0123456789abcdef0123456789abcdef \ -encryption_iv 00112233445566778899aabbccddeeff \ encrypted.mp4-g 48意思是每48帧一个关键帧。以25fps来算,差不多2秒一个关键帧,拖动响应就很流畅。-sc_threshold 0是为了防止编码器在场景切换时插入额外关键帧,导致GOP大小不可控。对于加密分片来说,GOP边界越稳定,分片切分越整齐。
5. 我的实操体会与建议
5.1 方案选型怎么取舍
写了这么多代码,最后给点实在的选择建议。如果你的目标是“整个文件加密,拖到播放器里输密码才能放”,那就老老实实走OpenSSL整体AES-CTR加密,简单直接还省事。如果你的目标是“做一个类似视频网站的在线点播系统,用户在浏览器里输入账号就能看”,那就选FFmpeg的CENC-AES-CTR转封装,再配一个DASH播放器。至于HLS,除非你的客户强制要求iOS Safari原生HLS,否则我不建议额外折腾,DASH的兼容范围和调试工具都在我这边更顺手。
5.2 密钥管理不能偷懒
很多系统做视频加密做到最后,攻破点都出在密钥管理上,而不是加密算法。密钥放在前端代码里、密钥写死在后端配置里、所有视频共用一个密钥,这三种情况我都见过。AES-CTR算法本身很安全,但如果你把key直接暴露给用户,那整个加密体系等于没有。
我个人的习惯是:密钥分成多个层级,主密钥永不出服务器,内容密钥用主密钥动态解密下发;每个视频文件的key独立生成,而且每个分片IV独立随机。这样就算某一条视频的key泄露了,影响范围也只限于那一条,不会波及整个内容库。
5.3 后续可以扩展的方向
这套AES-CTR方案学会以后,往后面扩展有不少方向。比如根据自己的业务把密钥接入内容分发网络,实现按地域、按用户组、按播放次数控制权限;又比如把解密能力封装成小程序插件或者浏览器SDK,做真正的端到端播放方案。我在实际项目里还曾经把CENC加密和DRM系统对接过,虽然DRM要额外引入许可证服务器,但原理上仍然是“容器加密+密钥授权”这套逻辑,和FFmpeg的CENC是完全兼容的。你如果已经看懂了这篇里的命令和代码,后面去碰DRM、EME这些东西,理解成本会低很多。