升级签名和摘要计算

This commit is contained in:
waner 2026-04-10 15:22:35 +08:00
parent ac024c1176
commit 05546fa213
7 changed files with 251 additions and 52 deletions

View File

@ -192,6 +192,7 @@ Open:
- `sql/`
- `firmware/`
- `scripts/`
- 外层升级包实际包含的是 `payload.zip``payload.zip` 内部再包含上述 `payload/` 目录
- `signature.sig` 需要能被配置的 PEM 公钥使用 `SM3withSM2` 软验签通过
- 服务端需要配置 `tms.upgrade.signature-public-key-pem-path`(公钥 PEM 文件路径)
- `manifest.json` 当前要求包含:
@ -202,9 +203,11 @@ Open:
- `minCompatibleVersion`
- `description`
- `entrypoints.execute`
- `payloadSm3`
- 可选 `entrypoints.precheck`
- 可选 `entrypoints.verify`
- 可选 `entrypoints.rollback`
- `payloadSm3` 是整个 `payload.zip` 文件的 `SM3` 摘要;后端验签 `manifest.json` 后,会校验 `payload.zip` 摘要并解压出 `payload/` 供脚本执行
当前执行规则:
- 同一时刻只允许一个升级任务处于 `RUNNING`

View File

@ -19,6 +19,8 @@ public class UpgradeManifest {
private String description;
@Schema(description = "升级脚本入口")
private Entrypoints entrypoints = new Entrypoints();
@Schema(description = "payload.zip 文件 SM3 摘要")
private String payloadSm3;
public String getPackageId() {
return packageId;
@ -76,6 +78,14 @@ public class UpgradeManifest {
this.entrypoints = entrypoints;
}
public String getPayloadSm3() {
return payloadSm3;
}
public void setPayloadSm3(String payloadSm3) {
this.payloadSm3 = payloadSm3;
}
public static class Entrypoints {
private String execute;
private String precheck;

View File

@ -56,6 +56,7 @@ public class UpgradeTaskRunner {
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found: " + task.getPackageFileId()));
Path stagedDir = upgradePackageStagingService.stage(task.getTaskId(), Path.of(record.getStoragePath()));
UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class);
upgradePackageStagingService.extractPayloadZip(stagedDir);
Path root = stagedDir.normalize().toAbsolutePath();
// 包内脚本按固定阶段执行可选阶段未声明时跳过execute 阶段必须存在
@ -137,6 +138,7 @@ public class UpgradeTaskRunner {
.orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package file not found: " + task.getPackageFileId()));
Path stagedDir = upgradePackageStagingService.stage(task.getTaskId() + "-rollback", Path.of(record.getStoragePath()));
UpgradeManifest manifest = objectMapper.readValue(Files.readString(stagedDir.resolve("manifest.json")), UpgradeManifest.class);
upgradePackageStagingService.extractPayloadZip(stagedDir);
String rollbackPath = manifest.getEntrypoints() == null ? "" : trim(manifest.getEntrypoints().getRollback());
if (rollbackPath.isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade rollback script not found");

View File

@ -16,7 +16,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Optional;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.springframework.stereotype.Service;
@Service
@ -56,6 +58,9 @@ public class UpgradePackageService {
validateProductType(manifest.getProductType());
// 签名对象固定为 manifest.json 原始字节具体算法由 UpgradePackageSignatureVerifier 实现
signatureVerifier.verify(manifest, stagedPackage.manifestPath(), stagedPackage.signaturePath());
// 校验 payload.zip 摘要确保已签名的 manifest 能约束实际载荷压缩包内容
validatePayloadZipSm3(stagedPackage.stagedDir(), manifest);
upgradePackageStagingService.extractPayloadZip(stagedPackage.stagedDir());
validateExecuteScript(stagedPackage.stagedDir(), manifest);
// 当前版本来自设备软件版本表空版本允许继续预检由前端展示未记录版本
@ -144,6 +149,24 @@ public class UpgradePackageService {
}
}
private void validatePayloadZipSm3(Path stagedDir, UpgradeManifest manifest) {
String expected = normalize(manifest.getPayloadSm3()).toLowerCase(Locale.ROOT);
if (expected.isEmpty()) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package payload sm3 missing");
}
if (!expected.matches("[0-9a-f]{64}")) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package payload sm3 invalid");
}
Path root = stagedDir.normalize().toAbsolutePath();
Path payloadZip = root.resolve("payload.zip").normalize().toAbsolutePath();
if (!payloadZip.startsWith(root) || !Files.exists(payloadZip) || !Files.isRegularFile(payloadZip)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade payload.zip not found");
}
if (!expected.equals(sm3Hex(payloadZip))) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package payload sm3 mismatch");
}
}
private void validateExecuteScript(Path stagedDir, UpgradeManifest manifest) {
Path executePath = stagedDir.resolve(normalize(manifest.getEntrypoints().getExecute())).normalize().toAbsolutePath();
// execute 路径必须在 staging 目录内避免 manifest 声明外部系统脚本
@ -166,6 +189,23 @@ public class UpgradePackageService {
return normalize(value).toUpperCase();
}
private String sm3Hex(Path path) {
try {
byte[] content = Files.readAllBytes(path);
SM3Digest digest = new SM3Digest();
digest.update(content, 0, content.length);
byte[] hash = new byte[digest.getDigestSize()];
digest.doFinal(hash, 0);
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
} catch (IOException ex) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade package checksum invalid");
}
}
private record StagedUpgradePackage(
Path stagedDir,
Path manifestPath,

View File

@ -8,6 +8,8 @@ import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.stereotype.Service;
@ -26,18 +28,36 @@ public class UpgradePackageStagingService {
Path taskDir = root.resolve(fileId).normalize().toAbsolutePath();
try {
Files.createDirectories(taskDir);
unzip(packagePath, taskDir);
unzip(packagePath, taskDir, "");
return taskDir;
} catch (IOException ex) {
throw new BizException(ErrorCode.INTERNAL_ERROR.getCode(), "failed to stage upgrade package");
}
}
private void unzip(Path zipFile, Path targetDir) throws IOException {
public void extractPayloadZip(Path stagedDir) {
Path root = stagedDir.normalize().toAbsolutePath();
Path payloadZip = root.resolve("payload.zip").normalize().toAbsolutePath();
if (!payloadZip.startsWith(root) || !Files.exists(payloadZip) || !Files.isRegularFile(payloadZip)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "upgrade payload.zip not found");
}
try {
deleteDirectoryIfExists(root.resolve("payload"));
unzip(payloadZip, root, "payload/");
} catch (IOException ex) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "failed to extract upgrade payload.zip");
}
}
private void unzip(Path zipFile, Path targetDir, String requiredEntryPrefix) throws IOException {
try (InputStream inputStream = Files.newInputStream(zipFile);
ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
String entryName = entry.getName();
if (!requiredEntryPrefix.isEmpty() && !entryName.startsWith(requiredEntryPrefix)) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "invalid upgrade payload entry");
}
Path target = targetDir.resolve(entry.getName()).normalize().toAbsolutePath();
// 防止恶意 zip 条目通过 ../ 写出 staging 目录
if (!target.startsWith(targetDir)) {
@ -53,6 +73,26 @@ public class UpgradePackageStagingService {
}
}
private void deleteDirectoryIfExists(Path directory) throws IOException {
if (!Files.exists(directory)) {
return;
}
try (Stream<Path> stream = Files.walk(directory)) {
stream.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.delete(path);
} catch (IOException ex) {
throw new IllegalStateException(ex);
}
});
} catch (IllegalStateException ex) {
if (ex.getCause() instanceof IOException ioException) {
throw ioException;
}
throw ex;
}
}
private String trim(String value) {
return value == null ? "" : value.trim();
}

View File

@ -10,6 +10,7 @@ import com.cisd.tms.modules.upgrade.entity.UpgradeTaskEntity;
import com.cisd.tms.modules.upgrade.repository.UpgradeTaskRepository;
import com.cisd.tms.modules.upgrade.support.UpgradePackageStagingService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@ -19,6 +20,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@ -189,6 +191,17 @@ class UpgradeTaskRunnerTest {
entrypoints.setRollback("payload/scripts/rollback.sh");
}
manifest.setEntrypoints(entrypoints);
Map<String, byte[]> payloadFiles = new HashMap<>();
if (includeOptionalScripts) {
payloadFiles.put("payload/scripts/precheck.sh", "#!/bin/sh\necho precheck\nexit 0\n".getBytes(StandardCharsets.UTF_8));
payloadFiles.put("payload/scripts/verify.sh", "#!/bin/sh\necho verify\nexit 0\n".getBytes(StandardCharsets.UTF_8));
}
payloadFiles.put("payload/scripts/execute.sh", executeScript.getBytes(StandardCharsets.UTF_8));
if (includeRollback) {
payloadFiles.put("payload/scripts/rollback.sh", "#!/bin/sh\necho rollback\nexit 0\n".getBytes(StandardCharsets.UTF_8));
}
byte[] payloadZip = payloadZip(payloadFiles);
manifest.setPayloadSm3(sm3Hex(payloadZip));
try (ZipOutputStream outputStream = new ZipOutputStream(Files.newOutputStream(packagePath))) {
outputStream.putNextEntry(new ZipEntry("manifest.json"));
@ -199,30 +212,9 @@ class UpgradeTaskRunnerTest {
outputStream.write("signature".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/"));
outputStream.putNextEntry(new ZipEntry("payload.zip"));
outputStream.write(payloadZip);
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/"));
outputStream.closeEntry();
if (includeOptionalScripts) {
outputStream.putNextEntry(new ZipEntry("payload/scripts/precheck.sh"));
outputStream.write("#!/bin/sh\necho precheck\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/verify.sh"));
outputStream.write("#!/bin/sh\necho verify\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
}
outputStream.putNextEntry(new ZipEntry("payload/scripts/execute.sh"));
outputStream.write(executeScript.getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
if (includeRollback) {
outputStream.putNextEntry(new ZipEntry("payload/scripts/rollback.sh"));
outputStream.write("#!/bin/sh\necho rollback\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
}
}
FileRecordEntity entity = new FileRecordEntity();
@ -233,6 +225,30 @@ class UpgradeTaskRunnerTest {
return entity;
}
private byte[] payloadZip(Map<String, byte[]> payloadFiles) throws Exception {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream outputStream = new ZipOutputStream(bytes)) {
for (Map.Entry<String, byte[]> entry : payloadFiles.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()) {
outputStream.putNextEntry(new ZipEntry(entry.getKey()));
outputStream.write(entry.getValue());
outputStream.closeEntry();
}
}
return bytes.toByteArray();
}
private String sm3Hex(byte[] content) {
SM3Digest digest = new SM3Digest();
digest.update(content, 0, content.length);
byte[] hash = new byte[digest.getDigestSize()];
digest.doFinal(hash, 0);
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
}
private UpgradeTaskEntity task(String taskId, String fileId, String taskType, String targetVersion) {
UpgradeTaskEntity entity = new UpgradeTaskEntity();
entity.setTaskId(taskId);

View File

@ -15,6 +15,7 @@ import com.cisd.tms.modules.upgrade.support.UpgradePackageSignatureVerifier;
import com.cisd.tms.modules.upgrade.support.UpgradePackageStagingService;
import com.cisd.tms.modules.upgrade.support.UpgradeVersionComparator;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.StringWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@ -30,6 +31,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
@ -167,6 +169,48 @@ class UpgradePackageServiceTest {
Assertions.assertEquals("upgrade execute script not found", exception.getMessage());
}
@Test
void shouldRejectWhenPayloadSm3IsMissing() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test");
FileRecordEntity fileRecord = buildPackage(tempDir, "RECEIVER", "DIRECT", "V1.4.0", "V1.3.0", true, false);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
UpgradePackageService service = new UpgradePackageService(
fileRepository,
new InMemoryDeviceSoftwareVersionRepository(),
stagingService(tempDir.resolve("staging")),
new PassThroughSignatureVerifier(),
new UpgradeVersionComparator(),
preset("DIRECT"),
objectMapper
);
BizException exception = Assertions.assertThrows(BizException.class, () -> service.preview(fileRecord.getFileId()));
Assertions.assertEquals("upgrade package payload sm3 missing", exception.getMessage());
}
@Test
void shouldRejectWhenPayloadZipSm3DoesNotMatch() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test");
FileRecordEntity fileRecord = buildPackage(tempDir, "RECEIVER", "DIRECT", "V1.4.0", "V1.3.0", true, true, true);
InMemoryFileRecordRepository fileRepository = new InMemoryFileRecordRepository();
fileRepository.save(fileRecord);
UpgradePackageService service = new UpgradePackageService(
fileRepository,
new InMemoryDeviceSoftwareVersionRepository(),
stagingService(tempDir.resolve("staging")),
new PassThroughSignatureVerifier(),
new UpgradeVersionComparator(),
preset("DIRECT"),
objectMapper
);
BizException exception = Assertions.assertThrows(BizException.class, () -> service.preview(fileRecord.getFileId()));
Assertions.assertEquals("upgrade package payload sm3 mismatch", exception.getMessage());
}
@Test
void shouldPreviewUpgradePackageWithSoftSignatureVerification() throws Exception {
Path tempDir = Files.createTempDirectory("upgrade-preview-test");
@ -218,8 +262,37 @@ class UpgradePackageServiceTest {
private FileRecordEntity buildPackage(Path tempDir, String taskType, String productType, String version, String minCompatibleVersion, boolean includeExecute)
throws IOException {
return buildPackage(tempDir, taskType, productType, version, minCompatibleVersion, includeExecute, true);
}
private FileRecordEntity buildPackage(
Path tempDir,
String taskType,
String productType,
String version,
String minCompatibleVersion,
boolean includeExecute,
boolean includePayloadSm3
) throws IOException {
return buildPackage(tempDir, taskType, productType, version, minCompatibleVersion, includeExecute, includePayloadSm3, false);
}
private FileRecordEntity buildPackage(
Path tempDir,
String taskType,
String productType,
String version,
String minCompatibleVersion,
boolean includeExecute,
boolean includePayloadSm3,
boolean corruptPayloadSm3
) throws IOException {
Path packagePath = tempDir.resolve("upgrade-package.zip");
byte[] signatureBytes = "signature".getBytes(StandardCharsets.UTF_8);
byte[] executeScript = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8);
byte[] precheckScript = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8);
byte[] verifyScript = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8);
byte[] rollbackScript = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8);
UpgradeManifest manifest = new UpgradeManifest();
manifest.setPackageId("PKG-001");
@ -234,6 +307,14 @@ class UpgradePackageServiceTest {
entrypoints.setVerify("payload/scripts/verify.sh");
entrypoints.setRollback("payload/scripts/rollback.sh");
manifest.setEntrypoints(entrypoints);
byte[] payloadZip = payloadZip(Map.of(
"payload/scripts/precheck.sh", precheckScript,
"payload/scripts/verify.sh", verifyScript,
"payload/scripts/rollback.sh", rollbackScript
), includeExecute ? Map.of("payload/scripts/execute.sh", executeScript) : Map.of());
if (includePayloadSm3) {
manifest.setPayloadSm3(corruptPayloadSm3 ? "0".repeat(64) : sm3Hex(payloadZip));
}
byte[] manifestBytes = objectMapper.writeValueAsBytes(manifest);
@ -246,27 +327,8 @@ class UpgradePackageServiceTest {
outputStream.write(signatureBytes);
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/"));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/"));
outputStream.closeEntry();
if (includeExecute) {
outputStream.putNextEntry(new ZipEntry("payload/scripts/execute.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
}
outputStream.putNextEntry(new ZipEntry("payload/scripts/precheck.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/verify.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/rollback.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.putNextEntry(new ZipEntry("payload.zip"));
outputStream.write(payloadZip);
outputStream.closeEntry();
}
@ -298,6 +360,9 @@ class UpgradePackageServiceTest {
UpgradeManifest.Entrypoints entrypoints = new UpgradeManifest.Entrypoints();
entrypoints.setExecute("payload/scripts/execute.sh");
manifest.setEntrypoints(entrypoints);
byte[] executeScript = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8);
byte[] payloadZip = payloadZip(Map.of("payload/scripts/execute.sh", executeScript), Map.of());
manifest.setPayloadSm3(sm3Hex(payloadZip));
byte[] manifestBytes = objectMapper.writeValueAsBytes(manifest);
byte[] signatureBytes = sign(manifestBytes, keyPair);
@ -311,12 +376,8 @@ class UpgradePackageServiceTest {
outputStream.write(signatureBytes);
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/"));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/"));
outputStream.closeEntry();
outputStream.putNextEntry(new ZipEntry("payload/scripts/execute.sh"));
outputStream.write("#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8));
outputStream.putNextEntry(new ZipEntry("payload.zip"));
outputStream.write(payloadZip);
outputStream.closeEntry();
}
@ -349,6 +410,33 @@ class UpgradePackageServiceTest {
return signature.sign();
}
private byte[] payloadZip(Map<String, byte[]> required, Map<String, byte[]> optional) throws IOException {
Map<String, byte[]> all = new HashMap<>();
all.putAll(required);
all.putAll(optional);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream outputStream = new ZipOutputStream(bytes)) {
for (Map.Entry<String, byte[]> entry : all.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()) {
outputStream.putNextEntry(new ZipEntry(entry.getKey()));
outputStream.write(entry.getValue());
outputStream.closeEntry();
}
}
return bytes.toByteArray();
}
private String sm3Hex(byte[] content) {
SM3Digest digest = new SM3Digest();
digest.update(content, 0, content.length);
byte[] hash = new byte[digest.getDigestSize()];
digest.doFinal(hash, 0);
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
}
private String toPem(String type, byte[] content) throws Exception {
StringWriter writer = new StringWriter();
try (PemWriter pemWriter = new PemWriter(writer)) {