加解密方法

密码加密解决方案

密钥示例:

# 生成一个 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);
}
​

 

涉及范围:

  1. 密码验证,密码存储;

  2. 数据源设置密码;

  3. 可能需要修改数据库的密码字段的长度 varchar 255

修改范围:

  1. 本地的vm变量;

  2. 线上的带参数的脚本;

  3. 之前的密码不可用(本地、线上、生产)(需要重新录入,或者写一个脚本方法批量修改)

posted @ 2026-04-15 23:22  景之1231  阅读(8)  评论(0)    收藏  举报