可以说的秘密-那些我们该讨论的前端加密方法

avatar
UX @京东
原文链接: mp.weixin.qq.com

随着信息安全重要性的日益凸显,如何保证用户数据的安全成为开发者重点关注的内容。目前,可供我们选择的加密方法有很多,我们需要根据实际的情况选择符合自己的安全解决方案。本文将介绍前端开发中常用的加密方法并给出其适用场景。

常用加密方法

1. Base64 编码

大家经常说的是 Base64 加密,有 Base64 加密吗?真木有,只有 Base64 编码。

Base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法,详见 [1]。常用于在通常处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的 email,email via MIME,在 XML 中存储复杂数据;主要用来解决把不可打印的内容塞进可打印内容的需求。很多编程语言中都内置了该编码方法,比如 Python 内置的 base64 方法使用如下:

  1. import base64;

  2. base64.b64encode('binary\x00string');

  3. //'YmluYXJ5AHN0cmluZw=='

  4. base64.b64decode('YmluYXJ5AHN0cmluZw==');

  5. //'binary\x00string'

因此,Base64 适用于小段内容的编码,比如数字证书签名、Cookie的内容等;而且 Base64 也是一种通过查表的编码方法,不能用于加密,如果需要加密,请使用专业的加密算法。

2. 哈希算法(Hash)

哈希(Hash)是将目标文本转换成具有固定长度的字符串(或叫做消息摘要)。当输入发生改变时,产生的哈希值也是完全不同的。从数学角度上讲,一个哈希算法是一个多对一的映射关系,对于目标文本 T,算法 H 可以将其唯一映射为 R,并且对于所有的 T,R 具有相同的长度,所以 H 不存在逆映射,也就是说哈希算法是不可逆的。

基于哈希算法的特性,其适用于该场景:被保护数据仅仅用作比较验证且不需要还原成明文形式。比较常用的哈希算法是 MD5 和 SHA1,详见 [2][3]。

我们比较熟悉的使用哈希存储数据的例子是:当我们登录某个已注册网站时,在忘记密码的情况下需要重置密码,此时网站会给你发一个随机的密码或者一个邮箱激活链接,而不是将之前的密码发给你,这就是因为哈希算法是不可逆的。

需要注意的是:在 Web 应用中,在浏览器中使用哈希加密的同时也要在服务端上进行哈希加密。

现在,对于简单的哈希算法的攻击方法主要有:寻找碰撞法和穷举法。所以,为了保证数据的安全,可以在哈希算法的基础上进一步的加密,常见的方法有:加盐、慢哈希、密钥哈希、XOR 等。

3. 加盐(Adding Salt)

加盐加密是一种对系统登录口令的加密方式,它实现的方式是将每一个口令同一个叫做“盐”(salt)的 n 位随机数相关联。以 sha1-hex 的使用方法为例:

  1. let salt = self.getCookie('salt') ? self.getCookie('salt') : uuid;

  2. let sign = Sha1hex(`${token}${taxpayerId}${self.state.submitParams.companyName}${uuid}${salt}`);//salt为盐值

盐值其实就是我们添加的一串随机字符串,如上所示,salt 值是随机的,

  1. ${token}${taxpayerId}${self.state.submitParams.companyName}${uuid}

这样处理之后,相同的字符串每次都会被加密为完全不同的字符串。

使用加盐加密时需要注意以下两点:

(1)短盐值(Short Slat)

如果盐值太短,攻击者可以预先制作针对所有可能的盐值的查询表。例如,如果盐值只有三个 ASCII 字符,那么只有 95x95x95=857,375 种可能性,加大了被攻击的可能性。还有,不要使用可预测的盐值,比如用户名,因为针对某系统用户名是唯一的且被经常用于其他服务。

(2)盐值复用(Salt Reuse)

在项目开发中,有时会遇到将盐值写死在程序里或者只有第一次是随机生成的,之后都会被重复使用,这种加盐方法是不起作用的。以登录密码为例,如果两个用户有相同的密码,那么他们就会有相同的哈希值,攻击者就可以使用反向查表法对每个哈希值进行字典攻击,使得该哈希值更容易被破解。

所以正确的加盐方法如下:

(1)盐值应该使用加密的安全伪随机数生成器( Cryptographically Secure Pseudo-Random Number Generator,CSPRNG )产生,比如 C 语言的 rand() 函数,这样生成的随机数高度随机、完全不可预测;

(2)盐值混入目标文本中,一起使用标准的加密函数进行加密;

(3)盐值要足够长(经验表明:盐值至少要跟哈希函数的输出一样长)且永不重复;

(4)盐值最好由服务端提供,前端取值使用。

4. 慢哈希函数(Slow Hash Function)

顾名思义,慢哈希函数是将哈希函数变得非常慢,使得攻击方法也变得很慢,慢到足以令攻击者放弃,而往往由此带来的延迟也不会引起用户的注意。降低攻击效率用到了密钥扩展( key stretching)的技术,而密钥扩展的实现使用了一种 CPU 密集型哈希函数( CPU-intensive hash function)。看起来有点晕~还是关注下该函数怎么用吧!

如果想在一个 Web 应用中使用密钥扩展,则需要设定较低的迭代次数来降低额外的计算成本。我们一般直接选择使用标准的算法来完成,比如 PBKDF2 或 bcrypt 。PHP、斯坦福大学的 JavaScript 加密库都包含了 PBKDF2 的实现,浏览器中则可以考虑使用 JavaScript 完成,否则这部分工作应该由服务端进行计算。

5. 密钥哈希

密钥哈希是将密钥添加到哈希加密,这样只有知道密钥的人才可以进行验证。目前有两种实现方式:使用 ASE 算法对哈希值加密、使用密钥哈希算法 HMAC 将密钥包含到哈希字符串中。为了保证密钥的安全,需要将其存储在外部系统(比如一个物理上隔离的服务端)。

即使选择了密钥哈希,在其基础上进行加盐或者密钥扩展处理也是很有必要。目前密钥哈希用于服务端比较多,例如来应对常见的 SQL 注入攻击。

6. XOR

XOR [4] 大家都不陌生,它指的是逻辑运算中的 “异或运算”。两个值相同时,返回 false,否则返回 true,用来判断两个值是否不同。

JavaScript 语言的二进制运算,有一个专门的 XOR 运算符,写作^。

  1. 1 ^ 1 // 0

  2. 0 ^ 0 // 0

  3. 1 ^ 0 // 1

  4. 0 ^ 1 // 1

XOR 运算有一个特性:如果对一个值连续做两次 XOR,会返回这个值本身。这也是其可以用于信息加密的根本。

  1. message XOR key // cipherText

  2. cipherText XOR key // message

目标文本 message,key 是密钥,第一次执行 XOR 会得到加密文本;在加密文本上再用 key 做一次 XOR 就会还原目标文本 message。为了保证 XOR 的安全,需要满足以下两点:

(1)key 的长度大于等于 message ;

(2)key 必须是一次性的,且每次都要随机产生。

下面以登录密码加密为例介绍下 XOR 的使用:

第一步:使用 MD5 算法,计算密码的哈希;

  1. const message = md5(password);

第二步:生成一个随机 key 值;

第三步:进行 XOR 运算,求出加密后的 message。

  1. function getXOR(message, key) {

  2.  const arr = [];

  3.  //假设 key 是32位的

  4.  for (let i = 0; i < 32; i++) {

  5.    const  m = parseInt(message.substr(i, 1), 16);

  6.    const k = parseInt(key.substr(i, 1), 16);

  7.    arr.push((m ^ k).toString(16));

  8.  }

  9.  return arr.join('');

  10. }

如上所示,使用 XOR 和一次性的密钥 key 对密码进行加密处理,只要 key 没有泄露,目标文本就不会被破解。

上面说了那么多,问题就来了:我们应该使用什么样的哈希算法呢?

(1)选择经过验证的成熟算法,如 PBKDF2 等 ;

(2)crypt 的安全版本;

(3)避免使用自己设计的加密算法。

有关哈希等算法介绍完了,下面来说下 加密(Encrypt)[5] 算法。

7. 加密(Encrypt)

不同于哈希,加密(Encrypt)是将目标文本转换成具有不同长度的、可逆的密文。也就是说加密算法是可逆的,而且其加密后生成的密文长度和明文本身的长度有关。从数学角度上讲的话: 一个加密算法是一个一对一的映射,其中第二个参数叫做加密密钥,E 可以将给定的明文 T 结合 Ke 唯一映射为密文 R,反过来,Kd 结合密文 R 也可以唯一映射为对应明文 T,其中 Kd 叫做解密密钥。

因此,如果被保护数据在以后需要被还原成明文,则需要使用加密。目前,我们用的比较多的是 crypto [5] 模块,它提供了安全相关的功能,如摘要运算、加密、电子签名等。在最近的 PC 端系统以及小程序项目中我们都用到了该模块里的加密算法。

例如,在最近开发的 PC 端的系统中,前端需要对用户的手机号、邮箱等敏感数据进行加密、解密处理,和后端协商之后选择了 AES 算法,封装了相应的加密、解密算法,如下所示:

  1. /**

  2. * AES 加密

  3. **/

  4. encrypt: function (data) {

  5.    let encrypted = CryptoJS.AES.encrypt(data, CryptoJS.enc.Utf8.parse(MAP.AuthTokenKey), {

  6.        mode: CryptoJS.mode.CBC,

  7.        padding: CryptoJS.pad.Pkcs7,

  8.        iv: CryptoJS.enc.Hex.parse(MAP.iv)

  9.    });

  10.    return encrypted.toString();

  11. }

  12. /**

  13. * AES 解密

  14. **/

  15. decrypt: function (data) {

  16.    let decrypted = CryptoJS.AES.decrypt(data, CryptoJS.enc.Utf8.parse(MAP.AuthTokenKey), {

  17.        mode: CryptoJS.mode.CBC,

  18.        padding: CryptoJS.pad.Pkcs7,

  19.        iv: CryptoJS.enc.Hex.parse(MAP.iv)

  20.    });

  21.    return decrypted.toString(CryptoJS.enc.Utf8);

  22. }

接下来分以下几方面简要介绍下 crypto 模块的内容:

(1)对称加密、非对称加密

根据加密、解密所用的密钥是否相同,可以将加密算法分为对称加密、非对称加密。

对称加密

密钥是相同的,即 encryptKey===decryptKey。常见的对称加密算法有 DES、AES 等,伪代码如下:

  1. encryptedText = encrypt(plainText, key); // 加密

  2. plainText = decrypt(encryptedText, key); // 解密

非对称加密

加密、解密所用的密钥是不同的,分为公钥和私钥,即 encryptKey!==decryptKey 。常见的非对称加密算法有 RSA、DSA 等,伪代码如下:

  1. encryptedText = encrypt(plainText, publicKey); // 加密

  2. plainText = decrypt(encryptedText, priviteKey); // 解密

对称加密与非对称加密除了密钥的不同之外,还有以下不同点:

① 对称加密的速度更快; ② 对称加密适用于加密长文本,非对称加密通常用于加密短文本。

(2)数字签名

数字签名主要用于确认信息来源于特定的主体且信息完整、未被篡改,发送方生成签名,接收方验证签名。

发送方首先计算目标文本的摘要(哈希值),通过私钥对摘要进行签名,将目标文本和电子签名发送给接收方。伪代码如下所示:

  1. digest = hash(message); // 计算摘要

  2. digitalSignature = sign(digest, priviteKey); // 计算数字签名

接收方验证签名的步骤如下:

① 通过公钥破解电子签名,得到摘要 D1 (如果失败,则信息来源主体校验失败); ② 计算目标文本摘要 D2; ③ 若 D1 === D2,则说明目标文本完整、未被篡改。

伪代码如下:

  1. digest1 = verify(digitalSignature, publicKey); // 获取摘要

  2. digest2 = hash(message); // 计算原始信息的摘要

  3. digest1 === digest2 // 验证是否相等

看起来和非对称加密有点像对不对,它们不一样!

① 非对称加密(加密/解密):公钥加密,私钥解密。 ② 数字签名(签名/验证):私钥签名,公钥验证。

(3)crypto 中常用API

大多数对称加密算法都采用了分组加密模式,比如上面我们用到的 AES。接下里有三个概念需要我们着重了解:模式、填充、初始化向量。

分组加密模式

分组加密指的是将(较长的)明文拆分成固定长度的块,然后对拆分的块按照特定的模式进行加密。常见的分组加密模式有ECB、CBC(最常用)、CFB等。

填充

假设定义每个块的长度为 128 位,那么采用分组拆分之后,最后一个数据块的长度可能小于 128 ,如果采用的是 ECB 或 CBC 模式,此时需要进行填充来满足长度要求。常用的填充方式是 PKCS7。

初始化向量

初始化向量(IV)主要是为了增强算法的安全性,部分分组加密模式里引入了 IV,这样可以使得加密结果随机化。以 CBC 为例,每一个数据块都与前一个加密块进行 XOR 运算后再加密(第一个数据块与 IV 进行 XOR 运算)。IV 的大小与数据块大小有关,如下图所示:

以上谈到的 API 在封装后的 AES 加密/解密算法都使用到了,crypto 模块中 还有很多没有提及的内容,有兴趣的同学可以自己学习。

小结

以上我们所介绍的都是相应算法的简要知识点,因为密码学本身是一门非常深奥的数学分支,作为开发者的我们无需太深入的学习,我们只需要了解每种算法的特性及适用场景,在有需要的时候灵活使用就可以了。值得注意的是,上面列出的都是在项目开发实践中学习 [7][8] 和用到的一些算法,内容有限;而且虽然前端在开发过程中做了一些加密,其实更重要的是服务端的加密处理,所以选择何种加密方法需要两端沟通决定。

敲黑板:以上说到的MD5、AES 等资源包都可以在我们的组件库 POPUI [9] 资源库中找到,欢迎大家使用。好了,关于前端开发中可以选择的加密方法就介绍到这里了,如有任何疑问,欢迎留言。

扩展阅读

[1] https://zh.wikipedia.org/zh/Base64

[2] https://en.wikipedia.org/wiki/MD5

[3] https://en.wikipedia.org/wiki/SHA-1

[4] https://en.wikipedia.org/wiki/XOR_gate

[5] https://www.drupal.org/project/encrypt

[6] https://nodejs.org/api/crypto.html

[7] http://www.cnblogs.com/chyingp/p/nodejs-learning-crypto-theory.html

[8] http://www.infoq.com/cn/articles/how-to-encrypt-the-user-password-correctly

[9] http://popui.jd.com/#/introduce