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