feat(crypto): add certificate chain validation and structured ccsp error mapping

This commit is contained in:
Claudecio Martins
2026-10-20 15:33:08 -03:00
parent 90e2c8ef3d
commit 002823ede3
2 changed files with 335 additions and 0 deletions
@@ -0,0 +1,144 @@
package br.com.cybercore.signer.core.validation;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.GeneralSecurityException;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Set;
/**
* Validador rigoroso de cadeias de certificados digitais X.509 ICP-Brasil e padrões internacionais.
* Realiza validação temporal, integridade de assinaturas hierárquicas, restrições básicas (CA)
* e ancoragem em raízes confiáveis (TrustStore), com mapeamento preciso para erros do protocolo CCSP.
*/
public final class CertificateChainValidator {
private static final Logger logger = LoggerFactory.getLogger(CertificateChainValidator.class);
private CertificateChainValidator() {
}
/**
* Valida a cadeia de certificados informada em relação à data/hora atual.
*/
public static void validateChain(List<X509Certificate> chain, Set<X509Certificate> trustedRoots)
throws CertificateProviderException {
validateChain(chain, trustedRoots, Date.from(Instant.now()));
}
/**
* Valida a cadeia de certificados informada em relação à data/hora de referência especificada.
*/
public static void validateChain(List<X509Certificate> chain, Set<X509Certificate> trustedRoots, Date referenceDate)
throws CertificateProviderException {
if (chain == null || chain.isEmpty()) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_NOT_FOUND,
"Cadeia de certificados vazia ou não informada."
);
}
Date dateToValidate = (referenceDate != null) ? referenceDate : Date.from(Instant.now());
// 1. Validação temporal de cada elo da cadeia
for (X509Certificate cert : chain) {
try {
cert.checkValidity(dateToValidate);
} catch (CertificateExpiredException e) {
logger.warn("Certificado expirado na cadeia: {}", cert.getSubjectX500Principal());
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_EXPIRED,
"Certificado '" + cert.getSubjectX500Principal().getName() + "' está expirado em " + dateToValidate,
e
);
} catch (CertificateNotYetValidException e) {
logger.warn("Certificado ainda não válido na cadeia: {}", cert.getSubjectX500Principal());
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"Certificado '" + cert.getSubjectX500Principal().getName() + "' ainda não é válido em " + dateToValidate,
e
);
}
}
// 2. Validação hierárquica de assinaturas e restrições básicas de CA
for (int i = 0; i < chain.size() - 1; i++) {
X509Certificate subjectCert = chain.get(i);
X509Certificate issuerCert = chain.get(i + 1);
// Verifica se o emissor do subject é de fato o issuer
if (!subjectCert.getIssuerX500Principal().equals(issuerCert.getSubjectX500Principal())) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"Quebra na cadeia de confiança: emissor de '" + subjectCert.getSubjectX500Principal().getName()
+ "' não corresponde ao sujeito de '" + issuerCert.getSubjectX500Principal().getName() + "'."
);
}
// O emissor deve ser uma autoridade certificadora (BasicConstraints isCA == true)
if (issuerCert.getBasicConstraints() < 0) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"O certificado intermediário '" + issuerCert.getSubjectX500Principal().getName()
+ "' não possui extensão BasicConstraints autorizando emissão de certificados (isCA=false)."
);
}
// Verifica a assinatura criptográfica de subjectCert pela chave pública de issuerCert
try {
subjectCert.verify(issuerCert.getPublicKey());
} catch (GeneralSecurityException e) {
logger.error("Assinatura inválida no certificado {}: {}", subjectCert.getSubjectX500Principal(), e.getMessage());
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"A assinatura do certificado '" + subjectCert.getSubjectX500Principal().getName()
+ "' não pôde ser verificada pela chave pública de seu emissor: " + e.getMessage(),
e
);
}
}
// 3. Validação da raiz (Root CA)
X509Certificate rootCert = chain.get(chain.size() - 1);
boolean isSelfSigned = rootCert.getIssuerX500Principal().equals(rootCert.getSubjectX500Principal());
if (isSelfSigned) {
try {
rootCert.verify(rootCert.getPublicKey());
} catch (GeneralSecurityException e) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"Assinatura do certificado raiz autoassinado é inválida.",
e
);
}
}
// Validação contra TrustStore de raízes confiáveis se fornecido
if (trustedRoots != null && !trustedRoots.isEmpty()) {
boolean anchoredInTrustStore = false;
for (X509Certificate trusted : trustedRoots) {
if (trusted.equals(rootCert) || (isSelfSigned && trusted.getPublicKey().equals(rootCert.getPublicKey()))) {
anchoredInTrustStore = true;
break;
}
}
if (!anchoredInTrustStore) {
throw new CertificateProviderException(
CcspErrorCode.CERTIFICATE_INVALID,
"A Autoridade Certificadora Raiz '" + rootCert.getSubjectX500Principal().getName()
+ "' não está presente na lista de raízes confiáveis do sistema."
);
}
}
}
}
@@ -0,0 +1,191 @@
package br.com.cybercore.signer.core.validation;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Testes de Validação de Cadeia de Certificados X.509")
class CertificateChainValidatorTest {
private static KeyPair rootKeyPair;
private static KeyPair intermediateKeyPair;
private static KeyPair leafKeyPair;
private static X509Certificate rootCert;
private static X509Certificate intermediateCert;
private static X509Certificate leafCert;
@BeforeAll
static void generateTestPki() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
rootKeyPair = kpg.generateKeyPair();
intermediateKeyPair = kpg.generateKeyPair();
leafKeyPair = kpg.generateKeyPair();
Date notBefore = Date.from(Instant.now().minus(10, ChronoUnit.DAYS));
Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
// 1. AC Raiz (Autoassinada, CA=true)
X500Name rootName = new X500Name("CN=AC Raiz Teste, O=ICP-Brasil Teste, C=BR");
X509v3CertificateBuilder rootBuilder = new X509v3CertificateBuilder(
rootName, BigInteger.valueOf(1), notBefore, notAfter, rootName,
SubjectPublicKeyInfo.getInstance(rootKeyPair.getPublic().getEncoded())
);
rootBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
rootBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
ContentSigner rootSigner = new JcaContentSignerBuilder("SHA256withRSA").build(rootKeyPair.getPrivate());
rootCert = new JcaX509CertificateConverter().getCertificate(rootBuilder.build(rootSigner));
// 2. AC Intermediária (Assinada pela Raiz, CA=true)
X500Name intermediateName = new X500Name("CN=AC Intermediaria Teste, O=ICP-Brasil Teste, C=BR");
X509v3CertificateBuilder intermediateBuilder = new X509v3CertificateBuilder(
rootName, BigInteger.valueOf(2), notBefore, notAfter, intermediateName,
SubjectPublicKeyInfo.getInstance(intermediateKeyPair.getPublic().getEncoded())
);
intermediateBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
intermediateBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
intermediateCert = new JcaX509CertificateConverter().getCertificate(intermediateBuilder.build(rootSigner));
// 3. Certificado Folha (Assinado pela Intermediária, CA=false)
X500Name leafName = new X500Name("CN=JOAO DA SILVA:12345678901, O=ICP-Brasil Teste, C=BR");
X509v3CertificateBuilder leafBuilder = new X509v3CertificateBuilder(
intermediateName, BigInteger.valueOf(3), notBefore, notAfter, leafName,
SubjectPublicKeyInfo.getInstance(leafKeyPair.getPublic().getEncoded())
);
leafBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
leafBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
ContentSigner intermediateSigner = new JcaContentSignerBuilder("SHA256withRSA").build(intermediateKeyPair.getPrivate());
leafCert = new JcaX509CertificateConverter().getCertificate(leafBuilder.build(intermediateSigner));
}
@Test
@DisplayName("Cadeia Válida Completa: deve validar com sucesso contra raízes confiáveis")
void shouldValidateCompliantChainSuccessfully() {
List<X509Certificate> chain = List.of(leafCert, intermediateCert, rootCert);
Set<X509Certificate> trustedRoots = Set.of(rootCert);
assertThatCode(() -> CertificateChainValidator.validateChain(chain, trustedRoots))
.doesNotThrowAnyException();
}
@Test
@DisplayName("Certificado Expirado: deve lançar CERTIFICATE_EXPIRED")
void shouldThrowCertificateExpiredWhenReferenceDateIsBeyondValidity() {
List<X509Certificate> chain = List.of(leafCert, intermediateCert, rootCert);
Date futureDate = Date.from(Instant.now().plus(400, ChronoUnit.DAYS));
assertThatThrownBy(() -> CertificateChainValidator.validateChain(chain, Set.of(rootCert), futureDate))
.isInstanceOf(CertificateProviderException.class)
.satisfies(ex -> {
CertificateProviderException cpe = (CertificateProviderException) ex;
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_EXPIRED);
});
}
@Test
@DisplayName("Assinatura Corrompida: deve lançar CERTIFICATE_INVALID se a assinatura de um elo falhar")
void shouldThrowCertificateInvalidWhenSignatureIsCorrupted() throws Exception {
// Gera um cert folha assinado por outra chave que não a intermediária
KeyPair fakeKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
ContentSigner fakeSigner = new JcaContentSignerBuilder("SHA256withRSA").build(fakeKeyPair.getPrivate());
X500Name intermediateName = new X500Name("CN=AC Intermediaria Teste, O=ICP-Brasil Teste, C=BR");
X500Name leafName = new X500Name("CN=ADULTERADO, C=BR");
X509v3CertificateBuilder fakeBuilder = new X509v3CertificateBuilder(
intermediateName, BigInteger.valueOf(99),
Date.from(Instant.now().minus(1, ChronoUnit.DAYS)),
Date.from(Instant.now().plus(30, ChronoUnit.DAYS)),
leafName,
SubjectPublicKeyInfo.getInstance(leafKeyPair.getPublic().getEncoded())
);
X509Certificate corruptedLeaf = new JcaX509CertificateConverter().getCertificate(fakeBuilder.build(fakeSigner));
List<X509Certificate> corruptedChain = List.of(corruptedLeaf, intermediateCert, rootCert);
assertThatThrownBy(() -> CertificateChainValidator.validateChain(corruptedChain, Set.of(rootCert)))
.isInstanceOf(CertificateProviderException.class)
.satisfies(ex -> {
CertificateProviderException cpe = (CertificateProviderException) ex;
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_INVALID);
});
}
@Test
@DisplayName("Cadeia Incompleta / Desconexa: deve lançar CERTIFICATE_INVALID")
void shouldThrowCertificateInvalidWhenChainIsBroken() {
// Passa folha diretamente apontando para raiz sem o intermediário
List<X509Certificate> brokenChain = List.of(leafCert, rootCert);
assertThatThrownBy(() -> CertificateChainValidator.validateChain(brokenChain, Set.of(rootCert)))
.isInstanceOf(CertificateProviderException.class)
.satisfies(ex -> {
CertificateProviderException cpe = (CertificateProviderException) ex;
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_INVALID);
});
}
@Test
@DisplayName("Raiz Não Confiável: deve lançar CERTIFICATE_INVALID quando a raiz não está no TrustStore")
void shouldThrowCertificateInvalidWhenRootIsNotTrusted() throws Exception {
KeyPair otherRootKey = KeyPairGenerator.getInstance("RSA").generateKeyPair();
X500Name otherRootName = new X500Name("CN=Outra Raiz Estranha, C=BR");
X509v3CertificateBuilder otherBuilder = new X509v3CertificateBuilder(
otherRootName, BigInteger.ONE,
Date.from(Instant.now().minus(1, ChronoUnit.DAYS)),
Date.from(Instant.now().plus(30, ChronoUnit.DAYS)),
otherRootName,
SubjectPublicKeyInfo.getInstance(otherRootKey.getPublic().getEncoded())
);
otherBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
ContentSigner otherSigner = new JcaContentSignerBuilder("SHA256withRSA").build(otherRootKey.getPrivate());
X509Certificate untrustedRoot = new JcaX509CertificateConverter().getCertificate(otherBuilder.build(otherSigner));
List<X509Certificate> chain = List.of(leafCert, intermediateCert, rootCert);
Set<X509Certificate> trustedRoots = Set.of(untrustedRoot); // Não inclui a rootCert da cadeia
assertThatThrownBy(() -> CertificateChainValidator.validateChain(chain, trustedRoots))
.isInstanceOf(CertificateProviderException.class)
.satisfies(ex -> {
CertificateProviderException cpe = (CertificateProviderException) ex;
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_INVALID);
});
}
@Test
@DisplayName("Cadeia Vazia: deve lançar CERTIFICATE_NOT_FOUND")
void shouldThrowCertificateNotFoundForEmptyChain() {
assertThatThrownBy(() -> CertificateChainValidator.validateChain(List.of(), null))
.isInstanceOf(CertificateProviderException.class)
.satisfies(ex -> {
CertificateProviderException cpe = (CertificateProviderException) ex;
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.CERTIFICATE_NOT_FOUND);
});
}
}