JPA AES 引起的 String 二進制數據 DATA LOSS 問題

引言

系統要求高安全性,試題的數據須要加密,防止在數據庫層面進行數據泄漏。java

但試題的加密還與密碼的加密不一樣,用戶密碼的加密可使用Hash算法,沒法解密,不光在數據庫中,即使是咱們的程序也沒法獲悉用戶的密碼。面試

試題的加密要求數據庫層面是加密數據,從數據庫中查詢數據後,再對數據進行解密,發送給前臺。接口是通過精密的認證與鑑權的,保證安全。算法

最終對比各類方案,決定採用converter的實現方案。spring

實現

圖解

image.png

如上圖所示:當數據寫入前,對數據進行加密;數據查詢後,對數據進行解密。數據庫

全部的加密解密工做,均由自定義converter完成。數組

錯誤示例

照着Demo寫了一個使用AES加解密的converter安全

/**
 * 加密解決轉換器
 * 對象字段數據 與 數據表列之間轉換
 */
public class EncryptConverter implements AttributeConverter<String, String> {

    private static final Logger logger = LoggerFactory.getLogger(EncryptConverter.class);

    /**
     * AES 密鑰
     */
    private static final byte[] VALUE = "XQRhrQnGNFJf1WaSGOOJEjNhDjRPMG5N".getBytes(StandardCharsets.UTF_8);

    /**
     * 加密/解密 算法
     */
    private static final String ALGORITHM = "AES";

    /**
     * 加密/解密 密鑰
     */
    private static final Key KEY = new SecretKeySpec(VALUE, ALGORITHM);

    /**
     * 加密過程
     * 從對象字段數據 到 數據庫列
     */
    @Override
    public String convertToDatabaseColumn(String data) {
        String result;

        try {
            logger.debug("獲取算法");
            Cipher cipher = Cipher.getInstance(ALGORITHM);

            logger.debug("設置加密模式與加密密鑰");
            cipher.init(Cipher.ENCRYPT_MODE, KEY);

            logger.debug("獲取原始內容");
            byte[] rawData = data.getBytes(StandardCharsets.UTF_8);

            logger.debug("加密");
            byte[] encryptedData = cipher.doFinal(rawData);

            logger.debug("加密後的字節數組編碼爲字符串");
            result = new String(encryptedData);
        } catch (IllegalBlockSizeException | BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("encrypt error!", e);
        }

        return result;
    }

    @Override
    public String convertToEntityAttribute(String data) {
        String result;

        try {
            logger.debug("獲取算法");
            Cipher cipher = Cipher.getInstance(ALGORITHM);

            logger.debug("設置解密模式與解密密鑰");
            cipher.init(Cipher.DECRYPT_MODE, KEY);

            logger.debug("獲取加密字節數組");
            byte[] encryptedData = data.getBytes(StandardCharsets.UTF_8);

            logger.debug("解密爲原始內容");
            byte[] rawData = cipher.doFinal(encryptedData);

            logger.debug("解密");
            result = new String(rawData);
        } catch (IllegalBlockSizeException | BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("decrypt error!", e);
        }

        return result;
    }
}

再對要加密的字段添加@Convert註解,指明converter爲以前編寫的EncryptConverter.classapp

@Entity
public class Information {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Convert(converter = EncryptConverter.class)
    private String content;
}

跑一個單元測試看看是否生效。ide

@Test
void encrypt() {
    Information information = new Information();
    information.setContent("測試內容");
    informationRepository.save(information);

    Optional<Information> optional = informationRepository.findById(information.getId());
    System.out.println(optional);
}

出錯了,忽然發現事情並不簡單。函數

image.png

由於異常處理得比較好,定位錯誤十分迅速:

// JPA異常:使用AttributeConverter時發生錯誤。
org.springframework.orm.jpa.JpaSystemException: Error attempting to apply AttributeConverter; nested exception is javax.persistence.PersistenceException: Error attempting to apply AttributeConverter.
// 由這個錯誤引發:持久化錯誤:使用AttributeConverter時發生錯誤。
Caused by: javax.persistence.PersistenceException: Error attempting to apply AttributeConverter.
// 由這個錯誤引發:運行錯誤:加密錯誤!
Caused by: java.lang.RuntimeException: decrypt error!
// 由這個錯誤引發:非法的塊大小異常,當解密時輸入長度必須是16的倍數。
Caused by: javax.crypto.IllegalBlockSizeException: Input length must be multiple of 16 when decrypting with padded cipher.

同時,加密後的數據也有問題:

ag�U�)q\�|����

調試

通過調試與錯誤排查,發現問題出在字節數組與字符串的轉換上。

logger.debug("加密");
byte[] encryptedData = cipher.doFinal(rawData);

logger.debug("加密後的字節數組編碼爲字符串");
result = new String(encryptedData);

通讀String類源碼,解決該問題。

String類內部實現中,字符串與字節數組之間的轉換,是經過編碼與解碼實現的。

getBytes方法,將字符串轉換爲字節數組,即字符到二進制的編碼。

public byte[] getBytes(Charset charset) {
    if (charset == null) throw new NullPointerException();
    return StringCoding.encode(charset, value, 0, value.length);
}

String類默認採用UTF-8編碼,編碼時調用UTF_8類內部的encode方法,將字符數組編碼爲字節數組。

encode源碼,建議閱讀。

public int encode(char[] sa, int sp, int len, byte[] da) {
    int sl = sp + len;
    int dp = 0;
    int dlASCII = dp + Math.min(len, da.length);

    // ASCII only optimized loop
    while (dp < dlASCII && sa[sp] < '\u0080')
        da[dp++] = (byte) sa[sp++];

    while (sp < sl) {
        char c = sa[sp++];
        if (c < 0x80) {
            // Have at most seven bits
            da[dp++] = (byte)c;
        } else if (c < 0x800) {
            // 2 bytes, 11 bits
            da[dp++] = (byte)(0xc0 | (c >> 6));
            da[dp++] = (byte)(0x80 | (c & 0x3f));
        } else if (Character.isSurrogate(c)) {
            if (sgp == null)
                sgp = new Surrogate.Parser();
            int uc = sgp.parse(c, sa, sp - 1, sl);
            if (uc < 0) {
                if (malformedInputAction() != CodingErrorAction.REPLACE)
                    return -1;
                da[dp++] = repl;
            } else {
                da[dp++] = (byte)(0xf0 | ((uc >> 18)));
                da[dp++] = (byte)(0x80 | ((uc >> 12) & 0x3f));
                da[dp++] = (byte)(0x80 | ((uc >>  6) & 0x3f));
                da[dp++] = (byte)(0x80 | (uc & 0x3f));
                sp++;  // 2 chars
            }
        } else {
            // 3 bytes, 16 bits
            da[dp++] = (byte)(0xe0 | ((c >> 12)));
            da[dp++] = (byte)(0x80 | ((c >>  6) & 0x3f));
            da[dp++] = (byte)(0x80 | (c & 0x3f));
        }
    }
    return dp;
}

String類中的bytes構造函數,將字節數組轉換爲字符串,即二進制到字符的解碼。

public String(byte bytes[]) {
    this(bytes, 0, bytes.length);
}

與編碼相似,解碼時調用UTF_8類內部的decode方法,將字節數組解碼爲字符數組。

decode源碼,建議閱讀。

public int decode(byte[] sa, int sp, int len, char[] da) {
    final int sl = sp + len;
    int dp = 0;
    int dlASCII = Math.min(len, da.length);
    ByteBuffer bb = null;  // only necessary if malformed

    // ASCII only optimized loop
    while (dp < dlASCII && sa[sp] >= 0)
        da[dp++] = (char) sa[sp++];

    while (sp < sl) {
        int b1 = sa[sp++];
        if (b1 >= 0) {
            // 1 byte, 7 bits: 0xxxxxxx
            da[dp++] = (char) b1;
        } else if ((b1 >> 5) == -2 && (b1 & 0x1e) != 0) {
            // 2 bytes, 11 bits: 110xxxxx 10xxxxxx
            if (sp < sl) {
                int b2 = sa[sp++];
                if (isNotContinuation(b2)) {
                    if (malformedInputAction() != CodingErrorAction.REPLACE)
                        return -1;
                    da[dp++] = replacement().charAt(0);
                    sp--;            // malformedN(bb, 2) always returns 1
                } else {
                    da[dp++] = (char) (((b1 << 6) ^ b2)^
                                   (((byte) 0xC0 << 6) ^
                                    ((byte) 0x80 << 0)));
                }
                continue;
            }
            if (malformedInputAction() != CodingErrorAction.REPLACE)
                return -1;
            da[dp++] = replacement().charAt(0);
            return dp;
        } else if ((b1 >> 4) == -2) {
            // 3 bytes, 16 bits: 1110xxxx 10xxxxxx 10xxxxxx
            if (sp + 1 < sl) {
                int b2 = sa[sp++];
                int b3 = sa[sp++];
                if (isMalformed3(b1, b2, b3)) {
                    if (malformedInputAction() != CodingErrorAction.REPLACE)
                        return -1;
                    da[dp++] = replacement().charAt(0);
                    sp -= 3;
                    bb = getByteBuffer(bb, sa, sp);
                    sp += malformedN(bb, 3).length();
                } else {
                    char c = (char)((b1 << 12) ^
                                      (b2 <<  6) ^
                                      (b3 ^
                                      (((byte) 0xE0 << 12) ^
                                      ((byte) 0x80 <<  6) ^
                                      ((byte) 0x80 <<  0))));
                    if (Character.isSurrogate(c)) {
                        if (malformedInputAction() != CodingErrorAction.REPLACE)
                            return -1;
                        da[dp++] = replacement().charAt(0);
                    } else {
                        da[dp++] = c;
                    }
                }
                continue;
            }
            if (malformedInputAction() != CodingErrorAction.REPLACE)
                return -1;
            if (sp  < sl && isMalformed3_2(b1, sa[sp])) {
                da[dp++] = replacement().charAt(0);
                continue;

            }
            da[dp++] = replacement().charAt(0);
            return dp;
        } else if ((b1 >> 3) == -2) {
            // 4 bytes, 21 bits: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
            if (sp + 2 < sl) {
                int b2 = sa[sp++];
                int b3 = sa[sp++];
                int b4 = sa[sp++];
                int uc = ((b1 << 18) ^
                          (b2 << 12) ^
                          (b3 <<  6) ^
                          (b4 ^
                           (((byte) 0xF0 << 18) ^
                           ((byte) 0x80 << 12) ^
                           ((byte) 0x80 <<  6) ^
                           ((byte) 0x80 <<  0))));
                if (isMalformed4(b2, b3, b4) ||
                    // shortest form check
                    !Character.isSupplementaryCodePoint(uc)) {
                    if (malformedInputAction() != CodingErrorAction.REPLACE)
                        return -1;
                    da[dp++] = replacement().charAt(0);
                    sp -= 4;
                    bb = getByteBuffer(bb, sa, sp);
                    sp += malformedN(bb, 4).length();
                } else {
                    da[dp++] = Character.highSurrogate(uc);
                    da[dp++] = Character.lowSurrogate(uc);
                }
                continue;
            }
            if (malformedInputAction() != CodingErrorAction.REPLACE)
                return -1;
            b1 &= 0xff;
            if (b1 > 0xf4 ||
                sp  < sl && isMalformed4_2(b1, sa[sp] & 0xff)) {
                da[dp++] = replacement().charAt(0);
                continue;
            }
            sp++;
            if (sp  < sl && isMalformed4_3(sa[sp])) {
                da[dp++] = replacement().charAt(0);
                continue;
            }
            da[dp++] = replacement().charAt(0);
            return dp;
        } else {
            if (malformedInputAction() != CodingErrorAction.REPLACE)
                return -1;
            da[dp++] = replacement().charAt(0);
        }
    }
    return dp;
}

DATA LOSS

若是閱讀過源碼以後,或者對編碼比較熟悉,應該明白問題出在哪裏了。

解碼過程當中可能出現解碼錯誤的狀況。

image.png

UTF-8中,一個字符確定對應着一個編碼,可是一個編碼,不必定對應着一個字符。

logger.debug("加密");
byte[] encryptedData = cipher.doFinal(rawData);

logger.debug("加密後的字節數組編碼爲字符串");
result = new String(encryptedData);

因此,加密後的字節數組不符合UTF-8編碼規則,解碼錯誤。

而後,數據庫中加密後的數據就這樣了:

ag�U�)q\�|����

解密的時候,查詢這個異常的字符串,再調用getBytes方法將其編碼爲字節數組。

由於解碼的錯誤,因此這裏的編碼結果也不正確,因此解密中獲取的字節數組與原加密後的字節數組不一樣。

image.png

如上圖所示:左側爲編碼時的字節數組各下標內容,右側爲解碼時的字節數組各下標內容。

二者不等價,這在Java術語中稱爲DATA LOSS

因此會有這句建議:

String is not a good container for binary data.

解決

將原須要進行編碼的new String(bytes)修改成對二進制數據無損的方法調用便可。

我這裏採用Base64方式對字節數組進行編碼解碼,保證數據無損。

完整代碼:

/**
 * 加密解決轉換器
 * 對象字段數據 與 數據表列之間轉換
 */
public class EncryptConverter implements AttributeConverter<String, String> {

    private static final Logger logger = LoggerFactory.getLogger(EncryptConverter.class);

    /**
     * AES 密鑰
     */
    private static final byte[] VALUE = "XQRhrQnGNFJf1WaSGOOJEjNhDjRPMG5N".getBytes(StandardCharsets.UTF_8);

    /**
     * 加密/解密 算法
     */
    private static final String ALGORITHM = "AES";

    /**
     * 加密/解密 密鑰
     */
    private static final Key KEY = new SecretKeySpec(VALUE, ALGORITHM);

    /**
     * 加密過程
     * 從對象字段數據 到 數據庫列
     */
    @Override
    public String convertToDatabaseColumn(String data) {
        String result;

        try {
            logger.debug("獲取算法");
            Cipher cipher = Cipher.getInstance(ALGORITHM);

            logger.debug("設置加密模式與加密密鑰");
            cipher.init(Cipher.ENCRYPT_MODE, KEY);

            logger.debug("獲取原始內容");
            byte[] rawData = data.getBytes(StandardCharsets.UTF_8);

            logger.debug("加密");
            byte[] encryptedData = cipher.doFinal(rawData);

            logger.debug("BASE64 將加密後的字節數組編碼爲字符串");
            result = Base64.getEncoder().encodeToString(encryptedData);
        } catch (IllegalBlockSizeException | BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("encrypt error!", e);
        }

        return result;
    }

    @Override
    public String convertToEntityAttribute(String data) {
        String result;

        try {
            logger.debug("獲取算法");
            Cipher cipher = Cipher.getInstance(ALGORITHM);

            logger.debug("設置解密模式與解密密鑰");
            cipher.init(Cipher.DECRYPT_MODE, KEY);

            logger.debug("BASE64 將編碼後的字符串解碼爲加密字節數組");
            byte[] encryptedData = Base64.getDecoder().decode(data);

            logger.debug("解密爲原始內容");
            byte[] rawData = cipher.doFinal(encryptedData);

            logger.debug("解密");
            result = new String(rawData);
        } catch (IllegalBlockSizeException | BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("decrypt error!", e);
        }

        return result;
    }
}

測試

再次運行該單元測試。

@Test
void encrypt() {
    Information information = new Information();
    information.setContent("測試內容");
    informationRepository.save(information);

    Optional<Information> optional = informationRepository.findById(information.getId());
    System.out.println(optional);
}

數據表中數據完成加密:

image.png

數據查詢後自動解密:

image.png

總結

源碼,由於面試而變了味道。

程序,由於資本而變得功利。

你,還記得寫代碼的初心嗎?

相關文章
相關標籤/搜索