feat(crypto): implement resilient tsa client with fallback and ocsp/crl cache with structured errors

This commit is contained in:
Claudecio Martins
2026-10-20 15:31:47 -03:00
parent 4b6ae923c6
commit 90e2c8ef3d
4 changed files with 527 additions and 0 deletions
@@ -0,0 +1,92 @@
package br.com.cybercore.signer.core.crypto.revocation;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Cache concorrente em memória com TTL (Time-To-Live) para respostas OCSP e listas CRL,
* evitando requisições de rede redundantes e respeitando tempos de atualização das ACs.
*/
public class RevocationCache<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private record CacheEntry<V>(V value, Instant expiresAt) {
boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
}
/**
* Armazena um item no cache com o TTL especificado.
*/
public void put(K key, V value, Duration ttl) {
Objects.requireNonNull(key, "key cannot be null");
Objects.requireNonNull(value, "value cannot be null");
Objects.requireNonNull(ttl, "ttl cannot be null");
Instant expiresAt = Instant.now().plus(ttl);
cache.put(key, new CacheEntry<>(value, expiresAt));
}
/**
* Recupera um item do cache se ainda for válido; caso tenha expirado, remove e retorna vazio.
*/
public Optional<V> get(K key) {
Objects.requireNonNull(key, "key cannot be null");
CacheEntry<V> entry = cache.get(key);
if (entry == null) {
return Optional.empty();
}
if (entry.isExpired()) {
cache.remove(key, entry);
return Optional.empty();
}
return Optional.of(entry.value());
}
/**
* Recupera o item em cache ou obtém um novo através do fetcher caso não exista ou tenha expirado.
*/
public <E extends Exception> V getOrFetch(K key, Duration ttl, ThrowingSupplier<V, E> fetcher) throws E {
Optional<V> existing = get(key);
if (existing.isPresent()) {
return existing.get();
}
V fetched = fetcher.get();
if (fetched != null) {
put(key, fetched, ttl);
}
return fetched;
}
/**
* Limpa todas as entradas expiradas do cache.
*/
public void evictExpired() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
/**
* Limpa integralmente o cache.
*/
public void clear() {
cache.clear();
}
/**
* Retorna o número de entradas ativas ou pendentes no cache.
*/
public int size() {
return cache.size();
}
@FunctionalInterface
public interface ThrowingSupplier<T, E extends Exception> {
T get() throws E;
}
}
@@ -0,0 +1,154 @@
package br.com.cybercore.signer.core.crypto.tsa;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.tsp.TSPAlgorithms;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampRequestGenerator;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Cliente resiliente de Carimbo do Tempo (TSA RFC 3161) com suporte a retentativas com backoff,
* timeout configurável e fallback automático para servidores secundários.
*/
public class TsaClient {
private static final Logger logger = LoggerFactory.getLogger(TsaClient.class);
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final List<String> serverUrls;
private final Duration timeout;
private final int maxRetries;
private final Duration initialBackoff;
private final HttpClient httpClient;
public TsaClient(List<String> serverUrls) {
this(serverUrls, Duration.ofSeconds(5), 2, Duration.ofMillis(100), null);
}
public TsaClient(List<String> serverUrls, Duration timeout, int maxRetries, Duration initialBackoff, HttpClient customHttpClient) {
Objects.requireNonNull(serverUrls, "serverUrls cannot be null");
if (serverUrls.isEmpty()) {
throw new IllegalArgumentException("serverUrls cannot be empty");
}
this.serverUrls = Collections.unmodifiableList(new ArrayList<>(serverUrls));
this.timeout = timeout != null ? timeout : Duration.ofSeconds(5);
this.maxRetries = Math.max(0, maxRetries);
this.initialBackoff = initialBackoff != null ? initialBackoff : Duration.ofMillis(100);
this.httpClient = customHttpClient != null ? customHttpClient : HttpClient.newBuilder()
.connectTimeout(this.timeout)
.build();
}
/**
* Solicita um carimbo de tempo RFC 3161 para o digest informado (SHA-256).
*/
public TimeStampToken requestTimestamp(byte[] digest) throws CertificateProviderException {
Objects.requireNonNull(digest, "digest cannot be null");
byte[] requestBytes = createTimestampRequest(digest);
Exception lastException = null;
for (String serverUrl : serverUrls) {
String sanitizedUrl = serverUrl.replaceAll("[\r\n]", "");
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
logger.debug("Enviando requisição RFC 3161 TSA para {} (tentativa {}/{})",
sanitizedUrl, attempt + 1, maxRetries + 1);
byte[] responseBytes = executeHttpRequest(serverUrl, requestBytes);
TimeStampResponse tsResponse = new TimeStampResponse(responseBytes);
tsResponse.validate(new TimeStampRequest(requestBytes));
TimeStampToken token = tsResponse.getTimeStampToken();
if (token == null) {
throw new IOException("TSA retornou resposta sem token: " + tsResponse.getStatusString());
}
logger.info("Carimbo de tempo RFC 3161 obtido com sucesso da TSA {}", sanitizedUrl);
return token;
} catch (Exception e) {
lastException = e;
logger.warn("Falha ao comunicar com TSA {} (tentativa {}/{}): {}",
sanitizedUrl, attempt + 1, maxRetries + 1, e.getMessage());
if (attempt < maxRetries) {
try {
long backoffMs = initialBackoff.toMillis() * (1L << attempt);
Thread.sleep(backoffMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new CertificateProviderException(
CcspErrorCode.COMMUNICATION_ERROR,
"Operação de carimbo de tempo interrompida",
ie
);
}
}
}
}
logger.warn("Esgotadas tentativas para TSA {}. Tentando próximo servidor da lista...", sanitizedUrl);
}
throw new CertificateProviderException(
CcspErrorCode.COMMUNICATION_ERROR,
"Falha ao obter carimbo de tempo RFC 3161 de todos os servidores TSA configurados: "
+ (lastException != null ? lastException.getMessage() : "sem resposta"),
lastException
);
}
private byte[] createTimestampRequest(byte[] digest) {
try {
TimeStampRequestGenerator reqGen = new TimeStampRequestGenerator();
reqGen.setCertReq(true);
BigInteger nonce = BigInteger.valueOf(SECURE_RANDOM.nextLong()).abs();
ASN1ObjectIdentifier oid = new ASN1ObjectIdentifier(TSPAlgorithms.SHA256.getId());
TimeStampRequest req = reqGen.generate(oid, digest, nonce);
return req.getEncoded();
} catch (IOException e) {
throw new IllegalStateException("Falha ao codificar requisição RFC 3161", e);
}
}
private byte[] executeHttpRequest(String serverUrl, byte[] requestBytes) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(serverUrl))
.timeout(timeout)
.header("Content-Type", "application/timestamp-query")
.header("Accept", "application/timestamp-reply")
.POST(HttpRequest.BodyPublishers.ofByteArray(requestBytes))
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IOException("HTTP status " + response.statusCode() + " retornado pela TSA");
}
return response.body();
}
public List<String> getServerUrls() {
return serverUrls;
}
}
@@ -0,0 +1,68 @@
package br.com.cybercore.signer.core.crypto.revocation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Testes do RevocationCache com TTL")
class RevocationCacheTest {
@Test
@DisplayName("Deve armazenar e recuperar valor válido dentro do TTL")
void shouldStoreAndRetrieveValidValue() {
RevocationCache<String, String> cache = new RevocationCache<>();
cache.put("cert-123", "VALID_OCSP_RESPONSE", Duration.ofMinutes(10));
Optional<String> result = cache.get("cert-123");
assertThat(result).isPresent().contains("VALID_OCSP_RESPONSE");
assertThat(cache.size()).isEqualTo(1);
}
@Test
@DisplayName("Deve descartar valor expirado após o TTL")
void shouldExpireValueAfterTtl() throws InterruptedException {
RevocationCache<String, String> cache = new RevocationCache<>();
cache.put("cert-exp", "TEMPORARY", Duration.ofMillis(20));
Thread.sleep(50);
Optional<String> result = cache.get("cert-exp");
assertThat(result).isEmpty();
}
@Test
@DisplayName("getOrFetch deve invocar o fetcher apenas uma vez enquanto o cache estiver válido")
void shouldInvokeFetcherOnlyOnceWhileValid() {
RevocationCache<String, Integer> cache = new RevocationCache<>();
AtomicInteger counter = new AtomicInteger(0);
Integer firstCall = cache.getOrFetch("key", Duration.ofMinutes(5), counter::incrementAndGet);
Integer secondCall = cache.getOrFetch("key", Duration.ofMinutes(5), counter::incrementAndGet);
assertThat(firstCall).isEqualTo(1);
assertThat(secondCall).isEqualTo(1);
assertThat(counter.get()).isEqualTo(1);
}
@Test
@DisplayName("evictExpired e clear devem limpar as entradas corretamente")
void shouldEvictExpiredAndClearCorrectly() throws InterruptedException {
RevocationCache<String, String> cache = new RevocationCache<>();
cache.put("k1", "val1", Duration.ofMillis(20));
cache.put("k2", "val2", Duration.ofMinutes(10));
Thread.sleep(50);
cache.evictExpired();
assertThat(cache.get("k1")).isEmpty();
assertThat(cache.get("k2")).isPresent();
cache.clear();
assertThat(cache.size()).isZero();
}
}
@@ -0,0 +1,213 @@
package br.com.cybercore.signer.core.crypto.tsa;
import br.com.cybercore.signer.core.protocol.CcspErrorCode;
import br.com.cybercore.signer.core.provider.CertificateProviderException;
import com.sun.net.httpserver.HttpServer;
import org.bouncycastle.asn1.x500.X500Name;
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.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.tsp.TSPAlgorithms;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampResponseGenerator;
import org.bouncycastle.tsp.TimeStampToken;
import org.bouncycastle.tsp.TimeStampTokenGenerator;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Testes do Cliente Resiliente de Carimbo do Tempo (TSA RFC 3161)")
class TsaClientTest {
private static KeyPair tsaKeyPair;
private static X509Certificate tsaCert;
private static HttpServer primaryServer;
private static HttpServer secondaryServer;
private static String primaryUrl;
private static String secondaryUrl;
private static final AtomicInteger primaryRequestCount = new AtomicInteger(0);
private static final AtomicInteger secondaryRequestCount = new AtomicInteger(0);
@BeforeAll
static void setUp() throws Exception {
// Gera par de chaves e certificado para a TSA de testes
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
tsaKeyPair = kpg.generateKeyPair();
X500Name subject = new X500Name("CN=Autoridade de Carimbo do Tempo de Teste, O=ICP-Brasil, C=BR");
BigInteger serial = BigInteger.valueOf(101);
Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
SubjectPublicKeyInfo subPubKeyInfo = SubjectPublicKeyInfo.getInstance(tsaKeyPair.getPublic().getEncoded());
X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(
subject, serial, notBefore, notAfter, subject, subPubKeyInfo
);
certBuilder.addExtension(
org.bouncycastle.asn1.x509.Extension.extendedKeyUsage,
true,
new org.bouncycastle.asn1.x509.ExtendedKeyUsage(org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_timeStamping)
);
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(tsaKeyPair.getPrivate());
X509CertificateHolder holder = certBuilder.build(signer);
tsaCert = new JcaX509CertificateConverter().getCertificate(holder);
// Servidor primário
primaryServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
primaryServer.createContext("/tsa", exchange -> {
int count = primaryRequestCount.incrementAndGet();
String query = exchange.getRequestURI().getQuery();
if (query != null && query.contains("error")) {
exchange.sendResponseHeaders(503, -1);
return;
}
if (query != null && query.contains("flaky") && count == 1) {
// Primeira tentativa falha, segunda sucede
exchange.sendResponseHeaders(500, -1);
return;
}
try (InputStream is = exchange.getRequestBody()) {
byte[] reqBytes = is.readAllBytes();
byte[] respBytes = generateTsaResponse(reqBytes);
exchange.getResponseHeaders().set("Content-Type", "application/timestamp-reply");
exchange.sendResponseHeaders(200, respBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(respBytes);
}
} catch (Exception e) {
exchange.sendResponseHeaders(500, -1);
}
});
primaryServer.start();
primaryUrl = "http://127.0.0.1:" + primaryServer.getAddress().getPort() + "/tsa";
// Servidor secundário (Fallback)
secondaryServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
secondaryServer.createContext("/tsa", exchange -> {
secondaryRequestCount.incrementAndGet();
try (InputStream is = exchange.getRequestBody()) {
byte[] reqBytes = is.readAllBytes();
byte[] respBytes = generateTsaResponse(reqBytes);
exchange.getResponseHeaders().set("Content-Type", "application/timestamp-reply");
exchange.sendResponseHeaders(200, respBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(respBytes);
}
} catch (Exception e) {
exchange.sendResponseHeaders(500, -1);
}
});
secondaryServer.start();
secondaryUrl = "http://127.0.0.1:" + secondaryServer.getAddress().getPort() + "/tsa";
}
@AfterAll
static void tearDown() {
if (primaryServer != null) primaryServer.stop(0);
if (secondaryServer != null) secondaryServer.stop(0);
}
private static byte[] generateTsaResponse(byte[] requestBytes) throws Exception {
TimeStampRequest req = new TimeStampRequest(requestBytes);
org.bouncycastle.operator.DigestCalculatorProvider digCalcProv = new JcaDigestCalculatorProviderBuilder().build();
org.bouncycastle.operator.DigestCalculator digCalc = digCalcProv.get(new org.bouncycastle.asn1.x509.AlgorithmIdentifier(org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256));
org.bouncycastle.cms.SignerInfoGenerator signerInfoGen = new JcaSignerInfoGeneratorBuilder(digCalcProv)
.build(new JcaContentSignerBuilder("SHA256withRSA").build(tsaKeyPair.getPrivate()), tsaCert);
TimeStampTokenGenerator tokenGen = new TimeStampTokenGenerator(
signerInfoGen,
digCalc,
new org.bouncycastle.asn1.ASN1ObjectIdentifier("2.16.76.1.4.1") // OID Política ACT ICP-Brasil
);
TimeStampResponseGenerator respGen = new TimeStampResponseGenerator(tokenGen, java.util.Collections.singleton(TSPAlgorithms.SHA256));
TimeStampResponse resp = respGen.generate(req, BigInteger.valueOf(System.currentTimeMillis()), new Date());
return resp.getEncoded();
}
@Test
@DisplayName("Deve emitir carimbo de tempo RFC 3161 com sucesso no servidor primário")
void shouldObtainTimestampSuccessfullyFromPrimary() throws Exception {
primaryRequestCount.set(0);
TsaClient client = new TsaClient(List.of(primaryUrl));
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-tsa".getBytes(StandardCharsets.UTF_8));
TimeStampToken token = client.requestTimestamp(digest);
assertThat(token).isNotNull();
assertThat(token.getTimeStampInfo().getGenTime()).isNotNull();
assertThat(primaryRequestCount.get()).isEqualTo(1);
}
@Test
@DisplayName("Deve realizar retentativa com backoff quando o servidor primário oscilar temporariamente")
void shouldRetryWithBackoffOnTemporaryFailure() throws Exception {
primaryRequestCount.set(0);
String flakyUrl = primaryUrl + "?flaky=true";
TsaClient client = new TsaClient(List.of(flakyUrl), Duration.ofSeconds(2), 2, Duration.ofMillis(50), null);
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-retry".getBytes(StandardCharsets.UTF_8));
TimeStampToken token = client.requestTimestamp(digest);
assertThat(token).isNotNull();
assertThat(primaryRequestCount.get()).isEqualTo(2); // 1ª falhou, 2ª sucedeu
}
@Test
@DisplayName("Deve fazer fallback transparente para o servidor secundário quando o primário estiver indisponível")
void shouldFallbackToSecondaryServerWhenPrimaryFails() throws Exception {
secondaryRequestCount.set(0);
String offlineUrl = primaryUrl + "?error=true";
TsaClient client = new TsaClient(List.of(offlineUrl, secondaryUrl), Duration.ofSeconds(2), 1, Duration.ofMillis(20), null);
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-fallback".getBytes(StandardCharsets.UTF_8));
TimeStampToken token = client.requestTimestamp(digest);
assertThat(token).isNotNull();
assertThat(secondaryRequestCount.get()).isGreaterThanOrEqualTo(1);
}
@Test
@DisplayName("Deve lançar COMMUNICATION_ERROR se todos os servidores falharem")
void shouldThrowCommunicationErrorWhenAllServersFail() throws Exception {
String offlineUrl1 = primaryUrl + "?error=true";
String offlineUrl2 = "http://127.0.0.1:54321/offline";
TsaClient client = new TsaClient(List.of(offlineUrl1, offlineUrl2), Duration.ofMillis(500), 0, Duration.ofMillis(10), null);
byte[] digest = MessageDigest.getInstance("SHA-256").digest("teste-falha-total".getBytes(StandardCharsets.UTF_8));
assertThatThrownBy(() -> client.requestTimestamp(digest))
.isInstanceOf(CertificateProviderException.class)
.satisfies(ex -> {
CertificateProviderException cpe = (CertificateProviderException) ex;
assertThat(cpe.getErrorCode()).isEqualTo(CcspErrorCode.COMMUNICATION_ERROR);
});
}
}