This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch coheigea/future-cache
in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git

commit 2d5ae8c8f900e67fabc89160df5546c7726cdb2b
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Wed Nov 4 08:23:42 2026 +0000

    Cache nonces and signatures for as long as their token is accepted
---
 .../org/apache/wss4j/common/util/DateUtil.java     |  24 ++++
 .../org/apache/wss4j/common/util/DateUtilTest.java |  64 ++++++++++
 .../wss4j/dom/processor/SignatureProcessor.java    |   7 +-
 .../dom/processor/UsernameTokenProcessor.java      |   7 +-
 .../org/apache/wss4j/dom/message/ReplayTest.java   | 129 ++++++++++++++++++++-
 .../processor/input/UsernameTokenInputHandler.java |   7 +-
 .../WSSSignatureReferenceVerifyInputProcessor.java |   8 +-
 7 files changed, 235 insertions(+), 11 deletions(-)

diff --git 
a/ws-security-common/src/main/java/org/apache/wss4j/common/util/DateUtil.java 
b/ws-security-common/src/main/java/org/apache/wss4j/common/util/DateUtil.java
index 0b4db312a..6743b63a8 100644
--- 
a/ws-security-common/src/main/java/org/apache/wss4j/common/util/DateUtil.java
+++ 
b/ws-security-common/src/main/java/org/apache/wss4j/common/util/DateUtil.java
@@ -75,6 +75,30 @@ public final class DateUtil {
         return true;
     }
 
+    /**
+     * Return how long the replay cache entry for a message with the given 
Timestamp must be kept,
+     * which is for as long as the Timestamp may still be accepted: the later 
of the Expires value
+     * and Created + timeToLive. verifyCreated accepts the message until 
Created + timeToLive,
+     * which can be after the Expires value if the Expires value is not 
enforced (a non-strict
+     * Timestamp check), and after the current time + timeToLive, as the 
Created value may be in
+     * the future, within the future time to live.
+     *
+     * @param created the Created value of the Timestamp, can be null
+     * @param expires the Expires value of the Timestamp, can be null
+     * @param timeToLive the value in seconds for the validity of the Created 
time
+     * @return the expiry of the replay cache entry, or null if the Timestamp 
does not bound it
+     */
+    public static Instant getTimestampReplayExpiry(Instant created, Instant 
expires, int timeToLive) {
+        if (created == null) {
+            return expires;
+        }
+        Instant createdExpiry = created.plusSeconds(timeToLive);
+        if (expires != null && expires.isAfter(createdExpiry)) {
+            return expires;
+        }
+        return createdExpiry;
+    }
+
     public static DateTimeFormatter getDateTimeFormatter(boolean milliseconds) 
{
         if (milliseconds) {
             return MILLISECOND_FORMATTER;
diff --git 
a/ws-security-common/src/test/java/org/apache/wss4j/common/util/DateUtilTest.java
 
b/ws-security-common/src/test/java/org/apache/wss4j/common/util/DateUtilTest.java
new file mode 100644
index 000000000..ae84e8ba4
--- /dev/null
+++ 
b/ws-security-common/src/test/java/org/apache/wss4j/common/util/DateUtilTest.java
@@ -0,0 +1,64 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.wss4j.common.util;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class DateUtilTest {
+
+    @Test
+    public void testTimestampReplayExpiryNoCreated() {
+        Instant expires = Instant.now().plusSeconds(100L);
+        assertEquals(expires, DateUtil.getTimestampReplayExpiry(null, expires, 
300));
+        assertNull(DateUtil.getTimestampReplayExpiry(null, null, 300));
+    }
+
+    @Test
+    public void testTimestampReplayExpiryNoExpires() {
+        Instant created = Instant.now();
+        assertEquals(created.plusSeconds(300L), 
DateUtil.getTimestampReplayExpiry(created, null, 300));
+    }
+
+    // A Created value in the future is accepted until Created + TTL, which is 
after now + TTL
+    @Test
+    public void testTimestampReplayExpiryFutureCreated() {
+        Instant created = Instant.now().plusSeconds(60L);
+        assertEquals(created.plusSeconds(300L), 
DateUtil.getTimestampReplayExpiry(created, null, 300));
+    }
+
+    // An Expires value before Created + TTL is not enforced by a non-strict 
Timestamp check
+    @Test
+    public void testTimestampReplayExpiryEarlyExpires() {
+        Instant created = Instant.now();
+        Instant expires = created.plusSeconds(1L);
+        assertEquals(created.plusSeconds(300L), 
DateUtil.getTimestampReplayExpiry(created, expires, 300));
+    }
+
+    @Test
+    public void testTimestampReplayExpiryLateExpires() {
+        Instant created = Instant.now();
+        Instant expires = created.plusSeconds(600L);
+        assertEquals(expires, DateUtil.getTimestampReplayExpiry(created, 
expires, 300));
+    }
+}
diff --git 
a/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/SignatureProcessor.java
 
b/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/SignatureProcessor.java
index ae0213342..c3672467b 100644
--- 
a/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/SignatureProcessor.java
+++ 
b/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/SignatureProcessor.java
@@ -64,6 +64,7 @@ import 
org.apache.wss4j.common.principal.UsernameTokenPrincipal;
 import org.apache.wss4j.common.principal.WSDerivedKeyTokenPrincipal;
 import org.apache.wss4j.common.token.BinarySecurity;
 import org.apache.wss4j.common.token.SecurityTokenReference;
+import org.apache.wss4j.common.util.DateUtil;
 import org.apache.wss4j.common.util.KeyUtils;
 import org.apache.wss4j.common.util.XMLUtils;
 import org.apache.wss4j.dom.WSConstants;
@@ -792,8 +793,10 @@ public class SignatureProcessor implements Processor {
         }
 
         // Return the Timestamp/SignatureValue/Key combination so that it can 
be stored in the
-        // cache once the signature has been validated
-        return new ReplayCacheEntry(replayCache, identifier, 
timeStamp.getExpires());
+        // cache once the signature has been validated, for as long as the 
Timestamp is accepted
+        Instant expires =
+            DateUtil.getTimestampReplayExpiry(created, timeStamp.getExpires(), 
requestData.getTimeStampTTL());
+        return new ReplayCacheEntry(replayCache, identifier, expires);
     }
 
     /**
diff --git 
a/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
 
b/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
index 074cf87a1..9e01ce8a1 100644
--- 
a/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
+++ 
b/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
@@ -162,11 +162,12 @@ public class UsernameTokenProcessor implements Processor {
             }
 
             // If no Created, then just cache for the default time
-            // Otherwise, cache for the configured TTL of the UsernameToken 
Created time, as any
-            // older token will just get rejected anyway
+            // Otherwise, cache until the Created time plus the configured 
TTL, as the token is
+            // rejected after that anyway. This is not the same as the current 
time plus the TTL,
+            // as the Created time may be in the future, within the future TTL.
             Instant created = ut.getCreatedDate();
             if (created != null && utTTL > 0) {
-                nonceExpiry = Instant.now().plusSeconds(utTTL);
+                nonceExpiry = created.plusSeconds(utTTL);
             }
         }
 
diff --git 
a/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java 
b/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java
index 5ed9233c3..0ea35a38b 100644
--- a/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java
+++ b/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java
@@ -19,7 +19,10 @@
 
 package org.apache.wss4j.dom.message;
 
+import java.io.IOException;
 import java.nio.file.Path;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -748,6 +751,102 @@ public class ReplayTest {
         replayCache.close();
     }
 
+    /**
+     * A UsernameToken is accepted until its Created time plus the TTL, and 
the Created time may be
+     * in the future (within the future TTL). The Nonce must be cached until 
then, not just until
+     * the current time plus the TTL.
+     */
+    @Test
+    public void testUsernameTokenFutureCreatedNonceExpiry() throws Exception {
+        Instant created = 
Instant.now().plusSeconds(30L).truncatedTo(ChronoUnit.MILLIS);
+
+        Document doc = SOAPUtil.toSOAPPart(SOAPUtil.SAMPLE_SOAP_MSG);
+        WSSecHeader secHeader = new WSSecHeader(doc);
+        secHeader.insertSecurityHeader();
+
+        WSSecUsernameToken builder = new WSSecUsernameToken(secHeader);
+        builder.setUserInfo("wernerd", "verySecret");
+        builder.setWsTimeSource(() -> created);
+
+        Document signedDoc = builder.build();
+
+        WSSConfig wssConfig = WSSConfig.getNewInstance();
+        RequestData data = new RequestData();
+        data.setCallbackHandler(new UsernamePasswordCallbackHandler());
+        data.setWssConfig(wssConfig);
+        RecordingReplayCache replayCache = new RecordingReplayCache();
+        data.setNonceReplayCache(replayCache);
+
+        verify(signedDoc, wssConfig, data);
+
+        assertEquals(created.plusSeconds(data.getUtTTL()), 
replayCache.lastExpiry);
+    }
+
+    /**
+     * A Timestamp with no Expires is accepted until its Created time plus the 
TTL, and the Created
+     * time may be in the future (within the future TTL). The signature must 
be cached until then.
+     */
+    @Test
+    public void testTimestampNoExpiresFutureCreatedReplayExpiry() throws 
Exception {
+        Instant created = 
Instant.now().plusSeconds(30L).truncatedTo(ChronoUnit.MILLIS);
+
+        RequestData data = new RequestData();
+        RecordingReplayCache replayCache = verifySignedTimestamp(created, 0, 
data);
+
+        assertEquals(created.plusSeconds(data.getTimeStampTTL()), 
replayCache.lastExpiry);
+    }
+
+    /**
+     * If the Timestamp check is not strict, then Expires is not enforced, and 
a Timestamp is
+     * accepted until its Created time plus the TTL, even if that is after its 
Expires time. The
+     * signature must be cached until then.
+     */
+    @Test
+    public void testNonStrictTimestampReplayExpiry() throws Exception {
+        Instant created = Instant.now().truncatedTo(ChronoUnit.MILLIS);
+
+        RequestData data = new RequestData();
+        data.setTimeStampStrict(false);
+        RecordingReplayCache replayCache = verifySignedTimestamp(created, 1, 
data);
+
+        assertEquals(created.plusSeconds(data.getTimeStampTTL()), 
replayCache.lastExpiry);
+    }
+
+    /**
+     * Sign a Timestamp with the given Created time and time to live, and 
verify it with the given
+     * RequestData, returning the replay cache that the signature was cached in
+     */
+    private RecordingReplayCache verifySignedTimestamp(
+        Instant created, int timeToLive, RequestData data
+    ) throws Exception {
+        Document doc = SOAPUtil.toSOAPPart(SOAPUtil.SAMPLE_SOAP_MSG);
+        WSSecHeader secHeader = new WSSecHeader(doc);
+        secHeader.insertSecurityHeader();
+
+        WSSecTimestamp timestamp = new WSSecTimestamp(secHeader);
+        timestamp.setTimeToLive(timeToLive);
+        timestamp.setWsTimeSource(() -> created);
+        Document createdDoc = timestamp.build();
+
+        WSSecSignature builder = new WSSecSignature(secHeader);
+        builder.setUserInfo("16c73ab6-b892-458f-abf5-2f875f74882e", 
"security");
+        builder.setKeyIdentifierType(WSConstants.ISSUER_SERIAL);
+        builder.getParts().add(new WSEncryptionPart("Timestamp", 
WSConstants.WSU_NS, ""));
+        builder.prepare(crypto);
+        List<javax.xml.crypto.dsig.Reference> referenceList =
+            builder.addReferencesToSign(builder.getParts());
+        builder.computeSignature(referenceList, false, null);
+
+        WSSConfig wssConfig = WSSConfig.getNewInstance();
+        data.setWssConfig(wssConfig);
+        data.setCallbackHandler(callbackHandler);
+        RecordingReplayCache replayCache = new RecordingReplayCache();
+        data.setTimestampReplayCache(replayCache);
+
+        verify(createdDoc, wssConfig, data);
+        return replayCache;
+    }
+
     /**
      * Test that creates, sends and processes an unsigned SAML 2 
authentication assertion. This
      * is just a sanity test to make sure that it is possible to send the SAML 
token twice, as
@@ -883,5 +982,33 @@ public class ReplayTest {
         return secEngine.processSecurityHeader(elem, data);
     }
 
+    /**
+     * A ReplayCache that records the expiry it was last asked to cache an 
identifier for
+     */
+    private static final class RecordingReplayCache implements ReplayCache {
+        private final MemoryReplayCache delegate = new MemoryReplayCache();
+        private Instant lastExpiry;
+
+        @Override
+        public void add(String identifier) {
+            lastExpiry = null;
+            delegate.add(identifier);
+        }
+
+        @Override
+        public void add(String identifier, Instant expiry) {
+            lastExpiry = expiry;
+            delegate.add(identifier, expiry);
+        }
 
-}
\ No newline at end of file
+        @Override
+        public boolean contains(String identifier) {
+            return delegate.contains(identifier);
+        }
+
+        @Override
+        public void close() throws IOException {
+            delegate.close();
+        }
+    }
+}
diff --git 
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
 
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
index 6a03e4a21..a6ff3fc25 100644
--- 
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
+++ 
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
@@ -94,11 +94,12 @@ public class UsernameTokenInputHandler extends 
AbstractInputSecurityHeaderHandle
             }
 
             // If no Created, then just cache for the default time
-            // Otherwise, cache for the configured TTL of the UsernameToken 
Created time, as any
-            // older token will just get rejected anyway
+            // Otherwise, cache until the Created time plus the configured 
TTL, as the token is
+            // rejected after that anyway. This is not the same as the current 
time plus the TTL,
+            // as the Created time may be in the future, within the future TTL.
             int utTTL = wssSecurityProperties.getUtTTL();
             if (created != null && utTTL > 0) {
-                nonceExpiry = Instant.now().plusSeconds(utTTL);
+                nonceExpiry = created.plusSeconds(utTTL);
             }
         }
 
diff --git 
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
 
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
index eb6aff5e5..c9b5ee6d8 100644
--- 
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
+++ 
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
@@ -49,6 +49,7 @@ import org.apache.wss4j.common.ext.AttachmentRequestCallback;
 import org.apache.wss4j.common.ext.AttachmentResultCallback;
 import org.apache.wss4j.common.ext.WSSecurityException;
 import org.apache.wss4j.common.util.AttachmentUtils;
+import org.apache.wss4j.common.util.DateUtil;
 import org.apache.wss4j.stax.ext.WSInboundSecurityContext;
 import org.apache.wss4j.stax.ext.WSSConstants;
 import org.apache.wss4j.stax.ext.WSSSecurityProperties;
@@ -431,8 +432,11 @@ public class WSSSignatureReferenceVerifyInputProcessor 
extends AbstractSignature
         }
 
         // Return the Timestamp/SignatureValue combination so that it can be 
stored in the cache
-        // once the signature has been verified
-        return new ReplayCacheEntry(replayCache, cacheKey, 
timestampSecurityEvent.getExpires());
+        // once the signature has been verified, for as long as the Timestamp 
is accepted
+        Instant expires = 
DateUtil.getTimestampReplayExpiry(timestampSecurityEvent.getCreated(),
+            timestampSecurityEvent.getExpires(),
+            
((WSSSecurityProperties)getSecurityProperties()).getTimestampTTL());
+        return new ReplayCacheEntry(replayCache, cacheKey, expires);
     }
 
     /**

Reply via email to