Repository: ably/ably-java
Issue: `IllegalStateException` in `Crypto` `CBCCipher`'s `decrypt` method

We've had this reported by a customer using our Flutter Plugin.

I've reported this issue in this repository, however, as the call stack falls entirely within code from this SDK.

```
Fatal Exception: java.lang.IllegalStateException
Cipher not initialized
javax.crypto.Cipher.checkCipherState (Cipher.java:1640)
javax.crypto.Cipher.doFinal (Cipher.java:2104)
io.ably.lib.util.Crypto$CBCCipher.decrypt (Crypto.java:255)
io.ably.lib.types.BaseMessage.decode (BaseMessage.java:134)
io.ably.lib.types.BaseMessage.decode (BaseMessage.java:80)
io.ably.lib.types.MessageSerializer$MessageBodyHandler.handleResponseBody (MessageSerializer.java:172)
io.ably.lib.types.MessageSerializer$MessageBodyHandler.handleResponseBody (MessageSerializer.java:157)
io.ably.lib.http.BasePaginatedQuery.handleResponse (BasePaginatedQuery.java:159)
io.ably.lib.http.BasePaginatedQuery.handleResponse (BasePaginatedQuery.java:25)
io.ably.lib.http.HttpCore.handleResponse (HttpCore.java:293)
io.ably.lib.http.HttpCore.httpExecute (HttpCore.java:270)
io.ably.lib.http.HttpCore.httpExecute (HttpCore.java:165)
io.ably.lib.http.HttpCore.httpExecuteWithRetry (HttpCore.java:83)
io.ably.lib.http.HttpScheduler$AsyncRequest.httpExecuteWithRetry (HttpScheduler.java:312)
io.ably.lib.http.HttpScheduler$AblyRequestWithFallback.run (HttpScheduler.java:205)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
java.lang.Thread.run (Thread.java:919)
```

They have tens of thousands of Android app users and thus far have just seen one instance of this in their reported crash logs.

See [this internal Slack message](https://ably-real-time.slack.com/archives/C012DA29VM0/p1643265205068800?thread_ts=1643101418.063300&cid=C012DA29VM0), which contains a link to the Firebase console for this Crashlytics entry, to which some the Ably internal team have access.

Other details:

* **Device**: Realme realme 3
* **OS**: Android 10
* **Device state**: background



┆Issue is synchronized with this [Jira Bug](https://ably.atlassian.net/browse/SDK-1518) by [Unito](https://www.unito.io)


Changed files:
lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java, lib/src/test/java/io/ably/lib/util/CryptoTest.java, lib/src/main/java/io/ably/lib/types/ChannelOptions.java, lib/src/main/java/io/ably/lib/types/BaseMessage.java, lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java, lib/src/main/java/io/ably/lib/util/Crypto.java

diff --git a/lib/src/main/java/io/ably/lib/types/BaseMessage.java b/lib/src/main/java/io/ably/lib/types/BaseMessage.java
index b5aa6c1c..1dc39eae 100644
--- a/lib/src/main/java/io/ably/lib/types/BaseMessage.java
+++ b/lib/src/main/java/io/ably/lib/types/BaseMessage.java
@@ -8,7 +8,7 @@ import com.google.gson.JsonObject;
 import com.google.gson.JsonParseException;
 import com.google.gson.JsonPrimitive;
 import io.ably.lib.util.Base64Coder;
-import io.ably.lib.util.Crypto.ChannelCipher;
+import io.ably.lib.util.Crypto.EncryptingChannelCipher;
 import io.ably.lib.util.Log;
 import io.ably.lib.util.Serialisation;
 import org.msgpack.core.MessageFormat;
@@ -131,7 +131,7 @@ public class BaseMessage implements Cloneable {
                         case "cipher":
                             if(opts != null && opts.encrypted) {
                                 try {
-                                    data = opts.getCipher().decrypt((byte[]) data);
+                                    data = opts.getCipherSet().getDecipher().decrypt((byte[]) data);
                                 } catch(AblyException e) {
                                     throw MessageDecodeException.fromDescription(e.errorInfo.message);
                                 }
@@ -179,7 +179,7 @@ public class BaseMessage implements Cloneable {
             }
         }
         if (opts != null && opts.encrypted) {
-            ChannelCipher cipher = opts.getCipher();
+            EncryptingChannelCipher cipher = opts.getCipherSet().getEncipher();
             data = cipher.encrypt((byte[]) data);
             encoding = ((encoding == null) ? "" : encoding + "/") + "cipher+" + cipher.getAlgorithm();
         }
diff --git a/lib/src/main/java/io/ably/lib/types/ChannelOptions.java b/lib/src/main/java/io/ably/lib/types/ChannelOptions.java
index 2757d064..305cf194 100644
--- a/lib/src/main/java/io/ably/lib/types/ChannelOptions.java
+++ b/lib/src/main/java/io/ably/lib/types/ChannelOptions.java
@@ -5,16 +5,14 @@ import java.util.Map;
 import io.ably.lib.util.Base64Coder;
 import io.ably.lib.util.Crypto;
 import io.ably.lib.util.Crypto.ChannelCipher;
+import io.ably.lib.util.Crypto.ChannelCipherSet;
 
 public class ChannelOptions {
     public Map<String, String> params;
-    
+
     public ChannelMode[] modes;
 
-    /**
-     * Cipher in use.
-     */
-    private ChannelCipher cipher;
+    private ChannelCipherSet cipherSet;
 
     /**
      * Parameters for the cipher.
@@ -25,15 +23,15 @@ public class ChannelOptions {
      * Whether or not this ChannelOptions is encrypted.
      */
     public boolean encrypted;
-    
+
     public boolean hasModes() {
         return null != modes && 0 != modes.length;
     }
-    
+
     public boolean hasParams() {
         return null != params && !params.isEmpty();
     }
-    
+
     public int getModeFlags() {
         int flags = 0;
         for (final ChannelMode mode : modes) {
@@ -41,17 +39,57 @@ public class ChannelOptions {
         }
         return flags;
     }
-    
+
+    /**
+     * Returns a wrapper around the cipher set to be used for this channel. This wrapper is only available in this API
+     * to support customers who may have been using it in their applications with version 1.2.10 or before.
+     *
+     * @deprecated Since version 1.2.11, this method (which was only ever intended for internal use within this library
+     * has been replaced by {@link #getCipherSet()}. It will be removed in the future.
+     */
+    @Deprecated
     public ChannelCipher getCipher() throws AblyException {
-        if(!this.encrypted) {
-            return null;
+        return new ChannelCipher() {
+            @Override
+            public byte[] encrypt(byte[] plaintext) throws AblyException {
+                return getCipherSet().getEncipher().encrypt(plaintext);
+            }
+
+            @Override
+            public byte[] decrypt(byte[] ciphertext) throws AblyException {
+                return getCipherSet().getDecipher().decrypt(ciphertext);
+            }
+
+            @Override
+            public String getAlgorithm() {
+                try {
+                    return getCipherSet().getEncipher().getAlgorithm();
+                } catch (final AblyException e) {
+                    throw new IllegalStateException("Unexpected exception when using legacy crypto cipher interface.", e);
+                }
+            }
+        };
+    }
+
+    /**
+     * Internal; this method is not intended for use by application developers. It may be changed or removed in future.
+     *
+     * Returns the cipher set to be used for encrypting and decrypting data on a channel, given the current state of
+     * this instance. On the first call to this method a new cipher set instance is created, with subsequent callers to
+     * this method being returned that same cipher set instance. This method is safe to be called from any thread.
+     *
+     * @apiNote Once this method has been called then the cipher set is fixed based on the value of the
+     * {@link #cipherParams} field at that time. If that field is then mutated, the cipher set will not be updated.
+     * This is not great API design and we should fix this under https://github.com/ably/ably-java/issues/745
+     */
+    public synchronized ChannelCipherSet getCipherSet() throws AblyException {
+        if (!encrypted) {
+            throw new IllegalStateException("ChannelOptions encrypted field value is false.");
         }
-        if(this.cipher != null) {
-            return this.cipher;
-        } else {
-            this.cipher = Crypto.getCipher(this);
-            return this.cipher;
+        if (null == cipherSet) {
+            cipherSet = Crypto.createChannelCipherSet(cipherParams);
         }
+        return cipherSet;
     }
 
     /**
@@ -88,7 +126,6 @@ public class ChannelOptions {
         ChannelOptions options = new ChannelOptions();
         options.encrypted = true;
         options.cipherParams = Crypto.getDefaultParams(key);
-        options.cipher = Crypto.getCipher(options);
         return options;
     }
 
diff --git a/lib/src/main/java/io/ably/lib/util/Crypto.java b/lib/src/main/java/io/ably/lib/util/Crypto.java
index 8fc19796..8c1054a7 100644
--- a/lib/src/main/java/io/ably/lib/util/Crypto.java
+++ b/lib/src/main/java/io/ably/lib/util/Crypto.java
@@ -4,7 +4,9 @@ import java.security.InvalidAlgorithmParameterException;
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
+import java.util.ConcurrentModificationException;
 import java.util.Locale;
+import java.util.concurrent.Semaphore;
 
 import javax.crypto.BadPaddingException;
 import javax.crypto.Cipher;
@@ -15,7 +17,6 @@ import javax.crypto.spec.IvParameterSpec;
 import javax.crypto.spec.SecretKeySpec;
 
 import io.ably.lib.types.AblyException;
-import io.ably.lib.types.ChannelOptions;
 import io.ably.lib.types.ErrorInfo;
 import io.ably.lib.types.Param;
 
@@ -171,7 +172,13 @@ public class Crypto {
     /**
      * Interface for a ChannelCipher instance that may be associated with a Channel.
      *
+     * The operational methods implemented by channel cipher instances (encrypt and decrypt) are not designed to be
+     * safe to be called from any thread.
+     *
+     * @deprecated Since version 1.2.11, this interface (which was only ever intended for internal use within this
+     * library) has been replaced by {@link ChannelCipherSet}. It will be removed in the future.
      */
+    @Deprecated
     public interface ChannelCipher {
         byte[] encrypt(byte[] plaintext) throws AblyException;
         byte[] decrypt(byte[] ciphertext) throws AblyException;
@@ -179,87 +186,143 @@ public class Crypto {
     }
 
     /**
-     * Internal; get a ChannelCipher instance based on the given ChannelOptions
-     * @param opts
-     * @return
-     * @throws AblyException
+     * Internal; a cipher used to encrypt plaintext to ciphertext, for a channel.
+     */
+    public interface EncryptingChannelCipher {
+        /**
+         * Enciphers plaintext.
+         *
+         * This method is not safe to be called from multiple threads at the same time, and it will throw a
+         * {@link ConcurrentModificationException} if that happens at runtime.
+         *
+         * @return ciphertext, being the result of encrypting plaintext.
+         * @throws ConcurrentModificationException If this method is called from more than one thread at a time.
+         */
+        byte[] encrypt(byte[] plaintext) throws AblyException;
+
+        String getAlgorithm();
+    }
+
+    /**
+     * Internal; a cipher used to decrypt plaintext from ciphertext, for a channel.
      */
-    public static ChannelCipher getCipher(final ChannelOptions opts) throws AblyException {
-        final Object opaqueCipherParams = opts.cipherParams;
-        final CipherParams cipherParams;
-        if(null == opaqueCipherParams)
-            cipherParams = Crypto.getDefaultParams();
-        else if(opts.cipherParams instanceof CipherParams)
-            cipherParams = (CipherParams)opts.cipherParams;
+    public interface DecryptingChannelCipher {
+        /**
+         * Deciphers ciphertext.
+         *
+         * This method is not safe to be called from multiple threads at the same time, and it will throw a
+         * {@link ConcurrentModificationException} if that happens at runtime.
+         *
+         * @return plaintext, being the result of decrypting ciphertext.
+         * @throws ConcurrentModificationException If this method is called from more than one thread at a time.
+         */
+        byte[] decrypt(byte[] ciphertext) throws AblyException;
+    }
+
+    /**
+     * Internal; a matching encipher and decipher pair, where both are guaranteed to have been configured with the same
+     * {@link CipherParams} as each other.
+     */
+    public interface ChannelCipherSet {
+        EncryptingChannelCipher getEncipher();
+        DecryptingChannelCipher getDecipher();
+    }
+
+    /**
+     * Internal; get an encrypting cipher instance based on the given channel options.
+     */
+    public static ChannelCipherSet createChannelCipherSet(final Object cipherParams) throws AblyException {
+        final CipherParams nonNullParams;
+        if (null == cipherParams)
+            nonNullParams = Crypto.getDefaultParams();
+        else if (cipherParams instanceof CipherParams)
+            nonNullParams = (CipherParams)cipherParams;
         else
             throw AblyException.fromErrorInfo(new ErrorInfo("ChannelOptions not supported", 400, 40000));
 
-        return new CBCCipher(cipherParams);
+        return new ChannelCipherSet() {
+            private final EncryptingChannelCipher encipher = new EncryptingCBCCipher(nonNullParams);
+            private final DecryptingChannelCipher decipher = new DecryptingCBCCipher(nonNullParams);
+
+            @Override
+            public EncryptingChannelCipher getEncipher() {
+                return encipher;
+            }
+
+            @Override
+            public DecryptingChannelCipher getDecipher() {
+                return decipher;
+            }
+        };
     }
 
     /**
-     * Internal: a class that implements a CBC mode ChannelCipher.
+     * Implements a CBC mode ChannelCipher.
      * A single block of secure random data is provided for an initial IV.
      * Consecutive messages are chained in a manner that allows each to be
      * emitted with an IV, allowing each to be deciphered independently,
      * whilst avoiding having to obtain further entropy for IVs, and reinit
      * the cipher, between successive messages.
-     *
      */
-    private static class CBCCipher implements ChannelCipher {
-        private final SecretKeySpec keySpec;
-        private final Cipher encryptCipher;
-        private final Cipher decryptCipher;
-        private final String algorithm;
-        private final int blockLength;
-        private byte[] iv;
-
-        private CBCCipher(CipherParams params) throws AblyException {
+    private static class CBCCipher {
+        protected final SecretKeySpec keySpec;
+        protected final IvParameterSpec ivSpec;
+        protected final Cipher cipher;
+        protected final int blockLength;
+        protected final String algorithm;
+        private final Semaphore semaphore = new Semaphore(1);
+
+        protected CBCCipher(final CipherParams params) throws AblyException {
             final String cipherAlgorithm = params.getAlgorithm();
             String transformation = cipherAlgorithm.toUpperCase(Locale.ROOT) + "/CBC/PKCS5Padding";
             try {
                 algorithm = cipherAlgorithm + '-' + params.getKeyLength() + "-cbc";
                 keySpec = params.keySpec;
-                encryptCipher = Cipher.getInstance(transformation);
-                encryptCipher.init(Cipher.ENCRYPT_MODE, params.keySpec, params.ivSpec);
-                decryptCipher = Cipher.getInstance(transformation);
-                iv = params.ivSpec.getIV();
-                blockLength = iv.length;
+                ivSpec = params.ivSpec;
+                blockLength = ivSpec.getIV().length;
+                cipher = Cipher.getInstance(transformation);
             }
-            catch (NoSuchAlgorithmException|NoSuchPaddingException|InvalidAlgorithmParameterException|InvalidKeyException e) {
+            catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
                 throw AblyException.fromThrowable(e);
             }
         }
 
-        @Override
-        public byte[] encrypt(byte[] plaintext) {
-            if(plaintext == null) return null;
-            int plaintextLength = plaintext.length;
-            int paddedLength = getPaddedLength(plaintextLength);
-            byte[] cipherIn = new byte[paddedLength];
-            byte[] ciphertext = new byte[paddedLength + blockLength];
-            int padding = paddedLength - plaintextLength;
-            System.arraycopy(plaintext, 0, cipherIn, 0, plaintextLength);
-            System.arraycopy(pkcs5Padding[padding], 0, cipherIn, plaintextLength, padding);
-            System.arraycopy(getIv(), 0, ciphertext, 0, blockLength);
-            byte[] cipherOut = encryptCipher.update(cipherIn);
-            System.arraycopy(cipherOut, 0, ciphertext, blockLength, paddedLength);
-            return ciphertext;
+        /**
+         * Subclasses must call this method before performing any work that uses the {@link #cipher} or otherwise
+         * mutates the state of this instance.
+         *
+         * TODO: under https://github.com/ably/ably-java/issues/747 we can then:
+         * - remove the need for the {@link #releaseOperationalPermit()} method, and
+         * - make this method return an AutoCloseable implementation that releases the semaphore.
+         */
+        protected void acquireOperationalPermit() {
+            if (!semaphore.tryAcquire()) {
+                throw new ConcurrentModificationException("ChannelCipher instances are not designed to be operated from multiple threads simultaneously.");
+            }
         }
 
-        @Override
-        public byte[] decrypt(byte[] ciphertext) throws AblyException {
-            if(ciphertext == null) return null;
-            byte[] plaintext = null;
+        /**
+         * Subclasses must call this method after performing any work that uses the {@link #cipher} or otherwise
+         * mutates the state of this instance.
+         */
+        protected void releaseOperationalPermit() {
+            semaphore.release();
+        }
+    }
+
+    private static class EncryptingCBCCipher extends CBCCipher implements EncryptingChannelCipher {
+        private byte[] iv;
+
+        EncryptingCBCCipher(final CipherParams params) throws AblyException {
+            super(params);
+
             try {
-                decryptCipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(ciphertext, 0, blockLength));
-                plaintext = decryptCipher.doFinal(ciphertext, blockLength, ciphertext.length - blockLength);
-            }
-            catch (InvalidKeyException|InvalidAlgorithmParameterException|IllegalBlockSizeException|BadPaddingException e) {
-                Log.e(TAG, "decrypt()", e);
+                cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
+            } catch (InvalidAlgorithmParameterException | InvalidKeyException e) {
                 throw AblyException.fromThrowable(e);
             }
-            return plaintext;
+
+            iv = params.ivSpec.getIV();
         }
 
         @Override
@@ -268,36 +331,12 @@ public class Crypto {
         }
 
         /**
-         * Internal: get an IV for the next message.
-         * Returns either the IV that was used to initialise the ChannelCipher,
-         * or generates an IV based on the current cipher state.
-         */
-        private byte[] getIv() {
-            if(iv == null)
-                return encryptCipher.update(emptyBlock);
-
-            final byte[] result = iv;
-            iv = null;
-            return result;
-        }
-
-        /**
-         * Internal: calculate the padded length of a given plaintext
-         * using PKCS5.
-         * @param plaintextLength
-         * @return
-         */
-        private static int getPaddedLength(int plaintextLength) {
-            return (plaintextLength + DEFAULT_BLOCKLENGTH) & -DEFAULT_BLOCKLENGTH;
-        }
-
-        /**
-         * Internal: a block containing zeros
+         * A block containing zeros.
          */
         private static final byte[] emptyBlock = new byte[DEFAULT_BLOCKLENGTH];
 
         /**
-         * Internal: obtain the pkcs5 padding string for a given padded length;
+         * The PKCS5 padding strings for given padded lengths.
          */
         private static final byte[][] pkcs5Padding = new byte[][] {
             new byte[] {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16},
@@ -318,6 +357,72 @@ public class Crypto {
             new byte[] {15,15,15,15,15,15,15,15,15,15,15,15,15,15,15},
             new byte[] {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16}
         };
+
+        /**
+         * Returns the padded length of a given plaintext, using PKCS5.
+         */
+        private static int getPaddedLength(int plaintextLength) {
+            return (plaintextLength + DEFAULT_BLOCKLENGTH) & -DEFAULT_BLOCKLENGTH;
+        }
+
+        /**
+         * Get an IV for the next message.
+         * Returns either the IV that was used to initialise the ChannelCipher,
+         * or generates an IV based on the current cipher state.
+         */
+        private byte[] getNextIv() {
+            if (iv == null)
+                return cipher.update(emptyBlock);
+
+            final byte[] result = iv;
+            iv = null;
+            return result;
+        }
+
+        @Override
+        public byte[] encrypt(byte[] plaintext) {
+            if (plaintext == null) return null;
+
+            acquireOperationalPermit();
+            try {
+                final int plaintextLength = plaintext.length;
+                final int paddedLength = getPaddedLength(plaintextLength);
+                final byte[] cipherIn = new byte[paddedLength];
+                final byte[] ciphertext = new byte[paddedLength + blockLength];
+                final int padding = paddedLength - plaintextLength;
+                System.arraycopy(plaintext, 0, cipherIn, 0, plaintextLength);
+                System.arraycopy(pkcs5Padding[padding], 0, cipherIn, plaintextLength, padding);
+                System.arraycopy(getNextIv(), 0, ciphertext, 0, blockLength);
+                final byte[] cipherOut = cipher.update(cipherIn);
+                System.arraycopy(cipherOut, 0, ciphertext, blockLength, paddedLength);
+                return ciphertext;
+            } finally {
+                // TODO: under https://github.com/ably/ably-java/issues/747 we will remove this call.
+                releaseOperationalPermit();
+            }
+        }
+    }
+
+    private static class DecryptingCBCCipher extends CBCCipher implements DecryptingChannelCipher {
+        DecryptingCBCCipher(final CipherParams params) throws AblyException {
+            super(params);
+        }
+
+        @Override
+        public byte[] decrypt(byte[] ciphertext) throws AblyException {
+            if(ciphertext == null) return null;
+
+            acquireOperationalPermit();
+            try {
+                cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(ciphertext, 0, blockLength));
+                return cipher.doFinal(ciphertext, blockLength, ciphertext.length - blockLength);
+            } catch (InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) {
+                throw AblyException.fromThrowable(e);
+            } finally {
+                // TODO: under https://github.com/ably/ably-java/issues/747 we will remove this call.
+                releaseOperationalPermit();
+            }
+        }
     }
 
     public static String getRandomId() {
diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java
index 2270f6ef..52add81f 100644
--- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java
+++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java
@@ -33,7 +33,7 @@ import io.ably.lib.types.ChannelOptions;
 import io.ably.lib.types.ClientOptions;
 import io.ably.lib.types.ErrorInfo;
 import io.ably.lib.util.Crypto;
-import io.ably.lib.util.Crypto.ChannelCipher;
+import io.ably.lib.util.Crypto.ChannelCipherSet;
 import io.ably.lib.util.Crypto.CipherParams;
 
 public class RealtimeCryptoTest extends ParameterizedTest {
@@ -805,12 +805,12 @@ public class RealtimeCryptoTest extends ParameterizedTest {
     @Test
     public void encodeDecodeVariableSizesWithAES256CBC() throws NoSuchAlgorithmException, AblyException {
         final CipherParams params = Crypto.getParams("aes", generateNonce(32), generateNonce(16));
-        final ChannelCipher cipher = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params; }});
+        final ChannelCipherSet cipherSet = Crypto.createChannelCipherSet(params);
         for (int i=1; i<1000; i++) {
             final int size = RANDOM.nextInt(2000) + 1;
             final byte[] message = generateNonce(size);
-            final byte[] encrypted = cipher.encrypt(message);
-            final byte[] decrypted = cipher.decrypt(encrypted);
+            final byte[] encrypted = cipherSet.getEncipher().encrypt(message);
+            final byte[] decrypted = cipherSet.getDecipher().decrypt(encrypted);
             try {
                 assertArrayEquals(message, decrypted);
             } catch (final AssertionError e) {
@@ -1066,12 +1066,12 @@ public class RealtimeCryptoTest extends ParameterizedTest {
             // We have to create a new ChannelCipher for each message we encode because
             // cipher instances only use the IV we've supplied via CipherParams for the
             // encryption of the very first message.
-            final ChannelCipher cipher = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params; }});
+            final ChannelCipherSet cipherSet = Crypto.createChannelCipherSet(params);
 
             final byte[] appleMessage = hexStringToByteArray(entry.getKey());
             final byte[] appleEncrypted = hexStringToByteArray(entry.getValue());
-            final byte[] encrypted = cipher.encrypt(appleMessage);
-            final byte[] decrypted = cipher.decrypt(appleEncrypted);
+            final byte[] encrypted = cipherSet.getEncipher().encrypt(appleMessage);
+            final byte[] decrypted = cipherSet.getDecipher().decrypt(appleEncrypted);
 
             try {
                 assertArrayEquals(appleMessage, decrypted);
diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java
index 183c895a..a3ac5e5f 100644
--- a/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java
+++ b/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java
@@ -67,8 +67,12 @@ public class RestCryptoTest extends ParameterizedTest {
         /* verify message contents */
         for (final Message message : messages.items())
             messageContents.put(message.name, message.data);
-        assertEquals("This is a string message payload", messageContents.get("publish0"));
-        assertEquals("This is a byte[] message payload", new String((byte[])messageContents.get("publish1")));
+        final Object payload0 = messageContents.get("publish0");
+        final Object payload1 = messageContents.get("publish1");
+        assertTrue("Unexpected " + payload0.getClass(), payload0 instanceof String);
+        assertTrue("Unexpected " + payload1.getClass(), payload1 instanceof byte[]);
+        assertEquals("This is a string message payload", payload0);
+        assertEquals("This is a byte[] message payload", new String((byte[])payload1));
     }
 
     /**
diff --git a/lib/src/test/java/io/ably/lib/util/CryptoTest.java b/lib/src/test/java/io/ably/lib/util/CryptoTest.java
index e4b0cb53..6b471411 100644
--- a/lib/src/test/java/io/ably/lib/util/CryptoTest.java
+++ b/lib/src/test/java/io/ably/lib/util/CryptoTest.java
@@ -18,9 +18,9 @@ import org.msgpack.core.MessagePacker;
 import com.google.gson.stream.JsonWriter;
 
 import io.ably.lib.types.AblyException;
-import io.ably.lib.types.ChannelOptions;
-import io.ably.lib.util.Crypto.ChannelCipher;
+import io.ably.lib.util.Crypto.ChannelCipherSet;
 import io.ably.lib.util.Crypto.CipherParams;
+import io.ably.lib.util.Crypto.EncryptingChannelCipher;
 import io.ably.lib.util.CryptoMessageTest.FixtureSet;
 
 public class CryptoTest {
@@ -57,10 +57,10 @@ public class CryptoTest {
         );
 
         byte[] plaintext = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
-        ChannelCipher channelCipher1 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params1; }});
-        ChannelCipher channelCipher2 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params2; }});
-        ChannelCipher channelCipher3 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params3; }});
-        ChannelCipher channelCipher4 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params4; }});
+        EncryptingChannelCipher channelCipher1 = Crypto.createChannelCipherSet(params1).getEncipher();
+        EncryptingChannelCipher channelCipher2 = Crypto.createChannelCipherSet(params2).getEncipher();
+        EncryptingChannelCipher channelCipher3 = Crypto.createChannelCipherSet(params3).getEncipher();
+        EncryptingChannelCipher channelCipher4 = Crypto.createChannelCipherSet(params4).getEncipher();
 
         byte[] ciphertext1 = channelCipher1.encrypt(plaintext);
         byte[] ciphertext2 = channelCipher2.encrypt(plaintext);
@@ -127,18 +127,18 @@ public class CryptoTest {
         for (int i=1; i<=maxLength; i++) {
             // We need to create a new ChannelCipher for each message we encode,
             // so that our IV gets used (being start of CBC chain).
-            final ChannelCipher cipher = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params; }});
+            final ChannelCipherSet cipherSet = Crypto.createChannelCipherSet(params);
 
             // Encrypt i bytes from the start of the message data.
             final byte[] encoded = Arrays.copyOfRange(message, 0, i);
-            final byte[] encrypted = cipher.encrypt(encoded);
+            final byte[] encrypted = cipherSet.getEncipher().encrypt(encoded);
 
             // Add encryption result to results in format ready for fixture.
             writeResult(writer, "byte 1 to " + i, encoded, encrypted, fixtureSet.cipherName);
 
             // Decrypt the encrypted data and verify the result is the same as what
             // we submitted for encryption.
-            final byte[] verify = cipher.decrypt(encrypted);
+            final byte[] verify = cipherSet.getDecipher().decrypt(encrypted);
             assertArrayEquals(verify, encoded);
         }
         writer.endArray();