feat(crypto): add certificate chain validation and structured ccsp error mapping
This commit is contained in:
+144
@@ -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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user