聊聊用户敏感数据保存

本文聊聊服务端系统设计中,关于用户敏感数据相关的设计。

密码安全

对于大部分的网络服务来说,都是注册登录之后才能使用。在注册成功后,服务端需要保存用户的账号和密码,这样后续才能验证用户登录是否合法。

MD5处理

最常见的加密方式,一般就是将用户密码进行一次 MD5 处理:

1
DigestUtils.md5Hex("123456") // e10adc3949ba59abbe56e057f20f883e

这时,存入数据库中的就不是明文密码了,而是 MD5 处理后的密码摘要。

MD5 摘要算法决定了无法根据摘要信息推算出原始数据,但仍然不安全。从攻击者角度来说,虽然 MD5 无法逆向计算,但是理论上可以根据正向计算,组合所有字符构建出一张大表(一般将这张表称为彩虹表),拿到密码摘要后,从彩虹表中反查,即可获取到用户的原始密码。

CMD5 查询,以上面的密码为例,不用多久就能查到原始密码:

加盐处理

在进行 MD5 前对密码加盐是一种常见的操作,但是需要注意,盐不要固定,并且需要有一定长度。

假设盐是固定的简单字符串:

1
2
// 假设密码为123456,盐为abc
DigestUtils.md5Hex("123456" + "abc") // df10ef8509dc176d733d59549e7dbfaf

通过查询可以 df10ef8509dc176d733d59549e7dbfaf 找到对应的原始字符串为 123456abc

那如何判断是否有加盐呢?通过注册一个新账号,使用简单密码进行注册,比如 1,服务端 MD5 处理后得到的最终密码摘要为 e511341dc05782269d3d859b5ff3939b,反查之后,就可以得出盐为 abc

因为使用了简单的并且是固定的盐,意味着如果用户的原始密码就简单,那拼接后的字符串也简单,那么一般从彩虹表中都能找到,进而可以推断出原始密码。

同时使用相同的盐,意味着相同密码的用户的 MD5 是一样的,那么只要知道一个,就可以获取多个用户的密码。

因此推荐每个用户的盐不一样,并且要长一点,例如使用 UUID。因为对于攻击者来说,即使拿到了所有用户的密码和盐,也加大了分析出每个用户的原始密码的成本。

使用BCrypt算法

也推荐使用其他算法,如 BCrypt 就是为了加密而设计的算法:

使用示例:

1
2
3
4
5
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
1
2
3
4
String password = "123456";
String hashed = BCrypt.hashpw(password, BCrypt.gensalt());
System.out.println(hashed); // $2a$10$AyXgU8Slib3qWn4BPFoCc.LPzOtSBflv11poa8XzIW/595/RK/eHe
assert BCrypt.checkpw(password, hashed);

身份敏感信息

身份敏感信息是指像用户姓名,身份证号等信息,像这种信息,我们不能使用密码加密那种单向算法,因为无法解密。这时候应该选择可逆算法,包括对称加密和非对称加密算法。

对称加密算法,是指双方使用相同的密钥进行加密和解密,特点是加密速度快,但是密钥在传输过程中可能会被窃取,如 AES,DES 就属于此类。

非对称加密算法,是指使用密钥对进行加密和解密,具体来说,就是使用公钥加密,使用私钥解密,公钥可以公开(可以发送给请求方,请求方使用公钥加密信息),私钥不能公开(接收方使用私钥解密信息)。特点是加密速度比较慢,但是可以避免密钥传输过程的安全问题,如 RSA 就属于此类。

因为我们是属于服务内部使用,不存在分发密钥的情况,所以更偏向于使用对称加密,此处使用AES算法对敏感信息做加密,而AES算法又有几种加密模式:ECB、CBC、CTR、OCF、CFB、XTS。

使用ECB模式

ECB模式是最简单的块密码加密模式,加密前根据加密块大小分成若干块,之后将每块使用相同的密钥单独加密,解密同理。因为使用相同的数据块密钥加密后的密文相同,所以会有安全问题。

如果我们将密文重复几次,可以发现仍然可以解密成功,结果明文被复制了多次:

1
2
3
4
5
6
7
8
9
10
11
12
13
String key = "77fe5145ef3749a4";
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"));
String ciphertext = Hex.encodeHexString(cipher.doFinal("0123456789abcdef".getBytes()));
System.out.println(ciphertext); // 10652dfd1b30e239b1b3474585ee0f71

cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"));
String origin = new String(cipher.doFinal(Hex.decodeHex(ciphertext)));
System.out.println(origin); // 0123456789abcdef

// 合并两段密文
String origin2 = new String(cipher.doFinal(Hex.decodeHex(ciphertext + ciphertext)));
System.out.println(origin2); // 0123456789abcdef0123456789abcdef

因此如果有个密文是由多个子密文拼接而成的 AES(A) + AES(B) + AES(C),即使我们不知道密钥,也可以通过调换子密文位置,使业务逻辑出错。

使用CBC模式

CBC模式先将明文切分组,每一组与上一组的密文块进行异或运算后,再使用密钥进行加密。第一个块需要使用初始化向量(IV),之后的分组使用前一个分组的数据,这样即使明文一样,加密后的数据也是不同。因为需要保证分组访问的顺序性,相较ECB提高了安全性,但是加密过程无法并行化,而且消息必须填充到分组大小的整数倍。

如果再将密文复制多次,发现无法解析出明文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String key = "77fe5145ef3749a4";
// 初始化向量
IvParameterSpec iv = new IvParameterSpec("fedcba9876543210".getBytes(StandardCharsets.UTF_8));
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"), iv);
String ciphertext = Hex.encodeHexString(cipher.doFinal("0123456789abcdef".getBytes()));
System.out.println(ciphertext); // 1e21a01bfb1c6512439958cf03356c75

cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"), iv);
String origin = new String(cipher.doFinal(Hex.decodeHex(ciphertext)));
System.out.println(origin); // 0123456789abcdef

// 合并两段密文
String origin2 = new String(cipher.doFinal(Hex.decodeHex(ciphertext + ciphertext)));
System.out.println(origin2); // 0123456789abcdefHu�K�HjL� �Sc8#

示例

对于敏感数据保存,可以使用 AES+CBC 加密保存。在数据库设计上,不要直接存储用户的明文信息,而存储密文信息以及数据脱敏信息,这样做普通查询的时候,直接使用脱敏信息即可。密钥和初始化向量最好是独立唯一并且变化的,并且使用独立的加密服务来保存密钥和加密操作。

以用户姓名和身份证为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Data
@Entity
@Table(name = "user_data")
public class UserData {

@Id
@Column(name = "id")
@GeneratedValue(generator = "userDataID", strategy = GenerationType.AUTO)
@GenericGenerator(name = "userDataID", strategy = "assigned")
private int id;

/**
* 用户姓名
*/
@Column(name = "name", nullable = false, length = 20)
private String name = "";

/**
* 用户姓名密文ID
*/
@Column(name = "name_cipher_id", nullable = false)
private int nameCipherId = 0;

/**
* 用户姓名密文内容
*/
@Column(name = "name_cipher_text", nullable = false)
private String nameCipherText = "";

/**
* 用户身份证
*/
@Column(name = "id_card", nullable = false, length = 20)
private String idCard = "";

/**
* 用户身份证密文ID
*/
@Column(name = "id_card_cipher_id", nullable = false)
private int idCardCipherId = 0;

/**
* 用户身份证密文内容
*/
@Column(name = "id_card_cipher_text", nullable = false)
private String idCardCipherText = "";
}

同时,还需要记录密钥和初始向量信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@Entity
@Table(name = "cipher_data")
public class CipherData {

@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;

/**
* 初始向量
*/
@Column(name = "iv", nullable = false)
private String iv;

/**
* 密钥
*/
@Column(name = "secret_key", nullable = false)
private String secretKey;
}

加密服务使用 GCM 模式的 AES-256 对称加密算法,加密时允许传入一个 AAD 用于认证(当解密时使用的 AAD 错误时,依然会解密失败):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@Service
public class CipherService {

//密钥长度
private static final int AES_KEY_SIZE = 256;

//初始化向量长度
private static final int GCM_IV_LENGTH = 12;

//GCM身份认证Tag长度
private static final int GCM_TAG_LENGTH = 16;

@Resource
private CipherDataRepository cipherDataRepository;

public CipherResult encrypt(String data, String aad) throws Exception {
// 生成密钥
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(AES_KEY_SIZE);
SecretKey key = keyGenerator.generateKey();

// 生成初始向量
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom random = new SecureRandom();
random.nextBytes(iv);

// 保存密钥&向量
CipherData cipherData = new CipherData();
cipherData.setIv(Base64.getEncoder().encodeToString(iv));
cipherData.setSecretKey(Base64.getEncoder().encodeToString(key.getEncoded()));
cipherDataRepository.save(cipherData);

//加密
byte[] aadBytes = StringUtils.isBlank(aad) ? null : aad.getBytes();
CipherResult cipherResult = new CipherResult();
cipherResult.setCipherText(Base64.getEncoder().encodeToString(encrypt(data.getBytes(), key, iv, aadBytes)));
cipherResult.setCipherId(cipherData.getId());

return cipherResult;
}

public String decrypt(int cipherId, String cipherText, String aad) throws Exception {
CipherData cipherData = cipherDataRepository.findById(cipherId)
.orElseThrow(() -> new IllegalArgumentException("invalid cipherId"));
// 获取密钥
byte[] decodedKey = Base64.getDecoder().decode(cipherData.getSecretKey());
SecretKey originalKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, "AES");

// 获取初始向量
byte[] decodedIv = Base64.getDecoder().decode(cipherData.getIv());

byte[] aadBytes = StringUtils.isBlank(aad) ? null : aad.getBytes();
return new String(decrypt(Base64.getDecoder().decode(cipherText.getBytes()), originalKey, decodedIv, aadBytes));
}

private byte[] encrypt(byte[] plaintext, SecretKey key, byte[] iv, byte[] aad) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.getEncoded(), "AES"), new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv));

if (aad != null) {
cipher.updateAAD(aad);
}
return cipher.doFinal(plaintext);
}

private byte[] decrypt(byte[] cipherText, SecretKey key, byte[] iv, byte[] aad) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getEncoded(), "AES"), new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv));

if (aad != null) {
cipher.updateAAD(aad);
}
return cipher.doFinal(cipherText);
}
}

上层接口调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@RequestMapping("save")
public UserData right(@RequestParam(value = "name", defaultValue = "吴彦祖") String name,
@RequestParam(value = "idCard", defaultValue = "430722199101131921") String idCard,
@RequestParam(value = "aad", defaultValue = "ed45s") String aad) throws Exception {
UserData userData = new UserData();
userData.setId(userId);
// 保存脱敏数据
userData.setName(DesensitizationUtils.desensitize(name, DesensitizationUtils.DesensitizationType.NAME));
userData.setIdCard(DesensitizationUtils.desensitize(idCard, DesensitizationUtils.DesensitizationType.ID_CARD));

// 加密姓名
CipherResult cipherResultName = cipherService.encrypt(name, aad);
userData.setNameCipherId(cipherResultName.getCipherId());
userData.setNameCipherText(cipherResultName.getCipherText());

// 加密身份证
CipherResult cipherResultIdCard = cipherService.encrypt(idCard, aad);
userData.setIdCardCipherId(cipherResultIdCard.getCipherId());
userData.setIdCardCipherText(cipherResultIdCard.getCipherText());

return userDataRepository.save(userData);
}

@RequestMapping("get-real-data")
public Map<String, String> get(@RequestParam(value = "aad", defaultValue = "ed45s") String aad) throws Exception {
Map<String, String> result = new HashMap<>();

UserData userData = userDataRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("userId[" + userId + "] not exist"));
// 解密姓名
String name = cipherService.decrypt(userData.getNameCipherId(), userData.getNameCipherText(), aad);
// 解密身份证
String idCard = cipherService.decrypt(userData.getIdCardCipherId(), userData.getIdCardCipherText(), aad);
result.put("name", name);
result.put("idCard", idCard);
return result;
}

调用加密接口后可以看到用户信息已经脱敏:

1
2
3
4
5
6
7
8
9
{
id: 10,
name: "吴*祖",
nameCipherId: 19,
nameCipherText: "KV5zTlh7szqDCdfl6fpgfQ2uXE6sxNbl1g==",
idCard: "430722********1921",
idCardCipherId: 20,
idCardCipherText: "J+137ENEtuGX3phLH7mYFigcm0g/nbaOeRM584VzuVcvGA=="
}

而调用解密接口可以看到原始信息:

1
2
3
4
{
idCard: "430722199101131921",
name: "吴彦祖"
}

如果 AAD 信息不对,将返回 Tag mismatch!

日志过滤

除开数据库外,另一个可能会存储敏感数据的地方就是日志。

一般来说,日志打印数据有两种情况:

  • 敏感信息在参数中,如:

    1
    2
    String password = "123456";
    log.info("user password: {}", password);

    这种情况方便处理,在写入日志之前先处理:

    1
    2
    String password = "123456";
    log.info("user password: `{}`.", DesensitizationUtils.desensitize(password);
  • 敏感信息在对象中,如:

    1
    2
    3
    User user1 = new User();
    user1.setPassword("123456");
    log.info("user: `{}`.", user1);

    这种情况下,因为最终是以字符串的形式写入文件的,因此可以在写入文件之前对字符串进行处理。

示例

Logback 为例,可以通过自定义 PatternLayout 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class DesensitizationDataPatternLayout extends PatternLayout {

private final List<Pattern> patterns = new ArrayList<>();

public DesensitizationDataPatternLayout() {
initPattern();
}

private void initPattern() {
patterns.add(Pattern.compile("password=(.+?)[,})]")); // 密码正则
}

@Override
public String doLayout(ILoggingEvent event) {
// 获取待写入文件的内容
StringBuilder message = new StringBuilder(super.doLayout(event));
for (Pattern pattern : patterns) {
// 正则进行脱敏处理
Matcher matcher = pattern.matcher(message);
while (matcher.find()) {
String target = matcher.group(1);
if (StringUtils.isNotBlank(target)) {
for (int i = matcher.start(1), j = matcher.end(1); i < j; i++) {
message.setCharAt(i, '*');
}
}
}
}
return message.toString();
}
}

然后修改配置文件 logback.xml,修改其中 <encoder> 标签部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="UTF-8"?>

<configuration>

<property name="loggerPattern" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5p [%F:%L] : %m%n"/>

<appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>

<!-- 使用 LayoutWrappingEncoder -->
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
<!-- 使用自定义的 PatternLayout -->
<layout class="org.example.desensitization.log.DesensitizationDataPatternLayout">
<pattern>${loggerPattern}</pattern>
</layout>
</encoder>
</appender>

<root level="info">
<appender-ref ref="stdout"/>
</root>

</configuration>

测试接口调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RequestMapping("/log")
public String log(HttpServletRequest httpServletRequest, User user) {
System.out.println("print param: " + getRequestParams(httpServletRequest));
log.info("param: {}.", getRequestParams(httpServletRequest));
System.out.println("print user: " + user);
log.info("user: {}.", user);
return "ok";
}

private Map<String, String> getRequestParams(HttpServletRequest httpServletRequest) {
Map<String, String[]> paramMap = httpServletRequest.getParameterMap();
Map<String, String> result = new HashMap<>();
for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
result.put(entry.getKey(), entry.getValue()[0]);
}
return result;
}

@Data
private static class User {

private String username;

private String password;
}

可以看到控制台输出内容,的确密码已经做了脱敏处理:

1
2
3
4
print param: {password=123, username=admin}
2022-04-26 17:33:40.474 INFO [LogTestController.java:19] : param: {password=***, username=admin}.
print foo: LogTestController.Foo(username=admin, password=123)
2022-04-26 17:33:40.480 INFO [LogTestController.java:21] : foo: LogTestController.Foo(username=admin, password=***).

代码地址

本文示例代码地址:data-desensitization-demo