feat:根证书导入增加指纹白名单校验
This commit is contained in:
parent
8287289167
commit
855ea1247b
@ -110,6 +110,7 @@ Open:
|
|||||||
- `POST /api/v1/crls/delete`
|
- `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 文件支持 PEM / DER 格式,导入接口会先创建后台任务并返回 `taskId`,前端通过 `POST /api/v1/crls/import-tasks/detail` 轮询 `PENDING/RUNNING/SUCCESS/FAILED` 状态。
|
||||||
- 后台导入完成后保存 CRL metadata 和 CRL 内每条 revoked certificate 明细;原始 CRL 文件只作为临时文件参与解析,任务结束后删除,不作为业务数据长期保存。
|
- 后台导入完成后保存 CRL metadata 和 CRL 内每条 revoked certificate 明细;原始 CRL 文件只作为临时文件参与解析,任务结束后删除,不作为业务数据长期保存。
|
||||||
- CRL issuer 通过可信 CA 的 issuer DN 候选和实际 CRL 签名验证解析。
|
- CRL issuer 通过可信 CA 的 issuer DN 候选和实际 CRL 签名验证解析。
|
||||||
|
|||||||
@ -41,6 +41,10 @@ tms:
|
|||||||
storage:
|
storage:
|
||||||
upload-base-dir: /home/tms/uploads
|
upload-base-dir: /home/tms/uploads
|
||||||
max-file-size-bytes: 104857600
|
max-file-size-bytes: 104857600
|
||||||
|
cert:
|
||||||
|
# 允许导入为根 CA 信任锚的 SHA-256 指纹白名单。为空表示不限制;生产建议填写线下确认的根 CA 指纹。
|
||||||
|
trusted-root-fingerprints:
|
||||||
|
- ""
|
||||||
init:
|
init:
|
||||||
executor:
|
executor:
|
||||||
mode: LOCAL
|
mode: LOCAL
|
||||||
|
|||||||
@ -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<String> trustedRootFingerprints = new ArrayList<>();
|
||||||
|
|
||||||
|
public List<String> getTrustedRootFingerprints() {
|
||||||
|
return trustedRootFingerprints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedRootFingerprints(List<String> trustedRootFingerprints) {
|
||||||
|
this.trustedRootFingerprints = trustedRootFingerprints == null ? new ArrayList<>() : trustedRootFingerprints;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.TrustedCertDetailResponse;
|
||||||
import com.cisd.tms.modules.cert.dto.TrustedCertItemResponse;
|
import com.cisd.tms.modules.cert.dto.TrustedCertItemResponse;
|
||||||
import com.cisd.tms.modules.cert.dto.TrustedCertListRequest;
|
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.entity.TrustedCertEntity;
|
||||||
import com.cisd.tms.modules.cert.repository.CertificateRepository;
|
import com.cisd.tms.modules.cert.repository.CertificateRepository;
|
||||||
import com.cisd.tms.modules.cert.repository.TrustedCertRepository;
|
import com.cisd.tms.modules.cert.repository.TrustedCertRepository;
|
||||||
import com.cisd.tms.modules.cert.support.CertPemSupport;
|
import com.cisd.tms.modules.cert.support.CertPemSupport;
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@ -24,7 +27,10 @@ import java.security.cert.X509CRL;
|
|||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class TrustedCertService {
|
public class TrustedCertService {
|
||||||
@ -40,13 +46,32 @@ public class TrustedCertService {
|
|||||||
|
|
||||||
private final TrustedCertRepository trustedCertRepository;
|
private final TrustedCertRepository trustedCertRepository;
|
||||||
private final CertificateRepository certificateRepository;
|
private final CertificateRepository certificateRepository;
|
||||||
|
private final Set<String> trustedRootFingerprints;
|
||||||
|
|
||||||
public TrustedCertService(
|
public TrustedCertService(
|
||||||
TrustedCertRepository trustedCertRepository,
|
TrustedCertRepository trustedCertRepository,
|
||||||
CertificateRepository certificateRepository
|
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<String> trustedRootFingerprints
|
||||||
) {
|
) {
|
||||||
this.trustedCertRepository = trustedCertRepository;
|
this.trustedCertRepository = trustedCertRepository;
|
||||||
this.certificateRepository = certificateRepository;
|
this.certificateRepository = certificateRepository;
|
||||||
|
this.trustedRootFingerprints = normalizeFingerprints(trustedRootFingerprints);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<TrustedCertItemResponse> list(TrustedCertListRequest request) {
|
public Page<TrustedCertItemResponse> list(TrustedCertListRequest request) {
|
||||||
@ -69,6 +94,7 @@ public class TrustedCertService {
|
|||||||
}
|
}
|
||||||
int caLevel = isSelfSigned(certificate) ? 0 : 1;
|
int caLevel = isSelfSigned(certificate) ? 0 : 1;
|
||||||
if (caLevel == 0) {
|
if (caLevel == 0) {
|
||||||
|
assertRootFingerprintAllowed(fingerprint);
|
||||||
// 根 CA 必须能自签自验,防止把 subject=issuer 的伪造证书放入信任锚。
|
// 根 CA 必须能自签自验,防止把 subject=issuer 的伪造证书放入信任锚。
|
||||||
certificate.verify(certificate.getPublicKey(), "BC");
|
certificate.verify(certificate.getPublicKey(), "BC");
|
||||||
} else {
|
} else {
|
||||||
@ -269,4 +295,30 @@ public class TrustedCertService {
|
|||||||
private boolean isSelfSigned(X509Certificate certificate) {
|
private boolean isSelfSigned(X509Certificate certificate) {
|
||||||
return certificate.getSubjectX500Principal().equals(certificate.getIssuerX500Principal());
|
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<String> normalizeFingerprints(List<String> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,8 @@ tms:
|
|||||||
# 上传文件大小上限(字节)。
|
# 上传文件大小上限(字节)。
|
||||||
max-file-size-bytes: ${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}
|
max-file-size-bytes: ${TMS_FILE_STORAGE_MAX_FILE_SIZE_BYTES:104857600}
|
||||||
cert:
|
cert:
|
||||||
|
# 允许导入为根 CA 信任锚的 SHA-256 指纹白名单,多个值用英文逗号分隔;为空时不限制根 CA 导入。
|
||||||
|
trusted-root-fingerprints: ${TMS_CERT_TRUSTED_ROOT_FINGERPRINTS:}
|
||||||
crl:
|
crl:
|
||||||
# CRL 导入临时目录;原始文件只在解析过程中暂存,导入结束后删除。
|
# CRL 导入临时目录;原始文件只在解析过程中暂存,导入结束后删除。
|
||||||
temp-dir: ${TMS_CERT_CRL_TEMP_DIR:/home/tms/tmp/crl-import}
|
temp-dir: ${TMS_CERT_CRL_TEMP_DIR:/home/tms/tmp/crl-import}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import java.time.Instant;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import javax.security.auth.x500.X500Principal;
|
import javax.security.auth.x500.X500Principal;
|
||||||
|
|
||||||
@ -214,6 +215,56 @@ class TrustedCertServiceTest {
|
|||||||
assertEquals("RSA", captor.getValue().getAlgoType());
|
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 {
|
private KeyPair sm2KeyPair() throws Exception {
|
||||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", "BC");
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", "BC");
|
||||||
generator.initialize(new ECGenParameterSpec("sm2p256v1"));
|
generator.initialize(new ECGenParameterSpec("sm2p256v1"));
|
||||||
@ -301,4 +352,8 @@ class TrustedCertServiceTest {
|
|||||||
CertPemSupport.toPem(certificate).getBytes()
|
CertPemSupport.toPem(certificate).getBytes()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String colonFingerprint(String fingerprint) {
|
||||||
|
return fingerprint.replaceAll("(.{2})(?=.)", "$1:");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user