拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot 配置文件加密:Jasypt、SM4、KMS 三方案对比

Spring Boot 配置文件加密:Jasypt、SM4、KMS 三方案对比

上个月帮一个朋友排查线上问题,日志里赫然打着一串明文的数据库口令,当时我俩的表情都很微妙——那份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,那么恭喜你,你只是把"明文密码"换成了"明文密钥 + 密文密码",攻击者拿到文件两秒钟就能还原,安全增益接近零。

密钥的投放方式我按推荐度排个序:

  1. 容器/编排层的 Secret:K8s 的 Secret、Docker Compose 的 secrets,以环境变量或挂载文件的形式注入。这是目前生产环境最稳妥的做法。
  2. 启动脚本里 export:export JASYPT_PASSWORD=xxx && java -jar app.jar,适合传统虚拟机部署。注意脚本本身的权限要收紧到600。
  3. -Djasypt.encryptor.password=xxx:能用,但不推荐。JVM 参数在ps -ef里是明文可见的,同一台机器上的其他用户都能看到。
  4. 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 ms4.45 秒
自研 SM4-CBC约 0.4 ms约 8 ms4.33 秒
自研 AES-GCM约 0.3 ms约 6 ms4.33 秒
KMS 动态获取数据密钥约 180 ms(含网络往返)约 180 ms4.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加上点耐心就够了,但效果比读十篇安全规范都实在。

第七条,密钥轮转要当成常规运维动作,而不是应急措施。我现在的做法是在运维日历里固定一个季度提醒,到点就走一遍轮转流程:生成新密钥、批量重加密、双密钥并行一段时间、下线旧密钥。第一次做会有点手忙脚乱,做完两轮之后就顺了,而且流程本身会暴露出很多平时看不见的设计问题。

最后分享一个小技巧:如果你不确定某个配置项到底算不算敏感,就问自己一句——"这条信息出现在公开的博客或者论坛里,我会不会不舒服?"会,那就加密。这个土办法判断准确率相当高,比翻安全规范快多了。

返回列表