上个月帮一个朋友排查线上问题,日志里赫然打着一串明文的数据库口令,当时我俩的表情都很微妙——那份application-prod.yml已经跟着镜像推到了三个环境,谁手里都有一份副本。Springboot 配置文件里的敏感信息加密这件事,说大不大,说小也真不小,它不是那种不做系统就跑不起来的刚需,而是那种"平时没人管、出事一次就够疼"的东西。我前后在四个项目里用过不同的方案,从最省事的 Jasypt,到自己撸一套 SM4 加解密,再到把密钥完全托管给外部系统,中间踩的坑加起来能写一篇小作文。
这篇内容主要面向已经能把 Springboot 项目跑起来、但对配置安全还没系统梳理过的同学,也适合正在做技术选型、纠结"到底要不要为了几个密码引入一套加密体系"的架构同学。我尽量把三种加密保护方法放在同一把尺子上量,讲清楚各自的实现步骤、密钥放哪儿、改动量多大、性能损耗多少,以及最关键的——它们各自防得住什么、防不住什么。
1. 先把问题定义清楚:加密到底在防谁
很多人一上来就问"用哪个加密库好",这个问题其实问早了。加密不是目的,挡住某一条具体的泄露路径才是目的。配置文件的敏感信息加密,如果没想清楚威胁模型,很容易做出"看起来很安全、实际上白忙一场"的方案——比如把密钥和密文写在同一个文件里,这种操作我见过不止一次。
1.1 配置文件明文的三类典型泄露路径
第一类是版本库泄露。这是最常见也最致命的。开发图省事,把生产环境的数据库口令、Redis 密码、第三方支付密钥直接写进application-prod.yml提交上去,仓库一公开或者权限一放开,等于把钥匙挂在门上。就算仓库是私有的,几年积累下来,离职员工手里的旧克隆、CI 机器上的缓存副本、本地没清理的工作区,都是潜在的出口。
第二类是制品与镜像泄露。Springboot 打出来的 fat jar 里,BOOT-INF/classes/下面就是打包进去的配置文件。镜像推到私有仓库,任何有拉取权限的人unzip一下就能看到明文。哪怕你后来把仓库里的文件删了,历史镜像里那份依然躺着。
第三类是运行时被动暴露。主机上cat一下配置文件、运维通过跳板机看一眼、监控采集脚本把整个配置目录打包上传,这些都属于这一类。还有一类比较隐蔽的:某些框架在启动失败时会把加载到的配置项打进异常堆栈,如果日志采集做得粗放,等于把密码写进了日志系统。
注意:这三类路径对应的防护手段完全不同。版本库泄露靠"不提交明文",镜像泄露靠"打包前替换",运行时暴露靠"文件权限 + 最小可见范围"。加密只是其中一环。
1.2 加密方案解决的是哪一段
把话说明白:配置文件加密,防的是"静态文件被读取",不防"运行时被攻击"。密钥最终要在应用进程里被加载,明文口令最终要出现在内存里,数据库连接池拿到的还是明文。所以它挡不住内存 dump、挡不住反编译、挡不住一个已经能执行任意代码的攻击者。
它真正挡住的是:别人拿到了你的 jar 包、拿到了你的配置文件副本、拿到了镜像层,但如果他拿不到密钥,这些密文就是一堆无意义的字符串。这就是它的全部价值,也是它足够的价值——因为现实中绝大多数的配置泄露事故,就是"文件流出去了",而不是"攻击者精准 dump 了你的堆内存"。
所以判断标准很简单:密钥和密文不能共存于同一个可泄露边界内。仓库里的密文,密钥应该在 CI 的变量里;镜像里的密文,密钥应该在容器运行时的 Secret 里;配置中心里的密文,密钥应该在服务端或者独立的下发通道里。任何破坏这条规则的设计,加密强度再高都是摆设。
1.3 三种方案的选择坐标系
我把实际用过的方案归成三类,后面会逐个展开:
| 维度 | Jasypt | 自研 SM4/AES + 环境后置处理 | 外部托管(配置中心/KMS/Secret) |
|---|---|---|---|
| 接入成本 | 低,加依赖加注解 | 中,要写加解密类和扩展点 | 高,要对接外部系统 |
| 密钥管理 | 容易做错,靠自觉 | 自己掌控,责任也自己扛 | 由外部系统承担 |
| 多环境支持 | 靠不同密钥或不同密文 | 灵活,可按 profile 分流 | 天然隔离 |
| 团队协作 | 密文需共享,密钥需分发 | 同左,但可做工具化 | 权限体系内解决 |
| 适用规模 | 中小项目、单体 | 中大型、有安全合规要求 | 微服务、多团队、云原生 |
这张表先放在这里,后面几章会用具体数据把它填满。
2. 方案一:Jasypt,二十分钟能跑起来的方案
如果你的诉求是"今天下班前把仓库里的明文密码干掉",Jasypt 基本是唯一理性的选择。它的原理非常朴素:提供一个StringEncryptor,然后在 Spring 的PropertySource层做一次拦截,凡是形如ENC(密文)的值,读取的时候自动解密成明文。对业务代码完全透明,你该怎么写@Value还怎么写。
2.1 依赖引入与版本选择的坑
Maven 里就一行:
<dependency> <groupId>com.github.ulisesbocchio</groupId> <artifactId>jasypt-spring-boot-starter</artifactId> <version>3.0.5</version> </dependency>版本这里有个真实的坑。3.0.x是适配 Spring Boot 3.x 的,如果你项目还在 Spring Boot 2.x 上,混用会碰到自动配置类加载失败或者NoSuchMethodError。反过来,Spring Boot 3 项目用2.1.x更是不行,因为 Spring Boot 3 把javax.*换成了jakarta.*,而 2.x 的启动器还依赖旧包路径。
我一般这么处理:Spring Boot 2.7 及以下用2.1.2,Spring Boot 3.x 用3.0.5。另外注意 JDK 版本,Jasypt 默认算法是PBEWITHHMACSHA512ANDAES_256,这个算法需要 JDK 8u161 以上才默认支持 AES-256 强度。更早的 JDK 8 版本因为加密策略限制会抛InvalidKeyException,那时候要么升级 JDK,要么手动装策略文件,要么退回PBEWITHHMACSHA512ANDDESEDE。现在还在用 8u161 之前版本的项目应该很少了,但如果你维护的是老系统,这个点一定要确认。
还有一个容易忽略的:如果你用的是spring-boot-starter-parent做依赖管理,注意别让父 POM 把 Jasypt 的传递依赖降到不兼容的版本,必要时显式锁一下版本。
2.2 密钥到底放哪里,这是整个方案的分水岭
配置长这样:
spring: datasource: password: ENC(3f9a2c8e7b1d4f6a...) jasypt: encryptor: password: ${JASYPT_PASSWORD} algorithm: PBEWITHHMACSHA512ANDAES_256 iv-generator-classname: org.jasypt.iv.RandomIvGenerator注意jasypt.encryptor.password这里我写的是${JASYPT_PASSWORD},也就是从环境变量取。这是关键。如果你直接写password: mySecretKey123,那么恭喜你,你只是把"明文密码"换成了"明文密钥 + 密文密码",攻击者拿到文件两秒钟就能还原,安全增益接近零。
密钥的投放方式我按推荐度排个序:
- 容器/编排层的 Secret:K8s 的 Secret、Docker Compose 的 secrets,以环境变量或挂载文件的形式注入。这是目前生产环境最稳妥的做法。
- 启动脚本里 export:
export JASYPT_PASSWORD=xxx && java -jar app.jar,适合传统虚拟机部署。注意脚本本身的权限要收紧到600。 -Djasypt.encryptor.password=xxx:能用,但不推荐。JVM 参数在ps -ef里是明文可见的,同一台机器上的其他用户都能看到。- CI/CD 的加密变量:在流水线的构建或部署阶段注入,注意别把变量回显到构建日志里。
提示:环境变量也不是绝对安全,
/proc/<pid>/environ在同一主机上有权限的前提下是能读到的。它比命令行参数强,但比"挂载文件 + 严格文件权限"稍弱。生产环境我一般选挂载文件的方式。
2.3 生成密文并写回配置文件
生成密文有三种办法,我按使用频率排:
命令行方式,适合临时用一次:
java -cp jasypt-1.9.3.jar org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI \ input="MyRealPassword" \ password="YourMasterKey" \ algorithm=PBEWITHHMACSHA512ANDAES_256 \ ivGeneratorClassName=org.jasypt.iv.RandomIvGenerator输出里会给出ENC(...)的完整字符串,直接贴进配置文件。
代码方式,适合批量处理或者集成到运维脚本里:
public class GenCipher { public static void main(String[] args) { PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor(); SimpleStringPBEConfig config = new SimpleStringPBEConfig(); config.setPassword(System.getenv("JASYPT_PASSWORD")); config.setAlgorithm("PBEWITHHMACSHA512ANDAES_256"); config.setKeyObtentionIterations(1000); config.setPoolSize(4); config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator"); config.setIvGeneratorClassName("org.jasypt.iv.RandomIvGenerator"); config.setStringOutputType("base64"); encryptor.setConfig(config); System.out.println("ENC(" + encryptor.encrypt("MyRealPassword") + ")"); } }单元测试里注意:测试类加载 Spring 上下文时会尝试解密,如果环境变量没设置就会直接启动失败。我的做法是在src/test/resources下放一个application-test.yml,里面用测试环境自己的密钥,并且把测试环境的配置项换成测试值。别想着在测试代码里硬编码生产密钥,那条路走一次就会后悔。
2.4 自定义 Encryptor 与几个必须知道的细节
Jasypt 允许你自定义StringEncryptor,但有个硬性约束:Bean 的名称必须叫jasyptStringEncryptor,否则自动配置会忽略你的实现,转而创建默认的。这是很多人改了半天发现没生效的原因。
@Configuration public class EncryptorConfig { @Bean("jasyptStringEncryptor") public StringEncryptor stringEncryptor() { PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor(); SimpleStringPBEConfig config = new SimpleStringPBEConfig(); config.setPassword(loadKeyFromSecureSource()); config.setAlgorithm("PBEWITHHMACSHA512ANDAES_256"); config.setPoolSize(4); encryptor.setConfig(config); return encryptor; } }还有个很多人第一次会慌的现象:同一个明文,加密两次结果不一样。因为默认启用了随机 Salt 和随机 IV,这其实是好事,说明没有用固定向量。但你没法用"密文是否相同"来判断两个配置项的值是不是一样,做配置比对脚本的时候要留意。如果你确实需要确定性输出(比如做配置 diff),可以把 IV 生成器换成ZeroIvGenerator,不过我不建议——那会削弱安全性,只在特定工具场景下用。
最后一个细节:poolSize建议设成 4 到 8。默认是 1,意味着所有解密请求串行。虽然配置文件里的加密项通常就十几二十个,启动阶段影响不大,但如果你的场景里有运行时的动态解密需求,池子太小会成为瓶颈。
3. 方案二:自研 SM4/AES 加解密,把主动权拿回来
Jasypt 用得越久,越会碰到它的天花板:算法和填充方式是它定的,密钥派生逻辑是它定的,加密串格式是它定的。有些团队出于国产化合规或者内部密码规范的考虑,需要指定 SM4、需要自己控制 IV、需要把加解密逻辑统一到公司的基础库里。这时候就得自己动手了。
3.1 为什么要自研,以及它的代价
自研的收益有三块。一是算法可控,可以按内部规范选 SM4-CBC 或者 AES-GCM,可以统一 IV 和 Tag 的长度约定。二是格式可控,可以设计成ENC{版本号:密文}这种带版本标记的格式,将来换算法时能做平滑迁移。三是链路可控,加密、解密、密钥加载三个环节都在自己的代码里,出问题能定位到行号。
代价也很实在:你得自己处理密钥加载、自己保证 IV 不重复、自己写扩展点接入 Spring 的配置加载流程,还要自己承担写错的风险。我见过自研实现里把 IV 写死成 16 个 0 的,也见过用 ECB 模式的,这些都在无形中把加密强度打回原形。所以自研的前提是团队里得有人真的懂这块,而不是"抄一段代码能跑就行"。
3.2 拦截时机:EnvironmentPostProcessor 怎么挂上去
要在 Spring 读取配置文件的过程中把密文替换掉,最合适的扩展点是EnvironmentPostProcessor。它在ApplicationEnvironmentPreparedEvent阶段被调用,此时配置文件已经加载进Environment的PropertySource里,但 Bean 还没开始创建,任何@Value和@ConfigurationProperties拿到的都将是解密后的值。
public class SensitivePropertyDecryptor implements EnvironmentPostProcessor, Ordered { @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { for (PropertySource<?> source : environment.getPropertySources()) { if (source instanceof EnumerablePropertySource) { decryptSource((EnumerablePropertySource<?>) source, environment); } } } @SuppressWarnings("unchecked") private void decryptSource(EnumerablePropertySource<?> source, ConfigurableEnvironment environment) { Map<String, Object> decrypted = new HashMap<>(); for (String name : source.getPropertyNames()) { Object value = source.getProperty(name); if (value instanceof String && ((String) value).startsWith("sm4(")) { decrypted.put(name, Sm4CryptoUtil.decrypt(unwrap((String) value))); } } if (!decrypted.isEmpty()) { environment.getPropertySources() .addFirst(new MapPropertySource(source.getName() + "-decrypted", decrypted)); } } @Override public int getOrder() { return Ordered.LOWEST_PRECEDENCE; } }这里有两个必须注意的点。
第一,执行顺序。Spring Boot 2.4 之后,配置文件的加载改由ConfigDataEnvironmentPostProcessor负责,它的 order 是Ordered.HIGHEST_PRECEDENCE + 10。如果你的处理器执行得比它早,PropertySource里根本还没有配置文件的内容,自然什么都解密不了。所以要么把 order 设得足够大(比如LOWEST_PRECEDENCE),要么显式设成比它大一点的值,比如Ordered.HIGHEST_PRECEDENCE + 20。
第二,注册方式变了。Spring Boot 2.7 开始,EnvironmentPostProcessor的注册从META-INF/spring.factories迁移到了META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports。文件内容就是实现类的全限定名,一行一个。如果你在 Spring Boot 3 项目里还往spring.factories里写,是不会生效的,而且不会有任何报错——就是静默不工作,特别容易被坑。
# META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports com.example.config.SensitivePropertyDecryptor还有一个细节:遍历PropertySource时不要在原对象上改。EnumerablePropertySource的getProperty是只读的,你没法回写。正确做法是收集所有解密结果,构造一个新的MapPropertySource,用addFirst插到最前面,让它的优先级高于原始配置源。这样原始密文还在,但读取时拿到的是解密值。
3.3 SM4-CBC 与 AES-GCM 的实现细节
国产化场景下 SM4 是常见选择,密钥固定 128 位(16 字节),分组也是 128 位。用 Hutool 的话大概是这样:
public final class Sm4CryptoUtil { private static final byte[] KEY = HexUtil.decodeHex( System.getenv("CONFIG_SM4_KEY")); private static final byte[] IV = HexUtil.decodeHex( System.getenv("CONFIG_SM4_IV")); public static String encrypt(String plain) { SM4 sm4 = SmUtil.sm4(KEY); sm4.setMode(Mode.CBC); sm4.setPadding(Padding.PKCS5Padding); sm4.setIv(IV); return sm4.encryptBase64(plain); } public static String decrypt(String cipher) { SM4 sm4 = SmUtil.sm4(KEY); sm4.setMode(Mode.CBC); sm4.setPadding(Padding.PKCS5Padding); sm4.setIv(IV); return sm4.decryptStr(cipher); } }这里我把 IV 也放在环境变量里。严格来说 CBC 模式下 IV 只需要满足"不可预测",不需要保密,但很多内部安全规范会要求一起保护,跟着来吧。
更推荐的其实是 AEAD 模式。SM4-GCM 或者 AES-GCM 自带完整性校验,密文被篡改会直接抛异常,能防住 CBC 模式下的填充预言类问题。AES-GCM 的 Java 实现大致是:
public static String aesGcmEncrypt(String plain, byte[] key) throws Exception { byte[] iv = new byte[12]; // GCM 推荐 12 字节 IV SecureRandom.getInstanceStrong().nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv)); // Tag 长度 128 位 byte[] out = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); byte[] result = new byte[iv.length + out.length]; // IV 前置拼接 System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(out, 0, result, iv.length, out.length); return Base64.getEncoder().encodeToString(result); }关键点是GCM 的 IV 绝对不能重复,同一个密钥下重复 IV 会直接摧毁安全性。所以不要用固定 IV,也不要用时间戳当 IV,老老实实用SecureRandom,每次加密随机生成,然后把 IV 和密文拼在一起存。解密时前 12 字节切出来当 IV,剩下的是密文加 Tag。
注意:不管选哪种模式,都不要用 ECB。ECB 对相同明文块产生相同密文块,配置文件里如果有重复的密码片段,会直接暴露规律。这是最基础也最常被忽视的一条。
3.4 和配置中心、多环境 Profile 的配合
自研方案在多环境下比 Jasypt 灵活得多,因为密钥来源完全由你控制。典型做法是:密钥前缀带环境标识,加载时按spring.profiles.active选择对应的密钥。
String env = environment.getProperty("spring.profiles.active", "dev"); String keyName = "CONFIG_KEY_" + env.toUpperCase(); String key = System.getenv(keyName);这样一套代码在开发、预发、生产用三把不同的密钥,密文也各不相同。有人在application.yml里用 Profile 分组的方式写多套密文,我不太推荐——那等于把三套密文和对应的密钥使用规则全放在一个文件里,缩小了攻击成本。
配合配置中心时,思路要变一下:让配置中心存密文,应用启动时解密。也就是配置中心下发的是sm4(...)格式的串,本地做最后一道解密。这样配置中心的运维人员看到的也是密文,适合那种"配置平台权限比较宽、但业务密钥必须收口"的组织。
4. 方案三:把密钥托管出去,让应用不碰密钥
前两种方案的共同点是:密钥最终还是要落到应用进程里。方案三的思路是换个方向——让应用根本不持有长期密钥,或者让密钥的获取过程本身就是一次受控的、可审计的交互。
4.1 配置中心的服务端加密
主流配置中心大多提供了加密能力,思路是把加解密挪到服务端。运维在控制台里录入明文,服务端用主密钥加密后存储,客户端拉取时服务端解密或者下发密文由客户端解密(取决于具体实现)。应用侧的改动通常很小,有的只需要加一个依赖,配置项本身完全不用动。
这个方案的真正价值在于权限收口:谁能在控制台看到明文、谁只能看到密文、谁只能读不能改,都能在平台层面控制。配置文件再也不会散落在各个开发者的笔记本上,因为本地开发压根不读生产配置。
不过有两个前提要确认清楚。一是主密钥的保护级别,如果配置中心的主密钥放在一个权限很松的配置文件里,那就是把风险从业务系统转移到了中间件,总量没降。二是网络与认证链路,客户端访问配置中心必须走内网且带认证,否则拉配置这个动作本身就变成了新的泄露口。
4.2 容器 Secret 与 configtree 导入
容器环境下的做法比较成熟。K8s 的 Secret 本质上是把敏感数据单独存一份,通过环境变量或挂载卷注入容器。挂载卷的方式更安全一些,因为不会出现在进程环境里。
apiVersion: v1 kind: Pod spec: containers: - name: app image: registry.example.com/demo:1.0.0 env: - name: SPRING_PROFILES_ACTIVE value: prod volumeMounts: - name: app-secrets mountPath: /etc/app/secrets readOnly: true volumes: - name: app-secrets secret: secretName: app-secret然后在application.yml里加一行:
spring: config: import: optional:configtree:/etc/app/secrets/configtree这个导入方式特别适合 secret 场景:它会把目录下的每个文件当成一个配置项,文件名就是 key,文件内容就是 value。比如/etc/app/secrets/db.password这个文件的内容就会变成db.password这个属性。这样你的配置文件里连密文都不用写,只有一行导入语句,真正的值全在运行时注入。这种做法的好处是配置文件可以完全公开,扔到哪都不怕。
Docker Compose 也有对应的 secrets 机制:
services: app: image: demo:1.0.0 secrets: - db_password secrets: db_password: file: ./secrets/db_password.txt默认挂载到/run/secrets/下,同样可以用configtree导入。
4.3 KMS 动态解密与凭证轮转
再往上走一层,是接入密钥管理服务。应用启动时用实例身份(比如容器工作负载身份)向 KMS 请求解密一段数据密钥,用这个数据密钥解出配置密文,数据密钥用完即弃,不落盘。这样应用进程里从头到尾不持有长期密钥,泄露面被压到极小。
代价是启动依赖外部服务,KMS 不可用时应用起不来,需要设计降级或者缓存策略。我的做法是在本地缓存一份短期有效的数据密钥(比如挂载在 tmpfs 里,配 5 分钟过期),KMS 短暂抖动时还能撑一会儿,但绝不做永久缓存。
密钥轮转是这套体系最容易被忽略的部分。很多团队上线时认真配了加密,之后三年没换过密钥,人员流动了几轮,密钥等于半公开。我在项目里一般遵循这个节奏:主密钥至少每季度轮转一次,密文在轮转时批量重加密;人员离职触发一次即时轮转。轮转的过程要有脚本支撑,靠手工改配置迟早出事。
5. 三种方案横向对比与实测记录
说了这么多实现,最终还是要落到"选哪个"。我把三个方案在同一个项目上做过对比测试,环境和数据如下。
5.1 关键维度对比表
| 维度 | Jasypt | 自研 SM4/AES | 外部托管 |
|---|---|---|---|
| 首次接入工作量 | 0.5 人天 | 3 到 5 人天 | 5 到 10 人天 |
| 加密算法可选范围 | 受限于内置算法集 | 完全自主 | 取决于平台能力 |
| 密文可否被篡改检测 | 默认无完整性校验 | 选 GCM 可检测 | 一般有 |
| 密钥与密文分离难度 | 依赖团队规范 | 依赖自身设计 | 平台强制分离 |
| 对业务代码侵入 | 无 | 无(扩展点方式) | 无 |
| 启动耗时影响 | 低 | 低到中 | 中(含网络请求) |
| 离线可启动 | 是 | 是 | 否(需缓存或降级) |
| 密钥轮转成本 | 需重新加密所有配置 | 需重新加密所有配置 | 平台支持,较低 |
5.2 启动耗时与操作成本实测
测试环境是本地开发机(8 核 16G,JDK 17,Spring Boot 3.2),配置文件中包含 20 个加密项,每个密文长度在 60 到 90 字符之间,连续跑 10 次取平均。
| 方案 | 单次解密平均耗时 | 20 项总解密耗时 | 应用启动总耗时变化 |
|---|---|---|---|
| 不加密(基线) | - | - | 4.32 秒 |
| Jasypt(PBEWITHHMACSHA512ANDAES_256) | 约 5.8 ms | 约 116 ms | 4.45 秒 |
| 自研 SM4-CBC | 约 0.4 ms | 约 8 ms | 4.33 秒 |
| 自研 AES-GCM | 约 0.3 ms | 约 6 ms | 4.33 秒 |
| KMS 动态获取数据密钥 | 约 180 ms(含网络往返) | 约 180 ms | 4.62 秒 |
几个结论挺明显的。Jasypt 的慢主要来自它的密钥派生设计,每次解密都要做 1000 轮迭代的 PBE 运算(keyObtentionIterations默认值),这是为了对抗暴力破解,安全性上是有意义的,代价就是慢了一个数量级。20 个配置项 116 毫秒,对启动时间来说完全可以接受,但如果你的配置项有几百个,或者有运行时动态解密的场景,这个开销就要认真评估了。
自研的对称加解密快得几乎无感,因为 SM4 和 AES 都是硬件友好型算法,加上现代 CPU 的 AES-NI 指令集,单次加解密都在微秒级。8 毫秒和 116 毫秒的差距,在配置项多的时候会放大到秒级。
KMS 的开销全在网络往返上,一次请求 180 毫秒左右,但只要不是每读一个配置就请求一次,整体影响也可控。关键是做好一次获取、多次复用。
提示:上面这些数据是本地实测,和生产环境的绝对数值会有差异,但相对关系是稳定的。评估的时候看比例,不要照抄绝对值。
5.3 什么项目该选哪个
我的判断标准大概是这样:
中小型项目、单体应用、团队规模 5 人以内、没有专职安全同学,直接用 Jasypt。它的接入成本最低,收益也最直接,只要把密钥投放这件事管住,实际防护效果足够。别为了"技术先进"去自研,自研写错了还不如不加密。
中大型项目、有内部密码算法规范、需要指定 SM4 或者需要控制密文格式,走自研路线。但要先把三件事定下来:算法和模式(推荐 AEAD)、密钥的存放和轮转机制、密文的格式约定(建议带版本前缀,方便将来迁移)。这三件事没有明确答案之前不要动笔写代码。
微服务架构、多团队共享配置、有云原生基础设施,用外部托管。这类场景下配置项数量大、环境多、人员流动快,靠人工规范根本管不住,必须用平台机制强制约束。前提是你得接受"应用启动依赖外部系统"这个事实,并且做好降级预案。
一个折中做法是混合:配置文件里的密文用自研方案解,长期密钥本身放在容器 Secret 或者 KMS 里。这样既有自研的灵活度和性能,又把最关键的根密钥托管出去,两层防护。
6. 踩坑实录与问题速查
下面这些是我实际碰到过的问题,有些花了不少时间才定位到,整理成速查表方便对照。
6.1 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
启动报DecryptionException | 密钥不对,或密文被换行/空格污染 | 检查密钥来源,确认密文没被编辑器自动折行 |
| Jasypt 自定义 Encryptor 不生效 | Bean 名称不是jasyptStringEncryptor | 改 Bean 名称,或显式指定bean属性 |
| Spring Boot 3 中扩展点静默失效 | 还在用spring.factories注册 | 改用META-INF/spring/xxx.imports |
解密拿到的是ENC(...)原文 | 处理器执行顺序早于配置加载 | 把 order 调到HIGHEST_PRECEDENCE + 20之后 |
| 单元测试启动失败 | 测试环境没有密钥 | 用测试专用密钥和测试配置文件 |
| 同一明文加密结果每次都不同 | 启用了随机 Salt 和 IV | 正常现象,不是 Bug |
| 中文口令解密后乱码 | 编码不一致,加密端用了 UTF-8,解密端用了平台默认 | 两端统一显式指定StandardCharsets.UTF_8 |
| 容器里读不到挂载的密钥文件 | 挂载路径或文件权限不对 | 检查mountPath和文件属主,Spring 进程用户要能读 |
| 换了新密钥后老密文解不开 | 没有做版本标记和兼容逻辑 | 密文格式带版本号,保留旧密钥一段时间 |
6.2 几条我自己的实操心得
第一条,加密这件事最怕"半途而废"。我见过项目里改了一半,application-prod.yml加密了,application-dev.yml还是明文,结果生产密钥和开发密钥是同一把。这种情况下攻击者从开发配置入手,一样能拿到生产库的入口。要么全环境统一做,要么至少保证每个环境用独立密钥。
第二条,配置文件里的注释和提交历史也是泄露源。有人加密了密码,但注释里写着"原密码是 xxx,2024-03 更换";有人加密了之后在提交信息里写"把 xxx 密码加密了"。这些细节在代码审查里经常被放过,但泄露效果和明文一样直接。
第三条,写一个内部的小工具。团队里每个人手工跑命令行加密,格式迟早会不统一。我一般会在项目里加一个maven插件或者一个独立的 CLI 模块,输入明文输出ENC(...),自动带上正确的算法参数。工具本身也顺手解决了"新同学不知道该怎么加密"的问题。类似地,IDE 里可以配一个外部工具,选中一段文本一键转换,用起来很顺手。
第四条,加密配置项的清单要维护。哪些 key 属于敏感信息、哪些不需要加密,最好有一份明确的列表,放在项目文档里持续维护。我见过把整个application.yml无差别加密的做法,结果连日志级别这种非敏感项也进了密文,维护成本一下子上去,而且完全没必要。
第五条,别忘了配置文件的文件权限。即使做了加密,chmod 600和正确的属主设置依然要做。这是一层几乎零成本的防护,很多团队因为"反正已经加密了"就跳过了,其实主机被入侵时它是最先起作用的。
第六条,定期做一次泄露演练。把打包好的 jar 解压,把镜像层扒开,把仓库历史翻一遍,看看能不能直接搜到密码。我自己做过两次,第二次还真的在历史提交里翻出了一条两年前的明文。这类演练不需要什么工具,grep -r加上点耐心就够了,但效果比读十篇安全规范都实在。
第七条,密钥轮转要当成常规运维动作,而不是应急措施。我现在的做法是在运维日历里固定一个季度提醒,到点就走一遍轮转流程:生成新密钥、批量重加密、双密钥并行一段时间、下线旧密钥。第一次做会有点手忙脚乱,做完两轮之后就顺了,而且流程本身会暴露出很多平时看不见的设计问题。
最后分享一个小技巧:如果你不确定某个配置项到底算不算敏感,就问自己一句——"这条信息出现在公开的博客或者论坛里,我会不会不舒服?"会,那就加密。这个土办法判断准确率相当高,比翻安全规范快多了。