blob: 84cc9a261d8241f25139f43e983f110c35c8cf10 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.encryption;
import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.index.IndexFileNames;
import org.apache.lucene.index.SegmentCommitInfo;
import org.apache.lucene.index.SegmentInfos;
import org.apache.lucene.store.DataInput;
import org.apache.lucene.store.DataOutput;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.util.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.solr.encryption.crypto.AesCtrEncrypter;
import org.apache.solr.encryption.crypto.AesCtrEncrypterFactory;
import org.apache.solr.encryption.crypto.DecryptingIndexInput;
import org.apache.solr.encryption.crypto.EncryptingIndexOutput;
import javax.annotation.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.nio.file.NoSuchFileException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.apache.solr.encryption.EncryptionUtil.*;
/**
* {@link FilterDirectory} that wraps a delegate {@link Directory} to encrypt/decrypt files on the fly.
* <p>
* When opening an {@link IndexOutput} for writing:
* <br>If {@link KeyManager#isEncryptable(String)} returns true, and if there is an
* {@link EncryptionUtil#getActiveKeyRefFromCommit(Map) active encryption key} defined in the latest
* commit user data, then the output is wrapped with a {@link EncryptingIndexOutput} to be encrypted
* on the fly. In this case an {@link #ENCRYPTION_MAGIC} header is written at the beginning of the output,
* followed by the key reference number.
* Otherwise, the {@link IndexOutput} created by the delegate is directly provided without encryption.
* <p>
* When opening an {@link IndexInput} for reading:
* <br>If the input header is the {@link #ENCRYPTION_MAGIC}, then the key reference number that follows
* is used to {@link EncryptionUtil#getKeyIdFromCommit get} the key id from the latest commit user data.
* In this case the input is wrapped with a {@link DecryptingIndexInput} to be decrypted on the fly.
* Otherwise, the {@link IndexInput} created by the delegate is directly provided without decryption.
*
* @see EncryptingIndexOutput
* @see DecryptingIndexInput
*/
public class EncryptionDirectory extends FilterDirectory {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
/**
* Constant to identify the start of an encrypted file.
* It is different from {@link CodecUtil#CODEC_MAGIC} to detect when a file is encrypted.
*/
public static final int ENCRYPTION_MAGIC = 0x2E5BF271; // 777777777 in decimal
protected final AesCtrEncrypterFactory encrypterFactory;
protected final KeyManager keyManager;
/** Cache of the latest commit user data. */
protected volatile CommitUserData commitUserData;
/** Optimization flag to avoid checking encryption when reading a file if we know the index is cleartext. */
protected volatile boolean shouldCheckEncryptionWhenReading;
/** Optimization flag to only read the commit user data once after a commit. */
protected volatile boolean shouldReadCommitUserData;
/**
* Creates an {@link EncryptionDirectory} which wraps a delegate {@link Directory} to encrypt/decrypt
* files on the fly.
*
* @param encrypterFactory creates {@link AesCtrEncrypter}.
* @param keyManager provides key secrets and determines which files are encryptable.
*/
public EncryptionDirectory(Directory delegate, AesCtrEncrypterFactory encrypterFactory, KeyManager keyManager)
throws IOException {
super(delegate);
this.encrypterFactory = encrypterFactory;
this.keyManager = keyManager;
commitUserData = readLatestCommitUserData();
// If there is no encryption key id parameter in the latest commit user data, then we know the index
// is cleartext, so we can skip fast any encryption check. This flag becomes true indefinitely if we
// detect an encryption key when opening a file for writing.
shouldCheckEncryptionWhenReading = hasKeyIdInCommit(commitUserData.data);
}
@Override
public IndexOutput createOutput(String fileName, IOContext context) throws IOException {
return maybeWrapOutput(in.createOutput(fileName, context));
}
@Override
public IndexOutput createTempOutput(String prefix, String suffix, IOContext context) throws IOException {
return maybeWrapOutput(in.createTempOutput(prefix, suffix, context));
}
/**
* Maybe wraps the {@link IndexOutput} created by the delegate {@link Directory} with an
* {@link EncryptingIndexOutput}.
*/
protected IndexOutput maybeWrapOutput(IndexOutput indexOutput) throws IOException {
String fileName = indexOutput.getName();
assert !fileName.startsWith(IndexFileNames.SEGMENTS);
if (fileName.startsWith(IndexFileNames.PENDING_SEGMENTS)) {
// The pending_segments file should not be encrypted. Do not wrap the IndexOutput.
// It also means a commit has started, so set the flag to read the commit user data
// next time we need it.
shouldReadCommitUserData = true;
return indexOutput;
}
if (!keyManager.isEncryptable(fileName)) {
// The file should not be encrypted, based on its name. Do not wrap the IndexOutput.
return indexOutput;
}
boolean success = false;
try {
String keyRef = getKeyRefForWriting(indexOutput);
if (keyRef != null) {
// The IndexOutput has to be wrapped to be encrypted with the key.
indexOutput = new EncryptingIndexOutput(indexOutput, getKeySecret(keyRef), encrypterFactory);
}
success = true;
} finally {
if (!success) {
// Something went wrong. Close the IndexOutput before the exception continues.
IOUtils.closeWhileHandlingException(indexOutput);
}
}
return indexOutput;
}
/**
* Gets the active key reference number for writing an index output.
* <p>
* The active key ref is defined in the user data of the latest commit. If it is present, then this method
* writes to the output the {@link #ENCRYPTION_MAGIC} header, followed by the key reference number as a
* 4B big-endian int.
*
* @return the key reference number; or null if none.
*/
protected String getKeyRefForWriting(IndexOutput indexOutput) throws IOException {
String keyRef;
if ((keyRef = getActiveKeyRefFromCommit(getLatestCommitData().data)) == null) {
return null;
}
shouldCheckEncryptionWhenReading = true;
// Write the encryption magic header and the key reference number.
writeBEInt(indexOutput, ENCRYPTION_MAGIC);
writeBEInt(indexOutput, Integer.parseInt(keyRef));
return keyRef;
}
/** Write int value on header / footer with big endian order. See readBEInt. */
private static void writeBEInt(DataOutput out, int i) throws IOException {
out.writeByte((byte) (i >> 24));
out.writeByte((byte) (i >> 16));
out.writeByte((byte) (i >> 8));
out.writeByte((byte) i);
}
/**
* Gets the user data from the latest commit, potentially reading the latest commit if the cache is stale.
*/
protected CommitUserData getLatestCommitData() throws IOException {
if (shouldReadCommitUserData) {
synchronized (this) {
if (shouldReadCommitUserData) {
CommitUserData newCommitUserData = readLatestCommitUserData();
if (newCommitUserData != commitUserData) {
commitUserData = newCommitUserData;
shouldReadCommitUserData = false;
}
}
}
}
return commitUserData;
}
/**
* Reads the user data from the latest commit, or keeps the cached value if the segments file name has
* not changed.
*/
protected CommitUserData readLatestCommitUserData() throws IOException {
try {
return new SegmentInfos.FindSegmentsFile<CommitUserData>(this) {
protected CommitUserData doBody(String segmentFileName) throws IOException {
if (commitUserData != null && commitUserData.segmentFileName.equals(segmentFileName)) {
// If the segments file is the same, then keep the same commit user data.
return commitUserData;
}
// New segments file, so we have to read it.
SegmentInfos segmentInfos = SegmentInfos.readCommit(EncryptionDirectory.this, segmentFileName);
return new CommitUserData(segmentFileName, segmentInfos.getUserData());
}
}.run();
} catch (NoSuchFileException | FileNotFoundException e) {
// No commit yet, so no encryption key.
return CommitUserData.EMPTY;
}
}
/**
* Gets the key secret from the provided key reference number.
* First, gets the key id corresponding to the key reference based on the mapping defined in the latest
* commit user data. Then, calls the {@link KeyManager} to get the corresponding key secret.
*/
protected byte[] getKeySecret(String keyRef) throws IOException {
String keyId = getKeyIdFromCommit(keyRef, getLatestCommitData().data);
return keyManager.getKeySecret(keyId, keyRef, this::getKeyCookie);
}
/**
* Gets the key cookie to provide to the {@link KeyManager} to get the key secret.
*
* @return the key cookie bytes; or null if none.
*/
@Nullable
protected byte[] getKeyCookie(String keyRef) {
return getKeyCookieFromCommit(keyRef, commitUserData.data);
}
@Override
public IndexInput openInput(String fileName, IOContext context) throws IOException {
IndexInput indexInput = in.openInput(fileName, context);
if (!shouldCheckEncryptionWhenReading) {
// Return the IndexInput directly as we know it is not encrypted.
return indexInput;
}
boolean success = false;
try {
String keyRef = getKeyRefForReading(indexInput);
if (keyRef != null) {
// The IndexInput has to be wrapped to be decrypted with the key.
indexInput = new DecryptingIndexInput(indexInput, getKeySecret(keyRef), encrypterFactory);
}
success = true;
} finally {
if (!success) {
// Something went wrong. Close the IndexInput before the exception continues.
IOUtils.closeWhileHandlingException(indexInput);
}
}
return indexInput;
}
/**
* Gets the key reference number for reading an index input.
* <p>
* If the file is ciphered, it starts with the {@link #ENCRYPTION_MAGIC} header, followed by the reference
* number as a 4B big-endian int.
* If the file is cleartext, it starts with the {@link CodecUtil#CODEC_MAGIC} header.
*
* @return the key reference number; or null if none.
*/
protected String getKeyRefForReading(IndexInput indexInput) throws IOException {
long filePointer = indexInput.getFilePointer();
int magic = readBEInt(indexInput);
if (magic == ENCRYPTION_MAGIC) {
// This file is encrypted.
// Read the key reference that follows.
return Integer.toString(readBEInt(indexInput));
} else {
// This file is cleartext.
// Restore the file pointer.
indexInput.seek(filePointer);
return null;
}
}
/**
* Read int value from header / footer with big endian order.
* We force big endian order when reading a codec. See CodecUtil.readBEInt in Lucene 9.0 or above.
*/
private static int readBEInt(DataInput in) throws IOException {
return ((in.readByte() & 0xFF) << 24)
| ((in.readByte() & 0xFF) << 16)
| ((in.readByte() & 0xFF) << 8)
| (in.readByte() & 0xFF);
}
/**
* Returns the segments having an encryption key id different from the active one.
*
* @param activeKeyId the current active key id, or null if none.
* @return the segments with old key ids, or an empty list if none.
*/
public List<SegmentCommitInfo> getSegmentsWithOldKeyId(SegmentInfos segmentInfos, String activeKeyId)
throws IOException {
List<SegmentCommitInfo> segmentsWithOldKeyId = null;
if (log.isDebugEnabled()) {
log.debug("reading segments {} for key ids different from {}",
segmentInfos.asList().stream().map(i -> i.info.name).collect(Collectors.toList()),
activeKeyId);
}
for (SegmentCommitInfo segmentCommitInfo : segmentInfos) {
for (String fileName : segmentCommitInfo.files()) {
if (keyManager.isEncryptable(fileName)) {
try (IndexInput fileInput = in.openInput(fileName, IOContext.READ)) {
String keyRef = getKeyRefForReading(fileInput);
String keyId = keyRef == null ? null : getKeyIdFromCommit(keyRef, segmentInfos.getUserData());
log.debug("reading file {} of segment {} => keyId={}", fileName, segmentCommitInfo.info.name, keyId);
if (!Objects.equals(keyId, activeKeyId)) {
if (segmentsWithOldKeyId == null) {
segmentsWithOldKeyId = new ArrayList<>();
}
segmentsWithOldKeyId.add(segmentCommitInfo);
}
}
break;
}
}
}
return segmentsWithOldKeyId == null ? Collections.emptyList() : segmentsWithOldKeyId;
}
/**
* Keeps the {@link SegmentInfos commit} file name and user data.
*/
protected static class CommitUserData {
protected static final CommitUserData EMPTY = new CommitUserData("", Collections.emptyMap());
public final String segmentFileName;
public final Map<String, String> data;
protected CommitUserData(String segmentFileName, Map<String, String> data) {
this.segmentFileName = segmentFileName;
this.data = data;
}
}
}