diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/CipherFactory.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/CipherFactory.java
index ac7aeb8..e3c8169 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/CipherFactory.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/CipherFactory.java
@@ -38,8 +38,10 @@ public interface CipherFactory {
 
   /**
    * Returns a new encipher instance.
+   * @param withPadding True if this method must return a {@link Cipher} instance with padding, false if this method
+   *                must return a {@link Cipher} instance without padding.
    */
-  VersionedCipher newEncipherInstance();
+  VersionedCipher newEncipherInstance(boolean withPadding);
 
   /**
    * Initializes an encipher instance with an IV.
@@ -53,8 +55,10 @@ public interface CipherFactory {
 
   /**
    * Returns a new decipher instance for the specified cipher version.
-   */
-  VersionedCipher newDecipherInstance(long version);
+   * @param version The version of the cipher
+   * @param withPadding True if this method must return a {@link Cipher} instance with padding, false if this method
+   *                must return a {@link Cipher} instance without padding.   */
+  VersionedCipher newDecipherInstance(long version, boolean withPadding);
 
   /**
    * Initializes a decipher instance with an IV.
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyCipherFactory.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyCipherFactory.java
index e72ba44..b55a7dd 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyCipherFactory.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyCipherFactory.java
@@ -20,8 +20,6 @@ package org.apache.lucene.codecs.encrypted;
 import javax.crypto.Cipher;
 import javax.crypto.spec.IvParameterSpec;
 import javax.crypto.spec.SecretKeySpec;
-import java.security.InvalidAlgorithmParameterException;
-import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 
@@ -55,10 +53,11 @@ public class DummyCipherFactory implements CipherFactory {
   }
 
   @Override
-  public VersionedCipher newEncipherInstance() {
+  public VersionedCipher newEncipherInstance(boolean withPadding) {
     try {
       //Cipher
-      Cipher aesCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+      String cipherString = withPadding ? "AES/CBC/PKCS5Padding" : "AES/CBC/NoPadding";
+      Cipher aesCipher = Cipher.getInstance(cipherString);
       aesCipher.init(Cipher.ENCRYPT_MODE, aesKey);
       return new VersionedCipher(aesCipher, this.getEncipherVersion());
     }
@@ -83,14 +82,15 @@ public class DummyCipherFactory implements CipherFactory {
   }
 
   @Override
-  public VersionedCipher newDecipherInstance(long version) {
+  public VersionedCipher newDecipherInstance(long version, boolean withPadding) {
     assert version == 0;
     try {
       // Sentinel iv for instantiation
       byte[] iv = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
       IvParameterSpec ivspec = new IvParameterSpec(iv);
       //Cipher
-      Cipher aesCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+      String cipherString = withPadding ? "AES/CBC/PKCS5Padding" : "AES/CBC/NoPadding";
+      Cipher aesCipher = Cipher.getInstance(cipherString);
       aesCipher.init(Cipher.DECRYPT_MODE, aesKey, ivspec);
       return new VersionedCipher(aesCipher, version);
     }
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyEncryptedLucene60Codec.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyEncryptedLucene60Codec.java
index 3344450..75e92c0 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyEncryptedLucene60Codec.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/DummyEncryptedLucene60Codec.java
@@ -17,10 +17,12 @@ package org.apache.lucene.codecs.encrypted;
  * limitations under the License.
  */
 
+import org.apache.lucene.codecs.DocValuesFormat;
 import org.apache.lucene.codecs.PostingsFormat;
 import org.apache.lucene.codecs.StoredFieldsFormat;
 import org.apache.lucene.codecs.compressing.CompressionMode;
 import org.apache.lucene.codecs.encrypted.compressing.EncryptedCompressingStoredFieldsFormat;
+import org.apache.lucene.codecs.encrypted.docvalues.EncryptedLucene54DocValuesFormat;
 
 /**
  * An implementation example of the {@link EncryptedLucene60Codec} based on the {@link DummyCipherFactory} that encrypts
@@ -65,4 +67,16 @@ public class DummyEncryptedLucene60Codec extends EncryptedLucene60Codec {
     return new DummyEncryptedLucene50PostingsFormat();
   }
 
+  public static final class DummyEncryptedLucene54DocValuesFormat extends EncryptedLucene54DocValuesFormat {
+    @Override
+    protected CipherFactory getCipherFactory() {
+      return new DummyCipherFactory();
+    }
+  }
+
+  @Override
+  public DocValuesFormat getDocValuesFormatForField(String field) {
+    return new DummyEncryptedLucene54DocValuesFormat();
+  }
+
 }
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/EncryptedLucene60Codec.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/EncryptedLucene60Codec.java
index 7b09e3a..d6548fc 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/EncryptedLucene60Codec.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/EncryptedLucene60Codec.java
@@ -116,20 +116,16 @@ public abstract class EncryptedLucene60Codec extends Codec {
    */
   public abstract PostingsFormat getPostingsFormatForField(String field);
 
-  /** Returns the docvalues format that should be used for writing
+  /** Concrete implementation should specify the docvalues format that should be used for writing
    *  new segments of <code>field</code>.
    */
-  public DocValuesFormat getDocValuesFormatForField(String field) {
-    return defaultDVFormat;
-  }
+  public abstract DocValuesFormat getDocValuesFormatForField(String field);
 
   @Override
   public final DocValuesFormat docValuesFormat() {
     return docValuesFormat;
   }
 
-  private final DocValuesFormat defaultDVFormat = DocValuesFormat.forName("Lucene54");
-
   private final NormsFormat normsFormat = new Lucene53NormsFormat();
 
   @Override
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedBlockTreeTermsWriter.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedBlockTreeTermsWriter.java
index 088d991..adbd463 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedBlockTreeTermsWriter.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedBlockTreeTermsWriter.java
@@ -307,7 +307,7 @@ public final class EncryptedBlockTreeTermsWriter extends FieldsConsumer {
     throws IOException
   {
     // Terms writer is single-threaded. We can instantiate the encipher here.
-    this.encipher = cipherFactory.newEncipherInstance();
+    this.encipher = cipherFactory.newEncipherInstance(true);
     this.cipherFactory = cipherFactory;
 
     validateSettings(minItemsInBlock,
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedFieldReader.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedFieldReader.java
index 851aca1..d14cfee 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedFieldReader.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedFieldReader.java
@@ -105,7 +105,7 @@ public final class EncryptedFieldReader extends Terms implements Accountable {
       int read = 0;
       try {
         // decipher instance must be local to ensure thread safety
-        CipherFactory.VersionedCipher decipher = parent.cipherFactory.newDecipherInstance(parent.keyVersion);
+        CipherFactory.VersionedCipher decipher = parent.cipherFactory.newDecipherInstance(parent.keyVersion, true);
         // initializes decipher with iv
         parent.cipherFactory.initDecipher(decipher, iv);
         // we can reuse encryptedBytesArray as output since the decrypt method is copy-safe
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedIntersectTermsEnum.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedIntersectTermsEnum.java
index cfebc88..2c10f33 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedIntersectTermsEnum.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedIntersectTermsEnum.java
@@ -89,7 +89,7 @@ final class EncryptedIntersectTermsEnum extends TermsEnum {
   // regexp foo*bar must be at least length 6 bytes
   public EncryptedIntersectTermsEnum(EncryptedFieldReader fr, Automaton automaton, RunAutomaton runAutomaton, BytesRef commonSuffix, BytesRef startTerm, int sinkState) throws IOException {
     this.fr = fr;
-    this.decipher = fr.parent.cipherFactory.newDecipherInstance(fr.parent.keyVersion);
+    this.decipher = fr.parent.cipherFactory.newDecipherInstance(fr.parent.keyVersion, true);
     this.sinkState = sinkState;
 
     assert automaton != null;
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedSegmentTermsEnum.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedSegmentTermsEnum.java
index 6acf7c8..6631211 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedSegmentTermsEnum.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/blocktree/EncryptedSegmentTermsEnum.java
@@ -71,7 +71,7 @@ final class EncryptedSegmentTermsEnum extends TermsEnum {
 
   public EncryptedSegmentTermsEnum(EncryptedFieldReader fr) throws IOException {
     this.fr = fr;
-    this.decipher = fr.parent.cipherFactory.newDecipherInstance(fr.parent.keyVersion);
+    this.decipher = fr.parent.cipherFactory.newDecipherInstance(fr.parent.keyVersion, true);
 
     // if (DEBUG) {
     //   System.out.println("BTTR.init seg=" + fr.parent.segment);
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/DecipherDecompressor.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/DecipherDecompressor.java
index b7b2e43..9669ac5 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/DecipherDecompressor.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/DecipherDecompressor.java
@@ -58,7 +58,7 @@ class DecipherDecompressor extends Decompressor {
     this.decompressor = decompressor;
     this.cipherFactory = cipherFactory;
     this.keyVersion = keyVersion;
-    this.decipher = cipherFactory.newDecipherInstance(keyVersion);
+    this.decipher = cipherFactory.newDecipherInstance(keyVersion, true);
   }
 
   @Override
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/EncipherCompressor.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/EncipherCompressor.java
index 631e86c..bfacaba 100644
--- lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/EncipherCompressor.java
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/compressing/EncipherCompressor.java
@@ -49,7 +49,7 @@ public class EncipherCompressor extends Compressor {
 
   EncipherCompressor(CipherFactory cipherFactory, Compressor compressor) {
     this.cipherFactory = cipherFactory;
-    this.encipher = cipherFactory.newEncipherInstance();
+    this.encipher = cipherFactory.newEncipherInstance(true);
     this.compressor = compressor;
   }
 
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedIndexInput.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedIndexInput.java
new file mode 100644
index 0000000..bacc876
--- /dev/null
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedIndexInput.java
@@ -0,0 +1,304 @@
+package org.apache.lucene.codecs.encrypted.docvalues;
+
+/*
+ * 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.
+ */
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.ShortBufferException;
+import java.io.EOFException;
+import java.io.IOException;
+
+import org.apache.lucene.codecs.encrypted.CipherFactory;
+import org.apache.lucene.store.IndexInput;
+
+/**
+ * Encrypted implementation of the IndexInput.
+ */
+public class EncryptedIndexInput extends IndexInput {
+
+  /** index input from which encrypted data blocks will be read */
+  private final IndexInput in;
+
+  private final CipherFactory cipherFactory;
+  private final long cipherVersion;
+  private final CipherFactory.VersionedCipher cipher;
+
+  /** iv size */
+  private final int ivSize;
+  /** block size */
+  private final int blockSize;
+  /** current block id */
+  private long blockId;
+  /** buffer for the current block */
+  private final byte[] buffer;
+  /** position in the current block */
+  private int bufferPos;
+  /** the number of available bytes in the current block */
+  private int bufferLength;
+  /** flag to indicate we have seeked to a new buffer */
+  private boolean hasSeekedToNewBlock = false;
+
+  /**
+   * Reusable buffer for block's ivs
+   */
+  private byte[] iv;
+
+  /**
+   * @param in IndexInput's slice starting at the beginning of the encrypted data
+   * @param cipherFactory the thread local Cipher used for encrypting the data
+   */
+  public EncryptedIndexInput(IndexInput in, CipherFactory cipherFactory, long cipherVersion) throws IOException {
+    this(in.toString(), in, cipherFactory, cipherVersion);
+  }
+
+  public EncryptedIndexInput(String resourceDescription, IndexInput in, CipherFactory cipherFactory, long cipherVersion) {
+    super(resourceDescription);
+    this.cipherFactory = cipherFactory;
+    this.cipherVersion = cipherVersion;
+    cipher = cipherFactory.newDecipherInstance(cipherVersion, false);
+
+    if (cipher.getCipher().getBlockSize() == 0) {
+      throw new IllegalArgumentException("Expected a block cipher algorithm");
+    }
+
+    ivSize = cipher.getCipher().getIV().length;
+    iv = new byte[ivSize];
+
+    this.in = in;
+    this.blockSize = ivSize + (EncryptedIndexOutput.N_BLOCKS * cipher.getCipher().getBlockSize());
+    this.buffer = new byte[cipher.getCipher().getOutputSize(blockSize - ivSize)];
+    this.bufferLength = this.bufferPos = 0;
+    this.blockId = -1;
+  }
+
+  @Override
+  public void close() throws IOException {
+    in.close();
+  }
+
+  @Override
+  public long getFilePointer() {
+    // if we have seeked to a new block, and not yet trigger a read, we need to adjust the block id by one
+    long currentBlockId = hasSeekedToNewBlock ? this.blockId + 1 : this.blockId;
+    // if the block id is inferior to 0, it is not initialised yet
+    currentBlockId = currentBlockId < 0 ? 0 : currentBlockId;
+
+    long fp = currentBlockId * (blockSize - ivSize); // block offset
+    fp += bufferPos; // adjust based on buffer position
+    return fp;
+  }
+
+  @Override
+  public void seek(final long seekPos) throws IOException {
+    if (seekPos < 0) {
+      throw new IllegalArgumentException("Seeking to negative position: " + this);
+    }
+    // This is not >= because seeking to exact end of file is OK: this is where
+    // you'd also be if you did a readBytes of all bytes in the file
+    if (seekPos > length()) {
+      throw new EOFException("seek beyond EOF: pos=" + seekPos + " vs length=" + length() + ": " + this);
+    }
+
+    final long blockIdTarget = seekPos / (blockSize - ivSize);
+    final long offsetWithinBlock = seekPos - (blockIdTarget * (blockSize - ivSize));
+
+    // If we have previously seeked to a new block and have not yet triggered a block decryption, the blockId will be
+    // off by one which can cause issue when computing the file pointer.
+    // Otherwise, adjust the block id and trigger a block decryption if we are not in the target block.
+    if (hasSeekedToNewBlock || blockId != blockIdTarget) {
+      blockId = blockIdTarget - 1;
+      hasSeekedToNewBlock = true; // trigger block decryption at the next read
+    }
+
+    // adjust the offset of the buffer
+    bufferPos = (int) offsetWithinBlock;
+  }
+
+  @Override
+  public long length() {
+    long nBlocks = in.length() / blockSize;
+    // remove the size of the ivs from the length
+    return nBlocks * (blockSize - ivSize);
+  }
+
+  /**
+   * Returns the start offset of the given block id.
+   */
+  private long getBlockOffset(long blockId) {
+    return blockId * blockSize;
+  }
+
+  private void decrypt() throws IOException {
+    // Increment block id to move to the next block
+    blockId++;
+
+    // Seek to start of next block
+    long blockOffset = this.getBlockOffset(blockId);
+    in.seek(blockOffset);
+
+    // Read IV
+    in.readBytes(iv, 0, iv.length);
+
+    // Read encrypted block
+    in.readBytes(buffer, 0, buffer.length);
+
+    // Decrypt block
+    try {
+      // Initializes decipher with iv
+      cipherFactory.initDecipher(cipher, iv);
+      // we can reuse buffer as output since the decrypt method is copy-safe
+      final int storedBytes = cipher.getCipher().doFinal(buffer, 0, buffer.length, buffer, 0);
+      assert storedBytes == buffer.length;
+    } catch (ShortBufferException | IllegalBlockSizeException | BadPaddingException e) {
+      throw new IOException("Unable to decrypt message", e);
+    }
+  }
+
+  private void maybeDecrypt() throws IOException {
+    // seeked to a new buffer, we do not reset its position
+    if (hasSeekedToNewBlock) {
+      decrypt();
+      bufferLength = buffer.length;
+      hasSeekedToNewBlock = false;
+    }
+    // First initialisation or end of current buffer
+    else if (bufferLength == 0 || bufferPos == bufferLength) {
+      decrypt();
+      bufferLength = buffer.length;
+      bufferPos = 0;
+    }
+  }
+
+  @Override
+  public byte readByte() throws IOException {
+    maybeDecrypt();
+    return buffer[bufferPos++];
+  }
+
+  @Override
+  public void readBytes(byte[] b, int offset, int len) throws IOException {
+    if (len == 0) {
+      return;
+    }
+    maybeDecrypt();
+    while (true) {
+      final int lenToCopy = len > buffer.length - bufferPos ? buffer.length - bufferPos : len;
+
+      System.arraycopy(buffer, bufferPos, b, offset, lenToCopy);
+      len -= lenToCopy;
+      offset += lenToCopy;
+
+      if (len == 0) {
+        bufferPos += lenToCopy;
+        break;
+      }
+
+      decrypt();
+
+      // Reset buffer
+      bufferLength = buffer.length;
+      bufferPos = 0;
+    }
+  }
+
+  @Override
+  public IndexInput slice(String sliceDescription, long offset, long sliceLength) throws IOException {
+    if (offset < 0 || sliceLength < 0 || offset + sliceLength > length()) {
+      throw new IllegalArgumentException("slice() " + sliceDescription + " out of bounds: "  + this);
+    }
+    return new SlicedIndexInput(sliceDescription, this.clone(), offset, sliceLength);
+  }
+
+  @Override
+  public EncryptedIndexInput clone() {
+    EncryptedIndexInput clone = new EncryptedIndexInput(this.toString(), in.clone(), cipherFactory, cipherVersion);
+    return clone;
+  }
+
+  /**
+   * Implementation of an IndexInput that reads from a portion of a file.
+   */
+  private static final class SlicedIndexInput extends IndexInput {
+
+    private final EncryptedIndexInput base;
+    private final long sliceOffset;
+    private final long sliceLength;
+
+    SlicedIndexInput(final String sliceDescription, final EncryptedIndexInput base,
+                     final long offset, final long length) throws IOException {
+      super(base.toString() + (sliceDescription == null ? "" : " [slice=" + sliceDescription + ",offset=" + offset + ",length=" + length + "]"));
+      this.base = base;
+      this.sliceOffset = offset;
+      this.sliceLength = length;
+      this.base.seek(this.sliceOffset);
+    }
+
+    @Override
+    public IndexInput slice(String sliceDescription, long offset, long length) throws IOException {
+      if (offset < 0 || length < 0 || offset + length > length()) {
+        throw new IllegalArgumentException("slice() " + sliceDescription + " out of bounds: "  + this);
+      }
+      return base.slice(sliceDescription, this.sliceOffset + offset, length);
+    }
+
+    @Override
+    public byte readByte() throws IOException {
+      if (getFilePointer() >= length()) {
+        throw new EOFException("cannot read another byte at EOF: pos=" + getFilePointer() + " vs length=" + length() + ": " + this);
+      }
+      return base.readByte();
+    }
+
+    @Override
+    public void readBytes(byte[] b, int offset, int len) throws IOException {
+      if (getFilePointer() + len > length()) {
+        throw new EOFException("cannot read bytes at EOF: [pos=" + getFilePointer() + ",len=" + len + "] vs length=" + length() + ": " + this);
+      }
+      base.readBytes(b, offset, len);
+    }
+
+    @Override
+    public void seek(long seekPos) throws IOException {
+      if (seekPos < 0) {
+        throw new IllegalArgumentException("Seeking to negative position: " + this);
+      }
+      // This is not >= because seeking to exact end of file is OK: this is where
+      // you'd also be if you did a readBytes of all bytes in the file
+      if (seekPos > length()) {
+        throw new IOException("seek beyond EOF: pos=" + seekPos + " vs length=" + length() + ": " + this);
+      }
+      base.seek(sliceOffset + seekPos);
+    }
+
+    @Override
+    public void close() throws IOException {
+      base.close();
+    }
+
+    @Override
+    public long getFilePointer() {
+      return base.getFilePointer() - sliceOffset;
+    }
+
+    @Override
+    public long length() {
+      return sliceLength;
+    }
+
+  }
+}
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedIndexOutput.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedIndexOutput.java
new file mode 100644
index 0000000..5db106a
--- /dev/null
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedIndexOutput.java
@@ -0,0 +1,163 @@
+package org.apache.lucene.codecs.encrypted.docvalues;
+
+/*
+ * 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.
+ */
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.ShortBufferException;
+import java.io.IOException;
+import java.util.Arrays;
+
+import org.apache.lucene.codecs.encrypted.CipherFactory;
+import org.apache.lucene.store.IndexOutput;
+
+/**
+ * Encrypted implementation of the IndexOutput. This class encrypts data in blocks.
+ * A data block is composed of an IV and of {@link #N_BLOCKS} cipher blocks.
+ */
+public class EncryptedIndexOutput extends IndexOutput {
+
+  /** index ouput in which encrypted data blocks will be written */
+  private final IndexOutput out;
+
+  /** iv size */
+  private final int ivSize;
+  /** block size */
+  private final int blockSize;
+  /** buffer for the current block */
+  private final byte[] buffer;
+  /** current block id */
+  private long blockId;
+  /** position in the current block */
+  private int pos;
+
+  private final CipherFactory cipherFactory;
+  private final CipherFactory.VersionedCipher cipher;
+
+  /**
+   * Reusable byte array for storing the iv
+   */
+  private byte[] iv;
+
+  /**
+   * The default number of cipher blocks in one encrypted data block
+   */
+  public static int N_BLOCKS = 128;
+
+  public EncryptedIndexOutput(IndexOutput out, CipherFactory cipherFactory) {
+    super(out.toString(), out.getName());
+    this.out = out;
+
+    this.cipherFactory = cipherFactory;
+    this.cipher = cipherFactory.newEncipherInstance(false);
+    if (cipher.getCipher().getBlockSize() == 0) {
+      throw new IllegalArgumentException("Expected a block cipher algorithm");
+    }
+    if (!cipher.getCipher().getAlgorithm().endsWith("/NoPadding")) {
+      throw new IllegalArgumentException("Expected a cipher algorithm with no padding");
+    }
+
+    ivSize = cipher.getCipher().getIV().length;
+    iv = new byte[ivSize];
+
+    this.blockSize = ivSize + (EncryptedIndexOutput.N_BLOCKS * cipher.getCipher().getBlockSize());
+    this.buffer = new byte[cipher.getCipher().getOutputSize(blockSize - ivSize)];
+    blockId = 0;
+  }
+
+  /**
+   * Expert: Flush the remaining bytes in the buffer. This should only be called before closing the stream.
+   * This method is needed to support the mix of encrypted and non-encrypted data on a same IndexOutput in
+   * {@link EncryptedLucene54DocValuesConsumer#close()}.
+   */
+  public void flush() throws IOException {
+    if (pos != 0) {
+      Arrays.fill(buffer, pos, buffer.length, (byte) 0);
+      writeBlock();
+    }
+  }
+
+  @Override
+  public void close() throws IOException {
+    try {
+      flush();
+    } finally {
+      out.close();
+    }
+  }
+
+  @Override
+  public long getFilePointer() {
+    long fp = blockId * (blockSize - ivSize); // block offset
+    fp += pos; // adjust based on buffer position
+    return fp;
+  }
+
+  @Override
+  public long getChecksum() throws IOException {
+    return out.getChecksum();
+  }
+
+  /**
+   * Encrypts the block and writes it to the wrapped IndexOutput
+   */
+  private void writeBlock() throws IOException {
+    try {
+      // generates a new IV
+      iv = cipherFactory.newIV(iv);
+      // initializes the encipher with the new IV
+      cipherFactory.initEncipher(cipher, iv);
+      // we can reuse the same buffer for input and output since encrypt is copy safe
+      final int storedBytes = cipher.getCipher().doFinal(buffer, 0, buffer.length, buffer, 0);
+      assert storedBytes == buffer.length;
+      // Write IV
+      out.writeBytes(iv, iv.length);
+      // Write encrypted bytes
+      out.writeBytes(buffer, buffer.length);
+      // increment block id
+      blockId++;
+      // reset block pos
+      pos = 0;
+    } catch (ShortBufferException | IllegalBlockSizeException | BadPaddingException e) {
+      throw new IOException("Unable to encrypt message", e);
+    }
+  }
+
+  @Override
+  public void writeByte(byte b) throws IOException {
+    if (pos >= buffer.length) {
+      writeBlock();
+    }
+    buffer[pos++] = b;
+  }
+
+  @Override
+  public void writeBytes(byte[] b, int offset, int length) throws IOException {
+    int freeSpace = buffer.length - pos;
+
+    while (length > freeSpace) {
+      System.arraycopy(b, offset, buffer, pos, freeSpace);
+      writeBlock();
+      offset += freeSpace;
+      length -= freeSpace;
+      freeSpace = buffer.length;
+    }
+    System.arraycopy(b, offset, buffer, pos, length);
+    pos += length;
+  }
+}
\ No newline at end of file
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesConsumer.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesConsumer.java
new file mode 100644
index 0000000..8bfc6e3
--- /dev/null
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesConsumer.java
@@ -0,0 +1,787 @@
+package org.apache.lucene.codecs.encrypted.docvalues;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.stream.StreamSupport;
+
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.DocValuesConsumer;
+import org.apache.lucene.codecs.encrypted.CipherFactory;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentWriteState;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.store.RAMOutputStream;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.BytesRefBuilder;
+import org.apache.lucene.util.IOUtils;
+import org.apache.lucene.util.LongsRef;
+import org.apache.lucene.util.MathUtil;
+import org.apache.lucene.util.PagedBytes;
+import org.apache.lucene.util.PagedBytes.PagedBytesDataInput;
+import org.apache.lucene.util.StringHelper;
+import org.apache.lucene.util.packed.DirectMonotonicWriter;
+import org.apache.lucene.util.packed.DirectWriter;
+import org.apache.lucene.util.packed.MonotonicBlockPackedWriter;
+import org.apache.lucene.util.packed.PackedInts;
+
+import static org.apache.lucene.codecs.encrypted.docvalues.EncryptedLucene54DocValuesFormat.*;
+
+/*
+ * 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.
+ */
+
+/** writer for {@link EncryptedLucene54DocValuesFormat} */
+final class EncryptedLucene54DocValuesConsumer extends DocValuesConsumer implements Closeable {
+
+  enum NumberType {
+    /** Dense ordinals */
+    ORDINAL,
+    /** Random long values */
+    VALUE;
+  }
+
+  IndexOutput nonEncryptedData, nonEncryptedMeta;
+  IndexOutput data, meta;
+  final int maxDoc;
+
+  /** expert: Creates a new writer */
+  public EncryptedLucene54DocValuesConsumer(CipherFactory cipherFactory, SegmentWriteState state, String dataCodec, String dataExtension, String metaCodec, String metaExtension) throws IOException {
+    boolean success = false;
+    try {
+      String dataName = IndexFileNames.segmentFileName(state.segmentInfo.name, state.segmentSuffix, dataExtension);
+      nonEncryptedData = state.directory.createOutput(dataName, state.context);
+      CodecUtil.writeIndexHeader(nonEncryptedData, dataCodec, EncryptedLucene54DocValuesFormat.VERSION_CURRENT, state.segmentInfo.getId(), state.segmentSuffix);
+      data = new EncryptedIndexOutput(nonEncryptedData, cipherFactory);
+      String metaName = IndexFileNames.segmentFileName(state.segmentInfo.name, state.segmentSuffix, metaExtension);
+      nonEncryptedMeta = state.directory.createOutput(metaName, state.context);
+      CodecUtil.writeIndexHeader(nonEncryptedMeta, metaCodec, EncryptedLucene54DocValuesFormat.VERSION_CURRENT, state.segmentInfo.getId(), state.segmentSuffix);
+      meta = new EncryptedIndexOutput(nonEncryptedMeta, cipherFactory);
+      maxDoc = state.segmentInfo.maxDoc();
+      success = true;
+    } finally {
+      if (!success) {
+        IOUtils.closeWhileHandlingException(this);
+      }
+    }
+  }
+  
+  @Override
+  public void addNumericField(FieldInfo field, Iterable<Number> values) throws IOException {
+    addNumericField(field, values, NumberType.VALUE);
+  }
+
+  void addNumericField(FieldInfo field, Iterable<Number> values, NumberType numberType) throws IOException {
+    long count = 0;
+    long minValue = Long.MAX_VALUE;
+    long maxValue = Long.MIN_VALUE;
+    long gcd = 0;
+    long missingCount = 0;
+    long zeroCount = 0;
+    // TODO: more efficient?
+    HashSet<Long> uniqueValues = null;
+    long missingOrdCount = 0;
+    if (numberType == NumberType.VALUE) {
+      uniqueValues = new HashSet<>();
+
+      for (Number nv : values) {
+        final long v;
+        if (nv == null) {
+          v = 0;
+          missingCount++;
+          zeroCount++;
+        } else {
+          v = nv.longValue();
+          if (v == 0) {
+            zeroCount++;
+          }
+        }
+
+        if (gcd != 1) {
+          if (v < Long.MIN_VALUE / 2 || v > Long.MAX_VALUE / 2) {
+            // in that case v - minValue might overflow and make the GCD computation return
+            // wrong results. Since these extreme values are unlikely, we just discard
+            // GCD computation for them
+            gcd = 1;
+          } else if (count != 0) { // minValue needs to be set first
+            gcd = MathUtil.gcd(gcd, v - minValue);
+          }
+        }
+
+        minValue = Math.min(minValue, v);
+        maxValue = Math.max(maxValue, v);
+
+        if (uniqueValues != null) {
+          if (uniqueValues.add(v)) {
+            if (uniqueValues.size() > 256) {
+              uniqueValues = null;
+            }
+          }
+        }
+
+        ++count;
+      }
+    } else {
+      for (Number nv : values) {
+        long v = nv.longValue();
+        if (v == -1L) {
+          missingOrdCount++;
+        }
+        minValue = Math.min(minValue, v);
+        maxValue = Math.max(maxValue, v);
+        ++count;
+      }
+    }
+    
+    final long delta = maxValue - minValue;
+    final int deltaBitsRequired = DirectWriter.unsignedBitsRequired(delta);
+    final int tableBitsRequired = uniqueValues == null
+        ? Integer.MAX_VALUE
+        : DirectWriter.bitsRequired(uniqueValues.size() - 1);
+
+    final boolean sparse; // 1% of docs or less have a value
+    switch (numberType) {
+      case VALUE:
+        sparse = (double) missingCount / count >= 0.99;
+        break;
+      case ORDINAL:
+        sparse = (double) missingOrdCount / count >= 0.99;
+        break;
+      default:
+        throw new AssertionError();
+    }
+
+    final int format;
+    if (uniqueValues != null 
+        && count <= Integer.MAX_VALUE
+        && (uniqueValues.size() == 1
+           || (uniqueValues.size() == 2 && missingCount > 0 && zeroCount == missingCount))) {
+      // either one unique value C or two unique values: "missing" and C
+      format = CONST_COMPRESSED;
+    } else if (sparse && count >= 1024) {
+      // require at least 1024 docs to avoid flipping back and forth when doing NRT search
+      format = SPARSE_COMPRESSED;
+    } else if (uniqueValues != null && tableBitsRequired < deltaBitsRequired) {
+      format = TABLE_COMPRESSED;
+    } else if (gcd != 0 && gcd != 1) {
+      final long gcdDelta = (maxValue - minValue) / gcd;
+      final long gcdBitsRequired = DirectWriter.unsignedBitsRequired(gcdDelta);
+      format = gcdBitsRequired < deltaBitsRequired ? GCD_COMPRESSED : DELTA_COMPRESSED;
+    } else {
+      format = DELTA_COMPRESSED;
+    }
+    meta.writeVInt(field.number);
+    meta.writeByte(EncryptedLucene54DocValuesFormat.NUMERIC);
+    meta.writeVInt(format);
+    if (format == SPARSE_COMPRESSED) {
+      meta.writeLong(data.getFilePointer());
+      final long numDocsWithValue;
+      switch (numberType) {
+        case VALUE:
+          numDocsWithValue = count - missingCount;
+          break;
+        case ORDINAL:
+          numDocsWithValue = count - missingOrdCount;
+          break;
+        default:
+          throw new AssertionError();
+      }
+      final long maxDoc = writeSparseMissingBitset(values, numberType, numDocsWithValue);
+      assert maxDoc == count;
+    } else if (missingCount == 0) {
+      meta.writeLong(ALL_LIVE);
+    } else if (missingCount == count) {
+      meta.writeLong(ALL_MISSING);
+    } else {
+      meta.writeLong(data.getFilePointer());
+      writeMissingBitset(values);
+    }
+    meta.writeLong(data.getFilePointer());
+    meta.writeVLong(count);
+
+    switch (format) {
+      case CONST_COMPRESSED:
+        // write the constant (nonzero value in the n=2 case, singleton value otherwise)
+        meta.writeLong(minValue < 0 ? Collections.min(uniqueValues) : Collections.max(uniqueValues));
+        break;
+      case GCD_COMPRESSED:
+        meta.writeLong(minValue);
+        meta.writeLong(gcd);
+        final long maxDelta = (maxValue - minValue) / gcd;
+        final int bits = DirectWriter.unsignedBitsRequired(maxDelta);
+        meta.writeVInt(bits);
+        final DirectWriter quotientWriter = DirectWriter.getInstance(data, count, bits);
+        for (Number nv : values) {
+          long value = nv == null ? 0 : nv.longValue();
+          quotientWriter.add((value - minValue) / gcd);
+        }
+        quotientWriter.finish();
+        break;
+      case DELTA_COMPRESSED:
+        final long minDelta = delta < 0 ? 0 : minValue;
+        meta.writeLong(minDelta);
+        meta.writeVInt(deltaBitsRequired);
+        final DirectWriter writer = DirectWriter.getInstance(data, count, deltaBitsRequired);
+        for (Number nv : values) {
+          long v = nv == null ? 0 : nv.longValue();
+          writer.add(v - minDelta);
+        }
+        writer.finish();
+        break;
+      case TABLE_COMPRESSED:
+        final Long[] decode = uniqueValues.toArray(new Long[uniqueValues.size()]);
+        Arrays.sort(decode);
+        final HashMap<Long,Integer> encode = new HashMap<>();
+        meta.writeVInt(decode.length);
+        for (int i = 0; i < decode.length; i++) {
+          meta.writeLong(decode[i]);
+          encode.put(decode[i], i);
+        }
+        meta.writeVInt(tableBitsRequired);
+        final DirectWriter ordsWriter = DirectWriter.getInstance(data, count, tableBitsRequired);
+        for (Number nv : values) {
+          ordsWriter.add(encode.get(nv == null ? 0 : nv.longValue()));
+        }
+        ordsWriter.finish();
+        break;
+      case SPARSE_COMPRESSED:
+        final Iterable<Number> filteredMissingValues;
+        switch (numberType) {
+          case VALUE:
+            meta.writeByte((byte) 0);
+            filteredMissingValues = new Iterable<Number>() {
+              @Override
+              public Iterator<Number> iterator() {
+                return StreamSupport
+                    .stream(values.spliterator(), false)
+                    .filter(value -> value != null)
+                    .iterator();
+              }
+            };
+            break;
+          case ORDINAL:
+            meta.writeByte((byte) 1);
+            filteredMissingValues = new Iterable<Number>() {
+              @Override
+              public Iterator<Number> iterator() {
+                return StreamSupport
+                    .stream(values.spliterator(), false)
+                    .filter(value -> value.longValue() != -1L)
+                    .iterator();
+              }
+            };
+            break;
+          default:
+            throw new AssertionError();
+        }
+        // Write non-missing values as a numeric field
+        addNumericField(field, filteredMissingValues, numberType);
+        break;
+      default:
+        throw new AssertionError();
+    }
+    meta.writeLong(data.getFilePointer());
+  }
+  
+  // TODO: in some cases representing missing with minValue-1 wouldn't take up additional space and so on,
+  // but this is very simple, and algorithms only check this for values of 0 anyway (doesnt slow down normal decode)
+  void writeMissingBitset(Iterable<?> values) throws IOException {
+    byte bits = 0;
+    int count = 0;
+    for (Object v : values) {
+      if (count == 8) {
+        data.writeByte(bits);
+        count = 0;
+        bits = 0;
+      }
+      if (v != null) {
+        bits |= 1 << (count & 7);
+      }
+      count++;
+    }
+    if (count > 0) {
+      data.writeByte(bits);
+    }
+  }
+
+  long writeSparseMissingBitset(Iterable<Number> values, NumberType numberType, long numDocsWithValue) throws IOException {
+    meta.writeVLong(numDocsWithValue);
+
+    // Write doc IDs that have a value
+    meta.writeVInt(DIRECT_MONOTONIC_BLOCK_SHIFT);
+    final DirectMonotonicWriter docIdsWriter = DirectMonotonicWriter.getInstance(meta, data, numDocsWithValue, DIRECT_MONOTONIC_BLOCK_SHIFT);
+    long docID = 0;
+    for (Number nv : values) {
+      switch (numberType) {
+        case VALUE:
+          if (nv != null) {
+            docIdsWriter.add(docID);
+          }
+          break;
+        case ORDINAL:
+          if (nv.longValue() != -1L) {
+            docIdsWriter.add(docID);
+          }
+          break;
+        default:
+          throw new AssertionError();
+      }
+      docID++;
+    }
+    docIdsWriter.finish();
+    return docID;
+  }
+
+  @Override
+  public void addBinaryField(FieldInfo field, Iterable<BytesRef> values) throws IOException {
+    // write the byte[] data
+    meta.writeVInt(field.number);
+    meta.writeByte(EncryptedLucene54DocValuesFormat.BINARY);
+    int minLength = Integer.MAX_VALUE;
+    int maxLength = Integer.MIN_VALUE;
+    final long startFP = data.getFilePointer();
+    long count = 0;
+    long missingCount = 0;
+    for(BytesRef v : values) {
+      final int length;
+      if (v == null) {
+        length = 0;
+        missingCount++;
+      } else {
+        length = v.length;
+      }
+      minLength = Math.min(minLength, length);
+      maxLength = Math.max(maxLength, length);
+      if (v != null) {
+        data.writeBytes(v.bytes, v.offset, v.length);
+      }
+      count++;
+    }
+    meta.writeVInt(minLength == maxLength ? BINARY_FIXED_UNCOMPRESSED : BINARY_VARIABLE_UNCOMPRESSED);
+    if (missingCount == 0) {
+      meta.writeLong(ALL_LIVE);
+    } else if (missingCount == count) {
+      meta.writeLong(ALL_MISSING);
+    } else {
+      meta.writeLong(data.getFilePointer());
+      writeMissingBitset(values);
+    }
+    meta.writeVInt(minLength);
+    meta.writeVInt(maxLength);
+    meta.writeVLong(count);
+    meta.writeLong(startFP);
+    
+    // if minLength == maxLength, it's a fixed-length byte[], we are done (the addresses are implicit)
+    // otherwise, we need to record the length fields...
+    if (minLength != maxLength) {
+      meta.writeLong(data.getFilePointer());
+      meta.writeVInt(DIRECT_MONOTONIC_BLOCK_SHIFT);
+
+      final DirectMonotonicWriter writer = DirectMonotonicWriter.getInstance(meta, data, count + 1, DIRECT_MONOTONIC_BLOCK_SHIFT);
+      long addr = 0;
+      writer.add(addr);
+      for (BytesRef v : values) {
+        if (v != null) {
+          addr += v.length;
+        }
+        writer.add(addr);
+      }
+      writer.finish();
+      meta.writeLong(data.getFilePointer());
+    }
+  }
+  
+  /** expert: writes a value dictionary for a sorted/sortedset field */
+  private void addTermsDict(FieldInfo field, final Iterable<BytesRef> values) throws IOException {
+    // first check if it's a "fixed-length" terms dict, and compressibility if so
+    int minLength = Integer.MAX_VALUE;
+    int maxLength = Integer.MIN_VALUE;
+    long numValues = 0;
+    BytesRefBuilder previousValue = new BytesRefBuilder();
+    long prefixSum = 0; // only valid for fixed-width data, as we have a choice there
+    for (BytesRef v : values) {
+      minLength = Math.min(minLength, v.length);
+      maxLength = Math.max(maxLength, v.length);
+      if (minLength == maxLength) {
+        int termPosition = (int) (numValues & INTERVAL_MASK);
+        if (termPosition == 0) {
+          // first term in block, save it away to compare against the last term later
+          previousValue.copyBytes(v);
+        } else if (termPosition == INTERVAL_COUNT - 1) {
+          // last term in block, accumulate shared prefix against first term
+          prefixSum += StringHelper.bytesDifference(previousValue.get(), v);
+        }
+      }
+      numValues++;
+    }
+    // for fixed width data, look at the avg(shared prefix) before deciding how to encode:
+    // prefix compression "costs" worst case 2 bytes per term because we must store suffix lengths.
+    // so if we share at least 3 bytes on average, always compress.
+    if (minLength == maxLength && prefixSum <= 3*(numValues >> INTERVAL_SHIFT)) {
+      // no index needed: not very compressible, direct addressing by mult
+      addBinaryField(field, values);
+    } else if (numValues < REVERSE_INTERVAL_COUNT) {
+      // low cardinality: waste a few KB of ram, but can't really use fancy index etc
+      addBinaryField(field, values);
+    } else {
+      assert numValues > 0; // we don't have to handle the empty case
+      // header
+      meta.writeVInt(field.number);
+      meta.writeByte(EncryptedLucene54DocValuesFormat.BINARY);
+      meta.writeVInt(BINARY_PREFIX_COMPRESSED);
+      meta.writeLong(-1L);
+      // now write the bytes: sharing prefixes within a block
+      final long startFP = data.getFilePointer();
+      // currently, we have to store the delta from expected for every 1/nth term
+      // we could avoid this, but it's not much and less overall RAM than the previous approach!
+      RAMOutputStream addressBuffer = new RAMOutputStream();
+      MonotonicBlockPackedWriter termAddresses = new MonotonicBlockPackedWriter(addressBuffer, MONOTONIC_BLOCK_SIZE);
+      // buffers up 16 terms
+      RAMOutputStream bytesBuffer = new RAMOutputStream();
+      // buffers up block header
+      RAMOutputStream headerBuffer = new RAMOutputStream();
+      BytesRefBuilder lastTerm = new BytesRefBuilder();
+      lastTerm.grow(maxLength);
+      long count = 0;
+      int suffixDeltas[] = new int[INTERVAL_COUNT];
+      for (BytesRef v : values) {
+        int termPosition = (int) (count & INTERVAL_MASK);
+        if (termPosition == 0) {
+          termAddresses.add(data.getFilePointer() - startFP);
+          // abs-encode first term
+          headerBuffer.writeVInt(v.length);
+          headerBuffer.writeBytes(v.bytes, v.offset, v.length);
+          lastTerm.copyBytes(v);
+        } else {
+          // prefix-code: we only share at most 255 characters, to encode the length as a single
+          // byte and have random access. Larger terms just get less compression.
+          int sharedPrefix = Math.min(255, StringHelper.bytesDifference(lastTerm.get(), v));
+          bytesBuffer.writeByte((byte) sharedPrefix);
+          bytesBuffer.writeBytes(v.bytes, v.offset + sharedPrefix, v.length - sharedPrefix);
+          // we can encode one smaller, because terms are unique.
+          suffixDeltas[termPosition] = v.length - sharedPrefix - 1;
+        }
+        
+        count++;
+        // flush block
+        if ((count & INTERVAL_MASK) == 0) {
+          flushTermsDictBlock(headerBuffer, bytesBuffer, suffixDeltas);
+        }
+      }
+      // flush trailing crap
+      int leftover = (int) (count & INTERVAL_MASK);
+      if (leftover > 0) {
+        Arrays.fill(suffixDeltas, leftover, suffixDeltas.length, 0);
+        flushTermsDictBlock(headerBuffer, bytesBuffer, suffixDeltas);
+      }
+      final long indexStartFP = data.getFilePointer();
+      // write addresses of indexed terms
+      termAddresses.finish();
+      addressBuffer.writeTo(data);
+      addressBuffer = null;
+      termAddresses = null;
+      meta.writeVInt(minLength);
+      meta.writeVInt(maxLength);
+      meta.writeVLong(count);
+      meta.writeLong(startFP);
+      meta.writeLong(indexStartFP);
+      meta.writeVInt(PackedInts.VERSION_CURRENT);
+      meta.writeVInt(MONOTONIC_BLOCK_SIZE);
+      addReverseTermIndex(field, values, maxLength);
+    }
+  }
+  
+  // writes term dictionary "block"
+  // first term is absolute encoded as vint length + bytes.
+  // lengths of subsequent N terms are encoded as either N bytes or N shorts.
+  // in the double-byte case, the first byte is indicated with -1.
+  // subsequent terms are encoded as byte suffixLength + bytes.
+  private void flushTermsDictBlock(RAMOutputStream headerBuffer, RAMOutputStream bytesBuffer, int suffixDeltas[]) throws IOException {
+    boolean twoByte = false;
+    for (int i = 1; i < suffixDeltas.length; i++) {
+      if (suffixDeltas[i] > 254) {
+        twoByte = true;
+      }
+    }
+    if (twoByte) {
+      headerBuffer.writeByte((byte)255);
+      for (int i = 1; i < suffixDeltas.length; i++) {
+        headerBuffer.writeShort((short) suffixDeltas[i]);
+      }
+    } else {
+      for (int i = 1; i < suffixDeltas.length; i++) {
+        headerBuffer.writeByte((byte) suffixDeltas[i]);
+      }
+    }
+    headerBuffer.writeTo(data);
+    headerBuffer.reset();
+    bytesBuffer.writeTo(data);
+    bytesBuffer.reset();
+  }
+  
+  // writes reverse term index: used for binary searching a term into a range of 64 blocks
+  // for every 64 blocks (1024 terms) we store a term, trimming any suffix unnecessary for comparison
+  // terms are written as a contiguous byte[], but never spanning 2^15 byte boundaries.
+  private void addReverseTermIndex(FieldInfo field, final Iterable<BytesRef> values, int maxLength) throws IOException {
+    long count = 0;
+    BytesRefBuilder priorTerm = new BytesRefBuilder();
+    priorTerm.grow(maxLength);
+    BytesRef indexTerm = new BytesRef();
+    long startFP = data.getFilePointer();
+    PagedBytes pagedBytes = new PagedBytes(15);
+    MonotonicBlockPackedWriter addresses = new MonotonicBlockPackedWriter(data, MONOTONIC_BLOCK_SIZE);
+    
+    for (BytesRef b : values) {
+      int termPosition = (int) (count & REVERSE_INTERVAL_MASK);
+      if (termPosition == 0) {
+        int len = StringHelper.sortKeyLength(priorTerm.get(), b);
+        indexTerm.bytes = b.bytes;
+        indexTerm.offset = b.offset;
+        indexTerm.length = len;
+        addresses.add(pagedBytes.copyUsingLengthPrefix(indexTerm));
+      } else if (termPosition == REVERSE_INTERVAL_MASK) {
+        priorTerm.copyBytes(b);
+      }
+      count++;
+    }
+    addresses.finish();
+    long numBytes = pagedBytes.getPointer();
+    pagedBytes.freeze(true);
+    PagedBytesDataInput in = pagedBytes.getDataInput();
+    meta.writeLong(startFP);
+    data.writeVLong(numBytes);
+    data.copyBytes(in, numBytes);
+  }
+
+  @Override
+  public void addSortedField(FieldInfo field, Iterable<BytesRef> values, Iterable<Number> docToOrd) throws IOException {
+    meta.writeVInt(field.number);
+    meta.writeByte(EncryptedLucene54DocValuesFormat.SORTED);
+    addTermsDict(field, values);
+    addNumericField(field, docToOrd, NumberType.ORDINAL);
+  }
+
+  @Override
+  public void addSortedNumericField(FieldInfo field, final Iterable<Number> docToValueCount, final Iterable<Number> values) throws IOException {
+    meta.writeVInt(field.number);
+    meta.writeByte(EncryptedLucene54DocValuesFormat.SORTED_NUMERIC);
+    if (isSingleValued(docToValueCount)) {
+      meta.writeVInt(SORTED_SINGLE_VALUED);
+      // The field is single-valued, we can encode it as NUMERIC
+      addNumericField(field, singletonView(docToValueCount, values, null));
+    } else {
+      final SortedSet<LongsRef> uniqueValueSets = uniqueValueSets(docToValueCount, values);
+      if (uniqueValueSets != null) {
+        meta.writeVInt(SORTED_SET_TABLE);
+
+        // write the set_id -> values mapping
+        writeDictionary(uniqueValueSets);
+
+        // write the doc -> set_id as a numeric field
+        addNumericField(field, docToSetId(uniqueValueSets, docToValueCount, values), NumberType.ORDINAL);
+      } else {
+        meta.writeVInt(SORTED_WITH_ADDRESSES);
+        // write the stream of values as a numeric field
+        addNumericField(field, values, NumberType.VALUE);
+        // write the doc -> ord count as a absolute index to the stream
+        addOrdIndex(field, docToValueCount);
+      }
+    }
+  }
+
+  @Override
+  public void addSortedSetField(FieldInfo field, Iterable<BytesRef> values, final Iterable<Number> docToOrdCount, final Iterable<Number> ords) throws IOException {
+    meta.writeVInt(field.number);
+    meta.writeByte(EncryptedLucene54DocValuesFormat.SORTED_SET);
+
+    if (isSingleValued(docToOrdCount)) {
+      meta.writeVInt(SORTED_SINGLE_VALUED);
+      // The field is single-valued, we can encode it as SORTED
+      addSortedField(field, values, singletonView(docToOrdCount, ords, -1L));
+    } else {
+      final SortedSet<LongsRef> uniqueValueSets = uniqueValueSets(docToOrdCount, ords);
+      if (uniqueValueSets != null) {
+        meta.writeVInt(SORTED_SET_TABLE);
+
+        // write the set_id -> ords mapping
+        writeDictionary(uniqueValueSets);
+
+        // write the ord -> byte[] as a binary field
+        addTermsDict(field, values);
+
+        // write the doc -> set_id as a numeric field
+        addNumericField(field, docToSetId(uniqueValueSets, docToOrdCount, ords), NumberType.ORDINAL);
+      } else {
+        meta.writeVInt(SORTED_WITH_ADDRESSES);
+
+        // write the ord -> byte[] as a binary field
+        addTermsDict(field, values);
+
+        // write the stream of ords as a numeric field
+        // NOTE: we could return an iterator that delta-encodes these within a doc
+        addNumericField(field, ords, NumberType.ORDINAL);
+
+        // write the doc -> ord count as a absolute index to the stream
+        addOrdIndex(field, docToOrdCount);
+      }
+    }
+  }
+
+  private SortedSet<LongsRef> uniqueValueSets(Iterable<Number> docToValueCount, Iterable<Number> values) {
+    Set<LongsRef> uniqueValueSet = new HashSet<>();
+    LongsRef docValues = new LongsRef(256);
+
+    Iterator<Number> valueCountIterator = docToValueCount.iterator();
+    Iterator<Number> valueIterator = values.iterator();
+    int totalDictSize = 0;
+    while (valueCountIterator.hasNext()) {
+      docValues.length = valueCountIterator.next().intValue();
+      if (docValues.length > 256) {
+        return null;
+      }
+      for (int i = 0; i < docValues.length; ++i) {
+        docValues.longs[i] = valueIterator.next().longValue();
+      }
+      if (uniqueValueSet.contains(docValues)) {
+        continue;
+      }
+      totalDictSize += docValues.length;
+      if (totalDictSize > 256) {
+        return null;
+      }
+      uniqueValueSet.add(new LongsRef(Arrays.copyOf(docValues.longs, docValues.length), 0, docValues.length));
+    }
+    assert valueIterator.hasNext() == false;
+    return new TreeSet<>(uniqueValueSet);
+  }
+
+  private void writeDictionary(SortedSet<LongsRef> uniqueValueSets) throws IOException {
+    int lengthSum = 0;
+    for (LongsRef longs : uniqueValueSets) {
+      lengthSum += longs.length;
+    }
+
+    meta.writeInt(lengthSum);
+    for (LongsRef valueSet : uniqueValueSets) {
+      for (int  i = 0; i < valueSet.length; ++i) {
+        meta.writeLong(valueSet.longs[valueSet.offset + i]);
+      }
+    }
+
+    meta.writeInt(uniqueValueSets.size());
+    for (LongsRef valueSet : uniqueValueSets) {
+      meta.writeInt(valueSet.length);
+    }
+  }
+
+  private Iterable<Number> docToSetId(SortedSet<LongsRef> uniqueValueSets, Iterable<Number> docToValueCount, Iterable<Number> values) {
+    final Map<LongsRef, Integer> setIds = new HashMap<>();
+    int i = 0;
+    for (LongsRef set : uniqueValueSets) {
+      setIds.put(set, i++);
+    }
+    assert i == uniqueValueSets.size();
+
+    return new Iterable<Number>() {
+
+      @Override
+      public Iterator<Number> iterator() {
+        final Iterator<Number> valueCountIterator = docToValueCount.iterator();
+        final Iterator<Number> valueIterator = values.iterator();
+        final LongsRef docValues = new LongsRef(256);
+        return new Iterator<Number>() {
+
+          @Override
+          public boolean hasNext() {
+            return valueCountIterator.hasNext();
+          }
+
+          @Override
+          public Number next() {
+            docValues.length = valueCountIterator.next().intValue();
+            for (int i = 0; i < docValues.length; ++i) {
+              docValues.longs[i] = valueIterator.next().longValue();
+            }
+            final Integer id = setIds.get(docValues);
+            assert id != null;
+            return id;
+          }
+
+        };
+
+      }
+    };
+  }
+
+  // writes addressing information as MONOTONIC_COMPRESSED integer
+  private void addOrdIndex(FieldInfo field, Iterable<Number> values) throws IOException {
+    meta.writeVInt(field.number);
+    meta.writeByte(EncryptedLucene54DocValuesFormat.NUMERIC);
+    meta.writeVInt(MONOTONIC_COMPRESSED);
+    meta.writeLong(-1L);
+    meta.writeLong(data.getFilePointer());
+    meta.writeVLong(maxDoc);
+    meta.writeVInt(DIRECT_MONOTONIC_BLOCK_SHIFT);
+
+    final DirectMonotonicWriter writer = DirectMonotonicWriter.getInstance(meta, data, maxDoc + 1, DIRECT_MONOTONIC_BLOCK_SHIFT);
+    long addr = 0;
+    writer.add(addr);
+    for (Number v : values) {
+      addr += v.longValue();
+      writer.add(addr);
+    }
+    writer.finish();
+    meta.writeLong(data.getFilePointer());
+  }
+
+  @Override
+  public void close() throws IOException {
+    boolean success = false;
+    try {
+      if (meta != null) {
+        meta.writeVInt(-1); // write EOF marker
+        ((EncryptedIndexOutput) meta).flush();
+      }
+      if (nonEncryptedMeta != null) {
+        CodecUtil.writeFooter(nonEncryptedMeta); // write checksum
+      }
+      if (data != null) {
+        ((EncryptedIndexOutput) data).flush();
+      }
+      if (nonEncryptedData != null) {
+        CodecUtil.writeFooter(nonEncryptedData); // write checksum
+      }
+      success = true;
+    } finally {
+      if (success) {
+        IOUtils.close(nonEncryptedData, nonEncryptedMeta);
+      } else {
+        IOUtils.closeWhileHandlingException(nonEncryptedData, nonEncryptedMeta);
+      }
+      meta = data = null;
+      nonEncryptedMeta = nonEncryptedData = null;
+    }
+  }
+}
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesFormat.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesFormat.java
new file mode 100644
index 0000000..3deb0bb
--- /dev/null
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesFormat.java
@@ -0,0 +1,141 @@
+package org.apache.lucene.codecs.encrypted.docvalues;
+
+import java.io.IOException;
+
+import org.apache.lucene.codecs.DocValuesConsumer;
+import org.apache.lucene.codecs.DocValuesFormat;
+import org.apache.lucene.codecs.DocValuesProducer;
+import org.apache.lucene.codecs.encrypted.CipherFactory;
+import org.apache.lucene.index.SegmentInfo;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.SegmentWriteState;
+
+/*
+ * 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.
+ */
+
+/**
+ * Encrypted Lucene 5.4 DocValues format. Taken from {@link org.apache.lucene.codecs.lucene54.Lucene54DocValuesFormat}.
+ * @lucene.experimental
+ */
+public abstract class EncryptedLucene54DocValuesFormat extends DocValuesFormat {
+
+  /** Sole Constructor */
+  public EncryptedLucene54DocValuesFormat() {
+    super("EncryptedLucene54");
+  }
+
+  /**
+   * Concrete implementation should specify the {@link CipherFactory}.
+   */
+  protected abstract CipherFactory getCipherFactory();
+
+  @Override
+  public DocValuesConsumer fieldsConsumer(SegmentWriteState state) throws IOException {
+    // Store cipher version in segment info
+    SegmentInfo si = state.segmentInfo;
+    long cipherVersion = this.getCipherFactory().getEncipherVersion();
+    // It might overwrite the value put by another format, e.g., EncryptedLucene50StoredFieldsFormat
+    // We just check that it is the same version.
+    String previous = si.putAttribute(CipherFactory.CIPHER_VERSION_KEY, Long.toString(cipherVersion));
+    if (previous != null && Long.parseLong(previous) != cipherVersion) {
+      throw new IllegalStateException("found existing value for " + CipherFactory.CIPHER_VERSION_KEY +
+          " for segment: " + si.name + "old=" + previous + ", new=" + cipherVersion);
+    }
+
+    return new EncryptedLucene54DocValuesConsumer(this.getCipherFactory(), state, DATA_CODEC, DATA_EXTENSION, META_CODEC, META_EXTENSION);
+  }
+
+  @Override
+  public DocValuesProducer fieldsProducer(SegmentReadState state) throws IOException {
+    SegmentInfo si = state.segmentInfo;
+    String value = si.getAttribute(CipherFactory.CIPHER_VERSION_KEY);
+    if (value == null) {
+      throw new IllegalStateException("missing value for " + CipherFactory.CIPHER_VERSION_KEY +
+          " for segment: " + si.name);
+    }
+
+    return new EncryptedLucene54DocValuesProducer(this.getCipherFactory(), state, DATA_CODEC, DATA_EXTENSION, META_CODEC, META_EXTENSION);
+  }
+  
+  static final String DATA_CODEC = "EncryptedLucene54DocValuesData";
+  static final String DATA_EXTENSION = "dvd";
+  static final String META_CODEC = "EncryptedLucene54DocValuesMetadata";
+  static final String META_EXTENSION = "dvm";
+  static final int VERSION_START = 0;
+  static final int VERSION_CURRENT = VERSION_START;
+  
+  // indicates docvalues type
+  static final byte NUMERIC = 0;
+  static final byte BINARY = 1;
+  static final byte SORTED = 2;
+  static final byte SORTED_SET = 3;
+  static final byte SORTED_NUMERIC = 4;
+  
+  // address terms in blocks of 16 terms
+  static final int INTERVAL_SHIFT = 4;
+  static final int INTERVAL_COUNT = 1 << INTERVAL_SHIFT;
+  static final int INTERVAL_MASK = INTERVAL_COUNT - 1;
+  
+  // build reverse index from every 1024th term
+  static final int REVERSE_INTERVAL_SHIFT = 10;
+  static final int REVERSE_INTERVAL_COUNT = 1 << REVERSE_INTERVAL_SHIFT;
+  static final int REVERSE_INTERVAL_MASK = REVERSE_INTERVAL_COUNT - 1;
+  
+  // for conversion from reverse index to block
+  static final int BLOCK_INTERVAL_SHIFT = REVERSE_INTERVAL_SHIFT - INTERVAL_SHIFT;
+  static final int BLOCK_INTERVAL_COUNT = 1 << BLOCK_INTERVAL_SHIFT;
+  static final int BLOCK_INTERVAL_MASK = BLOCK_INTERVAL_COUNT - 1;
+
+  /** Compressed using packed blocks of ints. */
+  static final int DELTA_COMPRESSED = 0;
+  /** Compressed by computing the GCD. */
+  static final int GCD_COMPRESSED = 1;
+  /** Compressed by giving IDs to unique values. */
+  static final int TABLE_COMPRESSED = 2;
+  /** Compressed with monotonically increasing values */
+  static final int MONOTONIC_COMPRESSED = 3;
+  /** Compressed with constant value (uses only missing bitset) */
+  static final int CONST_COMPRESSED = 4;
+  /** Compressed with sparse arrays. */
+  static final int SPARSE_COMPRESSED = 5;
+
+  /** Uncompressed binary, written directly (fixed length). */
+  static final int BINARY_FIXED_UNCOMPRESSED = 0;
+  /** Uncompressed binary, written directly (variable length). */
+  static final int BINARY_VARIABLE_UNCOMPRESSED = 1;
+  /** Compressed binary with shared prefixes */
+  static final int BINARY_PREFIX_COMPRESSED = 2;
+
+  /** Standard storage for sorted set values with 1 level of indirection:
+   *  {@code docId -> address -> ord}. */
+  static final int SORTED_WITH_ADDRESSES = 0;
+  /** Single-valued sorted set values, encoded as sorted values, so no level
+   *  of indirection: {@code docId -> ord}. */
+  static final int SORTED_SINGLE_VALUED = 1;
+  /** Compressed giving IDs to unique sets of values:
+   * {@code docId -> setId -> ords} */
+  static final int SORTED_SET_TABLE = 2;
+  
+  /** placeholder for missing offset that means there are no missing values */
+  static final int ALL_LIVE = -1;
+  /** placeholder for missing offset that means all values are missing */
+  static final int ALL_MISSING = -2;
+  
+  // addressing uses 16k blocks
+  static final int MONOTONIC_BLOCK_SIZE = 16384;
+  static final int DIRECT_MONOTONIC_BLOCK_SHIFT = 16;
+}
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesProducer.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesProducer.java
new file mode 100644
index 0000000..05026d8
--- /dev/null
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/EncryptedLucene54DocValuesProducer.java
@@ -0,0 +1,1512 @@
+package org.apache.lucene.codecs.encrypted.docvalues;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.DocValuesProducer;
+import org.apache.lucene.codecs.encrypted.CipherFactory;
+import org.apache.lucene.index.BinaryDocValues;
+import org.apache.lucene.index.CorruptIndexException;
+import org.apache.lucene.index.DocValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FieldInfos;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.PostingsEnum;
+import org.apache.lucene.index.RandomAccessOrds;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.SortedDocValues;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.index.SortedSetDocValues;
+import org.apache.lucene.index.TermsEnum;
+import org.apache.lucene.store.ChecksumIndexInput;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.RandomAccessInput;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.Accountables;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.IOUtils;
+import org.apache.lucene.util.LongValues;
+import org.apache.lucene.util.PagedBytes;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.lucene.util.packed.DirectMonotonicReader;
+import org.apache.lucene.util.packed.DirectReader;
+import org.apache.lucene.util.packed.MonotonicBlockPackedReader;
+
+import static org.apache.lucene.codecs.encrypted.docvalues.EncryptedLucene54DocValuesFormat.*;
+
+/*
+ * 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.
+ */
+
+/** reader for {@link EncryptedLucene54DocValuesFormat} */
+final class EncryptedLucene54DocValuesProducer extends DocValuesProducer implements Closeable {
+  private final Map<String,NumericEntry> numerics = new HashMap<>();
+  private final Map<String,BinaryEntry> binaries = new HashMap<>();
+  private final Map<String,SortedSetEntry> sortedSets = new HashMap<>();
+  private final Map<String,SortedSetEntry> sortedNumerics = new HashMap<>();
+  private final Map<String,NumericEntry> ords = new HashMap<>();
+  private final Map<String,NumericEntry> ordIndexes = new HashMap<>();
+  private final int numFields;
+  private final AtomicLong ramBytesUsed;
+  private final IndexInput data;
+  private final IndexInput nonEncryptedData;
+  private final int maxDoc;
+  private final long cipherVersion;
+  private final CipherFactory cipherFactory;
+  private final long encryptedDataStart;
+
+  // memory-resident structures
+  private final Map<String,MonotonicBlockPackedReader> addressInstances = new HashMap<>();
+  private final Map<String,ReverseTermsIndex> reverseIndexInstances = new HashMap<>();
+  private final Map<String,DirectMonotonicReader.Meta> directAddressesMeta = new HashMap<>();
+
+  private final boolean merging;
+
+  // clone for merge: when merging we don't do any instances.put()s
+  EncryptedLucene54DocValuesProducer(EncryptedLucene54DocValuesProducer original) throws IOException {
+    assert Thread.holdsLock(original);
+    numerics.putAll(original.numerics);
+    binaries.putAll(original.binaries);
+    sortedSets.putAll(original.sortedSets);
+    sortedNumerics.putAll(original.sortedNumerics);
+    ords.putAll(original.ords);
+    ordIndexes.putAll(original.ordIndexes);
+    numFields = original.numFields;
+    ramBytesUsed = new AtomicLong(original.ramBytesUsed.get());
+    cipherVersion = original.cipherVersion;
+    cipherFactory = original.cipherFactory;
+    encryptedDataStart = original.encryptedDataStart;
+    nonEncryptedData = original.nonEncryptedData.clone();
+    IndexInput encryptedDataSlice = nonEncryptedData.slice("encrypted-data-slice", encryptedDataStart, nonEncryptedData.length() - (encryptedDataStart + CodecUtil.footerLength()));
+    data = new EncryptedIndexInput(encryptedDataSlice, original.cipherFactory, original.cipherVersion);
+    maxDoc = original.maxDoc;
+
+    addressInstances.putAll(original.addressInstances);
+    reverseIndexInstances.putAll(original.reverseIndexInstances);
+    merging = true;
+  }
+
+  /** expert: instantiates a new reader */
+  EncryptedLucene54DocValuesProducer(CipherFactory cipherFactory, SegmentReadState state, String dataCodec, String dataExtension, String metaCodec, String metaExtension) throws IOException {
+    String metaName = IndexFileNames.segmentFileName(state.segmentInfo.name, state.segmentSuffix, metaExtension);
+    this.maxDoc = state.segmentInfo.maxDoc();
+    merging = false;
+    ramBytesUsed = new AtomicLong(RamUsageEstimator.shallowSizeOfInstance(getClass()));
+
+    // Read the cipher version from the segment info
+    cipherVersion = Long.parseLong(state.segmentInfo.getAttribute(CipherFactory.CIPHER_VERSION_KEY));
+    this.cipherFactory = cipherFactory;
+
+    int version = -1;
+    int numFields = -1;
+
+    // read in the entries from the metadata file.
+    try (ChecksumIndexInput in = state.directory.openChecksumInput(metaName, state.context)) {
+      Throwable priorE = null;
+      try {
+        // Index header is not encrypted
+        version = CodecUtil.checkIndexHeader(in, metaCodec,
+                                        EncryptedLucene54DocValuesFormat.VERSION_START,
+                                        EncryptedLucene54DocValuesFormat.VERSION_CURRENT,
+                                        state.segmentInfo.getId(),
+                                        state.segmentSuffix);
+        // Read encrypted part of the metadata file
+        // we need to wrap the ChecksumIndexInput since it does not support slice
+        IndexInput encryptedMetaSlice = new MetaSliceIndexInput("encrypted-meta-slice", in, in.getFilePointer());
+        numFields = readFields(new EncryptedIndexInput(encryptedMetaSlice, cipherFactory, cipherVersion), state.fieldInfos);
+      } catch (Throwable exception) {
+        priorE = exception;
+      } finally {
+        // Index footer is not encrypted
+        CodecUtil.checkFooter(in, priorE);
+      }
+    }
+
+    this.numFields = numFields;
+    String dataName = IndexFileNames.segmentFileName(state.segmentInfo.name, state.segmentSuffix, dataExtension);
+    nonEncryptedData = state.directory.openInput(dataName, state.context);
+    boolean success = false;
+    try {
+      // Index header is not encrypted
+      final int version2 = CodecUtil.checkIndexHeader(nonEncryptedData, dataCodec,
+                                                 EncryptedLucene54DocValuesFormat.VERSION_START,
+                                                 EncryptedLucene54DocValuesFormat.VERSION_CURRENT,
+                                                 state.segmentInfo.getId(),
+                                                 state.segmentSuffix);
+      if (version != version2) {
+        throw new CorruptIndexException("Format versions mismatch: meta=" + version + ", data=" + version2, nonEncryptedData);
+      }
+
+      // NOTE: data file is too costly to verify checksum against all the bytes on open,
+      // but for now we at least verify proper structure of the checksum footer: which looks
+      // for FOOTER_MAGIC + algorithmID. This is cheap and can detect some forms of corruption
+      // such as file truncation.
+      encryptedDataStart = nonEncryptedData.getFilePointer();
+      CodecUtil.retrieveChecksum(nonEncryptedData);
+
+      IndexInput encryptedDataSlice = nonEncryptedData.slice("encrypted-data-slice", encryptedDataStart, nonEncryptedData.length() - (encryptedDataStart + CodecUtil.footerLength()));
+      this.data = new EncryptedIndexInput(encryptedDataSlice, cipherFactory, cipherVersion);
+
+      success = true;
+    } finally {
+      if (!success) {
+        IOUtils.closeWhileHandlingException(this.nonEncryptedData);
+      }
+    }
+  }
+
+  private void readSortedField(FieldInfo info, IndexInput meta) throws IOException {
+    // sorted = binary + numeric
+    if (meta.readVInt() != info.number) {
+      throw new CorruptIndexException("sorted entry for field: " + info.name + " is corrupt", meta);
+    }
+    if (meta.readByte() != EncryptedLucene54DocValuesFormat.BINARY) {
+      throw new CorruptIndexException("sorted entry for field: " + info.name + " is corrupt", meta);
+    }
+    BinaryEntry b = readBinaryEntry(info, meta);
+    binaries.put(info.name, b);
+
+    if (meta.readVInt() != info.number) {
+      throw new CorruptIndexException("sorted entry for field: " + info.name + " is corrupt", meta);
+    }
+    if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+      throw new CorruptIndexException("sorted entry for field: " + info.name + " is corrupt", meta);
+    }
+    NumericEntry n = readNumericEntry(info, meta);
+    ords.put(info.name, n);
+  }
+
+  private void readSortedSetFieldWithAddresses(FieldInfo info, IndexInput meta) throws IOException {
+    // sortedset = binary + numeric (addresses) + ordIndex
+    if (meta.readVInt() != info.number) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    if (meta.readByte() != EncryptedLucene54DocValuesFormat.BINARY) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    BinaryEntry b = readBinaryEntry(info, meta);
+    binaries.put(info.name, b);
+
+    if (meta.readVInt() != info.number) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    NumericEntry n1 = readNumericEntry(info, meta);
+    ords.put(info.name, n1);
+
+    if (meta.readVInt() != info.number) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    NumericEntry n2 = readNumericEntry(info, meta);
+    ordIndexes.put(info.name, n2);
+  }
+
+  private void readSortedSetFieldWithTable(FieldInfo info, IndexInput meta) throws IOException {
+    // sortedset table = binary + ordset table + ordset index
+    if (meta.readVInt() != info.number) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    if (meta.readByte() != EncryptedLucene54DocValuesFormat.BINARY) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+
+    BinaryEntry b = readBinaryEntry(info, meta);
+    binaries.put(info.name, b);
+
+    if (meta.readVInt() != info.number) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+      throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+    }
+    NumericEntry n = readNumericEntry(info, meta);
+    ords.put(info.name, n);
+  }
+
+  private int readFields(IndexInput meta, FieldInfos infos) throws IOException {
+    int numFields = 0;
+    int fieldNumber = meta.readVInt();
+    while (fieldNumber != -1) {
+      numFields++;
+      FieldInfo info = infos.fieldInfo(fieldNumber);
+      if (info == null) {
+        // trickier to validate more: because we use multiple entries for "composite" types like sortedset, etc.
+        throw new CorruptIndexException("Invalid field number: " + fieldNumber, meta);
+      }
+      byte type = meta.readByte();
+      if (type == EncryptedLucene54DocValuesFormat.NUMERIC) {
+        numerics.put(info.name, readNumericEntry(info, meta));
+      } else if (type == EncryptedLucene54DocValuesFormat.BINARY) {
+        BinaryEntry b = readBinaryEntry(info, meta);
+        binaries.put(info.name, b);
+      } else if (type == EncryptedLucene54DocValuesFormat.SORTED) {
+        readSortedField(info, meta);
+      } else if (type == EncryptedLucene54DocValuesFormat.SORTED_SET) {
+        SortedSetEntry ss = readSortedSetEntry(meta);
+        sortedSets.put(info.name, ss);
+        if (ss.format == SORTED_WITH_ADDRESSES) {
+          readSortedSetFieldWithAddresses(info, meta);
+        } else if (ss.format == SORTED_SET_TABLE) {
+          readSortedSetFieldWithTable(info, meta);
+        } else if (ss.format == SORTED_SINGLE_VALUED) {
+          if (meta.readVInt() != fieldNumber) {
+            throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+          }
+          if (meta.readByte() != EncryptedLucene54DocValuesFormat.SORTED) {
+            throw new CorruptIndexException("sortedset entry for field: " + info.name + " is corrupt", meta);
+          }
+          readSortedField(info, meta);
+        } else {
+          throw new AssertionError();
+        }
+      } else if (type == EncryptedLucene54DocValuesFormat.SORTED_NUMERIC) {
+        SortedSetEntry ss = readSortedSetEntry(meta);
+        sortedNumerics.put(info.name, ss);
+        if (ss.format == SORTED_WITH_ADDRESSES) {
+          if (meta.readVInt() != fieldNumber) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          numerics.put(info.name, readNumericEntry(info, meta));
+          if (meta.readVInt() != fieldNumber) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          NumericEntry ordIndex = readNumericEntry(info, meta);
+          ordIndexes.put(info.name, ordIndex);
+        } else if (ss.format == SORTED_SET_TABLE) {
+          if (meta.readVInt() != info.number) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          NumericEntry n = readNumericEntry(info, meta);
+          ords.put(info.name, n);
+        } else if (ss.format == SORTED_SINGLE_VALUED) {
+          if (meta.readVInt() != fieldNumber) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          if (meta.readByte() != EncryptedLucene54DocValuesFormat.NUMERIC) {
+            throw new CorruptIndexException("sortednumeric entry for field: " + info.name + " is corrupt", meta);
+          }
+          numerics.put(info.name, readNumericEntry(info, meta));
+        } else {
+          throw new AssertionError();
+        }
+      } else {
+        throw new CorruptIndexException("invalid type: " + type, meta);
+      }
+      fieldNumber = meta.readVInt();
+    }
+    return numFields;
+  }
+
+  private NumericEntry readNumericEntry(FieldInfo info, IndexInput meta) throws IOException {
+    NumericEntry entry = new NumericEntry();
+    entry.format = meta.readVInt();
+    entry.missingOffset = meta.readLong();
+    if (entry.format == SPARSE_COMPRESSED) {
+      // sparse bits need a bit more metadata
+      entry.numDocsWithValue = meta.readVLong();
+      final int blockShift = meta.readVInt();
+      entry.monotonicMeta = DirectMonotonicReader.loadMeta(meta, entry.numDocsWithValue, blockShift);
+      ramBytesUsed.addAndGet(entry.monotonicMeta.ramBytesUsed());
+      directAddressesMeta.put(info.name, entry.monotonicMeta);
+    }
+    entry.offset = meta.readLong();
+    entry.count = meta.readVLong();
+    switch(entry.format) {
+      case CONST_COMPRESSED:
+        entry.minValue = meta.readLong();
+        if (entry.count > Integer.MAX_VALUE) {
+          // currently just a limitation e.g. of bits interface and so on.
+          throw new CorruptIndexException("illegal CONST_COMPRESSED count: " + entry.count, meta);
+        }
+        break;
+      case GCD_COMPRESSED:
+        entry.minValue = meta.readLong();
+        entry.gcd = meta.readLong();
+        entry.bitsPerValue = meta.readVInt();
+        break;
+      case TABLE_COMPRESSED:
+        final int uniqueValues = meta.readVInt();
+        if (uniqueValues > 256) {
+          throw new CorruptIndexException("TABLE_COMPRESSED cannot have more than 256 distinct values, got=" + uniqueValues, meta);
+        }
+        entry.table = new long[uniqueValues];
+        for (int i = 0; i < uniqueValues; ++i) {
+          entry.table[i] = meta.readLong();
+        }
+        ramBytesUsed.addAndGet(RamUsageEstimator.sizeOf(entry.table));
+        entry.bitsPerValue = meta.readVInt();
+        break;
+      case DELTA_COMPRESSED:
+        entry.minValue = meta.readLong();
+        entry.bitsPerValue = meta.readVInt();
+        break;
+      case MONOTONIC_COMPRESSED:
+        final int blockShift = meta.readVInt();
+        entry.monotonicMeta = DirectMonotonicReader.loadMeta(meta, maxDoc + 1, blockShift);
+        ramBytesUsed.addAndGet(entry.monotonicMeta.ramBytesUsed());
+        directAddressesMeta.put(info.name, entry.monotonicMeta);
+        break;
+      case SPARSE_COMPRESSED:
+        final byte numberType = meta.readByte();
+        switch (numberType) {
+          case 0:
+            entry.numberType = EncryptedLucene54DocValuesConsumer.NumberType.VALUE;
+            break;
+          case 1:
+            entry.numberType = EncryptedLucene54DocValuesConsumer.NumberType.ORDINAL;
+            break;
+          default:
+            throw new CorruptIndexException("Number type can only be 0 or 1, got=" + numberType, meta);
+        }
+
+        // now read the numeric entry for non-missing values
+        final int fieldNumber = meta.readVInt();
+        if (fieldNumber != info.number) {
+          throw new CorruptIndexException("Field numbers mistmatch: " + fieldNumber + " != " + info.number, meta);
+        }
+        final int dvFormat = meta.readByte();
+        if (dvFormat != NUMERIC) {
+          throw new CorruptIndexException("Formats mistmatch: " + dvFormat + " != " + NUMERIC, meta);
+        }
+        entry.nonMissingValues = readNumericEntry(info, meta);
+        break;
+      default:
+        throw new CorruptIndexException("Unknown format: " + entry.format + ", input=", meta);
+    }
+    entry.endOffset = meta.readLong();
+    return entry;
+  }
+
+  private BinaryEntry readBinaryEntry(FieldInfo info, IndexInput meta) throws IOException {
+    BinaryEntry entry = new BinaryEntry();
+    entry.format = meta.readVInt();
+    entry.missingOffset = meta.readLong();
+    entry.minLength = meta.readVInt();
+    entry.maxLength = meta.readVInt();
+    entry.count = meta.readVLong();
+    entry.offset = meta.readLong();
+    switch(entry.format) {
+      case BINARY_FIXED_UNCOMPRESSED:
+        break;
+      case BINARY_PREFIX_COMPRESSED:
+        entry.addressesOffset = meta.readLong();
+        entry.packedIntsVersion = meta.readVInt();
+        entry.blockSize = meta.readVInt();
+        entry.reverseIndexOffset = meta.readLong();
+        break;
+      case BINARY_VARIABLE_UNCOMPRESSED:
+        entry.addressesOffset = meta.readLong();
+        final int blockShift = meta.readVInt();
+        entry.addressesMeta = DirectMonotonicReader.loadMeta(meta, entry.count + 1, blockShift);
+        ramBytesUsed.addAndGet(entry.addressesMeta.ramBytesUsed());
+        directAddressesMeta.put(info.name, entry.addressesMeta);
+        entry.addressesEndOffset = meta.readLong();
+        break;
+      default:
+        throw new CorruptIndexException("Unknown format: " + entry.format, meta);
+    }
+    return entry;
+  }
+
+  SortedSetEntry readSortedSetEntry(IndexInput meta) throws IOException {
+    SortedSetEntry entry = new SortedSetEntry();
+    entry.format = meta.readVInt();
+    if (entry.format == SORTED_SET_TABLE) {
+      final int totalTableLength = meta.readInt();
+      if (totalTableLength > 256) {
+        throw new CorruptIndexException("SORTED_SET_TABLE cannot have more than 256 values in its dictionary, got=" + totalTableLength, meta);
+      }
+      entry.table = new long[totalTableLength];
+      for (int i = 0; i < totalTableLength; ++i) {
+        entry.table[i] = meta.readLong();
+      }
+      ramBytesUsed.addAndGet(RamUsageEstimator.sizeOf(entry.table));
+      final int tableSize = meta.readInt();
+      if (tableSize > totalTableLength + 1) { // +1 because of the empty set
+        throw new CorruptIndexException("SORTED_SET_TABLE cannot have more set ids than ords in its dictionary, got " + totalTableLength + " ords and " + tableSize + " sets", meta);
+      }
+      entry.tableOffsets = new int[tableSize + 1];
+      for (int i = 1; i < entry.tableOffsets.length; ++i) {
+        entry.tableOffsets[i] = entry.tableOffsets[i - 1] + meta.readInt();
+      }
+      ramBytesUsed.addAndGet(RamUsageEstimator.sizeOf(entry.tableOffsets));
+    } else if (entry.format != SORTED_SINGLE_VALUED && entry.format != SORTED_WITH_ADDRESSES) {
+      throw new CorruptIndexException("Unknown format: " + entry.format, meta);
+    }
+    return entry;
+  }
+
+  @Override
+  public NumericDocValues getNumeric(FieldInfo field) throws IOException {
+    NumericEntry entry = numerics.get(field.name);
+    return getNumeric(entry);
+  }
+
+  @Override
+  public long ramBytesUsed() {
+    return ramBytesUsed.get();
+  }
+
+  @Override
+  public synchronized Collection<Accountable> getChildResources() {
+    List<Accountable> resources = new ArrayList<>();
+    resources.addAll(Accountables.namedAccountables("addresses field", addressInstances));
+    resources.addAll(Accountables.namedAccountables("reverse index field", reverseIndexInstances));
+    resources.addAll(Accountables.namedAccountables("direct addresses meta field", directAddressesMeta));
+    return Collections.unmodifiableList(resources);
+  }
+
+  @Override
+  public void checkIntegrity() throws IOException {
+    CodecUtil.checksumEntireFile(nonEncryptedData);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "(fields=" + numFields + ")";
+  }
+
+  LongValues getNumeric(NumericEntry entry) throws IOException {
+    switch (entry.format) {
+      case CONST_COMPRESSED: {
+        final long constant = entry.minValue;
+        final Bits live = getLiveBits(entry.missingOffset, (int)entry.count);
+        return new LongValues() {
+          @Override
+          public long get(long index) {
+            return live.get((int)index) ? constant : 0;
+          }
+        };
+      }
+      case DELTA_COMPRESSED: {
+        RandomAccessInput slice = this.data.randomAccessSlice(entry.offset, entry.endOffset - entry.offset);
+        final long delta = entry.minValue;
+        final LongValues values = DirectReader.getInstance(slice, entry.bitsPerValue, 0);
+        return new LongValues() {
+          @Override
+          public long get(long id) {
+            return delta + values.get(id);
+          }
+        };
+      }
+      case GCD_COMPRESSED: {
+        RandomAccessInput slice = this.data.randomAccessSlice(entry.offset, entry.endOffset - entry.offset);
+        final long min = entry.minValue;
+        final long mult = entry.gcd;
+        final LongValues quotientReader = DirectReader.getInstance(slice, entry.bitsPerValue, 0);
+        return new LongValues() {
+          @Override
+          public long get(long id) {
+            return min + mult * quotientReader.get(id);
+          }
+        };
+      }
+      case TABLE_COMPRESSED: {
+        RandomAccessInput slice = this.data.randomAccessSlice(entry.offset, entry.endOffset - entry.offset);
+        final long table[] = entry.table;
+        final LongValues ords = DirectReader.getInstance(slice, entry.bitsPerValue, 0);
+        return new LongValues() {
+          @Override
+          public long get(long id) {
+            return table[(int) ords.get(id)];
+          }
+        };
+      }
+      case SPARSE_COMPRESSED:
+        final SparseBits docsWithField = getSparseLiveBits(entry);
+        final LongValues values = getNumeric(entry.nonMissingValues);
+        final long missingValue;
+        switch (entry.numberType) {
+          case ORDINAL:
+            missingValue = -1L;
+            break;
+          case VALUE:
+            missingValue = 0L;
+            break;
+          default:
+            throw new AssertionError();
+        }
+        return new SparseLongValues(docsWithField, values, missingValue);
+      default:
+        throw new AssertionError();
+    }
+  }
+
+  static class SparseBits implements Bits {
+
+    final long maxDoc, docIDsLength, firstDocId;
+    final LongValues docIds;
+
+    long index;     // index of docId in docIds
+    long docId;     // doc ID at index
+    long nextDocId; // doc ID at (index+1)
+
+    SparseBits(long maxDoc, long docIDsLength, LongValues docIDs) {
+      if (docIDsLength > 0 && maxDoc <= docIDs.get(docIDsLength - 1)) {
+        throw new IllegalArgumentException("maxDoc must be > the last element of docIDs");
+      }
+      this.maxDoc = maxDoc;
+      this.docIDsLength = docIDsLength;
+      this.docIds = docIDs;
+      this.firstDocId = docIDsLength == 0 ? maxDoc : docIDs.get(0);
+      reset();
+    }
+
+    private void reset() {
+      index = -1;
+      this.docId = -1;
+      this.nextDocId = firstDocId;
+    }
+
+    /** Gallop forward and stop as soon as an index is found that is greater than
+     *  the given docId. {@code index} will store an index that stores a value
+     *  that is &lt;= {@code docId} while the return value will give an index
+     *  that stores a value that is &gt; {@code docId}. These indices can then be
+     *  used to binary search. */
+    private long gallop(long docId) {
+      index++;
+      this.docId = nextDocId;
+      long hiIndex = index + 1;
+
+      while (true) {
+        if (hiIndex >= docIDsLength) {
+          hiIndex = docIDsLength;
+          nextDocId = maxDoc;
+          break;
+        }
+
+        final long hiDocId = docIds.get(hiIndex);
+        if (hiDocId > docId) {
+          nextDocId = hiDocId;
+          break;
+        }
+
+        final long delta = hiIndex - index;
+        index = hiIndex;
+        this.docId = hiDocId;
+        hiIndex += delta << 1; // double the step each time
+      }
+      return hiIndex;
+    }
+
+    private void binarySearch(long hiIndex, long docId) {
+      while (index + 1 < hiIndex) {
+        final long midIndex = (index + hiIndex) >>> 1;
+        final long midDocId = docIds.get(midIndex);
+        if (midDocId > docId) {
+          hiIndex = midIndex;
+          nextDocId = midDocId;
+        } else {
+          index = midIndex;
+          this.docId = midDocId;
+        }
+      }
+    }
+
+    private boolean checkInvariants(long nextIndex, long docId) {
+      assert this.docId <= docId;
+      assert this.nextDocId > docId;
+      assert (index == -1 && this.docId == -1) || this.docId == docIds.get(index);
+      assert (nextIndex == docIDsLength && nextDocId == maxDoc) || nextDocId == docIds.get(nextIndex);
+      return true;
+    }
+
+    private void exponentialSearch(long docId) {
+      // seek forward by doubling the interval on each iteration
+      final long hiIndex = gallop(docId);
+      assert checkInvariants(hiIndex, docId);
+
+      // now perform the actual binary search
+      binarySearch(hiIndex, docId);
+    }
+
+    boolean get(final long docId) {
+      if (docId < this.docId) {
+        // reading doc IDs backward, go back to the start
+        reset();
+      }
+
+      if (docId >= nextDocId) {
+        exponentialSearch(docId);
+      }
+
+      assert checkInvariants(index + 1, docId);
+      return docId == this.docId;
+    }
+
+    @Override
+    public boolean get(int index) {
+      return get((long) index);
+    }
+
+    @Override
+    public int length() {
+      return Math.toIntExact(maxDoc);
+    }
+  }
+
+  static class SparseLongValues extends LongValues {
+
+    final SparseBits docsWithField;
+    final LongValues values;
+    final long missingValue;
+
+    SparseLongValues(SparseBits docsWithField, LongValues values, long missingValue) {
+      this.docsWithField = docsWithField;
+      this.values = values;
+      this.missingValue = missingValue;
+    }
+
+    @Override
+    public long get(long docId) {
+      if (docsWithField.get(docId)) {
+        return values.get(docsWithField.index);
+      } else {
+        return missingValue;
+      }
+    }
+
+  }
+
+  @Override
+  public BinaryDocValues getBinary(FieldInfo field) throws IOException {
+    BinaryEntry bytes = binaries.get(field.name);
+    switch(bytes.format) {
+      case BINARY_FIXED_UNCOMPRESSED:
+        return getFixedBinary(field, bytes);
+      case BINARY_VARIABLE_UNCOMPRESSED:
+        return getVariableBinary(field, bytes);
+      case BINARY_PREFIX_COMPRESSED:
+        return getCompressedBinary(field, bytes);
+      default:
+        throw new AssertionError();
+    }
+  }
+
+  private BinaryDocValues getFixedBinary(FieldInfo field, final BinaryEntry bytes) throws IOException {
+    final IndexInput data = this.data.slice("fixed-binary", bytes.offset, bytes.count * bytes.maxLength);
+
+    final BytesRef term = new BytesRef(bytes.maxLength);
+    final byte[] buffer = term.bytes;
+    final int length = term.length = bytes.maxLength;
+
+    return new LongBinaryDocValues() {
+      @Override
+      public BytesRef get(long id) {
+        try {
+          data.seek(id * length);
+          data.readBytes(buffer, 0, buffer.length);
+          return term;
+        } catch (IOException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    };
+  }
+
+  private BinaryDocValues getVariableBinary(FieldInfo field, final BinaryEntry bytes) throws IOException {
+    final RandomAccessInput addressesData = this.data.randomAccessSlice(bytes.addressesOffset, bytes.addressesEndOffset - bytes.addressesOffset);
+    final LongValues addresses = DirectMonotonicReader.getInstance(bytes.addressesMeta, addressesData);
+
+    final IndexInput data = this.data.slice("var-binary", bytes.offset, bytes.addressesOffset - bytes.offset);
+    final BytesRef term = new BytesRef(Math.max(0, bytes.maxLength));
+    final byte buffer[] = term.bytes;
+
+    return new LongBinaryDocValues() {
+      @Override
+      public BytesRef get(long id) {
+        long startAddress = addresses.get(id);
+        long endAddress = addresses.get(id+1);
+        int length = (int) (endAddress - startAddress);
+        try {
+          data.seek(startAddress);
+          data.readBytes(buffer, 0, length);
+          term.length = length;
+          return term;
+        } catch (IOException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    };
+  }
+
+  /** returns an address instance for prefix-compressed binary values. */
+  private synchronized MonotonicBlockPackedReader getIntervalInstance(FieldInfo field, BinaryEntry bytes) throws IOException {
+    MonotonicBlockPackedReader addresses = addressInstances.get(field.name);
+    if (addresses == null) {
+      data.seek(bytes.addressesOffset);
+      final long size = (bytes.count + INTERVAL_MASK) >>> INTERVAL_SHIFT;
+      addresses = MonotonicBlockPackedReader.of(data, bytes.packedIntsVersion, bytes.blockSize, size, false);
+      if (!merging) {
+        addressInstances.put(field.name, addresses);
+        ramBytesUsed.addAndGet(addresses.ramBytesUsed() + Integer.BYTES);
+      }
+    }
+    return addresses;
+  }
+
+  /** returns a reverse lookup instance for prefix-compressed binary values. */
+  private synchronized ReverseTermsIndex getReverseIndexInstance(FieldInfo field, BinaryEntry bytes) throws IOException {
+    ReverseTermsIndex index = reverseIndexInstances.get(field.name);
+    if (index == null) {
+      index = new ReverseTermsIndex();
+      data.seek(bytes.reverseIndexOffset);
+      long size = (bytes.count + REVERSE_INTERVAL_MASK) >>> REVERSE_INTERVAL_SHIFT;
+      index.termAddresses = MonotonicBlockPackedReader.of(data, bytes.packedIntsVersion, bytes.blockSize, size, false);
+      long dataSize = data.readVLong();
+      PagedBytes pagedBytes = new PagedBytes(15);
+      pagedBytes.copy(data, dataSize);
+      index.terms = pagedBytes.freeze(true);
+      if (!merging) {
+        reverseIndexInstances.put(field.name, index);
+        ramBytesUsed.addAndGet(index.ramBytesUsed());
+      }
+    }
+    return index;
+  }
+
+  private BinaryDocValues getCompressedBinary(FieldInfo field, final BinaryEntry bytes) throws IOException {
+    final MonotonicBlockPackedReader addresses = getIntervalInstance(field, bytes);
+    final ReverseTermsIndex index = getReverseIndexInstance(field, bytes);
+    assert addresses.size() > 0; // we don't have to handle empty case
+    IndexInput slice = data.slice("terms", bytes.offset, bytes.addressesOffset - bytes.offset);
+    return new CompressedBinaryDocValues(bytes, addresses, index, slice);
+  }
+
+  @Override
+  public SortedDocValues getSorted(FieldInfo field) throws IOException {
+    final int valueCount = (int) binaries.get(field.name).count;
+    final BinaryDocValues binary = getBinary(field);
+    NumericEntry entry = ords.get(field.name);
+    final LongValues ordinals = getNumeric(entry);
+    return new SortedDocValues() {
+
+      @Override
+      public int getOrd(int docID) {
+        return (int) ordinals.get(docID);
+      }
+
+      @Override
+      public BytesRef lookupOrd(int ord) {
+        return binary.get(ord);
+      }
+
+      @Override
+      public int getValueCount() {
+        return valueCount;
+      }
+
+      @Override
+      public int lookupTerm(BytesRef key) {
+        if (binary instanceof CompressedBinaryDocValues) {
+          return (int) ((CompressedBinaryDocValues)binary).lookupTerm(key);
+        } else {
+          return super.lookupTerm(key);
+        }
+      }
+
+      @Override
+      public TermsEnum termsEnum() {
+        if (binary instanceof CompressedBinaryDocValues) {
+          return ((CompressedBinaryDocValues)binary).getTermsEnum();
+        } else {
+          return super.termsEnum();
+        }
+      }
+    };
+  }
+
+  /** returns an address instance for sortedset ordinal lists */
+  private LongValues getOrdIndexInstance(FieldInfo field, NumericEntry entry) throws IOException {
+    RandomAccessInput data = this.data.randomAccessSlice(entry.offset, entry.endOffset - entry.offset);
+    return DirectMonotonicReader.getInstance(entry.monotonicMeta, data);
+  }
+
+  @Override
+  public SortedNumericDocValues getSortedNumeric(FieldInfo field) throws IOException {
+    SortedSetEntry ss = sortedNumerics.get(field.name);
+    if (ss.format == SORTED_SINGLE_VALUED) {
+      NumericEntry numericEntry = numerics.get(field.name);
+      final LongValues values = getNumeric(numericEntry);
+      final Bits docsWithField;
+      if (numericEntry.format == SPARSE_COMPRESSED) {
+        docsWithField = ((SparseLongValues) values).docsWithField;
+      } else {
+        docsWithField = getLiveBits(numericEntry.missingOffset, maxDoc);
+      }
+      return DocValues.singleton(values, docsWithField);
+    } else if (ss.format == SORTED_WITH_ADDRESSES) {
+      NumericEntry numericEntry = numerics.get(field.name);
+      final LongValues values = getNumeric(numericEntry);
+      final LongValues ordIndex = getOrdIndexInstance(field, ordIndexes.get(field.name));
+
+      return new SortedNumericDocValues() {
+        long startOffset;
+        long endOffset;
+
+        @Override
+        public void setDocument(int doc) {
+          startOffset = ordIndex.get(doc);
+          endOffset = ordIndex.get(doc+1L);
+        }
+
+        @Override
+        public long valueAt(int index) {
+          return values.get(startOffset + index);
+        }
+
+        @Override
+        public int count() {
+          return (int) (endOffset - startOffset);
+        }
+      };
+    } else if (ss.format == SORTED_SET_TABLE) {
+      NumericEntry entry = ords.get(field.name);
+      final LongValues ordinals = getNumeric(entry);
+
+      final long[] table = ss.table;
+      final int[] offsets = ss.tableOffsets;
+      return new SortedNumericDocValues() {
+        int startOffset;
+        int endOffset;
+
+        @Override
+        public void setDocument(int doc) {
+          final int ord = (int) ordinals.get(doc);
+          startOffset = offsets[ord];
+          endOffset = offsets[ord + 1];
+        }
+
+        @Override
+        public long valueAt(int index) {
+          return table[startOffset + index];
+        }
+
+        @Override
+        public int count() {
+          return endOffset - startOffset;
+        }
+      };
+    } else {
+      throw new AssertionError();
+    }
+  }
+
+  @Override
+  public SortedSetDocValues getSortedSet(FieldInfo field) throws IOException {
+    SortedSetEntry ss = sortedSets.get(field.name);
+    switch (ss.format) {
+      case SORTED_SINGLE_VALUED:
+        final SortedDocValues values = getSorted(field);
+        return DocValues.singleton(values);
+      case SORTED_WITH_ADDRESSES:
+        return getSortedSetWithAddresses(field);
+      case SORTED_SET_TABLE:
+        return getSortedSetTable(field, ss);
+      default:
+        throw new AssertionError();
+    }
+  }
+
+  private SortedSetDocValues getSortedSetWithAddresses(FieldInfo field) throws IOException {
+    final long valueCount = binaries.get(field.name).count;
+    // we keep the byte[]s and list of ords on disk, these could be large
+    final LongBinaryDocValues binary = (LongBinaryDocValues) getBinary(field);
+    final LongValues ordinals = getNumeric(ords.get(field.name));
+    // but the addresses to the ord stream are in RAM
+    final LongValues ordIndex = getOrdIndexInstance(field, ordIndexes.get(field.name));
+
+    return new RandomAccessOrds() {
+      long startOffset;
+      long offset;
+      long endOffset;
+
+      @Override
+      public long nextOrd() {
+        if (offset == endOffset) {
+          return NO_MORE_ORDS;
+        } else {
+          long ord = ordinals.get(offset);
+          offset++;
+          return ord;
+        }
+      }
+
+      @Override
+      public void setDocument(int docID) {
+        startOffset = offset = ordIndex.get(docID);
+        endOffset = ordIndex.get(docID+1L);
+      }
+
+      @Override
+      public BytesRef lookupOrd(long ord) {
+        return binary.get(ord);
+      }
+
+      @Override
+      public long getValueCount() {
+        return valueCount;
+      }
+
+      @Override
+      public long lookupTerm(BytesRef key) {
+        if (binary instanceof CompressedBinaryDocValues) {
+          return ((CompressedBinaryDocValues)binary).lookupTerm(key);
+        } else {
+          return super.lookupTerm(key);
+        }
+      }
+
+      @Override
+      public TermsEnum termsEnum() {
+        if (binary instanceof CompressedBinaryDocValues) {
+          return ((CompressedBinaryDocValues)binary).getTermsEnum();
+        } else {
+          return super.termsEnum();
+        }
+      }
+
+      @Override
+      public long ordAt(int index) {
+        return ordinals.get(startOffset + index);
+      }
+
+      @Override
+      public int cardinality() {
+        return (int) (endOffset - startOffset);
+      }
+    };
+  }
+
+  private SortedSetDocValues getSortedSetTable(FieldInfo field, SortedSetEntry ss) throws IOException {
+    final long valueCount = binaries.get(field.name).count;
+    final LongBinaryDocValues binary = (LongBinaryDocValues) getBinary(field);
+    final LongValues ordinals = getNumeric(ords.get(field.name));
+
+    final long[] table = ss.table;
+    final int[] offsets = ss.tableOffsets;
+
+    return new RandomAccessOrds() {
+
+      int offset, startOffset, endOffset;
+
+      @Override
+      public void setDocument(int docID) {
+        final int ord = (int) ordinals.get(docID);
+        offset = startOffset = offsets[ord];
+        endOffset = offsets[ord + 1];
+      }
+
+      @Override
+      public long ordAt(int index) {
+        return table[startOffset + index];
+      }
+
+      @Override
+      public long nextOrd() {
+        if (offset == endOffset) {
+          return NO_MORE_ORDS;
+        } else {
+          return table[offset++];
+        }
+      }
+
+      @Override
+      public int cardinality() {
+        return endOffset - startOffset;
+      }
+
+      @Override
+      public BytesRef lookupOrd(long ord) {
+        return binary.get(ord);
+      }
+
+      @Override
+      public long getValueCount() {
+        return valueCount;
+      }
+
+      @Override
+      public long lookupTerm(BytesRef key) {
+        if (binary instanceof CompressedBinaryDocValues) {
+          return ((CompressedBinaryDocValues) binary).lookupTerm(key);
+        } else {
+          return super.lookupTerm(key);
+        }
+      }
+
+      @Override
+      public TermsEnum termsEnum() {
+        if (binary instanceof CompressedBinaryDocValues) {
+          return ((CompressedBinaryDocValues) binary).getTermsEnum();
+        } else {
+          return super.termsEnum();
+        }
+      }
+
+    };
+  }
+
+  private Bits getLiveBits(final long offset, final int count) throws IOException {
+    if (offset == ALL_MISSING) {
+      return new Bits.MatchNoBits(count);
+    } else if (offset == ALL_LIVE) {
+      return new Bits.MatchAllBits(count);
+    } else {
+      int length = (int) ((count + 7L) >>> 3);
+      final RandomAccessInput in = data.randomAccessSlice(offset, length);
+      return new Bits() {
+        @Override
+        public boolean get(int index) {
+          try {
+            return (in.readByte(index >> 3) & (1 << (index & 7))) != 0;
+          } catch (IOException e) {
+            throw new RuntimeException(e);
+          }
+        }
+
+        @Override
+        public int length() {
+          return count;
+        }
+      };
+    }
+  }
+
+  private SparseBits getSparseLiveBits(NumericEntry entry) throws IOException {
+    final RandomAccessInput docIdsData = this.data.randomAccessSlice(entry.missingOffset, entry.offset - entry.missingOffset);
+    final LongValues docIDs = DirectMonotonicReader.getInstance(entry.monotonicMeta, docIdsData);
+    return new SparseBits(maxDoc, entry.numDocsWithValue, docIDs);
+  }
+
+  @Override
+  public Bits getDocsWithField(FieldInfo field) throws IOException {
+    switch(field.getDocValuesType()) {
+      case SORTED_SET:
+        return DocValues.docsWithValue(getSortedSet(field), maxDoc);
+      case SORTED_NUMERIC:
+        return DocValues.docsWithValue(getSortedNumeric(field), maxDoc);
+      case SORTED:
+        return DocValues.docsWithValue(getSorted(field), maxDoc);
+      case BINARY:
+        BinaryEntry be = binaries.get(field.name);
+        return getLiveBits(be.missingOffset, maxDoc);
+      case NUMERIC:
+        NumericEntry ne = numerics.get(field.name);
+        if (ne.format == SPARSE_COMPRESSED) {
+          return getSparseLiveBits(ne);
+        } else {
+          return getLiveBits(ne.missingOffset, maxDoc);
+        }
+      default:
+        throw new AssertionError();
+    }
+  }
+
+  @Override
+  public synchronized DocValuesProducer getMergeInstance() throws IOException {
+    return new EncryptedLucene54DocValuesProducer(this);
+  }
+
+  @Override
+  public void close() throws IOException {
+    nonEncryptedData.close();
+  }
+
+  /** metadata entry for a numeric docvalues field */
+  static class NumericEntry {
+    private NumericEntry() {}
+    /** offset to the bitset representing docsWithField, or -1 if no documents have missing values */
+    long missingOffset;
+    /** offset to the actual numeric values */
+    public long offset;
+    /** end offset to the actual numeric values */
+    public long endOffset;
+    /** bits per value used to pack the numeric values */
+    public int bitsPerValue;
+
+    int format;
+    /** count of values written */
+    public long count;
+
+    /** monotonic meta */
+    public DirectMonotonicReader.Meta monotonicMeta;
+
+    long minValue;
+    long gcd;
+    long table[];
+
+    /** for sparse compression */
+    long numDocsWithValue;
+    NumericEntry nonMissingValues;
+    EncryptedLucene54DocValuesConsumer.NumberType numberType;
+
+  }
+
+  /** metadata entry for a binary docvalues field */
+  static class BinaryEntry {
+    private BinaryEntry() {}
+    /** offset to the bitset representing docsWithField, or -1 if no documents have missing values */
+    long missingOffset;
+    /** offset to the actual binary values */
+    long offset;
+
+    int format;
+    /** count of values written */
+    public long count;
+    int minLength;
+    int maxLength;
+    /** offset to the addressing data that maps a value to its slice of the byte[] */
+    public long addressesOffset, addressesEndOffset;
+    /** meta data for addresses */
+    public DirectMonotonicReader.Meta addressesMeta;
+    /** offset to the reverse index */
+    public long reverseIndexOffset;
+    /** packed ints version used to encode addressing information */
+    public int packedIntsVersion;
+    /** packed ints blocksize */
+    public int blockSize;
+  }
+
+  /** metadata entry for a sorted-set docvalues field */
+  static class SortedSetEntry {
+    private SortedSetEntry() {}
+    int format;
+
+    long[] table;
+    int[] tableOffsets;
+  }
+
+  // internally we compose complex dv (sorted/sortedset) from other ones
+  static abstract class LongBinaryDocValues extends BinaryDocValues {
+    @Override
+    public final BytesRef get(int docID) {
+      return get((long)docID);
+    }
+
+    abstract BytesRef get(long id);
+  }
+
+  // used for reverse lookup to a small range of blocks
+  static class ReverseTermsIndex implements Accountable {
+    public MonotonicBlockPackedReader termAddresses;
+    public PagedBytes.Reader terms;
+
+    @Override
+    public long ramBytesUsed() {
+      return termAddresses.ramBytesUsed() + terms.ramBytesUsed();
+    }
+
+    @Override
+    public Collection<Accountable> getChildResources() {
+      List<Accountable> resources = new ArrayList<>();
+      resources.add(Accountables.namedAccountable("term bytes", terms));
+      resources.add(Accountables.namedAccountable("term addresses", termAddresses));
+      return Collections.unmodifiableList(resources);
+    }
+
+    @Override
+    public String toString() {
+      return getClass().getSimpleName() + "(size=" + termAddresses.size() + ")";
+    }
+  }
+
+  //in the compressed case, we add a few additional operations for
+  //more efficient reverse lookup and enumeration
+  static final class CompressedBinaryDocValues extends LongBinaryDocValues {
+    final long numValues;
+    final long numIndexValues;
+    final int maxTermLength;
+    final MonotonicBlockPackedReader addresses;
+    final IndexInput data;
+    final CompressedBinaryTermsEnum termsEnum;
+    final PagedBytes.Reader reverseTerms;
+    final MonotonicBlockPackedReader reverseAddresses;
+    final long numReverseIndexValues;
+
+    public CompressedBinaryDocValues(BinaryEntry bytes, MonotonicBlockPackedReader addresses, ReverseTermsIndex index, IndexInput data) throws IOException {
+      this.maxTermLength = bytes.maxLength;
+      this.numValues = bytes.count;
+      this.addresses = addresses;
+      this.numIndexValues = addresses.size();
+      this.data = data;
+      this.reverseTerms = index.terms;
+      this.reverseAddresses = index.termAddresses;
+      this.numReverseIndexValues = reverseAddresses.size();
+      this.termsEnum = getTermsEnum(data);
+    }
+
+    @Override
+    public BytesRef get(long id) {
+      try {
+        termsEnum.seekExact(id);
+        return termsEnum.term();
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    long lookupTerm(BytesRef key) {
+      try {
+        switch (termsEnum.seekCeil(key)) {
+          case FOUND: return termsEnum.ord();
+          case NOT_FOUND: return -termsEnum.ord()-1;
+          default: return -numValues-1;
+        }
+      } catch (IOException bogus) {
+        throw new RuntimeException(bogus);
+      }
+    }
+
+    TermsEnum getTermsEnum() {
+      try {
+        return getTermsEnum(data.clone());
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    private CompressedBinaryTermsEnum getTermsEnum(IndexInput input) throws IOException {
+      return new CompressedBinaryTermsEnum(input);
+    }
+
+    class CompressedBinaryTermsEnum extends TermsEnum {
+      private long currentOrd = -1;
+      // offset to the start of the current block
+      private long currentBlockStart;
+      private final IndexInput input;
+      // delta from currentBlockStart to start of each term
+      private final int offsets[] = new int[INTERVAL_COUNT];
+      private final byte buffer[] = new byte[2*INTERVAL_COUNT-1];
+
+      private final BytesRef term = new BytesRef(maxTermLength);
+      private final BytesRef firstTerm = new BytesRef(maxTermLength);
+      private final BytesRef scratch = new BytesRef();
+
+      CompressedBinaryTermsEnum(IndexInput input) throws IOException {
+        this.input = input;
+        input.seek(0);
+      }
+
+      private void readHeader() throws IOException {
+        firstTerm.length = input.readVInt();
+        input.readBytes(firstTerm.bytes, 0, firstTerm.length);
+        input.readBytes(buffer, 0, INTERVAL_COUNT-1);
+        if (buffer[0] == -1) {
+          readShortAddresses();
+        } else {
+          readByteAddresses();
+        }
+        currentBlockStart = input.getFilePointer();
+      }
+
+      // read single byte addresses: each is delta - 2
+      // (shared prefix byte and length > 0 are both implicit)
+      private void readByteAddresses() throws IOException {
+        int addr = 0;
+        for (int i = 1; i < offsets.length; i++) {
+          addr += 2 + (buffer[i-1] & 0xFF);
+          offsets[i] = addr;
+        }
+      }
+
+      // read double byte addresses: each is delta - 2
+      // (shared prefix byte and length > 0 are both implicit)
+      private void readShortAddresses() throws IOException {
+        input.readBytes(buffer, INTERVAL_COUNT-1, INTERVAL_COUNT);
+        int addr = 0;
+        for (int i = 1; i < offsets.length; i++) {
+          int x = i<<1;
+          addr += 2 + ((buffer[x-1] << 8) | (buffer[x] & 0xFF));
+          offsets[i] = addr;
+        }
+      }
+
+      // set term to the first term
+      private void readFirstTerm() throws IOException {
+        term.length = firstTerm.length;
+        System.arraycopy(firstTerm.bytes, firstTerm.offset, term.bytes, 0, term.length);
+      }
+
+      // read term at offset, delta encoded from first term
+      private void readTerm(int offset) throws IOException {
+        int start = input.readByte() & 0xFF;
+        System.arraycopy(firstTerm.bytes, firstTerm.offset, term.bytes, 0, start);
+        int suffix = offsets[offset] - offsets[offset-1] - 1;
+        input.readBytes(term.bytes, start, suffix);
+        term.length = start + suffix;
+      }
+
+      @Override
+      public BytesRef next() throws IOException {
+        currentOrd++;
+        if (currentOrd >= numValues) {
+          return null;
+        } else {
+          int offset = (int) (currentOrd & INTERVAL_MASK);
+          if (offset == 0) {
+            // switch to next block
+            readHeader();
+            readFirstTerm();
+          } else {
+            readTerm(offset);
+          }
+          return term;
+        }
+      }
+
+      // binary search reverse index to find smaller
+      // range of blocks to search
+      long binarySearchIndex(BytesRef text) throws IOException {
+        long low = 0;
+        long high = numReverseIndexValues - 1;
+        while (low <= high) {
+          long mid = (low + high) >>> 1;
+          reverseTerms.fill(scratch, reverseAddresses.get(mid));
+          int cmp = scratch.compareTo(text);
+
+          if (cmp < 0) {
+            low = mid + 1;
+          } else if (cmp > 0) {
+            high = mid - 1;
+          } else {
+            return mid;
+          }
+        }
+        return high;
+      }
+
+      // binary search against first term in block range
+      // to find term's block
+      long binarySearchBlock(BytesRef text, long low, long high) throws IOException {
+        while (low <= high) {
+          long mid = (low + high) >>> 1;
+          input.seek(addresses.get(mid));
+          term.length = input.readVInt();
+          input.readBytes(term.bytes, 0, term.length);
+          int cmp = term.compareTo(text);
+
+          if (cmp < 0) {
+            low = mid + 1;
+          } else if (cmp > 0) {
+            high = mid - 1;
+          } else {
+            return mid;
+          }
+        }
+        return high;
+      }
+
+      @Override
+      public SeekStatus seekCeil(BytesRef text) throws IOException {
+        // locate block: narrow to block range with index, then search blocks
+        final long block;
+        long indexPos = binarySearchIndex(text);
+        if (indexPos < 0) {
+          block = 0;
+        } else {
+          long low = indexPos << BLOCK_INTERVAL_SHIFT;
+          long high = Math.min(numIndexValues - 1, low + BLOCK_INTERVAL_MASK);
+          block = Math.max(low, binarySearchBlock(text, low, high));
+        }
+
+        // position before block, then scan to term.
+        input.seek(addresses.get(block));
+        currentOrd = (block << INTERVAL_SHIFT) - 1;
+
+        while (next() != null) {
+          int cmp = term.compareTo(text);
+          if (cmp == 0) {
+            return SeekStatus.FOUND;
+          } else if (cmp > 0) {
+            return SeekStatus.NOT_FOUND;
+          }
+        }
+        return SeekStatus.END;
+      }
+
+      @Override
+      public void seekExact(long ord) throws IOException {
+        long block = ord >>> INTERVAL_SHIFT;
+        if (block != currentOrd >>> INTERVAL_SHIFT) {
+          // switch to different block
+          input.seek(addresses.get(block));
+          readHeader();
+        }
+
+        currentOrd = ord;
+
+        int offset = (int) (ord & INTERVAL_MASK);
+        if (offset == 0) {
+          readFirstTerm();
+        } else {
+          input.seek(currentBlockStart + offsets[offset-1]);
+          readTerm(offset);
+        }
+      }
+
+      @Override
+      public BytesRef term() throws IOException {
+        return term;
+      }
+
+      @Override
+      public long ord() throws IOException {
+        return currentOrd;
+      }
+
+      @Override
+      public int docFreq() throws IOException {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public long totalTermFreq() throws IOException {
+        return -1;
+      }
+
+      @Override
+      public PostingsEnum postings(PostingsEnum reuse, int flags) throws IOException {
+        throw new UnsupportedOperationException();
+      }
+
+    }
+  }
+}
diff --git lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/MetaSliceIndexInput.java lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/MetaSliceIndexInput.java
new file mode 100644
index 0000000..fbfe8ca
--- /dev/null
+++ lucene/codecs/src/java/org/apache/lucene/codecs/encrypted/docvalues/MetaSliceIndexInput.java
@@ -0,0 +1,76 @@
+package org.apache.lucene.codecs.encrypted.docvalues;
+
+/*
+ * 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.
+ */
+
+import java.io.IOException;
+
+import org.apache.lucene.store.IndexInput;
+
+/**
+ * Used by {@link EncryptedLucene54DocValuesProducer} to slice a {@link org.apache.lucene.store.ChecksumIndexInput}
+ * for the meta file.
+ */
+public class MetaSliceIndexInput extends IndexInput {
+
+  /** The wrapped index input */
+  private final IndexInput in;
+  /** The starting position */
+  private final long offset;
+
+  protected MetaSliceIndexInput(String resourceDescription, IndexInput in, long offset) {
+    super(resourceDescription);
+    this.offset = offset;
+    this.in = in;
+  }
+
+  @Override
+  public void close() throws IOException {
+    in.close();
+  }
+
+  @Override
+  public long getFilePointer() {
+    return in.getFilePointer() - offset;
+  }
+
+  @Override
+  public void seek(long pos) throws IOException {
+    in.seek(offset + pos);
+  }
+
+  @Override
+  public long length() {
+    return in.length() - offset;
+  }
+
+  @Override
+  public IndexInput slice(String sliceDescription, long offset, long length) throws IOException {
+    return in.slice(sliceDescription, offset + this.offset, length);
+  }
+
+  @Override
+  public byte readByte() throws IOException {
+    return in.readByte();
+  }
+
+  @Override
+  public void readBytes(byte[] b, int offset, int len) throws IOException {
+    in.readBytes(b, offset, len);
+  }
+
+}
diff --git lucene/codecs/src/resources/META-INF/services/org.apache.lucene.codecs.DocValuesFormat lucene/codecs/src/resources/META-INF/services/org.apache.lucene.codecs.DocValuesFormat
index 3e7164d..728f9c1 100644
--- lucene/codecs/src/resources/META-INF/services/org.apache.lucene.codecs.DocValuesFormat
+++ lucene/codecs/src/resources/META-INF/services/org.apache.lucene.codecs.DocValuesFormat
@@ -16,3 +16,4 @@
 org.apache.lucene.codecs.memory.MemoryDocValuesFormat
 org.apache.lucene.codecs.memory.DirectDocValuesFormat
 org.apache.lucene.codecs.simpletext.SimpleTextDocValuesFormat
+org.apache.lucene.codecs.encrypted.DummyEncryptedLucene60Codec$DummyEncryptedLucene54DocValuesFormat
diff --git lucene/codecs/src/test/META-INF/services/org.apache.lucene.codecs.DocValuesFormat lucene/codecs/src/test/META-INF/services/org.apache.lucene.codecs.DocValuesFormat
new file mode 100644
index 0000000..c4ce359
--- /dev/null
+++ lucene/codecs/src/test/META-INF/services/org.apache.lucene.codecs.DocValuesFormat
@@ -0,0 +1,33 @@
+#
+# 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.
+#
+
+#  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.
+
+org.apache.lucene.codecs.encrypted.RandomEncryptedLucene60Codec$RandomEncryptedLucene54DocValuesFormat
diff --git lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomCipherFactory.java lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomCipherFactory.java
index 68ddc20..8ea99be 100644
--- lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomCipherFactory.java
+++ lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomCipherFactory.java
@@ -65,8 +65,8 @@ class RandomCipherFactory implements CipherFactory {
   }
 
   @Override
-  public VersionedCipher newEncipherInstance() {
-    return newEncipherInstance(getEncipherVersion());
+  public VersionedCipher newEncipherInstance(boolean withPadding) {
+    return newEncipherInstance(getEncipherVersion(), withPadding);
   }
 
   @Override
@@ -79,10 +79,11 @@ class RandomCipherFactory implements CipherFactory {
     }
   }
 
-  private VersionedCipher newEncipherInstance(long version) {
+  private VersionedCipher newEncipherInstance(long version, boolean withPadding) {
     try {
       //Cipher
-      Cipher aesCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+      String cipherString = withPadding ? "AES/CBC/PKCS5Padding" : "AES/CBC/NoPadding";
+      Cipher aesCipher = Cipher.getInstance(cipherString);
       aesCipher.init(Cipher.ENCRYPT_MODE, this.aesKeys.get((int) version));
       return new VersionedCipher(aesCipher, version);
     } catch (Exception e) {
@@ -96,7 +97,7 @@ class RandomCipherFactory implements CipherFactory {
   }
 
   @Override
-  public VersionedCipher newDecipherInstance(long version) {
+  public VersionedCipher newDecipherInstance(long version, boolean withPadding) {
     // Generates the required key version
     while (version > currentVersion) {
       this.incrementVersion();
@@ -107,7 +108,8 @@ class RandomCipherFactory implements CipherFactory {
       byte[] iv = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
       IvParameterSpec ivspec = new IvParameterSpec(iv);
       //Cipher
-      Cipher aesCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+      String cipherString = withPadding ? "AES/CBC/PKCS5Padding" : "AES/CBC/NoPadding";
+      Cipher aesCipher = Cipher.getInstance(cipherString);
       aesCipher.init(Cipher.DECRYPT_MODE, this.aesKeys.get((int) version), ivspec);
       return new VersionedCipher(aesCipher, version);
     } catch (Exception e) {
diff --git lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomEncryptedLucene60Codec.java lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomEncryptedLucene60Codec.java
index 3581d34..6a2925f 100644
--- lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomEncryptedLucene60Codec.java
+++ lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/RandomEncryptedLucene60Codec.java
@@ -18,12 +18,15 @@ package org.apache.lucene.codecs.encrypted;
  */
 
 import com.carrotsearch.randomizedtesting.RandomizedTest;
+import org.apache.lucene.codecs.DocValuesFormat;
 import org.apache.lucene.codecs.PostingsFormat;
 import org.apache.lucene.codecs.StoredFieldsFormat;
 import org.apache.lucene.codecs.TermVectorsFormat;
 import org.apache.lucene.codecs.compressing.CompressionMode;
 import org.apache.lucene.codecs.encrypted.compressing.EncryptedCompressingStoredFieldsFormat;
+import org.apache.lucene.codecs.encrypted.docvalues.EncryptedLucene54DocValuesFormat;
 import org.apache.lucene.codecs.lucene50.Lucene50PostingsFormat;
+import org.apache.lucene.codecs.lucene54.Lucene54DocValuesFormat;
 import org.apache.lucene.util.BytesRef;
 
 import java.util.Random;
@@ -37,6 +40,8 @@ public class RandomEncryptedLucene60Codec extends EncryptedLucene60Codec {
   private final Lucene50PostingsFormat postingsFormat;
   private final EncryptedLucene50StoredFieldsFormat storedFieldsFormat;
   private final EncryptedLucene50TermVectorsFormat vectorsFormat;
+  private final EncryptedLucene54DocValuesFormat encryptedDocValuesFormat;
+  private final Lucene54DocValuesFormat docValuesFormat;
 
   private final RandomCipherFactory cipherFactory = new RandomCipherFactory(SEED);
   private final Random r = new Random();
@@ -50,6 +55,8 @@ public class RandomEncryptedLucene60Codec extends EncryptedLucene60Codec {
     this.encryptedPostingsFormat = new RandomEncryptedLucene50PostingsFormat(cipherFactory);
     this.storedFieldsFormat = new RandomEncryptedLucene50StoredFieldsFormat(cipherFactory);
     this.vectorsFormat = new RandomEncryptedLucene50TermVectorsFormat(cipherFactory);
+    this.docValuesFormat = new Lucene54DocValuesFormat();
+    this.encryptedDocValuesFormat = new RandomEncryptedLucene54DocValuesFormat(cipherFactory);
   }
 
   @Override
@@ -70,6 +77,18 @@ public class RandomEncryptedLucene60Codec extends EncryptedLucene60Codec {
   }
 
   @Override
+  public DocValuesFormat getDocValuesFormatForField(String field) {
+    r.setSeed(2^32 * new BytesRef(field).hashCode() + SEED);
+    boolean isEncrypted = true; //r.nextBoolean();
+    if (isEncrypted) {
+      return encryptedDocValuesFormat;
+    }
+    else {
+      return docValuesFormat;
+    }
+  }
+
+  @Override
   public EncryptedLucene50StoredFieldsFormat storedFieldsFormat() {
     return storedFieldsFormat;
   }
@@ -102,6 +121,28 @@ public class RandomEncryptedLucene60Codec extends EncryptedLucene60Codec {
 
   }
 
+  public static class RandomEncryptedLucene54DocValuesFormat extends EncryptedLucene54DocValuesFormat {
+
+    private CipherFactory cipherFactory = new RandomCipherFactory(SEED);
+
+    /**
+     * Default constructor used for reading. It will use the local {@link CipherFactory} instance.
+     */
+    public RandomEncryptedLucene54DocValuesFormat() {}
+
+    /**
+     * Constructor to pass the cipher factory instance that will be used for writing.
+     */
+    public RandomEncryptedLucene54DocValuesFormat(CipherFactory cipherFactory) {
+      this.cipherFactory = cipherFactory;
+    }
+
+    @Override
+    protected CipherFactory getCipherFactory() {
+      return cipherFactory;
+    }
+  }
+
   public static class RandomEncryptedLucene50StoredFieldsFormat extends EncryptedLucene50StoredFieldsFormat {
 
     private CipherFactory cipherFactory = new RandomCipherFactory(SEED);
diff --git lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/TestEncryptedIndexInput.java lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/TestEncryptedIndexInput.java
new file mode 100644
index 0000000..04bfee0
--- /dev/null
+++ lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/TestEncryptedIndexInput.java
@@ -0,0 +1,892 @@
+package org.apache.lucene.codecs.encrypted;
+
+/*
+ * 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.
+ */
+
+import javax.crypto.Cipher;
+import java.io.EOFException;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.file.NoSuchFileException;
+import java.util.Arrays;
+import java.util.Random;
+
+import org.apache.lucene.codecs.encrypted.docvalues.EncryptedIndexInput;
+import org.apache.lucene.codecs.encrypted.docvalues.EncryptedIndexOutput;
+import org.apache.lucene.store.BaseDirectoryWrapper;
+import org.apache.lucene.store.ByteArrayDataOutput;
+import org.apache.lucene.store.DataInput;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.store.MockDirectoryWrapper;
+import org.apache.lucene.store.RandomAccessInput;
+import org.apache.lucene.util.LuceneTestCase;
+import org.apache.lucene.util.TestUtil;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class TestEncryptedIndexInput extends LuceneTestCase {
+
+  static final byte[] READ_TEST_BYTES = new byte[] {
+      (byte) 0x80, 0x01,
+      (byte) 0xFF, 0x7F,
+      (byte) 0x80, (byte) 0x80, 0x01,
+      (byte) 0x81, (byte) 0x80, 0x01,
+      (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x07,
+      (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x0F,
+      (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x07,
+      (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x7F,
+      0x06, 'L', 'u', 'c', 'e', 'n', 'e',
+
+      // 2-byte UTF-8 (U+00BF "INVERTED QUESTION MARK")
+      0x02, (byte) 0xC2, (byte) 0xBF,
+      0x0A, 'L', 'u', (byte) 0xC2, (byte) 0xBF,
+      'c', 'e', (byte) 0xC2, (byte) 0xBF,
+      'n', 'e',
+
+      // 3-byte UTF-8 (U+2620 "SKULL AND CROSSBONES")
+      0x03, (byte) 0xE2, (byte) 0x98, (byte) 0xA0,
+      0x0C, 'L', 'u', (byte) 0xE2, (byte) 0x98, (byte) 0xA0,
+      'c', 'e', (byte) 0xE2, (byte) 0x98, (byte) 0xA0,
+      'n', 'e',
+
+      // surrogate pairs
+      // (U+1D11E "MUSICAL SYMBOL G CLEF")
+      // (U+1D160 "MUSICAL SYMBOL EIGHTH NOTE")
+      0x04, (byte) 0xF0, (byte) 0x9D, (byte) 0x84, (byte) 0x9E,
+      0x08, (byte) 0xF0, (byte) 0x9D, (byte) 0x84, (byte) 0x9E,
+      (byte) 0xF0, (byte) 0x9D, (byte) 0x85, (byte) 0xA0,
+      0x0E, 'L', 'u',
+      (byte) 0xF0, (byte) 0x9D, (byte) 0x84, (byte) 0x9E,
+      'c', 'e',
+      (byte) 0xF0, (byte) 0x9D, (byte) 0x85, (byte) 0xA0,
+      'n', 'e',
+
+      // null bytes
+      0x01, 0x00,
+      0x08, 'L', 'u', 0x00, 'c', 'e', 0x00, 'n', 'e',
+
+      // tests for Exceptions on invalid values
+      (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x17,
+      (byte) 0x01, // guard value
+      (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF,
+      (byte) 0x01, // guard value
+  };
+
+  static final int COUNT = RANDOM_MULTIPLIER * 65536;
+  static int[] INTS;
+  static long[] LONGS;
+  static byte[] RANDOM_TEST_BYTES;
+
+  @BeforeClass
+  public static void beforeClass() throws IOException {
+    Random random = random();
+    INTS = new int[COUNT];
+    LONGS = new long[COUNT];
+    RANDOM_TEST_BYTES = new byte[COUNT * (5 + 4 + 9 + 8)];
+    final ByteArrayDataOutput bdo = new ByteArrayDataOutput(RANDOM_TEST_BYTES);
+
+    for (int i = 0; i < COUNT; i++) {
+      final int i1 = INTS[i] = random.nextInt();
+      bdo.writeVInt(i1);
+      bdo.writeInt(i1);
+
+      final long l1;
+      if (rarely()) {
+        // a long with lots of zeroes at the end
+        l1 = LONGS[i] = TestUtil.nextLong(random, 0, Integer.MAX_VALUE) << 32;
+      } else {
+        l1 = LONGS[i] = TestUtil.nextLong(random, 0, Long.MAX_VALUE);
+      }
+      bdo.writeVLong(l1);
+      bdo.writeLong(l1);
+    }
+  }
+
+  @AfterClass
+  public static void afterClass() {
+    INTS = null;
+    LONGS = null;
+    RANDOM_TEST_BYTES = null;
+  }
+
+  public TestEncryptedIndexInput() {
+    EncryptedIndexOutput.N_BLOCKS = random().nextInt(64) + 1;
+  }
+
+  private void checkReads(DataInput is, Class<? extends Exception> expectedEx) throws IOException {
+    assertEquals(128,is.readVInt());
+    assertEquals(16383,is.readVInt());
+    assertEquals(16384,is.readVInt());
+    assertEquals(16385,is.readVInt());
+    assertEquals(Integer.MAX_VALUE, is.readVInt());
+    assertEquals(-1, is.readVInt());
+    assertEquals((long) Integer.MAX_VALUE, is.readVLong());
+    assertEquals(Long.MAX_VALUE, is.readVLong());
+    assertEquals("Lucene",is.readString());
+
+    assertEquals("\u00BF",is.readString());
+    assertEquals("Lu\u00BFce\u00BFne",is.readString());
+
+    assertEquals("\u2620",is.readString());
+    assertEquals("Lu\u2620ce\u2620ne",is.readString());
+
+    assertEquals("\uD834\uDD1E",is.readString());
+    assertEquals("\uD834\uDD1E\uD834\uDD60",is.readString());
+    assertEquals("Lu\uD834\uDD1Ece\uD834\uDD60ne",is.readString());
+
+    assertEquals("\u0000",is.readString());
+    assertEquals("Lu\u0000ce\u0000ne",is.readString());
+
+    try {
+      is.readVInt();
+      fail("Should throw " + expectedEx.getName());
+    } catch (Exception e) {
+      assertTrue(e.getMessage().startsWith("Invalid vInt"));
+      assertTrue(expectedEx.isInstance(e));
+    }
+    assertEquals(1, is.readVInt()); // guard value
+
+    try {
+      is.readVLong();
+      fail("Should throw " + expectedEx.getName());
+    } catch (Exception e) {
+      assertTrue(e.getMessage().startsWith("Invalid vLong"));
+      assertTrue(expectedEx.isInstance(e));
+    }
+    assertEquals(1L, is.readVLong()); // guard value
+  }
+
+  private IndexOutput getEncryptedIndexOutput(Directory dir, String name, Random random, DummyCipherFactory cipherFactory) throws IOException {
+    final IndexOutput out = dir.createOutput(name, newIOContext(random));
+    return new EncryptedIndexOutput(out, cipherFactory);
+  }
+
+  private IndexInput getEncryptedIndexInput(Directory dir, String name, Random random, DummyCipherFactory cipherFactory) throws IOException {
+    final IndexInput input = dir.openInput(name, newIOContext(random));
+    return new EncryptedIndexInput(input, cipherFactory, 0);
+  }
+
+  public void testSimple() throws IOException {
+    DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    Random random = random();
+    try (Directory dir = newDirectory()) {
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeString("test");
+      }
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        assertEquals("test", is.readString());
+      }
+    }
+  }
+
+  // this test checks the IndexInput methods of any impl
+  public void testRawIndexInputRead() throws IOException {
+    DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    for (int i = 0; i < 10; i++) {
+      Random random = random();
+      try (Directory dir = newDirectory()) {
+        try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+          os.writeBytes(READ_TEST_BYTES, READ_TEST_BYTES.length);
+        }
+
+        try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+          checkReads(is, IOException.class);
+        }
+      }
+    }
+  }
+
+  public void testBlocksOverlap() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[2 * cipher.getBlockSize() - 4];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        for (int i = 0; i < bytes.length; i++) {
+          assertEquals(bytes[i], is.readByte());
+        }
+      }
+    }
+  }
+
+  public void testBlockMultiple() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[EncryptedIndexOutput.N_BLOCKS * cipher.getBlockSize()];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        for (int i = 0; i < bytes.length; i++) {
+          assertEquals(bytes[i], is.readByte());
+        }
+        try {
+          is.readByte();
+          fail("Read past EOF");
+        } catch (EOFException e) {
+        }
+      }
+    }
+  }
+
+  public void testBoundaries() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[EncryptedIndexOutput.N_BLOCKS * cipher.getBlockSize() + 1];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length - 1);
+        os.writeByte(bytes[bytes.length - 1]);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        for (int i = 0; i < bytes.length; i++) {
+          assertEquals("i=" + i, bytes[i], is.readByte());
+        }
+      }
+    }
+  }
+
+  public void testSeekInSameBlock() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[EncryptedIndexOutput.N_BLOCKS * cipher.getBlockSize() + 1];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        final int seek = cipher.getBlockSize() - 3;
+        assertEquals(bytes[0], is.readByte());
+        is.seek(seek);
+        final byte[] bytes2 = new byte[cipher.getBlockSize()];
+        is.readBytes(bytes2, 0, bytes2.length);
+        assertArrayEquals(Arrays.copyOfRange(bytes, seek, seek + bytes2.length), bytes2);
+      }
+    }
+  }
+
+  public void testSeek() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[3 * cipher.getBlockSize()];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        for (int i = 0; i < bytes.length / 2; i++) {
+          assertEquals(bytes[i], is.readByte());
+        }
+        // seek in same block
+        is.seek(28);
+        assertEquals(bytes[28], is.readByte());
+        // seek backward
+        is.seek(2);
+        assertEquals(bytes[2], is.readByte());
+        // seek forward
+        is.seek(42);
+        assertEquals(bytes[42], is.readByte());
+
+        // seek on block multiple
+        is.seek(32);
+        assertEquals(bytes[32], is.readByte());
+        is.seek(47);
+        assertEquals(bytes[47], is.readByte());
+        is.seek(16);
+        assertEquals(bytes[16], is.readByte());
+        is.seek(0);
+        assertEquals(bytes[0], is.readByte());
+      }
+    }
+  }
+
+  public void testSeekAtTheEndWithPadding() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[2 * cipher.getBlockSize() - 4];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        is.seek(2);
+        assertEquals(bytes[2], is.readByte());
+
+        is.seek(24);
+        assertEquals(bytes[24], is.readByte());
+
+        is.seek(4);
+        assertEquals(bytes[4], is.readByte());
+
+        for (int i = 5; i < bytes.length; i++) {
+          assertEquals(bytes[i], is.readByte());
+        }
+      }
+    }
+  }
+
+  public void testReadBytes() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[3 * cipher.getBlockSize()];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        final byte[] b = new byte[2 * cipher.getBlockSize() - 2];
+
+        is.readBytes(b, 0, b.length);
+        for (int i = 0; i < b.length; i++) {
+          assertEquals(bytes[i], b[i]);
+        }
+      }
+    }
+  }
+
+  public void testSte() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[2 * EncryptedIndexOutput.N_BLOCKS * cipher.getBlockSize()];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        final IndexInput slice1 = is.slice("slice1", 0, bytes.length / 2);
+        final IndexInput slice2 = is.slice("slice2", bytes.length / 2, bytes.length / 2);
+
+        for (int i = 0; i < bytes.length / 2; i++) {
+          assertEquals(bytes[i], slice1.readByte());
+          assertEquals(bytes[i + bytes.length / 2], slice2.readByte());
+        }
+      }
+    }
+  }
+
+  public void testLastBytePadding() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[cipher.getBlockSize() - 1];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        for (int i = 0; i < bytes.length; i++) {
+          assertEquals(bytes[i], is.readByte());
+        }
+      }
+    }
+  }
+
+  public void testSlice() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[2 * cipher.getBlockSize()];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        final IndexInput slice1 = is.slice("slice 1", 0, cipher.getBlockSize());
+        final IndexInput slice2 = is.slice("slice 2", cipher.getBlockSize(), cipher.getBlockSize());
+
+        for (int i = 0; i < cipher.getBlockSize(); i++) {
+          assertEquals(bytes[i], slice1.readByte());
+        }
+        try {
+          slice1.readByte();
+          fail("Read past EOF");
+        } catch (EOFException e) {
+        }
+
+        for (int i = 0; i < cipher.getBlockSize(); i++) {
+          assertEquals(bytes[i + cipher.getBlockSize()], slice2.readByte());
+        }
+        try {
+          slice2.readByte();
+          fail("Read past EOF");
+        } catch (EOFException e) {
+        }
+      }
+    }
+  }
+
+  public void testSliceWithPadding() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[2 * cipher.getBlockSize() - 1];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        final IndexInput slice1 = is.slice("slice 1", 5, cipher.getBlockSize());
+
+        for (int i = 0; i < cipher.getBlockSize(); i++) {
+          assertEquals(bytes[i + 5], slice1.readByte());
+        }
+        try {
+          slice1.readByte();
+          fail("Read past EOF");
+        } catch (EOFException e) {
+        }
+      }
+    }
+  }
+
+  public void testSliceReadBytes() throws IOException {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final byte[] bytes = new byte[cipher.getBlockSize()];
+
+      random.nextBytes(bytes);
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        final IndexInput slice1 = is.slice("slice 1", 5, 5);
+
+        try {
+          slice1.readBytes(new byte[6], 0, 6);
+          fail("Read past EOF");
+        } catch (EOFException e) {
+        }
+
+        final byte[] b = new byte[4];
+        slice1.seek(1);
+        slice1.readBytes(b, 0, 4);
+        for (int i = 0; i < b.length; i++) {
+          assertEquals(bytes[i + 6], b[i]);
+        }
+      }
+    }
+  }
+
+  /**
+   * Copied from BaseDirectoryTestCase: LUCENE-2852
+   */
+  public void testSeekToEOFThenBack() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+    final int BUFFER_SIZE = 1024;
+
+    try (Directory dir = newDirectory()) {
+      final byte[] bytes = new byte[3 * BUFFER_SIZE];
+
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        is.seek(2 * BUFFER_SIZE - 1);
+        is.seek(3 * BUFFER_SIZE);
+        is.seek(BUFFER_SIZE);
+        is.readBytes(bytes, 0, 2 * BUFFER_SIZE);
+      }
+    }
+  }
+
+  /**
+   * Copied from BaseDirectoryTestCase
+   */
+  public void testSeekPastEOF() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final int blockSize = EncryptedIndexOutput.N_BLOCKS * cipher.getBlockSize();
+      final int len = random().nextInt(2048) / blockSize * blockSize;
+      final byte[] bytes = new byte[len];
+
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        try {
+          is.seek(len + random().nextInt(2048));
+          is.readByte();
+          fail("Did not get EOFException");
+        } catch (EOFException eof) {
+          // pass
+        }
+      }
+    }
+  }
+
+  /**
+   * Copied from BaseDirectoryTestCase
+   */
+  public void testSliceOutOfBounds() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final Cipher cipher = cipherFactory.newEncipherInstance(false).getCipher();
+      final int blockSize = EncryptedIndexOutput.N_BLOCKS * cipher.getBlockSize();
+      final int len = atLeast(blockSize) / blockSize * blockSize;
+      final byte[] bytes = new byte[len];
+
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput is = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        try {
+          IndexInput i = is.slice("slice1", 0, len + 1);
+          fail("Did not get IllegalArgumentException: " + i + " length=" + len);
+        } catch (IllegalArgumentException iae) {
+          // pass
+        }
+        try {
+          is.slice("slice2", -1, len);
+          fail("Did not get IllegalArgumentException");
+        } catch (IllegalArgumentException iae) {
+          // pass
+        }
+        IndexInput slice = is.slice("slice3", 4, len / 2);
+        try {
+          slice.slice("slice3sub", 1, len / 2);
+          fail("Did not get IllegalArgumentException");
+        } catch (IllegalArgumentException iae) {
+          // pass
+        }
+      }
+    }
+  }
+
+  /**
+   * Copied from BaseDirectoryTestCase
+   */
+  public void testRandomInt() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final int num = TestUtil.nextInt(random(), 50, 3000);
+      final int ints[] = new int[num];
+
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        for (int i = 0; i < ints.length; i++) {
+          ints[i] = random().nextInt();
+          os.writeInt(ints[i]);
+        }
+      }
+
+      try (IndexInput input = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        RandomAccessInput slice = input.randomAccessSlice(0, input.length());
+        for (int i = 0; i < ints.length; i++) {
+          assertEquals("i=" + i, ints[i], slice.readInt(i * 4));
+        }
+
+        // subslices
+        for (int i = 1; i < ints.length; i++) {
+          long offset = i * 4;
+          RandomAccessInput subslice = input.randomAccessSlice(offset, input.length() - offset);
+          for (int j = i; j < ints.length; j++) {
+            assertEquals(ints[j], subslice.readInt((j - i) * 4));
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Copied from BaseDirectoryTestCase
+   */
+  public void testRandomShort() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final int num = TestUtil.nextInt(random(), 50, 3000);
+      final short shorts[] = new short[num];
+
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        for (int i = 0; i < shorts.length; i++) {
+          shorts[i] = (short) random().nextInt();
+          os.writeShort(shorts[i]);
+        }
+      }
+
+      try (IndexInput input = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        // slice
+        RandomAccessInput slice = input.randomAccessSlice(0, input.length());
+        for (int i = 0; i < shorts.length; i++) {
+          assertEquals(shorts[i], slice.readShort(i * 2));
+        }
+
+        // subslices
+        for (int i = 1; i < shorts.length; i++) {
+          long offset = i * 2;
+          RandomAccessInput subslice = input.randomAccessSlice(offset, input.length() - offset);
+          for (int j = i; j < shorts.length; j++) {
+            assertEquals(shorts[j], subslice.readShort((j - i) * 2));
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Copied from BaseDirectoryTestCase
+   */
+  public void testRandomByte() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final int num = TestUtil.nextInt(random(), 50, 3000);
+      final byte bytes[] = new byte[num];
+      random().nextBytes(bytes);
+
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        for (byte b: bytes) {
+          os.writeByte(b);
+        }
+      }
+
+      try (IndexInput input = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        // slice
+        RandomAccessInput slice = input.randomAccessSlice(0, input.length());
+        for (int i = 0; i < bytes.length; i++) {
+          assertEquals(bytes[i], slice.readByte(i));
+        }
+
+        // subslices
+        for (int i = 1; i < bytes.length; i++) {
+          int offset = i;
+          RandomAccessInput subslice = input.randomAccessSlice(offset, input.length() - offset);
+          for (int j = i; j < bytes.length; j++) {
+            assertEquals(bytes[j], subslice.readByte(j - i));
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Copied from BaseDirectoryTestCase
+   * try to stress slices of slices
+   */
+  public void testSliceOfSlice() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+    final Random random = random();
+
+    try (Directory dir = newDirectory()) {
+      final int num;
+      if (TEST_NIGHTLY) {
+        num = TestUtil.nextInt(random(), 250, 2500);
+      } else {
+        num = TestUtil.nextInt(random(), 50, 250);
+      }
+      byte bytes[] = new byte[num];
+      random().nextBytes(bytes);
+
+      try (IndexOutput os = getEncryptedIndexOutput(dir, "foo", random, cipherFactory)) {
+        os.writeBytes(bytes, bytes.length);
+      }
+
+      try (IndexInput input = getEncryptedIndexInput(dir, "foo", random, cipherFactory)) {
+        // seek to a random spot shouldnt impact slicing.
+        input.seek(TestUtil.nextLong(random(), 0, input.length()));
+        for (int i = 0; i < num; i += 16) {
+          IndexInput slice1 = input.slice("slice1", i, num-i);
+          assertEquals(slice1.toString(), 0, slice1.getFilePointer());
+          assertEquals(num-i, slice1.length());
+
+          // seek to a random spot shouldnt impact slicing.
+          slice1.seek(TestUtil.nextLong(random(), 0, slice1.length()));
+          for (int j = 0; j < slice1.length(); j += 16) {
+            IndexInput slice2 = slice1.slice("slice2", j, num-i-j);
+            assertEquals(0, slice2.getFilePointer());
+            assertEquals(num-i-j, slice2.length());
+            byte data[] = new byte[num];
+            System.arraycopy(bytes, 0, data, 0, i+j);
+            if (random().nextBoolean()) {
+              // read the bytes for this slice-of-slice
+              slice2.readBytes(data, i+j, num-i-j);
+            } else {
+              // seek to a random spot in between, read some, seek back and read the rest
+              long seek = TestUtil.nextLong(random(), 0, slice2.length());
+              slice2.seek(seek);
+              slice2.readBytes(data, (int)(i+j+seek), (int)(num-i-j-seek));
+              slice2.seek(0);
+              slice2.readBytes(data, i+j, (int)seek);
+            }
+            assertArrayEquals("i=" + i + " j=" + j, bytes, data);
+          }
+        }
+      }
+    }
+  }
+
+  public void testThreadSafety() throws Exception {
+    final DummyCipherFactory cipherFactory = new DummyCipherFactory();
+
+    try (Directory dir = newDirectory()) {
+      if (dir instanceof BaseDirectoryWrapper) {
+        ((BaseDirectoryWrapper)dir).setCheckIndexOnClose(false); // we arent making an index
+      }
+      if (dir instanceof MockDirectoryWrapper) {
+        ((MockDirectoryWrapper)dir).setThrottling(MockDirectoryWrapper.Throttling.NEVER); // makes this test really slow
+      }
+
+      class TheThread extends Thread {
+        private String name;
+
+        public TheThread(String name) {
+          this.name = name;
+        }
+
+        @Override
+        public void run() {
+          for (int i = 0; i < 1000; i++) {
+            String fileName = this.name + i;
+            try {
+              IndexOutput output = getEncryptedIndexOutput(dir, fileName, random(), cipherFactory);
+              output.close();
+              assertTrue(slowFileExists(dir, fileName));
+            } catch (IOException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        }
+      }
+
+      class TheThread2 extends Thread {
+        private String name;
+        private volatile boolean stop;
+
+        public TheThread2(String name) {
+          this.name = name;
+        }
+
+        @Override
+        public void run() {
+          while (stop == false) {
+            try {
+              String[] files = dir.listAll();
+              for (String file : files) {
+                if (!file.startsWith(name)) {
+                  continue;
+                }
+                try {
+                  IndexInput input = getEncryptedIndexInput(dir, file, random(), cipherFactory);
+                  input.close();
+                } catch (FileNotFoundException | NoSuchFileException e) {
+                  // ignore
+                } catch (IOException e) {
+                  if (e.getMessage() != null && e.getMessage().contains("still open for writing")) {
+                    // ignore
+                  } else {
+                    throw new RuntimeException(e);
+                  }
+                }
+                if (random().nextBoolean()) {
+                  break;
+                }
+              }
+            } catch (IOException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        }
+      }
+
+      TheThread theThread = new TheThread("t1");
+      TheThread2 theThread2 = new TheThread2("t2");
+      theThread.start();
+      theThread2.start();
+
+      theThread.join();
+
+      // after first thread is done, no sense in waiting on thread 2
+      // to listFiles() and loop over and over
+      theThread2.stop = true;
+      theThread2.join();
+    }
+  }
+}
diff --git lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/TestEncryptedLucene54DocValuesFormat.java lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/TestEncryptedLucene54DocValuesFormat.java
new file mode 100644
index 0000000..0c9778d
--- /dev/null
+++ lucene/codecs/src/test/org/apache/lucene/codecs/encrypted/TestEncryptedLucene54DocValuesFormat.java
@@ -0,0 +1,37 @@
+package org.apache.lucene.codecs.encrypted;
+
+/*
+ * 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.
+ */
+
+import org.apache.lucene.codecs.Codec;
+import org.apache.lucene.codecs.encrypted.docvalues.EncryptedIndexOutput;
+import org.apache.lucene.index.BaseDocValuesFormatTestCase;
+
+public class TestEncryptedLucene54DocValuesFormat extends BaseDocValuesFormatTestCase {
+
+  private final Codec codec = new RandomEncryptedLucene60Codec();
+
+  public TestEncryptedLucene54DocValuesFormat() {
+    EncryptedIndexOutput.N_BLOCKS = random().nextInt(64) + 1;
+  }
+
+  @Override
+  protected Codec getCodec() {
+    return codec;
+  }
+
+}
