feat:备份恢复完善用户密钥的流程

This commit is contained in:
waner
2026-05-07 10:46:25 +08:00
parent 11e6049ae2
commit 03d8ccce78
18 changed files with 599 additions and 15 deletions
@@ -46,7 +46,7 @@ public class ResourceBackupController {
@PostMapping
@ReplayProtected
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.BACKUP, summary = "创建资源备份任务")
@Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。")
public ApiResponse<CreateResourceBackupResponse> createBackupTask(@Valid @RequestBody CreateResourceBackupRequest request) {
@@ -54,7 +54,7 @@ public class ResourceBackupController {
}
@GetMapping("/{taskId}")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源备份任务详情", description = "返回资源备份任务详情。")
public ApiResponse<ResourceBackupTaskDetailResponse> taskDetail(
@Parameter(description = "备份任务号")
@@ -64,7 +64,7 @@ public class ResourceBackupController {
}
@GetMapping("/{taskId}/download")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.EXPORT, summary = "下载资源备份包")
@Operation(summary = "下载资源备份包", description = "以浏览器附件方式下载资源备份包。")
public ResponseEntity<Resource> downloadPackage(
@@ -81,7 +81,7 @@ public class ResourceBackupController {
}
@GetMapping("/{taskId}/steps")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源备份任务步骤", description = "返回资源备份任务步骤列表。")
public ApiResponse<List<ResourceTaskStepItemResponse>> taskSteps(
@Parameter(description = "备份任务号")
@@ -91,7 +91,7 @@ public class ResourceBackupController {
}
@GetMapping("/{taskId}/steps/{stepNo}/log")
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源备份步骤日志", description = "返回资源备份任务步骤日志。")
public ApiResponse<ResourceTaskStepLogResponse> taskStepLog(
@Parameter(description = "备份任务号")
@@ -43,7 +43,7 @@ public class ResourceRestoreController {
private final ResourceTaskQueryService resourceTaskQueryService;
@PostMapping("/precheck")
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.ACCESS, summary = "预检资源恢复包")
@Operation(summary = "预检资源恢复请求", description = "根据 fileId 校验备份包头、签名和恢复范围摘要。")
public ApiResponse<ResourceRestorePrecheckResponse> precheck(@Valid @RequestBody ResourceRestorePrecheckRequest request) {
@@ -52,7 +52,7 @@ public class ResourceRestoreController {
@PostMapping
@ReplayProtected
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.RECOVER, summary = "创建资源恢复任务")
@Operation(summary = "创建资源恢复任务", description = "根据预检号创建资源恢复任务。")
public ApiResponse<CreateResourceRestoreResponse> createRestoreTask(@Valid @RequestBody CreateResourceRestoreRequest request) {
@@ -60,7 +60,7 @@ public class ResourceRestoreController {
}
@GetMapping("/{taskId}")
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复任务详情", description = "返回资源恢复任务详情和当前控制阶段。")
public ApiResponse<ResourceRestoreTaskDetailResponse> taskDetail(
@Parameter(description = "恢复任务号")
@@ -70,7 +70,7 @@ public class ResourceRestoreController {
}
@GetMapping("/{taskId}/steps")
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复任务步骤", description = "返回资源恢复任务步骤列表。")
public ApiResponse<List<ResourceTaskStepItemResponse>> taskSteps(
@Parameter(description = "恢复任务号")
@@ -80,7 +80,7 @@ public class ResourceRestoreController {
}
@GetMapping("/{taskId}/steps/{stepNo}/log")
// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL)
@Operation(summary = "查询资源恢复步骤日志", description = "返回资源恢复步骤日志。")
public ApiResponse<ResourceTaskStepLogResponse> taskStepLog(
@Parameter(description = "恢复任务号")
@@ -27,15 +27,23 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
throw new IllegalArgumentException("productType和mqType不能为空");
}
// 资源发现只决定“应该备份什么”,不在这里触碰文件系统、数据库或密码卡。
// 真实采集放在 ResourcePackageServiceImpl 中完成,便于统一处理必选资源缺失和包内 manifest。
List<DiscoveredResourceItem> items = new ArrayList<>();
// TMS 自身配置目录是恢复后服务能否按原部署参数启动的基础资源,按目录递归打包。
items.add(file("TMS_CONFIG", initExecutorProperties.getTmsConfigDir(), initExecutorProperties.getTmsConfigDir(), "DIR", true));
// 收发器配置和 license 按产品形态切换路径:标准版走 cmep4i,直连版走 direct 目标路径。
items.add(file("CP_CONFIG", resolveCpConfigPath(productType), resolveCpConfigPath(productType), "FILE", true));
items.add(file("RECEIVER_LICENSE", resolveReceiverLicensePath(productType), resolveReceiverLicensePath(productType), "FILE", true));
// 数据库资源在包内固定成 db/*.sql,恢复脚本通过 restore-databases.tsv 再决定导入目标库名。
items.add(database("TMS_DB", "TMS", "db/TMS.sql", true));
if (!"DIRECT".equals(productType)) {
// 第一版标准版迁移需要把收发器业务库一起带走,DIRECT 暂时不要求这份库。
items.add(database("CMEP_DB", "CMEP", "db/CMEP.sql", true));
}
// 实体证书表记录的 keyIdx 对应密码卡内用户签名密钥;备份时会按这些索引逐个调用 backupUserKey。
items.add(userKey(true));
// MQ 不直接备份运行时队列数据,第一版只生成可 replay 的上下文,交给现场脚本恢复配置。
items.add(mqReplay(true));
return items;
}
@@ -85,6 +93,14 @@ public class ResourceDiscoveryServiceImpl implements ResourceDiscoveryService {
return item;
}
private static DiscoveredResourceItem userKey(boolean required) {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("USER_KEY");
item.setResourceCode("USER_KEY");
item.setRequired(required);
return item;
}
private static String upper(String value) {
return value == null ? "" : value.trim().toUpperCase();
}
@@ -38,6 +38,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
private final ResourceBackupProperties resourceBackupProperties;
private final ResourceEnvelopeCryptoService resourceEnvelopeCryptoService;
private final DatabaseBackupCollector databaseBackupCollector;
private final ResourceUserKeyBackupCollector resourceUserKeyBackupCollector;
private final MqReplayContextBuilder mqReplayContextBuilder;
private final ObjectMapper objectMapper;
@@ -46,9 +47,33 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
ResourceBackupProperties resourceBackupProperties,
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
ResourceUserKeyBackupCollector resourceUserKeyBackupCollector,
ObjectMapper objectMapper
) {
this(resourceBackupProperties, resourceEnvelopeCryptoService, databaseBackupCollector, new MqReplayContextBuilder(objectMapper), objectMapper);
this(
resourceBackupProperties,
resourceEnvelopeCryptoService,
databaseBackupCollector,
resourceUserKeyBackupCollector,
new MqReplayContextBuilder(objectMapper),
objectMapper
);
}
public ResourcePackageServiceImpl(
ResourceBackupProperties resourceBackupProperties,
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
ObjectMapper objectMapper
) {
this(
resourceBackupProperties,
resourceEnvelopeCryptoService,
databaseBackupCollector,
ResourceUserKeyBackupCollector.empty(objectMapper),
new MqReplayContextBuilder(objectMapper),
objectMapper
);
}
public ResourcePackageServiceImpl(
@@ -57,10 +82,29 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
DatabaseBackupCollector databaseBackupCollector,
MqReplayContextBuilder mqReplayContextBuilder,
ObjectMapper objectMapper
) {
this(
resourceBackupProperties,
resourceEnvelopeCryptoService,
databaseBackupCollector,
ResourceUserKeyBackupCollector.empty(objectMapper),
mqReplayContextBuilder,
objectMapper
);
}
public ResourcePackageServiceImpl(
ResourceBackupProperties resourceBackupProperties,
ResourceEnvelopeCryptoService resourceEnvelopeCryptoService,
DatabaseBackupCollector databaseBackupCollector,
ResourceUserKeyBackupCollector resourceUserKeyBackupCollector,
MqReplayContextBuilder mqReplayContextBuilder,
ObjectMapper objectMapper
) {
this.resourceBackupProperties = resourceBackupProperties;
this.resourceEnvelopeCryptoService = resourceEnvelopeCryptoService;
this.databaseBackupCollector = databaseBackupCollector;
this.resourceUserKeyBackupCollector = resourceUserKeyBackupCollector;
this.mqReplayContextBuilder = mqReplayContextBuilder;
this.objectMapper = objectMapper;
}
@@ -87,8 +131,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
Files.createDirectories(taskDir);
// 资源恢复真正消费的是“受保护的内部归档”,而不是一段摘要 JSON。
// 这里先把可采集的文件资源打成 payload.zip,再统一交给数字信封加密,
// 后续即使补数据库导出/MQ definitions,也仍然沿着这个边界扩展。
// 这里先把文件、数据库 dump、用户密钥备份和 MQ 上下文打成 payload.zip,
// 再统一交给数字信封加密。外层 .tmsbak 只暴露包头、信封、密文和签名。
PayloadArchiveBuildResult payloadArchive = buildPayloadArchive(
taskId,
backupId,
@@ -96,6 +140,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
discoveredItems,
request
);
// seal 会生成随机 SM4 会话密钥加密 payload.zip,并用配置的 PCIe 用户加密密钥封装该会话密钥。
// 之后恢复端必须先验签,再解 envelope 拿回会话密钥,最后才能得到 payload 明文。
ResourceSealedPayload sealedPayload = resourceEnvelopeCryptoService.seal(payloadArchive.archiveBytes());
// manifest 只保留恢复前需要快速识别的元数据,不直接展开敏感资源内容。
@@ -233,6 +279,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {
for (DiscoveredResourceItem item : discoveredItems) {
// 每个资源都在 payload-manifest.json 中留一条资源级记录。
// 恢复端不会重新推断资源范围,而是严格按这个 manifest 生成恢复计划。
Map<String, Object> resourceEntry = new LinkedHashMap<>();
resourceEntry.put("resourceType", item.getResourceType());
resourceEntry.put("resourceCode", item.getResourceCode());
@@ -246,6 +294,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
resourceEntries.add(resourceEntry);
}
payloadManifest.put("resources", resourceEntries);
// payload-manifest.json 是恢复端的唯一内部目录索引:
// 文件、数据库、MQ、用户密钥都通过这里的 entryPath 找到 payload 中的真实条目。
addZipEntry(
zipOutputStream,
"payload-manifest.json",
@@ -261,12 +311,17 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
// 资源类型分派只认 V1 已实现的四类:文件、数据库、用户密钥和 MQ replay。
// 必选资源一旦采集失败会直接中断打包,避免生成看似成功但无法完整恢复的包。
if ("DATABASE".equalsIgnoreCase(trim(item.getResourceType())) || "DB".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectDatabaseItem(zipOutputStream, item, resourceEntry);
}
if ("MQ_REPLAY".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectMqReplayItem(zipOutputStream, initTask, item, resourceEntry);
}
if ("USER_KEY".equalsIgnoreCase(trim(item.getResourceType()))) {
return collectUserKeyItem(zipOutputStream, item, resourceEntry);
}
if (!"FILE".equalsIgnoreCase(trim(item.getResourceType()))) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
resourceEntry.put("collected", Boolean.FALSE);
@@ -277,6 +332,7 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
resourceEntry.put("mode", trim(item.getMode()).isEmpty() ? "FILE" : trim(item.getMode()));
resourceEntry.put("sourcePath", item.getResolvedPath());
resourceEntry.put("restorePath", trim(item.getRestorePath()).isEmpty() ? item.getResolvedPath() : item.getRestorePath());
// 文件类资源在备份端只允许采集真实存在的文件/目录;恢复端还会再次校验 restorePath 白名单。
Path resolvedPath = toResolvedPath(item.getResolvedPath());
if (resolvedPath == null || !Files.exists(resolvedPath)) {
assertOptionalResource(item, "PATH_NOT_FOUND");
@@ -325,6 +381,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
// 数据库资源在 manifest 中表现为 DATABASE,但真实内容是 payload 内的一份 SQL dump 文件。
// shell 恢复阶段会读取 restore-databases.tsv,再调用 restore-db.sh 导入到指定库。
DatabaseBackupCollector.CollectedDatabaseDump dump = resolveDatabaseDump(item);
if (dump == null) {
assertOptionalResource(item, "RESOURCE_TYPE_NOT_SUPPORTED_YET");
@@ -371,6 +429,8 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
// MQ 第一版只生成恢复上下文,不直接操作 RabbitMQ/TLQ/CFMQ。
// 这样 Java 包结构稳定,具体 replay 命令可由部署环境提供脚本适配。
byte[] contextBytes = mqReplayContextBuilder.build(initTask);
String entryPath = trim(item.getEntryPath()).isEmpty() ? "mq/mq-restore-context.json" : trim(item.getEntryPath());
addZipEntry(zipOutputStream, entryPath, contextBytes);
@@ -381,6 +441,23 @@ public class ResourcePackageServiceImpl implements ResourcePackageService {
return 1;
}
private int collectUserKeyItem(
ZipOutputStream zipOutputStream,
DiscoveredResourceItem item,
Map<String, Object> resourceEntry
) throws IOException {
try {
// 用户密钥备份必须在 payload 加密之前完成;备份 blob 本身仍会随 payload 一起被 SM4 加密保护。
return resourceUserKeyBackupCollector.collect(zipOutputStream, resourceEntry);
} catch (RuntimeException ex) {
assertOptionalResource(item, "USER_KEY_BACKUP_FAILED");
resourceEntry.put("kind", "USER_KEY");
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "USER_KEY_BACKUP_FAILED");
return 0;
}
}
private DatabaseBackupCollector.CollectedDatabaseDump resolveDatabaseDump(DiscoveredResourceItem item) {
String resourceCode = trim(item.getResourceCode());
if ("TMS_DB".equalsIgnoreCase(resourceCode) || "TMS_DB_DUMP".equalsIgnoreCase(resourceCode)) {
@@ -38,9 +38,12 @@ public class ResourcePayloadExtractor {
public ResourcePayloadExtractionResult extract(Path packagePath, Path workDir) {
Path normalizedWorkDir = workDir.normalize();
Path payloadDir = normalizedWorkDir.resolve("payload").normalize();
// inspectPackage 会完成外层包验签、数字信封解密,并返回 payload.zip 明文字节。
// 预检阶段不会解密 payload,真正恢复创建时才走这条完整解包路径。
ResourcePackageInspectResult inspectResult = resourcePackageService.inspectPackage(packagePath);
byte[] payloadArchiveBytes = Optional.ofNullable(inspectResult.getPayloadArchiveBytes())
.orElseThrow(() -> new IllegalStateException("payload归档内容缺失"));
// payload.zip 中包含配置文件、SQL dump、MQ 上下文、用户密钥备份文件和 payload-manifest.json。
extractPayloadArchive(payloadArchiveBytes, payloadDir);
Path manifestPath = payloadDir.resolve("payload-manifest.json").normalize();
@@ -48,7 +51,10 @@ public class ResourcePayloadExtractor {
throw new IllegalStateException("payload清单缺失:" + manifestPath);
}
Map<String, Object> manifest = readManifest(manifestPath);
// 文件恢复目标在启动 shell 前就校验白名单。数据库、MQ、用户密钥不走文件覆盖路径,不在这里校验 restorePath。
validateRestoreTargets(payloadDir, manifest);
// Java 把 manifest 转成 shell 最容易处理的 TSV:
// shell 不需要理解完整 JSON,只按行读取“payload entry -> 目标位置/目标库/上下文文件”。
writeShellPlans(normalizedWorkDir, manifest);
Path restoreContextPath = writeRestoreContext(packagePath, normalizedWorkDir, payloadDir, manifestPath, manifest);
@@ -133,12 +139,15 @@ public class ResourcePayloadExtractor {
if ("FILE".equalsIgnoreCase(kind)) {
appendFilePlanLines(fileLines, resource);
} else if ("DATABASE".equalsIgnoreCase(kind) || "DB".equalsIgnoreCase(kind)) {
// 数据库 TSV 格式:payload 内 SQL 文件路径 + 目标数据库名。
// restore-db.sh 负责具体 mysql 命令和错误处理。
String entryPath = firstText(resource, "entryPath");
String databaseName = normalizeDatabaseName(firstText(resource, "databaseName", "database", "resourceCode", "code"));
if (!entryPath.isEmpty()) {
databaseLines.add(entryPath + "\t" + databaseName);
}
} else if ("MQ_REPLAY".equalsIgnoreCase(kind)) {
// MQ TSV 只传上下文文件路径,具体 replay 策略交给部署环境脚本。
String entryPath = firstText(resource, "entryPath");
if (!entryPath.isEmpty()) {
mqLines.add(entryPath);
@@ -224,6 +233,7 @@ public class ResourcePayloadExtractor {
String relativeName = firstText(fileEntry, "relativeName");
String mode = firstText(resource, "mode");
if (!relativeName.isEmpty() && "DIR".equalsIgnoreCase(mode)) {
// 目录资源在备份包中被拆成多个相对路径文件,恢复时再拼回原目录结构。
return Path.of(resourceRestorePath).resolve(relativeName).normalize().toString();
}
return resourceRestorePath;
@@ -0,0 +1,139 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.integration.crypto.pcie.PcieUserKeyType;
import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.cert.entity.KeyEntity;
import com.cisd.tms.modules.cert.repository.KeyEntityRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.springframework.stereotype.Component;
@Component
public class ResourceUserKeyBackupCollector {
private static final int USER_KEY_BACKUP_BUFFER_SIZE = 4096;
private static final int USER_KEY_TYPE = PcieUserKeyType.SIGN.getCode();
private final KeyEntityRepository keyEntityRepository;
private final PcieCryptoService pcieCryptoService;
private final ObjectMapper objectMapper;
public ResourceUserKeyBackupCollector(
KeyEntityRepository keyEntityRepository,
PcieCryptoService pcieCryptoService,
ObjectMapper objectMapper
) {
this.keyEntityRepository = keyEntityRepository;
this.pcieCryptoService = pcieCryptoService;
this.objectMapper = objectMapper;
}
static ResourceUserKeyBackupCollector empty(ObjectMapper objectMapper) {
return new ResourceUserKeyBackupCollector(null, null, objectMapper);
}
int collect(ZipOutputStream zipOutputStream, Map<String, Object> resourceEntry) throws IOException {
// USER_KEY 资源的来源不是文件路径,而是实体证书表 t_key_entity 中已经分配过的 keyIdx。
// 每个 keyIdx 对应密码卡内的一把用户签名密钥,备份包要保证这些索引在新机器恢复后仍可用。
resourceEntry.put("kind", "USER_KEY");
resourceEntry.put("keyType", USER_KEY_TYPE);
List<Integer> keyIndexes = keyIndexes();
resourceEntry.put("keyCount", keyIndexes.size());
if (keyIndexes.isEmpty()) {
resourceEntry.put("collected", Boolean.FALSE);
resourceEntry.put("skipReason", "NO_USER_KEYS");
resourceEntry.put("keys", List.of());
return 0;
}
List<Map<String, Object>> keys = new ArrayList<>();
for (Integer keyIdx : keyIndexes) {
// 厂商接口返回的是可导入 recoverUserKey 的加密密钥 blob,不是明文私钥。
// 这里仍把它放进 payload 内部,再由外层数字信封整体加密和签名。
BackupDataResult backupData = pcieCryptoService.backupUserKey(keyIdx, USER_KEY_TYPE, USER_KEY_BACKUP_BUFFER_SIZE);
byte[] encryptedKey = normalizeBackupData(backupData);
if (encryptedKey.length == 0) {
throw new IllegalStateException("用户密钥备份数据为空:" + keyIdx);
}
String entryPath = "keys/user-key-" + keyIdx + ".json";
// 独立 key 文件保存恢复所需的最小参数:keyIdx、keyType 和加密 key blob。
// manifest 中只放 entryPath 和摘要,恢复端按 entryPath 再读取具体内容。
Map<String, Object> keyFile = new LinkedHashMap<>();
keyFile.put("schemaVersion", "1.0");
keyFile.put("keyIdx", keyIdx);
keyFile.put("keyType", USER_KEY_TYPE);
keyFile.put("encryptedKeyBase64", Base64.getEncoder().encodeToString(encryptedKey));
keyFile.put("length", encryptedKey.length);
keyFile.put("contentSm3", sm3Hex(encryptedKey));
addZipEntry(zipOutputStream, entryPath, objectMapper.writeValueAsBytes(keyFile));
Map<String, Object> keyEntry = new LinkedHashMap<>();
keyEntry.put("keyIdx", keyIdx);
keyEntry.put("keyType", USER_KEY_TYPE);
keyEntry.put("entryPath", entryPath);
keyEntry.put("length", encryptedKey.length);
keyEntry.put("contentSm3", sm3Hex(encryptedKey));
keys.add(keyEntry);
}
resourceEntry.put("collected", Boolean.TRUE);
resourceEntry.put("keys", keys);
return keys.size();
}
private List<Integer> keyIndexes() {
if (keyEntityRepository == null) {
return List.of();
}
List<KeyEntity> entities = keyEntityRepository.findAll();
if (entities == null || entities.isEmpty()) {
return List.of();
}
// 同一个 keyIdx 可能因为历史数据或测试夹具重复出现,恢复只应导入一次。
// 排序保证包内容稳定,便于测试、审计和人工排查。
return entities.stream()
.map(KeyEntity::getKeyIdx)
.filter(Objects::nonNull)
.distinct()
.sorted()
.toList();
}
private static byte[] normalizeBackupData(BackupDataResult backupData) {
if (backupData == null || backupData.getData() == null || backupData.getData().length == 0) {
return new byte[0];
}
byte[] data = backupData.getData();
int length = backupData.getLength();
int safeLength = length > 0 && length <= data.length ? length : data.length;
return Arrays.copyOf(data, safeLength);
}
private static void addZipEntry(ZipOutputStream zipOutputStream, String entryName, byte[] content) throws IOException {
zipOutputStream.putNextEntry(new ZipEntry(entryName));
zipOutputStream.write(content);
zipOutputStream.closeEntry();
}
private static String sm3Hex(byte[] data) {
SM3Digest digest = new SM3Digest();
digest.update(data, 0, data.length);
byte[] out = new byte[digest.getDigestSize()];
digest.doFinal(out, 0);
StringBuilder builder = new StringBuilder(out.length * 2);
for (byte item : out) {
builder.append(String.format("%02x", item));
}
return builder.toString();
}
}
@@ -0,0 +1,141 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.integration.crypto.pcie.model.RecoverUserKeyRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class ResourceUserKeyRestoreService {
private final PcieCryptoService pcieCryptoService;
private final ObjectMapper objectMapper;
public ResourceUserKeyRestoreService(PcieCryptoService pcieCryptoService, ObjectMapper objectMapper) {
this.pcieCryptoService = pcieCryptoService;
this.objectMapper = objectMapper;
}
public void recover(Path payloadDir) {
Path normalizedPayloadDir = payloadDir.normalize();
Path manifestPath = normalizedPayloadDir.resolve("payload-manifest.json").normalize();
if (!Files.isRegularFile(manifestPath)) {
return;
}
// 用户密钥恢复只相信 payload-manifest.json 中声明的 USER_KEY 条目。
// 这样即使 payload 目录里被混入额外 key 文件,也不会被恢复流程消费。
Map<String, Object> manifest = readMap(manifestPath);
Object resourcesValue = manifest.get("resources");
if (!(resourcesValue instanceof List<?> resources)) {
return;
}
for (Object resourceObject : resources) {
if (!(resourceObject instanceof Map<?, ?> rawResource)) {
continue;
}
Map<String, Object> resource = castMap(rawResource);
if (!"USER_KEY".equalsIgnoreCase(kindOf(resource))) {
continue;
}
recoverKeys(normalizedPayloadDir, resource);
}
}
private void recoverKeys(Path payloadDir, Map<String, Object> resource) {
Object keysValue = resource.get("keys");
if (!(keysValue instanceof List<?> keys)) {
return;
}
for (Object keyObject : keys) {
if (!(keyObject instanceof Map<?, ?> rawKeyEntry)) {
continue;
}
Map<String, Object> keyEntry = castMap(rawKeyEntry);
String entryPath = firstText(keyEntry, "entryPath");
if (entryPath.isEmpty()) {
continue;
}
// entryPath 必须停留在 payload 目录内,避免恶意 manifest 引导读取任意本地文件。
Path keyFile = payloadDir.resolve(entryPath).normalize();
if (!keyFile.startsWith(payloadDir)) {
throw new IllegalStateException("用户密钥备份条目越过payload目录:" + entryPath);
}
Map<String, Object> keyPayload = readMap(keyFile);
// recoverUserKey 需要的三个核心参数来自备份文件:
// keyIdx 保持旧机器的索引,keyType 当前为 SIGN(0),encryptedKey 是 backupUserKey 返回的 blob。
RecoverUserKeyRequest request = new RecoverUserKeyRequest();
request.setKeyIndex(intValue(keyPayload.get("keyIdx"), intValue(keyEntry.get("keyIdx"), -1)));
request.setKeyType(intValue(keyPayload.get("keyType"), intValue(keyEntry.get("keyType"), -1)));
request.setEncryptedKey(decodeEncryptedKey(keyPayload));
// storeFlag=1 表示掉电保存,符合跨机器恢复后长期使用的场景。
request.setStoreFlag(1);
pcieCryptoService.recoverUserKey(request);
}
}
private Map<String, Object> readMap(Path path) {
try {
return objectMapper.readValue(path.toFile(), new TypeReference<Map<String, Object>>() {
});
} catch (IOException ex) {
throw new IllegalStateException("读取用户密钥备份文件失败:" + path, ex);
}
}
private static byte[] decodeEncryptedKey(Map<String, Object> keyPayload) {
String encryptedKeyBase64 = firstText(keyPayload, "encryptedKeyBase64");
if (encryptedKeyBase64.isEmpty()) {
throw new IllegalStateException("用户密钥备份内容为空");
}
return Base64.getDecoder().decode(encryptedKeyBase64);
}
private static Map<String, Object> castMap(Map<?, ?> rawMap) {
Map<String, Object> result = new LinkedHashMap<>();
rawMap.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
private static String kindOf(Map<String, Object> resource) {
String kind = firstText(resource, "kind");
if (!kind.isEmpty()) {
return kind;
}
return firstText(resource, "resourceType");
}
private static String firstText(Map<String, Object> map, String... keys) {
for (String key : keys) {
Object value = map.get(key);
if (value != null) {
String text = String.valueOf(value).trim();
if (!text.isEmpty() && !"null".equalsIgnoreCase(text)) {
return text;
}
}
}
return "";
}
private static int intValue(Object value, int defaultValue) {
if (value instanceof Number number) {
return number.intValue();
}
if (value != null) {
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (NumberFormatException ignored) {
return defaultValue;
}
}
return defaultValue;
}
}
@@ -36,8 +36,12 @@ public class LightweightRestoreApplierLauncher {
public void launch(Path workDir) {
Path normalizedWorkDir = workDir.toAbsolutePath().normalize();
// shell 脚本需要数据库连接、恢复脚本路径、健康检查地址等运行时参数。
// 这些信息来自当前 TMS 配置,写入任务目录下的 restore-env.sh,避免在命令行暴露复杂参数。
writeRestoreEnv(normalizedWorkDir);
Path logFile = normalizedWorkDir.resolve("restore-launch.log");
// Java 阶段到这里已经完成包验签、解密、payload 展开和用户密钥恢复。
// 后续进入停机恢复脚本,当前 TMS 服务可能被脚本停止。
List<String> command = List.of(
"/bin/bash",
properties.getRestoreApplyScriptPath(),
@@ -55,6 +59,8 @@ public class LightweightRestoreApplierLauncher {
Files.createDirectories(workDir);
Map<String, String> values = new LinkedHashMap<>();
MysqlTarget target = parseMysqlTarget(datasourceUrl);
// restore-env.sh 是 apply-resource-backup.sh 的唯一环境输入。
// 脚本读取这些变量后执行停服务、导库、MQ replay、健康检查等现场动作。
values.put("TMS_SCRIPT_PATH", trim(properties.getTmsScriptPath()));
values.put("DB_RESTORE_SCRIPT_PATH", trim(properties.getRestoreDbScriptPath()));
values.put("MQ_REPLAY_SCRIPT_PATH", trim(properties.getMqReplayScriptPath()));
@@ -58,11 +58,15 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
@Override
public CreateResourceBackupResponse createBackupTask(CreateResourceBackupRequest request) {
// 备份会读取配置文件、导出数据库、调用密码卡备份用户密钥并生成本地包文件。
// 这些动作不能和恢复、初始化、升级并发,否则容易出现“刚导出又被覆盖”的不一致。
resourceTaskAdmissionGuard.assertCanStartBackup();
// 资源备份必须基于“最近一次成功初始化”快照收集上下文,避免前端重新传一套可能失真的参数。
InitTaskEntity initTask = initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS")
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "成功ful init task is missing"));
// taskId 用于后台任务表和工作目录;backupId 写入备份包 manifest,
// 恢复创建时用户需要确认同一个 backupId,避免把预检包和恢复包搞混。
String taskId = resourceTaskIdGenerator.nextBackupTaskId();
String backupId = resourceTaskIdGenerator.nextBackupId();
LocalDateTime now = LocalDateTime.now();
@@ -79,10 +83,12 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setStartTime(now);
// 先落 RUNNING,再开始真实采集。这样第二个备份/恢复请求能被互斥守卫拦住。
resourceBackupTaskRepository.save(entity);
try {
// 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。
// createPackage 内部会完成:资源采集 -> payload.zip -> 数字信封加密 -> 外层 .tmsbak 写盘。
ResourcePackageResult packageResult = resourcePackageService.createPackage(
taskId,
backupId,
@@ -110,6 +116,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
}
entity.setFinishTime(LocalDateTime.now());
entity.setUpdateTime(entity.getFinishTime());
// 同一条任务记录从 RUNNING 更新为 SUCCESS/FAILED,列表和详情均以这里的终态为准。
resourceBackupTaskRepository.update(entity);
CreateResourceBackupResponse response = new CreateResourceBackupResponse();
@@ -152,6 +159,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService {
public ResourceBackupDownloadResult downloadBackupPackage(String taskId) {
ResourceBackupTaskEntity entity = resourceBackupTaskRepository.findByTaskId(trim(taskId))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源备份任务不存在:" + trim(taskId)));
// 下载接口只按任务表保存的最终路径取包,不重新打包;如果包被人工删除,直接报业务错误。
Path packagePath = Path.of(trim(entity.getPackagePath())).normalize().toAbsolutePath();
if (trim(entity.getPackagePath()).isEmpty() || !Files.exists(packagePath) || !Files.isRegularFile(packagePath)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源备份包文件不存在");
@@ -34,6 +34,8 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
@Override
public ResourceRestorePrecheckResponse precheck(ResourceRestorePrecheckRequest request) {
// 预检输入是上传模块返回的 fileId。这里先通过文件表解析实际存储路径,
// 后续所有恢复创建都必须复用本次预检保存的 fileId,避免前端替换包文件。
FileRecordEntity fileRecord = fileRecordRepository.findByFileId(trim(request.getFileId()))
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "备份包文件不存在:" + trim(request.getFileId())));
// 预检阶段只解析包头、验签和比对指纹,不提前解密完整 payload。
@@ -43,6 +45,8 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
if (!expectedStorageFingerprint().equals(storageFingerprint) || !expectedAuthenticityFingerprint().equals(authenticityFingerprint)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源备份包密钥指纹不匹配");
}
// precheckId 是“用户看过并确认的包头结果”的短期凭据。
// 创建恢复任务时会校验 precheckId、backupId、兼容性和过期时间,避免跳过预检直接恢复。
ResourceRestorePrecheckResponse response = new ResourceRestorePrecheckResponse();
response.setPrecheckId(resourceTaskIdGenerator.nextPrecheckId());
response.setBackupId(stringValue(inspectResult.getManifest().get("backupId")));
@@ -57,6 +61,7 @@ public class ResourcePrecheckServiceImpl implements ResourcePrecheckService {
response.setPayloadDecrypted(false);
response.setWarnings(List.of());
response.setBlockingIssues(List.of());
// 第一版恢复计划固定为两段:Java 负责验签/解密/展开/用户密钥恢复,shell 负责停服后的落盘恢复。
response.setRestorePlan(List.of("JAVA_EXTRACT", "SHELL_APPLY"));
response.setRestoreScopeSummary(buildScopeSummary(inspectResult));
response.setExpiresAt(LocalDateTime.now().plusMinutes(30).toString());
@@ -7,6 +7,8 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractionResult;
import com.cisd.tms.modules.backup.packagex.ResourceUserKeyRestoreService;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
import com.cisd.tms.modules.backup.service.ResourceRestoreService;
@@ -35,12 +37,15 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
private final ResourceTaskIdGenerator resourceTaskIdGenerator;
private final ResourceBackupProperties resourceBackupProperties;
private final ResourcePayloadExtractor resourcePayloadExtractor;
private final ResourceUserKeyRestoreService resourceUserKeyRestoreService;
private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher;
private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard;
@Override
public CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request) {
// 恢复会导入用户密钥、覆盖配置文件、导入数据库并停启服务,
// 必须在入口处和其它高风险任务互斥。
resourceTaskAdmissionGuard.assertCanStartRestore();
if (!Boolean.TRUE.equals(request.getConfirmRiskAccepted())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "必须确认恢复风险");
@@ -64,7 +69,11 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
LocalDateTime now = LocalDateTime.now();
Path taskDir = Path.of(resourceBackupProperties.getRestoreTaskRootDir()).resolve(taskId).normalize();
createDirectories(taskDir);
resourcePayloadExtractor.extract(Path.of(packagePath), taskDir);
// Java 阶段完成包的完整验签、解密和 payload 展开,并生成 shell 可消费的 TSV 计划文件。
ResourcePayloadExtractionResult extractionResult = resourcePayloadExtractor.extract(Path.of(packagePath), taskDir);
// 用户密钥恢复必须放在 shell 停服/导库之前:数据库和证书数据恢复后会引用这些 keyIdx。
// 如果这里失败,不保存 APPLYING 任务、不启动 shell,避免出现“数据已覆盖但密钥不可用”的半恢复状态。
resourceUserKeyRestoreService.recover(extractionResult.getPayloadDir());
ResourceRestoreTaskEntity entity = new ResourceRestoreTaskEntity();
entity.setTaskId(taskId);
@@ -85,7 +94,10 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
entity.setCreateBy("SYSTEM");
entity.setCreateTime(now);
entity.setUpdateTime(now);
// 只有 Java 解包和用户密钥恢复都成功后,任务才进入 APPLYING。
// 后续状态由 shell 写 status.json,查询接口会读取该状态文件展示实时恢复进度。
resourceRestoreTaskRepository.save(entity);
// shell apply 是停机恢复的执行点:停服务、覆盖文件、导 SQL、MQ replay、起服务、健康检查。
lightweightRestoreApplierLauncher.launch(taskDir);
CreateResourceRestoreResponse response = new CreateResourceRestoreResponse();
@@ -111,6 +123,7 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService {
if (precheck == null) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "资源恢复预检不存在");
}
// 兼容性和过期时间在创建恢复时再次校验,防止用户拿旧的或不匹配的预检结果继续恢复。
if (!Boolean.TRUE.equals(precheck.getCompatible())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "resource restore precheck is not compatible");
}
@@ -41,6 +41,8 @@ public class ResourceTaskAdmissionGuard {
}
public synchronized void assertCanStartBackup() {
// 同一个 JVM 内先用 synchronized 收窄并发窗口,再查询任务表/状态文件做跨请求互斥。
// 第一版不引入分布式锁,目标是防止单机部署下最危险的并发恢复/备份。
assertNoActiveBackup();
assertNoActiveRestore();
assertNoRunningInitOrReset();
@@ -67,6 +69,8 @@ public class ResourceTaskAdmissionGuard {
if (!ACTIVE_RESTORE_STATUSES.contains(trim(task.getStatus()))) {
continue;
}
// 恢复任务的最终结果由 shell 写 status.json;数据库中的 APPLYING 可能不会被 Java 回写。
// 因此这里读取状态文件,如果 shell 已明确 SUCCESS/FAILED,就不再把该任务视为活动任务。
RestoreStatusFileReader.RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(task));
if ("SUCCESS".equalsIgnoreCase(trim(snapshot.status())) || "FAILED".equalsIgnoreCase(trim(snapshot.status()))) {
continue;
@@ -20,6 +20,7 @@ class ResourceDiscoveryServiceTest {
Assertions.assertTrue(containsCode(items, "CMEP_DB"));
Assertions.assertTrue(containsCode(items, "CP_CONFIG"));
Assertions.assertTrue(containsCode(items, "RECEIVER_LICENSE"));
Assertions.assertTrue(containsCode(items, "USER_KEY"));
Assertions.assertTrue(containsCode(items, "MQ_REPLAY"));
Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS"));
Assertions.assertFalse(containsCode(items, "TLQ_LICENSE"));
@@ -27,6 +28,7 @@ class ResourceDiscoveryServiceTest {
Assertions.assertTrue(items.stream().allMatch(item ->
"FILE".equals(item.getResourceType())
|| "DATABASE".equals(item.getResourceType())
|| "USER_KEY".equals(item.getResourceType())
|| "MQ_REPLAY".equals(item.getResourceType())));
Assertions.assertFalse(containsCode(items, "NGINX_CONFIG"));
Assertions.assertFalse(containsCode(items, "WEB_STATIC"));
@@ -45,6 +47,7 @@ class ResourceDiscoveryServiceTest {
Assertions.assertFalse(containsCode(items, "TLQ_LICENSE"));
Assertions.assertFalse(containsCode(items, "RABBITMQ_DEFINITIONS"));
Assertions.assertTrue(hasRequired(items, "TMS_DB"));
Assertions.assertTrue(hasRequired(items, "USER_KEY"));
Assertions.assertTrue(hasRequired(items, "MQ_REPLAY"));
Assertions.assertFalse(containsCode(items, "CMEP_DB"));
}
@@ -2,12 +2,15 @@ package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.modules.backup.config.ResourceBackupProperties;
import com.cisd.tms.modules.backup.crypto.ResourceEnvelopeCryptoServiceImpl;
import com.cisd.tms.integration.crypto.pcie.PcieUserKeyType;
import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalDecryptRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalEncryptRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest;
import com.cisd.tms.modules.backup.dto.support.DiscoveredResourceItem;
import com.cisd.tms.modules.cert.entity.KeyEntity;
import com.cisd.tms.modules.cert.repository.KeyEntityRepository;
import com.cisd.tms.modules.init.entity.InitTaskEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
@@ -157,6 +160,64 @@ class ResourcePackageServiceTest {
Assertions.assertTrue(exception.getMessage().contains("PATH_NOT_FOUND"));
}
@Test
void shouldCollectUserKeysFromEntityCertificateKeyIndexes() throws Exception {
ResourceBackupProperties properties = new ResourceBackupProperties();
properties.setOutputDir(tempDir.toString());
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
Mockito.when(pcieCryptoService.sdfeInternalEncryptEcc(Mockito.any(EccInternalEncryptRequest.class)))
.thenAnswer(invocation -> backup(invocation.<EccInternalEncryptRequest>getArgument(0).getData()));
Mockito.when(pcieCryptoService.eccInternalSign(Mockito.any()))
.thenReturn(backup(new byte[]{0x11, 0x22, 0x33}));
Mockito.when(pcieCryptoService.sdfeInternalDecryptEcc(Mockito.any(EccInternalDecryptRequest.class)))
.thenAnswer(invocation -> backup(invocation.<EccInternalDecryptRequest>getArgument(0).getCipherBlob()));
Mockito.doNothing().when(pcieCryptoService).eccInternalVerify(Mockito.any());
Mockito.when(pcieCryptoService.backupUserKey(2, PcieUserKeyType.SIGN.getCode(), 4096))
.thenReturn(backup(new byte[]{0x02, 0x20}));
Mockito.when(pcieCryptoService.backupUserKey(3, PcieUserKeyType.SIGN.getCode(), 4096))
.thenReturn(backup(new byte[]{0x03, 0x30}));
KeyEntityRepository keyEntityRepository = Mockito.mock(KeyEntityRepository.class);
Mockito.when(keyEntityRepository.findAll()).thenReturn(List.of(keyEntity(3), keyEntity(2), keyEntity(2)));
ResourcePackageService service = new ResourcePackageServiceImpl(
properties,
new ResourceEnvelopeCryptoServiceImpl(pcieCryptoService, properties),
mockDatabaseBackupCollector(),
new ResourceUserKeyBackupCollector(keyEntityRepository, pcieCryptoService, new ObjectMapper()),
new MqReplayContextBuilder(new ObjectMapper()),
new ObjectMapper()
);
ResourcePackageResult result = service.createPackage(
"RBKP-TASK-20260413-000001",
"RBKP-20260413-153000-000001",
sampleInitTask(),
List.of(userKeyResource()),
sampleRequest()
);
ResourcePackageInspectResult inspectResult = service.inspectPackage(Path.of(result.getPackagePath()));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> resources = (java.util.List<Map<String, Object>>) inspectResult.getPayload().get("resources");
Assertions.assertEquals(1, resources.size());
Assertions.assertEquals("USER_KEY", resources.get(0).get("kind"));
Assertions.assertEquals(2, resources.get(0).get("keyCount"));
try (ZipInputStream zipInputStream = new ZipInputStream(new java.io.ByteArrayInputStream(inspectResult.getPayloadArchiveBytes()))) {
java.util.Map<String, String> entries = new java.util.LinkedHashMap<>();
java.util.zip.ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
entries.put(entry.getName(), new String(zipInputStream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
}
Assertions.assertTrue(entries.containsKey("keys/user-key-2.json"));
Assertions.assertTrue(entries.get("keys/user-key-2.json").contains("\"keyIdx\":2"));
Assertions.assertTrue(entries.containsKey("keys/user-key-3.json"));
Assertions.assertTrue(entries.get("keys/user-key-3.json").contains("\"keyIdx\":3"));
}
Mockito.verify(pcieCryptoService).backupUserKey(2, PcieUserKeyType.SIGN.getCode(), 4096);
Mockito.verify(pcieCryptoService).backupUserKey(3, PcieUserKeyType.SIGN.getCode(), 4096);
}
private static BackupDataResult backup(byte[] data) {
BackupDataResult result = new BackupDataResult();
result.setData(data);
@@ -212,6 +273,18 @@ class ResourcePackageServiceTest {
return item;
}
private static DiscoveredResourceItem userKeyResource() {
DiscoveredResourceItem item = new DiscoveredResourceItem();
item.setResourceType("USER_KEY");
item.setResourceCode("USER_KEY");
item.setRequired(true);
return item;
}
private static KeyEntity keyEntity(int keyIdx) {
return new KeyEntity().setKeyIdx(keyIdx);
}
private static DatabaseBackupCollector mockDatabaseBackupCollector() {
DatabaseBackupCollector collector = Mockito.mock(DatabaseBackupCollector.class);
DatabaseBackupCollector.CollectedDatabaseDump tmsDump = new DatabaseBackupCollector.CollectedDatabaseDump();
@@ -0,0 +1,58 @@
package com.cisd.tms.modules.backup.packagex;
import com.cisd.tms.integration.crypto.pcie.PcieUserKeyType;
import com.cisd.tms.integration.crypto.pcie.model.RecoverUserKeyRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
class ResourceUserKeyRestoreServiceTest {
@TempDir
Path tempDir;
@Test
void shouldRecoverUserKeysDeclaredInPayloadManifest() throws Exception {
Path payloadDir = tempDir.resolve("payload");
Files.createDirectories(payloadDir.resolve("keys"));
byte[] key2 = new byte[]{0x02, 0x20};
byte[] key3 = new byte[]{0x03, 0x30};
Files.writeString(payloadDir.resolve("keys/user-key-2.json"), userKeyJson(2, key2), StandardCharsets.UTF_8);
Files.writeString(payloadDir.resolve("keys/user-key-3.json"), userKeyJson(3, key3), StandardCharsets.UTF_8);
Files.writeString(payloadDir.resolve("payload-manifest.json"), """
{"resources":[
{"kind":"USER_KEY","keys":[
{"keyIdx":2,"keyType":0,"entryPath":"keys/user-key-2.json"},
{"keyIdx":3,"keyType":0,"entryPath":"keys/user-key-3.json"}
]}
]}
""", StandardCharsets.UTF_8);
PcieCryptoService pcieCryptoService = Mockito.mock(PcieCryptoService.class);
ResourceUserKeyRestoreService service = new ResourceUserKeyRestoreService(pcieCryptoService, new ObjectMapper());
service.recover(payloadDir);
ArgumentCaptor<RecoverUserKeyRequest> captor = ArgumentCaptor.forClass(RecoverUserKeyRequest.class);
Mockito.verify(pcieCryptoService, Mockito.times(2)).recoverUserKey(captor.capture());
Assertions.assertEquals(2, captor.getAllValues().get(0).getKeyIndex());
Assertions.assertEquals(PcieUserKeyType.SIGN.getCode(), captor.getAllValues().get(0).getKeyType());
Assertions.assertArrayEquals(key2, captor.getAllValues().get(0).getEncryptedKey());
Assertions.assertEquals(1, captor.getAllValues().get(0).getStoreFlag());
Assertions.assertEquals(3, captor.getAllValues().get(1).getKeyIndex());
Assertions.assertArrayEquals(key3, captor.getAllValues().get(1).getEncryptedKey());
}
private static String userKeyJson(int keyIdx, byte[] backupData) {
return """
{"keyIdx":%d,"keyType":0,"encryptedKeyBase64":"%s"}
""".formatted(keyIdx, Base64.getEncoder().encodeToString(backupData));
}
}
@@ -7,6 +7,8 @@ import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse;
import com.cisd.tms.modules.backup.dto.response.ResourceRestorePrecheckResponse;
import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor;
import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractionResult;
import com.cisd.tms.modules.backup.packagex.ResourceUserKeyRestoreService;
import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository;
import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher;
import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl;
@@ -40,6 +42,7 @@ class ResourceRestoreHandoffServiceTest {
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak");
@@ -62,6 +65,15 @@ class ResourceRestoreHandoffServiceTest {
fileRecordEntity.setStoragePath(packagePath.toString());
Mockito.when(fileRecordRepository.findByFileId("FILE-20260413-000001"))
.thenReturn(java.util.Optional.of(fileRecordEntity));
ResourcePayloadExtractionResult extractionResult = new ResourcePayloadExtractionResult();
extractionResult.setPayloadDir(tempDir.resolve("resource-restore").resolve("placeholder").resolve("payload"));
Mockito.when(payloadExtractor.extract(Mockito.eq(packagePath), Mockito.any(Path.class)))
.thenAnswer(invocation -> {
Path taskDir = invocation.getArgument(1);
ResourcePayloadExtractionResult result = new ResourcePayloadExtractionResult();
result.setPayloadDir(taskDir.resolve("payload"));
return result;
});
ResourceRestoreService service = new ResourceRestoreServiceImpl(
repository,
@@ -70,6 +82,7 @@ class ResourceRestoreHandoffServiceTest {
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard
);
@@ -93,6 +106,7 @@ class ResourceRestoreHandoffServiceTest {
Assertions.assertEquals("[\"JAVA_EXTRACT\",\"SHELL_APPLY\"]", saved.getRestorePlanJson());
Mockito.verify(payloadExtractor).extract(packagePath, taskDir);
Mockito.verify(userKeyRestoreService).recover(taskDir.resolve("payload"));
Mockito.verify(applierLauncher).launch(taskDir);
}
@@ -105,6 +119,7 @@ class ResourceRestoreHandoffServiceTest {
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse();
@@ -117,6 +132,7 @@ class ResourceRestoreHandoffServiceTest {
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard
);
@@ -137,6 +153,7 @@ class ResourceRestoreHandoffServiceTest {
ResourceRestoreTaskRepository repository = Mockito.mock(ResourceRestoreTaskRepository.class);
FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class);
ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class);
ResourceUserKeyRestoreService userKeyRestoreService = Mockito.mock(ResourceUserKeyRestoreService.class);
LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class);
ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class);
ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse();
@@ -149,6 +166,7 @@ class ResourceRestoreHandoffServiceTest {
new ResourceTaskIdGenerator(),
properties,
payloadExtractor,
userKeyRestoreService,
applierLauncher,
admissionGuard
);