EncryptInterceptor.java

/*
 * 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.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.GCMParameterSpec;
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;

        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 = createCipher();
            blockSize = cipher.getBlockSize();
            cipherPool.offer(cipher);
            randomPool = new ConcurrentLinkedQueue<>();
        }

        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
    {
        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) {
            // See class javadoc for explanation of this magic number (128)
            return new GCMParameterSpec(128, bytes, offset, length);
        }
    }

    static class ChannelConfigException
        extends ChannelException
    {
        private static final long serialVersionUID = 1L;

        ChannelConfigException(String message) {
            super(message);
        }
    }
}