Springboot 配置文件、隱私數據脫敏實踐
本文轉載自微信公眾號「程序員內點事」,作者程序員內點事。轉載本文請聯系程序員內點事公眾號。
大家好!我是小富~
這幾天公司在排查內部數據賬號泄漏,原因是發現某些實習生小可愛居然連帶著賬號、密碼將源碼私傳到GitHub上,導致核心數據外漏,孩子還是沒挨過社會毒打,這種事的后果可大可小。
說起這個我是比較有感觸的,之前我TM被刪庫的經歷,到現在想起來心里還難受,我也是把數據庫賬號明文密碼誤提交到GitHub,然后被哪個大寶貝給我測試庫刪了,后邊我長記性了把配置文件內容都加密了,數據安全問題真的不容小覷,不管工作匯還是生活,敏感數據一定要做脫敏處理。
如果對脫敏概念不熟悉,可以看一下我之前寫過的一篇大廠也在用的6種數據脫敏方案,里邊對脫敏做了簡單的描述,接下來分享工作中兩個比較常見的脫敏場景。
配置脫敏
實現配置的脫敏我使用了Java的一個加解密工具Jasypt,它提供了單密鑰對稱加密和非對稱加密兩種脫敏方式。
單密鑰對稱加密:一個密鑰加鹽,可以同時用作內容的加密和解密依據;
非對稱加密:使用公鑰和私鑰兩個密鑰,才可以對內容加密和解密;
以上兩種加密方式使用都非常簡單,咱們以springboot集成單密鑰對稱加密方式做示例。
- <!--配置文件加密-->
- <dependency>
- <groupId>com.github.ulisesbocchio</groupId>
- <artifactId>jasypt-spring-boot-starter</artifactId>
- <version>2.1.0</version>
- </dependency>
配置文件加入秘鑰配置項jasypt.encryptor.password,并將需要脫敏的value值替換成預先經過加密的內容ENC(mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l)。
這個格式我們是可以隨意定義的,比如想要abc[mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l]格式,只要配置前綴和后綴即可。
- jasypt:
- encryptor:
- property:
- prefix: "abc["
- suffix: "]"
ENC(XXX)格式主要為了便于識別該值是否需要解密,如不按照該格式配置,在加載配置項的時候jasypt將保持原值,不進行解密。
- spring:
- datasource:
- url: jdbc:mysql://1.2.3.4:3306/xiaofu?useSSL=false&useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&ze oDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai
- username: xiaofu
- password: ENC(mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l)
- # 秘鑰
- jasypt:
- encryptor:
- password: 程序員內點事(然而不支持中文)
秘鑰是個安全性要求比較高的屬性,所以一般不建議直接放在項目內,可以通過啟動時-D參數注入,或者放在配置中心,避免泄露。
- java -jar -Djasypt.encryptor.password=1123 springboot-jasypt-2.3.3.RELEASE.jar
預先生成的加密值,可以通過代碼內調用API生成
- @Autowired
- private StringEncryptor stringEncryptor;
- public void encrypt(String content) {
- String encryptStr = stringEncryptor.encrypt(content);
- System.out.println("加密后的內容:" + encryptStr);
- }
或者通過如下Java命令生成,幾個參數D:\maven_lib\org\jasypt\jasypt\1.9.3\jasypt-1.9.3.jar為jasypt核心jar包,input待加密文本,password秘鑰,algorithm為使用的加密算法。
- java -cp D:\maven_lib\org\jasypt\jasypt\1.9.3\jasypt-1.9.3.jar org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI input="root" password=xiaofu algorithm=PBEWithMD5AndDES
一頓操作后如果還能正常啟動,說明配置文件脫敏就沒問題了。
敏感字段脫敏
生產環境用戶的隱私數據,比如手機號、身份證或者一些賬號配置等信息,入庫時都要進行不落地脫敏,也就是在進入我們系統時就要實時的脫敏處理。
用戶數據進入系統,脫敏處理后持久化到數據庫,用戶查詢數據時還要進行反向解密。這種場景一般需要全局處理,那么用AOP切面來實現在適合不過了。
首先自定義兩個注解@EncryptField、@EncryptMethod分別用在字段屬性和方法上,實現思路很簡單,只要方法上應用到@EncryptMethod注解,則檢查入參字段是否標注@EncryptField注解,有則將對應字段內容加密。
- @Documented
- @Target({ElementType.FIELD,ElementType.PARAMETER})
- @Retention(RetentionPolicy.RUNTIME)
- public @interface EncryptField {
- String[] value() default "";
- }
- @Documented
- @Target({ElementType.METHOD})
- @Retention(RetentionPolicy.RUNTIME)
- public @interface EncryptMethod {
- String type() default ENCRYPT;
- }
切面的實現也比較簡單,對入參加密,返回結果解密。為了方便閱讀這里就只貼出部分代碼,完整案例Github地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-jasypt
- @Slf4j
- @Aspect
- @Component
- public class EncryptHandler {
- @Autowired
- private StringEncryptor stringEncryptor;
- @Pointcut("@annotation(com.xiaofu.annotation.EncryptMethod)")
- public void pointCut() {
- }
- @Around("pointCut()")
- public Object around(ProceedingJoinPoint joinPoint) {
- /**
- * 加密
- */
- encrypt(joinPoint);
- /**
- * 解密
- */
- Object decrypt = decrypt(joinPoint);
- return decrypt;
- }
- public void encrypt(ProceedingJoinPoint joinPoint) {
- try {
- Object[] objects = joinPoint.getArgs();
- if (objects.length != 0) {
- for (Object o : objects) {
- if (o instanceof String) {
- encryptValue(o);
- } else {
- handler(o, ENCRYPT);
- }
- //TODO 其余類型自己看實際情況加
- }
- }
- } catch (IllegalAccessException e) {
- e.printStackTrace();
- }
- }
- public Object decrypt(ProceedingJoinPoint joinPoint) {
- Object result = null;
- try {
- Object obj = joinPoint.proceed();
- if (obj != null) {
- if (obj instanceof String) {
- decryptValue(obj);
- } else {
- result = handler(obj, DECRYPT);
- }
- //TODO 其余類型自己看實際情況加
- }
- } catch (Throwable e) {
- e.printStackTrace();
- }
- return result;
- }
- 。。。
- }
緊接著測試一下切面注解的效果,我們對字段mobile、address加上注解@EncryptField做脫敏處理。
- @EncryptMethod
- @PostMapping(value = "test")
- @ResponseBody
- public Object testEncrypt(@RequestBody UserVo user, @EncryptField String name) {
- return insertUser(user, name);
- }
- private UserVo insertUser(UserVo user, String name) {
- System.out.println("加密后的數據:user" + JSON.toJSONString(user));
- return user;
- }
- @Data
- public class UserVo implements Serializable {
- private Long userId;
- @EncryptField
- private String mobile;
- @EncryptField
- private String address;
- private String age;
- }
請求這個接口,看到參數被成功加密,而返回給用戶的數據依然是脫敏前的數據,符合我們的預期,那到這簡單的脫敏實現就完事了。
知其然知其所以然
Jasypt工具雖然簡單好用,但作為程序員我們不能僅滿足于熟練使用,底層實現原理還是有必要了解下的,這對后續調試bug、二次開發擴展功能很重要。
個人認為Jasypt配置文件脫敏的原理很簡單,無非就是在具體使用配置信息之前,先攔截獲取配置的操作,將對應的加密配置解密后再使用。
具體是不是如此我們簡單看下源碼的實現,既然是以springboot方式集成,那么就先從jasypt-spring-boot-starter源碼開始入手。
starter代碼很少,主要的工作就是通過SPI機制注冊服務和@Import注解來注入需前置處理的類JasyptSpringBootAutoConfiguration。
在前置加載類EnableEncryptablePropertiesConfiguration中注冊了一個核心處理類EnableEncryptablePropertiesBeanFactoryPostProcessor。
它的構造器有兩個參數,ConfigurableEnvironment用來獲取所有配屬信息,EncryptablePropertySourceConverter對配置信息做解析處理。
順藤摸瓜發現具體負責解密的處理類EncryptablePropertySourceWrapper,它通過對Spring屬性管理類PropertySource
既然知道了原理那么后續我們二次開發,比如:切換加密算法或者實現自己的脫敏工具就容易的多了。
案例Github地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-jasypt
PBE算法
再來聊一下Jasypt中用的加密算法,其實它是在JDK的JCE.jar包基礎上做了封裝,本質上還是用的JDK提供的算法,默認使用的是PBE算法PBEWITHMD5ANDDES,看到這個算法命名很有意思,段個句看看,PBE、WITH、MD5、AND、DES 好像有點故事,繼續看。
PBE算法(Password Based Encryption,基于口令(密碼)的加密)是一種基于口令的加密算法,其特點在于口令是由用戶自己掌握,在加上隨機數多重加密等方法保證數據的安全性。
PBE算法本質上并沒有真正構建新的加密、解密算法,而是對我們已知的算法做了包裝。比如:常用的消息摘要算法MD5和SHA算法,對稱加密算法DES、RC2等,而PBE算法就是將這些算法進行合理組合,這也呼應上前邊算法的名字。
既然PBE算法使用我們較為常用的對稱加密算法,那就會涉及密鑰的問題。但它本身又沒有鑰的概念,只有口令密碼,密鑰則是口令經過加密算法計算得來的。
口令本身并不會很長,所以不能用來替代密鑰,只用口令很容易通過窮舉攻擊方式破譯,這時候就得加點鹽了。
鹽通常會是一些隨機信息,比如隨機數、時間戳,將鹽附加在口令上,通過算法計算加大破譯的難度。
源碼里的貓膩
簡單了解PBE算法,回過頭看看Jasypt源碼是如何實現加解密的。
在加密的時候首先實例化秘鑰工廠SecretKeyFactory,生成八位鹽值,默認使用的jasypt.encryptor.RandomSaltGenerator生成器。
- public byte[] encrypt(byte[] message) {
- // 根據指定算法,初始化秘鑰工廠
- final SecretKeyFactory factory = SecretKeyFactory.getInstance(algorithm1);
- // 鹽值生成器,只選八位
- byte[] salt = saltGenerator.generateSalt(8);
- //
- final PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, iterations);
- // 鹽值、口令生成秘鑰
- SecretKey key = factory.generateSecret(keySpec);
- // 構建加密器
- final Cipher cipherEncrypt = Cipher.getInstance(algorithm1);
- cipherEncrypt.init(Cipher.ENCRYPT_MODE, key);
- // 密文頭部(鹽值)
- byte[] params = cipherEncrypt.getParameters().getEncoded();
- // 調用底層實現加密
- byte[] encryptedMessage = cipherEncrypt.doFinal(message);
- // 組裝最終密文內容并分配內存(鹽值+密文)
- return ByteBuffer
- .allocate(1 + params.length + encryptedMessage.length)
- .put((byte) params.length)
- .put(params)
- .put(encryptedMessage)
- .array();
- }
由于默認使用的是隨機鹽值生成器,導致相同內容每次加密后的內容都是不同的。
那么解密時該怎么對應上呢?
看上邊的源碼發現,最終的加密文本是由兩部分組成的,params消息頭里邊包含口令和隨機生成的鹽值,encryptedMessage密文。
加密
而在解密時會根據密文encryptedMessage的內容拆解出params內容解析出鹽值和口令,在調用JDK底層算法解密出實際內容。
- @Override
- @SneakyThrows
- public byte[] decrypt(byte[] encryptedMessage) {
- // 獲取密文頭部內容
- int paramsLength = Byte.toUnsignedInt(encryptedMessage[0]);
- // 獲取密文內容
- int messageLength = encryptedMessage.length - paramsLength - 1;
- byte[] params = new byte[paramsLength];
- byte[] message = new byte[messageLength];
- System.arraycopy(encryptedMessage, 1, params, 0, paramsLength);
- System.arraycopy(encryptedMessage, paramsLength + 1, message, 0, messageLength);
- // 初始化秘鑰工廠
- final SecretKeyFactory factory = SecretKeyFactory.getInstance(algorithm1);
- final PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
- SecretKey key = factory.generateSecret(keySpec);
- // 構建頭部鹽值口令參數
- AlgorithmParameters algorithmParameters = AlgorithmParameters.getInstance(algorithm1);
- algorithmParameters.init(params);
- // 構建加密器,調用底層算法
- final Cipher cipherDecrypt = Cipher.getInstance(algorithm1);
- cipherDecrypt.init(
- Cipher.DECRYPT_MODE,
- key,
- algorithmParameters
- );
- return cipherDecrypt.doFinal(message);
- }
解密