1. 程式人生 > 其它 >Springboot 配置檔案、隱私資料脫敏的最佳實踐(原理+原始碼)

Springboot 配置檔案、隱私資料脫敏的最佳實踐(原理+原始碼)

大家好!我是小富~

這幾天公司在排查內部資料賬號洩漏,原因是發現某些實習生小可愛居然連帶著賬號、密碼將原始碼私傳到GitHub上,導致核心資料外漏,孩子還是沒捱過社會毒打,這種事的後果可大可小。

說起這個我是比較有感觸的,之前我TM被刪庫的經歷,到現在想起來心裡還難受,我也是把資料庫賬號明文密碼誤提交到GitHub,然後被哪個大寶貝給我測試庫刪了,後邊我長記性了把配置檔案內容都加密了,資料安全問題真的不容小覷,不管工作匯還是生活,敏感資料一定要做脫敏處理。

如果對脫敏概念不熟悉,可以看一下我之前寫過的一篇大廠也在用的6種資料脫敏方案,裡邊對脫敏做了簡單的描述,接下來分享工作中兩個比較常見的脫敏場景。

配置脫敏

實現配置的脫敏我使用了Java的一個加解密工具Jasypt,它提供了單金鑰對稱加密非對稱加密兩種脫敏方式。

單金鑰對稱加密:一個金鑰加鹽,可以同時用作內容的加密和解密依據;

非對稱加密:使用公鑰和私鑰兩個金鑰,才可以對內容加密和解密;

以上兩種加密方式使用都非常簡單,咱們以springboot整合單金鑰對稱加密方式做示例。

首先引入jasypt-spring-boot-starter jar

 <!--配置檔案加密-->
 <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;
    }
    。。。
}

緊接著測試一下切面註解的效果,我們對欄位mobileaddress加上註解@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<T>做拓展,重寫了getProperty(String name)方法,在獲取配置時,凡是指定格式如ENC(x) 包裹的值全部解密處理。

既然知道了原理那麼後續我們二次開發,比如:切換加密演算法或者實現自己的脫敏工具就容易的多了。

案例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演算法本質上並沒有真正構建新的加密、解密演算法,而是對我們已知的演算法做了包裝。比如:常用的訊息摘要演算法MD5SHA演算法,對稱加密演算法DESRC2等,而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);
}

我是小富,下期見~

整理了幾百本各類技術電子書,有需要的同學自取。技術群快滿了,想進的同學可以加我好友,和大佬們一起吹吹技術。

電子書地址

個人公眾號: 程式設計師內點事,歡迎交流