Nacos 做配置中心,大多数团队都绕不开一个尴尬的场景:控制台里躺着数据库密码、Redis 密码、短信平台 AK、对象存储密钥,全是明文。内部环境还好说,生产环境一旦配置泄露,整个线上业务就像没锁门的库房。我去年接手一个老项目时,配置中心里甚至把云厂商的 SecretKey 直接写死在 Nacos 控制台,审计的时候差点没背过气去。后来陆陆续续给几个项目做了配置加密加固,用到的就是标题里说的 ENC(...) 这种写法——配置里存密文,应用启动时自动解密,敏感信息不落地。这篇文章不聊太多虚的,直接把我验证过的几种 Nacos 配置加密方案、核心代码、以及过程中踩过的坑整理出来,给正在做配置安全加固的团队一个能直接参考的落地样板。
1. 需求拆解与方案选型思路
1.1 为什么配置加密不能只靠“控制台权限”
很多团队的直觉是:给 Nacos 控制台加权限、加白名单、内网隔离,不就安全了吗?这话只对了一半。控制台权限只能防住“有人登录控制台去看”,但防不住这几类真实场景:
- 配置中心数据被导出或者通过 API 拉取后泄露,明文直接暴露。
- 应用日志、异常堆栈中打印了配置值,比如数据库连接串里带着密码。
- 承包商、第三方运维人员能接触到 Nacos 数据库或者控制台账号,明文配置没有任何抵御能力。
- 代码仓库里的 bootstrap.yml 或 application.yml 中引用了带默认值的配置项,万一仓库权限失控,密码跟着走。
所以配置加密的核心目标不是“防止控制台被人看”,而是“即使配置内容被拿到了,攻击者也拿不到真实值”。这就必须在配置存储侧和应用侧同时做处理:存储时是密文,读取后由应用层解码。ENC(...) 这种写法本质上是定义了一个约定——凡是包在 ENC() 里的值,客户端在加载配置时必须先解密,再交给业务逻辑使用。
1.2 密文格式与主流的三种实现路线
ENC(密文) 这种格式在 Java 生态里很常见,尤其是 Spring Cloud 项目。它本身不是一个标准协议,更多是一种“约定”:密文写在括号里,由某个解密器统一处理。选型的时候,我调研过三条路线,各有各的适配场景:
| 实现路线 | 改动成本 | 解密位置 | 适合场景 |
|---|---|---|---|
| Jasypt Spring Boot Starter | 低,加依赖加配置 | Spring Environment 装配阶段 | 中小团队,希望快速落地,能接受密钥放客户端环境变量 |
| 自定义 EnvironmentPostProcessor | 中,写少量代码 | Spring Environment 装配阶段 | 不想引入重量级依赖,或需要对加解密流程做定制 |
| Nacos 服务端解密插件 / ConfigFilter | 高,涉及客户端 SPI 与服务端改造 | Nacos 客户端或服务端 | 大型团队,安全基线高,愿意做底层集成 |
我实际项目中主要用的是前两条路线。第一条给新项目做标准化,第二条给老项目做兼容性修补。第三条我在后面会单独说明为什么没有作为首选。
2. 方案一:Jasypt 集成,最省事的 ENC() 解法
2.1 引入依赖与基础配置
Jasypt 的 Spring Boot Starter 是社区里最成熟的方案,本质上就是帮你实现了一个 EnvironmentPostProcessor,扫描配置里所有 ENC() 开头的值并替换为解密结果。第一步先引入依赖:
<dependency> <groupId>com.github.ulisesbocchio</groupId> <artifactId>jasypt-spring-boot-starter</artifactId> <version>3.0.5</version> </dependency>版本这里要多说一句:3.0.5 是目前兼容 Spring Boot 2.x 的主流版本。如果项目用的是 Spring Boot 3.x,这个 starter 会存在类路径兼容问题,需要谨慎处理。我用 Spring Boot 2.7 的项目测过,这个版本很稳。
接下来在 application.yml 里加一段配置:
jasypt: encryptor: password: ${JASYPT_ENCRYPTOR_PASSWORD:} algorithm: PBEWITHHMACSHA512ANDAES_256 iv-generator-classname: org.jasypt.iv.RandomIvGenerator这里有两个关键点。第一,password 不要写死在配置文件里,而是从环境变量 JASYPT_ENCRYPTOR_PASSWORD 读取,默认值为空,宁可让启动失败也不要留后门。第二,algorithm 指定为 PBEWITHHMACSHA512ANDAES_256,这是 Jasypt 3.x 里推荐的强算法,比老旧的 PBEWithMD5AndDES 安全得多。如果你的 JDK 版本较老,遇到 “PBEWITHHMACSHA512ANDAES_256 requires JCE unlimited strength jurisdiction policy” 这类报错,需要先确认 JDK 是否支持 256 位密钥。
2.2 生成密文并替换配置
依赖配好之后,写一个简单的 main 方法用来生成密文,不建议每次都打开网页版工具生成,命令行式的工具更容易融入开发流程:
import org.jasypt.util.text.BasicTextEncryptor; public class EncryptMain { public static void main(String[] args) { BasicTextEncryptor encryptor = new BasicTextEncryptor(); encryptor.setPassword(System.getenv("JASYPT_ENCRYPTOR_PASSWORD")); String plainText = "your-db-password"; String encryptedText = encryptor.encrypt(plainText); System.out.printf("ENC(%s)%n", encryptedText); } }生成出来的密文类似于 ENC(VbG6JNnR0eXcUvYrandomBase64String==),直接替换掉 Nacos 配置里对应的明文值:
spring: datasource: url: jdbc:mysql://localhost:3306/app_db username: root password: ENC(VbG6JNnR0eXcUvYrandomBase64String==)替换完成后,正常启动应用。Jasypt 会在 Spring Environment 加载配置时自动解密,数据源初始化时拿到的是真实密码,Nacos 控制台和配置文件里只有密文。这里我踩过一个坑:密文里可能包含 /、+、= 这类特殊字符,如果直接写在 YAML 中,解析时会出各种离奇问题。后来的经验是,对密文值统一加双引号,也就是用 "ENC(VbG6JNnR0eXcUvYrandomBase64String==)" 的写法,可以规避绝大多数解析问题。
2.3 密钥管理和算法选择的实操心得
Jasypt 用起来简单,但它的安全性锚点全在密钥上。如果密钥被人拿到了,密文就等于明文。所以我有几条实操建议:
- 生产环境的 JASYPT_ENCRYPTOR_PASSWORD 应该由部署平台注入,不要写进任何配置文件、Dockerfile 或者 CI 脚本的明文变量里。
- 多环境使用不同密钥。我见过一个团队所有环境共用同一个密钥,测试环境泄了,生产环境跟着遭殃。
- 定期轮换密钥。Jasypt 只是解密工具,不具备密钥版本管理能力,所以轮换时需要对配置重新加密并重启应用,没办法实现无缝热切换。如果业务对轮换频率要求很高,建议考虑专业的密钥管理服务。
- 算法统一写死在配置里,不要依赖默认值。不同版本 Jasypt 默认算法不一样,同一个密文在不同版本下可能解不出来,显式声明能让团队排查时少走弯路。
3. 方案二:自定义 EnvironmentPostProcessor,自己掌控解密逻辑
3.1 原理与关键实现
Jasypt 很方便,但有的团队不想为了一个解密功能引入外部依赖,还有些老项目的 Nacos 配置格式比较特殊,需要对解密过程做定制。这时候自己写一个 EnvironmentPostProcessor 更合适。
它的原理并不复杂:Spring Boot 在启动时会创建 Environment 对象,加载所有 PropertySource(包括 Nacos 配置中心的配置),EnvironmentPostProcessor 就是在 Environment 构建完成后、但还没有正式初始化 Spring 容器之前,给你一个机会去修改配置内容。我们在这个阶段扫描所有配置值,发现 ENC() 格式就调用解密器替换成明文。
核心代码如下:
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 java.util.HashMap; import java.util.Map; public class EncEnvironmentPostProcessor implements EnvironmentPostProcessor { private static final String ENC_PREFIX = "ENC("; private static final String ENC_SUFFIX = ")"; @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { String secretKey = environment.getProperty("config.encrypt.key"); if (secretKey == null || secretKey.isBlank()) { throw new IllegalStateException("config.encrypt.key 未配置,无法解密 ENC() 配置"); } MutablePropertySources propertySources = environment.getPropertySources(); for (PropertySource<?> source : propertySources) { if (source instanceof MapPropertySource mapSource) { Map<String, Object> newMap = new HashMap<>(); mapSource.getSource().forEach((key, value) -> { if (value instanceof String strValue && strValue.startsWith(ENC_PREFIX)) { String inner = strValue.substring(ENC_PREFIX.length(), strValue.length() - ENC_SUFFIX.length()); newMap.put(key, AesUtil.decrypt(inner, secretKey)); } }); if (!newMap.isEmpty()) { propertySources.addFirst(new MapPropertySource(source.getName() + "-decrypted", newMap)); } } } } }要注意,这里我只是处理了 MapPropertySource,因为 Nacos 的 PropertySource 在 Spring Cloud Alibaba 中通常是以 MapPropertySource 子类形式存在的。如果你的环境里还有其他类型的 PropertySource,需要补充处理逻辑。解密后的值我通过 addFirst 放在最前面,这样后续 Spring 读取属性时优先拿到解密后的值。
3.2 注册插件与 Spring Boot 版本适配
写了 Processor 之后,要在 classpath 下注册它,不同 Spring Boot 版本注册方式不同。Spring Boot 2.x 用 META-INF/spring.factories:
org.springframework.boot.env.EnvironmentPostProcessor=com.example.config.EncEnvironmentPostProcessorSpring Boot 3.x 换成 META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor 文件,内容同样是一行实现类全限定名。这个差异我曾经在升级 Spring Boot 版本时踩过,旧方式在 3.x 下压根不执行,排查了半天才发现是注册路径变了。
AES 工具类可以选用标准的 AES/GCM 或者 AES/CBC/PKCS5Padding,密钥长度 256 位。核心的加密代码如下,方便你直接复制生成密文:
import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; public class AesUtil { private static final String TRANSFORMATION = "AES/GCM/NoPadding"; private static final int GCM_TAG_LENGTH_BITS = 128; private static final int IV_LENGTH_BYTES = 12; public static String encrypt(String content, String key) throws Exception { SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"); byte[] iv = new byte[IV_LENGTH_BYTES]; new SecureRandom().nextBytes(iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); byte[] encrypted = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8)); byte[] combined = new byte[iv.length + encrypted.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); return Base64.getEncoder().encodeToString(combined); } public static String decrypt(String base64Content, String key) throws Exception { byte[] decoded = Base64.getDecoder().decode(base64Content); byte[] iv = new byte[IV_LENGTH_BYTES]; System.arraycopy(decoded, 0, iv, 0, iv.length); byte[] encrypted = new byte[decoded.length - iv.length]; System.arraycopy(decoded, iv.length, encrypted, 0, encrypted.length); SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); byte[] decrypted = cipher.doFinal(encrypted); return new String(decrypted, StandardCharsets.UTF_8); } }生成密文的工具类就不重复写了,直接调用 AesUtil.encrypt(明文, 密钥),输出值包上 ENC() 丢到 Nacos 配置里。这里要注意,密钥长度必须与 AES-256 匹配,也就是 32 个字节。如果密钥是 16 字节,运行时会报 Illegal key size 异常。
3.3 自定义方案的优缺点
先说优点:没有神秘的外部依赖,逻辑透明可控;可以对解密过程打日志(但注意不要打印出明文密码);解密算法可以随时替换,比如从 AES 换到国密 SM4 也只需改工具类;密钥也能通过配置项指定,灵活度比 Jasypt 高。
缺点也明显:需要自己维护加解密工具、注册文件、兼容性测试。而且如果团队里没有人知道这套东西是自研的,新接手的人会在排查配置时彻底懵掉——Nacos 里全是 ENC(),代码里找不到任何相关依赖。所以如果选了这条路,一定要在项目 README 里写清楚加密机制和解密入口。
4. 方案三:Nacos 侧解密与服务端加密的边界
4.1 自定义 Nacos ConfigFilter 的思路
除了在 Spring 层处理 ENC(),还有一条技术路线是做在 Nacos 客户端层面,利用 Nacos 提供的 ConfigFilter SPI 机制。Nacos 客户端拉取配置后,会经过一串过滤器,我们可以通过自定义过滤器在配置写入 Spring Environment 之前做解密。
这个方案的好处是:解密逻辑和 Spring 容器解耦,理论上无论接入方是 Spring Cloud 还是原生 SDK,都能复用。缺点也很直接:ConfigFilter 的 SPI 机制公开资料少,接口内部的加载逻辑比较隐晦,调试成本高;而且 Nacos 版本升级时,SPI 接口签名有变化的可能,维护成本不低。
我实际调研过这个路线,最终没有在正式项目里采用,因为它的边际收益不够大:Spring 层后处理完全能满足要求,ConfigFilter 方案却要面对更多兼容性风险。
4.2 服务端加密存储与控制台明文的取舍
还有一种思路是,直接改造 Nacos 服务端,让配置以密文形式落库,控制台展示时再解密。听起来更彻底,但这里有一个绕不开的矛盾:Nacos 控制台不仅给应用提供配置,还承担着人工排查、灰度上线、配置审计的作用。如果服务端存储密文,控制台展示也要做解密,等于要把整个权限体系和展示层都重做一遍;如果控制台直接展示密文,运维人员在排查问题时会非常痛苦。
我见到的实际案例里,大部分团队最后都接受了这样一个折中方案:Nacos 配置中心采用客户端解密,服务端和数据库层保持透明。换句话说,密文只存在于 Nacos 的配置文本里,应用侧拿到后立即解密。虽然 Nacos 控制台对外能看到密文,但看不到明文,这就已经满足了绝大多数的安全审计需求。如果一定要做到“服务端落库密文 + 控制台脱敏展示”,那已经不是配置中心的问题,而是整个平台需要引入专业密钥管理系统,不是几行代码能搞定的。
5. 完整实操流程:从明文到 ENC() 的落地全过程
5.1 准备环境与一键加密工具
我以一个真实做过的小型微服务为例,项目使用 Spring Boot 2.7、Spring Cloud Alibaba 2021.0.5.0、Nacos 2.x。需要加密的配置项有数据库密码、Redis 密码、短信平台 AK。
第一步,准备一个可复用的加密工具 JAR,或者直接在项目里放一个独立模块,避免每次加密都用临时代码。工具模块里提供一个命令行入口和 main 方法,支持从环境变量读取密钥,不把密钥留在命令行参数里:
export CONFIG_ENCRYPT_KEY=your-32-byte-aes-key java -cp enc-tool.jar com.example.config.EncryptTool "your-db-password"工具输出格式直接就是 ENC(...),方便复制。密钥统一由运维生成,32 字节随机字符串,生成方式可以用系统自带的随机源:
openssl rand -base64 24 | base64这个命令生成的字符串长度正好合适,注意保存后不要进 Git 仓库,也不要发到群聊里。
5.2 修改配置并启动验证
拿到密文后,依次在 Nacos 控制台里修改对应配置项。这里我建议一条一条改,改完一条就在应用日志里确认解密是否成功。如果一次改多个,某个配置值格式错了,排查范围会变大。
示例,改动前:
spring: datasource: password: YourPlainPassword123改动后:
spring: datasource: password: "ENC(aB3dEfGhiJkLmNoPqRsTuVwXyZ0123456789/AbC+Def==)"然后重启应用。验证方式不要只看启动日志里有没有报错,更直接的方式是调用一个内部接口,确认业务能正常访问数据库。比如项目里自带 health 接口,它会触发数据源连接操作,连接成功就说明解密链路通了。
启动后顺便检查一件事:Nacos 控制台里密文是否完整保存,有没有出现换行被吃掉、特殊字符被转义的情况。这个问题在 Web 控制台编辑时概率不低,尤其是 URL-safe Base64 之外的特殊字符。
5.3 多环境密钥管理与灰度节奏
实际项目里通常有 dev、test、prod 三套环境。我建议每个环境用不同密钥,通过部署平台的配置中心统一注入。Spring 侧读取方式保持一致:
jasypt: encryptor: password: ${JASYPT_ENCRYPTOR_PASSWORD}各环境只要注入不同的 JASYPT_ENCRYPTOR_PASSWORD,就能做到同一个项目在不同环境解密不同密钥的密文值。Nacos 配置里存的密文可以按环境各自生成,互不影响。
灰度节奏上,先拿一到两个非核心服务做试点,验证启动、动态刷新、日志脱敏都没问题后,再推广到所有服务。千万不要把二三十个服务一次性全部切过去,否则一旦密钥配置漏了,整个发布窗口炸掉排查很被动。
6. 踩坑实录与高频排查速查
6.1 我遇到过的四个典型问题
这些坑都是真实碰到的,整理成表格方便对照:
| 问题现象 | 根因分析 | 解决办法 |
|---|---|---|
| 启动时报 Decryption failed,提示 Unable to decrypt data | 加密时和运行时算法不一致,或被加密字符集问题干扰 | 确认加密工具与运行时使用相同算法、相同密钥,检查密文是否被 YAML 截断 |
| 连接数据库报 Access denied | 解密成功但密码本身因特殊字符在传输中被转义,出现了 +、/ 符号丢失 | 检查密文是否用双引号包裹,重新生成密文时确认 Base64 完整性 |
| EnvironmentPostProcessor 不生效 | Spring Boot 3.x 下注册文件路径不对 | 确认 META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor 文件存在且内容正确 |
| 其他服务读到配置时报明文密码泄露 | 解密后的值被某个监控端点暴露,比如 Spring Boot Actuator 的 /env | 关闭或限制 /actuator/env 匿名访问,配置脱敏规则对敏感字段打码 |
6.2 排查定位技巧:分清是解密失败还是连接失败
遇到配置无法解析的问题,第一件要做的事是分清阶段。解密失败会有明确的关键字,比如 Decryption failed、Unable to decrypt、PaddingException;而连接失败则表现为 No suitable driver、Access denied 这类。两个阶段是完全不同的排查路径。
我习惯在加密工具里加一个“自检模式”,传一个明文进去,加密后再解密,看结果是否一致。这样就能区分是工具本身的问题还是配置链路的问题。另外,在日志配置里临时打印解密后的值有助于定位,但打印完必须立即删除,别把明文密码留在日志里,那等于自己把安全门又打开了。
6.3 动态刷新场景下的一个隐藏时间点
Nacos 支持配置动态刷新,但 ENC() 的解密时机主要在应用启动阶段。如果一个配置项在运行中被人为改成了 ENC() 格式,单纯的 @RefreshScope 不会触发 EnvironmentPostProcessor 重新解密。Spring Cloud Alibaba 的 NacosPropertySource 在刷新时会做属性源替换,自研 PostProcessor 不会自动再次执行。
我处理这个问题的思路是:明确约定“加密配置只随发布变更,不通过控制台热修改”。如果需要热更新密钥,就得引入更复杂的监听器体系,在 Nacos 配置变更事件中主动触发解密并更新到 Environment。这个方案我在一个对可用性要求极高的项目里做过,代码成本不小,一般团队不建议一上来就上。
最后说点个人感受。ENC(...) 这套做法,本质上是一种“尽力而为”的安全方案:应用侧持有密钥,配置中心只存密文,密钥由部署平台环境变量管理。它挡得住“配置泄露”这一层的风险,但挡不住“运行进程内存被 dump”这类高难度攻击。安全是分层的,配置加密只是其中一层。我落地过几个项目后发现,最难的不是写那几十行加解密代码,而是和团队达成一致,让每个人都养成“敏感信息不入明文配置”的习惯。有些同事为了方便,加密后又把明文密码注释在代码里,这比不加密还要危险。如果你正在推动这件事,建议把这条作为团队红线写进规范里。