加解密方法
密钥示例:
# 生成一个 32 字节(256位)的 Base64 密钥 (你可以自己用代码生成一个写死在这里)
export DB_ENCRYPT_KEY="YourBase64Encoded32ByteKeyHere1234567890AB"
生成合法的 32 字节 Base64 密钥函数
import java.util.Base64;
public class KeyGen {
public static void main(String[] args) {
byte[] key = new byte[32]; // 256位 = 32字节
new java.security.SecureRandom().nextBytes(key);
System.out.println("你的本地密钥: " + Base64.getEncoder().encodeToString(key));
}
}
加密解密工具类
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
public class AesGcmUtil {
/*
* 算法解释:AES-256-GCM
* AES-256:当前最安全的对称加密标准,密钥长度必须是 32 字节(256位)。
* GCM:一种加密模式。比老式的 CBC/ECB 好在:它不仅能加密,还能自动给数据算一个“防伪标签”(Tag)。
* 如果黑客篡改了数据库里的密文哪怕一个标点符号,解密时 GCM 就会直接报错拒绝,防止恶意注入。
* NoPadding:GCM 模式不需要填充数据,写死就行。
*/
private static final String ALGORITHM = "AES/GCM/NoPadding";
// TAG_LENGTH_BIT = 128:防伪标签的长度是 128 位。这是 GCM 模式的最高安全级别,写死不能改。
private static final int TAG_LENGTH_BIT = 128;
// IV_LENGTH_BYTE = 12:初始化向量的长度是 12 字节。这是 GCM 模式官方推荐的长度,写死不能改。
private static final int IV_LENGTH_BYTE = 12;
/**
* 加密方法
*
* @param plainText 用户从前端传过来的原始密码(比如 "123456")
* @param base64Key 从 IDEA 环境变量拿到的那个长字符串密钥
* @return 返回一串 Base64 字符串,这就是你要存进数据库的“密文”
*/
public static String encrypt(String plainText, String base64Key) throws Exception {
// 1. 把 Base64 格式的字符串密钥,还原成 Java 认识的 32 字节二进制数组
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
if (keyBytes.length != 32) {
throw new IllegalArgumentException("AES-256 密钥必须严格为 32 字节!请检查环境变量配置。");
}
// 2. 生成随机 IV(初始化向量)
// 为什么需要 IV?如果两个用户的密码都是 "123456",没有 IV 的话,加密出来的密文是一模一样的,黑客一看就知道密码一样。
// 加上随机 IV 后,哪怕每次加密同一个 "123456",生成的密文也完全不同,极其安全。
byte[] iv = new byte[IV_LENGTH_BYTE];
new SecureRandom().nextBytes(iv); // 产生真随机数
// 3. 准备加密器
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
// 告诉加密器:用这个密钥,用这个随机 IV,准备干活
GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
// 4. 执行加密,得到加密后的二进制数据(里面已经包含了密文 + 128位的防伪Tag)
byte[] cipherText = cipher.doFinal(plainText.getBytes("UTF-8"));
// 5. 拼装最终要存库的数据
// 规则是:[ 12字节的IV ] + [ 加密后的密文+Tag ]
// 为什么要拼在一起?因为解密的时候,必须要有当初加密用的那个 IV,不然解不出来。所以把它们绑在一起存起来。
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
byteBuffer.put(iv); // 先放 IV
byteBuffer.put(cipherText); // 再放密文
// 6. 把二进制转成 Base64 字符串返回(因为数据库里通常存文本字段,不支持直接存二进制流)
return Base64.getEncoder().encodeToString(byteBuffer.array());
}
/**
* 解密方法
*
* @param cipherTextBase64 从数据库里查出来的那串长长的 Base64 密文
* @param base64Key 从 IDEA 环境变量拿到的那个长字符串密钥(必须和加密时用的一样)
* @return 返回一个 char 数组,里面装的是解密出来的原始密码
*/
public static char[] decryptToCharArray(String cipherTextBase64, String base64Key) throws Exception {
// 1. 把 Base64 密钥还原成 32 字节二进制
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
// 2. 把数据库里的 Base64 密文,还原成当初拼好的二进制流:[ IV(12字节) ] + [ 密文+Tag ]
byte[] cipherMessage = Base64.getDecoder().decode(cipherTextBase64);
if (cipherMessage.length < IV_LENGTH_BYTE) {
throw new IllegalArgumentException("密文数据太短,格式都不对,可能是被篡改了!");
}
// 3. 切割数据:把前 12 字节切出来当 IV,剩下的当成真正的加密数据
ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage);
byte[] iv = new byte[IV_LENGTH_BYTE];
byteBuffer.get(iv); // 从流里读走前 12 字节作为 IV
byte[] cipherText = new byte[byteBuffer.remaining()]; // 剩下的所有字节
byteBuffer.get(cipherText); // 读走剩下的字节作为密文+Tag
// 4. 准备解密器
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); // 注意这里是 DECRYPT_MODE
// 5. 执行解密
// 注意:这行代码底层会自动校验 Tag。如果数据库里的密文被人改过,这里直接抛出 AEADBadTagException 异常!
byte[] plainTextBytes = cipher.doFinal(cipherText);
// 6. 转换为 char 数组(这是安全规范要求的做法)
char[] plainTextChars = new char[plainTextBytes.length];
for (int i = 0; i < plainTextBytes.length; i++) {
plainTextChars[i] = (char) plainTextBytes[i];
}
// 7. 手动把 byte 数组清空(防止明文留在内存里)
Arrays.fill(plainTextBytes, (byte) 0);
return plainTextChars; // 把原始密码交出去
}
/**
* 安全清理工具方法
* 用完密码后,调用这个方法把 char 数组填满空字符,防止被内存抓取工具偷走
*/
public static void clearCharArray(char[] array) {
if (array != null) {
Arrays.fill(array, '\0');
}
}
}
优化后的方法:
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
/**
* 加密解密工具类
*/
public class AesGcmUtil {
private AesGcmUtil() {
/* This utility class should not be instantiated */
}
/*
* 算法解释:AES-256-GCM
* AES-256:当前最安全的对称加密标准,密钥长度必须是 32 字节(256位)。
* GCM:一种加密模式。拒绝密文被篡改,被篡改后直接报错
* NoPadding:GCM 模式不需要填充数据,写死就行。
*/
private static final String ALGORITHM = "AES/GCM/NoPadding";
// TAG_LENGTH_BIT = 128:防伪标签的长度是 128 位。这是 GCM 模式的最高安全级别,写死不能改。
private static final int TAG_LENGTH_BIT = 128;
// IV_LENGTH_BYTE = 12:初始化向量的长度是 12 字节。这是 GCM 模式官方推荐的长度,写死不能改。
private static final int IV_LENGTH_BYTE = 12;
/**
* 这是一个重量级对象,在这里用来复用
*/
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**
* 加密方法
*
* @param plainText 用户从前端传过来的原始密码(比如 "123456")
* @param base64Key 从 IDEA 环境变量拿到的那个长字符串密钥
* @return 返回一串 Base64 字符串,这就是你要存进数据库的“密文”
*/
public static String encrypt(String plainText, String base64Key) throws Exception {
// 1. 把 Base64 格式的字符串密钥,还原成 Java 认识的 32 字节二进制数组
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
if (keyBytes.length != 32) {
throw new IllegalArgumentException("AES-256 密钥必须严格为 32 字节!请检查环境变量配置。");
}
// 2. 生成随机 IV(初始化向量)
byte[] iv = new byte[IV_LENGTH_BYTE];
SECURE_RANDOM.nextBytes(iv); // 产生真随机数
// 3. 准备加密器
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
// 引入IV
GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
// 4. 执行加密,得到加密后的二进制数据(里面已经包含了密文 + 128位的防伪Tag)
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// 5. 拼装最终要存库的数据
// 规则是:[ 12字节的IV ] + [ 加密后的密文+Tag ]
// 为什么要拼在一起?因为解密的时候,必须要有当初加密用的那个 IV,不然解不出来。所以把它们绑在一起存起来。
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
byteBuffer.put(iv); // 先放 IV
byteBuffer.put(cipherText); // 再放密文
// 6. 把二进制转成 Base64 字符串返回(因为数据库里通常存文本字段,不支持直接存二进制流)
return Base64.getEncoder().encodeToString(byteBuffer.array());
}
/**
* 解密方法
*
* @param cipherTextBase64 从数据库里查出来的那串长长的 Base64 密文
* @param base64Key 从 IDEA 环境变量拿到的那个长字符串密钥(必须和加密时用的一样)
* @return 返回一个 char 数组,里面装的是解密出来的原始密码
*/
public static char[] decryptToCharArray(String cipherTextBase64, String base64Key) throws Exception {
// 1. 把 Base64 密钥还原成 32 字节二进制
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
// 2. 把数据库里的 Base64 密文,还原成当初拼好的二进制流:[ IV(12字节) ] + [ 密文+Tag ]
byte[] cipherMessage = Base64.getDecoder().decode(cipherTextBase64);
if (cipherMessage.length < IV_LENGTH_BYTE) {
throw new IllegalArgumentException("密文数据太短,格式都不对,可能是被篡改了!");
}
// 3. 切割数据:把前 12 字节切出来当 IV,剩下的当成真正的加密数据
ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage);
byte[] iv = new byte[IV_LENGTH_BYTE];
byteBuffer.get(iv); // 从流里读走前 12 字节作为 IV
byte[] cipherText = new byte[byteBuffer.remaining()]; // 剩下的所有字节
byteBuffer.get(cipherText); // 读走剩下的字节作为密文+Tag
// 4. 准备解密器
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); // 注意这里是 DECRYPT_MODE
// 5. 执行解密
// 注意:这行代码底层会自动校验 Tag。如果数据库里的密文被人改过,这里直接抛出 AEADBadTagException 异常!
byte[] plainTextBytes = cipher.doFinal(cipherText);
// 6. 转换为 char 数组(这是安全规范要求的做法)
char[] plainTextChars = new char[plainTextBytes.length];
for (int i = 0; i < plainTextBytes.length; i++) {
plainTextChars[i] = (char) plainTextBytes[i];
}
// 7. 手动把 byte 数组清空(防止明文留在内存里)
Arrays.fill(plainTextBytes, (byte) 0);
return plainTextChars; // 把原始密码交出去
}
/**
* 安全清理工具方法
* 用完密码后,调用这个方法把 char 数组填满空字符,防止被内存抓取工具偷走
*/
public static void clearCharArray(char[] array) {
if (array != null) {
Arrays.fill(array, '\0');
}
}
}
业务使用(存储与连接):
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.PreDestroy;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class DynamicDataSourceService {
// 从环境变量注入密钥,如果找不到直接启动失败,拒绝运行
@Value("${DB_ENCRYPT_KEY}")
private String aesKey;
// 假设这是你的 Mapper
private final DataSourceConfigMapper mapper;
// 缓存创建好的数据源
private final Map<Long, HikariDataSource> dataSourceCache = new ConcurrentHashMap<>();
public DynamicDataSourceService(DataSourceConfigMapper mapper) {
this.mapper = mapper;
}
/**
* 1. 用户输入明文,加密后存库
*/
public void saveDataSourceConfig(Long id, String ip, String port, String user, String rawPassword) {
try {
// 在这里直接加密,rawPassword 用完交给 GC,不落明文盘
String encryptedPwd = AesGcmUtil.encrypt(rawPassword, aesKey);
// 存入数据库 (实体类中也没有明文密码字段)
DataSourceEntity entity = new DataSourceEntity();
entity.setId(id);
entity.setIp(ip);
entity.setPort(port);
entity.setUser(user);
entity.setEncryptedPassword(encryptedPwd); // 存密文
mapper.insert(entity);
} catch (Exception e) {
throw new RuntimeException("数据源配置保存失败", e);
}
}
/**
* 2. 获取数据源连接 (核心安全逻辑在这里)
*/
public HikariDataSource getDataSource(Long id) {
// 先查缓存
if (dataSourceCache.containsKey(id)) {
return dataSourceCache.get(id);
}
// 从数据库读出密文
DataSourceEntity config = mapper.selectById(id);
if (config == null) throw new RuntimeException("数据源不存在");
char[] passwordChars = null;
try {
// 【关键点 1】解密出 char[],内存中此时有明文
passwordChars = AesGcmUtil.decryptToCharArray(config.getEncryptedPassword(), aesKey);
// 【关键点 2】组装数据源
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://" + config.getIp() + ":" + config.getPort() + "/test_db");
ds.setUsername(config.getUser());
ds.setPassword(new String(passwordChars)); // JDBC 规范限制,这里必须转 String 传给驱动
ds.setMaximumPoolSize(5);
ds.setConnectionTimeout(30000);
// 放入缓存
dataSourceCache.put(id, ds);
return ds;
} catch (Exception e) {
throw new RuntimeException("创建动态数据源失败", e);
} finally {
// 【关键点 3】无论成功失败,立刻擦除内存中的密码!
AesGcmUtil.clearCharArray(passwordChars);
// 注意:此时 HikariCP 内部为了维持连接池,其实已经把密码存成了 String。
// 这是 Java JDBC 的底层悲哀,我们作为业务层能做的安全清理已经做到极限了。
}
}
@PreDestroy
public void destroy() {
dataSourceCache.values().forEach(HikariDataSource::close);
}
}
业务调用处的防呆提醒:
char[] passwordChars = null;
try {
// 1. 解密出密码 (此时明文在内存中)
passwordChars = AesGcmUtil.decryptToCharArray(从数据库查出的密文, aesKey);
// 2. 传给 JDBC 驱动 (注意:JDBC 不认 char[],必须转成 String,这是 Java 的底层限制,没办法)
dataSource.setPassword(new String(passwordChars));
// ... 其他操作 ...
} catch (Exception e) {
throw new RuntimeException("连接失败", e);
} finally {
// 3. 擦除内存中的pwd
AesGcmUtil.clearCharArray(passwordChars);
}
涉及范围:
-
密码验证,密码存储;
-
数据源设置密码;
-
可能需要修改数据库的密码字段的长度 varchar 255
修改范围:
-
本地的vm变量;
-
线上的带参数的脚本;
-
浙公网安备 33010602011771号