From 855ea1247bdaea4d2885834d032889827f0629f2 Mon Sep 17 00:00:00 2001 From: waner Date: Wed, 6 May 2026 15:58:16 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=A0=B9=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=A2=9E=E5=8A=A0=E6=8C=87=E7=BA=B9=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + config/application.yml.example | 4 ++ .../modules/cert/config/CertProperties.java | 23 ++++++++ .../cert/service/TrustedCertService.java | 52 ++++++++++++++++++ src/main/resources/application.yml | 2 + .../cert/service/TrustedCertServiceTest.java | 55 +++++++++++++++++++ 6 files changed, 137 insertions(+) create mode 100644 src/main/java/com/cisd/tms/modules/cert/config/CertProperties.java diff --git a/README.md b/README.md index 87d669e..e25b379 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Open: - `POST /api/v1/crls/delete` 当前实现边界: +- 可通过 `tms.cert.trusted-root-fingerprints` 配置根 CA 证书 SHA-256 指纹白名单;配置后根 CA 导入必须命中白名单,中间 CA 仍按当前可信链校验。 - CRL 文件支持 PEM / DER 格式,导入接口会先创建后台任务并返回 `taskId`,前端通过 `POST /api/v1/crls/import-tasks/detail` 轮询 `PENDING/RUNNING/SUCCESS/FAILED` 状态。 - 后台导入完成后保存 CRL metadata 和 CRL 内每条 revoked certificate 明细;原始 CRL 文件只作为临时文件参与解析,任务结束后删除,不作为业务数据长期保存。 - CRL issuer 通过可信 CA 的 issuer DN 候选和实际 CRL 签名验证解析。 diff --git a/config/application.yml.example b/config/application.yml.example index 7e2c3c9..5fde141 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -41,6 +41,10 @@ tms: storage: upload-base-dir: /home/tms/uploads max-file-size-bytes: 104857600 + cert: + # 允许导入为根 CA 信任锚的 SHA-256 指纹白名单。为空表示不限制;生产建议填写线下确认的根 CA 指纹。 + trusted-root-fingerprints: + - "" init: executor: mode: LOCAL diff --git a/src/main/java/com/cisd/tms/modules/cert/config/CertProperties.java b/src/main/java/com/cisd/tms/modules/cert/config/CertProperties.java new file mode 100644 index 0000000..e19a8ae --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/cert/config/CertProperties.java @@ -0,0 +1,23 @@ +package com.cisd.tms.modules.cert.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(prefix = "tms.cert") +public class CertProperties { + + /** + * 允许导入为信任锚的根 CA 证书 SHA-256 指纹。为空时保持兼容,不限制根 CA 导入。 + */ + private List trustedRootFingerprints = new ArrayList<>(); + + public List getTrustedRootFingerprints() { + return trustedRootFingerprints; + } + + public void setTrustedRootFingerprints(List trustedRootFingerprints) { + this.trustedRootFingerprints = trustedRootFingerprints == null ? new ArrayList<>() : trustedRootFingerprints; + } +} diff --git a/src/main/java/com/cisd/tms/modules/cert/service/TrustedCertService.java b/src/main/java/com/cisd/tms/modules/cert/service/TrustedCertService.java index 81dc0f7..7f1a7a2 100644 --- a/src/main/java/com/cisd/tms/modules/cert/service/TrustedCertService.java +++ b/src/main/java/com/cisd/tms/modules/cert/service/TrustedCertService.java @@ -9,13 +9,16 @@ import com.cisd.tms.modules.cert.dto.TrustedCertDeleteRequest; import com.cisd.tms.modules.cert.dto.TrustedCertDetailResponse; import com.cisd.tms.modules.cert.dto.TrustedCertItemResponse; import com.cisd.tms.modules.cert.dto.TrustedCertListRequest; +import com.cisd.tms.modules.cert.config.CertProperties; import com.cisd.tms.modules.cert.entity.TrustedCertEntity; import com.cisd.tms.modules.cert.repository.CertificateRepository; import com.cisd.tms.modules.cert.repository.TrustedCertRepository; import com.cisd.tms.modules.cert.support.CertPemSupport; import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; @@ -24,7 +27,10 @@ import java.security.cert.X509CRL; import java.security.cert.X509Certificate; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.Locale; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; @Service public class TrustedCertService { @@ -40,13 +46,32 @@ public class TrustedCertService { private final TrustedCertRepository trustedCertRepository; private final CertificateRepository certificateRepository; + private final Set trustedRootFingerprints; public TrustedCertService( TrustedCertRepository trustedCertRepository, CertificateRepository certificateRepository + ) { + this(trustedCertRepository, certificateRepository, List.of()); + } + + @Autowired + public TrustedCertService( + TrustedCertRepository trustedCertRepository, + CertificateRepository certificateRepository, + CertProperties certProperties + ) { + this(trustedCertRepository, certificateRepository, certProperties.getTrustedRootFingerprints()); + } + + TrustedCertService( + TrustedCertRepository trustedCertRepository, + CertificateRepository certificateRepository, + List trustedRootFingerprints ) { this.trustedCertRepository = trustedCertRepository; this.certificateRepository = certificateRepository; + this.trustedRootFingerprints = normalizeFingerprints(trustedRootFingerprints); } public Page list(TrustedCertListRequest request) { @@ -69,6 +94,7 @@ public class TrustedCertService { } int caLevel = isSelfSigned(certificate) ? 0 : 1; if (caLevel == 0) { + assertRootFingerprintAllowed(fingerprint); // 根 CA 必须能自签自验,防止把 subject=issuer 的伪造证书放入信任锚。 certificate.verify(certificate.getPublicKey(), "BC"); } else { @@ -269,4 +295,30 @@ public class TrustedCertService { private boolean isSelfSigned(X509Certificate certificate) { return certificate.getSubjectX500Principal().equals(certificate.getIssuerX500Principal()); } + + private void assertRootFingerprintAllowed(String fingerprint) { + if (trustedRootFingerprints.isEmpty()) { + return; + } + if (!trustedRootFingerprints.contains(normalizeFingerprint(fingerprint))) { + throw new BizException(ErrorCode.BAD_REQUEST.getCode(), "根CA证书指纹不在白名单中"); + } + } + + private Set normalizeFingerprints(List fingerprints) { + if (fingerprints == null) { + return Set.of(); + } + return fingerprints.stream() + .map(this::normalizeFingerprint) + .filter(StringUtils::hasText) + .collect(Collectors.toUnmodifiableSet()); + } + + private String normalizeFingerprint(String fingerprint) { + if (!StringUtils.hasText(fingerprint)) { + return ""; + } + return fingerprint.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.ROOT); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9a2f919..4ed971c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,6 +50,8 @@ tms: # 上传文件大小上限(字节)。 max-file-size-bytes: ${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600} cert: + # 允许导入为根 CA 信任锚的 SHA-256 指纹白名单,多个值用英文逗号分隔;为空时不限制根 CA 导入。 + trusted-root-fingerprints: ${TMS_CERT_TRUSTED_ROOT_FINGERPRINTS:} crl: # CRL 导入临时目录;原始文件只在解析过程中暂存,导入结束后删除。 temp-dir: ${TMS_CERT_CRL_TEMP_DIR:/home/tms/tmp/crl-import} diff --git a/src/test/java/com/cisd/tms/modules/cert/service/TrustedCertServiceTest.java b/src/test/java/com/cisd/tms/modules/cert/service/TrustedCertServiceTest.java index 55cd164..a9a377e 100644 --- a/src/test/java/com/cisd/tms/modules/cert/service/TrustedCertServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/cert/service/TrustedCertServiceTest.java @@ -30,6 +30,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Optional; import javax.security.auth.x500.X500Principal; @@ -214,6 +215,56 @@ class TrustedCertServiceTest { assertEquals("RSA", captor.getValue().getAlgoType()); } + @Test + void shouldAllowRootCaWhenFingerprintMatchesWhitelist() throws Exception { + TrustedCertRepository trustedCertRepository = Mockito.mock(TrustedCertRepository.class); + KeyPair rootKeyPair = sm2KeyPair(); + X509Certificate root = certificate( + rootKeyPair, + rootKeyPair, + "CN=Root", + "CN=Root", + true, + KeyUsage.keyCertSign | KeyUsage.cRLSign + ); + TrustedCertService service = new TrustedCertService( + trustedCertRepository, + Mockito.mock(CertificateRepository.class), + List.of(colonFingerprint(CertPemSupport.sha256Fingerprint(root)).toLowerCase(Locale.ROOT)) + ); + Mockito.when(trustedCertRepository.findByFingerprint(Mockito.any())).thenReturn(Optional.empty()); + Mockito.when(trustedCertRepository.save(Mockito.any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.importTrusted(multipart(root), "root"); + + Mockito.verify(trustedCertRepository).save(Mockito.any()); + } + + @Test + void shouldRejectRootCaWhenFingerprintDoesNotMatchWhitelist() throws Exception { + TrustedCertRepository trustedCertRepository = Mockito.mock(TrustedCertRepository.class); + KeyPair rootKeyPair = sm2KeyPair(); + X509Certificate root = certificate( + rootKeyPair, + rootKeyPair, + "CN=Root", + "CN=Root", + true, + KeyUsage.keyCertSign | KeyUsage.cRLSign + ); + TrustedCertService service = new TrustedCertService( + trustedCertRepository, + Mockito.mock(CertificateRepository.class), + List.of("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + ); + Mockito.when(trustedCertRepository.findByFingerprint(Mockito.any())).thenReturn(Optional.empty()); + + BizException exception = assertThrows(BizException.class, () -> service.importTrusted(multipart(root), "root")); + + assertEquals("根CA证书指纹不在白名单中", exception.getMessage()); + Mockito.verify(trustedCertRepository, Mockito.never()).save(Mockito.any()); + } + private KeyPair sm2KeyPair() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", "BC"); generator.initialize(new ECGenParameterSpec("sm2p256v1")); @@ -301,4 +352,8 @@ class TrustedCertServiceTest { CertPemSupport.toPem(certificate).getBytes() ); } + + private String colonFingerprint(String fingerprint) { + return fingerprint.replaceAll("(.{2})(?=.)", "$1:"); + } }