You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@tomcat.apache.org by ma...@apache.org on 2026/06/17 17:12:05 UTC
(tomcat) branch main updated: Second version of replay protection.
This is an automated email from the ASF dual-hosted git repository.
markt-asf pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/main by this push:
new 24f501c698 Second version of replay protection.
24f501c698 is described below
commit 24f501c698499ea5d64c406fd610fc5dcbb2904c
Author: Mark Thomas <ma...@apache.org>
AuthorDate: Wed Jun 17 18:11:56 2026 +0100
Second version of replay protection.
Co-authored with GPT
---
.../group/interceptors/EncryptInterceptor.java | 197 +++++++++++++++++++--
.../interceptors/EncryptInterceptorMBean.java | 28 +++
.../group/interceptors/LocalStrings.properties | 3 +
.../group/interceptors/TestEncryptInterceptor.java | 100 +++++++++++
webapps/docs/changelog.xml | 4 +
webapps/docs/config/cluster-interceptor.xml | 31 +++-
6 files changed, 345 insertions(+), 18 deletions(-)
diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
index 5cc9359a5d..0616ab03c0 100644
--- a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
+++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
@@ -22,7 +22,11 @@ import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.security.spec.AlgorithmParameterSpec;
+import java.util.ArrayDeque;
+import java.util.HashMap;
import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import javax.crypto.Cipher;
@@ -36,6 +40,7 @@ import org.apache.catalina.tribes.ChannelException;
import org.apache.catalina.tribes.ChannelInterceptor;
import org.apache.catalina.tribes.ChannelMessage;
import org.apache.catalina.tribes.Member;
+import org.apache.catalina.tribes.UniqueId;
import org.apache.catalina.tribes.group.ChannelInterceptorBase;
import org.apache.catalina.tribes.group.InterceptorPayload;
import org.apache.catalina.tribes.io.XByteBuffer;
@@ -63,6 +68,9 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
private String encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGORITHM;
private byte[] encryptionKeyBytes;
private String encryptionKeyString;
+ // Milliseconds
+ private long replayWindowTime = 10_000;
+ private int replayWindowMessageCount = 8192;
private BaseEncryptionManager encryptionManager;
@@ -80,7 +88,7 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
if (Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) {
try {
encryptionManager = createEncryptionManager(getEncryptionAlgorithm(), getEncryptionKeyInternal(),
- getProviderName());
+ getProviderName(), getReplayWindowTime(), getReplayWindowMessageCount());
} catch (GeneralSecurityException gse) {
throw new ChannelException(sm.getString("encryptInterceptor.init.failed"), gse);
}
@@ -114,9 +122,18 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
throws ChannelException {
try {
byte[] data = msg.getMessage().getBytes();
+ // Need trusted time stamp on receiving side, so add time stamp to encrypted data.
+ long timestamp = msg.getTimestamp();
+ if (timestamp <= 0) {
+ timestamp = System.currentTimeMillis();
+ msg.setTimestamp(timestamp);
+ }
+ byte[] message = new byte[data.length + 8];
+ XByteBuffer.toBytes(timestamp, message, 0);
+ System.arraycopy(data, 0, message, 8, data.length);
// See #encrypt(byte[]) for an explanation of the return value
- byte[][] bytes = encryptionManager.encrypt(data);
+ byte[][] bytes = encryptionManager.encrypt(message);
XByteBuffer xbb = msg.getMessage();
@@ -137,14 +154,32 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
public void messageReceived(ChannelMessage msg) {
try {
byte[] data = msg.getMessage().getBytes();
+ byte[] encryptedData = data;
data = encryptionManager.decrypt(data);
+ if (data.length < 8) {
+ throw new GeneralSecurityException(sm.getString("encryptInterceptor.decrypt.error.short-message"));
+ }
+ /*
+ * This is trusted since it was encrypted.
+ *
+ * Excessive clock skew will cause problems here. Can't address that without creating risks of replay
+ * attacks.
+ */
+ long trustedTimstamp = XByteBuffer.toLong(data, 0);
+ if (!encryptionManager.checkIncomingMessage(encryptedData, trustedTimstamp)) {
+ log.error(sm.getString("encryptInterceptor.decrypt.replay"));
+ return;
+ }
XByteBuffer xbb = msg.getMessage();
- // Completely replace the message with the decrypted one
+ /*
+ * Completely replace the message with the decrypted one. No need to replace time stamp. At this point it
+ * will be the same as the trusted time stamp.
+ */
xbb.clear();
- xbb.append(data, 0, data.length);
+ xbb.append(data, 8, data.length - 8);
super.messageReceived(msg);
} catch (GeneralSecurityException gse) {
@@ -274,6 +309,58 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
return providerName;
}
+ /**
+ * Returns the time-based replay window in milliseconds.
+ *
+ * @return The replay window time
+ */
+ @Override
+ public long getReplayWindowTime() {
+ return replayWindowTime;
+ }
+
+ /**
+ * Sets the time-based replay window in milliseconds.
+ *
+ * @param replayWindowTime The replay window time
+ */
+ @Override
+ public void setReplayWindowTime(long replayWindowTime) {
+ if (replayWindowTime < 1) {
+ throw new IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowTime.tooSmall"));
+ }
+ this.replayWindowTime = replayWindowTime;
+ if (encryptionManager != null) {
+ encryptionManager.setReplayWindowTime(replayWindowTime);
+ }
+ }
+
+ /**
+ * Returns the maximum number of replay entries to retain.
+ *
+ * @return The replay window message count
+ */
+ @Override
+ public int getReplayWindowMessageCount() {
+ return replayWindowMessageCount;
+ }
+
+ /**
+ * Sets the maximum number of replay entries to retain.
+ *
+ * @param replayWindowMessageCount The replay window message count
+ */
+ @Override
+ public void setReplayWindowMessageCount(int replayWindowMessageCount) {
+ if (replayWindowMessageCount < 1) {
+ throw new IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowMessageCount.tooSmall"));
+ }
+ this.replayWindowMessageCount = replayWindowMessageCount;
+ if (encryptionManager != null) {
+ encryptionManager.setReplayWindowMessageCount(replayWindowMessageCount);
+ }
+ }
+
// Copied from org.apache.tomcat.util.buf.HexUtils
// @formatter:off
private static final int[] DEC = {
@@ -320,7 +407,8 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
}
private static BaseEncryptionManager createEncryptionManager(String algorithm, byte[] encryptionKey,
- String providerName) throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
+ String providerName, long replayWindowTime, int replayWindowMessageCount)
+ throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
if (null == encryptionKey) {
throw new IllegalStateException(sm.getString("encryptInterceptor.key.required"));
}
@@ -359,8 +447,7 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
*/
if ("NONE".equals(algorithmMode) || "ECB".equals(algorithmMode) || "PCBC".equals(algorithmMode) ||
"CTS".equals(algorithmMode) || "KW".equals(algorithmMode) || "KWP".equals(algorithmMode) ||
- "CTR".equals(algorithmMode) ||
- ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
+ "CTR".equals(algorithmMode) || ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
("CFB".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
("GCM".equals(algorithmMode) && "PKCS5PADDING".equals(algorithmPadding)) ||
("OFB".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding))) {
@@ -375,17 +462,18 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
} else if (algorithmMode.startsWith("CFB") || algorithmMode.startsWith("OFB")) {
// Using a non-default block size. Not supported as insecure and/or inefficient.
- throw new IllegalArgumentException(
- sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
+ throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
} else if ("GCM".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) {
// Needs a specialised encryption manager to handle the differences between GCM and other modes
- return new GCMEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName);
+ return new GCMEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName,
+ replayWindowTime, replayWindowMessageCount);
}
// Use the default encryption manager
try {
- return new BaseEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName);
+ return new BaseEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName,
+ replayWindowTime, replayWindowMessageCount);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | NoSuchProviderException ex) {
throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm), ex);
}
@@ -423,12 +511,22 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
* SecureRandom is thread-safe, but sharing a single instance will likely be a bottleneck.
*/
private final ConcurrentLinkedQueue<SecureRandom> randomPool;
-
- BaseEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName)
+ private final TreeMap<Long,ArrayDeque<UniqueId>> receivedTimestampNonces = new TreeMap<>();
+ private final Map<UniqueId,Long> receivedNonceTimestamps = new HashMap<>();
+ private long replayWindowTime;
+ private volatile int replayWindowMessageCount;
+ private long lastRemovedTimestamp;
+ private int receivedNonceCount = 0;
+
+ BaseEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName, long replayWindowTime,
+ int replayWindowMessageCount)
throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
this.algorithm = algorithm;
this.providerName = providerName;
this.secretKey = secretKey;
+ this.replayWindowTime = replayWindowTime;
+ this.lastRemovedTimestamp = System.currentTimeMillis() - replayWindowTime;
+ this.replayWindowMessageCount = replayWindowMessageCount;
cipherPool = new ConcurrentLinkedQueue<>();
Cipher cipher = createCipher();
@@ -441,6 +539,52 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
// Individual Cipher and SecureRandom objects need no explicit tear down
cipherPool.clear();
randomPool.clear();
+ synchronized (this) {
+ receivedTimestampNonces.clear();
+ receivedNonceTimestamps.clear();
+ lastRemovedTimestamp = Long.MIN_VALUE;
+ receivedNonceCount = 0;
+ }
+ }
+
+ public synchronized void setReplayWindowTime(long replayWindowTime) {
+ this.replayWindowTime = replayWindowTime;
+ // Only move the lastRemovedTimestamp forwards. Moving it backwards could open a window for replay attacks.
+ if (lastRemovedTimestamp < System.currentTimeMillis() - replayWindowTime) {
+ lastRemovedTimestamp = System.currentTimeMillis() - replayWindowTime;
+ }
+ }
+
+ public void setReplayWindowMessageCount(int replayWindowMessageCount) {
+ this.replayWindowMessageCount = replayWindowMessageCount;
+ synchronized (this) {
+ while (receivedNonceCount > replayWindowMessageCount) {
+ removeEldestEntry();
+ }
+ }
+ }
+
+ public synchronized boolean checkIncomingMessage(byte[] bytes, long messageTimestamp) {
+ if (messageTimestamp < (System.currentTimeMillis() - replayWindowTime)) {
+ return false;
+ }
+ if (messageTimestamp <= lastRemovedTimestamp) {
+ return false;
+ }
+
+ UniqueId nonce = new UniqueId(bytes, 0, getIVSize());
+ if (receivedNonceTimestamps.containsKey(nonce)) {
+ return false;
+ }
+
+ receivedTimestampNonces.computeIfAbsent(Long.valueOf(messageTimestamp), k -> new ArrayDeque<>()).addLast(nonce);
+ receivedNonceTimestamps.put(nonce, Long.valueOf(messageTimestamp));
+ receivedNonceCount++;
+ while (receivedNonceCount > replayWindowMessageCount) {
+ removeEldestEntry();
+ }
+
+ return true;
}
private String getAlgorithm() {
@@ -593,6 +737,28 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
protected AlgorithmParameterSpec generateIV(byte[] ivBytes, int offset, int length) {
return new IvParameterSpec(ivBytes, offset, length);
}
+
+ private void removeEldestEntry() {
+ Map.Entry<Long,ArrayDeque<UniqueId>> entry = receivedTimestampNonces.firstEntry();
+ if (entry != null) {
+ ArrayDeque<UniqueId> nonces = entry.getValue();
+ UniqueId nonce = nonces.pollFirst();
+ if (nonce != null) {
+ receivedNonceTimestamps.remove(nonce);
+ updateLastRemovedTimestamp(entry.getKey().longValue());
+ receivedNonceCount--;
+ }
+ if (nonces.isEmpty()) {
+ receivedTimestampNonces.pollFirstEntry();
+ }
+ }
+ }
+
+ private void updateLastRemovedTimestamp(long removedTimestamp) {
+ if (removedTimestamp > lastRemovedTimestamp) {
+ lastRemovedTimestamp = removedTimestamp;
+ }
+ }
}
/**
@@ -611,9 +777,10 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
* number of bits supported 128-bit provide the best security.
*/
private static class GCMEncryptionManager extends BaseEncryptionManager {
- GCMEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName)
+ GCMEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName, long replayWindowTime,
+ int replayWindowMessageCount)
throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
- super(algorithm, secretKey, providerName);
+ super(algorithm, secretKey, providerName, replayWindowTime, replayWindowMessageCount);
}
@Override
diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
index 7d10a1f4f1..c6362b26c5 100644
--- a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
+++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
@@ -76,4 +76,32 @@ public interface EncryptInterceptorMBean {
* @return the JCA provider name, or {@code null} for default
*/
String getProviderName();
+
+ /**
+ * Sets the time-based replay window in milliseconds.
+ *
+ * @param replayWindowTime the replay window time
+ */
+ void setReplayWindowTime(long replayWindowTime);
+
+ /**
+ * Returns the time-based replay window in milliseconds.
+ *
+ * @return the replay window time
+ */
+ long getReplayWindowTime();
+
+ /**
+ * Sets the maximum number of replay cache entries.
+ *
+ * @param replayWindowMessageCount the replay window message count
+ */
+ void setReplayWindowMessageCount(int replayWindowMessageCount);
+
+ /**
+ * Returns the maximum number of replay cache entries.
+ *
+ * @return the replay window message count
+ */
+ int getReplayWindowMessageCount();
}
diff --git a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
index a8e3c5d6e5..5c13178485 100644
--- a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
+++ b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
@@ -21,9 +21,12 @@ encryptInterceptor.algorithm.switch=The EncryptInterceptor is using the algorith
encryptInterceptor.algorithm.unsupported=EncryptInterceptor does not support algorithm [{0}]
encryptInterceptor.decrypt.error.short-message=Failed to decrypt message: premature end-of-message
encryptInterceptor.decrypt.failed=Failed to decrypt message
+encryptInterceptor.decrypt.replay=Failed to decrypt message: replay attack detected
encryptInterceptor.encrypt.failed=Failed to encrypt message
encryptInterceptor.init.failed=Failed to initialize EncryptInterceptor
encryptInterceptor.key.required=Encryption key is required
+encryptInterceptor.replayWindowMessageCount.tooSmall=Replay window message count must be at least 1
+encryptInterceptor.replayWindowTime.tooSmall=Replay window time must be at least 1 millisecond
encryptInterceptor.tcpFailureDetector.ordering=EncryptInterceptor must be upstream of TcpFailureDetector. Please re-order EncryptInterceptor to be listed before TcpFailureDetector in your channel interceptor pipeline.
fragmentationInterceptor.fragments.missing=Fragments are missing.
diff --git a/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
index e30e84321f..8c6785c4a8 100644
--- a/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
+++ b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
@@ -216,6 +216,90 @@ public class TestEncryptInterceptor extends EncryptionInterceptorBaseTest {
cipherText1, IsNot.not(IsEqual.equalTo(cipherText2)));
}
+ @Test
+ public void testRejectReplay() throws Exception {
+ src.setNext(new ValueCaptureInterceptor());
+ dest.setPrevious(new ValuesCaptureInterceptor());
+ src.start(Channel.SND_TX_SEQ);
+ dest.start(Channel.SND_TX_SEQ);
+
+ byte[] encrypted = encrypt("msg-1", System.currentTimeMillis());
+
+ deliver(encrypted);
+ deliver(encrypted);
+
+ Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
+ Assert.assertEquals(1, messages.size());
+ }
+
+ @Test
+ public void testReplayWindowRejectsOldMessage() throws Exception {
+ src.setNext(new ValueCaptureInterceptor());
+ dest.setPrevious(new ValuesCaptureInterceptor());
+ dest.setReplayWindowTime(50);
+ src.start(Channel.SND_TX_SEQ);
+ dest.start(Channel.SND_TX_SEQ);
+
+ long now = System.currentTimeMillis();
+ byte[] encryptedRecent = encrypt("msg-recent", now);
+ byte[] encryptedOld = encrypt("msg-old", now - 1000);
+
+ deliver(encryptedRecent);
+ deliver(encryptedOld);
+
+ Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
+ Assert.assertEquals(1, messages.size());
+ }
+
+ @Test
+ public void testReplayWindowRejectsOldMessageAfterCountEviction() throws Exception {
+ src.setNext(new ValueCaptureInterceptor());
+ dest.setPrevious(new ValuesCaptureInterceptor());
+ dest.setReplayWindowMessageCount(2);
+ src.start(Channel.SND_TX_SEQ);
+ dest.start(Channel.SND_TX_SEQ);
+
+ long now = System.currentTimeMillis();
+ byte[] encrypted1000 = encrypt("msg-1000", now);
+ byte[] encrypted1001 = encrypt("msg-1001", now + 1);
+ byte[] encrypted1002 = encrypt("msg-1002", now + 2);
+ byte[] encrypted1003 = encrypt("msg-1003", now + 3);
+
+ deliver(encrypted1000);
+ deliver(encrypted1001);
+ deliver(encrypted1002);
+ deliver(encrypted1003);
+ deliver(encrypted1000);
+
+ Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
+ Assert.assertEquals(4, messages.size());
+ }
+
+ @Test
+ public void testReplayWindowEvictsOldestTimestampFirst() throws Exception {
+ src.setNext(new ValueCaptureInterceptor());
+ dest.setPrevious(new ValuesCaptureInterceptor());
+ dest.setReplayWindowMessageCount(2);
+ src.start(Channel.SND_TX_SEQ);
+ dest.start(Channel.SND_TX_SEQ);
+
+ long now = System.currentTimeMillis();
+ byte[] encrypted300 = encrypt("msg-300", now + 300);
+ byte[] encrypted100 = encrypt("msg-100", now + 100);
+ byte[] encrypted200 = encrypt("msg-200", now + 200);
+ byte[] encrypted225 = encrypt("msg-225", now + 225);
+ byte[] encrypted250 = encrypt("msg-250", now + 250);
+
+ deliver(encrypted300);
+ deliver(encrypted100);
+ deliver(encrypted200);
+ deliver(encrypted225);
+ deliver(encrypted250);
+
+ Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
+ Assert.assertEquals(5, messages.size());
+ }
+
@Test
public void testPickup() throws Exception {
File file = new File(MESSAGE_FILE);
@@ -316,4 +400,20 @@ public class TestEncryptInterceptor extends EncryptionInterceptorBaseTest {
Assert.fail("EncryptionInterceptor should throw ChannelConfigException, not " + t.getClass().getName());
}
}
+
+ private byte[] encrypt(String message, long timestamp) throws Exception {
+ ChannelData msg = new ChannelData(false);
+ msg.setTimestamp(timestamp);
+ msg.setMessage(new XByteBuffer(message.getBytes("UTF-8"), false));
+ src.sendMessage(null, msg, null);
+ return ((ValueCaptureInterceptor) src.getNext()).getValue();
+ }
+
+ private void deliver(byte[] encrypted) {
+ ChannelData incoming = new ChannelData(false);
+ XByteBuffer xbb = new XByteBuffer(encrypted.length, false);
+ xbb.append(encrypted, 0, encrypted.length);
+ incoming.setMessage(xbb);
+ dest.messageReceived(incoming);
+ }
}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 26d01987ed..e9dd0bdf10 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -602,6 +602,10 @@
Fix concurrency issues generating MD5 digests in the
<code>CloudMembershipProvider</code> implementations. (markt)
</fix>
+ <add>
+ Add replay protection to the <code>EncryptInterceptor</code>. This us a
+ breaking change for the <code>EncryptInterceptor</code>.(markt)
+ </add>
</changelog>
</subsection>
<subsection name="WebSocket">
diff --git a/webapps/docs/config/cluster-interceptor.xml b/webapps/docs/config/cluster-interceptor.xml
index d0f5f88537..9b26c8ed73 100644
--- a/webapps/docs/config/cluster-interceptor.xml
+++ b/webapps/docs/config/cluster-interceptor.xml
@@ -212,6 +212,12 @@
<i>before</i> the <code>TcpFailureDetector</code> on the sender and <i>after</i>
it on the receiver, otherwise message corruption will occur.
</p>
+ <p>
+ The EncryptInterceptor uses the timestamp of the message to provide replay
+ protection. For this to be effective, clock skew between cluster nodes
+ should be minimised. If not, it is likely a high proportion of valid
+ messages will be rejected.
+ </p>
<attributes>
<attribute name="encryptionAlgorithm" required="false">
<p>The encryption algorithm to be used, including the mode and padding.
@@ -231,11 +237,30 @@
<p>The default algorithm is <code>AES/GCM/NoPadding</code>.</p>
</attribute>
<attribute name="encryptionKey" required="true">
- The key to be used with the encryption algorithm.
+ <p>The key to be used with the encryption algorithm.</p>
- The key should be specified as hex-encoded bytes of the appropriate
+ <p>The key should be specified as hex-encoded bytes of the appropriate
length for the algorithm (e.g. 16 bytes / 32 characters / 128 bits for
- AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).
+ AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).</p>
+ </attribute>
+ <attribute name="replayWindowTime" required="false">
+ <p>Messages with a timestamp before the current time less this window
+ will be rejected. This needs to account for clock skew across the cluster
+ as well as the expected maximum delay between messages being sent and
+ received. Specified in milliseconds. If not specified, the default value
+ of 10000 (10 seconds) will be used.</p>
+ </attribute>
+ <attribute name="replayWindowMessageCount" required="false">
+ <p>The number of past messages for which the nonces will be tracked to
+ prevent replay attacks. Messages with a nonce that has already been
+ tracked will be rejected. If not specified, the default value of 8192
+ will be used.</p>
+
+ <p>If messages are received at a high rate, it is possible that a nonce
+ will be removed from the list of tracked nonces before
+ <code>replayWindowTime</code> milliseconds have elapsed. If that happens,
+ any message with a timestamp older than the last nonce evicted from the
+ list of tracked nonces will also be rejected.</p>
</attribute>
</attributes>
</subsection>
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org
Re: (tomcat) branch main updated: Second version of replay protection.
Posted by Mark Thomas <ma...@apache.org>.
On 17/06/2026 18:33, Rémy Maucherat wrote:
> On Wed, Jun 17, 2026 at 7:12 PM <ma...@apache.org> wrote:
>>
>> This is an automated email from the ASF dual-hosted git repository.
>>
>> markt-asf pushed a commit to branch main
>> in repository https://gitbox.apache.org/repos/asf/tomcat.git
>>
>>
>> The following commit(s) were added to refs/heads/main by this push:
>> new 24f501c698 Second version of replay protection.
>> 24f501c698 is described below
>>
>> commit 24f501c698499ea5d64c406fd610fc5dcbb2904c
>> Author: Mark Thomas <ma...@apache.org>
>> AuthorDate: Wed Jun 17 18:11:56 2026 +0100
>>
>> Second version of replay protection.
>>
>> Co-authored with GPT
>
> The review is happy about this patch, except for the trustedTimstamp
> type on line 169.
Thanks I'd missed that. (So did both AI reviews)
> But then it was happy about the previous iteration too, so ...
I'm happy with what we have now. The defaults might need tweaking but
they are configurable. It is a breaking change but there is no easy way
around that (short of a complete re-design of the cluster message format).
Mark
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org
Re: (tomcat) branch main updated: Second version of replay protection.
Posted by Rémy Maucherat <re...@apache.org>.
On Wed, Jun 17, 2026 at 7:12 PM <ma...@apache.org> wrote:
>
> This is an automated email from the ASF dual-hosted git repository.
>
> markt-asf pushed a commit to branch main
> in repository https://gitbox.apache.org/repos/asf/tomcat.git
>
>
> The following commit(s) were added to refs/heads/main by this push:
> new 24f501c698 Second version of replay protection.
> 24f501c698 is described below
>
> commit 24f501c698499ea5d64c406fd610fc5dcbb2904c
> Author: Mark Thomas <ma...@apache.org>
> AuthorDate: Wed Jun 17 18:11:56 2026 +0100
>
> Second version of replay protection.
>
> Co-authored with GPT
The review is happy about this patch, except for the trustedTimstamp
type on line 169.
But then it was happy about the previous iteration too, so ...
Rémy
> ---
> .../group/interceptors/EncryptInterceptor.java | 197 +++++++++++++++++++--
> .../interceptors/EncryptInterceptorMBean.java | 28 +++
> .../group/interceptors/LocalStrings.properties | 3 +
> .../group/interceptors/TestEncryptInterceptor.java | 100 +++++++++++
> webapps/docs/changelog.xml | 4 +
> webapps/docs/config/cluster-interceptor.xml | 31 +++-
> 6 files changed, 345 insertions(+), 18 deletions(-)
>
> diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
> index 5cc9359a5d..0616ab03c0 100644
> --- a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
> +++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
> @@ -22,7 +22,11 @@ import java.security.NoSuchAlgorithmException;
> import java.security.NoSuchProviderException;
> import java.security.SecureRandom;
> import java.security.spec.AlgorithmParameterSpec;
> +import java.util.ArrayDeque;
> +import java.util.HashMap;
> import java.util.Locale;
> +import java.util.Map;
> +import java.util.TreeMap;
> import java.util.concurrent.ConcurrentLinkedQueue;
>
> import javax.crypto.Cipher;
> @@ -36,6 +40,7 @@ import org.apache.catalina.tribes.ChannelException;
> import org.apache.catalina.tribes.ChannelInterceptor;
> import org.apache.catalina.tribes.ChannelMessage;
> import org.apache.catalina.tribes.Member;
> +import org.apache.catalina.tribes.UniqueId;
> import org.apache.catalina.tribes.group.ChannelInterceptorBase;
> import org.apache.catalina.tribes.group.InterceptorPayload;
> import org.apache.catalina.tribes.io.XByteBuffer;
> @@ -63,6 +68,9 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> private String encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGORITHM;
> private byte[] encryptionKeyBytes;
> private String encryptionKeyString;
> + // Milliseconds
> + private long replayWindowTime = 10_000;
> + private int replayWindowMessageCount = 8192;
>
>
> private BaseEncryptionManager encryptionManager;
> @@ -80,7 +88,7 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> if (Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) {
> try {
> encryptionManager = createEncryptionManager(getEncryptionAlgorithm(), getEncryptionKeyInternal(),
> - getProviderName());
> + getProviderName(), getReplayWindowTime(), getReplayWindowMessageCount());
> } catch (GeneralSecurityException gse) {
> throw new ChannelException(sm.getString("encryptInterceptor.init.failed"), gse);
> }
> @@ -114,9 +122,18 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> throws ChannelException {
> try {
> byte[] data = msg.getMessage().getBytes();
> + // Need trusted time stamp on receiving side, so add time stamp to encrypted data.
> + long timestamp = msg.getTimestamp();
> + if (timestamp <= 0) {
> + timestamp = System.currentTimeMillis();
> + msg.setTimestamp(timestamp);
> + }
> + byte[] message = new byte[data.length + 8];
> + XByteBuffer.toBytes(timestamp, message, 0);
> + System.arraycopy(data, 0, message, 8, data.length);
>
> // See #encrypt(byte[]) for an explanation of the return value
> - byte[][] bytes = encryptionManager.encrypt(data);
> + byte[][] bytes = encryptionManager.encrypt(message);
>
> XByteBuffer xbb = msg.getMessage();
>
> @@ -137,14 +154,32 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> public void messageReceived(ChannelMessage msg) {
> try {
> byte[] data = msg.getMessage().getBytes();
> + byte[] encryptedData = data;
>
> data = encryptionManager.decrypt(data);
> + if (data.length < 8) {
> + throw new GeneralSecurityException(sm.getString("encryptInterceptor.decrypt.error.short-message"));
> + }
> + /*
> + * This is trusted since it was encrypted.
> + *
> + * Excessive clock skew will cause problems here. Can't address that without creating risks of replay
> + * attacks.
> + */
> + long trustedTimstamp = XByteBuffer.toLong(data, 0);
> + if (!encryptionManager.checkIncomingMessage(encryptedData, trustedTimstamp)) {
> + log.error(sm.getString("encryptInterceptor.decrypt.replay"));
> + return;
> + }
>
> XByteBuffer xbb = msg.getMessage();
>
> - // Completely replace the message with the decrypted one
> + /*
> + * Completely replace the message with the decrypted one. No need to replace time stamp. At this point it
> + * will be the same as the trusted time stamp.
> + */
> xbb.clear();
> - xbb.append(data, 0, data.length);
> + xbb.append(data, 8, data.length - 8);
>
> super.messageReceived(msg);
> } catch (GeneralSecurityException gse) {
> @@ -274,6 +309,58 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> return providerName;
> }
>
> + /**
> + * Returns the time-based replay window in milliseconds.
> + *
> + * @return The replay window time
> + */
> + @Override
> + public long getReplayWindowTime() {
> + return replayWindowTime;
> + }
> +
> + /**
> + * Sets the time-based replay window in milliseconds.
> + *
> + * @param replayWindowTime The replay window time
> + */
> + @Override
> + public void setReplayWindowTime(long replayWindowTime) {
> + if (replayWindowTime < 1) {
> + throw new IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowTime.tooSmall"));
> + }
> + this.replayWindowTime = replayWindowTime;
> + if (encryptionManager != null) {
> + encryptionManager.setReplayWindowTime(replayWindowTime);
> + }
> + }
> +
> + /**
> + * Returns the maximum number of replay entries to retain.
> + *
> + * @return The replay window message count
> + */
> + @Override
> + public int getReplayWindowMessageCount() {
> + return replayWindowMessageCount;
> + }
> +
> + /**
> + * Sets the maximum number of replay entries to retain.
> + *
> + * @param replayWindowMessageCount The replay window message count
> + */
> + @Override
> + public void setReplayWindowMessageCount(int replayWindowMessageCount) {
> + if (replayWindowMessageCount < 1) {
> + throw new IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowMessageCount.tooSmall"));
> + }
> + this.replayWindowMessageCount = replayWindowMessageCount;
> + if (encryptionManager != null) {
> + encryptionManager.setReplayWindowMessageCount(replayWindowMessageCount);
> + }
> + }
> +
> // Copied from org.apache.tomcat.util.buf.HexUtils
> // @formatter:off
> private static final int[] DEC = {
> @@ -320,7 +407,8 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> }
>
> private static BaseEncryptionManager createEncryptionManager(String algorithm, byte[] encryptionKey,
> - String providerName) throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> + String providerName, long replayWindowTime, int replayWindowMessageCount)
> + throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> if (null == encryptionKey) {
> throw new IllegalStateException(sm.getString("encryptInterceptor.key.required"));
> }
> @@ -359,8 +447,7 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> */
> if ("NONE".equals(algorithmMode) || "ECB".equals(algorithmMode) || "PCBC".equals(algorithmMode) ||
> "CTS".equals(algorithmMode) || "KW".equals(algorithmMode) || "KWP".equals(algorithmMode) ||
> - "CTR".equals(algorithmMode) ||
> - ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
> + "CTR".equals(algorithmMode) || ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
> ("CFB".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
> ("GCM".equals(algorithmMode) && "PKCS5PADDING".equals(algorithmPadding)) ||
> ("OFB".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding))) {
> @@ -375,17 +462,18 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
>
> } else if (algorithmMode.startsWith("CFB") || algorithmMode.startsWith("OFB")) {
> // Using a non-default block size. Not supported as insecure and/or inefficient.
> - throw new IllegalArgumentException(
> - sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
> + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>
> } else if ("GCM".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) {
> // Needs a specialised encryption manager to handle the differences between GCM and other modes
> - return new GCMEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName);
> + return new GCMEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName,
> + replayWindowTime, replayWindowMessageCount);
> }
>
> // Use the default encryption manager
> try {
> - return new BaseEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName);
> + return new BaseEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName,
> + replayWindowTime, replayWindowMessageCount);
> } catch (NoSuchAlgorithmException | NoSuchPaddingException | NoSuchProviderException ex) {
> throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm), ex);
> }
> @@ -423,12 +511,22 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> * SecureRandom is thread-safe, but sharing a single instance will likely be a bottleneck.
> */
> private final ConcurrentLinkedQueue<SecureRandom> randomPool;
> -
> - BaseEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName)
> + private final TreeMap<Long,ArrayDeque<UniqueId>> receivedTimestampNonces = new TreeMap<>();
> + private final Map<UniqueId,Long> receivedNonceTimestamps = new HashMap<>();
> + private long replayWindowTime;
> + private volatile int replayWindowMessageCount;
> + private long lastRemovedTimestamp;
> + private int receivedNonceCount = 0;
> +
> + BaseEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName, long replayWindowTime,
> + int replayWindowMessageCount)
> throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> this.algorithm = algorithm;
> this.providerName = providerName;
> this.secretKey = secretKey;
> + this.replayWindowTime = replayWindowTime;
> + this.lastRemovedTimestamp = System.currentTimeMillis() - replayWindowTime;
> + this.replayWindowMessageCount = replayWindowMessageCount;
>
> cipherPool = new ConcurrentLinkedQueue<>();
> Cipher cipher = createCipher();
> @@ -441,6 +539,52 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> // Individual Cipher and SecureRandom objects need no explicit tear down
> cipherPool.clear();
> randomPool.clear();
> + synchronized (this) {
> + receivedTimestampNonces.clear();
> + receivedNonceTimestamps.clear();
> + lastRemovedTimestamp = Long.MIN_VALUE;
> + receivedNonceCount = 0;
> + }
> + }
> +
> + public synchronized void setReplayWindowTime(long replayWindowTime) {
> + this.replayWindowTime = replayWindowTime;
> + // Only move the lastRemovedTimestamp forwards. Moving it backwards could open a window for replay attacks.
> + if (lastRemovedTimestamp < System.currentTimeMillis() - replayWindowTime) {
> + lastRemovedTimestamp = System.currentTimeMillis() - replayWindowTime;
> + }
> + }
> +
> + public void setReplayWindowMessageCount(int replayWindowMessageCount) {
> + this.replayWindowMessageCount = replayWindowMessageCount;
> + synchronized (this) {
> + while (receivedNonceCount > replayWindowMessageCount) {
> + removeEldestEntry();
> + }
> + }
> + }
> +
> + public synchronized boolean checkIncomingMessage(byte[] bytes, long messageTimestamp) {
> + if (messageTimestamp < (System.currentTimeMillis() - replayWindowTime)) {
> + return false;
> + }
> + if (messageTimestamp <= lastRemovedTimestamp) {
> + return false;
> + }
> +
> + UniqueId nonce = new UniqueId(bytes, 0, getIVSize());
> + if (receivedNonceTimestamps.containsKey(nonce)) {
> + return false;
> + }
> +
> + receivedTimestampNonces.computeIfAbsent(Long.valueOf(messageTimestamp), k -> new ArrayDeque<>()).addLast(nonce);
> + receivedNonceTimestamps.put(nonce, Long.valueOf(messageTimestamp));
> + receivedNonceCount++;
> + while (receivedNonceCount > replayWindowMessageCount) {
> + removeEldestEntry();
> + }
> +
> + return true;
> }
>
> private String getAlgorithm() {
> @@ -593,6 +737,28 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> protected AlgorithmParameterSpec generateIV(byte[] ivBytes, int offset, int length) {
> return new IvParameterSpec(ivBytes, offset, length);
> }
> +
> + private void removeEldestEntry() {
> + Map.Entry<Long,ArrayDeque<UniqueId>> entry = receivedTimestampNonces.firstEntry();
> + if (entry != null) {
> + ArrayDeque<UniqueId> nonces = entry.getValue();
> + UniqueId nonce = nonces.pollFirst();
> + if (nonce != null) {
> + receivedNonceTimestamps.remove(nonce);
> + updateLastRemovedTimestamp(entry.getKey().longValue());
> + receivedNonceCount--;
> + }
> + if (nonces.isEmpty()) {
> + receivedTimestampNonces.pollFirstEntry();
> + }
> + }
> + }
> +
> + private void updateLastRemovedTimestamp(long removedTimestamp) {
> + if (removedTimestamp > lastRemovedTimestamp) {
> + lastRemovedTimestamp = removedTimestamp;
> + }
> + }
> }
>
> /**
> @@ -611,9 +777,10 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> * number of bits supported 128-bit provide the best security.
> */
> private static class GCMEncryptionManager extends BaseEncryptionManager {
> - GCMEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName)
> + GCMEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName, long replayWindowTime,
> + int replayWindowMessageCount)
> throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> - super(algorithm, secretKey, providerName);
> + super(algorithm, secretKey, providerName, replayWindowTime, replayWindowMessageCount);
> }
>
> @Override
> diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
> index 7d10a1f4f1..c6362b26c5 100644
> --- a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
> +++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
> @@ -76,4 +76,32 @@ public interface EncryptInterceptorMBean {
> * @return the JCA provider name, or {@code null} for default
> */
> String getProviderName();
> +
> + /**
> + * Sets the time-based replay window in milliseconds.
> + *
> + * @param replayWindowTime the replay window time
> + */
> + void setReplayWindowTime(long replayWindowTime);
> +
> + /**
> + * Returns the time-based replay window in milliseconds.
> + *
> + * @return the replay window time
> + */
> + long getReplayWindowTime();
> +
> + /**
> + * Sets the maximum number of replay cache entries.
> + *
> + * @param replayWindowMessageCount the replay window message count
> + */
> + void setReplayWindowMessageCount(int replayWindowMessageCount);
> +
> + /**
> + * Returns the maximum number of replay cache entries.
> + *
> + * @return the replay window message count
> + */
> + int getReplayWindowMessageCount();
> }
> diff --git a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
> index a8e3c5d6e5..5c13178485 100644
> --- a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
> +++ b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
> @@ -21,9 +21,12 @@ encryptInterceptor.algorithm.switch=The EncryptInterceptor is using the algorith
> encryptInterceptor.algorithm.unsupported=EncryptInterceptor does not support algorithm [{0}]
> encryptInterceptor.decrypt.error.short-message=Failed to decrypt message: premature end-of-message
> encryptInterceptor.decrypt.failed=Failed to decrypt message
> +encryptInterceptor.decrypt.replay=Failed to decrypt message: replay attack detected
> encryptInterceptor.encrypt.failed=Failed to encrypt message
> encryptInterceptor.init.failed=Failed to initialize EncryptInterceptor
> encryptInterceptor.key.required=Encryption key is required
> +encryptInterceptor.replayWindowMessageCount.tooSmall=Replay window message count must be at least 1
> +encryptInterceptor.replayWindowTime.tooSmall=Replay window time must be at least 1 millisecond
> encryptInterceptor.tcpFailureDetector.ordering=EncryptInterceptor must be upstream of TcpFailureDetector. Please re-order EncryptInterceptor to be listed before TcpFailureDetector in your channel interceptor pipeline.
>
> fragmentationInterceptor.fragments.missing=Fragments are missing.
> diff --git a/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
> index e30e84321f..8c6785c4a8 100644
> --- a/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
> +++ b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
> @@ -216,6 +216,90 @@ public class TestEncryptInterceptor extends EncryptionInterceptorBaseTest {
> cipherText1, IsNot.not(IsEqual.equalTo(cipherText2)));
> }
>
> + @Test
> + public void testRejectReplay() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + byte[] encrypted = encrypt("msg-1", System.currentTimeMillis());
> +
> + deliver(encrypted);
> + deliver(encrypted);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(1, messages.size());
> + }
> +
> + @Test
> + public void testReplayWindowRejectsOldMessage() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + dest.setReplayWindowTime(50);
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + long now = System.currentTimeMillis();
> + byte[] encryptedRecent = encrypt("msg-recent", now);
> + byte[] encryptedOld = encrypt("msg-old", now - 1000);
> +
> + deliver(encryptedRecent);
> + deliver(encryptedOld);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(1, messages.size());
> + }
> +
> + @Test
> + public void testReplayWindowRejectsOldMessageAfterCountEviction() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + dest.setReplayWindowMessageCount(2);
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + long now = System.currentTimeMillis();
> + byte[] encrypted1000 = encrypt("msg-1000", now);
> + byte[] encrypted1001 = encrypt("msg-1001", now + 1);
> + byte[] encrypted1002 = encrypt("msg-1002", now + 2);
> + byte[] encrypted1003 = encrypt("msg-1003", now + 3);
> +
> + deliver(encrypted1000);
> + deliver(encrypted1001);
> + deliver(encrypted1002);
> + deliver(encrypted1003);
> + deliver(encrypted1000);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(4, messages.size());
> + }
> +
> + @Test
> + public void testReplayWindowEvictsOldestTimestampFirst() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + dest.setReplayWindowMessageCount(2);
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + long now = System.currentTimeMillis();
> + byte[] encrypted300 = encrypt("msg-300", now + 300);
> + byte[] encrypted100 = encrypt("msg-100", now + 100);
> + byte[] encrypted200 = encrypt("msg-200", now + 200);
> + byte[] encrypted225 = encrypt("msg-225", now + 225);
> + byte[] encrypted250 = encrypt("msg-250", now + 250);
> +
> + deliver(encrypted300);
> + deliver(encrypted100);
> + deliver(encrypted200);
> + deliver(encrypted225);
> + deliver(encrypted250);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(5, messages.size());
> + }
> +
> @Test
> public void testPickup() throws Exception {
> File file = new File(MESSAGE_FILE);
> @@ -316,4 +400,20 @@ public class TestEncryptInterceptor extends EncryptionInterceptorBaseTest {
> Assert.fail("EncryptionInterceptor should throw ChannelConfigException, not " + t.getClass().getName());
> }
> }
> +
> + private byte[] encrypt(String message, long timestamp) throws Exception {
> + ChannelData msg = new ChannelData(false);
> + msg.setTimestamp(timestamp);
> + msg.setMessage(new XByteBuffer(message.getBytes("UTF-8"), false));
> + src.sendMessage(null, msg, null);
> + return ((ValueCaptureInterceptor) src.getNext()).getValue();
> + }
> +
> + private void deliver(byte[] encrypted) {
> + ChannelData incoming = new ChannelData(false);
> + XByteBuffer xbb = new XByteBuffer(encrypted.length, false);
> + xbb.append(encrypted, 0, encrypted.length);
> + incoming.setMessage(xbb);
> + dest.messageReceived(incoming);
> + }
> }
> diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
> index 26d01987ed..e9dd0bdf10 100644
> --- a/webapps/docs/changelog.xml
> +++ b/webapps/docs/changelog.xml
> @@ -602,6 +602,10 @@
> Fix concurrency issues generating MD5 digests in the
> <code>CloudMembershipProvider</code> implementations. (markt)
> </fix>
> + <add>
> + Add replay protection to the <code>EncryptInterceptor</code>. This us a
> + breaking change for the <code>EncryptInterceptor</code>.(markt)
> + </add>
> </changelog>
> </subsection>
> <subsection name="WebSocket">
> diff --git a/webapps/docs/config/cluster-interceptor.xml b/webapps/docs/config/cluster-interceptor.xml
> index d0f5f88537..9b26c8ed73 100644
> --- a/webapps/docs/config/cluster-interceptor.xml
> +++ b/webapps/docs/config/cluster-interceptor.xml
> @@ -212,6 +212,12 @@
> <i>before</i> the <code>TcpFailureDetector</code> on the sender and <i>after</i>
> it on the receiver, otherwise message corruption will occur.
> </p>
> + <p>
> + The EncryptInterceptor uses the timestamp of the message to provide replay
> + protection. For this to be effective, clock skew between cluster nodes
> + should be minimised. If not, it is likely a high proportion of valid
> + messages will be rejected.
> + </p>
> <attributes>
> <attribute name="encryptionAlgorithm" required="false">
> <p>The encryption algorithm to be used, including the mode and padding.
> @@ -231,11 +237,30 @@
> <p>The default algorithm is <code>AES/GCM/NoPadding</code>.</p>
> </attribute>
> <attribute name="encryptionKey" required="true">
> - The key to be used with the encryption algorithm.
> + <p>The key to be used with the encryption algorithm.</p>
>
> - The key should be specified as hex-encoded bytes of the appropriate
> + <p>The key should be specified as hex-encoded bytes of the appropriate
> length for the algorithm (e.g. 16 bytes / 32 characters / 128 bits for
> - AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).
> + AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).</p>
> + </attribute>
> + <attribute name="replayWindowTime" required="false">
> + <p>Messages with a timestamp before the current time less this window
> + will be rejected. This needs to account for clock skew across the cluster
> + as well as the expected maximum delay between messages being sent and
> + received. Specified in milliseconds. If not specified, the default value
> + of 10000 (10 seconds) will be used.</p>
> + </attribute>
> + <attribute name="replayWindowMessageCount" required="false">
> + <p>The number of past messages for which the nonces will be tracked to
> + prevent replay attacks. Messages with a nonce that has already been
> + tracked will be rejected. If not specified, the default value of 8192
> + will be used.</p>
> +
> + <p>If messages are received at a high rate, it is possible that a nonce
> + will be removed from the list of tracked nonces before
> + <code>replayWindowTime</code> milliseconds have elapsed. If that happens,
> + any message with a timestamp older than the last nonce evicted from the
> + list of tracked nonces will also be rejected.</p>
> </attribute>
> </attributes>
> </subsection>
>
>
> ---------------------------------------------------------------------
> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
> For additional commands, e-mail: dev-help@tomcat.apache.org
>
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org
Re: (tomcat) branch main updated: Second version of replay protection.
Posted by Christopher Schultz <ch...@christopherschultz.net>.
Mark,
On 6/17/26 4:44 PM, Mark Thomas wrote:
> On 17/06/2026 21:38, Christopher Schultz wrote:
>> Mark,
>>
>> On 6/17/26 4:23 PM, Mark Thomas wrote:
>>> On 17/06/2026 21:01, Christopher Schultz wrote:
>>>> Mark,
>>>>
>>>> Since this is a breaking change, I think we need something in here:
>>>> https://tomcat.apache.org/
>>>> migration-11.0.html#Tomcat_11.0.x_noteable_changes
>>>>
>>>> I can draft this if you'd like.
>>>
>>> Thanks. PLease go ahead.
>>
>> How's this? (apologies for the formatting).
>
> +1. And I guess we'll need somethign similar for 10.1.x and 9.0.x
Yes, I will adapt.
-chris
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org
Re: (tomcat) branch main updated: Second version of replay protection.
Posted by Mark Thomas <ma...@apache.org>.
On 17/06/2026 21:38, Christopher Schultz wrote:
> Mark,
>
> On 6/17/26 4:23 PM, Mark Thomas wrote:
>> On 17/06/2026 21:01, Christopher Schultz wrote:
>>> Mark,
>>>
>>> Since this is a breaking change, I think we need something in here:
>>> https://tomcat.apache.org/
>>> migration-11.0.html#Tomcat_11.0.x_noteable_changes
>>>
>>> I can draft this if you'd like.
>>
>> Thanks. PLease go ahead.
>
> How's this? (apologies for the formatting).
+1. And I guess we'll need somethign similar for 10.1.x and 9.0.x
Mark
>
> "
> <li>
> Tomcat 11.0.23 introduces a <b>breaking change</b> for users of
> the
> <code>EncryptInterceptor</code> in their clustering configuration.
> Migration from prior-to-Tomcat-11.0.23 to after-Tomcat-11.0.23
> will require that <i>all</i> cluster nodes are stopped and
> restarted
> to perform the migration. There is an additional, required,
> piece of
> data that is being introduced into the cluster data stream for
> <code>EncryptInterceptor</code> and nodes running any older
> versions
> will fail to send or receive cluster messages.
> </li>
> "
>
> Thanks,
> -chris
>
>>
>> Mark
>>
>>>
>>> -chris
>>>
>>> On 6/17/26 1:12 PM, markt@apache.org wrote:
>>>> This is an automated email from the ASF dual-hosted git repository.
>>>>
>>>> markt-asf pushed a commit to branch main
>>>> in repository https://gitbox.apache.org/repos/asf/tomcat.git
>>>>
>>>>
>>>> The following commit(s) were added to refs/heads/main by this push:
>>>> new 24f501c698 Second version of replay protection.
>>>> 24f501c698 is described below
>>>>
>>>> commit 24f501c698499ea5d64c406fd610fc5dcbb2904c
>>>> Author: Mark Thomas <ma...@apache.org>
>>>> AuthorDate: Wed Jun 17 18:11:56 2026 +0100
>>>>
>>>> Second version of replay protection.
>>>> Co-authored with GPT
>>>> ---
>>>> .../group/interceptors/EncryptInterceptor.java | 197 +++++++++
>>>> + + ++++++++--
>>>> .../interceptors/EncryptInterceptorMBean.java | 28 +++
>>>> .../group/interceptors/LocalStrings.properties | 3 +
>>>> .../group/interceptors/TestEncryptInterceptor.java | 100 +++++++++++
>>>> webapps/docs/changelog.xml | 4 +
>>>> webapps/docs/config/cluster-interceptor.xml | 31 +++-
>>>> 6 files changed, 345 insertions(+), 18 deletions(-)
>>>>
>>>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>>>> EncryptInterceptor.java b/java/org/apache/catalina/tribes/group/
>>>> interceptors/EncryptInterceptor.java
>>>> index 5cc9359a5d..0616ab03c0 100644
>>>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>>>> EncryptInterceptor.java
>>>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>>>> EncryptInterceptor.java
>>>> @@ -22,7 +22,11 @@ import java.security.NoSuchAlgorithmException;
>>>> import java.security.NoSuchProviderException;
>>>> import java.security.SecureRandom;
>>>> import java.security.spec.AlgorithmParameterSpec;
>>>> +import java.util.ArrayDeque;
>>>> +import java.util.HashMap;
>>>> import java.util.Locale;
>>>> +import java.util.Map;
>>>> +import java.util.TreeMap;
>>>> import java.util.concurrent.ConcurrentLinkedQueue;
>>>> import javax.crypto.Cipher;
>>>> @@ -36,6 +40,7 @@ import org.apache.catalina.tribes.ChannelException;
>>>> import org.apache.catalina.tribes.ChannelInterceptor;
>>>> import org.apache.catalina.tribes.ChannelMessage;
>>>> import org.apache.catalina.tribes.Member;
>>>> +import org.apache.catalina.tribes.UniqueId;
>>>> import org.apache.catalina.tribes.group.ChannelInterceptorBase;
>>>> import org.apache.catalina.tribes.group.InterceptorPayload;
>>>> import org.apache.catalina.tribes.io.XByteBuffer;
>>>> @@ -63,6 +68,9 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> private String encryptionAlgorithm =
>>>> DEFAULT_ENCRYPTION_ALGORITHM;
>>>> private byte[] encryptionKeyBytes;
>>>> private String encryptionKeyString;
>>>> + // Milliseconds
>>>> + private long replayWindowTime = 10_000;
>>>> + private int replayWindowMessageCount = 8192;
>>>> private BaseEncryptionManager encryptionManager;
>>>> @@ -80,7 +88,7 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> if (Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) {
>>>> try {
>>>> encryptionManager =
>>>> createEncryptionManager(getEncryptionAlgorithm(),
>>>> getEncryptionKeyInternal(),
>>>> - getProviderName());
>>>> + getProviderName(), getReplayWindowTime(),
>>>> getReplayWindowMessageCount());
>>>> } catch (GeneralSecurityException gse) {
>>>> throw new
>>>> ChannelException(sm.getString("encryptInterceptor.init.failed"), gse);
>>>> }
>>>> @@ -114,9 +122,18 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> throws ChannelException {
>>>> try {
>>>> byte[] data = msg.getMessage().getBytes();
>>>> + // Need trusted time stamp on receiving side, so add
>>>> time stamp to encrypted data.
>>>> + long timestamp = msg.getTimestamp();
>>>> + if (timestamp <= 0) {
>>>> + timestamp = System.currentTimeMillis();
>>>> + msg.setTimestamp(timestamp);
>>>> + }
>>>> + byte[] message = new byte[data.length + 8];
>>>> + XByteBuffer.toBytes(timestamp, message, 0);
>>>> + System.arraycopy(data, 0, message, 8, data.length);
>>>> // See #encrypt(byte[]) for an explanation of the
>>>> return value
>>>> - byte[][] bytes = encryptionManager.encrypt(data);
>>>> + byte[][] bytes = encryptionManager.encrypt(message);
>>>> XByteBuffer xbb = msg.getMessage();
>>>> @@ -137,14 +154,32 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> public void messageReceived(ChannelMessage msg) {
>>>> try {
>>>> byte[] data = msg.getMessage().getBytes();
>>>> + byte[] encryptedData = data;
>>>> data = encryptionManager.decrypt(data);
>>>> + if (data.length < 8) {
>>>> + throw new
>>>> GeneralSecurityException(sm.getString("encryptInterceptor.decrypt.error.short-message"));
>>>> + }
>>>> + /*
>>>> + * This is trusted since it was encrypted.
>>>> + *
>>>> + * Excessive clock skew will cause problems here.
>>>> Can't address that without creating risks of replay
>>>> + * attacks.
>>>> + */
>>>> + long trustedTimstamp = XByteBuffer.toLong(data, 0);
>>>> + if (!
>>>> encryptionManager.checkIncomingMessage(encryptedData,
>>>> trustedTimstamp)) {
>>>> + log.error(sm.getString("encryptInterceptor.decrypt.replay"));
>>>> + return;
>>>> + }
>>>> XByteBuffer xbb = msg.getMessage();
>>>> - // Completely replace the message with the decrypted one
>>>> + /*
>>>> + * Completely replace the message with the decrypted
>>>> one. No need to replace time stamp. At this point it
>>>> + * will be the same as the trusted time stamp.
>>>> + */
>>>> xbb.clear();
>>>> - xbb.append(data, 0, data.length);
>>>> + xbb.append(data, 8, data.length - 8);
>>>> super.messageReceived(msg);
>>>> } catch (GeneralSecurityException gse) {
>>>> @@ -274,6 +309,58 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> return providerName;
>>>> }
>>>> + /**
>>>> + * Returns the time-based replay window in milliseconds.
>>>> + *
>>>> + * @return The replay window time
>>>> + */
>>>> + @Override
>>>> + public long getReplayWindowTime() {
>>>> + return replayWindowTime;
>>>> + }
>>>> +
>>>> + /**
>>>> + * Sets the time-based replay window in milliseconds.
>>>> + *
>>>> + * @param replayWindowTime The replay window time
>>>> + */
>>>> + @Override
>>>> + public void setReplayWindowTime(long replayWindowTime) {
>>>> + if (replayWindowTime < 1) {
>>>> + throw new
>>>> IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowTime.tooSmall"));
>>>> + }
>>>> + this.replayWindowTime = replayWindowTime;
>>>> + if (encryptionManager != null) {
>>>> + encryptionManager.setReplayWindowTime(replayWindowTime);
>>>> + }
>>>> + }
>>>> +
>>>> + /**
>>>> + * Returns the maximum number of replay entries to retain.
>>>> + *
>>>> + * @return The replay window message count
>>>> + */
>>>> + @Override
>>>> + public int getReplayWindowMessageCount() {
>>>> + return replayWindowMessageCount;
>>>> + }
>>>> +
>>>> + /**
>>>> + * Sets the maximum number of replay entries to retain.
>>>> + *
>>>> + * @param replayWindowMessageCount The replay window message count
>>>> + */
>>>> + @Override
>>>> + public void setReplayWindowMessageCount(int
>>>> replayWindowMessageCount) {
>>>> + if (replayWindowMessageCount < 1) {
>>>> + throw new
>>>> IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowMessageCount.tooSmall"));
>>>> + }
>>>> + this.replayWindowMessageCount = replayWindowMessageCount;
>>>> + if (encryptionManager != null) {
>>>> +
>>>> encryptionManager.setReplayWindowMessageCount(replayWindowMessageCount);
>>>> + }
>>>> + }
>>>> +
>>>> // Copied from org.apache.tomcat.util.buf.HexUtils
>>>> // @formatter:off
>>>> private static final int[] DEC = {
>>>> @@ -320,7 +407,8 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> }
>>>> private static BaseEncryptionManager
>>>> createEncryptionManager(String algorithm, byte[] encryptionKey,
>>>> - String providerName) throws NoSuchAlgorithmException,
>>>> NoSuchPaddingException, NoSuchProviderException {
>>>> + String providerName, long replayWindowTime, int
>>>> replayWindowMessageCount)
>>>> + throws NoSuchAlgorithmException,
>>>> NoSuchPaddingException, NoSuchProviderException {
>>>> if (null == encryptionKey) {
>>>> throw new
>>>> IllegalStateException(sm.getString("encryptInterceptor.key.required"));
>>>> }
>>>> @@ -359,8 +447,7 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> */
>>>> if ("NONE".equals(algorithmMode) ||
>>>> "ECB".equals(algorithmMode) || "PCBC".equals(algorithmMode) ||
>>>> "CTS".equals(algorithmMode) ||
>>>> "KW".equals(algorithmMode) || "KWP".equals(algorithmMode) ||
>>>> - "CTR".equals(algorithmMode) ||
>>>> - ("CBC".equals(algorithmMode) &&
>>>> "NOPADDING".equals(algorithmPadding)) ||
>>>> + "CTR".equals(algorithmMode) ||
>>>> ("CBC".equals(algorithmMode) &&
>>>> "NOPADDING".equals(algorithmPadding)) ||
>>>> ("CFB".equals(algorithmMode) &&
>>>> "NOPADDING".equals(algorithmPadding)) ||
>>>> ("GCM".equals(algorithmMode) &&
>>>> "PKCS5PADDING".equals(algorithmPadding)) ||
>>>> ("OFB".equals(algorithmMode) &&
>>>> "NOPADDING".equals(algorithmPadding))) {
>>>> @@ -375,17 +462,18 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> } else if (algorithmMode.startsWith("CFB") ||
>>>> algorithmMode.startsWith("OFB")) {
>>>> // Using a non-default block size. Not supported as
>>>> insecure and/or inefficient.
>>>> - throw new IllegalArgumentException(
>>>> - sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>>>> + throw new
>>>> IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>>>> } else if ("GCM".equals(algorithmMode) &&
>>>> "NOPADDING".equals(algorithmPadding)) {
>>>> // Needs a specialised encryption manager to handle
>>>> the differences between GCM and other modes
>>>> - return new GCMEncryptionManager(algorithm, new
>>>> SecretKeySpec(encryptionKey, algorithmName), providerName);
>>>> + return new GCMEncryptionManager(algorithm, new
>>>> SecretKeySpec(encryptionKey, algorithmName), providerName,
>>>> + replayWindowTime, replayWindowMessageCount);
>>>> }
>>>> // Use the default encryption manager
>>>> try {
>>>> - return new BaseEncryptionManager(algorithm, new
>>>> SecretKeySpec(encryptionKey, algorithmName), providerName);
>>>> + return new BaseEncryptionManager(algorithm, new
>>>> SecretKeySpec(encryptionKey, algorithmName), providerName,
>>>> + replayWindowTime, replayWindowMessageCount);
>>>> } catch (NoSuchAlgorithmException | NoSuchPaddingException
>>>> | NoSuchProviderException ex) {
>>>> throw new
>>>> IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm), ex);
>>>> }
>>>> @@ -423,12 +511,22 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> * SecureRandom is thread-safe, but sharing a single
>>>> instance will likely be a bottleneck.
>>>> */
>>>> private final ConcurrentLinkedQueue<SecureRandom> randomPool;
>>>> -
>>>> - BaseEncryptionManager(String algorithm, SecretKeySpec
>>>> secretKey, String providerName)
>>>> + private final TreeMap<Long,ArrayDeque<UniqueId>>
>>>> receivedTimestampNonces = new TreeMap<>();
>>>> + private final Map<UniqueId,Long> receivedNonceTimestamps =
>>>> new HashMap<>();
>>>> + private long replayWindowTime;
>>>> + private volatile int replayWindowMessageCount;
>>>> + private long lastRemovedTimestamp;
>>>> + private int receivedNonceCount = 0;
>>>> +
>>>> + BaseEncryptionManager(String algorithm, SecretKeySpec
>>>> secretKey, String providerName, long replayWindowTime,
>>>> + int replayWindowMessageCount)
>>>> throws NoSuchAlgorithmException,
>>>> NoSuchPaddingException, NoSuchProviderException {
>>>> this.algorithm = algorithm;
>>>> this.providerName = providerName;
>>>> this.secretKey = secretKey;
>>>> + this.replayWindowTime = replayWindowTime;
>>>> + this.lastRemovedTimestamp = System.currentTimeMillis()
>>>> - replayWindowTime;
>>>> + this.replayWindowMessageCount = replayWindowMessageCount;
>>>> cipherPool = new ConcurrentLinkedQueue<>();
>>>> Cipher cipher = createCipher();
>>>> @@ -441,6 +539,52 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> // Individual Cipher and SecureRandom objects need no
>>>> explicit tear down
>>>> cipherPool.clear();
>>>> randomPool.clear();
>>>> + synchronized (this) {
>>>> + receivedTimestampNonces.clear();
>>>> + receivedNonceTimestamps.clear();
>>>> + lastRemovedTimestamp = Long.MIN_VALUE;
>>>> + receivedNonceCount = 0;
>>>> + }
>>>> + }
>>>> +
>>>> + public synchronized void setReplayWindowTime(long
>>>> replayWindowTime) {
>>>> + this.replayWindowTime = replayWindowTime;
>>>> + // Only move the lastRemovedTimestamp forwards. Moving
>>>> it backwards could open a window for replay attacks.
>>>> + if (lastRemovedTimestamp < System.currentTimeMillis() -
>>>> replayWindowTime) {
>>>> + lastRemovedTimestamp = System.currentTimeMillis() -
>>>> replayWindowTime;
>>>> + }
>>>> + }
>>>> +
>>>> + public void setReplayWindowMessageCount(int
>>>> replayWindowMessageCount) {
>>>> + this.replayWindowMessageCount = replayWindowMessageCount;
>>>> + synchronized (this) {
>>>> + while (receivedNonceCount >
>>>> replayWindowMessageCount) {
>>>> + removeEldestEntry();
>>>> + }
>>>> + }
>>>> + }
>>>> +
>>>> + public synchronized boolean checkIncomingMessage(byte[]
>>>> bytes, long messageTimestamp) {
>>>> + if (messageTimestamp < (System.currentTimeMillis() -
>>>> replayWindowTime)) {
>>>> + return false;
>>>> + }
>>>> + if (messageTimestamp <= lastRemovedTimestamp) {
>>>> + return false;
>>>> + }
>>>> +
>>>> + UniqueId nonce = new UniqueId(bytes, 0, getIVSize());
>>>> + if (receivedNonceTimestamps.containsKey(nonce)) {
>>>> + return false;
>>>> + }
>>>> +
>>>> +
>>>> receivedTimestampNonces.computeIfAbsent(Long.valueOf(messageTimestamp), k -> new ArrayDeque<>()).addLast(nonce);
>>>> + receivedNonceTimestamps.put(nonce,
>>>> Long.valueOf(messageTimestamp));
>>>> + receivedNonceCount++;
>>>> + while (receivedNonceCount > replayWindowMessageCount) {
>>>> + removeEldestEntry();
>>>> + }
>>>> +
>>>> + return true;
>>>> }
>>>> private String getAlgorithm() {
>>>> @@ -593,6 +737,28 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> protected AlgorithmParameterSpec generateIV(byte[]
>>>> ivBytes, int offset, int length) {
>>>> return new IvParameterSpec(ivBytes, offset, length);
>>>> }
>>>> +
>>>> + private void removeEldestEntry() {
>>>> + Map.Entry<Long,ArrayDeque<UniqueId>> entry =
>>>> receivedTimestampNonces.firstEntry();
>>>> + if (entry != null) {
>>>> + ArrayDeque<UniqueId> nonces = entry.getValue();
>>>> + UniqueId nonce = nonces.pollFirst();
>>>> + if (nonce != null) {
>>>> + receivedNonceTimestamps.remove(nonce);
>>>> + updateLastRemovedTimestamp(entry.getKey().longValue());
>>>> + receivedNonceCount--;
>>>> + }
>>>> + if (nonces.isEmpty()) {
>>>> + receivedTimestampNonces.pollFirstEntry();
>>>> + }
>>>> + }
>>>> + }
>>>> +
>>>> + private void updateLastRemovedTimestamp(long
>>>> removedTimestamp) {
>>>> + if (removedTimestamp > lastRemovedTimestamp) {
>>>> + lastRemovedTimestamp = removedTimestamp;
>>>> + }
>>>> + }
>>>> }
>>>> /**
>>>> @@ -611,9 +777,10 @@ public class EncryptInterceptor extends
>>>> ChannelInterceptorBase implements Encryp
>>>> * number of bits supported 128-bit provide the best security.
>>>> */
>>>> private static class GCMEncryptionManager extends
>>>> BaseEncryptionManager {
>>>> - GCMEncryptionManager(String algorithm, SecretKeySpec
>>>> secretKey, String providerName)
>>>> + GCMEncryptionManager(String algorithm, SecretKeySpec
>>>> secretKey, String providerName, long replayWindowTime,
>>>> + int replayWindowMessageCount)
>>>> throws NoSuchAlgorithmException,
>>>> NoSuchPaddingException, NoSuchProviderException {
>>>> - super(algorithm, secretKey, providerName);
>>>> + super(algorithm, secretKey, providerName,
>>>> replayWindowTime, replayWindowMessageCount);
>>>> }
>>>> @Override
>>>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>>>> EncryptInterceptorMBean.java b/java/org/apache/catalina/tribes/
>>>> group/ interceptors/EncryptInterceptorMBean.java
>>>> index 7d10a1f4f1..c6362b26c5 100644
>>>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>>>> EncryptInterceptorMBean.java
>>>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>>>> EncryptInterceptorMBean.java
>>>> @@ -76,4 +76,32 @@ public interface EncryptInterceptorMBean {
>>>> * @return the JCA provider name, or {@code null} for default
>>>> */
>>>> String getProviderName();
>>>> +
>>>> + /**
>>>> + * Sets the time-based replay window in milliseconds.
>>>> + *
>>>> + * @param replayWindowTime the replay window time
>>>> + */
>>>> + void setReplayWindowTime(long replayWindowTime);
>>>> +
>>>> + /**
>>>> + * Returns the time-based replay window in milliseconds.
>>>> + *
>>>> + * @return the replay window time
>>>> + */
>>>> + long getReplayWindowTime();
>>>> +
>>>> + /**
>>>> + * Sets the maximum number of replay cache entries.
>>>> + *
>>>> + * @param replayWindowMessageCount the replay window message count
>>>> + */
>>>> + void setReplayWindowMessageCount(int replayWindowMessageCount);
>>>> +
>>>> + /**
>>>> + * Returns the maximum number of replay cache entries.
>>>> + *
>>>> + * @return the replay window message count
>>>> + */
>>>> + int getReplayWindowMessageCount();
>>>> }
>>>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>>>> LocalStrings.properties b/java/org/apache/catalina/tribes/group/
>>>> interceptors/LocalStrings.properties
>>>> index a8e3c5d6e5..5c13178485 100644
>>>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>>>> LocalStrings.properties
>>>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>>>> LocalStrings.properties
>>>> @@ -21,9 +21,12 @@ encryptInterceptor.algorithm.switch=The
>>>> EncryptInterceptor is using the algorith
>>>> encryptInterceptor.algorithm.unsupported=EncryptInterceptor does
>>>> not support algorithm [{0}]
>>>> encryptInterceptor.decrypt.error.short-message=Failed to decrypt
>>>> message: premature end-of-message
>>>> encryptInterceptor.decrypt.failed=Failed to decrypt message
>>>> +encryptInterceptor.decrypt.replay=Failed to decrypt message: replay
>>>> attack detected
>>>> encryptInterceptor.encrypt.failed=Failed to encrypt message
>>>> encryptInterceptor.init.failed=Failed to initialize
>>>> EncryptInterceptor
>>>> encryptInterceptor.key.required=Encryption key is required
>>>> +encryptInterceptor.replayWindowMessageCount.tooSmall=Replay window
>>>> message count must be at least 1
>>>> +encryptInterceptor.replayWindowTime.tooSmall=Replay window time
>>>> must be at least 1 millisecond
>>>> encryptInterceptor.tcpFailureDetector.ordering=EncryptInterceptor
>>>> must be upstream of TcpFailureDetector. Please re-order
>>>> EncryptInterceptor to be listed before TcpFailureDetector in your
>>>> channel interceptor pipeline.
>>>> fragmentationInterceptor.fragments.missing=Fragments are missing.
>>>> diff --git a/test/org/apache/catalina/tribes/group/interceptors/
>>>> TestEncryptInterceptor.java b/test/org/apache/catalina/tribes/group/
>>>> interceptors/TestEncryptInterceptor.java
>>>> index e30e84321f..8c6785c4a8 100644
>>>> --- a/test/org/apache/catalina/tribes/group/interceptors/
>>>> TestEncryptInterceptor.java
>>>> +++ b/test/org/apache/catalina/tribes/group/interceptors/
>>>> TestEncryptInterceptor.java
>>>> @@ -216,6 +216,90 @@ public class TestEncryptInterceptor extends
>>>> EncryptionInterceptorBaseTest {
>>>> cipherText1,
>>>> IsNot.not(IsEqual.equalTo(cipherText2)));
>>>> }
>>>> + @Test
>>>> + public void testRejectReplay() throws Exception {
>>>> + src.setNext(new ValueCaptureInterceptor());
>>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>>> + src.start(Channel.SND_TX_SEQ);
>>>> + dest.start(Channel.SND_TX_SEQ);
>>>> +
>>>> + byte[] encrypted = encrypt("msg-1",
>>>> System.currentTimeMillis());
>>>> +
>>>> + deliver(encrypted);
>>>> + deliver(encrypted);
>>>> +
>>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>>> dest.getPrevious()).getValues();
>>>> + Assert.assertEquals(1, messages.size());
>>>> + }
>>>> +
>>>> + @Test
>>>> + public void testReplayWindowRejectsOldMessage() throws Exception {
>>>> + src.setNext(new ValueCaptureInterceptor());
>>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>>> + dest.setReplayWindowTime(50);
>>>> + src.start(Channel.SND_TX_SEQ);
>>>> + dest.start(Channel.SND_TX_SEQ);
>>>> +
>>>> + long now = System.currentTimeMillis();
>>>> + byte[] encryptedRecent = encrypt("msg-recent", now);
>>>> + byte[] encryptedOld = encrypt("msg-old", now - 1000);
>>>> +
>>>> + deliver(encryptedRecent);
>>>> + deliver(encryptedOld);
>>>> +
>>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>>> dest.getPrevious()).getValues();
>>>> + Assert.assertEquals(1, messages.size());
>>>> + }
>>>> +
>>>> + @Test
>>>> + public void
>>>> testReplayWindowRejectsOldMessageAfterCountEviction() throws
>>>> Exception {
>>>> + src.setNext(new ValueCaptureInterceptor());
>>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>>> + dest.setReplayWindowMessageCount(2);
>>>> + src.start(Channel.SND_TX_SEQ);
>>>> + dest.start(Channel.SND_TX_SEQ);
>>>> +
>>>> + long now = System.currentTimeMillis();
>>>> + byte[] encrypted1000 = encrypt("msg-1000", now);
>>>> + byte[] encrypted1001 = encrypt("msg-1001", now + 1);
>>>> + byte[] encrypted1002 = encrypt("msg-1002", now + 2);
>>>> + byte[] encrypted1003 = encrypt("msg-1003", now + 3);
>>>> +
>>>> + deliver(encrypted1000);
>>>> + deliver(encrypted1001);
>>>> + deliver(encrypted1002);
>>>> + deliver(encrypted1003);
>>>> + deliver(encrypted1000);
>>>> +
>>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>>> dest.getPrevious()).getValues();
>>>> + Assert.assertEquals(4, messages.size());
>>>> + }
>>>> +
>>>> + @Test
>>>> + public void testReplayWindowEvictsOldestTimestampFirst() throws
>>>> Exception {
>>>> + src.setNext(new ValueCaptureInterceptor());
>>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>>> + dest.setReplayWindowMessageCount(2);
>>>> + src.start(Channel.SND_TX_SEQ);
>>>> + dest.start(Channel.SND_TX_SEQ);
>>>> +
>>>> + long now = System.currentTimeMillis();
>>>> + byte[] encrypted300 = encrypt("msg-300", now + 300);
>>>> + byte[] encrypted100 = encrypt("msg-100", now + 100);
>>>> + byte[] encrypted200 = encrypt("msg-200", now + 200);
>>>> + byte[] encrypted225 = encrypt("msg-225", now + 225);
>>>> + byte[] encrypted250 = encrypt("msg-250", now + 250);
>>>> +
>>>> + deliver(encrypted300);
>>>> + deliver(encrypted100);
>>>> + deliver(encrypted200);
>>>> + deliver(encrypted225);
>>>> + deliver(encrypted250);
>>>> +
>>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>>> dest.getPrevious()).getValues();
>>>> + Assert.assertEquals(5, messages.size());
>>>> + }
>>>> +
>>>> @Test
>>>> public void testPickup() throws Exception {
>>>> File file = new File(MESSAGE_FILE);
>>>> @@ -316,4 +400,20 @@ public class TestEncryptInterceptor extends
>>>> EncryptionInterceptorBaseTest {
>>>> Assert.fail("EncryptionInterceptor should throw
>>>> ChannelConfigException, not " + t.getClass().getName());
>>>> }
>>>> }
>>>> +
>>>> + private byte[] encrypt(String message, long timestamp) throws
>>>> Exception {
>>>> + ChannelData msg = new ChannelData(false);
>>>> + msg.setTimestamp(timestamp);
>>>> + msg.setMessage(new XByteBuffer(message.getBytes("UTF-8"),
>>>> false));
>>>> + src.sendMessage(null, msg, null);
>>>> + return ((ValueCaptureInterceptor) src.getNext()).getValue();
>>>> + }
>>>> +
>>>> + private void deliver(byte[] encrypted) {
>>>> + ChannelData incoming = new ChannelData(false);
>>>> + XByteBuffer xbb = new XByteBuffer(encrypted.length, false);
>>>> + xbb.append(encrypted, 0, encrypted.length);
>>>> + incoming.setMessage(xbb);
>>>> + dest.messageReceived(incoming);
>>>> + }
>>>> }
>>>> diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
>>>> index 26d01987ed..e9dd0bdf10 100644
>>>> --- a/webapps/docs/changelog.xml
>>>> +++ b/webapps/docs/changelog.xml
>>>> @@ -602,6 +602,10 @@
>>>> Fix concurrency issues generating MD5 digests in the
>>>> <code>CloudMembershipProvider</code> implementations. (markt)
>>>> </fix>
>>>> + <add>
>>>> + Add replay protection to the <code>EncryptInterceptor</
>>>> code>. This us a
>>>> + breaking change for the <code>EncryptInterceptor</code>.
>>>> (markt)
>>>> + </add>
>>>> </changelog>
>>>> </subsection>
>>>> <subsection name="WebSocket">
>>>> diff --git a/webapps/docs/config/cluster-interceptor.xml b/webapps/
>>>> docs/config/cluster-interceptor.xml
>>>> index d0f5f88537..9b26c8ed73 100644
>>>> --- a/webapps/docs/config/cluster-interceptor.xml
>>>> +++ b/webapps/docs/config/cluster-interceptor.xml
>>>> @@ -212,6 +212,12 @@
>>>> <i>before</i> the <code>TcpFailureDetector</code> on the
>>>> sender and <i>after</i>
>>>> it on the receiver, otherwise message corruption will occur.
>>>> </p>
>>>> + <p>
>>>> + The EncryptInterceptor uses the timestamp of the message to
>>>> provide replay
>>>> + protection. For this to be effective, clock skew between
>>>> cluster nodes
>>>> + should be minimised. If not, it is likely a high proportion of
>>>> valid
>>>> + messages will be rejected.
>>>> + </p>
>>>> <attributes>
>>>> <attribute name="encryptionAlgorithm" required="false">
>>>> <p>The encryption algorithm to be used, including the mode
>>>> and padding.
>>>> @@ -231,11 +237,30 @@
>>>> <p>The default algorithm is <code>AES/GCM/NoPadding</
>>>> code>.</p>
>>>> </attribute>
>>>> <attribute name="encryptionKey" required="true">
>>>> - The key to be used with the encryption algorithm.
>>>> + <p>The key to be used with the encryption algorithm.</p>
>>>> - The key should be specified as hex-encoded bytes of the
>>>> appropriate
>>>> + <p>The key should be specified as hex-encoded bytes of the
>>>> appropriate
>>>> length for the algorithm (e.g. 16 bytes / 32 characters /
>>>> 128 bits for
>>>> - AES-128, 32 bytes / 64 characters / 256 bits for AES-256,
>>>> etc.).
>>>> + AES-128, 32 bytes / 64 characters / 256 bits for AES-256,
>>>> etc.).</p>
>>>> + </attribute>
>>>> + <attribute name="replayWindowTime" required="false">
>>>> + <p>Messages with a timestamp before the current time less
>>>> this window
>>>> + will be rejected. This needs to account for clock skew
>>>> across the cluster
>>>> + as well as the expected maximum delay between messages being
>>>> sent and
>>>> + received. Specified in milliseconds. If not specified, the
>>>> default value
>>>> + of 10000 (10 seconds) will be used.</p>
>>>> + </attribute>
>>>> + <attribute name="replayWindowMessageCount" required="false">
>>>> + <p>The number of past messages for which the nonces will be
>>>> tracked to
>>>> + prevent replay attacks. Messages with a nonce that has
>>>> already been
>>>> + tracked will be rejected. If not specified, the default
>>>> value of 8192
>>>> + will be used.</p>
>>>> +
>>>> + <p>If messages are received at a high rate, it is possible
>>>> that a nonce
>>>> + will be removed from the list of tracked nonces before
>>>> + <code>replayWindowTime</code> milliseconds have elapsed. If
>>>> that happens,
>>>> + any message with a timestamp older than the last nonce
>>>> evicted from the
>>>> + list of tracked nonces will also be rejected.</p>
>>>> </attribute>
>>>> </attributes>
>>>> </subsection>
>>>>
>>>>
>>>> ---------------------------------------------------------------------
>>>> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
>>>> For additional commands, e-mail: dev-help@tomcat.apache.org
>>>>
>>>
>>>
>>> ---------------------------------------------------------------------
>>> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
>>> For additional commands, e-mail: dev-help@tomcat.apache.org
>>>
>>
>>
>> ---------------------------------------------------------------------
>> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
>> For additional commands, e-mail: dev-help@tomcat.apache.org
>>
>
>
> ---------------------------------------------------------------------
> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
> For additional commands, e-mail: dev-help@tomcat.apache.org
>
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org
Re: (tomcat) branch main updated: Second version of replay protection.
Posted by Christopher Schultz <ch...@christopherschultz.net>.
Mark,
On 6/17/26 4:23 PM, Mark Thomas wrote:
> On 17/06/2026 21:01, Christopher Schultz wrote:
>> Mark,
>>
>> Since this is a breaking change, I think we need something in here:
>> https://tomcat.apache.org/
>> migration-11.0.html#Tomcat_11.0.x_noteable_changes
>>
>> I can draft this if you'd like.
>
> Thanks. PLease go ahead.
How's this? (apologies for the formatting).
"
<li>
Tomcat 11.0.23 introduces a <b>breaking change</b> for users of the
<code>EncryptInterceptor</code> in their clustering configuration.
Migration from prior-to-Tomcat-11.0.23 to after-Tomcat-11.0.23
will require that <i>all</i> cluster nodes are stopped and
restarted
to perform the migration. There is an additional, required,
piece of
data that is being introduced into the cluster data stream for
<code>EncryptInterceptor</code> and nodes running any older
versions
will fail to send or receive cluster messages.
</li>
"
Thanks,
-chris
>
> Mark
>
>>
>> -chris
>>
>> On 6/17/26 1:12 PM, markt@apache.org wrote:
>>> This is an automated email from the ASF dual-hosted git repository.
>>>
>>> markt-asf pushed a commit to branch main
>>> in repository https://gitbox.apache.org/repos/asf/tomcat.git
>>>
>>>
>>> The following commit(s) were added to refs/heads/main by this push:
>>> new 24f501c698 Second version of replay protection.
>>> 24f501c698 is described below
>>>
>>> commit 24f501c698499ea5d64c406fd610fc5dcbb2904c
>>> Author: Mark Thomas <ma...@apache.org>
>>> AuthorDate: Wed Jun 17 18:11:56 2026 +0100
>>>
>>> Second version of replay protection.
>>> Co-authored with GPT
>>> ---
>>> .../group/interceptors/EncryptInterceptor.java | 197 ++++++++++
>>> + ++++++++--
>>> .../interceptors/EncryptInterceptorMBean.java | 28 +++
>>> .../group/interceptors/LocalStrings.properties | 3 +
>>> .../group/interceptors/TestEncryptInterceptor.java | 100 +++++++++++
>>> webapps/docs/changelog.xml | 4 +
>>> webapps/docs/config/cluster-interceptor.xml | 31 +++-
>>> 6 files changed, 345 insertions(+), 18 deletions(-)
>>>
>>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>>> EncryptInterceptor.java b/java/org/apache/catalina/tribes/group/
>>> interceptors/EncryptInterceptor.java
>>> index 5cc9359a5d..0616ab03c0 100644
>>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>>> EncryptInterceptor.java
>>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>>> EncryptInterceptor.java
>>> @@ -22,7 +22,11 @@ import java.security.NoSuchAlgorithmException;
>>> import java.security.NoSuchProviderException;
>>> import java.security.SecureRandom;
>>> import java.security.spec.AlgorithmParameterSpec;
>>> +import java.util.ArrayDeque;
>>> +import java.util.HashMap;
>>> import java.util.Locale;
>>> +import java.util.Map;
>>> +import java.util.TreeMap;
>>> import java.util.concurrent.ConcurrentLinkedQueue;
>>> import javax.crypto.Cipher;
>>> @@ -36,6 +40,7 @@ import org.apache.catalina.tribes.ChannelException;
>>> import org.apache.catalina.tribes.ChannelInterceptor;
>>> import org.apache.catalina.tribes.ChannelMessage;
>>> import org.apache.catalina.tribes.Member;
>>> +import org.apache.catalina.tribes.UniqueId;
>>> import org.apache.catalina.tribes.group.ChannelInterceptorBase;
>>> import org.apache.catalina.tribes.group.InterceptorPayload;
>>> import org.apache.catalina.tribes.io.XByteBuffer;
>>> @@ -63,6 +68,9 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> private String encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGORITHM;
>>> private byte[] encryptionKeyBytes;
>>> private String encryptionKeyString;
>>> + // Milliseconds
>>> + private long replayWindowTime = 10_000;
>>> + private int replayWindowMessageCount = 8192;
>>> private BaseEncryptionManager encryptionManager;
>>> @@ -80,7 +88,7 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> if (Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) {
>>> try {
>>> encryptionManager =
>>> createEncryptionManager(getEncryptionAlgorithm(),
>>> getEncryptionKeyInternal(),
>>> - getProviderName());
>>> + getProviderName(), getReplayWindowTime(),
>>> getReplayWindowMessageCount());
>>> } catch (GeneralSecurityException gse) {
>>> throw new
>>> ChannelException(sm.getString("encryptInterceptor.init.failed"), gse);
>>> }
>>> @@ -114,9 +122,18 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> throws ChannelException {
>>> try {
>>> byte[] data = msg.getMessage().getBytes();
>>> + // Need trusted time stamp on receiving side, so add
>>> time stamp to encrypted data.
>>> + long timestamp = msg.getTimestamp();
>>> + if (timestamp <= 0) {
>>> + timestamp = System.currentTimeMillis();
>>> + msg.setTimestamp(timestamp);
>>> + }
>>> + byte[] message = new byte[data.length + 8];
>>> + XByteBuffer.toBytes(timestamp, message, 0);
>>> + System.arraycopy(data, 0, message, 8, data.length);
>>> // See #encrypt(byte[]) for an explanation of the
>>> return value
>>> - byte[][] bytes = encryptionManager.encrypt(data);
>>> + byte[][] bytes = encryptionManager.encrypt(message);
>>> XByteBuffer xbb = msg.getMessage();
>>> @@ -137,14 +154,32 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> public void messageReceived(ChannelMessage msg) {
>>> try {
>>> byte[] data = msg.getMessage().getBytes();
>>> + byte[] encryptedData = data;
>>> data = encryptionManager.decrypt(data);
>>> + if (data.length < 8) {
>>> + throw new
>>> GeneralSecurityException(sm.getString("encryptInterceptor.decrypt.error.short-message"));
>>> + }
>>> + /*
>>> + * This is trusted since it was encrypted.
>>> + *
>>> + * Excessive clock skew will cause problems here. Can't
>>> address that without creating risks of replay
>>> + * attacks.
>>> + */
>>> + long trustedTimstamp = XByteBuffer.toLong(data, 0);
>>> + if (!
>>> encryptionManager.checkIncomingMessage(encryptedData,
>>> trustedTimstamp)) {
>>> + log.error(sm.getString("encryptInterceptor.decrypt.replay"));
>>> + return;
>>> + }
>>> XByteBuffer xbb = msg.getMessage();
>>> - // Completely replace the message with the decrypted one
>>> + /*
>>> + * Completely replace the message with the decrypted
>>> one. No need to replace time stamp. At this point it
>>> + * will be the same as the trusted time stamp.
>>> + */
>>> xbb.clear();
>>> - xbb.append(data, 0, data.length);
>>> + xbb.append(data, 8, data.length - 8);
>>> super.messageReceived(msg);
>>> } catch (GeneralSecurityException gse) {
>>> @@ -274,6 +309,58 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> return providerName;
>>> }
>>> + /**
>>> + * Returns the time-based replay window in milliseconds.
>>> + *
>>> + * @return The replay window time
>>> + */
>>> + @Override
>>> + public long getReplayWindowTime() {
>>> + return replayWindowTime;
>>> + }
>>> +
>>> + /**
>>> + * Sets the time-based replay window in milliseconds.
>>> + *
>>> + * @param replayWindowTime The replay window time
>>> + */
>>> + @Override
>>> + public void setReplayWindowTime(long replayWindowTime) {
>>> + if (replayWindowTime < 1) {
>>> + throw new
>>> IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowTime.tooSmall"));
>>> + }
>>> + this.replayWindowTime = replayWindowTime;
>>> + if (encryptionManager != null) {
>>> + encryptionManager.setReplayWindowTime(replayWindowTime);
>>> + }
>>> + }
>>> +
>>> + /**
>>> + * Returns the maximum number of replay entries to retain.
>>> + *
>>> + * @return The replay window message count
>>> + */
>>> + @Override
>>> + public int getReplayWindowMessageCount() {
>>> + return replayWindowMessageCount;
>>> + }
>>> +
>>> + /**
>>> + * Sets the maximum number of replay entries to retain.
>>> + *
>>> + * @param replayWindowMessageCount The replay window message count
>>> + */
>>> + @Override
>>> + public void setReplayWindowMessageCount(int
>>> replayWindowMessageCount) {
>>> + if (replayWindowMessageCount < 1) {
>>> + throw new
>>> IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowMessageCount.tooSmall"));
>>> + }
>>> + this.replayWindowMessageCount = replayWindowMessageCount;
>>> + if (encryptionManager != null) {
>>> +
>>> encryptionManager.setReplayWindowMessageCount(replayWindowMessageCount);
>>> + }
>>> + }
>>> +
>>> // Copied from org.apache.tomcat.util.buf.HexUtils
>>> // @formatter:off
>>> private static final int[] DEC = {
>>> @@ -320,7 +407,8 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> }
>>> private static BaseEncryptionManager
>>> createEncryptionManager(String algorithm, byte[] encryptionKey,
>>> - String providerName) throws NoSuchAlgorithmException,
>>> NoSuchPaddingException, NoSuchProviderException {
>>> + String providerName, long replayWindowTime, int
>>> replayWindowMessageCount)
>>> + throws NoSuchAlgorithmException, NoSuchPaddingException,
>>> NoSuchProviderException {
>>> if (null == encryptionKey) {
>>> throw new
>>> IllegalStateException(sm.getString("encryptInterceptor.key.required"));
>>> }
>>> @@ -359,8 +447,7 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> */
>>> if ("NONE".equals(algorithmMode) ||
>>> "ECB".equals(algorithmMode) || "PCBC".equals(algorithmMode) ||
>>> "CTS".equals(algorithmMode) ||
>>> "KW".equals(algorithmMode) || "KWP".equals(algorithmMode) ||
>>> - "CTR".equals(algorithmMode) ||
>>> - ("CBC".equals(algorithmMode) &&
>>> "NOPADDING".equals(algorithmPadding)) ||
>>> + "CTR".equals(algorithmMode) ||
>>> ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
>>> ("CFB".equals(algorithmMode) &&
>>> "NOPADDING".equals(algorithmPadding)) ||
>>> ("GCM".equals(algorithmMode) &&
>>> "PKCS5PADDING".equals(algorithmPadding)) ||
>>> ("OFB".equals(algorithmMode) &&
>>> "NOPADDING".equals(algorithmPadding))) {
>>> @@ -375,17 +462,18 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> } else if (algorithmMode.startsWith("CFB") ||
>>> algorithmMode.startsWith("OFB")) {
>>> // Using a non-default block size. Not supported as
>>> insecure and/or inefficient.
>>> - throw new IllegalArgumentException(
>>> - sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>>> + throw new
>>> IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>>> } else if ("GCM".equals(algorithmMode) &&
>>> "NOPADDING".equals(algorithmPadding)) {
>>> // Needs a specialised encryption manager to handle the
>>> differences between GCM and other modes
>>> - return new GCMEncryptionManager(algorithm, new
>>> SecretKeySpec(encryptionKey, algorithmName), providerName);
>>> + return new GCMEncryptionManager(algorithm, new
>>> SecretKeySpec(encryptionKey, algorithmName), providerName,
>>> + replayWindowTime, replayWindowMessageCount);
>>> }
>>> // Use the default encryption manager
>>> try {
>>> - return new BaseEncryptionManager(algorithm, new
>>> SecretKeySpec(encryptionKey, algorithmName), providerName);
>>> + return new BaseEncryptionManager(algorithm, new
>>> SecretKeySpec(encryptionKey, algorithmName), providerName,
>>> + replayWindowTime, replayWindowMessageCount);
>>> } catch (NoSuchAlgorithmException | NoSuchPaddingException
>>> | NoSuchProviderException ex) {
>>> throw new
>>> IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm), ex);
>>> }
>>> @@ -423,12 +511,22 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> * SecureRandom is thread-safe, but sharing a single
>>> instance will likely be a bottleneck.
>>> */
>>> private final ConcurrentLinkedQueue<SecureRandom> randomPool;
>>> -
>>> - BaseEncryptionManager(String algorithm, SecretKeySpec
>>> secretKey, String providerName)
>>> + private final TreeMap<Long,ArrayDeque<UniqueId>>
>>> receivedTimestampNonces = new TreeMap<>();
>>> + private final Map<UniqueId,Long> receivedNonceTimestamps =
>>> new HashMap<>();
>>> + private long replayWindowTime;
>>> + private volatile int replayWindowMessageCount;
>>> + private long lastRemovedTimestamp;
>>> + private int receivedNonceCount = 0;
>>> +
>>> + BaseEncryptionManager(String algorithm, SecretKeySpec
>>> secretKey, String providerName, long replayWindowTime,
>>> + int replayWindowMessageCount)
>>> throws NoSuchAlgorithmException,
>>> NoSuchPaddingException, NoSuchProviderException {
>>> this.algorithm = algorithm;
>>> this.providerName = providerName;
>>> this.secretKey = secretKey;
>>> + this.replayWindowTime = replayWindowTime;
>>> + this.lastRemovedTimestamp = System.currentTimeMillis() -
>>> replayWindowTime;
>>> + this.replayWindowMessageCount = replayWindowMessageCount;
>>> cipherPool = new ConcurrentLinkedQueue<>();
>>> Cipher cipher = createCipher();
>>> @@ -441,6 +539,52 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> // Individual Cipher and SecureRandom objects need no
>>> explicit tear down
>>> cipherPool.clear();
>>> randomPool.clear();
>>> + synchronized (this) {
>>> + receivedTimestampNonces.clear();
>>> + receivedNonceTimestamps.clear();
>>> + lastRemovedTimestamp = Long.MIN_VALUE;
>>> + receivedNonceCount = 0;
>>> + }
>>> + }
>>> +
>>> + public synchronized void setReplayWindowTime(long
>>> replayWindowTime) {
>>> + this.replayWindowTime = replayWindowTime;
>>> + // Only move the lastRemovedTimestamp forwards. Moving
>>> it backwards could open a window for replay attacks.
>>> + if (lastRemovedTimestamp < System.currentTimeMillis() -
>>> replayWindowTime) {
>>> + lastRemovedTimestamp = System.currentTimeMillis() -
>>> replayWindowTime;
>>> + }
>>> + }
>>> +
>>> + public void setReplayWindowMessageCount(int
>>> replayWindowMessageCount) {
>>> + this.replayWindowMessageCount = replayWindowMessageCount;
>>> + synchronized (this) {
>>> + while (receivedNonceCount > replayWindowMessageCount) {
>>> + removeEldestEntry();
>>> + }
>>> + }
>>> + }
>>> +
>>> + public synchronized boolean checkIncomingMessage(byte[]
>>> bytes, long messageTimestamp) {
>>> + if (messageTimestamp < (System.currentTimeMillis() -
>>> replayWindowTime)) {
>>> + return false;
>>> + }
>>> + if (messageTimestamp <= lastRemovedTimestamp) {
>>> + return false;
>>> + }
>>> +
>>> + UniqueId nonce = new UniqueId(bytes, 0, getIVSize());
>>> + if (receivedNonceTimestamps.containsKey(nonce)) {
>>> + return false;
>>> + }
>>> +
>>> +
>>> receivedTimestampNonces.computeIfAbsent(Long.valueOf(messageTimestamp), k -> new ArrayDeque<>()).addLast(nonce);
>>> + receivedNonceTimestamps.put(nonce,
>>> Long.valueOf(messageTimestamp));
>>> + receivedNonceCount++;
>>> + while (receivedNonceCount > replayWindowMessageCount) {
>>> + removeEldestEntry();
>>> + }
>>> +
>>> + return true;
>>> }
>>> private String getAlgorithm() {
>>> @@ -593,6 +737,28 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> protected AlgorithmParameterSpec generateIV(byte[] ivBytes,
>>> int offset, int length) {
>>> return new IvParameterSpec(ivBytes, offset, length);
>>> }
>>> +
>>> + private void removeEldestEntry() {
>>> + Map.Entry<Long,ArrayDeque<UniqueId>> entry =
>>> receivedTimestampNonces.firstEntry();
>>> + if (entry != null) {
>>> + ArrayDeque<UniqueId> nonces = entry.getValue();
>>> + UniqueId nonce = nonces.pollFirst();
>>> + if (nonce != null) {
>>> + receivedNonceTimestamps.remove(nonce);
>>> + updateLastRemovedTimestamp(entry.getKey().longValue());
>>> + receivedNonceCount--;
>>> + }
>>> + if (nonces.isEmpty()) {
>>> + receivedTimestampNonces.pollFirstEntry();
>>> + }
>>> + }
>>> + }
>>> +
>>> + private void updateLastRemovedTimestamp(long
>>> removedTimestamp) {
>>> + if (removedTimestamp > lastRemovedTimestamp) {
>>> + lastRemovedTimestamp = removedTimestamp;
>>> + }
>>> + }
>>> }
>>> /**
>>> @@ -611,9 +777,10 @@ public class EncryptInterceptor extends
>>> ChannelInterceptorBase implements Encryp
>>> * number of bits supported 128-bit provide the best security.
>>> */
>>> private static class GCMEncryptionManager extends
>>> BaseEncryptionManager {
>>> - GCMEncryptionManager(String algorithm, SecretKeySpec
>>> secretKey, String providerName)
>>> + GCMEncryptionManager(String algorithm, SecretKeySpec
>>> secretKey, String providerName, long replayWindowTime,
>>> + int replayWindowMessageCount)
>>> throws NoSuchAlgorithmException,
>>> NoSuchPaddingException, NoSuchProviderException {
>>> - super(algorithm, secretKey, providerName);
>>> + super(algorithm, secretKey, providerName,
>>> replayWindowTime, replayWindowMessageCount);
>>> }
>>> @Override
>>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>>> EncryptInterceptorMBean.java b/java/org/apache/catalina/tribes/group/
>>> interceptors/EncryptInterceptorMBean.java
>>> index 7d10a1f4f1..c6362b26c5 100644
>>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>>> EncryptInterceptorMBean.java
>>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>>> EncryptInterceptorMBean.java
>>> @@ -76,4 +76,32 @@ public interface EncryptInterceptorMBean {
>>> * @return the JCA provider name, or {@code null} for default
>>> */
>>> String getProviderName();
>>> +
>>> + /**
>>> + * Sets the time-based replay window in milliseconds.
>>> + *
>>> + * @param replayWindowTime the replay window time
>>> + */
>>> + void setReplayWindowTime(long replayWindowTime);
>>> +
>>> + /**
>>> + * Returns the time-based replay window in milliseconds.
>>> + *
>>> + * @return the replay window time
>>> + */
>>> + long getReplayWindowTime();
>>> +
>>> + /**
>>> + * Sets the maximum number of replay cache entries.
>>> + *
>>> + * @param replayWindowMessageCount the replay window message count
>>> + */
>>> + void setReplayWindowMessageCount(int replayWindowMessageCount);
>>> +
>>> + /**
>>> + * Returns the maximum number of replay cache entries.
>>> + *
>>> + * @return the replay window message count
>>> + */
>>> + int getReplayWindowMessageCount();
>>> }
>>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>>> LocalStrings.properties b/java/org/apache/catalina/tribes/group/
>>> interceptors/LocalStrings.properties
>>> index a8e3c5d6e5..5c13178485 100644
>>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>>> LocalStrings.properties
>>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>>> LocalStrings.properties
>>> @@ -21,9 +21,12 @@ encryptInterceptor.algorithm.switch=The
>>> EncryptInterceptor is using the algorith
>>> encryptInterceptor.algorithm.unsupported=EncryptInterceptor does
>>> not support algorithm [{0}]
>>> encryptInterceptor.decrypt.error.short-message=Failed to decrypt
>>> message: premature end-of-message
>>> encryptInterceptor.decrypt.failed=Failed to decrypt message
>>> +encryptInterceptor.decrypt.replay=Failed to decrypt message: replay
>>> attack detected
>>> encryptInterceptor.encrypt.failed=Failed to encrypt message
>>> encryptInterceptor.init.failed=Failed to initialize EncryptInterceptor
>>> encryptInterceptor.key.required=Encryption key is required
>>> +encryptInterceptor.replayWindowMessageCount.tooSmall=Replay window
>>> message count must be at least 1
>>> +encryptInterceptor.replayWindowTime.tooSmall=Replay window time must
>>> be at least 1 millisecond
>>> encryptInterceptor.tcpFailureDetector.ordering=EncryptInterceptor
>>> must be upstream of TcpFailureDetector. Please re-order
>>> EncryptInterceptor to be listed before TcpFailureDetector in your
>>> channel interceptor pipeline.
>>> fragmentationInterceptor.fragments.missing=Fragments are missing.
>>> diff --git a/test/org/apache/catalina/tribes/group/interceptors/
>>> TestEncryptInterceptor.java b/test/org/apache/catalina/tribes/group/
>>> interceptors/TestEncryptInterceptor.java
>>> index e30e84321f..8c6785c4a8 100644
>>> --- a/test/org/apache/catalina/tribes/group/interceptors/
>>> TestEncryptInterceptor.java
>>> +++ b/test/org/apache/catalina/tribes/group/interceptors/
>>> TestEncryptInterceptor.java
>>> @@ -216,6 +216,90 @@ public class TestEncryptInterceptor extends
>>> EncryptionInterceptorBaseTest {
>>> cipherText1, IsNot.not(IsEqual.equalTo(cipherText2)));
>>> }
>>> + @Test
>>> + public void testRejectReplay() throws Exception {
>>> + src.setNext(new ValueCaptureInterceptor());
>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>> + src.start(Channel.SND_TX_SEQ);
>>> + dest.start(Channel.SND_TX_SEQ);
>>> +
>>> + byte[] encrypted = encrypt("msg-1",
>>> System.currentTimeMillis());
>>> +
>>> + deliver(encrypted);
>>> + deliver(encrypted);
>>> +
>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>> dest.getPrevious()).getValues();
>>> + Assert.assertEquals(1, messages.size());
>>> + }
>>> +
>>> + @Test
>>> + public void testReplayWindowRejectsOldMessage() throws Exception {
>>> + src.setNext(new ValueCaptureInterceptor());
>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>> + dest.setReplayWindowTime(50);
>>> + src.start(Channel.SND_TX_SEQ);
>>> + dest.start(Channel.SND_TX_SEQ);
>>> +
>>> + long now = System.currentTimeMillis();
>>> + byte[] encryptedRecent = encrypt("msg-recent", now);
>>> + byte[] encryptedOld = encrypt("msg-old", now - 1000);
>>> +
>>> + deliver(encryptedRecent);
>>> + deliver(encryptedOld);
>>> +
>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>> dest.getPrevious()).getValues();
>>> + Assert.assertEquals(1, messages.size());
>>> + }
>>> +
>>> + @Test
>>> + public void
>>> testReplayWindowRejectsOldMessageAfterCountEviction() throws Exception {
>>> + src.setNext(new ValueCaptureInterceptor());
>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>> + dest.setReplayWindowMessageCount(2);
>>> + src.start(Channel.SND_TX_SEQ);
>>> + dest.start(Channel.SND_TX_SEQ);
>>> +
>>> + long now = System.currentTimeMillis();
>>> + byte[] encrypted1000 = encrypt("msg-1000", now);
>>> + byte[] encrypted1001 = encrypt("msg-1001", now + 1);
>>> + byte[] encrypted1002 = encrypt("msg-1002", now + 2);
>>> + byte[] encrypted1003 = encrypt("msg-1003", now + 3);
>>> +
>>> + deliver(encrypted1000);
>>> + deliver(encrypted1001);
>>> + deliver(encrypted1002);
>>> + deliver(encrypted1003);
>>> + deliver(encrypted1000);
>>> +
>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>> dest.getPrevious()).getValues();
>>> + Assert.assertEquals(4, messages.size());
>>> + }
>>> +
>>> + @Test
>>> + public void testReplayWindowEvictsOldestTimestampFirst() throws
>>> Exception {
>>> + src.setNext(new ValueCaptureInterceptor());
>>> + dest.setPrevious(new ValuesCaptureInterceptor());
>>> + dest.setReplayWindowMessageCount(2);
>>> + src.start(Channel.SND_TX_SEQ);
>>> + dest.start(Channel.SND_TX_SEQ);
>>> +
>>> + long now = System.currentTimeMillis();
>>> + byte[] encrypted300 = encrypt("msg-300", now + 300);
>>> + byte[] encrypted100 = encrypt("msg-100", now + 100);
>>> + byte[] encrypted200 = encrypt("msg-200", now + 200);
>>> + byte[] encrypted225 = encrypt("msg-225", now + 225);
>>> + byte[] encrypted250 = encrypt("msg-250", now + 250);
>>> +
>>> + deliver(encrypted300);
>>> + deliver(encrypted100);
>>> + deliver(encrypted200);
>>> + deliver(encrypted225);
>>> + deliver(encrypted250);
>>> +
>>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>>> dest.getPrevious()).getValues();
>>> + Assert.assertEquals(5, messages.size());
>>> + }
>>> +
>>> @Test
>>> public void testPickup() throws Exception {
>>> File file = new File(MESSAGE_FILE);
>>> @@ -316,4 +400,20 @@ public class TestEncryptInterceptor extends
>>> EncryptionInterceptorBaseTest {
>>> Assert.fail("EncryptionInterceptor should throw
>>> ChannelConfigException, not " + t.getClass().getName());
>>> }
>>> }
>>> +
>>> + private byte[] encrypt(String message, long timestamp) throws
>>> Exception {
>>> + ChannelData msg = new ChannelData(false);
>>> + msg.setTimestamp(timestamp);
>>> + msg.setMessage(new XByteBuffer(message.getBytes("UTF-8"),
>>> false));
>>> + src.sendMessage(null, msg, null);
>>> + return ((ValueCaptureInterceptor) src.getNext()).getValue();
>>> + }
>>> +
>>> + private void deliver(byte[] encrypted) {
>>> + ChannelData incoming = new ChannelData(false);
>>> + XByteBuffer xbb = new XByteBuffer(encrypted.length, false);
>>> + xbb.append(encrypted, 0, encrypted.length);
>>> + incoming.setMessage(xbb);
>>> + dest.messageReceived(incoming);
>>> + }
>>> }
>>> diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
>>> index 26d01987ed..e9dd0bdf10 100644
>>> --- a/webapps/docs/changelog.xml
>>> +++ b/webapps/docs/changelog.xml
>>> @@ -602,6 +602,10 @@
>>> Fix concurrency issues generating MD5 digests in the
>>> <code>CloudMembershipProvider</code> implementations. (markt)
>>> </fix>
>>> + <add>
>>> + Add replay protection to the <code>EncryptInterceptor</
>>> code>. This us a
>>> + breaking change for the <code>EncryptInterceptor</code>.(markt)
>>> + </add>
>>> </changelog>
>>> </subsection>
>>> <subsection name="WebSocket">
>>> diff --git a/webapps/docs/config/cluster-interceptor.xml b/webapps/
>>> docs/config/cluster-interceptor.xml
>>> index d0f5f88537..9b26c8ed73 100644
>>> --- a/webapps/docs/config/cluster-interceptor.xml
>>> +++ b/webapps/docs/config/cluster-interceptor.xml
>>> @@ -212,6 +212,12 @@
>>> <i>before</i> the <code>TcpFailureDetector</code> on the
>>> sender and <i>after</i>
>>> it on the receiver, otherwise message corruption will occur.
>>> </p>
>>> + <p>
>>> + The EncryptInterceptor uses the timestamp of the message to
>>> provide replay
>>> + protection. For this to be effective, clock skew between
>>> cluster nodes
>>> + should be minimised. If not, it is likely a high proportion of
>>> valid
>>> + messages will be rejected.
>>> + </p>
>>> <attributes>
>>> <attribute name="encryptionAlgorithm" required="false">
>>> <p>The encryption algorithm to be used, including the mode
>>> and padding.
>>> @@ -231,11 +237,30 @@
>>> <p>The default algorithm is <code>AES/GCM/NoPadding</code>.</p>
>>> </attribute>
>>> <attribute name="encryptionKey" required="true">
>>> - The key to be used with the encryption algorithm.
>>> + <p>The key to be used with the encryption algorithm.</p>
>>> - The key should be specified as hex-encoded bytes of the
>>> appropriate
>>> + <p>The key should be specified as hex-encoded bytes of the
>>> appropriate
>>> length for the algorithm (e.g. 16 bytes / 32 characters /
>>> 128 bits for
>>> - AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).
>>> + AES-128, 32 bytes / 64 characters / 256 bits for AES-256,
>>> etc.).</p>
>>> + </attribute>
>>> + <attribute name="replayWindowTime" required="false">
>>> + <p>Messages with a timestamp before the current time less
>>> this window
>>> + will be rejected. This needs to account for clock skew across
>>> the cluster
>>> + as well as the expected maximum delay between messages being
>>> sent and
>>> + received. Specified in milliseconds. If not specified, the
>>> default value
>>> + of 10000 (10 seconds) will be used.</p>
>>> + </attribute>
>>> + <attribute name="replayWindowMessageCount" required="false">
>>> + <p>The number of past messages for which the nonces will be
>>> tracked to
>>> + prevent replay attacks. Messages with a nonce that has
>>> already been
>>> + tracked will be rejected. If not specified, the default value
>>> of 8192
>>> + will be used.</p>
>>> +
>>> + <p>If messages are received at a high rate, it is possible
>>> that a nonce
>>> + will be removed from the list of tracked nonces before
>>> + <code>replayWindowTime</code> milliseconds have elapsed. If
>>> that happens,
>>> + any message with a timestamp older than the last nonce
>>> evicted from the
>>> + list of tracked nonces will also be rejected.</p>
>>> </attribute>
>>> </attributes>
>>> </subsection>
>>>
>>>
>>> ---------------------------------------------------------------------
>>> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
>>> For additional commands, e-mail: dev-help@tomcat.apache.org
>>>
>>
>>
>> ---------------------------------------------------------------------
>> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
>> For additional commands, e-mail: dev-help@tomcat.apache.org
>>
>
>
> ---------------------------------------------------------------------
> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
> For additional commands, e-mail: dev-help@tomcat.apache.org
>
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org
Re: (tomcat) branch main updated: Second version of replay protection.
Posted by Mark Thomas <ma...@apache.org>.
On 17/06/2026 21:01, Christopher Schultz wrote:
> Mark,
>
> Since this is a breaking change, I think we need something in here:
> https://tomcat.apache.org/
> migration-11.0.html#Tomcat_11.0.x_noteable_changes
>
> I can draft this if you'd like.
Thanks. PLease go ahead.
Mark
>
> -chris
>
> On 6/17/26 1:12 PM, markt@apache.org wrote:
>> This is an automated email from the ASF dual-hosted git repository.
>>
>> markt-asf pushed a commit to branch main
>> in repository https://gitbox.apache.org/repos/asf/tomcat.git
>>
>>
>> The following commit(s) were added to refs/heads/main by this push:
>> new 24f501c698 Second version of replay protection.
>> 24f501c698 is described below
>>
>> commit 24f501c698499ea5d64c406fd610fc5dcbb2904c
>> Author: Mark Thomas <ma...@apache.org>
>> AuthorDate: Wed Jun 17 18:11:56 2026 +0100
>>
>> Second version of replay protection.
>> Co-authored with GPT
>> ---
>> .../group/interceptors/EncryptInterceptor.java | 197 +++++++++++
>> ++++++++--
>> .../interceptors/EncryptInterceptorMBean.java | 28 +++
>> .../group/interceptors/LocalStrings.properties | 3 +
>> .../group/interceptors/TestEncryptInterceptor.java | 100 +++++++++++
>> webapps/docs/changelog.xml | 4 +
>> webapps/docs/config/cluster-interceptor.xml | 31 +++-
>> 6 files changed, 345 insertions(+), 18 deletions(-)
>>
>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>> EncryptInterceptor.java b/java/org/apache/catalina/tribes/group/
>> interceptors/EncryptInterceptor.java
>> index 5cc9359a5d..0616ab03c0 100644
>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>> EncryptInterceptor.java
>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>> EncryptInterceptor.java
>> @@ -22,7 +22,11 @@ import java.security.NoSuchAlgorithmException;
>> import java.security.NoSuchProviderException;
>> import java.security.SecureRandom;
>> import java.security.spec.AlgorithmParameterSpec;
>> +import java.util.ArrayDeque;
>> +import java.util.HashMap;
>> import java.util.Locale;
>> +import java.util.Map;
>> +import java.util.TreeMap;
>> import java.util.concurrent.ConcurrentLinkedQueue;
>> import javax.crypto.Cipher;
>> @@ -36,6 +40,7 @@ import org.apache.catalina.tribes.ChannelException;
>> import org.apache.catalina.tribes.ChannelInterceptor;
>> import org.apache.catalina.tribes.ChannelMessage;
>> import org.apache.catalina.tribes.Member;
>> +import org.apache.catalina.tribes.UniqueId;
>> import org.apache.catalina.tribes.group.ChannelInterceptorBase;
>> import org.apache.catalina.tribes.group.InterceptorPayload;
>> import org.apache.catalina.tribes.io.XByteBuffer;
>> @@ -63,6 +68,9 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> private String encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGORITHM;
>> private byte[] encryptionKeyBytes;
>> private String encryptionKeyString;
>> + // Milliseconds
>> + private long replayWindowTime = 10_000;
>> + private int replayWindowMessageCount = 8192;
>> private BaseEncryptionManager encryptionManager;
>> @@ -80,7 +88,7 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> if (Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) {
>> try {
>> encryptionManager =
>> createEncryptionManager(getEncryptionAlgorithm(),
>> getEncryptionKeyInternal(),
>> - getProviderName());
>> + getProviderName(), getReplayWindowTime(),
>> getReplayWindowMessageCount());
>> } catch (GeneralSecurityException gse) {
>> throw new
>> ChannelException(sm.getString("encryptInterceptor.init.failed"), gse);
>> }
>> @@ -114,9 +122,18 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> throws ChannelException {
>> try {
>> byte[] data = msg.getMessage().getBytes();
>> + // Need trusted time stamp on receiving side, so add time
>> stamp to encrypted data.
>> + long timestamp = msg.getTimestamp();
>> + if (timestamp <= 0) {
>> + timestamp = System.currentTimeMillis();
>> + msg.setTimestamp(timestamp);
>> + }
>> + byte[] message = new byte[data.length + 8];
>> + XByteBuffer.toBytes(timestamp, message, 0);
>> + System.arraycopy(data, 0, message, 8, data.length);
>> // See #encrypt(byte[]) for an explanation of the return
>> value
>> - byte[][] bytes = encryptionManager.encrypt(data);
>> + byte[][] bytes = encryptionManager.encrypt(message);
>> XByteBuffer xbb = msg.getMessage();
>> @@ -137,14 +154,32 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> public void messageReceived(ChannelMessage msg) {
>> try {
>> byte[] data = msg.getMessage().getBytes();
>> + byte[] encryptedData = data;
>> data = encryptionManager.decrypt(data);
>> + if (data.length < 8) {
>> + throw new
>> GeneralSecurityException(sm.getString("encryptInterceptor.decrypt.error.short-message"));
>> + }
>> + /*
>> + * This is trusted since it was encrypted.
>> + *
>> + * Excessive clock skew will cause problems here. Can't
>> address that without creating risks of replay
>> + * attacks.
>> + */
>> + long trustedTimstamp = XByteBuffer.toLong(data, 0);
>> + if (!
>> encryptionManager.checkIncomingMessage(encryptedData, trustedTimstamp)) {
>> +
>> log.error(sm.getString("encryptInterceptor.decrypt.replay"));
>> + return;
>> + }
>> XByteBuffer xbb = msg.getMessage();
>> - // Completely replace the message with the decrypted one
>> + /*
>> + * Completely replace the message with the decrypted one.
>> No need to replace time stamp. At this point it
>> + * will be the same as the trusted time stamp.
>> + */
>> xbb.clear();
>> - xbb.append(data, 0, data.length);
>> + xbb.append(data, 8, data.length - 8);
>> super.messageReceived(msg);
>> } catch (GeneralSecurityException gse) {
>> @@ -274,6 +309,58 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> return providerName;
>> }
>> + /**
>> + * Returns the time-based replay window in milliseconds.
>> + *
>> + * @return The replay window time
>> + */
>> + @Override
>> + public long getReplayWindowTime() {
>> + return replayWindowTime;
>> + }
>> +
>> + /**
>> + * Sets the time-based replay window in milliseconds.
>> + *
>> + * @param replayWindowTime The replay window time
>> + */
>> + @Override
>> + public void setReplayWindowTime(long replayWindowTime) {
>> + if (replayWindowTime < 1) {
>> + throw new
>> IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowTime.tooSmall"));
>> + }
>> + this.replayWindowTime = replayWindowTime;
>> + if (encryptionManager != null) {
>> + encryptionManager.setReplayWindowTime(replayWindowTime);
>> + }
>> + }
>> +
>> + /**
>> + * Returns the maximum number of replay entries to retain.
>> + *
>> + * @return The replay window message count
>> + */
>> + @Override
>> + public int getReplayWindowMessageCount() {
>> + return replayWindowMessageCount;
>> + }
>> +
>> + /**
>> + * Sets the maximum number of replay entries to retain.
>> + *
>> + * @param replayWindowMessageCount The replay window message count
>> + */
>> + @Override
>> + public void setReplayWindowMessageCount(int
>> replayWindowMessageCount) {
>> + if (replayWindowMessageCount < 1) {
>> + throw new
>> IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowMessageCount.tooSmall"));
>> + }
>> + this.replayWindowMessageCount = replayWindowMessageCount;
>> + if (encryptionManager != null) {
>> +
>> encryptionManager.setReplayWindowMessageCount(replayWindowMessageCount);
>> + }
>> + }
>> +
>> // Copied from org.apache.tomcat.util.buf.HexUtils
>> // @formatter:off
>> private static final int[] DEC = {
>> @@ -320,7 +407,8 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> }
>> private static BaseEncryptionManager
>> createEncryptionManager(String algorithm, byte[] encryptionKey,
>> - String providerName) throws NoSuchAlgorithmException,
>> NoSuchPaddingException, NoSuchProviderException {
>> + String providerName, long replayWindowTime, int
>> replayWindowMessageCount)
>> + throws NoSuchAlgorithmException, NoSuchPaddingException,
>> NoSuchProviderException {
>> if (null == encryptionKey) {
>> throw new
>> IllegalStateException(sm.getString("encryptInterceptor.key.required"));
>> }
>> @@ -359,8 +447,7 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> */
>> if ("NONE".equals(algorithmMode) ||
>> "ECB".equals(algorithmMode) || "PCBC".equals(algorithmMode) ||
>> "CTS".equals(algorithmMode) ||
>> "KW".equals(algorithmMode) || "KWP".equals(algorithmMode) ||
>> - "CTR".equals(algorithmMode) ||
>> - ("CBC".equals(algorithmMode) &&
>> "NOPADDING".equals(algorithmPadding)) ||
>> + "CTR".equals(algorithmMode) ||
>> ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
>> ("CFB".equals(algorithmMode) &&
>> "NOPADDING".equals(algorithmPadding)) ||
>> ("GCM".equals(algorithmMode) &&
>> "PKCS5PADDING".equals(algorithmPadding)) ||
>> ("OFB".equals(algorithmMode) &&
>> "NOPADDING".equals(algorithmPadding))) {
>> @@ -375,17 +462,18 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> } else if (algorithmMode.startsWith("CFB") ||
>> algorithmMode.startsWith("OFB")) {
>> // Using a non-default block size. Not supported as
>> insecure and/or inefficient.
>> - throw new IllegalArgumentException(
>> -
>> sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>> + throw new
>> IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>> } else if ("GCM".equals(algorithmMode) &&
>> "NOPADDING".equals(algorithmPadding)) {
>> // Needs a specialised encryption manager to handle the
>> differences between GCM and other modes
>> - return new GCMEncryptionManager(algorithm, new
>> SecretKeySpec(encryptionKey, algorithmName), providerName);
>> + return new GCMEncryptionManager(algorithm, new
>> SecretKeySpec(encryptionKey, algorithmName), providerName,
>> + replayWindowTime, replayWindowMessageCount);
>> }
>> // Use the default encryption manager
>> try {
>> - return new BaseEncryptionManager(algorithm, new
>> SecretKeySpec(encryptionKey, algorithmName), providerName);
>> + return new BaseEncryptionManager(algorithm, new
>> SecretKeySpec(encryptionKey, algorithmName), providerName,
>> + replayWindowTime, replayWindowMessageCount);
>> } catch (NoSuchAlgorithmException | NoSuchPaddingException |
>> NoSuchProviderException ex) {
>> throw new
>> IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm), ex);
>> }
>> @@ -423,12 +511,22 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> * SecureRandom is thread-safe, but sharing a single
>> instance will likely be a bottleneck.
>> */
>> private final ConcurrentLinkedQueue<SecureRandom> randomPool;
>> -
>> - BaseEncryptionManager(String algorithm, SecretKeySpec
>> secretKey, String providerName)
>> + private final TreeMap<Long,ArrayDeque<UniqueId>>
>> receivedTimestampNonces = new TreeMap<>();
>> + private final Map<UniqueId,Long> receivedNonceTimestamps =
>> new HashMap<>();
>> + private long replayWindowTime;
>> + private volatile int replayWindowMessageCount;
>> + private long lastRemovedTimestamp;
>> + private int receivedNonceCount = 0;
>> +
>> + BaseEncryptionManager(String algorithm, SecretKeySpec
>> secretKey, String providerName, long replayWindowTime,
>> + int replayWindowMessageCount)
>> throws NoSuchAlgorithmException,
>> NoSuchPaddingException, NoSuchProviderException {
>> this.algorithm = algorithm;
>> this.providerName = providerName;
>> this.secretKey = secretKey;
>> + this.replayWindowTime = replayWindowTime;
>> + this.lastRemovedTimestamp = System.currentTimeMillis() -
>> replayWindowTime;
>> + this.replayWindowMessageCount = replayWindowMessageCount;
>> cipherPool = new ConcurrentLinkedQueue<>();
>> Cipher cipher = createCipher();
>> @@ -441,6 +539,52 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> // Individual Cipher and SecureRandom objects need no
>> explicit tear down
>> cipherPool.clear();
>> randomPool.clear();
>> + synchronized (this) {
>> + receivedTimestampNonces.clear();
>> + receivedNonceTimestamps.clear();
>> + lastRemovedTimestamp = Long.MIN_VALUE;
>> + receivedNonceCount = 0;
>> + }
>> + }
>> +
>> + public synchronized void setReplayWindowTime(long
>> replayWindowTime) {
>> + this.replayWindowTime = replayWindowTime;
>> + // Only move the lastRemovedTimestamp forwards. Moving it
>> backwards could open a window for replay attacks.
>> + if (lastRemovedTimestamp < System.currentTimeMillis() -
>> replayWindowTime) {
>> + lastRemovedTimestamp = System.currentTimeMillis() -
>> replayWindowTime;
>> + }
>> + }
>> +
>> + public void setReplayWindowMessageCount(int
>> replayWindowMessageCount) {
>> + this.replayWindowMessageCount = replayWindowMessageCount;
>> + synchronized (this) {
>> + while (receivedNonceCount > replayWindowMessageCount) {
>> + removeEldestEntry();
>> + }
>> + }
>> + }
>> +
>> + public synchronized boolean checkIncomingMessage(byte[]
>> bytes, long messageTimestamp) {
>> + if (messageTimestamp < (System.currentTimeMillis() -
>> replayWindowTime)) {
>> + return false;
>> + }
>> + if (messageTimestamp <= lastRemovedTimestamp) {
>> + return false;
>> + }
>> +
>> + UniqueId nonce = new UniqueId(bytes, 0, getIVSize());
>> + if (receivedNonceTimestamps.containsKey(nonce)) {
>> + return false;
>> + }
>> +
>> +
>> receivedTimestampNonces.computeIfAbsent(Long.valueOf(messageTimestamp), k -> new ArrayDeque<>()).addLast(nonce);
>> + receivedNonceTimestamps.put(nonce,
>> Long.valueOf(messageTimestamp));
>> + receivedNonceCount++;
>> + while (receivedNonceCount > replayWindowMessageCount) {
>> + removeEldestEntry();
>> + }
>> +
>> + return true;
>> }
>> private String getAlgorithm() {
>> @@ -593,6 +737,28 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> protected AlgorithmParameterSpec generateIV(byte[] ivBytes,
>> int offset, int length) {
>> return new IvParameterSpec(ivBytes, offset, length);
>> }
>> +
>> + private void removeEldestEntry() {
>> + Map.Entry<Long,ArrayDeque<UniqueId>> entry =
>> receivedTimestampNonces.firstEntry();
>> + if (entry != null) {
>> + ArrayDeque<UniqueId> nonces = entry.getValue();
>> + UniqueId nonce = nonces.pollFirst();
>> + if (nonce != null) {
>> + receivedNonceTimestamps.remove(nonce);
>> +
>> updateLastRemovedTimestamp(entry.getKey().longValue());
>> + receivedNonceCount--;
>> + }
>> + if (nonces.isEmpty()) {
>> + receivedTimestampNonces.pollFirstEntry();
>> + }
>> + }
>> + }
>> +
>> + private void updateLastRemovedTimestamp(long removedTimestamp) {
>> + if (removedTimestamp > lastRemovedTimestamp) {
>> + lastRemovedTimestamp = removedTimestamp;
>> + }
>> + }
>> }
>> /**
>> @@ -611,9 +777,10 @@ public class EncryptInterceptor extends
>> ChannelInterceptorBase implements Encryp
>> * number of bits supported 128-bit provide the best security.
>> */
>> private static class GCMEncryptionManager extends
>> BaseEncryptionManager {
>> - GCMEncryptionManager(String algorithm, SecretKeySpec
>> secretKey, String providerName)
>> + GCMEncryptionManager(String algorithm, SecretKeySpec
>> secretKey, String providerName, long replayWindowTime,
>> + int replayWindowMessageCount)
>> throws NoSuchAlgorithmException,
>> NoSuchPaddingException, NoSuchProviderException {
>> - super(algorithm, secretKey, providerName);
>> + super(algorithm, secretKey, providerName,
>> replayWindowTime, replayWindowMessageCount);
>> }
>> @Override
>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>> EncryptInterceptorMBean.java b/java/org/apache/catalina/tribes/group/
>> interceptors/EncryptInterceptorMBean.java
>> index 7d10a1f4f1..c6362b26c5 100644
>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>> EncryptInterceptorMBean.java
>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>> EncryptInterceptorMBean.java
>> @@ -76,4 +76,32 @@ public interface EncryptInterceptorMBean {
>> * @return the JCA provider name, or {@code null} for default
>> */
>> String getProviderName();
>> +
>> + /**
>> + * Sets the time-based replay window in milliseconds.
>> + *
>> + * @param replayWindowTime the replay window time
>> + */
>> + void setReplayWindowTime(long replayWindowTime);
>> +
>> + /**
>> + * Returns the time-based replay window in milliseconds.
>> + *
>> + * @return the replay window time
>> + */
>> + long getReplayWindowTime();
>> +
>> + /**
>> + * Sets the maximum number of replay cache entries.
>> + *
>> + * @param replayWindowMessageCount the replay window message count
>> + */
>> + void setReplayWindowMessageCount(int replayWindowMessageCount);
>> +
>> + /**
>> + * Returns the maximum number of replay cache entries.
>> + *
>> + * @return the replay window message count
>> + */
>> + int getReplayWindowMessageCount();
>> }
>> diff --git a/java/org/apache/catalina/tribes/group/interceptors/
>> LocalStrings.properties b/java/org/apache/catalina/tribes/group/
>> interceptors/LocalStrings.properties
>> index a8e3c5d6e5..5c13178485 100644
>> --- a/java/org/apache/catalina/tribes/group/interceptors/
>> LocalStrings.properties
>> +++ b/java/org/apache/catalina/tribes/group/interceptors/
>> LocalStrings.properties
>> @@ -21,9 +21,12 @@ encryptInterceptor.algorithm.switch=The
>> EncryptInterceptor is using the algorith
>> encryptInterceptor.algorithm.unsupported=EncryptInterceptor does not
>> support algorithm [{0}]
>> encryptInterceptor.decrypt.error.short-message=Failed to decrypt
>> message: premature end-of-message
>> encryptInterceptor.decrypt.failed=Failed to decrypt message
>> +encryptInterceptor.decrypt.replay=Failed to decrypt message: replay
>> attack detected
>> encryptInterceptor.encrypt.failed=Failed to encrypt message
>> encryptInterceptor.init.failed=Failed to initialize EncryptInterceptor
>> encryptInterceptor.key.required=Encryption key is required
>> +encryptInterceptor.replayWindowMessageCount.tooSmall=Replay window
>> message count must be at least 1
>> +encryptInterceptor.replayWindowTime.tooSmall=Replay window time must
>> be at least 1 millisecond
>> encryptInterceptor.tcpFailureDetector.ordering=EncryptInterceptor
>> must be upstream of TcpFailureDetector. Please re-order
>> EncryptInterceptor to be listed before TcpFailureDetector in your
>> channel interceptor pipeline.
>> fragmentationInterceptor.fragments.missing=Fragments are missing.
>> diff --git a/test/org/apache/catalina/tribes/group/interceptors/
>> TestEncryptInterceptor.java b/test/org/apache/catalina/tribes/group/
>> interceptors/TestEncryptInterceptor.java
>> index e30e84321f..8c6785c4a8 100644
>> --- a/test/org/apache/catalina/tribes/group/interceptors/
>> TestEncryptInterceptor.java
>> +++ b/test/org/apache/catalina/tribes/group/interceptors/
>> TestEncryptInterceptor.java
>> @@ -216,6 +216,90 @@ public class TestEncryptInterceptor extends
>> EncryptionInterceptorBaseTest {
>> cipherText1, IsNot.not(IsEqual.equalTo(cipherText2)));
>> }
>> + @Test
>> + public void testRejectReplay() throws Exception {
>> + src.setNext(new ValueCaptureInterceptor());
>> + dest.setPrevious(new ValuesCaptureInterceptor());
>> + src.start(Channel.SND_TX_SEQ);
>> + dest.start(Channel.SND_TX_SEQ);
>> +
>> + byte[] encrypted = encrypt("msg-1", System.currentTimeMillis());
>> +
>> + deliver(encrypted);
>> + deliver(encrypted);
>> +
>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>> dest.getPrevious()).getValues();
>> + Assert.assertEquals(1, messages.size());
>> + }
>> +
>> + @Test
>> + public void testReplayWindowRejectsOldMessage() throws Exception {
>> + src.setNext(new ValueCaptureInterceptor());
>> + dest.setPrevious(new ValuesCaptureInterceptor());
>> + dest.setReplayWindowTime(50);
>> + src.start(Channel.SND_TX_SEQ);
>> + dest.start(Channel.SND_TX_SEQ);
>> +
>> + long now = System.currentTimeMillis();
>> + byte[] encryptedRecent = encrypt("msg-recent", now);
>> + byte[] encryptedOld = encrypt("msg-old", now - 1000);
>> +
>> + deliver(encryptedRecent);
>> + deliver(encryptedOld);
>> +
>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>> dest.getPrevious()).getValues();
>> + Assert.assertEquals(1, messages.size());
>> + }
>> +
>> + @Test
>> + public void testReplayWindowRejectsOldMessageAfterCountEviction()
>> throws Exception {
>> + src.setNext(new ValueCaptureInterceptor());
>> + dest.setPrevious(new ValuesCaptureInterceptor());
>> + dest.setReplayWindowMessageCount(2);
>> + src.start(Channel.SND_TX_SEQ);
>> + dest.start(Channel.SND_TX_SEQ);
>> +
>> + long now = System.currentTimeMillis();
>> + byte[] encrypted1000 = encrypt("msg-1000", now);
>> + byte[] encrypted1001 = encrypt("msg-1001", now + 1);
>> + byte[] encrypted1002 = encrypt("msg-1002", now + 2);
>> + byte[] encrypted1003 = encrypt("msg-1003", now + 3);
>> +
>> + deliver(encrypted1000);
>> + deliver(encrypted1001);
>> + deliver(encrypted1002);
>> + deliver(encrypted1003);
>> + deliver(encrypted1000);
>> +
>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>> dest.getPrevious()).getValues();
>> + Assert.assertEquals(4, messages.size());
>> + }
>> +
>> + @Test
>> + public void testReplayWindowEvictsOldestTimestampFirst() throws
>> Exception {
>> + src.setNext(new ValueCaptureInterceptor());
>> + dest.setPrevious(new ValuesCaptureInterceptor());
>> + dest.setReplayWindowMessageCount(2);
>> + src.start(Channel.SND_TX_SEQ);
>> + dest.start(Channel.SND_TX_SEQ);
>> +
>> + long now = System.currentTimeMillis();
>> + byte[] encrypted300 = encrypt("msg-300", now + 300);
>> + byte[] encrypted100 = encrypt("msg-100", now + 100);
>> + byte[] encrypted200 = encrypt("msg-200", now + 200);
>> + byte[] encrypted225 = encrypt("msg-225", now + 225);
>> + byte[] encrypted250 = encrypt("msg-250", now + 250);
>> +
>> + deliver(encrypted300);
>> + deliver(encrypted100);
>> + deliver(encrypted200);
>> + deliver(encrypted225);
>> + deliver(encrypted250);
>> +
>> + Collection<byte[]> messages = ((ValuesCaptureInterceptor)
>> dest.getPrevious()).getValues();
>> + Assert.assertEquals(5, messages.size());
>> + }
>> +
>> @Test
>> public void testPickup() throws Exception {
>> File file = new File(MESSAGE_FILE);
>> @@ -316,4 +400,20 @@ public class TestEncryptInterceptor extends
>> EncryptionInterceptorBaseTest {
>> Assert.fail("EncryptionInterceptor should throw
>> ChannelConfigException, not " + t.getClass().getName());
>> }
>> }
>> +
>> + private byte[] encrypt(String message, long timestamp) throws
>> Exception {
>> + ChannelData msg = new ChannelData(false);
>> + msg.setTimestamp(timestamp);
>> + msg.setMessage(new XByteBuffer(message.getBytes("UTF-8"),
>> false));
>> + src.sendMessage(null, msg, null);
>> + return ((ValueCaptureInterceptor) src.getNext()).getValue();
>> + }
>> +
>> + private void deliver(byte[] encrypted) {
>> + ChannelData incoming = new ChannelData(false);
>> + XByteBuffer xbb = new XByteBuffer(encrypted.length, false);
>> + xbb.append(encrypted, 0, encrypted.length);
>> + incoming.setMessage(xbb);
>> + dest.messageReceived(incoming);
>> + }
>> }
>> diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
>> index 26d01987ed..e9dd0bdf10 100644
>> --- a/webapps/docs/changelog.xml
>> +++ b/webapps/docs/changelog.xml
>> @@ -602,6 +602,10 @@
>> Fix concurrency issues generating MD5 digests in the
>> <code>CloudMembershipProvider</code> implementations. (markt)
>> </fix>
>> + <add>
>> + Add replay protection to the <code>EncryptInterceptor</code>.
>> This us a
>> + breaking change for the <code>EncryptInterceptor</code>.(markt)
>> + </add>
>> </changelog>
>> </subsection>
>> <subsection name="WebSocket">
>> diff --git a/webapps/docs/config/cluster-interceptor.xml b/webapps/
>> docs/config/cluster-interceptor.xml
>> index d0f5f88537..9b26c8ed73 100644
>> --- a/webapps/docs/config/cluster-interceptor.xml
>> +++ b/webapps/docs/config/cluster-interceptor.xml
>> @@ -212,6 +212,12 @@
>> <i>before</i> the <code>TcpFailureDetector</code> on the sender
>> and <i>after</i>
>> it on the receiver, otherwise message corruption will occur.
>> </p>
>> + <p>
>> + The EncryptInterceptor uses the timestamp of the message to
>> provide replay
>> + protection. For this to be effective, clock skew between cluster
>> nodes
>> + should be minimised. If not, it is likely a high proportion of
>> valid
>> + messages will be rejected.
>> + </p>
>> <attributes>
>> <attribute name="encryptionAlgorithm" required="false">
>> <p>The encryption algorithm to be used, including the mode
>> and padding.
>> @@ -231,11 +237,30 @@
>> <p>The default algorithm is <code>AES/GCM/NoPadding</code>.</p>
>> </attribute>
>> <attribute name="encryptionKey" required="true">
>> - The key to be used with the encryption algorithm.
>> + <p>The key to be used with the encryption algorithm.</p>
>> - The key should be specified as hex-encoded bytes of the
>> appropriate
>> + <p>The key should be specified as hex-encoded bytes of the
>> appropriate
>> length for the algorithm (e.g. 16 bytes / 32 characters / 128
>> bits for
>> - AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).
>> + AES-128, 32 bytes / 64 characters / 256 bits for AES-256,
>> etc.).</p>
>> + </attribute>
>> + <attribute name="replayWindowTime" required="false">
>> + <p>Messages with a timestamp before the current time less this
>> window
>> + will be rejected. This needs to account for clock skew across
>> the cluster
>> + as well as the expected maximum delay between messages being
>> sent and
>> + received. Specified in milliseconds. If not specified, the
>> default value
>> + of 10000 (10 seconds) will be used.</p>
>> + </attribute>
>> + <attribute name="replayWindowMessageCount" required="false">
>> + <p>The number of past messages for which the nonces will be
>> tracked to
>> + prevent replay attacks. Messages with a nonce that has already
>> been
>> + tracked will be rejected. If not specified, the default value
>> of 8192
>> + will be used.</p>
>> +
>> + <p>If messages are received at a high rate, it is possible
>> that a nonce
>> + will be removed from the list of tracked nonces before
>> + <code>replayWindowTime</code> milliseconds have elapsed. If
>> that happens,
>> + any message with a timestamp older than the last nonce evicted
>> from the
>> + list of tracked nonces will also be rejected.</p>
>> </attribute>
>> </attributes>
>> </subsection>
>>
>>
>> ---------------------------------------------------------------------
>> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
>> For additional commands, e-mail: dev-help@tomcat.apache.org
>>
>
>
> ---------------------------------------------------------------------
> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
> For additional commands, e-mail: dev-help@tomcat.apache.org
>
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org
Re: (tomcat) branch main updated: Second version of replay protection.
Posted by Christopher Schultz <ch...@christopherschultz.net>.
Mark,
Since this is a breaking change, I think we need something in here:
https://tomcat.apache.org/migration-11.0.html#Tomcat_11.0.x_noteable_changes
I can draft this if you'd like.
-chris
On 6/17/26 1:12 PM, markt@apache.org wrote:
> This is an automated email from the ASF dual-hosted git repository.
>
> markt-asf pushed a commit to branch main
> in repository https://gitbox.apache.org/repos/asf/tomcat.git
>
>
> The following commit(s) were added to refs/heads/main by this push:
> new 24f501c698 Second version of replay protection.
> 24f501c698 is described below
>
> commit 24f501c698499ea5d64c406fd610fc5dcbb2904c
> Author: Mark Thomas <ma...@apache.org>
> AuthorDate: Wed Jun 17 18:11:56 2026 +0100
>
> Second version of replay protection.
>
> Co-authored with GPT
> ---
> .../group/interceptors/EncryptInterceptor.java | 197 +++++++++++++++++++--
> .../interceptors/EncryptInterceptorMBean.java | 28 +++
> .../group/interceptors/LocalStrings.properties | 3 +
> .../group/interceptors/TestEncryptInterceptor.java | 100 +++++++++++
> webapps/docs/changelog.xml | 4 +
> webapps/docs/config/cluster-interceptor.xml | 31 +++-
> 6 files changed, 345 insertions(+), 18 deletions(-)
>
> diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
> index 5cc9359a5d..0616ab03c0 100644
> --- a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
> +++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
> @@ -22,7 +22,11 @@ import java.security.NoSuchAlgorithmException;
> import java.security.NoSuchProviderException;
> import java.security.SecureRandom;
> import java.security.spec.AlgorithmParameterSpec;
> +import java.util.ArrayDeque;
> +import java.util.HashMap;
> import java.util.Locale;
> +import java.util.Map;
> +import java.util.TreeMap;
> import java.util.concurrent.ConcurrentLinkedQueue;
>
> import javax.crypto.Cipher;
> @@ -36,6 +40,7 @@ import org.apache.catalina.tribes.ChannelException;
> import org.apache.catalina.tribes.ChannelInterceptor;
> import org.apache.catalina.tribes.ChannelMessage;
> import org.apache.catalina.tribes.Member;
> +import org.apache.catalina.tribes.UniqueId;
> import org.apache.catalina.tribes.group.ChannelInterceptorBase;
> import org.apache.catalina.tribes.group.InterceptorPayload;
> import org.apache.catalina.tribes.io.XByteBuffer;
> @@ -63,6 +68,9 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> private String encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGORITHM;
> private byte[] encryptionKeyBytes;
> private String encryptionKeyString;
> + // Milliseconds
> + private long replayWindowTime = 10_000;
> + private int replayWindowMessageCount = 8192;
>
>
> private BaseEncryptionManager encryptionManager;
> @@ -80,7 +88,7 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> if (Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) {
> try {
> encryptionManager = createEncryptionManager(getEncryptionAlgorithm(), getEncryptionKeyInternal(),
> - getProviderName());
> + getProviderName(), getReplayWindowTime(), getReplayWindowMessageCount());
> } catch (GeneralSecurityException gse) {
> throw new ChannelException(sm.getString("encryptInterceptor.init.failed"), gse);
> }
> @@ -114,9 +122,18 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> throws ChannelException {
> try {
> byte[] data = msg.getMessage().getBytes();
> + // Need trusted time stamp on receiving side, so add time stamp to encrypted data.
> + long timestamp = msg.getTimestamp();
> + if (timestamp <= 0) {
> + timestamp = System.currentTimeMillis();
> + msg.setTimestamp(timestamp);
> + }
> + byte[] message = new byte[data.length + 8];
> + XByteBuffer.toBytes(timestamp, message, 0);
> + System.arraycopy(data, 0, message, 8, data.length);
>
> // See #encrypt(byte[]) for an explanation of the return value
> - byte[][] bytes = encryptionManager.encrypt(data);
> + byte[][] bytes = encryptionManager.encrypt(message);
>
> XByteBuffer xbb = msg.getMessage();
>
> @@ -137,14 +154,32 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> public void messageReceived(ChannelMessage msg) {
> try {
> byte[] data = msg.getMessage().getBytes();
> + byte[] encryptedData = data;
>
> data = encryptionManager.decrypt(data);
> + if (data.length < 8) {
> + throw new GeneralSecurityException(sm.getString("encryptInterceptor.decrypt.error.short-message"));
> + }
> + /*
> + * This is trusted since it was encrypted.
> + *
> + * Excessive clock skew will cause problems here. Can't address that without creating risks of replay
> + * attacks.
> + */
> + long trustedTimstamp = XByteBuffer.toLong(data, 0);
> + if (!encryptionManager.checkIncomingMessage(encryptedData, trustedTimstamp)) {
> + log.error(sm.getString("encryptInterceptor.decrypt.replay"));
> + return;
> + }
>
> XByteBuffer xbb = msg.getMessage();
>
> - // Completely replace the message with the decrypted one
> + /*
> + * Completely replace the message with the decrypted one. No need to replace time stamp. At this point it
> + * will be the same as the trusted time stamp.
> + */
> xbb.clear();
> - xbb.append(data, 0, data.length);
> + xbb.append(data, 8, data.length - 8);
>
> super.messageReceived(msg);
> } catch (GeneralSecurityException gse) {
> @@ -274,6 +309,58 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> return providerName;
> }
>
> + /**
> + * Returns the time-based replay window in milliseconds.
> + *
> + * @return The replay window time
> + */
> + @Override
> + public long getReplayWindowTime() {
> + return replayWindowTime;
> + }
> +
> + /**
> + * Sets the time-based replay window in milliseconds.
> + *
> + * @param replayWindowTime The replay window time
> + */
> + @Override
> + public void setReplayWindowTime(long replayWindowTime) {
> + if (replayWindowTime < 1) {
> + throw new IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowTime.tooSmall"));
> + }
> + this.replayWindowTime = replayWindowTime;
> + if (encryptionManager != null) {
> + encryptionManager.setReplayWindowTime(replayWindowTime);
> + }
> + }
> +
> + /**
> + * Returns the maximum number of replay entries to retain.
> + *
> + * @return The replay window message count
> + */
> + @Override
> + public int getReplayWindowMessageCount() {
> + return replayWindowMessageCount;
> + }
> +
> + /**
> + * Sets the maximum number of replay entries to retain.
> + *
> + * @param replayWindowMessageCount The replay window message count
> + */
> + @Override
> + public void setReplayWindowMessageCount(int replayWindowMessageCount) {
> + if (replayWindowMessageCount < 1) {
> + throw new IllegalArgumentException(sm.getString("encryptInterceptor.replayWindowMessageCount.tooSmall"));
> + }
> + this.replayWindowMessageCount = replayWindowMessageCount;
> + if (encryptionManager != null) {
> + encryptionManager.setReplayWindowMessageCount(replayWindowMessageCount);
> + }
> + }
> +
> // Copied from org.apache.tomcat.util.buf.HexUtils
> // @formatter:off
> private static final int[] DEC = {
> @@ -320,7 +407,8 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> }
>
> private static BaseEncryptionManager createEncryptionManager(String algorithm, byte[] encryptionKey,
> - String providerName) throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> + String providerName, long replayWindowTime, int replayWindowMessageCount)
> + throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> if (null == encryptionKey) {
> throw new IllegalStateException(sm.getString("encryptInterceptor.key.required"));
> }
> @@ -359,8 +447,7 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> */
> if ("NONE".equals(algorithmMode) || "ECB".equals(algorithmMode) || "PCBC".equals(algorithmMode) ||
> "CTS".equals(algorithmMode) || "KW".equals(algorithmMode) || "KWP".equals(algorithmMode) ||
> - "CTR".equals(algorithmMode) ||
> - ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
> + "CTR".equals(algorithmMode) || ("CBC".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
> ("CFB".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) ||
> ("GCM".equals(algorithmMode) && "PKCS5PADDING".equals(algorithmPadding)) ||
> ("OFB".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding))) {
> @@ -375,17 +462,18 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
>
> } else if (algorithmMode.startsWith("CFB") || algorithmMode.startsWith("OFB")) {
> // Using a non-default block size. Not supported as insecure and/or inefficient.
> - throw new IllegalArgumentException(
> - sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
> + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm));
>
> } else if ("GCM".equals(algorithmMode) && "NOPADDING".equals(algorithmPadding)) {
> // Needs a specialised encryption manager to handle the differences between GCM and other modes
> - return new GCMEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName);
> + return new GCMEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName,
> + replayWindowTime, replayWindowMessageCount);
> }
>
> // Use the default encryption manager
> try {
> - return new BaseEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName);
> + return new BaseEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName,
> + replayWindowTime, replayWindowMessageCount);
> } catch (NoSuchAlgorithmException | NoSuchPaddingException | NoSuchProviderException ex) {
> throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported", algorithm), ex);
> }
> @@ -423,12 +511,22 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> * SecureRandom is thread-safe, but sharing a single instance will likely be a bottleneck.
> */
> private final ConcurrentLinkedQueue<SecureRandom> randomPool;
> -
> - BaseEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName)
> + private final TreeMap<Long,ArrayDeque<UniqueId>> receivedTimestampNonces = new TreeMap<>();
> + private final Map<UniqueId,Long> receivedNonceTimestamps = new HashMap<>();
> + private long replayWindowTime;
> + private volatile int replayWindowMessageCount;
> + private long lastRemovedTimestamp;
> + private int receivedNonceCount = 0;
> +
> + BaseEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName, long replayWindowTime,
> + int replayWindowMessageCount)
> throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> this.algorithm = algorithm;
> this.providerName = providerName;
> this.secretKey = secretKey;
> + this.replayWindowTime = replayWindowTime;
> + this.lastRemovedTimestamp = System.currentTimeMillis() - replayWindowTime;
> + this.replayWindowMessageCount = replayWindowMessageCount;
>
> cipherPool = new ConcurrentLinkedQueue<>();
> Cipher cipher = createCipher();
> @@ -441,6 +539,52 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> // Individual Cipher and SecureRandom objects need no explicit tear down
> cipherPool.clear();
> randomPool.clear();
> + synchronized (this) {
> + receivedTimestampNonces.clear();
> + receivedNonceTimestamps.clear();
> + lastRemovedTimestamp = Long.MIN_VALUE;
> + receivedNonceCount = 0;
> + }
> + }
> +
> + public synchronized void setReplayWindowTime(long replayWindowTime) {
> + this.replayWindowTime = replayWindowTime;
> + // Only move the lastRemovedTimestamp forwards. Moving it backwards could open a window for replay attacks.
> + if (lastRemovedTimestamp < System.currentTimeMillis() - replayWindowTime) {
> + lastRemovedTimestamp = System.currentTimeMillis() - replayWindowTime;
> + }
> + }
> +
> + public void setReplayWindowMessageCount(int replayWindowMessageCount) {
> + this.replayWindowMessageCount = replayWindowMessageCount;
> + synchronized (this) {
> + while (receivedNonceCount > replayWindowMessageCount) {
> + removeEldestEntry();
> + }
> + }
> + }
> +
> + public synchronized boolean checkIncomingMessage(byte[] bytes, long messageTimestamp) {
> + if (messageTimestamp < (System.currentTimeMillis() - replayWindowTime)) {
> + return false;
> + }
> + if (messageTimestamp <= lastRemovedTimestamp) {
> + return false;
> + }
> +
> + UniqueId nonce = new UniqueId(bytes, 0, getIVSize());
> + if (receivedNonceTimestamps.containsKey(nonce)) {
> + return false;
> + }
> +
> + receivedTimestampNonces.computeIfAbsent(Long.valueOf(messageTimestamp), k -> new ArrayDeque<>()).addLast(nonce);
> + receivedNonceTimestamps.put(nonce, Long.valueOf(messageTimestamp));
> + receivedNonceCount++;
> + while (receivedNonceCount > replayWindowMessageCount) {
> + removeEldestEntry();
> + }
> +
> + return true;
> }
>
> private String getAlgorithm() {
> @@ -593,6 +737,28 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> protected AlgorithmParameterSpec generateIV(byte[] ivBytes, int offset, int length) {
> return new IvParameterSpec(ivBytes, offset, length);
> }
> +
> + private void removeEldestEntry() {
> + Map.Entry<Long,ArrayDeque<UniqueId>> entry = receivedTimestampNonces.firstEntry();
> + if (entry != null) {
> + ArrayDeque<UniqueId> nonces = entry.getValue();
> + UniqueId nonce = nonces.pollFirst();
> + if (nonce != null) {
> + receivedNonceTimestamps.remove(nonce);
> + updateLastRemovedTimestamp(entry.getKey().longValue());
> + receivedNonceCount--;
> + }
> + if (nonces.isEmpty()) {
> + receivedTimestampNonces.pollFirstEntry();
> + }
> + }
> + }
> +
> + private void updateLastRemovedTimestamp(long removedTimestamp) {
> + if (removedTimestamp > lastRemovedTimestamp) {
> + lastRemovedTimestamp = removedTimestamp;
> + }
> + }
> }
>
> /**
> @@ -611,9 +777,10 @@ public class EncryptInterceptor extends ChannelInterceptorBase implements Encryp
> * number of bits supported 128-bit provide the best security.
> */
> private static class GCMEncryptionManager extends BaseEncryptionManager {
> - GCMEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName)
> + GCMEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName, long replayWindowTime,
> + int replayWindowMessageCount)
> throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
> - super(algorithm, secretKey, providerName);
> + super(algorithm, secretKey, providerName, replayWindowTime, replayWindowMessageCount);
> }
>
> @Override
> diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
> index 7d10a1f4f1..c6362b26c5 100644
> --- a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
> +++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java
> @@ -76,4 +76,32 @@ public interface EncryptInterceptorMBean {
> * @return the JCA provider name, or {@code null} for default
> */
> String getProviderName();
> +
> + /**
> + * Sets the time-based replay window in milliseconds.
> + *
> + * @param replayWindowTime the replay window time
> + */
> + void setReplayWindowTime(long replayWindowTime);
> +
> + /**
> + * Returns the time-based replay window in milliseconds.
> + *
> + * @return the replay window time
> + */
> + long getReplayWindowTime();
> +
> + /**
> + * Sets the maximum number of replay cache entries.
> + *
> + * @param replayWindowMessageCount the replay window message count
> + */
> + void setReplayWindowMessageCount(int replayWindowMessageCount);
> +
> + /**
> + * Returns the maximum number of replay cache entries.
> + *
> + * @return the replay window message count
> + */
> + int getReplayWindowMessageCount();
> }
> diff --git a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
> index a8e3c5d6e5..5c13178485 100644
> --- a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
> +++ b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties
> @@ -21,9 +21,12 @@ encryptInterceptor.algorithm.switch=The EncryptInterceptor is using the algorith
> encryptInterceptor.algorithm.unsupported=EncryptInterceptor does not support algorithm [{0}]
> encryptInterceptor.decrypt.error.short-message=Failed to decrypt message: premature end-of-message
> encryptInterceptor.decrypt.failed=Failed to decrypt message
> +encryptInterceptor.decrypt.replay=Failed to decrypt message: replay attack detected
> encryptInterceptor.encrypt.failed=Failed to encrypt message
> encryptInterceptor.init.failed=Failed to initialize EncryptInterceptor
> encryptInterceptor.key.required=Encryption key is required
> +encryptInterceptor.replayWindowMessageCount.tooSmall=Replay window message count must be at least 1
> +encryptInterceptor.replayWindowTime.tooSmall=Replay window time must be at least 1 millisecond
> encryptInterceptor.tcpFailureDetector.ordering=EncryptInterceptor must be upstream of TcpFailureDetector. Please re-order EncryptInterceptor to be listed before TcpFailureDetector in your channel interceptor pipeline.
>
> fragmentationInterceptor.fragments.missing=Fragments are missing.
> diff --git a/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
> index e30e84321f..8c6785c4a8 100644
> --- a/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
> +++ b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java
> @@ -216,6 +216,90 @@ public class TestEncryptInterceptor extends EncryptionInterceptorBaseTest {
> cipherText1, IsNot.not(IsEqual.equalTo(cipherText2)));
> }
>
> + @Test
> + public void testRejectReplay() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + byte[] encrypted = encrypt("msg-1", System.currentTimeMillis());
> +
> + deliver(encrypted);
> + deliver(encrypted);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(1, messages.size());
> + }
> +
> + @Test
> + public void testReplayWindowRejectsOldMessage() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + dest.setReplayWindowTime(50);
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + long now = System.currentTimeMillis();
> + byte[] encryptedRecent = encrypt("msg-recent", now);
> + byte[] encryptedOld = encrypt("msg-old", now - 1000);
> +
> + deliver(encryptedRecent);
> + deliver(encryptedOld);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(1, messages.size());
> + }
> +
> + @Test
> + public void testReplayWindowRejectsOldMessageAfterCountEviction() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + dest.setReplayWindowMessageCount(2);
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + long now = System.currentTimeMillis();
> + byte[] encrypted1000 = encrypt("msg-1000", now);
> + byte[] encrypted1001 = encrypt("msg-1001", now + 1);
> + byte[] encrypted1002 = encrypt("msg-1002", now + 2);
> + byte[] encrypted1003 = encrypt("msg-1003", now + 3);
> +
> + deliver(encrypted1000);
> + deliver(encrypted1001);
> + deliver(encrypted1002);
> + deliver(encrypted1003);
> + deliver(encrypted1000);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(4, messages.size());
> + }
> +
> + @Test
> + public void testReplayWindowEvictsOldestTimestampFirst() throws Exception {
> + src.setNext(new ValueCaptureInterceptor());
> + dest.setPrevious(new ValuesCaptureInterceptor());
> + dest.setReplayWindowMessageCount(2);
> + src.start(Channel.SND_TX_SEQ);
> + dest.start(Channel.SND_TX_SEQ);
> +
> + long now = System.currentTimeMillis();
> + byte[] encrypted300 = encrypt("msg-300", now + 300);
> + byte[] encrypted100 = encrypt("msg-100", now + 100);
> + byte[] encrypted200 = encrypt("msg-200", now + 200);
> + byte[] encrypted225 = encrypt("msg-225", now + 225);
> + byte[] encrypted250 = encrypt("msg-250", now + 250);
> +
> + deliver(encrypted300);
> + deliver(encrypted100);
> + deliver(encrypted200);
> + deliver(encrypted225);
> + deliver(encrypted250);
> +
> + Collection<byte[]> messages = ((ValuesCaptureInterceptor) dest.getPrevious()).getValues();
> + Assert.assertEquals(5, messages.size());
> + }
> +
> @Test
> public void testPickup() throws Exception {
> File file = new File(MESSAGE_FILE);
> @@ -316,4 +400,20 @@ public class TestEncryptInterceptor extends EncryptionInterceptorBaseTest {
> Assert.fail("EncryptionInterceptor should throw ChannelConfigException, not " + t.getClass().getName());
> }
> }
> +
> + private byte[] encrypt(String message, long timestamp) throws Exception {
> + ChannelData msg = new ChannelData(false);
> + msg.setTimestamp(timestamp);
> + msg.setMessage(new XByteBuffer(message.getBytes("UTF-8"), false));
> + src.sendMessage(null, msg, null);
> + return ((ValueCaptureInterceptor) src.getNext()).getValue();
> + }
> +
> + private void deliver(byte[] encrypted) {
> + ChannelData incoming = new ChannelData(false);
> + XByteBuffer xbb = new XByteBuffer(encrypted.length, false);
> + xbb.append(encrypted, 0, encrypted.length);
> + incoming.setMessage(xbb);
> + dest.messageReceived(incoming);
> + }
> }
> diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
> index 26d01987ed..e9dd0bdf10 100644
> --- a/webapps/docs/changelog.xml
> +++ b/webapps/docs/changelog.xml
> @@ -602,6 +602,10 @@
> Fix concurrency issues generating MD5 digests in the
> <code>CloudMembershipProvider</code> implementations. (markt)
> </fix>
> + <add>
> + Add replay protection to the <code>EncryptInterceptor</code>. This us a
> + breaking change for the <code>EncryptInterceptor</code>.(markt)
> + </add>
> </changelog>
> </subsection>
> <subsection name="WebSocket">
> diff --git a/webapps/docs/config/cluster-interceptor.xml b/webapps/docs/config/cluster-interceptor.xml
> index d0f5f88537..9b26c8ed73 100644
> --- a/webapps/docs/config/cluster-interceptor.xml
> +++ b/webapps/docs/config/cluster-interceptor.xml
> @@ -212,6 +212,12 @@
> <i>before</i> the <code>TcpFailureDetector</code> on the sender and <i>after</i>
> it on the receiver, otherwise message corruption will occur.
> </p>
> + <p>
> + The EncryptInterceptor uses the timestamp of the message to provide replay
> + protection. For this to be effective, clock skew between cluster nodes
> + should be minimised. If not, it is likely a high proportion of valid
> + messages will be rejected.
> + </p>
> <attributes>
> <attribute name="encryptionAlgorithm" required="false">
> <p>The encryption algorithm to be used, including the mode and padding.
> @@ -231,11 +237,30 @@
> <p>The default algorithm is <code>AES/GCM/NoPadding</code>.</p>
> </attribute>
> <attribute name="encryptionKey" required="true">
> - The key to be used with the encryption algorithm.
> + <p>The key to be used with the encryption algorithm.</p>
>
> - The key should be specified as hex-encoded bytes of the appropriate
> + <p>The key should be specified as hex-encoded bytes of the appropriate
> length for the algorithm (e.g. 16 bytes / 32 characters / 128 bits for
> - AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).
> + AES-128, 32 bytes / 64 characters / 256 bits for AES-256, etc.).</p>
> + </attribute>
> + <attribute name="replayWindowTime" required="false">
> + <p>Messages with a timestamp before the current time less this window
> + will be rejected. This needs to account for clock skew across the cluster
> + as well as the expected maximum delay between messages being sent and
> + received. Specified in milliseconds. If not specified, the default value
> + of 10000 (10 seconds) will be used.</p>
> + </attribute>
> + <attribute name="replayWindowMessageCount" required="false">
> + <p>The number of past messages for which the nonces will be tracked to
> + prevent replay attacks. Messages with a nonce that has already been
> + tracked will be rejected. If not specified, the default value of 8192
> + will be used.</p>
> +
> + <p>If messages are received at a high rate, it is possible that a nonce
> + will be removed from the list of tracked nonces before
> + <code>replayWindowTime</code> milliseconds have elapsed. If that happens,
> + any message with a timestamp older than the last nonce evicted from the
> + list of tracked nonces will also be rejected.</p>
> </attribute>
> </attributes>
> </subsection>
>
>
> ---------------------------------------------------------------------
> To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
> For additional commands, e-mail: dev-help@tomcat.apache.org
>
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org