feat(cache): 敏感缓存 L2 整包 AES-GCM 加密

将 SecureAesGcmEncryptor 下沉至 common-config 并注册唯一 Bean,与 DB 字段加密共用。secure: 前缀的 cacheName 在 Redis 中整包加密 value;加密未启用时仅 L1 不写 Redis。
This commit is contained in:
DaxPay Dev
2026-07-16 08:46:27 +08:00
parent a42052b821
commit 477f41ca07
20 changed files with 569 additions and 68 deletions

View File

@@ -39,6 +39,10 @@ daxpay:
maximum-size: 10000 # L1 本地缓存最大容量
l2:
default-ttl: 1800 # L2 Redis 缓存默认过期时间(秒)
secure-prefix: "secure:" # 敏感缓存名前缀,匹配的 L2 value 整包 AES-GCM 加密
# secure-names: [] # 额外精确敏感 cacheName 列表(可选)
# 敏感 L2 加密复用 DB 字段加密密钥(全进程同一 SecureAesGcmEncryptor
# platform.config.encrypt.enable / keys
```
## 架构设计
@@ -178,6 +182,8 @@ public void clearUserCache() {
@Cacheable(value = "merchant:config", key = "#mchNo")
```
**含密钥/证书等敏感数据的缓存名必须以 `secure:` 开头**(见下文「敏感缓存」)。
### 7. 如何处理缓存穿透?
当前实现不缓存 null 值(`disableCachingNullValues()`),如需防止缓存穿透:
@@ -208,3 +214,57 @@ public void printCacheStats(String cacheName) {
log.info("命中次数: {}, 未命中次数: {}", stats.hitCount(), stats.missCount());
}
```
## 敏感缓存secure:
通道密钥、商户对接密钥等敏感对象若使用 Spring Cache**必须**使用 `secure:` 前缀的 cacheName基础设施会自动对 L2 Redis value 做**整包 AES-GCM 加密**。
### 与 DB 字段加密的区别
| 层 | 粒度 | 说明 |
|----|------|------|
| DBDataEncryptTypeHandler | **字段级** | 仅 `privateKey` / `apiKey` 等列密文,其它列明文 |
| Redis L2secure: | **整包** | 整个缓存对象 JSON 一次加密Redis 中不可见任何字段明文 |
| L1 本地 | 明文对象 | 进程内,与普通缓存一致 |
密钥管理复用 `daxpay.platform.config.encrypt`(与 DB 同一 `SecureAesGcmEncryptor` Bean
### 业务写法(与普通缓存相同)
```java
// 读:默认 CacheManager仅 cacheName 用 secure: 前缀
@Cacheable(value = "secure:channel-key:adapay-direct",
key = "#channelMchNo + ':' + #sandbox")
public AdapayDirectKeyConfig getForPay(String channelMchNo, boolean sandbox) {
return manager.find(...).orElseThrow(...);
}
// 写:必须同名同 key 失效
@CacheEvict(value = "secure:channel-key:adapay-direct",
key = "#param.channelMchNo + ':' + #param.sandbox")
public void saveConfig(AdapayDirectKeyConfigParam param) {
// ...
}
```
命名建议:
```
secure:channel-key:wechat-isv
secure:channel-key:adapay-direct
secure:merchant-credential
secure:platform-encrypt-config
```
### 数据加密未启用时
`daxpay.platform.config.encrypt.enable=false`
- `secure:*` **禁止写 Redis**L1-only 降级),避免明文密钥落盘
- 启动日志会 warn 提示
### 禁止事项
- 禁止对含密钥的实体使用非 `secure:` 的 cacheName会明文进 Redis
- 禁止缓存完整 `*SdkCredential` 到普通 cacheName
- 不需要也不应指定第二个 `cacheManager`:统一用默认二级缓存即可

View File

@@ -45,5 +45,10 @@
<artifactId>common-artemis</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,14 +1,16 @@
package cn.daxpay.open.platform.capability.cache;
import cn.daxpay.open.platform.common.config.ConfigAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.ComponentScan;
/// # 缓存配置
///
/// 依赖 [ConfigAutoConfiguration] 先注册 SecureAesGcmEncryptor供敏感缓存 L2 加密使用。
@ComponentScan
@ConfigurationPropertiesScan
@AutoConfiguration
@AutoConfiguration(after = ConfigAutoConfiguration.class)
public class CacheAutoConfiguration {
}

View File

@@ -4,9 +4,14 @@ import cn.daxpay.open.platform.capability.cache.core.MultiLevelCacheManager;
import cn.daxpay.open.platform.capability.cache.core.DaxpayRedisCacheManager;
import cn.daxpay.open.platform.capability.cache.core.LocalCacheRegistry;
import cn.daxpay.open.platform.capability.cache.notify.publisher.CacheInvalidationPublisher;
import cn.daxpay.open.platform.capability.cache.secure.EncryptingRedisSerializer;
import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher;
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
import cn.daxpay.open.platform.common.config.properties.PlatformCommonProperties;
import cn.daxpay.open.platform.common.redis.serializer.JacksonRedisSerializer;
import cn.daxpay.open.platform.common.artemis.service.ArtemisTemplateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -35,6 +40,8 @@ import java.util.stream.Collectors;
/// - L1 本地缓存始终启用,作为性能加速层
/// - L2 Redis 作为共享缓存主层
/// - 缓存失效通知通过 Artemis 广播,始终启用
/// - 名称匹配 secure: 的缓存L2 整包 AES-GCM 加密;加密未启用时仅 L1
@Slf4j
@Configuration
@EnableCaching
@EnableConfigurationProperties(PlatformCommonProperties.class)
@@ -65,16 +72,44 @@ public class CachingConfiguration implements CachingConfigurer {
};
}
/// 敏感缓存名匹配器
@Bean
@ConditionalOnMissingBean
public SecureCacheNameMatcher secureCacheNameMatcher() {
var cache = platformCommonProperties.getCache();
return new SecureCacheNameMatcher(cache.getSecurePrefix(), cache.getSecureNames());
}
/// Redis 缓存管理器L2
///
/// 作为二级缓存的 L2 层,负责跨节点数据共享
///
/// 普通 cacheName 使用明文 JSON敏感 cacheName 在 encryptor 可用时使用整包加密序列化。
@Bean
@ConditionalOnMissingBean(DaxpayRedisCacheManager.class)
public DaxpayRedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
public DaxpayRedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory,
SecureCacheNameMatcher secureCacheNameMatcher,
ObjectProvider<SecureAesGcmEncryptor> encryptorProvider) {
var l2Config = platformCommonProperties.getCache().getL2();
Duration ttl = Duration.ofSeconds(l2Config.getDefaultTtl());
RedisCacheConfiguration plainConfig = this.plainValueConfig(ttl);
SecureAesGcmEncryptor encryptor = encryptorProvider.getIfAvailable();
RedisCacheConfiguration secureConfig = null;
if (encryptor != null) {
EncryptingRedisSerializer encryptingSerializer = new EncryptingRedisSerializer(encryptor);
secureConfig = this.secureValueConfig(ttl, encryptingSerializer);
log.info("敏感缓存 L2 整包加密已启用,前缀: {}", secureCacheNameMatcher.getSecurePrefix());
} else {
log.warn("未启用数据加密:名称匹配 {} 的敏感缓存将仅使用 L1不写 Redis",
secureCacheNameMatcher.getSecurePrefix());
}
return new DaxpayRedisCacheManager(
RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory),
this.getRedisCacheConfigurationWithTtl(Duration.ofSeconds(l2Config.getDefaultTtl())));
plainConfig,
secureConfig,
secureCacheNameMatcher);
}
/// 本地缓存注册表
@@ -106,16 +141,21 @@ public class CachingConfiguration implements CachingConfigurer {
@Primary
public CacheManager cacheManager(DaxpayRedisCacheManager redisCacheManager,
LocalCacheRegistry localCacheRegistry,
CacheInvalidationPublisher publisher) {
CacheInvalidationPublisher publisher,
SecureCacheNameMatcher secureCacheNameMatcher,
ObjectProvider<SecureAesGcmEncryptor> encryptorProvider) {
boolean secureL2Enabled = encryptorProvider.getIfAvailable() != null;
return new MultiLevelCacheManager(
redisCacheManager,
localCacheRegistry,
publisher
publisher,
secureCacheNameMatcher,
secureL2Enabled
);
}
/// 缓存管理器策略过期时间配置
private RedisCacheConfiguration getRedisCacheConfigurationWithTtl(Duration duration) {
/// 缓存管理器策略过期时间配置(普通缓存:明文 JSON value
private RedisCacheConfiguration plainValueConfig(Duration duration) {
// redis缓存配置
return RedisCacheConfiguration.defaultCacheConfig()
// 设置key为String
@@ -130,5 +170,20 @@ public class CachingConfiguration implements CachingConfigurer {
.entryTtl(duration);
}
}
/// 缓存管理器策略过期时间配置(敏感缓存:整包 AES-GCM 加密 value
private RedisCacheConfiguration secureValueConfig(Duration duration, EncryptingRedisSerializer encryptingSerializer) {
// redis缓存配置
return RedisCacheConfiguration.defaultCacheConfig()
// 设置key为String
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// 设置value 序列化方式为整包加密JSON + AES-GCM
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(encryptingSerializer))
// 不缓存null
.disableCachingNullValues()
// 覆盖默认的构造key否则会多出一个冒号
.computePrefixWith(name -> name + ":")
// 过期时间
.entryTtl(duration);
}
}

View File

@@ -1,52 +1,59 @@
package cn.daxpay.open.platform.capability.cache.core;
import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher;
import org.springframework.data.redis.cache.RedisCache;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import java.util.Map;
/// # 自定义 Redis 缓存管理器
///
/// 作为二级缓存的 L2 层,负责跨节点数据共享
///
/// 按 cacheName 选择序列化策略:
/// - 敏感名SecureCacheNameMatcher 命中)且已配置 secureConfig整包 AES-GCM 加密
/// - 其它:明文 JSON
public class DaxpayRedisCacheManager extends RedisCacheManager {
private final RedisCacheWriter cacheWriter;
private final RedisCacheConfiguration defaultConfig;
private final RedisCacheConfiguration secureConfig;
private final SecureCacheNameMatcher secureMatcher;
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
/// @param cacheWriter Redis 写入器
/// @param defaultCacheConfiguration 普通缓存配置(明文 JSON
/// @param secureCacheConfiguration 敏感缓存配置整包加密encrypt 未启用时可为 null
/// @param secureMatcher 敏感缓存名匹配器
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter,
RedisCacheConfiguration defaultCacheConfiguration,
RedisCacheConfiguration secureCacheConfiguration,
SecureCacheNameMatcher secureMatcher) {
super(cacheWriter, defaultCacheConfiguration);
this.cacheWriter = cacheWriter;
}
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, String... initialCacheNames) {
super(cacheWriter, defaultCacheConfiguration, initialCacheNames);
this.cacheWriter = cacheWriter;
}
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration,
boolean allowInFlightCacheCreation, String... initialCacheNames) {
super(cacheWriter, defaultCacheConfiguration, allowInFlightCacheCreation, initialCacheNames);
this.cacheWriter = cacheWriter;
}
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration,
Map<String, RedisCacheConfiguration> initialCacheConfigurations) {
super(cacheWriter, defaultCacheConfiguration, initialCacheConfigurations);
this.cacheWriter = cacheWriter;
}
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration,
Map<String, RedisCacheConfiguration> initialCacheConfigurations, boolean allowInFlightCacheCreation) {
super(cacheWriter, defaultCacheConfiguration, allowInFlightCacheCreation, initialCacheConfigurations);
this.cacheWriter = cacheWriter;
this.defaultConfig = defaultCacheConfiguration;
this.secureConfig = secureCacheConfiguration;
this.secureMatcher = secureMatcher;
}
/// 创建 Redis 缓存
@Override
@SuppressWarnings({ "ConstantConditions", "NullableProblems" })
protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
return new DaxpayRedisCache(name, this.cacheWriter, cacheConfig);
RedisCacheConfiguration config = this.resolveConfig(name, cacheConfig);
return new DaxpayRedisCache(name, this.cacheWriter, config);
}
/// 按 cacheName 解析最终 RedisCacheConfiguration
private RedisCacheConfiguration resolveConfig(String name, RedisCacheConfiguration cacheConfig) {
// 敏感缓存且加密 L2 可用:强制使用 secure 序列化,避免 initialCacheConfigurations 漏配
if (this.secureMatcher != null
&& this.secureMatcher.matches(name)
&& this.secureConfig != null) {
return this.secureConfig;
}
if (cacheConfig != null) {
return cacheConfig;
}
return this.defaultConfig;
}
}

View File

@@ -14,8 +14,9 @@ import java.util.concurrent.Callable;
/// 设计要点:
/// - L2 Redis 是共享缓存主层,负责跨节点数据共享
/// - L1 Caffeine 是性能加速层,仅在本节点生效
/// - 缓存失效通过 RocketMQ 广播通知其他节点删除本地 L1
/// - 缓存失效通过 Artemis 广播通知其他节点删除本地 L1
/// - 本地缓存 key 必须统一使用字符串形式,保证跨节点广播删除一致性
/// - 敏感缓存secure:)在数据加密未启用时仅使用 L1禁止明文写 Redis
@Slf4j
public class MultiLevelCache implements Cache {
@@ -27,14 +28,27 @@ public class MultiLevelCache implements Cache {
private final CacheInvalidationPublisher publisher;
/// 是否为敏感缓存名
private final boolean secureCache;
/// 敏感缓存是否允许写/读 L2依赖数据加密已启用
private final boolean secureL2Enabled;
public MultiLevelCache(String name,
LocalCacheRegistry localCacheRegistry,
Cache redisCache,
CacheInvalidationPublisher publisher) {
CacheInvalidationPublisher publisher,
boolean secureCache,
boolean secureL2Enabled) {
this.name = name;
this.localCache = localCacheRegistry.getOrCreate(name);
this.redisCache = redisCache;
this.publisher = publisher;
this.secureCache = secureCache;
this.secureL2Enabled = secureL2Enabled;
if (secureCache && !secureL2Enabled) {
log.warn("敏感缓存 [{}] 因未启用数据加密,仅使用 L1 本地缓存,不写 Redis", name);
}
}
@Override
@@ -47,6 +61,11 @@ public class MultiLevelCache implements Cache {
return this.localCache;
}
/// 是否跳过 L2敏感且未开加密
private boolean isL1Only() {
return this.secureCache && !this.secureL2Enabled;
}
/// 读取缓存,优先从 L1 本地缓存读取,未命中则从 L2 Redis 读取并回填 L1
///
/// 读取流程:
@@ -54,6 +73,8 @@ public class MultiLevelCache implements Cache {
/// - L1 未命中则查 L2 Redis
/// - L2 命中则回填 L1
/// - 全未命中返回 null
///
/// 敏感缓存且未启用加密时跳过 L2仅查 L1
@Override
public ValueWrapper get(Object key) {
String localKey = this.toLocalKey(key);
@@ -62,6 +83,12 @@ public class MultiLevelCache implements Cache {
return () -> localValue;
}
// 敏感缓存未开加密:禁止读 Redis避免历史明文或无法解密的脏数据
if (this.isL1Only()) {
log.debug("敏感缓存 L1-only 未命中: cacheName={}, key={}", this.name, key);
return null;
}
ValueWrapper redisValue = this.redisCache.get(key);
if (redisValue != null) {
this.localCache.put(localKey, Objects.requireNonNull(redisValue.get()));
@@ -103,6 +130,8 @@ public class MultiLevelCache implements Cache {
/// 写入缓存,同时写入 L2 Redis 和 L1 本地缓存
///
/// 注意:写入操作不广播通知其他节点,其他节点在读取时会从 L2 加载最新值
///
/// 敏感缓存且未启用加密时仅写 L1禁止明文写 Redis
@Override
public void put(Object key, Object value) {
if (value == null) {
@@ -110,6 +139,11 @@ public class MultiLevelCache implements Cache {
return;
}
String localKey = this.toLocalKey(key);
if (this.isL1Only()) {
this.localCache.put(localKey, value);
log.debug("写入 L1-only 敏感缓存: cacheName={}, key={}", this.name, key);
return;
}
this.redisCache.put(key, value);
this.localCache.put(localKey, value);
log.debug("写入二级缓存: cacheName={}, key={}", this.name, key);
@@ -120,11 +154,12 @@ public class MultiLevelCache implements Cache {
/// 删除流程:
/// - 删除 L2 Redis
/// - 删除本机 L1
/// - 发布 RocketMQ 广播消息
/// - 发布 Artemis 广播消息
/// - 其他节点收到消息后删除各自 L1
@Override
public void evict(Object key) {
String localKey = this.toLocalKey(key);
// 仍尝试删 Redis清理可能存在的历史数据
this.redisCache.evict(key);
this.localCache.invalidate(localKey);
log.debug("删除二级缓存: cacheName={}, key={}", this.name, key);
@@ -144,7 +179,7 @@ public class MultiLevelCache implements Cache {
///
/// 为什么必须统一使用字符串 key
/// - 本地缓存原始 key 可能是任意对象类型Long、String、自定义对象等
/// - RocketMQ 广播消息中的 key 只能是字符串
/// - Artemis 广播消息中的 key 只能是字符串
/// - 如果本地缓存使用原始对象作为 key广播消息使用字符串会导致跨节点删除失败
/// - 例如:本机 key=Long(1),广播 key="1",远端无法匹配
///
@@ -159,4 +194,3 @@ public class MultiLevelCache implements Cache {
return String.valueOf(key);
}
}

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.platform.capability.cache.core;
import cn.daxpay.open.platform.capability.cache.notify.publisher.CacheInvalidationPublisher;
import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
@@ -18,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap;
/// 设计要点:
/// - 作为 Spring 默认 CacheManager通过 @Primary 标识
/// - L1 本地缓存始终启用,不需要开关控制
/// - 缓存失效通知通过 RocketMQ 广播,始终启用
/// - 缓存失效通知通过 Artemis 广播,始终启用
/// - 敏感 cacheName 结合 secureL2Enabled 决定是否写 Redis
@Slf4j
@RequiredArgsConstructor
public class MultiLevelCacheManager implements CacheManager {
@@ -29,6 +31,11 @@ public class MultiLevelCacheManager implements CacheManager {
private final CacheInvalidationPublisher publisher;
private final SecureCacheNameMatcher secureMatcher;
/// 敏感缓存是否允许 L2数据加密已启用时为 true
private final boolean secureL2Enabled;
private final Map<String, MultiLevelCache> cacheMap = new ConcurrentHashMap<>();
@Override
@@ -50,13 +57,15 @@ public class MultiLevelCacheManager implements CacheManager {
throw new IllegalStateException("Redis cache not found: " + name);
}
log.debug("创建二级缓存: name={}", name);
boolean secureCache = this.secureMatcher != null && this.secureMatcher.matches(name);
log.debug("创建二级缓存: name={}, secure={}, secureL2Enabled={}", name, secureCache, this.secureL2Enabled);
return new MultiLevelCache(
name,
this.localCacheRegistry,
redisCache,
this.publisher
this.publisher,
secureCache,
this.secureL2Enabled
);
}
}

View File

@@ -0,0 +1,77 @@
package cn.daxpay.open.platform.capability.cache.secure;
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import java.nio.charset.StandardCharsets;
/// # 敏感缓存 L2 整包加密序列化器
///
/// 将缓存对象序列化为 JSON 后,对**整段 JSON 字符串**做 AES-256-GCM 加密再写入 Redis。
/// 非字段级加密:密钥字段与非敏感字段一起进入同一密文包。
///
/// 流程:
/// - serialize: Object → JSON → encrypt → `v{n}:...` bytes
/// - deserialize: bytes → decrypt → JSON → Object
///
/// JSON 编解码与 JacksonRedisSerializer 一致,使用平台标准 ObjectMapper。
@Slf4j
public class EncryptingRedisSerializer implements RedisSerializer<Object> {
private final SecureAesGcmEncryptor encryptor;
public EncryptingRedisSerializer(SecureAesGcmEncryptor encryptor) {
if (encryptor == null) {
throw new IllegalArgumentException("SecureAesGcmEncryptor 不能为空");
}
this.encryptor = encryptor;
}
@Override
public byte[] serialize(Object value) throws SerializationException {
if (value == null) {
return new byte[0];
}
try {
ObjectMapper objectMapper = JacksonUtil.getObjectMapper();
String json = objectMapper.writeValueAsString(value);
String cipher = encryptor.encrypt(json);
return cipher.getBytes(StandardCharsets.UTF_8);
} catch (JacksonException e) {
log.error("敏感缓存 JSON 序列化失败: {}", e.getMessage(), e);
throw new SerializationException("Could not serialize to JSON for encrypt: " + e.getMessage(), e);
} catch (RuntimeException e) {
log.error("敏感缓存加密序列化失败: {}", e.getMessage(), e);
throw new SerializationException("Could not encrypt-serialize cache value: " + e.getMessage(), e);
}
}
@Override
public Object deserialize(byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length == 0) {
return null;
}
try {
String cipher = new String(bytes, StandardCharsets.UTF_8);
String json = encryptor.decrypt(cipher);
if (json == null) {
throw new SerializationException("敏感缓存解密失败,密文格式或密钥版本无效");
}
ObjectMapper objectMapper = JacksonUtil.getObjectMapper();
return objectMapper.readValue(json, Object.class);
} catch (SerializationException e) {
throw e;
} catch (JacksonException e) {
log.error("敏感缓存解密后 JSON 反序列化失败: {}", e.getMessage(), e);
throw new SerializationException("Could not deserialize decrypted cache value: " + e.getMessage(), e);
} catch (Exception e) {
log.error("敏感缓存解密反序列化失败: {}", e.getMessage(), e);
throw new SerializationException("Could not decrypt-deserialize cache value: " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,46 @@
package cn.daxpay.open.platform.capability.cache.secure;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/// # 敏感缓存名匹配器
///
/// 判断 cacheName 是否为敏感缓存S2
/// - 名称以 [securePrefix] 开头(默认 `secure:`
/// - 或落在 [secureNames] 精确列表中
///
/// 匹配的缓存 L2 value 使用整包 AES-GCM 加密;未启用数据加密时则仅 L1、不写 Redis。
public class SecureCacheNameMatcher {
private final String securePrefix;
private final Set<String> secureNames;
public SecureCacheNameMatcher(String securePrefix, List<String> secureNames) {
this.securePrefix = StrUtil.blankToDefault(securePrefix, "secure:");
if (CollUtil.isEmpty(secureNames)) {
this.secureNames = Collections.emptySet();
} else {
this.secureNames = new HashSet<>(secureNames);
}
}
/// 是否为敏感缓存名
public boolean matches(String cacheName) {
if (StrUtil.isBlank(cacheName)) {
return false;
}
if (cacheName.startsWith(this.securePrefix)) {
return true;
}
return this.secureNames.contains(cacheName);
}
public String getSecurePrefix() {
return this.securePrefix;
}
}

View File

@@ -0,0 +1,98 @@
package cn.daxpay.open.platform.capability.cache.secure;
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.serializer.SerializationException;
import tools.jackson.databind.json.JsonMapper;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/// # 敏感缓存整包加密序列化器测试
class EncryptingRedisSerializerTest {
private EncryptingRedisSerializer serializer;
@BeforeAll
static void initJackson() {
if (JacksonUtil.getObjectMapper() == null) {
JacksonUtil.setObjectMapper(JsonMapper.builder().build());
}
}
@BeforeEach
void setUp() {
EncryptKeyInfo keyInfo = new EncryptKeyInfo();
keyInfo.setVersion(1);
keyInfo.setKey(generateKey(32));
SecureAesGcmEncryptor encryptor = new SecureAesGcmEncryptor(List.of(keyInfo));
serializer = new EncryptingRedisSerializer(encryptor);
}
@Test
@DisplayName("整包加解密 round-trip 保留字段值")
void shouldRoundTrip() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("channelMchNo", "MCH001");
value.put("apiKey", "sk_live_secret_key_value");
value.put("privateKey", "-----BEGIN PRIVATE KEY-----\nABCxyz\n-----END PRIVATE KEY-----");
byte[] bytes = serializer.serialize(value);
Object restored = serializer.deserialize(bytes);
assertInstanceOf(Map.class, restored);
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) restored;
assertEquals("MCH001", map.get("channelMchNo"));
assertEquals("sk_live_secret_key_value", map.get("apiKey"));
assertTrue(String.valueOf(map.get("privateKey")).contains("BEGIN PRIVATE KEY"));
}
@Test
@DisplayName("序列化结果为 v1: 密文且不含明文密钥")
void shouldProduceCipherWithoutPlainSecrets() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("privateKey", "-----BEGIN PRIVATE KEY-----\nSUPER_SECRET\n-----END PRIVATE KEY-----");
value.put("apiKey", "sk_live_plain_should_not_appear");
byte[] bytes = serializer.serialize(value);
String stored = new String(bytes, StandardCharsets.UTF_8);
assertTrue(stored.startsWith("v1:"), "密文应带版本前缀 v1:");
assertFalse(stored.contains("BEGIN PRIVATE KEY"), "Redis 存储不应含 PEM 明文");
assertFalse(stored.contains("SUPER_SECRET"), "Redis 存储不应含私钥内容");
assertFalse(stored.contains("sk_live_plain_should_not_appear"), "Redis 存储不应含 apiKey 明文");
}
@Test
@DisplayName("null 序列化为空字节")
void shouldSerializeNullAsEmpty() {
assertEquals(0, serializer.serialize(null).length);
assertNull(serializer.deserialize(null));
assertNull(serializer.deserialize(new byte[0]));
}
@Test
@DisplayName("非法密文反序列化抛 SerializationException")
void shouldFailOnInvalidCipher() {
assertThrows(SerializationException.class,
() -> serializer.deserialize("not-a-cipher".getBytes(StandardCharsets.UTF_8)));
}
private static String generateKey(int length) {
byte[] keyBytes = new byte[length];
new SecureRandom().nextBytes(keyBytes);
return Base64.getEncoder().encodeToString(keyBytes).substring(0, length);
}
}

View File

@@ -0,0 +1,47 @@
package cn.daxpay.open.platform.capability.cache.secure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/// # 敏感缓存名匹配器测试
class SecureCacheNameMatcherTest {
@Test
@DisplayName("前缀匹配 secure:")
void shouldMatchSecurePrefix() {
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", List.of());
assertTrue(matcher.matches("secure:channel-key:wechat-isv"));
assertTrue(matcher.matches("secure:merchant-credential"));
assertFalse(matcher.matches("system:dict"));
assertFalse(matcher.matches("payment:channel-merchant"));
}
@Test
@DisplayName("精确列表命中")
void shouldMatchExactNames() {
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", List.of("legacy:secret-config"));
assertTrue(matcher.matches("legacy:secret-config"));
assertFalse(matcher.matches("legacy:other"));
}
@Test
@DisplayName("空名称不匹配")
void shouldNotMatchBlank() {
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", null);
assertFalse(matcher.matches(null));
assertFalse(matcher.matches(""));
assertFalse(matcher.matches(" "));
}
@Test
@DisplayName("默认前缀")
void shouldDefaultPrefixWhenBlank() {
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher(null, List.of());
assertEquals("secure:", matcher.getSecurePrefix());
assertTrue(matcher.matches("secure:demo"));
}
}

View File

@@ -22,6 +22,16 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- EncryptorConfiguration 条件装配 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,14 +1,17 @@
package cn.daxpay.open.platform.common.config;
import cn.daxpay.open.platform.common.config.encrypt.EncryptorConfiguration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Import;
/// # 配置自动配置
///
@Slf4j
@AutoConfiguration
@ConfigurationPropertiesScan
@Import(EncryptorConfiguration.class)
public class ConfigAutoConfiguration {
}

View File

@@ -0,0 +1,35 @@
package cn.daxpay.open.platform.common.config.encrypt;
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
import cn.hutool.core.collection.CollUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/// # 数据加密器 Spring 配置
///
/// 全进程唯一 [SecureAesGcmEncryptor] 实例,供 DB TypeHandler 与缓存 L2 加密序列化共用,
/// 避免两套密钥实例不一致。
///
/// 仅在 `daxpay.platform.config.encrypt.enable=true` 时注册 Bean。
@Slf4j
@Configuration
@EnableConfigurationProperties(PlatformConfigProperties.class)
public class EncryptorConfiguration {
/// 创建 AES-GCM 加密器(启用加密时必须配置 keys
@Bean
@ConditionalOnProperty(prefix = "daxpay.platform.config.encrypt", name = "enable", havingValue = "true")
public SecureAesGcmEncryptor secureAesGcmEncryptor(PlatformConfigProperties platformConfigProperties) {
var keys = platformConfigProperties.getEncrypt().getKeys();
if (CollUtil.isEmpty(keys)) {
throw new IllegalStateException(
"启用数据加密时必须配置至少一个密钥,请配置 daxpay.platform.config.encrypt.keys");
}
SecureAesGcmEncryptor encryptor = new SecureAesGcmEncryptor(keys);
log.info("已注册 SecureAesGcmEncryptor Bean当前密钥版本: v{}", encryptor.getCurrentVersion());
return encryptor;
}
}

View File

@@ -1,4 +1,4 @@
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
package cn.daxpay.open.platform.common.config.encrypt;
import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo;
import cn.hutool.core.collection.CollUtil;
@@ -16,7 +16,7 @@ import java.util.stream.Collectors;
/// # 安全的 AES-256-GCM 加密工具支持多密钥版本
///
/// 适用于支付系统中加密各类敏感数据使用
/// 适用于支付系统中加密各类敏感数据DB 字段 TypeHandler缓存 L2 整包 value
///
/// 密文格式v{version}:{base64(IV + AES-GCM-Encrypt(plaintext))}
/// 示例v2:7Kf8jD2mNpQrStUvWxYz...
@@ -47,32 +47,32 @@ public class SecureAesGcmEncryptor {
if (CollUtil.isEmpty(keys)) {
throw new IllegalArgumentException("密钥列表不能为空");
}
// 校验密钥配置
validateKeys(keys);
// 第一个密钥为当前密钥
this.currentKey = keys.getFirst();
// 构建密钥映射表
this.keyMap = keys.stream()
.collect(Collectors.toMap(EncryptKeyInfo::getVersion, k -> k, (k1, k2) -> {
throw new IllegalArgumentException("存在重复的密钥版本号: " + k1.getVersion());
}));
// 初始化密钥缓存
this.secretKeyCache = new HashMap<>();
for (EncryptKeyInfo keyInfo : keys) {
secretKeyCache.put(keyInfo.getVersion(), createSecretKey(keyInfo.getKey()));
}
// 记录日志
List<Integer> historyVersions = keys.stream()
.skip(1)
.map(EncryptKeyInfo::getVersion)
.toList();
log.info("加密器初始化成功,当前版本: v{},历史版本: {}",
currentKey.getVersion(),
log.info("加密器初始化成功,当前版本: v{},历史版本: {}",
currentKey.getVersion(),
historyVersions.isEmpty() ? "" : historyVersions.stream().map(v -> "v" + v).toList());
}
@@ -88,7 +88,7 @@ public class SecureAesGcmEncryptor {
throw new IllegalArgumentException("存在重复的密钥版本号: " + keyInfo.getVersion());
}
versions.add(keyInfo.getVersion());
// 校验密钥长度
if (keyInfo.getKey() == null || keyInfo.getKey().length() != KEY_LENGTH) {
throw new IllegalArgumentException("密钥版本 v" + keyInfo.getVersion() + " 的密钥长度需要为32位");
@@ -146,13 +146,13 @@ public class SecureAesGcmEncryptor {
log.warn("密文格式错误,缺少版本前缀");
return null;
}
int separatorIndex = ciphertext.indexOf(VERSION_SEPARATOR);
if (separatorIndex == -1) {
log.warn("密文格式错误,缺少版本分隔符");
return null;
}
int version;
try {
version = Integer.parseInt(ciphertext.substring(1, separatorIndex));
@@ -160,16 +160,16 @@ public class SecureAesGcmEncryptor {
log.warn("密文版本号格式错误");
return null;
}
String encryptedBase64 = ciphertext.substring(separatorIndex + 1);
// 获取对应版本的密钥
SecretKey secretKey = secretKeyCache.get(version);
if (secretKey == null) {
log.warn("找不到版本 v{} 对应的密钥", version);
return null;
}
// 解密
byte[] combined = Base64.getDecoder().decode(encryptedBase64);
byte[] iv = new byte[GCM_IV_LENGTH];
@@ -196,4 +196,3 @@ public class SecureAesGcmEncryptor {
}
}

View File

@@ -4,6 +4,9 @@ import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/// # 平台通用配置属性
///
/// 整合了缓存、异常处理、Spring 和 Swagger 配置
@@ -28,6 +31,10 @@ public class PlatformCommonProperties {
private L1 l1 = new L1();
/// L2 Redis 缓存配置
private L2 l2 = new L2();
/// 敏感缓存名前缀;匹配的 L2 value 整包 AES-GCM 加密(默认 secure:
private String securePrefix = "secure:";
/// 额外视为敏感的 cacheName 精确列表(可选)
private List<String> secureNames = new ArrayList<>();
/// # L1 本地缓存配置
///

View File

@@ -1,4 +1,4 @@
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
package cn.daxpay.open.platform.common.config.encrypt;
import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo;
import org.junit.jupiter.api.BeforeEach;

View File

@@ -1,12 +1,14 @@
package cn.daxpay.open.platform.common.mybatisplus;
import cn.daxpay.open.platform.common.config.ConfigAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.ComponentScan;
/// # mybatis自动配置
///
@AutoConfiguration
/// 依赖 [ConfigAutoConfiguration] 先注册 SecureAesGcmEncryptor供 DataEncryptTypeHandler 使用。
@AutoConfiguration(after = ConfigAutoConfiguration.class)
@ComponentScan
@ConfigurationPropertiesScan
public class MybatisPlusCommonAutoConfiguration {

View File

@@ -1,5 +1,6 @@
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.type.BaseTypeHandler;
@@ -13,6 +14,7 @@ import java.sql.SQLException;
/// # 数据加密类型处理器, 使用 AES-256-GCM 加密
///
/// 支持多密钥版本密文格式v{version}:{encrypted}
/// 加密器由 [DataEncryptTypeHandlerConfiguration] 注入全进程唯一的 [SecureAesGcmEncryptor] Bean。
@Slf4j
public class DataEncryptTypeHandler extends BaseTypeHandler<String> {

View File

@@ -1,15 +1,18 @@
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
import cn.hutool.core.collection.CollUtil;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/// # 数据加密类型处理器初始化配置
///
/// 复用 [SecureAesGcmEncryptor] Spring Bean由 [EncryptorConfiguration] 创建),
/// 保证与缓存 L2 加密使用同一加密器实例。
@Slf4j
@Configuration
@RequiredArgsConstructor
@@ -17,6 +20,7 @@ import org.springframework.context.annotation.Configuration;
public class DataEncryptTypeHandlerConfiguration {
private final PlatformConfigProperties platformConfigProperties;
private final ObjectProvider<SecureAesGcmEncryptor> encryptorProvider;
@PostConstruct
public void initEncryptTypeHandler() {
@@ -27,12 +31,11 @@ public class DataEncryptTypeHandlerConfiguration {
return;
}
var keys = encrypt.getKeys();
if (CollUtil.isEmpty(keys)) {
throw new IllegalStateException("启用数据加密时必须配置至少一个密钥,请配置 daxpay.platform.config.encrypt.keys");
SecureAesGcmEncryptor encryptor = encryptorProvider.getIfAvailable();
if (encryptor == null) {
throw new IllegalStateException(
"已启用数据加密但未找到 SecureAesGcmEncryptor Bean请检查 daxpay.platform.config.encrypt 配置");
}
var encryptor = new SecureAesGcmEncryptor(keys);
DataEncryptTypeHandler.initialize(encryptor, true);
}
}