1. 项目概述与核心价值
最近在做一个对数据安全要求比较高的项目,客户明确要求配置文件中的敏感信息,比如数据库密码、API密钥这些,不能再用明文了。这要求很合理,毕竟谁也不想自己的生产库密码在配置文件里裸奔。一开始我考虑用Jasypt,这是SpringBoot生态里老牌的配置加密工具,社区成熟,用起来也顺手。但和客户方技术负责人沟通后,他们提了一个硬性要求:必须支持国密算法。原因很简单,他们的系统需要满足特定行业的安全合规要求,使用国密算法是强制标准。
这下Jasypt就不太合适了,它主要支持的是AES、DES这些国际通用算法。于是,任务就变成了如何在SpringBoot项目中,集成国密算法来实现配置项的加解密。国密算法里,SM4是一种分组对称加密算法,类似于AES,用来加密配置文件这种静态文本非常合适。我的目标很明确:实现一个工具类,能对application.yml或application.properties里标记的加密值(比如{cipher}U2FsdGVkX1...)进行自动解密,让业务代码像读取明文一样无感知地使用这些配置,同时加密过程要方便,支持通过命令行或一个小程序来生成密文。
这个方案的核心价值在于,它在不改变SpringBoot原有配置读取习惯的前提下,无缝地提升了配置信息的安全性,并且满足了国产化替代和特定行业合规的刚性需求。无论你是开发金融、政务类应用,还是任何对数据安全有更高要求的内部系统,这套方案都能直接拿来参考。
2. 国密SM4算法与工具类设计解析
2.1 为什么选择SM4算法?
在国密算法体系中,SM1、SM4、SM7都属于对称加密算法。SM1和SM7的算法细节不公开,需要通过硬件芯片实现,而SM4是公开的分组算法,软件实现方便,因此成为了在软件层面实现国密对称加密的首选。它的分组长度和密钥长度均为128位,在安全性上对标国际上的AES-128。从功能定位上看,用它来加密配置文件,就和用AES加密是一样的道理。
设计这个工具类,我们主要参考了Spring Cloud Config Server的加密解密思路。它的模式很优雅:在配置文件中,用{cipher}前缀标识一个加密值。应用启动时,在配置属性被加载到Spring Environment的过程中,拦截并识别这些前缀,调用我们自己的解密逻辑,将密文还原为明文,然后再交给后续的Bean使用。这样,业务代码里的@Value(“${db.password}”)拿到的就已经是解密后的字符串了,完全无需关心底层加密细节。
2.2 工具类的核心职责与接口设计
我们的SM4加密工具类需要承担两个主要职责:
- 加解密逻辑本身:提供静态方法,传入明文和密钥,返回密文,或者传入密文和密钥,返回明文。这是最基础的功能。
- 与SpringBoot配置属性源的集成:实现一个
PropertySource或BeanFactoryPostProcessor,在Spring容器初始化配置属性的早期阶段介入,完成解密工作。
为了清晰和可维护,我决定将这两个职责分离:
Sm4Utils:一个纯粹的、无状态的加解密工具类。它只负责根据SM4算法和给定的密钥(Key)和初始向量(IV)执行ECB或CBC模式的加解密运算。这个类不依赖Spring任何组件,可以独立测试和复用。Sm4PropertyDecryptor:一个Spring组件,负责集成。它会读取预先配置好的密钥,在Spring的Environment准备完毕后,遍历所有属性源(PropertySource),查找以{sm4}(我自定义的前缀,以区别于{cipher})开头的属性值,调用Sm4Utils进行解密,并用解密后的值替换原加密值。
这里有个关键设计点:密钥的管理。密钥本身不能写在配置文件中,否则就成了“把钥匙挂在锁旁边”。常见的做法有:
- 环境变量:将密钥设置在部署服务器的操作系统环境变量中,如
SM4_KEY。工具类启动时从System.getenv()读取。 - 启动参数:通过Java的
-D参数传入,如-Dsm4.key=your_key_here,工具类从System.getProperty()读取。 - 专用密钥管理服务:在更复杂的云原生环境中,可以从HashiCorp Vault、阿里云KMS等服务中动态获取。
在本方案中,为了平衡安全性和简易性,我选择使用“环境变量”结合“启动参数”的方式作为密钥来源,并在代码中明确提示生产环境应采取更安全的措施。
3. 核心工具类Sm4Utils的实现细节
3.1 依赖引入与算法基础
首先,我们需要一个实现了国密SM4算法的JCE(Java Cryptography Extension)提供者。这里有两个主流选择:Bouncy Castle(BC)和国密官方的参考实现。Bouncy Castle支持更广泛,社区活跃。我选择使用Bouncy Castle。
在pom.xml中添加依赖:
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> <!-- 请使用最新稳定版 --> </dependency>Sm4Utils类的骨架设计如下,我们将支持最常用的CBC模式(需要IV)和ECB模式(无需IV,但安全性较CBC弱,适用于加密独立数据块)。
import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.Security; import java.util.Base64; public class Sm4Utils { static { // 静态代码块注册BouncyCastle提供者,确保算法可用 if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } // 算法名称:SM4 private static final String ALGORITHM_NAME = "SM4"; // 默认使用CBC模式,PKCS5Padding填充方式 private static final String ALGORITHM_NAME_CBC_PADDING = "SM4/CBC/PKCS5Padding"; private static final String ALGORITHM_NAME_ECB_PADDING = "SM4/ECB/PKCS5Padding"; // 密钥和IV的长度(字节) public static final int KEY_LENGTH = 16; // 128 bit public static final int IV_LENGTH = 16; // CBC模式需要16字节IV }3.2 CBC模式加解密实现
CBC(Cipher Block Chaining)模式是更推荐的使用方式,因为它引入了初始化向量(IV),使得加密相同的明文会产生不同的密文,安全性更好。
/** * SM4 CBC模式加密 * @param data 待加密明文 * @param key 密钥,长度必须为16字节 * @param iv 初始化向量,长度必须为16字节 * @return Base64编码的加密字符串 */ public static String encryptCbc(String data, String key, String iv) throws Exception { if (key == null || key.length() != KEY_LENGTH) { throw new IllegalArgumentException("密钥长度必须为16字节"); } if (iv == null || iv.length() != IV_LENGTH) { throw new IllegalArgumentException("初始向量IV长度必须为16字节"); } Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_CBC_PADDING, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME); IvParameterSpec ivParameterSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * SM4 CBC模式解密 * @param encryptedData Base64编码的加密字符串 * @param key 密钥,长度必须为16字节 * @param iv 初始化向量,长度必须为16字节 * @return 解密后的明文 */ public static String decryptCbc(String encryptedData, String key, String iv) throws Exception { if (key == null || key.length() != KEY_LENGTH) { throw new IllegalArgumentException("密钥长度必须为16字节"); } if (iv == null || iv.length() != IV_LENGTH) { throw new IllegalArgumentException("初始向量IV长度必须为16字节"); } Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_CBC_PADDING, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME); IvParameterSpec ivParameterSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); }注意:密钥和IV的生成与管理。在实际项目中,绝对不要使用像“1234567890123456”这样的硬编码字符串作为密钥。密钥和IV必须是强随机数。你可以用
SecureRandom生成,并妥善保存。例如,在初始化项目时,通过一个简单的Java程序生成一次,然后将其存入环境变量或配置服务器。IV在CBC模式中可以不保密,但必须不可预测,通常也建议随机生成。
3.3 ECB模式加解密实现
ECB(Electronic Codebook)模式简单,不需要IV,但相同的明文块会被加密成相同的密文块,容易受到模式分析攻击,一般不建议用于加密有模式的数据(如配置文件)。仅在某些特定场景(如加密独立令牌)下使用。
/** * SM4 ECB模式加密 * @param data 待加密明文 * @param key 密钥,长度必须为16字节 * @return Base64编码的加密字符串 */ public static String encryptEcb(String data, String key) throws Exception { if (key == null || key.length() != KEY_LENGTH) { throw new IllegalArgumentException("密钥长度必须为16字节"); } Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_ECB_PADDING, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * SM4 ECB模式解密 * @param encryptedData Base64编码的加密字符串 * @param key 密钥,长度必须为16字节 * @return 解密后的明文 */ public static String decryptEcb(String encryptedData, String key) throws Exception { if (key == null || key.length() != KEY_LENGTH) { throw new IllegalArgumentException("密钥长度必须为16字节"); } Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_ECB_PADDING, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM_NAME); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec); byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); }3.4 工具类的测试与验证
写完工具类,一定要先进行单元测试,确保加解密的正确性。这里给出一个简单的JUnit测试示例:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; public class Sm4UtilsTest { // 测试用的密钥和IV,务必随机生成,这里仅为示例 private static final String TEST_KEY = “0123456789abcdef”; // 16字节 private static final String TEST_IV = “fedcba9876543210”; // 16字节 private static final String PLAIN_TEXT = “This is a secret database password!”; @Test public void testCbcEncryptAndDecrypt() throws Exception { String encrypted = Sm4Utils.encryptCbc(PLAIN_TEXT, TEST_KEY, TEST_IV); System.out.println(“CBC Encrypted: “ + encrypted); String decrypted = Sm4Utils.decryptCbc(encrypted, TEST_KEY, TEST_IV); System.out.println(“CBC Decrypted: “ + decrypted); assertEquals(PLAIN_TEXT, decrypted); } @Test public void testEcbEncryptAndDecrypt() throws Exception { String encrypted = Sm4Utils.encryptEcb(PLAIN_TEXT, TEST_KEY); System.out.println(“ECB Encrypted: “ + encrypted); String decrypted = Sm4Utils.decryptEcb(encrypted, TEST_KEY); System.out.println(“ECB Decrypted: “ + decrypted); assertEquals(PLAIN_TEXT, decrypted); } }运行测试,如果控制台能成功输出密文,并且解密后的文本与原文一致,说明我们的Sm4Utils基础功能是正常的。这一步至关重要,它是后续与SpringBoot集成的基石。
4. 与SpringBoot环境集成的解密器实现
有了可靠的Sm4Utils,下一步就是让它融入SpringBoot的生命周期。我们需要在配置属性被解析后、Bean使用它们之前,完成解密工作。Spring提供了EnvironmentPostProcessor接口,它允许我们在Environment对象被完全创建之前,对其中的属性源进行操作,这是最合适的切入点。
4.1 实现EnvironmentPostProcessor
创建一个类Sm4EnvironmentPostProcessor实现EnvironmentPostProcessor接口。
import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.MutablePropertySources; import org.springframework.core.env.PropertySource; import org.springframework.util.StringUtils; import java.util.HashMap; import java.util.Map; public class Sm4EnvironmentPostProcessor implements EnvironmentPostProcessor { // 自定义的加密属性前缀 private static final String SM4_PREFIX = “{sm4}”; // 从环境变量或系统属性中读取密钥和IV的Key private static final String ENV_KEY = “SM4_KEY”; private static final String ENV_IV = “SM4_IV”; private String sm4Key; private String sm4Iv; public Sm4EnvironmentPostProcessor() { // 优先从系统属性读取,便于本地测试;生产环境应从更安全的地方获取 this.sm4Key = System.getProperty(ENV_KEY, System.getenv(ENV_KEY)); this.sm4Iv = System.getProperty(ENV_IV, System.getenv(ENV_IV)); if (!StringUtils.hasText(sm4Key)) { throw new IllegalStateException(“SM4加密密钥未配置。请设置环境变量或系统属性: “ + ENV_KEY); } if (!StringUtils.hasText(sm4Iv)) { // 如果使用ECB模式,可以不需要IV。这里按CBC模式要求,抛出异常。 throw new IllegalStateException(“SM4加密初始向量IV未配置。请设置环境变量或系统属性: “ + ENV_IV); } // 简单校验长度,更严格的校验应在工具类内 if (sm4Key.length() != 16 || sm4Iv.length() != 16) { throw new IllegalStateException(“SM4密钥或IV长度必须为16字节。”); } } @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { MutablePropertySources propertySources = environment.getPropertySources(); Map<String, Object> decryptedProperties = new HashMap<>(); // 遍历所有属性源 for (PropertySource<?> source : propertySources) { if (source instanceof EnumerablePropertySource) { EnumerablePropertySource<?> enumerableSource = (EnumerablePropertySource<?>) source; for (String propertyName : enumerableSource.getPropertyNames()) { Object propertyValue = enumerableSource.getProperty(propertyName); if (propertyValue instanceof String) { String value = (String) propertyValue; // 判断属性值是否以 {sm4} 开头 if (value.startsWith(SM4_PREFIX)) { String encryptedValue = value.substring(SM4_PREFIX.length()); try { // 调用工具类解密 String decryptedValue = Sm4Utils.decryptCbc(encryptedValue, sm4Key, sm4Iv); // 将解密后的键值对暂存 decryptedProperties.put(propertyName, decryptedValue); // 注意:这里不能直接修改原PropertySource,需要先收集再添加新源 } catch (Exception e) { throw new RuntimeException(“解密配置项 [“ + propertyName + “] 失败: “ + encryptedValue, e); } } } } } } // 将解密后的属性作为一个新的、高优先级的属性源加入 if (!decryptedProperties.isEmpty()) { MapPropertySource decryptedSource = new MapPropertySource(“sm4DecryptedProperties”, decryptedProperties); propertySources.addFirst(decryptedSource); // 添加到最前面,确保优先级最高 } } }关键点解析:
- 密钥获取:在构造器中,我们尝试从系统属性(
-D参数)和环境变量中读取密钥和IV。生产环境强烈建议使用更安全的方式,如从专门的密钥管理服务获取。 - 属性遍历:
EnumerablePropertySource接口允许我们获取属性名列表。我们遍历所有属性源(如命令行参数、application.yml、系统环境变量等)的所有属性。 - 前缀识别:我们约定加密的配置值以
{sm4}开头。例如,在配置文件中写db.password: {sm4}5U4Lk4w...。 - 解密与替换:识别到加密值后,去掉前缀,调用
Sm4Utils.decryptCbc解密。不能直接修改遍历中的PropertySource,因为可能引发并发修改异常。正确做法是将解密后的键值对收集到一个Map中。 - 新增属性源:解密完成后,将整个Map作为一个新的
MapPropertySource,并添加到属性源列表的最前面(addFirst)。这样,当Spring通过属性名查找值时,会优先从这个新源中获取解密后的值,覆盖掉原始的加密值。这是一种非侵入式的、安全的覆盖方式。
4.2 注册Processor到SpringBoot
为了让SpringBoot在启动时能发现并调用我们的Sm4EnvironmentPostProcessor,需要在resources目录下创建META-INF/spring.factories文件(对于SpringBoot 2.7+,也可以使用org.springframework.boot.env.EnvironmentPostProcessor进行自动注册,但通过spring.factories是最兼容的方式)。
在src/main/resources/META-INF/spring.factories文件中添加:
org.springframework.boot.env.EnvironmentPostProcessor=com.yourpackage.config.Sm4EnvironmentPostProcessor请将com.yourpackage.config替换为你实际的包名。
4.3 配置加密值与启动验证
现在,我们可以在application.yml中写入加密后的配置了。首先,你需要一个加密程序来生成密文。可以写一个简单的Main方法,或者使用单元测试来生成。
假设你的密钥是0123456789abcdef,IV是fedcba9876543210,数据库密码明文是MySuperSecretDBPwd123。 运行加密测试,得到密文(例如):5U4Lk4wE/EXAMPLEENCRYPTEDSTRINGBASE64==
然后在配置文件中这样写:
spring: datasource: url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC username: root password: ‘{sm4}5U4Lk4wE/EXAMPLEENCRYPTEDSTRINGBASE64==‘ # 注意引号,确保YAML解析正确 your: api: secret-key: ‘{sm4}ANOTHERENCRYPTEDSTRINGBASE64==‘启动SpringBoot应用。如果集成成功,你在Controller或Service中使用@Value(“${spring.datasource.password}”)注入时,拿到的就会是解密后的MySuperSecretDBPwd123。你可以通过在Sm4EnvironmentPostProcessor的postProcessEnvironment方法中添加日志来验证解密过程是否被触发。
5. 生产环境部署、问题排查与进阶优化
5.1 密钥安全管理与部署实践
在开发测试环境,通过环境变量传递密钥是方便的。但在生产环境,这需要更严谨的流程:
- 禁止硬编码:绝对不要在代码或配置文件中留下真实的密钥。
- 使用容器编排平台的Secret:如果你使用Kubernetes,可以将密钥和IV创建为Secret对象,然后通过环境变量或Volume挂载到Pod中。应用从这些挂载点读取。
- 使用云厂商的KMS:阿里云、腾讯云等都提供了密钥管理服务。应用启动时,通过实例角色等方式获取临时访问凭证,向KMS请求解密一个加密的数据密钥(DEK),再用这个DEK在内存中解密配置。这是安全性很高的做法。
- 密钥轮转:定期更换密钥。当密钥轮转时,需要有一个过渡期,新旧密钥同时有效,支持解密用旧密钥加密的配置。这需要你的解密逻辑能够支持多密钥尝试,或者提前将存量配置用新密钥重新加密。
在我们的Sm4EnvironmentPostProcessor中,可以将密钥获取逻辑抽象成一个KeyProvider接口,针对不同环境(本地、K8s、云)提供不同实现,这样代码更清晰,也更容易适配不同的安全架构。
5.2 常见问题与排查技巧
在实际集成过程中,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
应用启动失败,报IllegalStateException: SM4加密密钥未配置 | 1. 环境变量SM4_KEY/SM4_IV未设置。2. 系统属性 -D参数未传递。 | 1. 在服务器上执行echo $SM4_KEY检查环境变量。2. 检查应用启动脚本或Dockerfile,确认 -D参数或环境变量已正确设置。3. 在 Sm4EnvironmentPostProcessor构造器中添加调试日志,打印读取到的密钥值。 |
配置项解密失败,报javax.crypto.BadPaddingException | 1. 密文被篡改或传输过程中损坏。 2. 使用的密钥或IV与加密时不一致。 3. 密文Base64解码失败。 | 1. 确认配置文件中加密字符串完整,没有多余空格或换行(YAML中字符串建议用引号包裹)。 2.重点检查:确保加密和解密使用的是完全相同的密钥和IV。对比加密生成密文时使用的值,和运行时环境中的值。 3. 尝试将密文进行Base64解码,看是否是合法Base64字符串。 |
解密成功,但注入的配置值仍是加密字符串(带{sm4}前缀) | 1.EnvironmentPostProcessor未生效。2. 解密后的属性源优先级不够,被其他源覆盖。 3. 属性名拼写错误。 | 1. 检查META-INF/spring.factories文件位置和内容是否正确。2. 在 postProcessEnvironment方法开始和结束处打日志,确认方法被调用以及解密Map不为空。3. 确认解密后的 MapPropertySource是通过addFirst添加的。4. 使用 /actuator/env端点(需引入Spring Boot Actuator)查看最终生效的属性值来源。 |
使用@ConfigurationProperties绑定的对象,其字段值为空 | EnvironmentPostProcessor的执行时机早于配置属性绑定到Bean。如果解密逻辑有问题,绑定会失败。 | 确保解密过程不能抛出异常。任何解密失败都应导致应用启动失败,而不是静默跳过。在postProcessEnvironment中用try-catch包裹解密逻辑,并将任何异常包装为RuntimeException抛出,让SpringBoot启动失败,这样能快速定位问题。 |
一个关键的实操心得:在本地开发时,为了方便,我通常会创建一个application-local.yml,里面的敏感配置使用一个固定的、仅供开发环境使用的测试密钥加密。而真正的生产密钥,只在CI/CD流水线或部署脚本中注入。这样既保证了代码库中配置文件的“安全形式”,又避免了开发效率的降低。
5.3 性能考量与进阶优化
对于大多数应用,启动时解密几十个配置项的性能开销可以忽略不计。但如果你有成千上万个加密配置,可能需要考虑:
- 懒解密:不是启动时全部解密,而是在第一次访问某个属性时才解密,并缓存解密结果。这可以通过实现一个自定义的
PropertySource,在getProperty方法中实现解密逻辑来完成。但这会增加实现的复杂性。 - 支持多种算法/前缀:你可能未来还需要支持其他加密方式。可以设计一个更通用的
CipherEnvironmentPostProcessor,通过前缀(如{sm4},{aes})来路由到不同的解密器(Decryptor)。 - 集成配置中心:当使用Nacos、Apollo等配置中心时,加密解密最好在配置中心服务端完成,客户端直接获取明文。如果必须在客户端解密,那么
EnvironmentPostProcessor依然有效,因为配置中心客户端最终也是将配置加载到Spring的Environment中。
这套基于Sm4Utils和EnvironmentPostProcessor的SpringBoot国密配置加密方案,我已经在多个要求国密合规的项目中稳定使用。它最大的优点就是对业务代码零侵入,开发人员写配置、读配置的方式和以前完全一样,所有的加密解密都在框架层面自动完成。当你需要切换加密算法或者密钥管理方式时,也只需要修改这个处理器和工具类,业务侧无需任何改动,这非常符合设计模式中的“开闭原则”。