Disconf 加密配置自动解密
为啥要做这件事
Disconf 里经常放 AK、SK、API Key 这类东西。以前是明文,改配置方便,可配置中心能看的人不少,仓库、下载目录里也能碰到。
最近给 Disconf 配了自动解密:敏感配置在控制台里存密文,应用启动时自动解开,再注入到 @Value 字段。业务代码不用每个字段自己解密,改配置也还是热更新那套。
目标就三件事:
- Disconf 里只存密文
- 业务代码还是
@Value("${xxx}"),不用每个字段自己解密 - 启动能解,热更新也能解,行为一致
下面把接入步骤、核心知识点,以及上线后踩到的坑讲清楚。
先说结论
Disconf 托管配置走的是 Spring 的PropertyPlaceholderConfigurer这一套。启动时 Spring 会先 convertProperties,再解析 ${},所以重写convertPropertyValue就能解密。
热更新不是这条路。Disconf 会直接拿 mergeProperties() 的返回值去改 Bean,不会再走一遍 convertProperties。密文就这么被塞进字段了。
所以自定义 Configurer 里两件事都要做:
1.convertPropertyValue:识别密文并解密
2.mergeProperties:合并完立刻 convertProperties,让热更新也吃到明文
约定很简单:密文后面跟 _encrypted。不是这个后缀,原样返回。
接入怎么做
1. XML 里把 Configurer 换成自己的
原来用的是 Disconf 自带的 ReloadingPropertyPlaceholderConfigurer。它负责两件事:启动时解析占位符,配置文件变了再把新值写回 Bean。
自动解密就插在这一层。业务 Bean 不用动。
<bean id="application_Properties"
class="com.baidu.disconf.client.addons.properties.ReloadablePropertiesFactoryBean">
<property name="locationPaths">
<list>
<value>classpath:*/xxx1.properties</value>
<value>classpath:*/xxx2.properties</value>
<!-- 其他托管文件 -->
</list>
</property>
</bean>
<bean id="applicationPropertyConfigurer"
class="weli.spring.config.ReloadingEncryptedPropertyPlaceholderConfigurer">
<property name="ignoreUnresolvablePlaceholders" value="true"/>
<property name="propertiesArray">
<list>
<ref bean="application_Properties"/>
</list>
</property>
</bean>
敏感配置单独放到
xxx.properties
这类文件里,Disconf 上托管的是加密版本。普通开关、白名单还走原来的明文文件就行。
2. 自定义 ReloadingEncryptedPropertyPlaceholderConfigurer
核心就两个方法:
public class ReloadingEncryptedPropertyPlaceholderConfigurer
extends ReloadingPropertyPlaceholderConfigurer {
private static final String ENCRYPTED_SUFFIX = "_encrypted";
/**
* Disconf 热更新会直接用 mergeProperties() 的返回值更新 Bean,
* 不会走 Spring 启动期的 convertProperties。
* 合并完立刻解密,热更新才跟启动一致。
*/
@Override
protected Properties mergeProperties() throws IOException {
Properties properties = super.mergeProperties();
convertProperties(properties);
return properties;
}
@Override
protected String convertPropertyValue(String originalValue) {
if (!StringUtils.endsWith(originalValue, ENCRYPTED_SUFFIX)) {
return originalValue;
}
String cipherText = originalValue.substring(
0, originalValue.length() - ENCRYPTED_SUFFIX.length());
return KmsAesUtils.decrypt(cipherText, resolveEncryptKey());
}
}
业务侧不用改。
CommonDisconfConfigService这一类java类还是:
c@Value("${volc.ai.apiKey}")
private String volcApiKey;
字段拿到的已经是明文。
3. Disconf 里怎么配
加密值按这个格式:
{Base64密文}_encrypted
例如:
# 明文时代
volc.ai.apiKey=sk-xxxxxxxx
# 加密后
volc.ai.apiKey=AbCdEf....==_encrypted
识别靠后缀,不靠 key 名字。同一个文件里,明文和密文可以混着放。开关继续写1 / 0,密钥才加 _encrypted。
解密走统一的 KMS AES 工具,密钥不要写进博客、不要进 Git 明文。生产上从 KMS 或受控配置拿。
上线后遇到的问题:启动能解,热更新为啥不解?
接入本身不复杂,自定义一个 Configurer,重写 convertPropertyValue就行。按 Spring 的扩展方式接入后,启动、重启验证都正常:@Value拿到的是明文,下游 SDK 也能调通。
上线后有同事在 Disconf 里改了一个加密配置。热更新生效了,Bean 字段却变成了 xxxx_encrypted这种密文。下游拿着密文去调第三方,直接失败。
重启服务又恢复正常。也就是说:只重启永远看不出问题,一改配置就把密文写进内存。
不是解密逻辑写错了,是热更新根本没走进解密。
为啥会这样
Spring 的 PropertyPlaceholderConfigurer 继承自 PropertyResourceConfigurer。启动时大概是这样:
postProcessBeanFactory
├─ mergeProperties() // 把本地文件、Disconf 下载文件合成一份 Properties
├─ convertProperties() // 遍历每个 value,调用 convertPropertyValue
└─ processProperties() // 解析 ${},注入 Bean
convertProperties会把每个 value 丢给 convertPropertyValue。启动期重写这个方法,密文在占位符解析之前就已经变成明文了。所以重启验证会过。
问题在热更新。Disconf 的 ReloadingPropertyPlaceholderConfigurer 实现了 IReloadablePropertiesListener。配置文件一变,走的是propertiesReloaded:
配置文件变更
└─ propertiesReloaded()
├─ 取出 lastMergedProperties(旧值)
├─ 再调一次 mergeProperties()(新值,原始文本)
├─ 对比哪些 key 变了
├─ parseStringValue(占位符, 新 Properties) // 直接从 Properties 取值
└─ BeanWrapper.setPropertyValue(...) // 写回字段
注意中间没有convertProperties。
parseStringValue就是按 key 从这份 Properties 里取值。Properties 里还是 xxxx_encrypted,字段就被写成密文了。
怎么解决的
热更新能不能解密,不取决于有没有重写 convertPropertyValue,而取决于送进 parseStringValue 的那份 Properties 是不是已经解密。
启动和热更新都会走到 mergeProperties,这是两条链路的交汇点。于是在合并完成后主动调一次 convertProperties:
@Override
protected Properties mergeProperties() throws IOException {
Properties properties = super.mergeProperties();
convertProperties(properties);
return properties;
}
这样启动和热更新共用已经解密的Properties。convertPropertyValue 仍然只负责“看到_encrypted就解密”,职责没有变。
Disconf 自己的mergeProperties还会把结果缓存到 lastMergedProperties。我们是在 super.mergeProperties() 之后原地 convertProperties,缓存里也是明文。下次对比旧值、新值,比的都是解密后的内容,不会把“密文变密文”误判成没变化。
启动时 convertProperties其实会跑两遍:我们在mergeProperties 里跑一次,Spring 的postProcessBeanFactory再跑一次。没关系。解密过的值已经没有_encrypted后缀,第二次原样返回。
两条链路,一张图看完
补 mergeProperties的原因就一句话:把解密提前到“Properties 刚合并完”,启动和热更新共用这一份已经解密的 Properties。
核心知识点
1. Spring 预留了“改 value”的钩子,但只保证启动期
PropertyResourceConfigurer 里大概是这样:
protected void convertProperties(Properties props) {
Enumeration<?> names = props.propertyNames();
while (names.hasMoreElements()) {
String name = (String) names.nextElement();
String value = props.getProperty(name);
String converted = convertProperty(name, value);
if (!ObjectUtils.nullSafeEquals(value, converted)) {
props.setProperty(name, converted);
}
}
}
protected String convertPropertyValue(String originalValue) {
return originalValue; // 默认啥也不干
}
这是给加密、脱敏、统一裁剪留的扩展点。Jasypt 的 EncryptablePropertyPlaceholderConfigurer 也是走这条路。前提是调用方会走 postProcessBeanFactory。 Disconf 热更新自己另起了一条路,这个钩子就用不上了。
2. Disconf 热更新是“直接改对象”,不是“重新走一遍 Spring 注入”
启动时 @Value靠 BeanFactory 后置处理。热更新时 Bean 已经在容器里了,Disconf 用 BeanWrapper.setPropertyValue按属性名改字段。
所以热更新能不能解密,不取决于有没有重写 convertPropertyValue,而取决于送进 parseStringValue 的那份 Properties 是不是已经解密。
3. 用后缀标记密文,比改 key 名更合适
没有用 ENC(...)这种包裹,也没有搞 xxx.encrypted=true这种并列 key,就是 value 末尾加 _encrypted。
好处:
- Disconf 控制台里一眼能看出来哪些是密文
- 明文、密文可以混在同一个 properties 里
@Value("${volc.ai.apiKey}")不用改- 解密过的值没有后缀,
convertPropertyValue再进来也是幂等的,不用怕跑两遍 代价:约定要守住。明文别随手加_encrypted,不然会拿去当密文解,直接失败。
4. mergeProperties 才是两条链路的交汇点
要让行为一致,解密放在交汇点最合适。只改 convertPropertyValue,等于只覆盖了表格第一行。
5.托管文件和注解注入是配套的
项目里 Disconf 有两种用法:
- XML 托管 properties / yml,靠 PlaceholderConfigurer 解析
${},再注入@Value @DisconfFile这类 Java 配置类
这次解密挂在 PlaceholderConfigurer 上,覆盖的是第一种。走托管文件 + @Value的密钥都能自动解。如果以后有配置类自己读文件内容,那是另一条链路,不会经过这个 Configurer。
使用时注意几点
加密范围别铺太大。 开关、名单、URL 没必要加密,增加排障成本。AK、SK、Token、密钥才加密。
热更新要专门测一次。 只重启验证会漏。改 Disconf 里的加密值,看 Bean 字段是不是明文、下游调用是不是正常。日志里不要把解密后的密钥打出来。
解密失败要让它暴露。 别吞异常再返回原文。密文解不开,宁可启动失败或热更新失败,也好过带着错误密钥跑到第三方。
密钥本身别再进 Disconf 明文。 配置解密的 key 如果也明文放 Disconf,等于锁和钥匙放一起。走 KMS 或机器侧受控配置。
后缀是契约。 加密工具吐出来的字符串,末尾必须是_encrypted,和接口响应加密那套标记可以保持一致,运营、排障都好认。
小结
这次接入可以压成三句话:
1.把 Disconf 的 ReloadingPropertyPlaceholderConfigurer换成带解密的子类,业务 @Value不用动。
2.convertPropertyValue 负责“看到_encrypted就解密”,解决启动期。
3.mergeProperties里主动 convertProperties,解决热更新不走 Spring 转换钩子的问题。
看起来像多写了十来行。真正有用的是分清两条链路:启动走 Spring 的 convertProperties,热更新走 Disconf 的 mergeProperties+ BeanWrapper。钩子挂错地方,重启永远正常,一改配置就把密文写进内存。
作者介绍:
- 刘杰 资深服务端开发工程师