You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by br...@apache.org on 2023/09/18 08:03:53 UTC

[solr-sandbox] branch main updated: Add CharStreamEncrypter. (#71)

This is an automated email from the ASF dual-hosted git repository.

broustant pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-sandbox.git


The following commit(s) were added to refs/heads/main by this push:
     new fc865c1  Add CharStreamEncrypter. (#71)
fc865c1 is described below

commit fc865c133f7553171a67b7369907cf1519ddd787
Author: Bruno Roustant <33...@users.noreply.github.com>
AuthorDate: Mon Sep 18 10:03:48 2023 +0200

    Add CharStreamEncrypter. (#71)
---
 encryption/build.gradle                            |   5 +
 .../encryption/crypto/CharStreamEncrypter.java     | 188 +++++++++++++++++++++
 .../encryption/crypto/CipherAesCtrEncrypter.java   |   1 +
 .../encryption/crypto/DecryptingInputStream.java   | 162 ++++++++++++++++++
 .../encryption/crypto/EncryptingOutputStream.java  | 138 +++++++++++++++
 .../encryption/crypto/AesCtrEncrypterTest.java     |  27 +--
 .../encryption/crypto/CharStreamEncrypterTest.java |  64 +++++++
 7 files changed, 567 insertions(+), 18 deletions(-)

diff --git a/encryption/build.gradle b/encryption/build.gradle
index 1e5ccdb..294c2cd 100644
--- a/encryption/build.gradle
+++ b/encryption/build.gradle
@@ -37,6 +37,11 @@ dependencies {
     implementation 'org.apache.lucene:lucene-core:9.7.0'
     implementation 'com.google.code.findbugs:jsr305:3.0.2'
 
+    // commons-io and commons-codec are only required by the tool class
+    // CharStreamEncrypter, which is not used for the index encryption.
+    implementation 'commons-io:commons-io:2.11.0'
+    implementation 'commons-codec:commons-codec:1.16.0'
+
     testImplementation 'org.apache.solr:solr-test-framework:9.3.0'
     testImplementation 'org.apache.lucene:lucene-test-framework:9.7.0'
 }
diff --git a/encryption/src/main/java/org/apache/solr/encryption/crypto/CharStreamEncrypter.java b/encryption/src/main/java/org/apache/solr/encryption/crypto/CharStreamEncrypter.java
new file mode 100644
index 0000000..d370a1e
--- /dev/null
+++ b/encryption/src/main/java/org/apache/solr/encryption/crypto/CharStreamEncrypter.java
@@ -0,0 +1,188 @@
+/*
+ * 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.solr.encryption.crypto;
+
+import org.apache.commons.codec.binary.Base64InputStream;
+import org.apache.commons.codec.binary.Base64OutputStream;
+import org.apache.commons.io.input.ReaderInputStream;
+import org.apache.commons.io.output.AppendableWriter;
+import org.apache.commons.io.output.WriterOutputStream;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Encrypts a character stream to a base 64 encoding compatible with JSON.
+ * <p>
+ * The whole encryption and base 64 encoding process is streamed, with no large
+ * buffers allocated. The encryption transformation is AES/CTR/NoPadding.
+ * A secure random IV is generated for each encryption and appended as the first
+ * appended chars.
+ */
+public class CharStreamEncrypter {
+
+  private static final int BUFFER_MIN_SIZE = 128;
+  private static final int BUFFER_MAX_SIZE = 8192;
+
+  private final AesCtrEncrypterFactory factory;
+
+  public CharStreamEncrypter(AesCtrEncrypterFactory factory) {
+    this.factory = factory;
+  }
+
+  /**
+   * Encrypts an input string to base 64 characters compatible with JSON.
+   *
+   * @param key    AES key, can either 16, 24 or 32 bytes.
+   * @param output where to append the encrypted base 64 chars.
+   * @throws IOException propagates any exception thrown when appending to the output.
+   */
+  public void encrypt(String input, byte[] key, Appendable output)
+    throws IOException {
+    encrypt(new StringReader(input), input.length(), key, output);
+  }
+
+  /**
+   * Encrypts a char reader stream to base 64 characters compatible with JSON.
+   *
+   * @param inputSizeHint optional hint for the input size; or -1 if unknown.
+   * @param key           AES key, can either 16, 24 or 32 bytes.
+   * @param output        where to append the encrypted base 64 chars.
+   * @throws IOException propagates any exception thrown when appending to the output.
+   */
+  public void encrypt(Reader inputReader, int inputSizeHint, byte[] key, Appendable output)
+    throws IOException {
+    // Don't use jdk Base64.getEncoder().wrap() because it's buggy.
+    int bufferSize = getBufferSize(inputSizeHint);
+    try (OutputStreamWriter encryptedOutputWriter =
+           new OutputStreamWriter(
+             new EncryptingOutputStream(
+               new Base64OutputStream(
+                 new LightWriterOutputStream(
+                   toWriter(output),
+                   StandardCharsets.ISO_8859_1,
+                   bufferSize
+                 )
+               ),
+               key,
+               factory
+             ),
+             StandardCharsets.UTF_8
+           )
+    ) {
+      transfer(inputReader,
+               encryptedOutputWriter,
+               bufferSize
+      );
+    }
+  }
+
+  /**
+   * Decrypts an input string previously encrypted with {@link #encrypt}.
+   *
+   * @param key    AES key, can either 16, 24 or 32 bytes.
+   * @param output where to append the decrypted chars.
+   * @throws IOException propagates any exception thrown when appending to the output.
+   */
+  public void decrypt(String input, byte[] key, Appendable output)
+    throws IOException {
+    decrypt(new StringReader(input), input.length(), key, output);
+  }
+
+  /**
+   * Decrypts a char reader stream previously encrypted with {@link #encrypt}.
+   *
+   * @param inputSizeHint optional hint for the input size; or -1 if unknown.
+   * @param key           AES key, can either 16, 24 or 32 bytes.
+   * @param output        where to append the decrypted chars.
+   * @throws IOException propagates any exception thrown when appending to the output.
+   */
+  public void decrypt(Reader inputReader, int inputSizeHint, byte[] key, Appendable output)
+    throws IOException {
+    // Don't use jdk Base64.getDecoder().wrap() because it's buggy.
+    int bufferSize = getBufferSize(inputSizeHint);
+    try (InputStreamReader decryptedInputReader =
+           new InputStreamReader(
+             new DecryptingInputStream(
+               new Base64InputStream(
+                 new ReaderInputStream(
+                   inputReader,
+                   StandardCharsets.ISO_8859_1,
+                   bufferSize
+                 )
+               ),
+               key,
+               factory
+             ),
+             StandardCharsets.UTF_8
+           )
+    ) {
+      transfer(decryptedInputReader,
+               toWriter(output),
+               bufferSize
+      );
+    }
+  }
+
+  private static int getBufferSize(int inputSizeHint) {
+    return inputSizeHint < 0 ? BUFFER_MAX_SIZE
+      : Math.min(Math.max(inputSizeHint / 16, BUFFER_MIN_SIZE), BUFFER_MAX_SIZE);
+  }
+
+  private static Writer toWriter(Appendable appendable) {
+    return appendable instanceof Writer ? (Writer) appendable : new AppendableWriter<>(appendable);
+  }
+
+  /**
+   * Similar to {@link Reader#transferTo(Writer)} with a provided buffer size.
+   */
+  private static void transfer(Reader input, Writer output, int bufferSize)
+    throws IOException {
+    char[] buffer = new char[bufferSize];
+    int nRead;
+    while ((nRead = input.read(buffer, 0, bufferSize)) >= 0) {
+      output.write(buffer, 0, nRead);
+    }
+  }
+
+  /**
+   * Same as {@link WriterOutputStream} without creating a buffer for each
+   * call to {@link #write(int)}.
+   */
+  private static class LightWriterOutputStream extends WriterOutputStream {
+
+    private final byte[] oneByteBuf = new byte[1];
+
+    public LightWriterOutputStream(Writer writer,
+                                   Charset charset,
+                                   int bufferSize) {
+      super(writer, charset, bufferSize, false);
+    }
+
+    @Override
+    public void write(int b) throws IOException {
+      oneByteBuf[0] = (byte) b;
+      write(oneByteBuf, 0, 1);
+    }
+  }
+}
diff --git a/encryption/src/main/java/org/apache/solr/encryption/crypto/CipherAesCtrEncrypter.java b/encryption/src/main/java/org/apache/solr/encryption/crypto/CipherAesCtrEncrypter.java
index d1bfa90..b11a294 100644
--- a/encryption/src/main/java/org/apache/solr/encryption/crypto/CipherAesCtrEncrypter.java
+++ b/encryption/src/main/java/org/apache/solr/encryption/crypto/CipherAesCtrEncrypter.java
@@ -133,6 +133,7 @@ public class CipherAesCtrEncrypter implements AesCtrEncrypter {
       this.iv = iv;
     }
 
+    @Override
     public byte[] getIV() {
       return iv.clone();
     }
diff --git a/encryption/src/main/java/org/apache/solr/encryption/crypto/DecryptingInputStream.java b/encryption/src/main/java/org/apache/solr/encryption/crypto/DecryptingInputStream.java
new file mode 100644
index 0000000..909466f
--- /dev/null
+++ b/encryption/src/main/java/org/apache/solr/encryption/crypto/DecryptingInputStream.java
@@ -0,0 +1,162 @@
+/*
+ * 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.solr.encryption.crypto;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+import static org.apache.solr.encryption.crypto.AesCtrUtil.AES_BLOCK_SIZE;
+import static org.apache.solr.encryption.crypto.AesCtrUtil.IV_LENGTH;
+
+/**
+ * {@link InputStream} that reads from a delegate {@link InputStream} and decrypts data on the fly.
+ * <p>The encryption transformation is AES/CTR/NoPadding. It decrypts the data previously encrypted
+ * with an {@link EncryptingOutputStream}.
+ * <p>It first reads the CTR Initialization Vector (IV). This random IV is not encrypted. Then it
+ * can decrypt the rest of the file.
+ *
+ * @see EncryptingOutputStream
+ * @see AesCtrEncrypter
+ */
+public class DecryptingInputStream extends InputStream {
+
+  /**
+   * Must be a multiple of {@link AesCtrUtil#AES_BLOCK_SIZE}.
+   * Benchmarks showed that 6 x {@link AesCtrUtil#AES_BLOCK_SIZE} is a good buffer size.
+   */
+  private static final int BUFFER_CAPACITY = 6 * AES_BLOCK_SIZE; // 96 B
+
+  private final InputStream inputStream;
+  private final AesCtrEncrypter encrypter;
+  private final ByteBuffer inBuffer;
+  private final ByteBuffer outBuffer;
+  private final byte[] inArray;
+  private final byte[] oneByteBuf;
+  private boolean closed;
+
+  /**
+   * @param inputStream The delegate {@link InputStream} to read and decrypt data from.
+   * @param key         The encryption key secret. It is cloned internally, its content
+   *                    is not modified, and no reference to it is kept.
+   * @param factory     The factory to use to create one instance of {@link AesCtrEncrypter}.
+   */
+  public DecryptingInputStream(InputStream inputStream, byte[] key, AesCtrEncrypterFactory factory) throws IOException {
+    this.inputStream = inputStream;
+    this.encrypter = createEncrypter(inputStream, key, factory);
+    encrypter.init(0);
+    inBuffer = ByteBuffer.allocate(getBufferCapacity());
+    outBuffer = ByteBuffer.allocate(getBufferCapacity() + AES_BLOCK_SIZE);
+    outBuffer.limit(0);
+    assert inBuffer.hasArray() && outBuffer.hasArray();
+    assert inBuffer.arrayOffset() == 0;
+    inArray = inBuffer.array();
+    oneByteBuf = new byte[1];
+  }
+
+  /**
+   * Creates the {@link AesCtrEncrypter} based on the secret key and the IV at the beginning
+   * of the input stream.
+   */
+  private static AesCtrEncrypter createEncrypter(InputStream inputStream,
+                                                 byte[] key,
+                                                 AesCtrEncrypterFactory factory)
+    throws IOException {
+    byte[] iv = new byte[IV_LENGTH];
+    int n = inputStream.read(iv, 0, iv.length);
+    if (n != iv.length) {
+      throw new IOException("Missing IV");
+    }
+    return factory.create(key, iv);
+  }
+
+  /**
+   * Gets the buffer capacity. It must be a multiple of {@link AesCtrUtil#AES_BLOCK_SIZE}.
+   */
+  protected int getBufferCapacity() {
+    return BUFFER_CAPACITY;
+  }
+
+  @Override
+  public void close() throws IOException {
+    if (!closed) {
+      closed = true;
+      inputStream.close();
+    }
+  }
+
+  @Override
+  public int read() throws IOException {
+    int n = read(oneByteBuf, 0, 1);
+    return n == -1 ? -1 : oneByteBuf[0] & 0xFF;
+  }
+
+  @Override
+  public int read(byte[] b, int offset, int length) throws IOException {
+    if (offset < 0 || length < 0 || offset + length > b.length) {
+      throw new IllegalArgumentException(
+        "Invalid read buffer parameters (offset=" + offset + ", length=" + length
+          + ", arrayLength=" + b.length + ")");
+    }
+    int numRead = 0;
+    while (length > 0) {
+      // Transfer decrypted bytes from outBuffer.
+      int outRemaining = outBuffer.remaining();
+      if (outRemaining > 0) {
+        if (length <= outRemaining) {
+          outBuffer.get(b, offset, length);
+          numRead += length;
+          return numRead;
+        }
+        outBuffer.get(b, offset, outRemaining);
+        numRead += outRemaining;
+        assert outBuffer.remaining() == 0;
+        offset += outRemaining;
+        length -= outRemaining;
+      }
+
+      if (!readToFillBuffer(length)) {
+        return numRead == 0 ? -1 : numRead;
+      }
+      decryptBuffer();
+    }
+    return numRead;
+  }
+
+  private boolean readToFillBuffer(int length) throws IOException {
+    assert length > 0;
+    int inRemaining = inBuffer.remaining();
+    if (inRemaining > 0) {
+      int position = inBuffer.position();
+      int numBytesToRead = Math.min(inRemaining, length);
+      int n = inputStream.read(inArray, position, numBytesToRead);
+      if (n == -1) {
+        return false;
+      }
+      inBuffer.position(position + n);
+    }
+    return true;
+  }
+
+  private void decryptBuffer() {
+    inBuffer.flip();
+    outBuffer.clear();
+    encrypter.process(inBuffer, outBuffer);
+    inBuffer.clear();
+    outBuffer.flip();
+  }
+}
\ No newline at end of file
diff --git a/encryption/src/main/java/org/apache/solr/encryption/crypto/EncryptingOutputStream.java b/encryption/src/main/java/org/apache/solr/encryption/crypto/EncryptingOutputStream.java
new file mode 100644
index 0000000..c5f6da1
--- /dev/null
+++ b/encryption/src/main/java/org/apache/solr/encryption/crypto/EncryptingOutputStream.java
@@ -0,0 +1,138 @@
+/*
+ * 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.solr.encryption.crypto;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+
+import static org.apache.solr.encryption.crypto.AesCtrUtil.AES_BLOCK_SIZE;
+import static org.apache.solr.encryption.crypto.AesCtrUtil.generateRandomAesCtrIv;
+
+/**
+ * {@link OutputStream} that encrypts data and writes to a delegate {@link OutputStream} on the fly.
+ * <p>The encryption transformation is AES/CTR/NoPadding. Use a {@link DecryptingInputStream} to
+ * decrypt the encrypted data.
+ * <p>It generates a cryptographically strong random CTR Initialization Vector (IV). This random IV
+ * is not encrypted and is skipped by any {@link DecryptingInputStream} reading the written data.
+ * Then it can encrypt the rest of the file.
+ *
+ * @see DecryptingInputStream
+ * @see AesCtrEncrypter
+ */
+public class EncryptingOutputStream extends OutputStream {
+
+  /**
+   * Must be a multiple of {@link AesCtrUtil#AES_BLOCK_SIZE}.
+   */
+  private static final int BUFFER_CAPACITY = 64 * AES_BLOCK_SIZE; // 1024
+
+  private final OutputStream outputStream;
+  private final AesCtrEncrypter encrypter;
+  private final ByteBuffer inBuffer;
+  private final ByteBuffer outBuffer;
+  private final byte[] outArray;
+  private final byte[] oneByteBuf;
+  private boolean closed;
+
+  /**
+   * @param outputStream The delegate {@link OutputStream} to write encrypted data to.
+   * @param key          The encryption key secret. It is cloned internally, its content
+   *                     is not modified, and no reference to it is kept.
+   * @param factory      The factory to use to create one instance of {@link AesCtrEncrypter}.
+   */
+  public EncryptingOutputStream(OutputStream outputStream, byte[] key, AesCtrEncrypterFactory factory)
+    throws IOException {
+    this.outputStream = outputStream;
+
+    byte[] iv = generateRandomIv();
+    encrypter = factory.create(key, iv);
+    encrypter.init(0);
+    // IV is written at the beginning of the output stream. It's public.
+    outputStream.write(iv, 0, iv.length);
+
+    inBuffer = ByteBuffer.allocate(getBufferCapacity());
+    outBuffer = ByteBuffer.allocate(getBufferCapacity() + AES_BLOCK_SIZE);
+    assert inBuffer.hasArray() && outBuffer.hasArray();
+    assert outBuffer.arrayOffset() == 0;
+    outArray = outBuffer.array();
+    oneByteBuf = new byte[1];
+  }
+
+  /**
+   * Generates a cryptographically strong CTR random IV of length {@link AesCtrUtil#IV_LENGTH}.
+   */
+  protected byte[] generateRandomIv() {
+    return generateRandomAesCtrIv(SecureRandomProvider.get());
+  }
+
+  /**
+   * Gets the buffer capacity. It must be a multiple of {@link AesCtrUtil#AES_BLOCK_SIZE}.
+   */
+  protected int getBufferCapacity() {
+    return BUFFER_CAPACITY;
+  }
+
+  @Override
+  public void close() throws IOException {
+    if (!closed) {
+      closed = true;
+      try {
+        if (inBuffer.position() != 0) {
+          encryptBufferAndWrite();
+        }
+      } finally {
+        outputStream.close();
+      }
+    }
+  }
+
+  @Override
+  public void write(int b) throws IOException {
+    oneByteBuf[0] = (byte) b;
+    write(oneByteBuf, 0, oneByteBuf.length);
+  }
+
+  @Override
+  public void write(byte[] b, int offset, int length) throws IOException {
+    if (offset < 0 || length < 0 || offset + length > b.length) {
+      throw new IllegalArgumentException("Invalid write buffer parameters (offset=" + offset + ", length=" + length + ", arrayLength=" + b.length + ")");
+    }
+    while (length > 0) {
+      int remaining = inBuffer.remaining();
+      if (length < remaining) {
+        inBuffer.put(b, offset, length);
+        break;
+      } else {
+        inBuffer.put(b, offset, remaining);
+        offset += remaining;
+        length -= remaining;
+        encryptBufferAndWrite();
+      }
+    }
+  }
+
+  private void encryptBufferAndWrite() throws IOException {
+    assert inBuffer.position() != 0;
+    inBuffer.flip();
+    outBuffer.clear();
+    encrypter.process(inBuffer, outBuffer);
+    inBuffer.clear();
+    outBuffer.flip();
+    outputStream.write(outArray, 0, outBuffer.limit());
+  }
+}
\ No newline at end of file
diff --git a/encryption/src/test/java/org/apache/solr/encryption/crypto/AesCtrEncrypterTest.java b/encryption/src/test/java/org/apache/solr/encryption/crypto/AesCtrEncrypterTest.java
index 7852424..ef7807d 100644
--- a/encryption/src/test/java/org/apache/solr/encryption/crypto/AesCtrEncrypterTest.java
+++ b/encryption/src/test/java/org/apache/solr/encryption/crypto/AesCtrEncrypterTest.java
@@ -18,15 +18,16 @@ package org.apache.solr.encryption.crypto;
 
 import java.nio.ByteBuffer;
 
-import org.apache.lucene.tests.util.LuceneTestCase;
+import com.carrotsearch.randomizedtesting.RandomizedTest;
 import org.junit.Test;
 
+import static junit.framework.TestCase.assertEquals;
 import static org.apache.solr.encryption.crypto.AesCtrUtil.*;
 
 /**
  * Tests {@link AesCtrEncrypter} implementations.
  */
-public class AesCtrEncrypterTest extends LuceneTestCase {
+public class AesCtrEncrypterTest extends RandomizedTest {
 
   /**
    * Verifies that {@link AesCtrEncrypter} implementations encrypt and decrypt data exactly the
@@ -35,8 +36,8 @@ public class AesCtrEncrypterTest extends LuceneTestCase {
   @Test
   public void testEncryptionDecryption() {
     for (int i = 0; i < 100; i++) {
-      ByteBuffer clearData = generateRandomData(10000);
-      byte[] key = generateRandomBytes(AES_BLOCK_SIZE);
+      ByteBuffer clearData = generateRandomData(randomIntBetween(5000, 10000));
+      byte[] key = randomBytesOfLength(randomIntBetween(2, 4) * 8);
       byte[] iv = generateRandomAesCtrIv(SecureRandomProvider.get());
       AesCtrEncrypter encrypter1 = encrypterFactory().create(key, iv);
       AesCtrEncrypter encrypter2 = encrypterFactory().create(key, iv);
@@ -54,7 +55,7 @@ public class AesCtrEncrypterTest extends LuceneTestCase {
 
   private AesCtrEncrypterFactory encrypterFactory() {
     if (LightAesCtrEncrypter.isSupported()) {
-      return random().nextBoolean() ? CipherAesCtrEncrypter.FACTORY : LightAesCtrEncrypter.FACTORY;
+      return randomBoolean() ? CipherAesCtrEncrypter.FACTORY : LightAesCtrEncrypter.FACTORY;
     }
     return CipherAesCtrEncrypter.FACTORY;
   }
@@ -62,29 +63,19 @@ public class AesCtrEncrypterTest extends LuceneTestCase {
   private static ByteBuffer generateRandomData(int numBytes) {
     ByteBuffer buffer = ByteBuffer.allocate(numBytes);
     for (int i = 0; i < numBytes; i++) {
-      buffer.put((byte) random().nextInt());
+      buffer.put((byte) randomInt());
     }
     buffer.position(0);
     return buffer;
   }
 
-  private static byte[] generateRandomBytes(int numBytes) {
-    byte[] b = new byte[numBytes];
-    // Random.nextBytes(byte[]) does not produce good enough randomness here,
-    // it has a bias to produce 0 and -1 bytes.
-    for (int i = 0; i < numBytes; i++) {
-      b[i] = (byte) random().nextInt();
-    }
-    return b;
-  }
-
   private ByteBuffer crypt(ByteBuffer inputBuffer, AesCtrEncrypter encrypter) {
     encrypter = randomClone(encrypter);
     encrypter.init(0);
     int inputInitialPosition = inputBuffer.position();
     ByteBuffer outputBuffer = ByteBuffer.allocate(inputBuffer.capacity());
     while (inputBuffer.remaining() > 0) {
-      int length = Math.min(random().nextInt(51) + 1, inputBuffer.remaining());
+      int length = Math.min(randomIntBetween(0, 50) + 1, inputBuffer.remaining());
       ByteBuffer inputSlice = inputBuffer.slice();
       inputSlice.limit(inputSlice.position() + length);
       encrypter.process(inputSlice, outputBuffer);
@@ -96,6 +87,6 @@ public class AesCtrEncrypterTest extends LuceneTestCase {
   }
 
   private static AesCtrEncrypter randomClone(AesCtrEncrypter encrypter) {
-    return random().nextBoolean() ? encrypter.clone() : encrypter;
+    return randomBoolean() ? encrypter.clone() : encrypter;
   }
 }
\ No newline at end of file
diff --git a/encryption/src/test/java/org/apache/solr/encryption/crypto/CharStreamEncrypterTest.java b/encryption/src/test/java/org/apache/solr/encryption/crypto/CharStreamEncrypterTest.java
new file mode 100644
index 0000000..d6aa764
--- /dev/null
+++ b/encryption/src/test/java/org/apache/solr/encryption/crypto/CharStreamEncrypterTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.solr.encryption.crypto;
+
+import com.carrotsearch.randomizedtesting.RandomizedTest;
+import org.junit.Test;
+
+import java.io.IOException;
+
+import static org.junit.Assert.assertEquals;
+
+/** Tests {@link CharStreamEncrypter}. */
+public class CharStreamEncrypterTest extends RandomizedTest {
+
+  @Test
+  public void testEmptyString() throws Exception {
+    checkEncryptionDecryption("", new CharStreamEncrypter(encrypterFactory()));
+  }
+
+  @Test
+  public void testRandomString() throws Exception {
+    CharStreamEncrypter encrypter = new CharStreamEncrypter(encrypterFactory());
+    for (int i = 0; i < 100; i++) {
+      checkEncryptionDecryption(randomUnicodeOfCodepointLengthBetween(1, 10000), encrypter);
+    }
+  }
+
+  private void checkEncryptionDecryption(String inputString, CharStreamEncrypter encrypter)
+    throws IOException {
+
+    // AES key length can either 16, 24 or 32 bytes.
+    byte[] key = randomBytesOfLength(randomIntBetween(2, 4) * 8);
+
+    // Encrypt the input string.
+    StringBuilder encryptedBuilder = new StringBuilder();
+    encrypter.encrypt(inputString, key, encryptedBuilder);
+
+    // Decrypt the encrypted string.
+    StringBuilder decryptedBuilder = new StringBuilder();
+    encrypter.decrypt(encryptedBuilder.toString(), key, decryptedBuilder);
+    assertEquals(inputString, decryptedBuilder.toString());
+  }
+
+  private AesCtrEncrypterFactory encrypterFactory() {
+    if (LightAesCtrEncrypter.isSupported()) {
+      return randomBoolean() ? CipherAesCtrEncrypter.FACTORY : LightAesCtrEncrypter.FACTORY;
+    }
+    return CipherAesCtrEncrypter.FACTORY;
+  }
+}