本文聊聊服务端系统设计中,关于用户敏感数据相关的设计。
密码安全
对于大部分的网络服务来说,都是注册登录之后才能使用。在注册成功后,服务端需要保存用户的账号和密码,这样后续才能验证用户登录是否合法。
MD5处理
最常见的加密方式,一般就是将用户密码进行一次 MD5 处理:
1
| DigestUtils.md5Hex("123456")
|
这时,存入数据库中的就不是明文密码了,而是 MD5 处理后的密码摘要。
MD5 摘要算法决定了无法根据摘要信息推算出原始数据,但仍然不安全。从攻击者角度来说,虽然 MD5 无法逆向计算,但是理论上可以根据正向计算,组合所有字符构建出一张大表(一般将这张表称为彩虹表),拿到密码摘要后,从彩虹表中反查,即可获取到用户的原始密码。
在 CMD5 查询,以上面的密码为例,不用多久就能查到原始密码:
加盐处理
在进行 MD5 前对密码加盐是一种常见的操作,但是需要注意,盐不要固定,并且需要有一定长度。
假设盐是固定的简单字符串:
1 2
| DigestUtils.md5Hex("123456" + "abc")
|
通过查询可以 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); 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);
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);
String origin2 = new String(cipher.doFinal(Hex.decodeHex(ciphertext + ciphertext))); System.out.println(origin2);
|
因此如果有个密文是由多个子密文拼接而成的 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);
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);
String origin2 = new String(cipher.doFinal(Hex.decodeHex(ciphertext + ciphertext))); System.out.println(origin2);
|
示例
对于敏感数据保存,可以使用 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 48
| @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 = "";
@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 = "";
@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 76
| @Service public class CipherService {
private static final int AES_KEY_SIZE = 256;
private static final int GCM_IV_LENGTH = 12;
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 38
| @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!
日志过滤
除开数据库外,另一个可能会存储敏感数据的地方就是日志。
一般来说,日志打印数据有两种情况:
示例
以 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> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <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