實(shí)戰(zhàn):隱藏SpringBoot中的私密數(shù)據(jù)!

這幾天公司在排查內(nèi)部數(shù)據(jù)賬號(hào)泄漏,原因是發(fā)現(xiàn)某些實(shí)習(xí)生小可愛(ài)居然連帶著賬號(hào)、密碼將源碼私傳到GitHub上,導(dǎo)致核心數(shù)據(jù)外漏,孩子還是沒(méi)挨過(guò)社會(huì)毒打,這種事的后果可大可小。

說(shuō)起這個(gè)我是比較有感觸的,之前我TM被刪庫(kù)的經(jīng)歷,到現(xiàn)在想起來(lái)心里還難受,我也是把數(shù)據(jù)庫(kù)賬號(hào)明文密碼誤提交到GitHub,然后被哪個(gè)大寶貝給我測(cè)試庫(kù)刪了,后邊我長(zhǎng)記性了把配置文件內(nèi)容都加密了,數(shù)據(jù)安全問(wèn)題真的不容小覷,不管工作匯還是生活,敏感數(shù)據(jù)一定要做脫敏處理。
所以接下來(lái),咱們需要開(kāi)展兩方面的工作:
對(duì)配置文件進(jìn)行脫敏操作;
對(duì)敏感的數(shù)據(jù)庫(kù)字段進(jìn)行脫敏操作。
說(shuō)干就干,接下來(lái)咱們一起來(lái)對(duì)項(xiàng)目進(jìn)行脫敏操作。
1.配置脫敏
實(shí)現(xiàn)配置的脫敏我使用了Java的一個(gè)加解密工具Jasypt,它提供了單密鑰對(duì)稱加密和非對(duì)稱加密兩種脫敏方式。
單密鑰對(duì)稱加密:一個(gè)密鑰加鹽,可以同時(shí)用作內(nèi)容的加密和解密依據(jù);
非對(duì)稱加密:使用公鑰和私鑰兩個(gè)密鑰,才可以對(duì)內(nèi)容加密和解密;
以上兩種加密方式使用都非常簡(jiǎn)單,咱們以springboot集成單密鑰對(duì)稱加密方式做示例。
首先引入jasypt-spring-boot-starter jar
<!--配置文件加密-->
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
配置文件加入秘鑰配置項(xiàng)jasypt.encryptor.password,并將需要脫敏的value值替換成預(yù)先經(jīng)過(guò)加密的內(nèi)容ENC(mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l)。
這個(gè)格式我們是可以隨意定義的,比如想要abc[mVTvp4IddqdaYGqPl9lCQbzM3H/b0B6l]格式,只要配置前綴和后綴即可。
jasypt:
encryptor:
property:
prefix: "abc["
suffix: "]"
ENC(XXX)格式主要為了便于識(shí)別該值是否需要解密,如不按照該格式配置,在加載配置項(xiàng)的時(shí)候jasypt將保持原值,不進(jìn)行解密。
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: 程序員內(nèi)點(diǎn)事(然而不支持中文)
秘鑰是個(gè)安全性要求比較高的屬性,所以一般不建議直接放在項(xiàng)目?jī)?nèi),可以通過(guò)啟動(dòng)時(shí)-D參數(shù)注入,或者放在配置中心,避免泄露。
java -jar -Djasypt.encryptor.password=1123 springboot-jasypt-2.3.3.RELEASE.jar
預(yù)先生成的加密值,可以通過(guò)代碼內(nèi)調(diào)用API生成
@Autowired
private StringEncryptor stringEncryptor;
public void encrypt(String content) {
String encryptStr = stringEncryptor.encrypt(content);
System.out.println("加密后的內(nèi)容:" + encryptStr);
}
或者通過(guò)如下Java命令生成,幾個(gè)參數(shù)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

一頓操作后如果還能正常啟動(dòng),說(shuō)明配置文件脫敏就沒(méi)問(wèn)題了。
2.敏感字段脫敏
生產(chǎn)環(huán)境用戶的隱私數(shù)據(jù),比如手機(jī)號(hào)、身份證或者一些賬號(hào)配置等信息,入庫(kù)時(shí)都要進(jìn)行不落地脫敏,也就是在進(jìn)入我們系統(tǒng)時(shí)就要實(shí)時(shí)的脫敏處理。
用戶數(shù)據(jù)進(jìn)入系統(tǒng),脫敏處理后持久化到數(shù)據(jù)庫(kù),用戶查詢數(shù)據(jù)時(shí)還要進(jìn)行反向解密。這種場(chǎng)景一般需要全局處理,那么用AOP切面來(lái)實(shí)現(xiàn)在適合不過(guò)了。

首先自定義兩個(gè)注解@EncryptField、@EncryptMethod分別用在字段屬性和方法上,實(shí)現(xiàn)思路很簡(jiǎn)單,只要方法上應(yīng)用到@EncryptMethod注解,則檢查入?yún)⒆侄问欠駱?biāo)注@EncryptField注解,有則將對(duì)應(yīng)字段內(nèi)容加密。
@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;
}
切面的實(shí)現(xiàn)也比較簡(jiǎn)單,對(duì)入?yún)⒓用埽祷亟Y(jié)果解密。為了方便閱讀這里就只貼出部分代碼,完整案例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 其余類(lèi)型自己看實(shí)際情況加
}
}
} 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 其余類(lèi)型自己看實(shí)際情況加
}
} catch (Throwable e) {
e.printStackTrace();
}
return result;
}
。。。
}
緊接著測(cè)試一下切面注解的效果,我們對(duì)字段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("加密后的數(shù)據(jù):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;
}
請(qǐng)求這個(gè)接口,看到參數(shù)被成功加密,而返回給用戶的數(shù)據(jù)依然是脫敏前的數(shù)據(jù),符合我們的預(yù)期,那到這簡(jiǎn)單的脫敏實(shí)現(xiàn)就完事了。


3.原理分析
Jasypt工具雖然簡(jiǎn)單好用,但作為程序員我們不能僅滿足于熟練使用,底層實(shí)現(xiàn)原理還是有必要了解下的,這對(duì)后續(xù)調(diào)試bug、二次開(kāi)發(fā)擴(kuò)展功能很重要。
個(gè)人認(rèn)為Jasypt配置文件脫敏的原理很簡(jiǎn)單,無(wú)非就是在具體使用配置信息之前,先攔截獲取配置的操作,將對(duì)應(yīng)的加密配置解密后再使用。
具體是不是如此我們簡(jiǎn)單看下源碼的實(shí)現(xiàn),既然是以springboot方式集成,那么就先從jasypt-spring-boot-starter源碼開(kāi)始入手。
starter代碼很少,主要的工作就是通過(guò)SPI機(jī)制注冊(cè)服務(wù)和@Import注解來(lái)注入需前置處理的類(lèi)JasyptSpringBootAutoConfiguration。

在前置加載類(lèi)EnableEncryptablePropertiesConfiguration中注冊(cè)了一個(gè)核心處理類(lèi)EnableEncryptablePropertiesBeanFactoryPostProcessor。

它的構(gòu)造器有兩個(gè)參數(shù),ConfigurableEnvironment用來(lái)獲取所有配屬信息,EncryptablePropertySourceConverter對(duì)配置信息做解析處理。
順藤摸瓜發(fā)現(xiàn)具體負(fù)責(zé)解密的處理類(lèi)EncryptablePropertySourceWrapper,它通過(guò)對(duì)Spring屬性管理類(lèi)PropertySource<T>做拓展,重寫(xiě)了getProperty(String name)方法,在獲取配置時(shí),凡是指定格式如ENC(x) 包裹的值全部解密處理。

既然知道了原理那么后續(xù)我們二次開(kāi)發(fā),比如:切換加密算法或者實(shí)現(xiàn)自己的脫敏工具就容易的多了。
“案例Github地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-jasypt
PBE算法
再來(lái)聊一下Jasypt中用的加密算法,其實(shí)它是在JDK的JCE.jar包基礎(chǔ)上做了封裝,本質(zhì)上還是用的JDK提供的算法,默認(rèn)使用的是PBE算法PBEWITHMD5ANDDES,看到這個(gè)算法命名很有意思,段個(gè)句看看,PBE、WITH、MD5、AND、DES 好像有點(diǎn)故事,繼續(xù)看。

PBE算法(Password Based Encryption,基于口令(密碼)的加密)是一種基于口令的加密算法,其特點(diǎn)在于口令是由用戶自己掌握,在加上隨機(jī)數(shù)多重加密等方法保證數(shù)據(jù)的安全性。
PBE算法本質(zhì)上并沒(méi)有真正構(gòu)建新的加密、解密算法,而是對(duì)我們已知的算法做了包裝。比如:常用的消息摘要算法MD5和SHA算法,對(duì)稱加密算法DES、RC2等,而PBE算法就是將這些算法進(jìn)行合理組合,這也呼應(yīng)上前邊算法的名字。

既然PBE算法使用我們較為常用的對(duì)稱加密算法,那就會(huì)涉及密鑰的問(wèn)題。但它本身又沒(méi)有鑰的概念,只有口令密碼,密鑰則是口令經(jīng)過(guò)加密算法計(jì)算得來(lái)的。
口令本身并不會(huì)很長(zhǎng),所以不能用來(lái)替代密鑰,只用口令很容易通過(guò)窮舉攻擊方式破譯,這時(shí)候就得加點(diǎn)鹽了。
鹽通常會(huì)是一些隨機(jī)信息,比如隨機(jī)數(shù)、時(shí)間戳,將鹽附加在口令上,通過(guò)算法計(jì)算加大破譯的難度。
源碼里的貓膩
簡(jiǎn)單了解PBE算法,回過(guò)頭看看Jasypt源碼是如何實(shí)現(xiàn)加解密的。
在加密的時(shí)候首先實(shí)例化秘鑰工廠SecretKeyFactory,生成八位鹽值,默認(rèn)使用的jasypt.encryptor.RandomSaltGenerator生成器。
public byte[] encrypt(byte[] message) {
// 根據(jù)指定算法,初始化秘鑰工廠
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);
// 構(gòu)建加密器
final Cipher cipherEncrypt = Cipher.getInstance(algorithm1);
cipherEncrypt.init(Cipher.ENCRYPT_MODE, key);
// 密文頭部(鹽值)
byte[] params = cipherEncrypt.getParameters().getEncoded();
// 調(diào)用底層實(shí)現(xiàn)加密
byte[] encryptedMessage = cipherEncrypt.doFinal(message);
// 組裝最終密文內(nèi)容并分配內(nèi)存(鹽值+密文)
return ByteBuffer
.allocate(1 + params.length + encryptedMessage.length)
.put((byte) params.length)
.put(params)
.put(encryptedMessage)
.array();
}
由于默認(rèn)使用的是隨機(jī)鹽值生成器,導(dǎo)致相同內(nèi)容每次加密后的內(nèi)容都是不同的。
那么解密時(shí)該怎么對(duì)應(yīng)上呢?
看上邊的源碼發(fā)現(xiàn),最終的加密文本是由兩部分組成的,params消息頭里邊包含口令和隨機(jī)生成的鹽值,encryptedMessage密文。

而在解密時(shí)會(huì)根據(jù)密文encryptedMessage的內(nèi)容拆解出params內(nèi)容解析出鹽值和口令,在調(diào)用JDK底層算法解密出實(shí)際內(nèi)容。
@Override
@SneakyThrows
public byte[] decrypt(byte[] encryptedMessage) {
// 獲取密文頭部?jī)?nèi)容
int paramsLength = Byte.toUnsignedInt(encryptedMessage[0]);
// 獲取密文內(nèi)容
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);
// 構(gòu)建頭部鹽值口令參數(shù)
AlgorithmParameters algorithmParameters = AlgorithmParameters.getInstance(algorithm1);
algorithmParameters.init(params);
// 構(gòu)建加密器,調(diào)用底層算法
final Cipher cipherDecrypt = Cipher.getInstance(algorithm1);
cipherDecrypt.init(
Cipher.DECRYPT_MODE,
key,
algorithmParameters
);
return cipherDecrypt.doFinal(message);
}

我是磊哥~,如果對(duì)你有用在看、關(guān)注支持下,咱們下期見(jiàn)~

往期推薦

SpringBoot 如何統(tǒng)一后端返回格式?老鳥(niǎo)們都是這樣玩的!

SpringBoot時(shí)間格式化的5種方法!

SpringBoot 優(yōu)雅的參數(shù)效驗(yàn)!
