This is an automated email from the ASF dual-hosted git repository. markt pushed a commit to branch 7.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/7.0.x by this push: new 5668ae6 Back-port EncryptInterceptor from Tomcat 8.5.x onwards. 5668ae6 is described below commit 5668ae6548722c6d78d5fa7d98e19f356454a1eb Author: Christopher Schultz <schu...@apache.org> AuthorDate: Sat Jan 5 20:52:28 2019 +0000 Back-port EncryptInterceptor from Tomcat 8.5.x onwards. --- .../group/interceptors/EncryptInterceptor.java | 644 +++++++++++++++++++++ .../interceptors/EncryptInterceptorMBean.java | 31 + .../group/interceptors/LocalStrings.properties | 24 + res/checkstyle/org-import-control.xml | 1 + .../group/interceptors/TestEncryptInterceptor.java | 542 +++++++++++++++++ webapps/docs/changelog.xml | 10 + webapps/docs/config/cluster-interceptor.xml | 41 +- webapps/docs/config/cluster.xml | 1 + 8 files changed, 1293 insertions(+), 1 deletion(-) diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java new file mode 100644 index 0000000..827bf78 --- /dev/null +++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java @@ -0,0 +1,644 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.tribes.group.interceptors; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.SecureRandom; +import java.security.spec.AlgorithmParameterSpec; +import java.util.concurrent.ConcurrentLinkedQueue; + +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.catalina.tribes.Channel; +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.group.ChannelInterceptorBase; +import org.apache.catalina.tribes.group.InterceptorPayload; +import org.apache.catalina.tribes.io.XByteBuffer; +import org.apache.catalina.tribes.util.StringManager; +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; + +/** + * Adds encryption using a pre-shared key. + * + * The length of the key (in bytes) must be acceptable for the encryption + * algorithm being used. For example, for AES, you must use a key of either + * 16 bytes (128 bits, 24 bytes 192 bits), or 32 bytes (256 bits). + * + * You can supply the raw key bytes by calling {@link #setEncryptionKey(byte[])} + * or the hex-encoded binary bytes by calling + * {@link #setEncryptionKey(String)}. + */ +public class EncryptInterceptor extends ChannelInterceptorBase implements EncryptInterceptorMBean { + + private static final Log log = LogFactory.getLog(EncryptInterceptor.class); + protected static final StringManager sm = StringManager.getManager(EncryptInterceptor.class); + + private static final String DEFAULT_ENCRYPTION_ALGORITHM = "AES/CBC/PKCS5Padding"; + + private String providerName; + private String encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGORITHM; + private byte[] encryptionKeyBytes; + private String encryptionKeyString; + + + private BaseEncryptionManager encryptionManager; + + public EncryptInterceptor() { + } + + @Override + public void start(int svc) throws ChannelException { + validateChannelChain(); + + if(Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) { + try { + encryptionManager = createEncryptionManager(getEncryptionAlgorithm(), + getEncryptionKeyInternal(), + getProviderName()); + } catch (GeneralSecurityException gse) { + throw new ChannelException(sm.getString("encryptInterceptor.init.failed"), gse); + } + } + + super.start(svc); + } + + private void validateChannelChain() throws ChannelException { + ChannelInterceptor interceptor = getPrevious(); + while(null != interceptor) { + if(interceptor instanceof TcpFailureDetector) + throw new ChannelConfigException(sm.getString("encryptInterceptor.tcpFailureDetector.ordering")); + + interceptor = interceptor.getPrevious(); + } + } + + @Override + public void stop(int svc) throws ChannelException { + if(Channel.SND_TX_SEQ == (svc & Channel.SND_TX_SEQ)) { + encryptionManager.shutdown(); + } + + super.stop(svc); + } + + @Override + public void sendMessage(Member[] destination, ChannelMessage msg, InterceptorPayload payload) + throws ChannelException { + try { + byte[] data = msg.getMessage().getBytes(); + + // See #encrypt(byte[]) for an explanation of the return value + byte[][] bytes = encryptionManager.encrypt(data); + + XByteBuffer xbb = msg.getMessage(); + + // Completely replace the message + xbb.clear(); + xbb.append(bytes[0], 0, bytes[0].length); + xbb.append(bytes[1], 0, bytes[1].length); + + super.sendMessage(destination, msg, payload); + + } catch (GeneralSecurityException gse) { + log.error(sm.getString("encryptInterceptor.encrypt.failed")); + throw new ChannelException(gse); + } + } + + @Override + public void messageReceived(ChannelMessage msg) { + try { + byte[] data = msg.getMessage().getBytes(); + + data = encryptionManager.decrypt(data); + + XByteBuffer xbb = msg.getMessage(); + + // Completely replace the message with the decrypted one + xbb.clear(); + xbb.append(data, 0, data.length); + + super.messageReceived(msg); + } catch (GeneralSecurityException gse) { + log.error(sm.getString("encryptInterceptor.decrypt.failed"), gse); + } + } + + /** + * Sets the encryption algorithm to be used for encrypting and decrypting + * channel messages. You must specify the <code>algorithm/mode/padding</code>. + * Information on standard algorithm names may be found in the + * <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html">Java + * documentation</a>. + * + * Default is <code>AES/CBC/PKCS5Padding</code>. + * + * @param algorithm The algorithm to use. + */ + @Override + public void setEncryptionAlgorithm(String algorithm) { + if(null == getEncryptionAlgorithm()) + throw new IllegalStateException(sm.getString("encryptInterceptor.algorithm.required")); + + int pos = algorithm.indexOf('/'); + if(pos < 0) + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.required")); + pos = algorithm.indexOf('/', pos + 1); + if(pos < 0) + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.required")); + + encryptionAlgorithm = algorithm; + } + + /** + * Gets the encryption algorithm being used to encrypt and decrypt channel + * messages. + * + * @return The algorithm being used, including the algorithm mode and padding. + */ + @Override + public String getEncryptionAlgorithm() { + return encryptionAlgorithm; + } + + /** + * Sets the encryption key for encryption and decryption. The length of the + * key must be appropriate for the algorithm being used. + * + * @param key The encryption key. + */ + @Override + public void setEncryptionKey(byte[] key) { + if (null == key) { + encryptionKeyBytes = null; + } else { + encryptionKeyBytes = key.clone(); + } + } + + /** + * Gets the encryption key being used for encryption and decryption. + * The key is encoded using hex-encoding where e.g. the byte <code>0xab</code> + * will be shown as "ab". The length of the string in characters will + * be twice the length of the key in bytes. + * + * @param keyBytes The encryption key. + */ + public void setEncryptionKey(String keyBytes) { + this.encryptionKeyString = keyBytes; + if (null == keyBytes) { + setEncryptionKey((byte[])null); + } else { + setEncryptionKey(fromHexString(keyBytes.trim())); + } + } + + /** + * Gets the encryption key being used for encryption and decryption. + * + * @return The encryption key. + */ + @Override + public byte[] getEncryptionKey() { + byte[] key = getEncryptionKeyInternal(); + + if(null != key) + key = key.clone(); + + return key; + } + + private byte[] getEncryptionKeyInternal() { + return encryptionKeyBytes; + } + + public String getEncryptionKeyString() { + return encryptionKeyString; + } + + public void setEncryptionKeyString(String encryptionKeyString) { + setEncryptionKey(encryptionKeyString); + } + + /** + * Sets the JCA provider name used for cryptographic activities. + * + * Default is the JVM platform default. + * + * @param provider The name of the JCA provider. + */ + @Override + public void setProviderName(String provider) { + providerName = provider; + } + + /** + * Gets the JCA provider name used for cryptographic activities. + * + * Default is the JVM platform default. + * + * @return The name of the JCA provider. + */ + @Override + public String getProviderName() { + return providerName; + } + + // Copied from org.apache.tomcat.util.buf.HexUtils + + private static final int[] DEC = { + 00, 01, 02, 03, 04, 05, 06, 07, 8, 9, -1, -1, -1, -1, -1, -1, + -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, 10, 11, 12, 13, 14, 15, + }; + + + private static int getDec(int index) { + // Fast for correct values, slower for incorrect ones + try { + return DEC[index - '0']; + } catch (ArrayIndexOutOfBoundsException ex) { + return -1; + } + } + + + private static byte[] fromHexString(String input) { + if (input == null) { + return null; + } + + if ((input.length() & 1) == 1) { + // Odd number of characters + throw new IllegalArgumentException(sm.getString("hexUtils.fromHex.oddDigits")); + } + + char[] inputChars = input.toCharArray(); + byte[] result = new byte[input.length() >> 1]; + for (int i = 0; i < result.length; i++) { + int upperNibble = getDec(inputChars[2*i]); + int lowerNibble = getDec(inputChars[2*i + 1]); + if (upperNibble < 0 || lowerNibble < 0) { + // Non hex character + throw new IllegalArgumentException(sm.getString("hexUtils.fromHex.nonHex")); + } + result[i] = (byte) ((upperNibble << 4) + lowerNibble); + } + return result; + } + + private static BaseEncryptionManager createEncryptionManager(String algorithm, + byte[] encryptionKey, String providerName) + throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException { + if(null == encryptionKey) + throw new IllegalStateException(sm.getString("encryptInterceptor.key.required")); + + String algorithmName; + String algorithmMode; + + // We need to break-apart the algorithm name e.g. AES/CBC/PKCS5Padding + // take just the algorithm part. + int pos = algorithm.indexOf('/'); + + if(pos >= 0) { + algorithmName = algorithm.substring(0, pos); + int pos2 = algorithm.indexOf('/', pos+1); + + if(pos2 >= 0) { + algorithmMode = algorithm.substring(pos + 1, pos2); + } else { + algorithmMode = "CBC"; + } + } else { + algorithmName = algorithm; + algorithmMode = "CBC"; + } + + if("GCM".equalsIgnoreCase(algorithmMode)) + return new GCMEncryptionManager(algorithm, new SecretKeySpec(encryptionKey, algorithmName), providerName); + else if("CBC".equalsIgnoreCase(algorithmMode) + || "OFB".equalsIgnoreCase(algorithmMode) + || "CFB".equalsIgnoreCase(algorithmMode)) + return new BaseEncryptionManager(algorithm, + new SecretKeySpec(encryptionKey, algorithmName), + providerName); + else + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.unsupported-mode", algorithmMode)); + } + + private static class BaseEncryptionManager { + /** + * The fully-specified algorithm e.g. AES/CBC/PKCS5Padding. + */ + private final String algorithm; + + /** + * The block size of the cipher. + */ + private final int blockSize; + + /** + * The cryptographic provider name. + */ + private final String providerName; + + /** + * The secret key to use for encryption and decryption operations. + */ + private final SecretKeySpec secretKey; + + /** + * A pool of Cipher objects. Ciphers are expensive to create, but not + * to re-initialize, so we use a pool of them which grows as necessary. + */ + private final ConcurrentLinkedQueue<Cipher> cipherPool; + + /** + * A pool of SecureRandom objects. Each encrypt operation requires access + * to a source of randomness. SecureRandom is thread-safe, but sharing a + * single instance will likely be a bottleneck. + */ + private final ConcurrentLinkedQueue<SecureRandom> randomPool; + + public BaseEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName) + throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException { + this.algorithm = algorithm; + this.providerName = providerName; + this.secretKey = secretKey; + + cipherPool = new ConcurrentLinkedQueue<Cipher>(); + Cipher cipher = createCipher(); + blockSize = cipher.getBlockSize(); + cipherPool.offer(cipher); + randomPool = new ConcurrentLinkedQueue<SecureRandom>(); + } + + public void shutdown() { + // Individual Cipher and SecureRandom objects need no explicit teardown + cipherPool.clear(); + randomPool.clear(); + } + + private String getAlgorithm() { + return algorithm; + } + + private SecretKeySpec getSecretKey() { + return secretKey; + } + + /** + * Gets the size, in bytes, of the initialization vector for the + * cipher being used. The IV size is often, but not always, the block + * size for the cipher. + * + * @return The size of the initialization vector for this algorithm. + */ + protected int getIVSize() { + return blockSize; + } + + private String getProviderName() { + return providerName; + } + + private Cipher createCipher() + throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException { + String providerName = getProviderName(); + + if(null == providerName) { + return Cipher.getInstance(getAlgorithm()); + } else { + return Cipher.getInstance(getAlgorithm(), providerName); + } + } + + private Cipher getCipher() throws GeneralSecurityException { + Cipher cipher = cipherPool.poll(); + + if(null == cipher) { + cipher = createCipher(); + } + + return cipher; + } + + private void returnCipher(Cipher cipher) { + cipherPool.offer(cipher); + } + + private SecureRandom getRandom() { + SecureRandom random = randomPool.poll(); + + if(null == random) { + random = new SecureRandom(); + } + + return random; + } + + private void returnRandom(SecureRandom random) { + randomPool.offer(random); + } + + /** + * Encrypts the input <code>bytes</code> into two separate byte arrays: + * one for the random initialization vector (IV) used for this message, + * and the second one containing the actual encrypted payload. + * + * This method returns a pair of byte arrays instead of a single + * concatenated one to reduce the number of byte buffers created + * and copied during the whole operation -- including message re-building. + * + * @param bytes The data to encrypt. + * + * @return The IV in [0] and the encrypted data in [1]. + * + * @throws GeneralSecurityException If the input data cannot be encrypted. + */ + private byte[][] encrypt(byte[] bytes) throws GeneralSecurityException { + Cipher cipher = null; + + // Always use a random IV For cipher setup. + // The recipient doesn't need the (matching) IV because we will always + // pre-pad messages with the IV as a nonce. + byte[] iv = generateIVBytes(); + + try { + cipher = getCipher(); + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey(), generateIV(iv, 0, getIVSize())); + + // Prepend the IV to the beginning of the encrypted data + byte[][] data = new byte[2][]; + data[0] = iv; + data[1] = cipher.doFinal(bytes); + + return data; + } finally { + if(null != cipher) + returnCipher(cipher); + } + } + + /** + * Decrypts the input <code>bytes</code>. + * + * @param bytes The data to decrypt. + * + * @return The decrypted data. + * + * @throws GeneralSecurityException If the input data cannot be decrypted. + */ + private byte[] decrypt(byte[] bytes) throws GeneralSecurityException { + Cipher cipher = null; + + int ivSize = getIVSize(); + AlgorithmParameterSpec IV = generateIV(bytes, 0, ivSize); + + try { + cipher = getCipher(); + + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), IV); + + // Decrypt remainder of the message. + return cipher.doFinal(bytes, ivSize, bytes.length - ivSize); + } finally { + if(null != cipher) + returnCipher(cipher); + } + } + + protected byte[] generateIVBytes() { + byte[] ivBytes = new byte[getIVSize()]; + + SecureRandom random = null; + + try { + random = getRandom(); + + // Always use a random IV For cipher setup. + // The recipient doesn't need the (matching) IV because we will always + // pre-pad messages with the IV as a nonce. + random.nextBytes(ivBytes); + + return ivBytes; + } finally { + if(null != random) + returnRandom(random); + } + } + + protected AlgorithmParameterSpec generateIV(byte[] ivBytes, int offset, int length) { + return new IvParameterSpec(ivBytes, offset, length); + } + } + + /** + * Implements an EncryptionManager for using GCM block cipher modes. + * + * GCM works a little differently than some of the other block cipher modes + * supported by EncryptInterceptor. First of all, it requires a different + * kind of AlgorithmParameterSpec object to be used, and second, it + * requires a slightly different initialization vector and something called + * an "authentication tag". + * + * The choice of IV length can be somewhat arbitrary, but there is consensus + * that 96-bit (12-byte) IVs for GCM are the best trade-off between security + * and performance. For other block cipher modes, IV length is the same as + * the block size. + * + * The "authentication tag" is a computed authentication value based upon + * the message and the encryption process. GCM defines these tags as the + * number of bits to use for the authentication tag, and it's clear that + * the highest number of bits supported 128-bit provide the best security. + */ + private static class GCMEncryptionManager extends BaseEncryptionManager + { + private static Constructor<?> gcmParameterSpecConstructor; + + static { + Constructor<?> c1 = null; + try { + Class<?> clazz = Class.forName("javax.crypto.spec.GCMParameterSpec"); + c1 = clazz.getConstructor(int.class, byte[].class, int.class, int.class); + } catch (ClassNotFoundException e) { + // Ignore + } catch (SecurityException e) { + // Ignore + } catch (NoSuchMethodException e) { + // Ignore + } + + gcmParameterSpecConstructor = c1; + } + + public GCMEncryptionManager(String algorithm, SecretKeySpec secretKey, String providerName) + throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException { + super(algorithm, secretKey, providerName); + } + + @Override + protected int getIVSize() { + return 12; // See class javadoc for explanation of this magic number (12) + } + + @Override + protected AlgorithmParameterSpec generateIV(byte[] bytes, int offset, int length) { + // Can't use org.apache.tomcat.util.compat as Tribes only has JULI + // as a dependency. + if (gcmParameterSpecConstructor == null) { + throw new UnsupportedOperationException(sm.getString("encryptInterceptor.noGCM")); + } else { + try { + return (AlgorithmParameterSpec) gcmParameterSpecConstructor.newInstance( + Integer.valueOf(128), bytes, Integer.valueOf(offset), Integer.valueOf(length)); + } catch (IllegalArgumentException e) { + throw new UnsupportedOperationException(sm.getString("encryptInterceptor.noGCM"), e); + } catch (InstantiationException e) { + throw new UnsupportedOperationException(sm.getString("encryptInterceptor.noGCM"), e); + } catch (IllegalAccessException e) { + throw new UnsupportedOperationException(sm.getString("encryptInterceptor.noGCM"), e); + } catch (InvocationTargetException e) { + throw new UnsupportedOperationException(sm.getString("encryptInterceptor.noGCM"), e); + } + } + } + } + + static class ChannelConfigException + extends ChannelException + { + private static final long serialVersionUID = 1L; + + public ChannelConfigException(String message) { + super(message); + } + } +} diff --git a/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java new file mode 100644 index 0000000..dcf0f7b --- /dev/null +++ b/java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptorMBean.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.tribes.group.interceptors; + +public interface EncryptInterceptorMBean { + + // Config + public int getOptionFlag(); + public void setOptionFlag(int optionFlag); + + public void setEncryptionAlgorithm(String algorithm); + public String getEncryptionAlgorithm(); + public void setEncryptionKey(byte[] key); + public byte[] getEncryptionKey(); + public void setProviderName(String provider); + public String getProviderName(); +} diff --git a/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties new file mode 100644 index 0000000..e216ba5 --- /dev/null +++ b/java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties @@ -0,0 +1,24 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +encryptInterceptor.algorithm.required=Encryption algorithm is required, fully-specified e.g. AES/CBC/PKCS5Padding +encryptInterceptor.algorithm.unsupported-mode=EncryptInterceptor does not support block cipher mode [{0}] +encryptInterceptor.decrypt.error.short-message=Failed to decrypt message: premature end-of-message +encryptInterceptor.decrypt.failed=Failed to decrypt message +encryptInterceptor.encrypt.failed=Failed to encrypt message +encryptInterceptor.init.failed=Failed to initialize EncryptInterceptor +encryptInterceptor.key.required=Encryption key is required +encryptInterceptor.noGCM=The current JRE does not support GCM. You must use Java 7 or later to use this feature. +encryptInterceptor.tcpFailureDetector.ordering=EncryptInterceptor must be upstream of TcpFailureDetector. Please re-order EncryptInterceptor to be listed before TcpFailureDetector in your channel interceptor pipeline. diff --git a/res/checkstyle/org-import-control.xml b/res/checkstyle/org-import-control.xml index 463077b..c277886 100644 --- a/res/checkstyle/org-import-control.xml +++ b/res/checkstyle/org-import-control.xml @@ -23,6 +23,7 @@ <!-- Anything in J2SE is OK but need to list javax by package as not all javax packages are in J2SE --> <allow pkg="java"/> + <allow pkg="javax.crypto"/> <allow class="javax.imageio.ImageIO"/> <allow pkg="javax.management"/> <allow pkg="javax.naming"/> diff --git a/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java new file mode 100644 index 0000000..a69a68b --- /dev/null +++ b/test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java @@ -0,0 +1,542 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.tribes.group.interceptors; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.Collection; + +import org.hamcrest.core.IsEqual; +import org.hamcrest.core.IsNot; + +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +import org.apache.catalina.tribes.Channel; +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.group.ChannelInterceptorBase; +import org.apache.catalina.tribes.group.InterceptorPayload; +import org.apache.catalina.tribes.io.ChannelData; +import org.apache.catalina.tribes.io.XByteBuffer; + +/** + * Tests the EncryptInterceptor. + * + * Many of the tests in this class use strings as input and output, even + * though the interceptor actually operates on byte arrays. This is done + * for readability for the tests and their outputs. + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class TestEncryptInterceptor { + private static final String MESSAGE_FILE = "message.bin"; + + private static final String encryptionKey128 = "cafebabedeadbeefbeefcafecafebabe"; + private static final String encryptionKey192 = "cafebabedeadbeefbeefcafecafebabedeadbeefbeefcafe"; + private static final String encryptionKey256 = "cafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeef"; + + EncryptInterceptor src; + EncryptInterceptor dest; + + + @AfterClass + public static void cleanup() { + File f = new File(MESSAGE_FILE); + if (f.isFile()) { + Assert.assertTrue(f.delete()); + } + } + + @Before + public void setup() { + src = new EncryptInterceptor(); + src.setEncryptionKey(encryptionKey128); + + dest = new EncryptInterceptor(); + dest.setEncryptionKey(encryptionKey128); + + src.setNext(new PipedInterceptor(dest)); + dest.setPrevious(new ValueCaptureInterceptor()); + } + + @Test + public void testBasic() throws Exception { + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Basic roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testMultipleMessages() throws Exception { + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Basic roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + + Assert.assertEquals("Second roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + + Assert.assertEquals("Third roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + + Assert.assertEquals("Fourth roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + + Assert.assertEquals("Fifth roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testTinyPayload() throws Exception { + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "x"; + + Assert.assertEquals("Tiny payload roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testLargePayload() throws Exception { + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + byte[] bytes = new byte[1024*1024]; + + Assert.assertArrayEquals("Huge payload roundtrip failed", + bytes, + roundTrip(bytes, src, dest)); + } + + @Test + @Ignore("Too big for default settings. Breaks Gump, Eclipse, ...") + public void testHugePayload() throws Exception { + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + byte[] bytes = new byte[1024*1024*1024]; + + Assert.assertArrayEquals("Huge payload roundtrip failed", + bytes, + roundTrip(bytes, src, dest)); + } + + @Test + public void testCustomProvider() throws Exception { + src.setProviderName("SunJCE"); // Explicitly set the provider name + dest.setProviderName("SunJCE"); + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Failed to set custom provider name", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void test192BitKey() throws Exception { + src.setEncryptionKey(encryptionKey192); + dest.setEncryptionKey(encryptionKey192); + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Failed to set custom provider name", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void test256BitKey() throws Exception { + src.setEncryptionKey(encryptionKey256); + dest.setEncryptionKey(encryptionKey256); + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Failed to set custom provider name", + testInput, + roundTrip(testInput, src, dest)); + } + + /** + * Actually go through the interceptor's send/receive message methods. + */ + private static String roundTrip(String input, EncryptInterceptor src, EncryptInterceptor dest) throws Exception { + byte[] bytes = input.getBytes("UTF-8"); + + bytes = roundTrip(bytes, src, dest); + + return new String(bytes, "UTF-8"); + } + + /** + * Actually go through the interceptor's send/receive message methods. + */ + private static byte[] roundTrip(byte[] input, EncryptInterceptor src, EncryptInterceptor dest) throws Exception { + ChannelData msg = new ChannelData(false); + msg.setMessage(new XByteBuffer(input, false)); + src.sendMessage(null, msg, null); + + return ((ValueCaptureInterceptor)dest.getPrevious()).getValue(); + } + + @Test + @Ignore("ECB mode isn't implemented because it's insecure") + public void testECB() throws Exception { + src.setEncryptionAlgorithm("AES/ECB/PKCS5Padding"); + src.start(Channel.SND_TX_SEQ); + dest.setEncryptionAlgorithm("AES/ECB/PKCS5Padding"); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Failed in ECB mode", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testOFB() throws Exception { + src.setEncryptionAlgorithm("AES/OFB/PKCS5Padding"); + src.start(Channel.SND_TX_SEQ); + dest.setEncryptionAlgorithm("AES/OFB/PKCS5Padding"); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Failed in OFB mode", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testCFB() throws Exception { + src.setEncryptionAlgorithm("AES/CFB/PKCS5Padding"); + src.start(Channel.SND_TX_SEQ); + dest.setEncryptionAlgorithm("AES/CFB/PKCS5Padding"); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Failed in CFB mode", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testGCM() throws Exception { + src.setEncryptionAlgorithm("AES/GCM/PKCS5Padding"); + src.start(Channel.SND_TX_SEQ); + dest.setEncryptionAlgorithm("AES/GCM/PKCS5Padding"); + dest.start(Channel.SND_TX_SEQ); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + Assert.assertEquals("Failed in GCM mode", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testIllegalECB() throws Exception { + try { + src.setEncryptionAlgorithm("AES/ECB/PKCS5Padding"); + src.start(Channel.SND_TX_SEQ); + + // start() should trigger IllegalArgumentException + Assert.fail("ECB mode is not being refused"); + } catch (IllegalArgumentException iae) { + // Expected + } + } + + @Test + public void testViaFile() throws Exception { + src.start(Channel.SND_TX_SEQ); + src.setNext(new ValueCaptureInterceptor()); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + ChannelData msg = new ChannelData(false); + msg.setMessage(new XByteBuffer(testInput.getBytes("UTF-8"), false)); + src.sendMessage(null, msg, null); + + byte[] bytes = ((ValueCaptureInterceptor)src.getNext()).getValue(); + + FileOutputStream out = null; + try { + out = new FileOutputStream(MESSAGE_FILE); + out.write(bytes); + } finally { + if (out != null) { + out.close(); + } + } + + dest.start(Channel.SND_TX_SEQ); + + bytes = new byte[8192]; + int read; + + FileInputStream in = null; + try { + in = new FileInputStream(MESSAGE_FILE); + read = in.read(bytes); + } finally { + if (in != null) { + in.close(); + } + } + + msg = new ChannelData(false); + XByteBuffer xbb = new XByteBuffer(read, false); + xbb.append(bytes, 0, read); + msg.setMessage(xbb); + + dest.messageReceived(msg); + } + + @Test + public void testMessageUniqueness() throws Exception { + src.start(Channel.SND_TX_SEQ); + src.setNext(new ValueCaptureInterceptor()); + + String testInput = "The quick brown fox jumps over the lazy dog."; + + ChannelData msg = new ChannelData(false); + msg.setMessage(new XByteBuffer(testInput.getBytes("UTF-8"), false)); + src.sendMessage(null, msg, null); + + byte[] cipherText1 = ((ValueCaptureInterceptor)src.getNext()).getValue(); + + msg.setMessage(new XByteBuffer(testInput.getBytes("UTF-8"), false)); + src.sendMessage(null, msg, null); + + byte[] cipherText2 = ((ValueCaptureInterceptor)src.getNext()).getValue(); + + Assert.assertThat("Two identical cleartexts encrypt to the same ciphertext", + cipherText1, IsNot.not(IsEqual.equalTo(cipherText2))); + } + + @Test + public void testPickup() throws Exception { + File file = new File(MESSAGE_FILE); + if(!file.exists()) { + System.err.println("File message.bin does not exist. Skipping test."); + return; + } + + dest.start(Channel.SND_TX_SEQ); + + byte[] bytes = new byte[8192]; + int read; + + FileInputStream in = null; + try { + in = new FileInputStream(file); + read = in.read(bytes); + } finally { + if (in != null) { + in.close(); + } + } + + + ChannelData msg = new ChannelData(false); + XByteBuffer xbb = new XByteBuffer(read, false); + xbb.append(bytes, 0, read); + msg.setMessage(xbb); + + dest.messageReceived(msg); + } + + /* + * This test isn't guaranteed to catch any multithreaded issues, but it + * gives a good exercise. + */ + @Test + public void testMultithreaded() throws Exception { + String inputValue = "A test string to fight over."; + final byte[] bytes = inputValue.getBytes("UTF-8"); + int numThreads = 100; + final int messagesPerThread = 10; + + dest.setPrevious(new ValuesCaptureInterceptor()); + + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + Runnable job = new Runnable() { + @Override + public void run() { + try { + ChannelData msg = new ChannelData(false); + XByteBuffer xbb = new XByteBuffer(1024, false); + xbb.append(bytes, 0, bytes.length); + msg.setMessage(xbb); + + for(int i=0; i<messagesPerThread; ++i) + src.sendMessage(null, msg, null); + } catch (ChannelException e) { + Assert.fail("Encountered exception sending messages: " + e.getMessage()); + } + } + }; + + Thread[] threads = new Thread[numThreads]; + for(int i=0; i<numThreads; ++i) { + threads[i] = new Thread(job); + threads[i].setName("Message-Thread-" + i); + } + + for(int i=0; i<numThreads; ++i) + threads[i].start(); + + for(int i=0; i<numThreads; ++i) + threads[i].join(); + + // Check all received messages to make sure they are not corrupted + Collection<byte[]> messages = ((ValuesCaptureInterceptor)dest.getPrevious()).getValues(); + + Assert.assertEquals("Did not receive all expected messages", + numThreads * messagesPerThread, messages.size()); + + for(byte[] message : messages) + Assert.assertArrayEquals("Message is corrupted", message, bytes); + } + + @Test + public void testTcpFailureDetectorDetection() { + src.setPrevious(new TcpFailureDetector()); + + try { + src.start(Channel.SND_TX_SEQ); + Assert.fail("EncryptInterceptor should detect TcpFailureDetector and throw an error"); + } catch (EncryptInterceptor.ChannelConfigException cce) { + // Expected behavior + } catch (AssertionError ae) { + // This is the junit assertion being thrown + throw ae; + } catch (Throwable t) { + Assert.fail("EncryptionInterceptor should throw ChannelConfigException, not " + t.getClass().getName()); + } + } + + /** + * Interceptor that delivers directly to a destination. + */ + private static class PipedInterceptor + extends ChannelInterceptorBase + { + private ChannelInterceptor dest; + + public PipedInterceptor(ChannelInterceptor dest) { + if(null == dest) + throw new IllegalArgumentException("Destination must not be null"); + + this.dest = dest; + } + + @Override + public void sendMessage(Member[] destination, ChannelMessage msg, InterceptorPayload payload) + throws ChannelException { + dest.messageReceived(msg); + } + } + + /** + * Interceptor that simply captures the latest message sent to or received by it. + */ + private static class ValueCaptureInterceptor + extends ChannelInterceptorBase + { + private byte[] value; + + @Override + public void sendMessage(Member[] destination, ChannelMessage msg, InterceptorPayload payload) + throws ChannelException { + value = msg.getMessage().getBytes(); + } + + @Override + public void messageReceived(ChannelMessage msg) { + value = msg.getMessage().getBytes(); + } + + public byte[] getValue() { + return value; + } + } + + /** + * Interceptor that simply captures all messages sent to or received by it. + */ + private static class ValuesCaptureInterceptor + extends ChannelInterceptorBase + { + private ArrayList<byte[]> messages = new ArrayList<byte[]>(); + + @Override + public void sendMessage(Member[] destination, ChannelMessage msg, InterceptorPayload payload) + throws ChannelException { + synchronized(messages) { + messages.add(msg.getMessage().getBytes()); + } + } + + @Override + public void messageReceived(ChannelMessage msg) { + synchronized(messages) { + messages.add(msg.getMessage().getBytes()); + } + } + + @SuppressWarnings("unchecked") + public Collection<byte[]> getValues() { + return (Collection<byte[]>)messages.clone(); + } + } +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 74c7bf5..8c304d0 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -129,6 +129,16 @@ </fix> </changelog> </subsection> + <subsection name="Tribes"> + <changelog> + <add> + Add EncryptInterceptor to the portfolio of available clustering + interceptors. This adds symmetric encryption of session data + to Tomcat clustering regardless of the type of cluster manager + or membership being used. (schultz/markt) + </add> + </changelog> + </subsection> </section> <section name="Tomcat 7.0.99 (violetagg)" rtext="released 2019-12-17"> <subsection name="Catalina"> diff --git a/webapps/docs/config/cluster-interceptor.xml b/webapps/docs/config/cluster-interceptor.xml index db19dcc..594c2ef 100644 --- a/webapps/docs/config/cluster-interceptor.xml +++ b/webapps/docs/config/cluster-interceptor.xml @@ -36,7 +36,7 @@ <section name="Introduction"> <p> Apache Tribes supports an interceptor architecture to intercept both messages and membership notifications. - This architecture allows decoupling of logic and opens the way for some very kewl feature add ons. + This architecture allows decoupling of logic and opens the way for some very useful feature add ons. </p> </section> @@ -55,6 +55,7 @@ <li><code>org.apache.catalina.tribes.group.interceptors.FragmentationInterceptor</code></li> <li><code>org.apache.catalina.tribes.group.interceptors.GzipInterceptor</code></li> <li><code>org.apache.catalina.tribes.group.interceptors.TcpPingInterceptor</code></li> + <li><code>org.apache.catalina.tribes.group.interceptors.EncryptInterceptor</code></li> </ul> </section> @@ -212,6 +213,44 @@ </attribute> </attributes> </subsection> + <subsection name="org.apache.catalina.tribes.group.interceptors.EncryptInterceptor Attributes"> + <p> + The EncryptInterceptor adds encryption to the channel messages carrying + session data between nodes. Added in Tomcat 7.0.100. + </p> + <p> + If using the <code>TcpFailureDetector</code>, the <code>EncryptInterceptor</code> + <i>must</i> be inserted into the interceptor chain <i>before</i> the + <code>TcpFailureDetector</code>. This is because when validating cluster + members, <code>TcpFailureDetector</code> writes channel data directly + to the other members without using the remainder of the interceptor chain, + but on the receiving side, the message still goes through the chain (in reverse). + Because of this asymmetry, the <code>EncryptInterceptor</code> must execute + <i>before</i> the <code>TcpFailureDetector</code> on the sender and <i>after</i> + it on the receiver, otherwise message corruption will occur. + </p> + <attributes> + <attribute name="encryptionAlgorithm" required="false"> + The encryption algorithm to be used, including the mode and padding. Please see + <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html">https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html</a> + for the standard JCA names that can be used. + + The <i>mode</i> is currently required to be <code>CBC</code>. + + The length of the key will specify the flavor of the encryption algorithm + to be used, if applicable (e.g. AES-128 versus AES-256). + + The default algorithm is <code>AES/CBC/PKCS5Padding</code>. + </attribute> + <attribute name="encryptionKey" required="true"> + The key to be used with the encryption algorithm. + + 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.). + </attribute> + </attributes> + </subsection> </section> <section name="Nested Components"> diff --git a/webapps/docs/config/cluster.xml b/webapps/docs/config/cluster.xml index cbaa743..252ae2b 100644 --- a/webapps/docs/config/cluster.xml +++ b/webapps/docs/config/cluster.xml @@ -52,6 +52,7 @@ to run a cluster on a insecure, untrusted network.</p> <p>There are many options for providing a secure, trusted network for use by a Tomcat cluster. These include:</p> <ul> + <li><a href="cluster-interceptor.html#org.apache.catalina.tribes.group.interceptors.EncryptInterceptor_Attributes">EncryptInterceptor</a></li> <li>private LAN</li> <li>a Virtual Private Network (VPN)</li> <li>IPSEC</li> --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org