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); } /**
