-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 All,
Bump. I have a full patch at this point (still without documentation), but this one includes resolution of the IV issue and also a set of unit tests which pass. I'd appreciate it if someone could install this into their cluster and see if it works. Just configure the interceptor into your interceptor chain and set an encryption key (e.g. "cafebabecafebabe"). Thanks, - -chris On 10/23/18 16:14, Christopher Schultz wrote: > All, > > On 10/23/18 10:05, Christopher Schultz wrote: >> Can I get a technical review for (a) appropriateness and (b) >> technical implementation of the attached cluster interceptor? >> Let's assume for a moment that encryption is something worth >> adding to clustering and not argue that point. > >> It should be straightforward. Knowing virtually nothing about >> the way that Tribes works, implementing this as an interceptor >> seemed like the least invasive (and easiest!) way to add >> encryption to clustering. > > For the record, I haven't tested this AT ALL. I just made it > compile :) > > If someone has a test-cluster available, could you please try to > add this (it just requires an "encryptionKey" attribute on the > interceptor... just use "cafebabe" or something simple) and see if > (a) the messages flow properly and (b) they transmit in > non-plaintext? > > Thanks, -chris > > --------------------------------------------------------------------- > > To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org > For additional commands, e-mail: dev-h...@tomcat.apache.org > -----BEGIN PGP SIGNATURE----- Comment: Using GnuPG with Thunderbird - https://www.enigmail.net/ iQIzBAEBCAAdFiEEMmKgYcQvxMe7tcJcHPApP6U8pFgFAlvR3hcACgkQHPApP6U8 pFiQjQ/+OOneeU7lH84A/qzplSjPkyYFunMhAVhhK3bO+dMEjYT+Hw5kWcvtudAA x3djNxKBz2BGB5cva2K7022iMfrC5wKJzQV9CP5/VRgObu+klQem7ui8g8hqVizT emEbkojYjzudNygjzhpT1CQgEWoUBGL3g1ssR6uY8RS72O9Jr9G9y/WFMusFCiXH ERYSY9Wv/e5+gB8+k1jtJS1idAB3ZrTT3qjfEKl4q3gerdX4NU/fQOtRdISoj54m qUjRaSas+x53QHpCbqVlQbuDNlS4cIHrmXNj4hVuaiCon8yYaeV6LGdadSPnP7a2 AXg81MQjL667rv8o91Tw+3NgaepljwrXhiVqtUFuhFCsomTUQTjkqSZNmD3Z27D3 jVjZzmRi6oDM6niDNFulkTpnr9mTe3KuE58dG2TvtpqrZVN2JIjc/bssbVVzqhtV j5SwmMdehDAy96eguE+MzXXEvpMbI4PJ8HaTg80A9dDihd6vCUI7XXHeE70YVqvT r+kZgqGykLnXpiWB4u5oNAKDFlvV9N2WzK4UaaZV+Kr8rhij0Us7ajrO+619NYY9 KAUtRksXzTo0VOl5k3SdLV37HwKaqH+3/s/19nULHyjSLgGM/r4WxqBTgjvcM8Tn yokrOxCaFgwZWWhInXHi07Z6Wz0Eqn8GoB13rNxP+z3vGOlVr6g= =XEXS -----END PGP SIGNATURE-----
Index: java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java =================================================================== --- java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java (nonexistent) +++ java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java (working copy) @@ -0,0 +1,292 @@ +/* + * 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.security.GeneralSecurityException; +import java.security.SecureRandom; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +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.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; +import org.apache.tomcat.util.buf.HexUtils; + +/** + * 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 { + + 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 Cipher encryptionCipher; + private Cipher decryptionCipher; + + public EncryptInterceptor() { + } + + @Override + public void start(int svc) throws ChannelException { + super.start(svc); + + if(Channel.SND_TX_SEQ == svc) { + try { + initCiphers(); + } catch (GeneralSecurityException gse) { + log.fatal(sm.getString("encryptInterceptor.init.failed")); + throw new ChannelException(sm.getString("encryptInterceptor.init.failed"), gse); + } + } + } + + @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 = encrypt(data); + + XByteBuffer xbb = msg.getMessage(); + + // Completely replace the message + xbb.setLength(0); + xbb.append(bytes[0], 0, bytes[0].length); + xbb.append(bytes[1], 0, bytes[1].length); + + super.sendMessage(destination, msg, payload); + + } catch (IllegalBlockSizeException ibse) { + log.error(sm.getString("encryptInterceptor.encrypt.failed")); + throw new ChannelException(ibse); + } catch (BadPaddingException bpe) { + log.error(sm.getString("encryptInterceptor.encrypt.failed")); + throw new ChannelException(bpe); + } + } + + @Override + public void messageReceived(ChannelMessage msg) { + try { + byte[] data = msg.getMessage().getBytes(); + + data = decrypt(data); + + // Remove the decrypted IV/nonce block from the front of the message + int blockSize = getDecryptionCipher().getBlockSize(); + int trimmedSize = data.length - blockSize; + if(trimmedSize < 0) { + log.error(sm.getString("encryptInterceptor.decrypt.error.short-message")); + throw new IllegalStateException(sm.getString("encryptInterceptor.decrypt.error.short-message")); + } + + XByteBuffer xbb = msg.getMessage(); + + // Completely replace the message with the decrypted one + xbb.setLength(0); + xbb.append(data, blockSize, data.length - blockSize); + + super.messageReceived(msg); + } catch (IllegalBlockSizeException ibse) { + log.error(sm.getString("encryptInterceptor.decrypt.failed"), ibse); + } catch (BadPaddingException bpe) { + log.error(sm.getString("encryptInterceptor.decrypt.failed"), bpe); + } + } + + 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; + } + + public String getEncryptionAlgorithm() { + return encryptionAlgorithm; + } + + public void setEncryptionKey(byte[] key) { + encryptionKeyBytes = key; + } + + public void setEncryptionKey(String keyBytes) { + setEncryptionKey(HexUtils.fromHexString(keyBytes)); + } + + private byte[] getEncryptionKey() { + return encryptionKeyBytes; + } + + public void setProviderName(String provider) { + providerName = provider; + } + + public String getProviderName() { + return providerName; + } + + private void initCiphers() throws GeneralSecurityException { + if(null == getEncryptionKey()) + throw new IllegalStateException(sm.getString("encryptInterceptor.key.required")); + + String algorithm = getEncryptionAlgorithm(); + + String mode = getAlgorithmMode(algorithm); + + if(!"CBC".equalsIgnoreCase(mode)) + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.requires-cbc-mode", mode)); + + Cipher cipher; + + String providerName = getProviderName(); + if(null == providerName) { + cipher = Cipher.getInstance(algorithm); + } else { + cipher = Cipher.getInstance(algorithm, getProviderName()); + } + + byte[] iv = new byte[cipher.getBlockSize()]; + + // 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. + new SecureRandom().nextBytes(iv); + + IvParameterSpec IV = new IvParameterSpec(iv); + + // If this is a cipher transform of the form ALGO/MODE/PAD, + // take just the algorithm part. + int pos = algorithm.indexOf('/'); + + String bareAlgorithm; + if(pos >= 0) { + bareAlgorithm = algorithm.substring(0, pos); + } else { + bareAlgorithm = algorithm; + } + + SecretKeySpec encryptionKey = new SecretKeySpec(getEncryptionKey(), bareAlgorithm); + + cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, IV); + + encryptionCipher = cipher; + + if(null == providerName) { + cipher = Cipher.getInstance(algorithm); + } else { + cipher = Cipher.getInstance(algorithm, getProviderName()); + } + + cipher.init(Cipher.DECRYPT_MODE, encryptionKey, new IvParameterSpec(iv)); + + decryptionCipher = cipher; + } + + private Cipher getEncryptionCipher() { + return encryptionCipher; + } + + private Cipher getDecryptionCipher() { + return decryptionCipher; + } + + private static String getAlgorithmMode(String algorithm) { + int start = algorithm.indexOf('/'); + if(start < 0) + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.required")); + int end = algorithm.indexOf('/', start + 1); + if(start < 0) + throw new IllegalArgumentException(sm.getString("encryptInterceptor.algorithm.required")); + + return algorithm.substring(start + 1, end); + } + + /** + * Encrypts the input <code>bytes</code> into two separate byte arrays: + * one for the initial block (which will be the encrypted random IV) + * 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 encrypted IV block in [0] and the encrypted data in [1]. + * + * @throws GeneralSecurityException If there is a problem performing the encryption. + */ + private byte[][] encrypt(byte[] bytes) throws IllegalBlockSizeException, BadPaddingException { + Cipher cipher = getEncryptionCipher(); + + // Adding the IV to the beginning of the encrypted data + byte[] iv = cipher.getIV(); + + byte[][] data = new byte[2][]; + data[0] = cipher.update(iv, 0, iv.length); + data[1] = cipher.doFinal(bytes); + + return data; + } + + /** + * Decrypts the input <code>bytes</code>. + * + * @param bytes The data to decrypt. + * + * @return The decrypted data. + * + * @throws GeneralSecurityException If there is a problem performing the decryption. + */ + private byte[] decrypt(byte[] bytes) throws IllegalBlockSizeException, BadPaddingException { + return getDecryptionCipher().doFinal(bytes); + } +} Index: java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties =================================================================== --- java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties (revision 1844644) +++ java/org/apache/catalina/tribes/group/interceptors/LocalStrings.properties (working copy) @@ -73,4 +73,10 @@ \n\tRx Speed:{8} MB/sec (since 1st msg)\ \n\tReceived:{9} MB]\n twoPhaseCommitInterceptor.originalMessage.missing=Received a confirmation, but original message is missing. Id:[{0}] -twoPhaseCommitInterceptor.heartbeat.failed=Unable to perform heartbeat on the TwoPhaseCommit interceptor. \ No newline at end of file +twoPhaseCommitInterceptor.heartbeat.failed=Unable to perform heartbeat on the TwoPhaseCommit interceptor. +encryptInterceptor.init.failed=Failed to initialize EncryptInterceptor +encryptInterceptor.encrypt.failed=Failed to encrypt message +encryptInterceptor.decrypt.failed=Failed to decrypt message +encryptInterceptor.algorithm.required=Encryption algorithm is required, fully-specified e.g. AES/CBC/PKCS5Padding +encryptInterceptor.key.required=Encryption key is required +encryptInterceptor.algorithm.requires-cbc-mode=EncryptInterceptor only supports CBC cipher modes, not [{0}] \ No newline at end of file Index: test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java =================================================================== --- test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java (nonexistent) +++ test/org/apache/catalina/tribes/group/interceptors/TestEncryptInterceptor.java (working copy) @@ -0,0 +1,191 @@ +package org.apache.catalina.tribes.group.interceptors; + +import static org.junit.Assert.*; + +import java.nio.charset.StandardCharsets; +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; +import org.apache.tomcat.util.buf.HexUtils; +import org.junit.Before; +import org.junit.Test; + +/** + * 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. + */ +public class TestEncryptInterceptor { + private static final String encryptionKey128 = HexUtils.toHexString("cafebabedeadbeef".getBytes(StandardCharsets.UTF_8)); + private static final String encryptionKey192 = HexUtils.toHexString("cafebabedeadbeefbeefcafe".getBytes(StandardCharsets.UTF_8)); + private static final String encryptionKey256 = HexUtils.toHexString("cafebabedeadbeefcafebabedeadbeef".getBytes(StandardCharsets.UTF_8)); + + EncryptInterceptor src; + EncryptInterceptor dest; + + @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."; + + assertEquals("Basic 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"; + + assertEquals("Tiny payload roundtrip failed", + testInput, + roundTrip(testInput, src, dest)); + } + + @Test + public void testHugePayload() throws Exception { + src.start(Channel.SND_TX_SEQ); + dest.start(Channel.SND_TX_SEQ); + + byte[] bytes = new byte[1073741824]; // 1MiB, all zeros + + assertArrayEquals("Tiny 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."; + + 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."; + + 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."; + + 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(((ValueCaptureInterceptor)dest.getPrevious()).getValue(), "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(); + } + + /** + * 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; + } + } +}
--------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org