From 90e2c8ef3d6595a3e8e7b786294b13431d384f10 Mon Sep 17 00:00:00 2001 From: Claudecio Martins Date: Tue, 20 Oct 2026 15:31:47 -0300 Subject: [PATCH] feat(crypto): implement resilient tsa client with fallback and ocsp/crl cache with structured errors --- .../crypto/revocation/RevocationCache.java | 92 ++++++++ .../signer/core/crypto/tsa/TsaClient.java | 154 +++++++++++++ .../revocation/RevocationCacheTest.java | 68 ++++++ .../signer/core/crypto/tsa/TsaClientTest.java | 213 ++++++++++++++++++ 4 files changed, 527 insertions(+) create mode 100644 modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCache.java create mode 100644 modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/tsa/TsaClient.java create mode 100644 modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCacheTest.java create mode 100644 modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/tsa/TsaClientTest.java diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCache.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCache.java new file mode 100644 index 0000000..630837a --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCache.java @@ -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 { + + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + private record CacheEntry(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 get(K key) { + Objects.requireNonNull(key, "key cannot be null"); + CacheEntry 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 V getOrFetch(K key, Duration ttl, ThrowingSupplier fetcher) throws E { + Optional 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 get() throws E; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/tsa/TsaClient.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/tsa/TsaClient.java new file mode 100644 index 0000000..49bdaec --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/tsa/TsaClient.java @@ -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 serverUrls; + private final Duration timeout; + private final int maxRetries; + private final Duration initialBackoff; + private final HttpClient httpClient; + + public TsaClient(List serverUrls) { + this(serverUrls, Duration.ofSeconds(5), 2, Duration.ofMillis(100), null); + } + + public TsaClient(List 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 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 getServerUrls() { + return serverUrls; + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCacheTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCacheTest.java new file mode 100644 index 0000000..f869d22 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/revocation/RevocationCacheTest.java @@ -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 cache = new RevocationCache<>(); + cache.put("cert-123", "VALID_OCSP_RESPONSE", Duration.ofMinutes(10)); + + Optional 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 cache = new RevocationCache<>(); + cache.put("cert-exp", "TEMPORARY", Duration.ofMillis(20)); + + Thread.sleep(50); + + Optional 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 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 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(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/tsa/TsaClientTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/tsa/TsaClientTest.java new file mode 100644 index 0000000..985c9e7 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/tsa/TsaClientTest.java @@ -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); + }); + } +}