diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/NonceManager.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/NonceManager.java index bb3d85b..646e9f0 100644 --- a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/NonceManager.java +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/NonceManager.java @@ -10,6 +10,8 @@ import java.util.concurrent.ConcurrentHashMap; */ public class NonceManager { + public static final Duration DEFAULT_CLOCK_SKEW = Duration.ofSeconds(30); + private final Duration windowDuration; private final ConcurrentHashMap usedNonces = new ConcurrentHashMap<>(); @@ -21,6 +23,34 @@ public class NonceManager { this(Duration.ofSeconds(120)); } + /** + * Valida um timestamp de requisição em relação ao relógio local, considerando a janela + * de validade e uma margem segura de clock skew (tolerância a desvio de relógio entre cliente e agente). + * + * @param requestTimestamp Instant da requisição informado pelo cliente. + * @param clockSkew Tolerância de tolerância a skew de relógio. + * @return {@code true} se o timestamp estiver na faixa válida permitida. + */ + public boolean isTimestampValid(Instant requestTimestamp, Duration clockSkew) { + if (requestTimestamp == null) { + return false; + } + Duration skew = clockSkew != null ? clockSkew : DEFAULT_CLOCK_SKEW; + Instant now = Instant.now(); + Instant earliestAllowed = now.minus(windowDuration).minus(skew); + Instant latestAllowed = now.plus(skew); + + return !requestTimestamp.isBefore(earliestAllowed) && !requestTimestamp.isAfter(latestAllowed); + } + + public boolean isTimestampValid(Instant requestTimestamp) { + return isTimestampValid(requestTimestamp, DEFAULT_CLOCK_SKEW); + } + + public Duration getWindowDuration() { + return windowDuration; + } + /** * Tenta registrar um nonce. * diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/NonceManagerTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/NonceManagerTest.java index 4a59247..969ba92 100644 --- a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/NonceManagerTest.java +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/NonceManagerTest.java @@ -28,4 +28,84 @@ class NonceManagerTest { assertThat(manager.registerNonce(null)).isFalse(); assertThat(manager.registerNonce(" ")).isFalse(); } + + @Test + @DisplayName("Deve permitir apenas 1 registro concorrente simultâneo para o mesmo nonce") + void shouldBeThreadSafeUnderHighConcurrency() throws Exception { + NonceManager manager = new NonceManager(); + String identicalNonce = "shared-race-nonce-" + java.util.UUID.randomUUID(); + int threads = 30; + + java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(threads); + java.util.concurrent.CountDownLatch startLatch = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.atomic.AtomicInteger successes = new java.util.concurrent.atomic.AtomicInteger(0); + java.util.concurrent.atomic.AtomicInteger rejections = new java.util.concurrent.atomic.AtomicInteger(0); + + java.util.List> futures = new java.util.ArrayList<>(); + for (int i = 0; i < threads; i++) { + futures.add(executor.submit(() -> { + try { + startLatch.await(); + boolean registered = manager.registerNonce(identicalNonce); + if (registered) { + successes.incrementAndGet(); + } else { + rejections.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + } + + startLatch.countDown(); + for (java.util.concurrent.Future f : futures) { + f.get(); + } + executor.shutdown(); + + assertThat(successes.get()).isEqualTo(1); + assertThat(rejections.get()).isEqualTo(threads - 1); + assertThat(manager.activeNonceCount()).isEqualTo(1); + } + + @Test + @DisplayName("Deve validar timestamp com tolerância a clock skew (passado recente e pequeno futuro)") + void shouldValidateTimestampWithClockSkew() { + NonceManager manager = new NonceManager(Duration.ofSeconds(60)); + java.time.Instant now = java.time.Instant.now(); + + // Timestamp atual é válido + assertThat(manager.isTimestampValid(now)).isTrue(); + + // 10 segundos no passado (dentro da janela de 60s) é válido + assertThat(manager.isTimestampValid(now.minusSeconds(10))).isTrue(); + + // 20 segundos no futuro (dentro do clock skew de 30s) é tolerado + assertThat(manager.isTimestampValid(now.plusSeconds(20))).isTrue(); + + // 100 segundos no passado (fora de 60s + 30s = 90s) é rejeitado + assertThat(manager.isTimestampValid(now.minusSeconds(100))).isFalse(); + + // 60 segundos no futuro (além do skew de 30s) é rejeitado + assertThat(manager.isTimestampValid(now.plusSeconds(60))).isFalse(); + + // Nulo é rejeitado + assertThat(manager.isTimestampValid(null)).isFalse(); + } + + @Test + @DisplayName("Deve limpar nonces expirados além da janela configurada") + void shouldCleanupExpiredNonces() throws Exception { + NonceManager manager = new NonceManager(Duration.ofMillis(50)); + manager.registerNonce("temporary-nonce"); + assertThat(manager.activeNonceCount()).isEqualTo(1); + + Thread.sleep(70); + manager.cleanupExpired(); + assertThat(manager.activeNonceCount()).isEqualTo(0); + + // Após expirar e ser limpo, pode ser aceito novamente + assertThat(manager.registerNonce("temporary-nonce")).isTrue(); + } }