diff --git a/README.md b/README.md index fbc931b..a659527 100644 --- a/README.md +++ b/README.md @@ -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::) +``` + +当前实现使用 `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 运行时保持一致。 diff --git a/config/application.yml.example b/config/application.yml.example index e1c6e79..aa68ddc 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -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: diff --git a/src/main/java/com/cisd/tms/TmsApplication.java b/src/main/java/com/cisd/tms/TmsApplication.java index 9a5a26f..fb73702 100644 --- a/src/main/java/com/cisd/tms/TmsApplication.java +++ b/src/main/java/com/cisd/tms/TmsApplication.java @@ -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); } } diff --git a/src/main/java/com/cisd/tms/common/crypto/config/ConfigCryptoCli.java b/src/main/java/com/cisd/tms/common/crypto/config/ConfigCryptoCli.java new file mode 100644 index 0000000..201cc8c --- /dev/null +++ b/src/main/java/com/cisd/tms/common/crypto/config/ConfigCryptoCli.java @@ -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=]"); + 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; + } +} diff --git a/src/main/java/com/cisd/tms/common/crypto/config/ConfigDecryptingEnvironmentPostProcessor.java b/src/main/java/com/cisd/tms/common/crypto/config/ConfigDecryptingEnvironmentPostProcessor.java new file mode 100644 index 0000000..2c22eff --- /dev/null +++ b/src/main/java/com/cisd/tms/common/crypto/config/ConfigDecryptingEnvironmentPostProcessor.java @@ -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 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; + } +} diff --git a/src/main/java/com/cisd/tms/common/crypto/config/ConfigTextEncryptor.java b/src/main/java/com/cisd/tms/common/crypto/config/ConfigTextEncryptor.java new file mode 100644 index 0000000..0052a7b --- /dev/null +++ b/src/main/java/com/cisd/tms/common/crypto/config/ConfigTextEncryptor.java @@ -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); + } + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..be96e80 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +com.cisd.tms.common.crypto.config.ConfigDecryptingEnvironmentPostProcessor diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index ee6d5a2..b4d6561 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -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: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7dff4d2..11fa96f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/test/java/com/cisd/tms/common/crypto/config/ConfigCryptoCliTest.java b/src/test/java/com/cisd/tms/common/crypto/config/ConfigCryptoCliTest.java new file mode 100644 index 0000000..559794e --- /dev/null +++ b/src/test/java/com/cisd/tms/common/crypto/config/ConfigCryptoCliTest.java @@ -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()); + } +} diff --git a/src/test/java/com/cisd/tms/common/crypto/config/ConfigDecryptingEnvironmentPostProcessorTest.java b/src/test/java/com/cisd/tms/common/crypto/config/ConfigDecryptingEnvironmentPostProcessorTest.java new file mode 100644 index 0000000..9e63a2a --- /dev/null +++ b/src/test/java/com/cisd/tms/common/crypto/config/ConfigDecryptingEnvironmentPostProcessorTest.java @@ -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")); + } +} diff --git a/src/test/java/com/cisd/tms/common/crypto/config/ConfigTextEncryptorTest.java b/src/test/java/com/cisd/tms/common/crypto/config/ConfigTextEncryptorTest.java new file mode 100644 index 0000000..acf211d --- /dev/null +++ b/src/test/java/com/cisd/tms/common/crypto/config/ConfigTextEncryptorTest.java @@ -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("解密失败")); + } +}