feat(crypto): implement resilient tsa client with fallback and ocsp/crl cache with structured errors
This commit is contained in:
+92
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user