02 图片加解密二三事

近来公司新项目管理后台需要做图片上传并加解密功能,加密在服务端进行,加密成功后返回加密后图片地址,后台负责解密在线图片然后预览,折腾一天,此中曲折,闲做记录。

高级加密标准 AES

高级加密标准(Advanced Encryption Standard: AES)是美国国家标准与技术研究院(NIST)在 2001 年建立了电子数据的加密规范。它是一种分组加密标准,每个加密块大小为 128 位,允许的密钥长度为 128、192 和 256 位。

AES 加密有 ECB、CBC、CFB 和 OFB 多种加密模式,各种模式功用各不同。

密码学中,分组(block)密码的工作模式(mode of operation)允许使用同一个分组密码密钥对多于一块的数据进行加密,并保证其安全性。分组密码自身只能加密长度等于密码分组长度的单块数据,若要加密变长数据,则数据必须先被划分为一些单独的密码块。通常而言,最后一块数据也需要使用合适填充方式将数据扩展到匹配密码块大小的长度。一种工作模式描述了加密每一数据块的过程,并常常使用基于一个通常称为初始化向量的附加输入值以进行随机化,以保证安全。

ECB 模式

ECB 模式(电子密码本模式:Electronic codebook)是最简单的块密码加密模式,加密前根据加密块大小(如 AES 为 128 位)分成若干块,之后将每块使用相同的密钥单独加密,解密同理。

ECB 模式最大的缺点是相同的明文块会被加密成相同的密文块,这种方法在某些环境下不能提供严格的数据保密性。

ECB 加密
ECB 解密

CBC 模式

CBC 模式(密码分组链接:Cipher-block chaining)对于每个待加密的密码块在加密前会先与前一个密码块的密文异或然后再用加密器加密。在这种方法中,每个密文块都依赖于它前面的所有明文块。同时,为了保证每条消息的唯一性,第一个明文块与一个叫 初始化向量 的数据块异或。

CBC 加密
CBC 解密

CBC 是最为常用的工作模式。CBC 模式相比 ECB 有更高的保密性,但由于对每个数据块的加密依赖与前一个数据块的加密所以加密无法并行。与 ECB 一样在加密前需要对数据进行填充,不是很适合对流数据进行加密。

加密时,明文中的微小改变会导致其后的全部密文块发生改变,而在解密时,从两个邻接的密文块中即可得到一个明文块。因此,解密过程可以被并行化,而解密时,密文中一位的改变只会导致其对应的明文块完全改变和下一个明文块中对应位发生改变,不会影响到其它明文的内容。

CFB 模式

CFB 模式(密文反馈:Cipher feedback)模式类似于 CBC,可以将块密码变为自同步的流密码;工作过程亦非常相似,CFB 的解密过程几乎就是颠倒的 CBC 的加密过程。

CFB 加密
CFB 解密

注意:CFB、OFB 和 CTR 模式中解密也都是用的加密器而非解密器。 CFB 的加密工作分为两部分:

  1. 将一前段加密得到的密文再加密;

  2. 将第 1 步加密得到的数据与当前段的明文异或。

由于加密流程和解密流程中被块加密器加密的数据是前一段密文,因此即使明文数据的长度不是加密块大小的整数倍也是不需要填充的,这保证了数据长度在加密前后是相同的。

与 CBC 相似,明文的改变会影响接下来所有的密文,因此加密过程不能并行化;而同样的,与 CBC 类似,解密过程是可以并行化的。在解密时,密文中一位数据的改变仅会影响两个明文块:对应明文块中的一位数据与下一块中全部的数据,而之后的数据将恢复正常。

OFB 模式

OFB 模式(输出反馈:Output feedback)是先用块加密器生成密钥流(Keystream),然后再将密钥流与明文流异或得到密文流,解密是先用块加密器生成密钥流,再将密钥流与密文流异或得到明文,由于异或操作的对称性所以加密和解密的流程是完全一样的。

OFB 加密
OFB 解密

每个使用 OFB 的输出块与其前面所有的输出块相关,因此不能并行化处理。然而,由于明文和密文只在最终的异或过程中使用,因此可以事先对 IV 进行加密,最后并行的将明文或密文进行并行的异或处理。

采坑

图片上传后服务端采用的是 AES-256-CBC 加密方式,故此后台也须采用同样的解密方式。通过创建 XMLHttpRequest 请求访问加密图片链接,并设置 responseType 为 arraybuffer 便可得到加密后的图片流,然后将流转换为 base64,采用 crypto-js 解密,将解密后的流重新转为 base64 图片。

总体过程如下:

  1. 创建 XMLHttpRequest 请求图片流;

  2. 将图片流 utf8 解码后再转换为 base64;

  3. 采用 crypto-js 解密;

  4. 将解密后的流转为 base64 图片。

在将图片流 utf8 解码时踩了坑,一开始 buffer 解码时采用如下方法:

let base64String = String.fromCharCode(...new Uint8Array(buffer))

报错 Uncaught RangeError: Maximum call stack size exceeded

搜索到 stackflow 同款问题:Converting arraybuffer to string : Maximum call stack size exceeded

The error is caused by a limitation in the number of function arguments.

如上所述,报错原因是由于函数参数过长导致。在该问题下找到解决方法:Uint8Array to string in Javascript

let base64String = new TextDecoder('utf-8').decode(buffer)

TextEncoder and TextDecoder from the Encoding standard, which is polyfilled by the stringencoding library, converts between strings and ArrayBuffers。

查询 MDN web docs 对 TextDecoder 文档说明:

The TextDecoder interface represents a decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points.

大功告成

完整图片解密代码如下:

const CryptoJS = require('crypto-js')
let key = '95362058623512345678901234567890'
let iv = '0473bd1234567890'
key = CryptoJS.enc.Utf8.parse(key)
iv = CryptoJS.enc.Utf8.parse(iv)
export function decrypt(url) {
if (!url) return
return new Promise(resolve => {
let xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'arraybuffer'
xhr.setRequestHeader('Access-Control-Allow-Origin', '*')
xhr.onload = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
let base64Img = process(xhr.response)
resolve(base64Img)
}
}
}
xhr.send()
})
}
function process(buffer) {
// 将 buffer 转换为base64
let view = new TextDecoder('utf-8').decode(buffer)
let base64String = view.toString(CryptoJS.enc.Base64)
// 解密
let decryptedData = CryptoJS.AES.decrypt(base64String, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
})
// 把解密后的对象再转为 base64 编码
let d64 = decryptedData.toString(CryptoJS.enc.Base64)
let imgSrc = 'data:image/png;base64,' + d64
return imgSrc
}