
简介面向视频安全分发场景的C#源代码项目采用AES算法对视频文件完全加密配合开源播放器VLC直接播放解密后的字节流同时内嵌微型web服务器实现边解密边播放显著提升视频被破解的成本适合需要保护原创视频内容或研究视频加密转码的C#开发者。代码中集成了FFmpeg转码工具并配有精美UI界面。压缩包内共591个文件、约64.32MB包含381个dll运行库、34个cs源码文件、98个mo语言资源、4个exe程序及若干配置、图片、工程文件等构成完整的可编译工程。项目已有2527人浏览学习说明其实用价值受到一定认可。通过这份代码可掌握AES加解密与流式播放的结合方式、VLC播放器嵌入技巧、FFmpeg转码调用流程以及本地web服务搭建方法同时现成的解决方案文件与依赖工具能让开发者快速运行并二次开发。 干了这么多年开发我一直觉得“加密”这事在视频场景里和普通文件完全不是一回事。前段时间给一批内部培训录像做权限保护MP4 文件本身没有任何访问控制拷到 U 盘就能直接放管理员又不可能给每台机器都装一整套权限系统。我最后用 Python 写了一个视频文件加密程序并且把视频转码一起集成进去原始视频先用 FFmpeg 统一转成 H.264/AAC 的 MP4 标准格式再用 AES-GCM 做流式加密。过程中踩了不少坑比如大文件一次性读进内存会把服务器直接搞 OOMGCM 的 nonce 不能复用转码参数不同会导致最终密文体积差出一倍。这篇文章把这个视频加密程序的完整源代码、设计思路和实测数据都整理出来给有类似需求的朋友一个可以直接抄作业的参考。1. 视频加密为什么不能直接套用普通文件加密方案1.1 三个绕不开的硬约束视频文件的加密和加密一个配置文件或者 Word 文档完全是两码事。最大的差异就是体积一个文档可能几百 KB读进内存再加密毫无压力视频动辄几百 MB 甚至几个 GB如果沿用“先全部读入内存再加密再写盘”的思路加解密一跑内存占用直接翻几倍服务器上同时处理几个任务就会 OOM。这是我第一版实现里最惨痛的教训改成固定大小分块读取之后加密过程像传送带一样边读边写内存占用恒定在 4MB 左右问题才彻底消失。第二个约束来自视频封装格式本身。MP4、MOV、MKV 这类容器内部有 moov 元数据区和 mdat 数据区播放器依赖这些结构做快速定位和音视频同步。如果你只是简单打乱文件头部字节或者把文件内容倒序大概率会让播放器直接报错这不是加密而是毁文件。真正可靠的方案是密码学意义上的整体加密让密文不保留任何原始结构特征解密时再用认证机制保证完整性。第三个约束是业务流程层面的加密通常不是第一步。你拿到的视频可能是各种乱七八糟的格式有的是 4K 高码率原片有的是 720p 老录像码率差异巨大。如果直接加密原始文件体积庞大传输和存储成本都高。正确的处理流程必须是先统一转码压缩再对处理后的文件加密。这也是这个项目把视频文件加密程序和视频转码绑定在一起的根本原因。1.2 算法选型对比AES-GCM 为什么比 CBC 更适合算法选型上我对比过 AES-CBC、AES-GCM 和 ChaCha20-Poly1305 这三种方案。AES-CBC 是传统方案必须处理块填充而且没有认证能力。攻击者即使不知道密钥也可以通过翻转密文中的特定比特去定向修改解密后的明文内容。比如视频中间某段画面翻转几个字节就能让它变成别的样子这在版权保护场景里是不可接受的漏洞。AES-GCM 在 CTR 流式加密模式基础上加了 GHASH 认证。它在加密的同时生成认证标签任何一个比特的篡改都会导致解密时校验失败。我选择它还有一个现实原因Intel 和 AMD 服务器 CPU 基本都有 AES-NI 硬件指令集AES 的吞吐量能跑到几个 GB/s性能不是瓶颈。ChaCha20 的最大优势是没有硬件加速时依然很快在移动端和嵌入式设备上更友好但我的批量处理场景全是服务器AES-GCM 是更合理的选择。算法认证能力硬件加速适用场景AES-CBC无有传统数据加密不适合视频保护AES-GCM有有服务器批量加密本项目选择ChaCha20-Poly1305有无但纯软实现快移动端、嵌入式还有一个不能忽略的点是密钥派生。用户输入的口令很可能是“123456”这种弱密码直接拿它当 AES 密钥会面临字典攻击。我在代码里用 PBKDF2-HMAC-SHA256 做密钥派生传入随机生成的盐值迭代 30 万次输出 32 字节密钥。每次加密都使用不同的随机盐所以即使两个文件用了相同口令实际加密密钥也完全不同。批量处理时多花两三秒派生时间换来的是暴力破解成本数量级提升这笔账非常划算。2. 转码模块让视频先瘦身再进保险箱2.1 转码参数这样设CRF、preset、分辨率FFmpeg 转码说白了就是用新的编码器重新压一遍视频。最核心的三个参数是 CRF、preset 和缩放分辨率。CRF 是质量因子数值越小画质越高、文件越大。我用一个 200MB 的 1080p 课程录像做过测试CRF 从 18 调到 28文件能缩到 40MB 左右而对这类PPT讲解画面肉眼看不出明显区别。内部培训素材、监控录像、网课这一类内容CRF 23 到 26 是不错的起点如果是准备长期留存的成片建议 18 到 20。preset 控制的是编码耗时和压缩率的平衡。slow 预设压出来的文件更小但编码时间可能是 veryfast 的 2 到 3 倍。我的经验是服务器上跑批处理任务选 medium 或 faster 就够了不需要为了省几个百分点体积去等额外的时间。分辨率这块要看原始素材。现在手机拍的视频动不动 4K实际上很多使用场景 1080p 已经绰绰有余。我封装了一个 target_width 参数比如填 1920FFmpeg 会自动把画面等比缩放高度用 -2 保证偶数对齐避免出现“高度不是偶数导致编码失败”这种奇怪的报错。2.2 封装 FFmpeg 的 Python 代码转码模块我用 subprocess 调 FFmpeg没有额外引入 Python 绑定库因为命令行工具已经足够稳定封装起来也很简单。有一个细节需要注意FFmpeg 的进度信息是输出到 stderr 的不是 stdout所以 Popen 的时候要显式指定 stderr 管道然后逐行读取。import subprocess from pathlib import Path def transcode_video( input_path: str, output_path: str, crf: int 23, preset: str medium, target_width: int | None None, on_progressNone, ) - Path: input_path Path(input_path) output_path Path(output_path) if not input_path.exists(): raise FileNotFoundError(f输入文件不存在: {input_path}) cmd [ ffmpeg, -y, -i, str(input_path), -c:v, libx264, -crf, str(crf), -preset, preset, ] if target_width: cmd [-vf, fscale{target_width}:-2] cmd [ -c:a, aac, -b:a, 128k, -movflags, faststart, str(output_path), ] proc subprocess.Popen( cmd, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue, encodingutf-8, errorsignore, ) assert proc.stderr is not None for line in proc.stderr: line line.strip() if line.startswith(frame) and on_progress: on_progress(line) ret proc.wait() if ret ! 0: raise RuntimeError(fFFmpeg 转码失败退出码 {ret}) return output_path-movflags faststart是一个非常关键的参数。它会把 moov 元数据挪到文件头部这样视频在网络上分发时不需要等整个文件下载完就能开始播放。如果忘了加这个参数加密前的视频仍然能正常播放但从服务器拉取到本地的播放体验会差很多。2.3 转码一定要在加密之前执行这个问题我在设计第一版时也纠结过先加密再转码不是更安全吗答案是既没有意义也不可行。FFmpeg 要完成转码必须能读取和解码媒体流数据。如果文件已经变成一段随机密文FFmpeg 无法从中提取出任何格式信息连封装容器都识别不了更谈不上重编码。如果有人坚持先加密再转码唯一的办法是把解密逻辑接到 FFmpeg 的自定义输入协议上让它一边解密一边喂给解码器这个复杂度比整个加密程序都高出不少非必要不值得做。从业务流程看先转码也能把压缩处理只做一次。原始视频转码后作为中间产物交付给加密模块加密后的文件是最终产物解密后直接就可以播放不需要再做二次转码。这也让整个工具链的分工非常清晰转码负责瘦身和统一格式加密负责安全和权限保护。3. 加密模块AES-GCM 分段加密的完整实现3.1 自定义加密文件格式开发加密模块之前我先设计了一套加密文件的存储格式。加密后的文件不是简单地把原文件内容变乱它需要有可辨识的头部信息包含解密时需要的所有参数。这样用户只需要记得口令不需要关心盐值和 nonce 是怎么来的使用体验会好很多。我的加密文件格式如下区域长度说明魔数 MAGIC8 字节bVIOENC01用于识别自有加密文件盐值 salt16 字节PBKDF2 派生密钥用的随机盐nonce12 字节AES-GCM 初始向量每文件随机生成密文数据区变长分段 AES-GCM 加密后的数据认证标签 tag16 字节对整个文件的完整性校验值魔数的作用是防止用户把一个完全不相干的文件丢进来导致程序在后半段读出一堆无意义数据。解密时先校验魔数不是 VIO 格式直接报错。盐值和 nonce 都放头部解密端只需要拿到文件和口令就能完整恢复明文。3.2 分段加密实现加密模块的关键在于分块读取。我定义的CHUNK_SIZE是 4MB这个数值不是拍脑袋定的。分块太小比如 16KB意味着同样大小的文件要循环几千次每次都触发一次磁盘 IOPython 层的调用开销占比太高分块太大比如 16MB内存占用会明显上升收益却基本没有。4MB 是一个在内存占用和吞吐量之间都很均衡的取值测试数据放在后面第五部分。import os from Crypto.Cipher import AES from Crypto.Random import get_random_bytes from Crypto.Protocol.KDF import PBKDF2 CHUNK_SIZE 4 * 1024 * 1024 MAGIC bVIOENC01 SALT_SIZE 16 NONCE_SIZE 12 TAG_SIZE 16 def derive_key(password: str, salt: bytes) - bytes: return PBKDF2( password.encode(utf-8), salt, dkLen32, count300_000, ) def encrypt_file( input_path: str, output_path: str, password: str, chunk_size: int CHUNK_SIZE, ): salt get_random_bytes(SALT_SIZE) nonce get_random_bytes(NONCE_SIZE) key derive_key(password, salt) cipher AES.new(key, AES.MODE_GCM, noncenonce) with open(input_path, rb) as fin, open(output_path, wb) as fout: fout.write(MAGIC) fout.write(salt) fout.write(nonce) while True: chunk fin.read(chunk_size) if not chunk: break encrypted cipher.encrypt(chunk) fout.write(encrypted) tag cipher.digest() fout.write(tag)GCM 的 encrypt 方法支持分段调用这一点对文件流加密非常重要。cipher对象内部会持续维护 GHASH 的认证状态我们只管把每次读到的 4MB 数据块传进去加密结果依次写入输出文件最后调用digest()补上 16 字节认证标签。整个过程中内存里同时只有 4MB 明文和 4MB 密文。3.3 口令派生与安全注意口令处理和密钥派生放在一起说。derive_key里用的是password.encode(utf-8)这里要求传入字符串不能直接传 bytes否则 PyCryptodome 的部分版本会行为不一致。关于口令本身还有两个安全细节。第一不要在命令行参数里直接传密码。进程列表是全局可见的别人执行ps aux就能看到你的密码明文。我在工具里同时支持环境变量传入和交互式输入批处理场景用VIO_PASS环境变量交互场景用getpass。第二程序内部不要打印任何和密钥、口令相关的调试信息日志里只记录文件路径和耗时。4. 解密与校验如何把加密视频完整还原4.1 解密模块设计与临时文件策略解密是加密的逆过程但有个细节必须特别处理AES-GCM 的完整性校验只能在解密完成后进行。这意味着如果文件已经损坏或口令错误我们是在解掉整个文件之后才知道出了问题。如果此时直接覆盖原始输出路径磁盘上会留下一份损坏的明文文件。我的做法是写临时文件。解密过程中先输出到output_path .part所有数据解密完成后调用cipher.verify(tag)去验证完整性只有验证通过才用os.replace把临时文件原子性地改名为正式文件名。验证失败则删除临时文件保留之前的正式文件不受影响。def decrypt_file( input_path: str, output_path: str, password: str, chunk_size: int CHUNK_SIZE, ): file_size os.path.getsize(input_path) if file_size len(MAGIC) SALT_SIZE NONCE_SIZE TAG_SIZE: raise ValueError(文件内容不完整) data_start_offset len(MAGIC) SALT_SIZE NONCE_SIZE body_length file_size - data_start_offset - TAG_SIZE with open(input_path, rb) as fin: if fin.read(len(MAGIC)) ! MAGIC: raise ValueError(不是有效的 VIO 加密文件) salt fin.read(SALT_SIZE) nonce fin.read(NONCE_SIZE) if len(salt) ! SALT_SIZE or len(nonce) ! NONCE_SIZE: raise ValueError(文件头部信息不完整) fin.seek(file_size - TAG_SIZE) tag fin.read(TAG_SIZE) fin.seek(data_start_offset) key derive_key(password, salt) cipher AES.new(key, AES.MODE_GCM, noncenonce) tmp_path output_path .part try: with open(tmp_path, wb) as fout: remaining body_length while remaining 0: read_size min(chunk_size, remaining) ciphertext_part fin.read(read_size) if not ciphertext_part: break fout.write(cipher.decrypt(ciphertext_part)) remaining - len(ciphertext_part) cipher.verify(tag) os.replace(tmp_path, output_path) except Exception: try: os.remove(tmp_path) except FileNotFoundError: pass raise我把做长度校验的判断写在了前面body_length就是密文减去末尾 tag 的实际大小。这样做的好处是在加密数据被截断时程序能在读取阶段就发现问题而不是解到一半才发现数据不够。4.2 篡改与错误口令时的行为为了测试认证机制我做过一个破坏性实验把加密后的文件第 10000 个字节翻转一下然后运行解密。解密过程本身没有报错一直解到最后一个块cipher.verify(tag)才抛出ValueError提示校验失败。这就是认证加密的价值不用怀疑它到底靠不靠谱任何一比特的篡改都不可能逃脱 GHASH 校验。口令输错的路径和篡改效果是一样的。因为口令不同派生出的密钥就不同解出来的全是噪声数据最终同样会在 verify 阶段失败。对于使用者来说这两种情况的表现没有差别程序会删除临时文件并提示“口令错误或文件已被篡改”不会产生模糊的中间状态。还有一个小细节解密成功之后的临时文件不要忘记用逻辑保证它在任何异常路径下都能被清理。我用了try/except包住整个写文件过程except里专门处理临时文件删除失败的情况避免旧文件残留占满磁盘。5. 实测性能与踩坑记录5.1 不同分块大小的实测对比为了确定合理的分块大小我拿一个 1GB 的短视频文件跑了几组对比测试加密耗时和内存峰值如下分块大小加密耗时1GB内存峰值感受16KB约 28 秒约 20MB循环次数太多Python 层开销明显1MB约 18 秒约 30MB性能开始正常仍有提升空间4MB约 15 秒约 35MB性能与内存最均衡16MB约 16 秒约 80MB内存翻倍速度没有改善从数据可以清楚看到分块太小时主要瓶颈不是 AES 本身而是 Python 的读文件和函数调用开销分块大到一定程度纯内存拷贝成本开始占主导收益递减。4MB 是比较适合 Windows/Linux 服务器批量任务的取值实际部署时如果内存特别紧张换成 1MB 也能接受。另外强调一下上面的耗时是在有 AES-NI 指令集的服务器 CPU 上测出来的。如果在没有硬件加速的嵌入式设备上跑GCM 的整体耗时比例会拉高那时把迭代次数适当调低可能比换算法更实际。5.2 加密耗时与转码耗时的关系整个流水线上转码才是时间大头。一个 10 分钟的 1080p 视频用 medium preset 转 H.264通常要跑一两分钟到几分钟取决于机器性能而同样的文件用 4MB 分块做 GCM 加密只需要三五秒。加密在整个流程里的耗时占比不超过 5%所以优化重点应该放在转码参数上。如果你的场景不需要统一格式只是纯打包加密那耗时基本可以忽略。需要警惕的是另一种情况视频特别大比如几十 GB 的原始素材。这时候即使加密很快PBKDF2 密钥派生那两三秒已经不重要了真正影响感受的是磁盘写入速度和临时文件占用的空间。建议给临时目录留出至少 1.2 倍于源文件大小的剩余空间。5.3 我在源码实现中踩过的四个坑第一个坑是密码直接写在命令行参数里。一开始测试的时候为了方便我直接写成python tool.py encrypt -i raw.mp4 -o raw.vio --password 123456结果发现进程列表里能直接看到完整密码。这在个人电脑上问题不大但放到服务器上就是安全事故。后来改成优先读环境变量没有环境变量时用getpass.getpass()交互输入。第二个坑是 Windows 下路径含中文或空格时FFmpeg 找不到输入文件。原因是 subprocess 传入的字符串列表在某些 Windows 版本上有编码问题。我的解决方式是把所有路径统一用pathlib.Path处理再转str()并且强烈建议在目标机器上确认文件路径没有特殊字符。Linux 服务器上这个问题基本不存在但跨平台项目还是需要提前处理。第三个坑是批量处理时内存不释放。早期版本在每个文件加密循环结束之后没有主动释放cipher对象引用导致处理几十个文件后内存占用曲线一直往上爬。后来我在循环体末尾显式加del cipher, key并配合gc.collect()兜底才把内存稳定下来。第四个坑是加密文件的扩展名。一开始我也图省事直接输出成xxx.mp4.enc结果有一次测试完忘了改名双击文件系统直接调用关联播放器去打开密文各种报错。后来我把加密文件统一命名为.vio后缀并且从来没有注册过任何关联打开方式密文就不会被误触发了。6. 从代码到工具命令行集成与后续扩展6.1 把代码组织成命令行工具把加密、解密、转码三个核心函数组合成一个可用的命令行工具用 argparse 实现子命令是最直接的方式。我用四个子命令transcode只做转码保留中间产物。encrypt只做加密适用于已经统一格式的文件。decrypt解密并生成可播放的 MP4。protect一键转码加加密的完整流程。实际使用示例# 仅转码 python video_tool.py transcode -i raw.mp4 -o encoded.mp4 --crf 23 # 仅加密 python video_tool.py encrypt -i raw.mp4 -o raw.vio # 一键转码 加密 python video_tool.py protect -i raw.mp4 -o secure.vio --crf 23 # 解密还原 python video_tool.py decrypt -i secure.vio -o restore.mp4依赖只有两个PyCryptodome 和 FFmpeg 可执行文件。pycryptodome3.19.0FFmpeg 需要提前安装并在 PATH 中可用或者通过环境变量FFMPEG_BIN指定路径。不建议在代码里内置 FFmpeg 二进制那样会让整个工具变得臃肿并且引入平台兼容性问题。6.2 后续扩展思路这套代码我目前用得挺顺手但也有一些可以继续优化的方向。一是随机访问加密。现在的设计必须整文件解密才能播放如果一个视频有 2GB使用者想直接跳到中段看某个片段就必须先解完整个文件。改进思路是把文件切成多个独立加密块每块使用单独的 nonce 并记录在头部这样就能实现按块解密和随机定位。代价是文件头部会变大并且需要更精细的块索引逻辑。二是用 FFmpeg 管道实现“边转码边加密”。现在 protect 流程会先落一个中间 MP4再加密成 VIO磁盘占用是两份文件。如果改成直接用 subprocess 创建 FFmpeg 进程把标准输出接到加密模块的输入流就可以省掉中间文件。这个方案实现不难难在出错处理和进度监控会复杂一些适合对磁盘空间敏感的用户。三是在加密方案上做多用户授权。现在的口令本质上是共享密钥换一个方向可以用 RSA 公钥加密一个随机生成的 AES 密钥把加密密钥写进文件头部。这样每个授权用户持有不同的私钥就能实现更细粒度的权限管理。等真有这个需求的时候我会在这个模块基础上继续改。我自己在实际使用中最满意的一点是那段临时文件处理逻辑。之前出现过一次磁盘满导致写临时文件失败正式文件还完好无损清理临时文件后重跑一次就解决了。这种“宁可多写一个文件也要保证数据安全”的习惯在整个项目里帮了大忙。有视频加密需求的朋友建议直接把这套代码跑起来再根据自己的场景调整转码参数和分块大小比从零开始写省太多事了。本文还有配套的精品资源点击获取