feat:配置加密机制

This commit is contained in:
waner 2026-05-14 11:10:31 +08:00
parent 3040ed443b
commit 3a9a76c100
12 changed files with 383 additions and 6 deletions

View File

@ -62,6 +62,30 @@ chmod +x /home/tms/scripts/tms.sh
- `apply_standard_db.sh` 依赖预置环境变量,例如 `DB_USER`、`DB_PASSWORD`,不要直接写入 `application.yml`
- 运行目录结构、配置项说明、文件上传 `fileId` 流程和故障排查,请查看上面的完整部署手册。
## 配置敏感信息加密
配置文件中的敏感值可以写成 `ENC(...)`,应用启动早期会自动解密后再交给 Spring 绑定:
```yaml
spring:
datasource:
password: ENC(v1:<iv>:<ciphertext>)
```
当前实现使用 `AES-256-GCM`,主密钥按当前交付要求临时硬编码在代码中。生成密文:
```bash
java -jar tms-framework.jar --tms.crypto.encrypt
```
命令会从标准输入读取一行明文并输出 `ENC(...)`。解密校验:
```bash
java -jar tms-framework.jar --tms.crypto.decrypt
```
生产加固时应把硬编码密钥替换为环境变量、独立密钥文件或密码机/KMS 托管密钥。
运行时建议:
- 本地构建和测试统一使用 JDK 17与项目和 CI 运行时保持一致。

View File

@ -37,6 +37,8 @@ tms:
cpu-model-default: ""
memory-total-default: 16GB
disk-total-default: 256G
runtime-status:
disk-usage-path: /home/tms
file:
storage:
upload-base-dir: /home/tms/uploads
@ -115,7 +117,7 @@ tms:
product-types:
- ENTERPRISE
- INDIRECT
# TMS 库备份排除表。默认跳过角色、授权、会话、审计和资源任务表,避免恢复时覆盖新机器安全状态或带回旧 RUNNING 任务。
# TMS 库备份排除表。默认跳过角色、授权、会话和资源任务表,避免恢复时覆盖新机器安全登录状态或带回旧 RUNNING 任务。
mysqldump-timeout-seconds: 300
restore-db-timeout-seconds: 300
tms-db-excluded-tables:
@ -125,7 +127,6 @@ tms:
- tms_auth_session
- tms_auth_challenge
- tms_auth_audit_log
- tms_operation_audit_log
- tms_resource_backup_task
- tms_resource_restore_task
allowed-restore-roots:

View File

@ -1,5 +1,6 @@
package com.cisd.tms;
import com.cisd.tms.common.crypto.config.ConfigCryptoCli;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@ -11,6 +12,13 @@ import org.springframework.scheduling.annotation.EnableScheduling;
public class TmsApplication {
public static void main(String[] args) {
if (ConfigCryptoCli.shouldHandle(args)) {
int exitCode = ConfigCryptoCli.run(args, System.in, System.out, System.err);
if (exitCode != 0) {
System.exit(exitCode);
}
return;
}
SpringApplication.run(TmsApplication.class, args);
}
}

View File

@ -0,0 +1,69 @@
package com.cisd.tms.common.crypto.config;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public final class ConfigCryptoCli {
private static final String ENCRYPT_ARG = "--tms.crypto.encrypt";
private static final String DECRYPT_ARG = "--tms.crypto.decrypt";
private static final String VALUE_ARG_PREFIX = "--tms.crypto.value=";
private ConfigCryptoCli() {
}
public static boolean shouldHandle(String[] args) {
return contains(args, ENCRYPT_ARG) || contains(args, DECRYPT_ARG);
}
public static int run(String[] args, InputStream in, PrintStream out, PrintStream err) {
boolean encrypt = contains(args, ENCRYPT_ARG);
boolean decrypt = contains(args, DECRYPT_ARG);
if (encrypt == decrypt) {
err.println("Usage: --tms.crypto.encrypt|--tms.crypto.decrypt [--tms.crypto.value=<value>]");
return 2;
}
try {
String input = valueFromArgs(args);
if (input == null) {
input = readLine(in);
}
ConfigTextEncryptor encryptor = ConfigTextEncryptor.withDefaultHardcodedKey();
out.println(encrypt ? encryptor.encrypt(input) : encryptor.decryptIfNecessary(input));
return 0;
} catch (Exception ex) {
err.println(ex.getMessage());
return 1;
}
}
private static boolean contains(String[] args, String expected) {
return Arrays.stream(args == null ? new String[0] : args).anyMatch(expected::equals);
}
private static String valueFromArgs(String[] args) {
if (args == null) {
return null;
}
for (String arg : args) {
if (arg != null && arg.startsWith(VALUE_ARG_PREFIX)) {
return arg.substring(VALUE_ARG_PREFIX.length());
}
}
return null;
}
private static String readLine(InputStream in) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
String line = reader.readLine();
if (line == null) {
throw new IllegalArgumentException("请输入待处理文本");
}
return line;
}
}

View File

@ -0,0 +1,50 @@
package com.cisd.tms.common.crypto.config;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.PropertySource;
public class ConfigDecryptingEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
private static final String DECRYPTED_PROPERTY_SOURCE_NAME = "tmsConfigDecryptedProperties";
private final ConfigTextEncryptor encryptor;
public ConfigDecryptingEnvironmentPostProcessor() {
this(ConfigTextEncryptor.withDefaultHardcodedKey());
}
ConfigDecryptingEnvironmentPostProcessor(ConfigTextEncryptor encryptor) {
this.encryptor = encryptor;
}
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
Map<String, Object> decryptedValues = new LinkedHashMap<>();
for (PropertySource<?> propertySource : environment.getPropertySources()) {
if (!(propertySource instanceof EnumerablePropertySource<?> enumerablePropertySource)) {
continue;
}
for (String propertyName : enumerablePropertySource.getPropertyNames()) {
Object rawValue = enumerablePropertySource.getProperty(propertyName);
if (rawValue instanceof String stringValue && encryptor.isEncrypted(stringValue)) {
decryptedValues.putIfAbsent(propertyName, encryptor.decryptIfNecessary(stringValue));
}
}
}
if (!decryptedValues.isEmpty()) {
environment.getPropertySources().addFirst(new MapPropertySource(DECRYPTED_PROPERTY_SOURCE_NAME, decryptedValues));
}
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}

View File

@ -0,0 +1,90 @@
package com.cisd.tms.common.crypto.config;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class ConfigTextEncryptor {
private static final String ENCRYPTED_PREFIX = "ENC(";
private static final String ENCRYPTED_SUFFIX = ")";
private static final String FORMAT_VERSION = "v1";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final String KEY_ALGORITHM = "AES";
private static final int IV_LENGTH_BYTES = 12;
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
// Temporary fixed key required by the current delivery request. Replace with an external key source later.
private static final String HARDCODED_KEY_BASE64 = "LMJI3dMjle5U9sMH/tlX1/1mOuVAYsg+cPsxGppFua0=";
private final SecretKeySpec keySpec;
private final SecureRandom secureRandom;
public static ConfigTextEncryptor withDefaultHardcodedKey() {
return new ConfigTextEncryptor(Base64.getDecoder().decode(HARDCODED_KEY_BASE64), new SecureRandom());
}
ConfigTextEncryptor(byte[] keyBytes, SecureRandom secureRandom) {
if (keyBytes == null || keyBytes.length != 32) {
throw new IllegalArgumentException("配置加密密钥必须是32字节AES-256密钥");
}
this.keySpec = new SecretKeySpec(keyBytes, KEY_ALGORITHM);
this.secureRandom = secureRandom;
}
public String encrypt(String plainText) {
if (plainText == null) {
throw new IllegalArgumentException("待加密明文不能为空");
}
byte[] iv = new byte[IV_LENGTH_BYTES];
secureRandom.nextBytes(iv);
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] ciphertext = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return ENCRYPTED_PREFIX
+ FORMAT_VERSION
+ ":"
+ ENCODER.encodeToString(iv)
+ ":"
+ ENCODER.encodeToString(ciphertext)
+ ENCRYPTED_SUFFIX;
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("配置加密失败", ex);
}
}
public String decryptIfNecessary(String value) {
if (value == null || !isEncrypted(value)) {
return value;
}
return decrypt(value);
}
public boolean isEncrypted(String value) {
return value != null && value.startsWith(ENCRYPTED_PREFIX) && value.endsWith(ENCRYPTED_SUFFIX);
}
private String decrypt(String encryptedValue) {
String payload = encryptedValue.substring(ENCRYPTED_PREFIX.length(), encryptedValue.length() - ENCRYPTED_SUFFIX.length());
String[] parts = payload.split(":", -1);
if (parts.length != 3 || !FORMAT_VERSION.equals(parts[0])) {
throw new IllegalArgumentException("配置密文格式无效");
}
try {
byte[] iv = DECODER.decode(parts[1]);
byte[] ciphertext = DECODER.decode(parts[2]);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (IllegalArgumentException | GeneralSecurityException ex) {
throw new IllegalArgumentException("配置密文解密失败", ex);
}
}
}

View File

@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
com.cisd.tms.common.crypto.config.ConfigDecryptingEnvironmentPostProcessor

View File

@ -1,8 +1,8 @@
spring:
datasource:
url: jdbc:mysql://127.0.0.1:4000/tms?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: Sunyard123
username: ENC(v1:XBsGZZ5ugvSBy0HZ:nvihsBsr4b9HhHYGTDbVlqy7kT0)
password: ENC(v1:K_VO3NW0dq59UjJh:KcngqSlmeiCP0VXGwkB031xOMjE4ktoz6vo)
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:

View File

@ -182,6 +182,9 @@ tms:
memory-total-default: ${TMS_DEVICE_PROFILE_MEMORY_TOTAL_DEFAULT:16GB}
# 磁盘总量默认展示值;命令读取失败或为空时回退。
disk-total-default: ${TMS_DEVICE_PROFILE_DISK_TOTAL_DEFAULT:256G}
runtime-status:
# 运行状态磁盘已用量探测路径;总量使用 lsblk 统计 OS 可见物理磁盘容量。
disk-usage-path: ${TMS_DEVICE_RUNTIME_STATUS_DISK_USAGE_PATH:/home/tms}
upgrade:
# 升级包解压和脚本执行暂存目录。
staging-root-dir: ${TMS_UPGRADE_STAGING_ROOT_DIR:/home/tmp/tms-upgrade-staging}
@ -300,17 +303,22 @@ tms:
mysql-path: ${TMS_BACKUP_MYSQL_PATH:mysql}
# 数据库恢复脚本路径apply 脚本在 RESTORE_DATABASES 阶段调用。
restore-db-script-path: ${TMS_BACKUP_RESTORE_DB_SCRIPT_PATH:/home/tms/bin/resource-restore/restore-db.sh}
# 单个数据库恢复最大等待时间(秒);超时会标记恢复失败并尝试重新启动服务。
restore-db-timeout-seconds: ${TMS_BACKUP_RESTORE_DB_TIMEOUT_SECONDS:300}
# MQ replay 脚本路径apply 脚本在 MQ_REPLAY 阶段调用。
mq-replay-script-path: ${TMS_BACKUP_MQ_REPLAY_SCRIPT_PATH:/home/tms/bin/resource-restore/replay-mq.sh}
# 备份 TMS 库名;为空时默认从 spring.datasource.url 中解析。
tms-database-name: ${TMS_BACKUP_TMS_DATABASE_NAME:}
# TMS 库备份排除表。默认不备份角色、授权、会话和审计表,避免恢复时覆盖新机器安全状态
# TMS 库备份排除表。默认不备份角色、授权、会话和资源备份/恢复任务表,避免恢复时覆盖新机器安全登录状态或带回旧 RUNNING 任务
tms-db-excluded-tables:
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_ROLE_ACCOUNT:tms_role_account}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_ROLE_UKEY:tms_role_ukey_binding}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_FULL:tms_auth_full_account}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_SESSION:tms_auth_session}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_CHALLENGE:tms_auth_challenge}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_AUTH_AUDIT:tms_auth_audit_log}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_RESOURCE_BACKUP_TASK:tms_resource_backup_task}
- ${TMS_BACKUP_TMS_DB_EXCLUDED_TABLE_RESOURCE_RESTORE_TASK:tms_resource_restore_task}
# 备份/恢复标准收发器业务库名;第一版默认使用 CMEP。
cmep-database-name: ${TMS_BACKUP_CMEP_DATABASE_NAME:CMEP}
# 恢复完成后的最小健康检查地址。
@ -341,7 +349,7 @@ tms:
debug-session-token: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_SESSION_TOKEN:DEBUG-BYPASS}
replay:
# 是否启用防重放校验;本地 Postman/联调可临时设为 false生产环境应保持 true。
enabled: ${TMS_SECURITY_REPLAY_ENABLED:false}
enabled: ${TMS_SECURITY_REPLAY_ENABLED:true}
openapi:
# 外部签名服务接口允许的时间戳偏差(秒),防重放。
timestamp-skew-seconds: 300

View File

@ -0,0 +1,47 @@
package com.cisd.tms.common.crypto.config;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class ConfigCryptoCliTest {
@Test
void encryptCommandReadsPlainTextFromStdinAndPrintsEncValue() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
int exitCode = ConfigCryptoCli.run(
new String[] {"--tms.crypto.encrypt"},
new ByteArrayInputStream("cli-secret\n".getBytes(StandardCharsets.UTF_8)),
new PrintStream(out, true, StandardCharsets.UTF_8),
new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)
);
String encrypted = out.toString(StandardCharsets.UTF_8).trim();
Assertions.assertEquals(0, exitCode);
Assertions.assertTrue(encrypted.startsWith("ENC(v1:"));
Assertions.assertEquals(
"cli-secret",
ConfigTextEncryptor.withDefaultHardcodedKey().decryptIfNecessary(encrypted)
);
}
@Test
void decryptCommandReadsEncryptedTextFromStdinAndPrintsPlainText() {
String encrypted = ConfigTextEncryptor.withDefaultHardcodedKey().encrypt("cli-secret");
ByteArrayOutputStream out = new ByteArrayOutputStream();
int exitCode = ConfigCryptoCli.run(
new String[] {"--tms.crypto.decrypt"},
new ByteArrayInputStream((encrypted + "\n").getBytes(StandardCharsets.UTF_8)),
new PrintStream(out, true, StandardCharsets.UTF_8),
new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8)
);
Assertions.assertEquals(0, exitCode);
Assertions.assertEquals("cli-secret", out.toString(StandardCharsets.UTF_8).trim());
}
}

View File

@ -0,0 +1,28 @@
package com.cisd.tms.common.crypto.config;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
class ConfigDecryptingEnvironmentPostProcessorTest {
@Test
void decryptsEncryptedEnumerablePropertiesBeforeBinding() {
ConfigTextEncryptor encryptor = ConfigTextEncryptor.withDefaultHardcodedKey();
String encryptedPassword = encryptor.encrypt("db-password");
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(new MapPropertySource("test", Map.of(
"spring.datasource.password", encryptedPassword,
"server.port", "8080"
)));
new ConfigDecryptingEnvironmentPostProcessor()
.postProcessEnvironment(environment, new SpringApplication(Object.class));
Assertions.assertEquals("db-password", environment.getProperty("spring.datasource.password"));
Assertions.assertEquals("8080", environment.getProperty("server.port"));
}
}

View File

@ -0,0 +1,50 @@
package com.cisd.tms.common.crypto.config;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class ConfigTextEncryptorTest {
@Test
void encryptWrapsValueAsEncAndDecryptRestoresPlainText() {
ConfigTextEncryptor encryptor = ConfigTextEncryptor.withDefaultHardcodedKey();
String encrypted = encryptor.encrypt("Sunyard123");
Assertions.assertTrue(encrypted.startsWith("ENC(v1:"));
Assertions.assertEquals("Sunyard123", encryptor.decryptIfNecessary(encrypted));
}
@Test
void encryptUsesRandomIvForEachValue() {
ConfigTextEncryptor encryptor = ConfigTextEncryptor.withDefaultHardcodedKey();
String first = encryptor.encrypt("same-secret");
String second = encryptor.encrypt("same-secret");
Assertions.assertNotEquals(first, second);
Assertions.assertEquals("same-secret", encryptor.decryptIfNecessary(first));
Assertions.assertEquals("same-secret", encryptor.decryptIfNecessary(second));
}
@Test
void decryptLeavesPlainValuesUnchanged() {
ConfigTextEncryptor encryptor = ConfigTextEncryptor.withDefaultHardcodedKey();
Assertions.assertEquals("plain", encryptor.decryptIfNecessary("plain"));
Assertions.assertNull(encryptor.decryptIfNecessary(null));
}
@Test
void decryptRejectsTamperedCiphertext() {
ConfigTextEncryptor encryptor = ConfigTextEncryptor.withDefaultHardcodedKey();
String encrypted = encryptor.encrypt("secret");
String tampered = encrypted.substring(0, encrypted.length() - 2) + "AA)";
IllegalArgumentException exception = Assertions.assertThrows(
IllegalArgumentException.class,
() -> encryptor.decryptIfNecessary(tampered)
);
Assertions.assertTrue(exception.getMessage().contains("解密失败"));
}
}