diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/CertificateChainValidator.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/CertificateChainValidator.java new file mode 100644 index 0000000..9c245ab --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/CertificateChainValidator.java @@ -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 chain, Set 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 chain, Set 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." + ); + } + } + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/validation/CertificateChainValidatorTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/validation/CertificateChainValidatorTest.java new file mode 100644 index 0000000..e893da5 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/validation/CertificateChainValidatorTest.java @@ -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 chain = List.of(leafCert, intermediateCert, rootCert); + Set trustedRoots = Set.of(rootCert); + + assertThatCode(() -> CertificateChainValidator.validateChain(chain, trustedRoots)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Certificado Expirado: deve lançar CERTIFICATE_EXPIRED") + void shouldThrowCertificateExpiredWhenReferenceDateIsBeyondValidity() { + List 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 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 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 chain = List.of(leafCert, intermediateCert, rootCert); + Set 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); + }); + } +}