<audio id="audio" title="30 | 如何正确保存和传输敏感数据？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/d6/63978f5797af79797ed9fbbe6b1596d6.mp3"></audio>

你好，我是朱晔。

今天，我们从安全角度来聊聊用户名、密码、身份证等敏感信息，应该怎么保存和传输。同时，你还可以进一步复习加密算法中的散列、对称加密和非对称加密算法，以及HTTPS等相关知识。

## 应该怎样保存用户密码？

最敏感的数据恐怕就是用户的密码了。黑客一旦窃取了用户密码，或许就可以登录进用户的账号，消耗其资产、发布不良信息等；更可怕的是，有些用户至始至终都是使用一套密码，密码一旦泄露，就可以被黑客用来登录全网。

为了防止密码泄露，最重要的原则是不要保存用户密码。你可能会觉得很好笑，不保存用户密码，之后用户登录的时候怎么验证？其实，我指的是**不保存原始密码，这样即使拖库也不会泄露用户密码。**

我经常会听到大家说，不要明文保存用户密码，应该把密码通过MD5加密后保存。这的确是一个正确的方向，但这个说法并不准确。

首先，MD5其实不是真正的加密算法。所谓加密算法，是可以使用密钥把明文加密为密文，随后还可以使用密钥解密出明文，是双向的。

而MD5是散列、哈希算法或者摘要算法。不管多长的数据，使用MD5运算后得到的都是固定长度的摘要信息或指纹信息，无法再解密为原始数据。所以，MD5是单向的。**最重要的是，仅仅使用MD5对密码进行摘要，并不安全**。

比如，使用如下代码在保持用户信息时，对密码进行了MD5计算：

```
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//密码字段使用MD5哈希后保存
userData.setPassword(DigestUtils.md5Hex(password));
return userRepository.save(userData);

```

通过输出，可以看到密码是32位的MD5：

```
&quot;password&quot;: &quot;325a2cc052914ceeb8c19016c091d2ac&quot;

```

到某MD5破解网站上输入这个MD5，不到1秒就得到了原始密码：

<img src="https://static001.geekbang.org/resource/image/e1/de/e1b3638dea64636494c3dcb0bb9b8ade.png" alt="">

其实你可以想一下，虽然MD5不可解密，但是我们可以构建一个超大的数据库，把所有20位以内的数字和字母组合的密码全部计算一遍MD5存进去，需要解密的时候搜索一下MD5就可以得到原始值了。这就是字典表。

目前，有些MD5解密网站使用的是彩虹表，是一种使用时间空间平衡的技术，即可以使用更大的空间来降低破解时间，也可以使用更长的破解时间来换取更小的空间。

**此外，你可能会觉得多次MD5比较安全，其实并不是这样**。比如，如下代码使用两次MD5进行摘要：

```
userData.setPassword(DigestUtils.md5Hex(DigestUtils.md5Hex( password)));

```

得到下面的MD5：

```
&quot;password&quot;: &quot;ebbca84993fe002bac3a54e90d677d09&quot;

```

也可以破解出密码，并且破解网站还告知我们这是两次MD5算法：

<img src="https://static001.geekbang.org/resource/image/ce/b1/ce87f65a3289e50d4e29754073b7eab1.png" alt="">

所以直接保存MD5后的密码是不安全的。一些同学可能会说，还需要加盐。是的，但是加盐如果不当，还是非常不安全，比较重要的有两点。

第一，**不能在代码中写死盐，且盐需要有一定的长度**，比如这样：

```
userData.setPassword(DigestUtils.md5Hex(&quot;salt&quot; + password));

```

得到了如下MD5：

```
&quot;password&quot;: &quot;58b1d63ed8492f609993895d6ba6b93a&quot;

```

对于这样一串MD5，虽然破解网站上找不到原始密码，但是黑客可以自己注册一个账号，使用一个简单的密码，比如1：

```
&quot;password&quot;: &quot;55f312f84e7785aa1efa552acbf251db&quot;

```

然后，再去破解网站试一下这个MD5，就可以得到原始密码是salt，也就知道了盐值是salt：

<img src="https://static001.geekbang.org/resource/image/32/ca/321dfe5822da9fe186b17f283bda1fca.png" alt="">

其实，知道盐是什么没什么关系，关键的是我们是在代码里写死了盐，并且盐很短、所有用户都是这个盐。这么做有三个问题：

- 因为盐太短、太简单了，如果用户原始密码也很简单，那么整个拼起来的密码也很短，这样一般的MD5破解网站都可以直接解密这个MD5，除去盐就知道原始密码了。
- 相同的盐，意味着使用相同密码的用户MD5值是一样的，知道了一个用户的密码就可能知道了多个。
- 我们也可以使用这个盐来构建一张彩虹表，虽然会花不少代价，但是一旦构建完成，所有人的密码都可以被破解。

**所以，最好是每一个密码都有独立的盐，并且盐要长一点，比如超过20位**。

第二，**虽然说每个人的盐最好不同，但我也不建议将一部分用户数据作为盐。**比如，使用用户名作为盐：

```
userData.setPassword(DigestUtils.md5Hex(name + password));

```

如果世界上所有的系统都是按照这个方案来保存密码，那么root、admin这样的用户使用再复杂的密码也总有一天会被破解，因为黑客们完全可以针对这些常用用户名来做彩虹表。**所以，盐最好是随机的值，并且是全球唯一的，意味着全球不可能有现成的彩虹表给你用。**

正确的做法是，使用全球唯一的、和用户无关的、足够长的随机值作为盐。比如，可以使用UUID作为盐，把盐一起保存到数据库中：

```
userData.setSalt(UUID.randomUUID().toString());
userData.setPassword(DigestUtils.md5Hex(userData.getSalt() + password));

```

并且每次用户修改密码的时候都重新计算盐，重新保存新的密码。你可能会问，盐保存在数据库中，那被拖库了不是就可以看到了吗？难道不应该加密保存吗？

在我看来，盐没有必要加密保存。盐的作用是，防止通过彩虹表快速实现密码“解密”，如果用户的盐都是唯一的，那么生成一次彩虹表只可能拿到一个用户的密码，这样黑客的动力会小很多。

**更好的做法是，不要使用像MD5这样快速的摘要算法，而是使用慢一点的算法**。比如Spring Security已经废弃了MessageDigestPasswordEncoder，推荐使用BCryptPasswordEncoder，也就是[BCrypt](https://en.wikipedia.org/wiki/Bcrypt)来进行密码哈希。BCrypt是为保存密码设计的算法，相比MD5要慢很多。

写段代码来测试一下MD5，以及使用不同代价因子的BCrypt，看看哈希一次密码的耗时。

```
private static BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

@GetMapping(&quot;performance&quot;)
public void performance() {
    StopWatch stopWatch = new StopWatch();
    String password = &quot;Abcd1234&quot;;
    stopWatch.start(&quot;MD5&quot;);
    //MD5
    DigestUtils.md5Hex(password);
    stopWatch.stop();
    stopWatch.start(&quot;BCrypt(10)&quot;);
    //代价因子为10的BCrypt
    String hash1 = BCrypt.gensalt(10);
    BCrypt.hashpw(password, hash1);
    System.out.println(hash1);
    stopWatch.stop();
    stopWatch.start(&quot;BCrypt(12)&quot;);
    //代价因子为12的BCrypt
    String hash2 = BCrypt.gensalt(12);
    BCrypt.hashpw(password, hash2);
    System.out.println(hash2);
    stopWatch.stop();
    stopWatch.start(&quot;BCrypt(14)&quot;);
    //代价因子为14的BCrypt
    String hash3 = BCrypt.gensalt(14);
    BCrypt.hashpw(password, hash3);
    System.out.println(hash3);
    stopWatch.stop();
    log.info(&quot;{}&quot;, stopWatch.prettyPrint());
}

```

可以看到，MD5只需要0.8毫秒，而三次BCrypt哈希（代价因子分别设置为10、12和14）耗时分别是82毫秒、312毫秒和1.2秒：

<img src="https://static001.geekbang.org/resource/image/13/46/13241938861dd3ca9ba984776cc90846.png" alt="">

也就是说，如果制作8位密码长度的MD5彩虹表需要5个月，那么对于BCrypt来说，可能就需要几十年，大部分黑客应该都没有这个耐心。

我们写一段代码观察下，BCryptPasswordEncoder生成的密码哈希的规律：

```
@GetMapping(&quot;better&quot;)
public UserData better(@RequestParam(value = &quot;name&quot;, defaultValue = &quot;zhuye&quot;) String name, @RequestParam(value = &quot;password&quot;, defaultValue = &quot;Abcd1234&quot;) String password) {
    UserData userData = new UserData();
    userData.setId(1L);
    userData.setName(name);
    //保存哈希后的密码
    userData.setPassword(passwordEncoder.encode(password));
    userRepository.save(userData);
    //判断密码是否匹配
    log.info(&quot;match ? {}&quot;, passwordEncoder.matches(password, userData.getPassword()));
    return userData;
}

```

我们可以发现三点规律。

第一，我们调用encode、matches方法进行哈希、做密码比对的时候，不需要传入盐。**BCrypt把盐作为了算法的一部分，强制我们遵循安全保存密码的最佳实践。**

第二，生成的盐和哈希后的密码拼在了一起：`$`是字段分隔符，其中第一个`$`后的2a代表算法版本，第二个`$`后的10是代价因子（默认是10，代表2的10次方次哈希），第三个`$`后的22个字符是盐，再后面是摘要。所以说，我们不需要使用单独的数据库字段来保存盐。

```
&quot;password&quot;: &quot;$2a$10$wPWdQwfQO2lMxqSIb6iCROXv7lKnQq5XdMO96iCYCj7boK9pk6QPC&quot;
//格式为：$&lt;ver&gt;$&lt;cost&gt;$&lt;salt&gt;&lt;digest&gt;

```

第三，代价因子的值越大，BCrypt哈希的耗时越久。因此，对于代价因子的值，更建议的实践是，根据用户的忍耐程度和硬件，设置一个尽可能大的值。

最后，我们需要注意的是，虽然黑客已经很难通过彩虹表来破解密码了，但是仍然有可能暴力破解密码，也就是对于同一个用户名使用常见的密码逐一尝试登录。因此，除了做好密码哈希保存的工作外，我们还要建设一套完善的安全防御机制，在感知到暴力破解危害的时候，开启短信验证、图形验证码、账号暂时锁定等防御机制来抵御暴力破解。

## 应该怎么保存姓名和身份证？

我们把姓名和身份证，叫做二要素。

现在互联网非常发达，很多服务都可以在网上办理，很多网站仅仅依靠二要素来确认你是谁。所以，二要素是比较敏感的数据，如果在数据库中明文保存，那么数据库被攻破后，黑客就可能拿到大量的二要素信息。如果这些二要素被用来申请贷款等，后果不堪设想。

之前我们提到的单向散列算法，显然不适合用来加密保存二要素，因为数据无法解密。这个时候，我们需要选择真正的加密算法。可供选择的算法，包括对称加密和非对称加密算法两类。

对称加密算法，是使用相同的密钥进行加密和解密。使用对称加密算法来加密双方的通信的话，双方需要先约定一个密钥，加密方才能加密，接收方才能解密。如果密钥在发送的时候被窃取，那么加密就是白忙一场。因此，这种加密方式的特点是，加密速度比较快，但是密钥传输分发有泄露风险。

非对称加密算法，或者叫公钥密码算法。公钥密码是由一对密钥对构成的，使用公钥或者说加密密钥来加密，使用私钥或者说解密密钥来解密，公钥可以任意公开，私钥不能公开。使用非对称加密的话，通信双方可以仅分享公钥用于加密，加密后的数据没有私钥无法解密。因此，这种加密方式的特点是，加密速度比较慢，但是解决了密钥的配送分发安全问题。

但是，对于保存敏感信息的场景来说，加密和解密都是我们的服务端程序，不太需要考虑密钥的分发安全性，也就是说使用非对称加密算法没有太大的意义。在这里，我们使用对称加密算法来加密数据。

接下来，我就重点与你说说对称加密算法。对称加密常用的加密算法，有DES、3DES和AES。

虽然，现在仍有许多老项目使用了DES算法，但我不推荐使用。在1999年的DES挑战赛3中，DES密码破解耗时不到一天，而现在DES密码破解更快，使用DES来加密数据非常不安全。因此，**在业务代码中要避免使用DES加密**。

而3DES算法，是使用不同的密钥进行三次DES串联调用，虽然解决了DES不够安全的问题，但是比AES慢，也不太推荐。

AES是当前公认的比较安全，兼顾性能的对称加密算法。不过严格来说，AES并不是实际的算法名称，而是算法标准。2000年，NIST选拔出Rijndael算法作为AES的标准。

AES有一个重要的特点就是分组加密体制，一次只能处理128位的明文，然后生成128位的密文。如果要加密很长的明文，那么就需要迭代处理，而迭代方式就叫做模式。网上很多使用AES来加密的代码，使用的是最简单的ECB模式（也叫电子密码本模式），其基本结构如下：

<img src="https://static001.geekbang.org/resource/image/27/8b/27c2534caeefcac4a5dd1a2814957d8b.png" alt="">

可以看到，这种结构有两个风险：明文和密文是一一对应的，如果明文中有重复的分组，那么密文中可以观察到重复，掌握密文的规律；因为每一个分组是独立加密和解密的 ，如果密文分组的顺序，也可以反过来操纵明文，那么就可以实现不解密密文的情况下，来修改明文。

我们写一段代码来测试下。在下面的代码中，我们使用ECB模式测试：

- 加密一段包含16个字符的字符串，得到密文A；然后把这段字符串复制一份成为一个32个字符的字符串，再进行加密得到密文B。我们验证下密文B是不是重复了一遍的密文A。
- 模拟银行转账的场景，假设整个数据由发送方账号、接收方账号、金额三个字段构成。我们尝试改变密文中数据的顺序来操纵明文。

```
private static final String KEY = &quot;secretkey1234567&quot;; //密钥
//测试ECB模式
@GetMapping(&quot;ecb&quot;)
public void ecb() throws Exception {
	Cipher cipher = Cipher.getInstance(&quot;AES/ECB/NoPadding&quot;);
	test(cipher, null);
}
//获取加密秘钥帮助方法
private static SecretKeySpec setKey(String secret) {
    return new SecretKeySpec(secret.getBytes(), &quot;AES&quot;);
}
//测试逻辑
private static void test(Cipher cipher, AlgorithmParameterSpec parameterSpec) throws Exception {
    //初始化Cipher
    cipher.init(Cipher.ENCRYPT_MODE, setKey(KEY), parameterSpec);
    //加密测试文本
    System.out.println(&quot;一次：&quot; + Hex.encodeHexString(cipher.doFinal(&quot;abcdefghijklmnop&quot;.getBytes())));
    //加密重复一次的测试文本
    System.out.println(&quot;两次：&quot; + Hex.encodeHexString(cipher.doFinal(&quot;abcdefghijklmnopabcdefghijklmnop&quot;.getBytes())));
    //下面测试是否可以通过操纵密文来操纵明文    
    //发送方账号
    byte[] sender = &quot;1000000000012345&quot;.getBytes();
    //接收方账号
    byte[] receiver = &quot;1000000000034567&quot;.getBytes();
    //转账金额
    byte[] money = &quot;0000000010000000&quot;.getBytes();
    //加密发送方账号
    System.out.println(&quot;发送方账号：&quot; + Hex.encodeHexString(cipher.doFinal(sender)));
    //加密接收方账号
    System.out.println(&quot;接收方账号：&quot; + Hex.encodeHexString(cipher.doFinal(receiver)));
    //加密金额
    System.out.println(&quot;金额：&quot; + Hex.encodeHexString(cipher.doFinal(money)));
    //加密完整的转账信息
    byte[] result = cipher.doFinal(ByteUtils.concatAll(sender, receiver, money));
    System.out.println(&quot;完整数据：&quot; + Hex.encodeHexString(result));
    //用于操纵密文的临时字节数组
    byte[] hack = new byte[result.length];
    //把密文前两段交换
    System.arraycopy(result, 16, hack, 0, 16);
    System.arraycopy(result, 0, hack, 16, 16);
    System.arraycopy(result, 32, hack, 32, 16);
    cipher.init(Cipher.DECRYPT_MODE, setKey(KEY), parameterSpec);
    //尝试解密
    System.out.println(&quot;原始明文：&quot; + new String(ByteUtils.concatAll(sender, receiver, money)));
    System.out.println(&quot;操纵密文：&quot; + new String(cipher.doFinal(hack)));
}

```

输出如下：

<img src="https://static001.geekbang.org/resource/image/cd/59/cd506b4cf8a020d4b6077fdfa3b34959.png" alt="">

可以看到：

- 两个相同明文分组产生的密文，就是两个相同的密文分组叠在一起。
- 在不知道密钥的情况下，我们操纵密文实现了对明文数据的修改，对调了发送方账号和接收方账号。

所以说，**ECB模式虽然简单，但是不安全，不推荐使用**。我们再看一下另一种常用的加密模式，CBC模式。

CBC模式，在解密或解密之前引入了XOR运算，第一个分组使用外部提供的初始化向量IV，从第二个分组开始使用前一个分组的数据，这样即使明文是一样的，加密后的密文也是不同的，并且分组的顺序不能任意调换。这就解决了ECB模式的缺陷：

<img src="https://static001.geekbang.org/resource/image/79/e8/7955a199e2400adc7ac7577b3712bae8.png" alt="">

我们把之前的代码修改为CBC模式，再次进行测试：

```
 private static final String initVector = &quot;abcdefghijklmnop&quot;; //初始化向量

@GetMapping(&quot;cbc&quot;)
public void cbc() throws Exception {
    Cipher cipher = Cipher.getInstance(&quot;AES/CBC/NoPadding&quot;);
    IvParameterSpec iv = new IvParameterSpec(initVector.getBytes(&quot;UTF-8&quot;));
    test(cipher, iv);
}

```

可以看到，相同的明文字符串复制一遍得到的密文并不是重复两个密文分组，并且调换密文分组的顺序无法操纵明文：

<img src="https://static001.geekbang.org/resource/image/8b/08/8b79074d6533a84c32e48eab3daef808.png" alt="">

其实，除了ECB模式和CBC模式外，AES算法还有CFB、OFB、CTR模式，你可以参考[这里](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation)了解它们的区别。《实用密码学》一书比较推荐的是CBC和CTR模式。还需要注意的是，ECB和CBC模式还需要设置合适的填充模式，才能处理超过一个分组的数据。

对于敏感数据保存，除了选择AES+合适模式进行加密外，我还推荐以下几个实践：

- 不要在代码中写死一个固定的密钥和初始化向量，最好和之前提到的盐一样，是唯一、独立并且每次都变化的。
- 推荐使用独立的加密服务来管控密钥、做加密操作，千万不要把密钥和密文存在一个数据库，加密服务需要设置非常高的管控标准。
- 数据库中不能保存明文的敏感信息，但可以保存脱敏的信息。普通查询的时候，直接查脱敏信息即可。

接下来，我们按照这个策略完成相关代码实现。

第一步，对于用户姓名和身份证，我们分别保存三个信息，脱敏后的明文、密文和加密ID。加密服务加密后返回密文和加密ID，随后使用加密ID来请求加密服务进行解密：

```
@Data
@Entity
public class UserData {
    @Id
    private Long id;
    private String idcard;//脱敏的身份证
    private Long idcardCipherId;//身份证加密ID
    private String idcardCipherText;//身份证密文
    private String name;//脱敏的姓名
    private Long nameCipherId;//姓名加密ID
    private String nameCipherText;//姓名密文
}

```

第二步，加密服务数据表保存加密ID、初始化向量和密钥。加密服务表中没有密文，实现了密文和密钥分离保存：

```
@Data
@Entity
public class CipherData {
    @Id
    @GeneratedValue(strategy = AUTO)
    private Long id;
    private String iv;//初始化向量
    private String secureKey;//密钥
}

```

第三步，加密服务使用GCM模式（ Galois/Counter Mode）的AES-256对称加密算法，也就是AES-256-GCM。

这是一种[AEAD](https://tools.ietf.org/html/rfc5116)（Authenticated Encryption with Associated Data）认证加密算法，除了能实现普通加密算法提供的保密性之外，还能实现可认证性和密文完整性，是目前最推荐的AES模式。

使用类似GCM的AEAD算法进行加解密，除了需要提供初始化向量和密钥之外，还可以提供一个AAD（附加认证数据，additional authenticated data），用于验证未包含在明文中的附加信息，解密时不使用加密时的AAD将解密失败。其实，GCM模式的内部使用的就是CTR模式，只不过还使用了GMAC签名算法，对密文进行签名实现完整性校验。

接下来，我们实现基于AES-256-GCM的加密服务，包含下面的主要逻辑：

- 加密时允许外部传入一个AAD用于认证，加密服务每次都会使用新生成的随机值作为密钥和初始化向量。
- 在加密后，加密服务密钥和初始化向量保存到数据库中，返回加密ID作为本次加密的标识。
- 应用解密时，需要提供加密ID、密文和加密时的AAD来解密。加密服务使用加密ID，从数据库查询出密钥和初始化向量。

这段逻辑的实现代码比较长，我加了详细注释方便你仔细阅读：

```
@Service
public class CipherService {
    //密钥长度
    public static final int AES_KEY_SIZE = 256;
    //初始化向量长度
    public static final int GCM_IV_LENGTH = 12;
    //GCM身份认证Tag长度
    public static final int GCM_TAG_LENGTH = 16;
    @Autowired
    private CipherRepository cipherRepository;

    //内部加密方法
    public static byte[] doEncrypt(byte[] plaintext, SecretKey key, byte[] iv, byte[] aad) throws Exception {
        //加密算法
        Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);
        //Key规范
        SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), &quot;AES&quot;);
        //GCM参数规范
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
        //加密模式
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec);
        //设置aad
        if (aad != null)
            cipher.updateAAD(aad);
        //加密
        byte[] cipherText = cipher.doFinal(plaintext);
        return cipherText;
    }

    //内部解密方法
    public static String doDecrypt(byte[] cipherText, SecretKey key, byte[] iv, byte[] aad) throws Exception {
        //加密算法
        Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);
        //Key规范
        SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), &quot;AES&quot;);
        //GCM参数规范
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
        //解密模式
        cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);
        //设置aad
        if (aad != null)
            cipher.updateAAD(aad);
        //解密
        byte[] decryptedText = cipher.doFinal(cipherText);
        return new String(decryptedText);
    }

    //加密入口
    public CipherResult encrypt(String data, String aad) throws Exception {
        //加密结果
        CipherResult encryptResult = new CipherResult();
        //密钥生成器
        KeyGenerator keyGenerator = KeyGenerator.getInstance(&quot;AES&quot;);
        //生成密钥
        keyGenerator.init(AES_KEY_SIZE);
        SecretKey key = keyGenerator.generateKey();
        //IV数据
        byte[] iv = new byte[GCM_IV_LENGTH];
        //随机生成IV
        SecureRandom random = new SecureRandom();
        random.nextBytes(iv);
        //处理aad
        byte[] aaddata = null;
        if (!StringUtils.isEmpty(aad))
            aaddata = aad.getBytes();
        //获得密文
        encryptResult.setCipherText(Base64.getEncoder().encodeToString(doEncrypt(data.getBytes(), key, iv, aaddata)));
        //加密上下文数据
        CipherData cipherData = new CipherData();
        //保存IV
        cipherData.setIv(Base64.getEncoder().encodeToString(iv));
        //保存密钥
        cipherData.setSecureKey(Base64.getEncoder().encodeToString(key.getEncoded()));
        cipherRepository.save(cipherData);
        //返回本地加密ID
        encryptResult.setId(cipherData.getId());
        return encryptResult;
    }

    //解密入口
    public String decrypt(long cipherId, String cipherText, String aad) throws Exception {
        //使用加密ID找到加密上下文数据
        CipherData cipherData = cipherRepository.findById(cipherId).orElseThrow(() -&gt; new IllegalArgumentException(&quot;invlaid cipherId&quot;));
        //加载密钥
        byte[] decodedKey = Base64.getDecoder().decode(cipherData.getSecureKey());
        //初始化密钥
        SecretKey originalKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, &quot;AES&quot;);
        //加载IV
        byte[] decodedIv = Base64.getDecoder().decode(cipherData.getIv());
        //处理aad
        byte[] aaddata = null;
        if (!StringUtils.isEmpty(aad))
            aaddata = aad.getBytes();
        //解密
        return doDecrypt(Base64.getDecoder().decode(cipherText.getBytes()), originalKey, decodedIv, aaddata);
    }
}

```

第四步，分别实现加密和解密接口用于测试。

我们可以让用户选择，如果需要保护二要素的话，就自己输入一个查询密码作为AAD。系统需要读取用户敏感信息的时候，还需要用户提供这个密码，否则无法解密。这样一来，即使黑客拿到了用户数据库的密文、加密服务的密钥和IV，也会因为缺少AAD无法解密：

```
@Autowired
private CipherService cipherService;


//加密
@GetMapping(&quot;right&quot;)
public UserData right(@RequestParam(value = &quot;name&quot;, defaultValue = &quot;朱晔&quot;) String name,
                      @RequestParam(value = &quot;idcard&quot;, defaultValue = &quot;300000000000001234&quot;) String idCard,
                      @RequestParam(value = &quot;aad&quot;, required = false)String aad) throws Exception {
    UserData userData = new UserData();
    userData.setId(1L);
    //脱敏姓名
    userData.setName(chineseName(name));
    //脱敏身份证
    userData.setIdcard(idCard(idCard));
    //加密姓名
    CipherResult cipherResultName = cipherService.encrypt(name,aad);
    userData.setNameCipherId(cipherResultName.getId());
    userData.setNameCipherText(cipherResultName.getCipherText());
    //加密身份证
    CipherResult cipherResultIdCard = cipherService.encrypt(idCard,aad);
    userData.setIdcardCipherId(cipherResultIdCard.getId());
    userData.setIdcardCipherText(cipherResultIdCard.getCipherText());
    return userRepository.save(userData);
}

//解密
@GetMapping(&quot;read&quot;)
public void read(@RequestParam(value = &quot;aad&quot;, required = false)String aad) throws Exception {
    //查询用户信息
    UserData userData = userRepository.findById(1L).get();
    //使用AAD来解密姓名和身份证
    log.info(&quot;name : {} idcard : {}&quot;,
            cipherService.decrypt(userData.getNameCipherId(), userData.getNameCipherText(),aad),
            cipherService.decrypt(userData.getIdcardCipherId(), userData.getIdcardCipherText(),aad));

}
//脱敏身份证
private static String idCard(String idCard) {
    String num = StringUtils.right(idCard, 4);
    return StringUtils.leftPad(num, StringUtils.length(idCard), &quot;*&quot;);
}
//脱敏姓名
public static String chineseName(String chineseName) {
    String name = StringUtils.left(chineseName, 1);
    return StringUtils.rightPad(name, StringUtils.length(chineseName), &quot;*&quot;);

```

访问加密接口获得如下结果，可以看到数据库表中只有脱敏数据和密文：

```
{&quot;id&quot;:1,&quot;name&quot;:&quot;朱*&quot;,&quot;idcard&quot;:&quot;**************1234&quot;,&quot;idcardCipherId&quot;:26346,&quot;idcardCipherText&quot;:&quot;t/wIh1XTj00wJP1Lt3aGzSvn9GcqQWEwthN58KKU4KZ4Tw==&quot;,&quot;nameCipherId&quot;:26347,&quot;nameCipherText&quot;:&quot;+gHrk1mWmveBMVUo+CYon8Zjj9QAtw==&quot;}

```

访问解密接口，可以看到解密成功了：

```
[21:46:00.079] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.s.s.StoreIdCardController:102 ] - name : 朱晔 idcard : 300000000000001234

```

如果AAD输入不对，会得到如下异常：

```
javax.crypto.AEADBadTagException: Tag mismatch!
	at com.sun.crypto.provider.GaloisCounterMode.decryptFinal(GaloisCounterMode.java:578)
	at com.sun.crypto.provider.CipherCore.finalNoPadding(CipherCore.java:1116)
	at com.sun.crypto.provider.CipherCore.fillOutputBuffer(CipherCore.java:1053)
	at com.sun.crypto.provider.CipherCore.doFinal(CipherCore.java:853)
	at com.sun.crypto.provider.AESCipher.engineDoFinal(AESCipher.java:446)
	at javax.crypto.Cipher.doFinal(Cipher.java:2164)

```

经过这样的设计，二要素就比较安全了。黑客要查询用户二要素的话，需要同时拿到密文、IV+密钥、AAD。而这三者可能由三方掌管，要全部拿到比较困难。

## 用一张图说清楚HTTPS

我们知道，HTTP协议传输数据使用的是明文。那在传输敏感信息的场景下，如果客户端和服务端中间有一个黑客作为中间人拦截请求，就可以窃听到这些数据，还可以修改客户端传过来的数据。这就是很大的安全隐患。

为解决这个安全隐患，有了HTTPS协议。HTTPS=SSL/TLS+HTTP，通过使用一系列加密算法来确保信息安全传输，以实现数据传输的机密性、完整性和权威性。

- 机密性：使用非对称加密来加密密钥，然后使用密钥来加密数据，既安全又解决了非对称加密大量数据慢的问题。你可以做一个实验来测试两者的差距。
- 完整性：使用散列算法对信息进行摘要，确保信息完整无法被中间人篡改。
- 权威性：使用数字证书，来确保我们是在和合法的服务端通信。

可以看出，理解HTTPS的流程，将有助于我们理解各种加密算法的区别，以及证书的意义。此外，SSL/TLS还是混合加密系统的一个典范，如果你需要自己开发应用层数据加密系统，也可以参考它的流程。

那么，我们就来看看HTTPS TLS 1.2连接（RSA握手）的整个过程吧。

<img src="https://static001.geekbang.org/resource/image/98/7c/982510795a50e4b18808eed81dac647c.png" alt="">

作为准备工作，网站管理员需要申请并安装CA证书到服务端。CA证书中包含非对称加密的公钥、网站域名等信息，密钥是服务端自己保存的，不会在任何地方公开。

建立HTTPS连接的过程，首先是TCP握手，然后是TLS握手的一系列工作，包括：

1. 客户端告知服务端自己支持的密码套件（比如TLS_RSA_WITH_AES_256_GCM_SHA384，其中RSA是密钥交换的方式，AES_256_GCM是加密算法，SHA384是消息验证摘要算法），提供客户端随机数。
1. 服务端应答选择的密码套件，提供服务端随机数。
1. 服务端发送CA证书给客户端，客户端验证CA证书（后面详细说明）。
1. 客户端生成PreMasterKey，并使用非对称加密+公钥加密PreMasterKey。
1. 客户端把加密后的PreMasterKey传给服务端。
1. 服务端使用非对称加密+私钥解密得到PreMasterKey，并使用PreMasterKey+两个随机数，生成MasterKey。
1. 客户端也使用PreMasterKey+两个随机数生成MasterKey。
1. 客户端告知服务端之后将进行加密传输。
1. 客户端使用MasterKey配合对称加密算法，进行对称加密测试。
1. 服务端也使用MasterKey配合对称加密算法，进行对称加密测试。

接下来，客户端和服务端的所有通信都是加密通信，并且数据通过签名确保无法篡改。你可能会问，客户端怎么验证CA证书呢？

其实，CA证书是一个证书链，你可以看一下上图的左边部分：

- 从服务端拿到的CA证书是用户证书，我们需要通过证书中的签发人信息找到上级中间证书，再网上找到根证书。
- 根证书只有为数不多的权威机构才能生成，一般预置在OS中，根本无法伪造。
- 找到根证书后，提取其公钥来验证中间证书的签名，判断其权威性。
- 最后再拿到中间证书的公钥，验证用户证书的签名。

这，就验证了用户证书的合法性，然后再校验其有效期、域名等信息进一步验证有效性。

总结一下，TLS通过巧妙的流程和算法搭配解决了传输安全问题：使用对称加密加密数据，使用非对称加密算法确保密钥无法被中间人解密；使用CA证书链认证，确保中间人无法伪造自己的证书和公钥。

如果网站涉及敏感数据的传输，必须使用HTTPS协议。作为用户，如果你看到网站不是HTTPS的或者看到无效证书警告，也不应该继续使用这个网站，以免敏感信息被泄露。

## 重点回顾

今天，我们一起学习了如何保存和传输敏感数据。我来带你回顾一下重点内容。

对于数据保存，你需要记住两点：

- 用户密码不能加密保存，更不能明文保存，需要使用全球唯一的、具有一定长度的、随机的盐，配合单向散列算法保存。使用BCrypt算法，是一个比较好的实践。
- 诸如姓名和身份证这种需要可逆解密查询的敏感信息，需要使用对称加密算法保存。我的建议是，把脱敏数据和密文保存在业务数据库，独立使用加密服务来做数据加解密；对称加密需要用到的密钥和初始化向量，可以和业务数据库分开保存。

对于数据传输，则务必通过SSL/TLS进行传输。对于用于客户端到服务端传输数据的HTTP，我们需要使用基于SSL/TLS的HTTPS。对于一些走TCP的RPC服务，同样可以使用SSL/TLS来确保传输安全。

最后，我要提醒你的是，如果不确定应该如何实现加解密方案或流程，可以咨询公司内部的安全专家，或是参考业界各大云厂商的方案，切勿自己想当然地去设计流程，甚至创造加密算法。

今天用到的代码，我都放在了GitHub上，你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。

## 思考与讨论

1. 虽然我们把用户名和密码脱敏加密保存在数据库中，但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面，对日志进行脱敏吗？
1. 你知道HTTPS双向认证的目的是什么吗？流程上又有什么区别呢？

关于各种加密算法，你还遇到过什么坑吗？你又是如何保存敏感数据的呢？我是朱晔，欢迎在评论区与我留言分享你的想法，也欢迎你把今天的内容分享给你的朋友或同事，一起交流。
