Golang 中官方包提供的功能基本都非常基本,对于一些特殊的要求,很多时候需要自己去实现。如果只是对于一个已知长度的数据块(比如文件、已知长度的字节数组)进行加解密的操作,那么根本不需要实现太过复杂的功能。
这里我主要以在下载加密的 m3u8 视频时,边写边解密以提升效率为目的,实现了一个能够去除 padding 的 CBC Writer。
https://github.com/keytouch/m3u8dl/tree/master/cbcio
在写这个包之前,我也查过一些 gist 上别人贴出来的实现,但我看到的实现方式都是在写入方法中是一个循环解密,但实际上 Go 提供的 cipher.BlockMode
的 CryptBlocks
方法是支持多块一次性加解密的,并且对于长块的解密做了很大的优化。
CBC加解密的原理
CBC (Cipher Block Chaining) 直译就是 加密块 链。也就是在加密的过程中,前一段的密文参与后一段的加密过程,先和明文做异或运算。这个参数就是 IV 初始化向量,IV 的长度跟块大小一样。
我们重点关注解密的过程,要解密,首先需要用密钥做块解密,之后是和 IV 进行异或运算,还原出明文。这里其实我们已经可以发现:我们每次如果顺着自然顺序,从前向后解密整个“块”链,那么我们如果用两块不同的源内存和目标内存来进行解密的话,那么确实不会造成任何问题,只要每次去取上一个原始密文的值就行了。
要节省内存?在同一块内存上就地完成解密,用现在的模式就需要将解密前的原始密文保存在一个临时内存中。但马上就会发现,这完全可以避免。我们从后往前解密,不就解决了嘛!原本的 lookbehind 操作就变成了 lookahead 操作。CryptBlocks
方法的源码:
|
|
可以看到,Decrypt
方法是只负责解密的,只能操作单块,从最后一块开始,没解密完一块,不论源切片和目标切片的真实内存是不是同一块内存,都可以在接下来的 xorBytes
中直接 lookahead 取得前一块的密文(和当前块异或,得到明文)。
这种做法只需要考虑一种特殊情况:接收进来的密文的第一块,IV 需要特殊处理,并且接收进来的密文的最后一块的密文需要保存下来,供下次调用时作为下一段的 IV。
|
|
那么这样一看,如果我们在调用 CryptBlocks
时还来个循环调用,那真的是种浪费了。
我的解密过程实现
直接贴代码:
|
|
这里主要对头尾做特殊处理,先计算新传入字节串的头部和上次缓存着的拼接后的长度,如果超过一块了,那么就去解密,并且将多余的不满一块的内容保存下来。
PKCS7 unpadding
PKCS7 的填充原理
PKCS7 的填充,粗略看过其实觉得挺简单优雅,也确实解决了非整块数据加密的问题。在原始数据之后追加重复字节,字节值就是欲添加的长度。
但很快我就有了疑问,如果这数据本来就是整块的呢?更甚的,如果这整块的数据的最后一块恰好满足 unpadding 的需要呢?比如:
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
-----------------------------------------------
12 34 56 78 12 34 56 78 08 08 08 08 08 08 08 08
但其实,PKCS7规定,必须 padding!也就是,解密得到的明文的最后一定可以 妥妥 地 unpadding 掉!
思考一下,我得到的其实也是同样的方案,无论如何都加上这填充,解密的时候不就可以肆无忌惮地去除这段填充了嘛!
实现
相应的,只要在最后调用这个 Writer
的上下文中判断到输入流已经到了结尾了,那么就调用这个 Final
方法,判断一下 padding 的合法性并且截断到正确的位置写到指定的上游 Writer
。
在这里,核心的逻辑就是对 小于等于块大小 的块做维护,需要滞后写入这一块的内容,因为在这个 Writer
中我们无从获知是不是到了文件结尾,滞后最后一块的写入,就解决了问题。
|
|