
org.elasticsearch.common.settings.KeyStoreWrapper Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of elasticsearch Show documentation
Show all versions of elasticsearch Show documentation
Elasticsearch - Open Source, Distributed, RESTful Search Engine
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch 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.elasticsearch.common.settings;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.security.auth.DestroyFailedException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.CharBuffer;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.store.BufferedChecksumIndexInput;
import org.apache.lucene.store.ChecksumIndexInput;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.store.SimpleFSDirectory;
import org.apache.lucene.util.SetOnce;
/**
* A wrapper around a Java KeyStore which provides supplements the keystore with extra metadata.
*
* Loading a keystore has 2 phases. First, call {@link #load(Path)}. Then call
* {@link #decrypt(char[])} with the keystore password, or an empty char array if
* {@link #hasPassword()} is {@code false}. Loading and decrypting should happen
* in a single thread. Once decrypted, keys may be read with the wrapper in
* multiple threads.
*/
public class KeyStoreWrapper implements SecureSettings {
/** An identifier for the type of data that may be stored in a keystore entry. */
private enum KeyType {
STRING,
FILE
}
/** The name of the keystore file to read and write. */
private static final String KEYSTORE_FILENAME = "elasticsearch.keystore";
/** The version of the metadata written before the keystore data. */
private static final int FORMAT_VERSION = 2;
/** The oldest metadata format version that can be read. */
private static final int MIN_FORMAT_VERSION = 1;
/** The keystore type for a newly created keystore. */
private static final String NEW_KEYSTORE_TYPE = "PKCS12";
/** The algorithm used to store string setting contents. */
private static final String NEW_KEYSTORE_STRING_KEY_ALGO = "PBE";
/** The algorithm used to store file setting contents. */
private static final String NEW_KEYSTORE_FILE_KEY_ALGO = "PBE";
/** An encoder to check whether string values are ascii. */
private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder();
/** The metadata format version used to read the current keystore wrapper. */
private final int formatVersion;
/** True iff the keystore has a password needed to read. */
private final boolean hasPassword;
/** The type of the keystore, as passed to {@link java.security.KeyStore#getInstance(String)} */
private final String type;
/** A factory necessary for constructing instances of string secrets in a {@link KeyStore}. */
private final SecretKeyFactory stringFactory;
/** A factory necessary for constructing instances of file secrets in a {@link KeyStore}. */
private final SecretKeyFactory fileFactory;
/**
* The settings that exist in the keystore, mapped to their type of data.
*/
private final Map settingTypes;
/** The raw bytes of the encrypted keystore. */
private final byte[] keystoreBytes;
/** The loaded keystore. See {@link #decrypt(char[])}. */
private final SetOnce keystore = new SetOnce<>();
/** The password for the keystore. See {@link #decrypt(char[])}. */
private final SetOnce keystorePassword = new SetOnce<>();
private KeyStoreWrapper(int formatVersion, boolean hasPassword, String type,
String stringKeyAlgo, String fileKeyAlgo,
Map settingTypes, byte[] keystoreBytes) {
this.formatVersion = formatVersion;
this.hasPassword = hasPassword;
this.type = type;
try {
stringFactory = SecretKeyFactory.getInstance(stringKeyAlgo);
fileFactory = SecretKeyFactory.getInstance(fileKeyAlgo);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
this.settingTypes = settingTypes;
this.keystoreBytes = keystoreBytes;
}
/** Returns a path representing the ES keystore in the given config dir. */
static Path keystorePath(Path configDir) {
return configDir.resolve(KEYSTORE_FILENAME);
}
/** Constructs a new keystore with the given password. */
static KeyStoreWrapper create(char[] password) throws Exception {
KeyStoreWrapper wrapper = new KeyStoreWrapper(FORMAT_VERSION, password.length != 0, NEW_KEYSTORE_TYPE,
NEW_KEYSTORE_STRING_KEY_ALGO, NEW_KEYSTORE_FILE_KEY_ALGO, new HashMap<>(), null);
KeyStore keyStore = KeyStore.getInstance(NEW_KEYSTORE_TYPE);
keyStore.load(null, null);
wrapper.keystore.set(keyStore);
wrapper.keystorePassword.set(new KeyStore.PasswordProtection(password));
return wrapper;
}
/**
* Loads information about the Elasticsearch keystore from the provided config directory.
*
* {@link #decrypt(char[])} must be called before reading or writing any entries.
* Returns {@code null} if no keystore exists.
*/
public static KeyStoreWrapper load(Path configDir) throws IOException {
Path keystoreFile = keystorePath(configDir);
if (Files.exists(keystoreFile) == false) {
return null;
}
SimpleFSDirectory directory = new SimpleFSDirectory(configDir);
try (IndexInput indexInput = directory.openInput(KEYSTORE_FILENAME, IOContext.READONCE)) {
ChecksumIndexInput input = new BufferedChecksumIndexInput(indexInput);
int formatVersion = CodecUtil.checkHeader(input, KEYSTORE_FILENAME, MIN_FORMAT_VERSION, FORMAT_VERSION);
byte hasPasswordByte = input.readByte();
boolean hasPassword = hasPasswordByte == 1;
if (hasPassword == false && hasPasswordByte != 0) {
throw new IllegalStateException("hasPassword boolean is corrupt: "
+ String.format(Locale.ROOT, "%02x", hasPasswordByte));
}
String type = input.readString();
String stringKeyAlgo = input.readString();
final String fileKeyAlgo;
if (formatVersion >= 2) {
fileKeyAlgo = input.readString();
} else {
fileKeyAlgo = NEW_KEYSTORE_FILE_KEY_ALGO;
}
final Map settingTypes;
if (formatVersion >= 2) {
settingTypes = input.readMapOfStrings().entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey,
e -> KeyType.valueOf(e.getValue())));
} else {
settingTypes = new HashMap<>();
}
byte[] keystoreBytes = new byte[input.readInt()];
input.readBytes(keystoreBytes, 0, keystoreBytes.length);
CodecUtil.checkFooter(input);
return new KeyStoreWrapper(formatVersion, hasPassword, type, stringKeyAlgo, fileKeyAlgo, settingTypes, keystoreBytes);
}
}
@Override
public boolean isLoaded() {
return keystore.get() != null;
}
/** Return true iff calling {@link #decrypt(char[])} requires a non-empty password. */
public boolean hasPassword() {
return hasPassword;
}
/**
* Decrypts the underlying java keystore.
*
* This may only be called once. The provided password will be zeroed out.
*/
public void decrypt(char[] password) throws GeneralSecurityException, IOException {
if (keystore.get() != null) {
throw new IllegalStateException("Keystore has already been decrypted");
}
keystore.set(KeyStore.getInstance(type));
try (InputStream in = new ByteArrayInputStream(keystoreBytes)) {
keystore.get().load(in, password);
} finally {
Arrays.fill(keystoreBytes, (byte)0);
}
keystorePassword.set(new KeyStore.PasswordProtection(password));
Arrays.fill(password, '\0');
Enumeration aliases = keystore.get().aliases();
if (formatVersion == 1) {
while (aliases.hasMoreElements()) {
settingTypes.put(aliases.nextElement(), KeyType.STRING);
}
} else {
// verify integrity: keys in keystore match what the metadata thinks exist
Set expectedSettings = new HashSet<>(settingTypes.keySet());
while (aliases.hasMoreElements()) {
String settingName = aliases.nextElement();
if (expectedSettings.remove(settingName) == false) {
throw new SecurityException("Keystore has been corrupted or tampered with");
}
}
if (expectedSettings.isEmpty() == false) {
throw new SecurityException("Keystore has been corrupted or tampered with");
}
}
}
/** Write the keystore to the given config directory. */
void save(Path configDir) throws Exception {
char[] password = this.keystorePassword.get().getPassword();
SimpleFSDirectory directory = new SimpleFSDirectory(configDir);
// write to tmp file first, then overwrite
String tmpFile = KEYSTORE_FILENAME + ".tmp";
try (IndexOutput output = directory.createOutput(tmpFile, IOContext.DEFAULT)) {
CodecUtil.writeHeader(output, KEYSTORE_FILENAME, FORMAT_VERSION);
output.writeByte(password.length == 0 ? (byte)0 : (byte)1);
output.writeString(NEW_KEYSTORE_TYPE);
output.writeString(NEW_KEYSTORE_STRING_KEY_ALGO);
output.writeString(NEW_KEYSTORE_FILE_KEY_ALGO);
output.writeMapOfStrings(settingTypes.entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().name())));
// TODO: in the future if we ever change any algorithms used above, we need
// to create a new KeyStore here instead of using the existing one, so that
// the encoded material inside the keystore is updated
assert type.equals(NEW_KEYSTORE_TYPE) : "keystore type changed";
assert stringFactory.getAlgorithm().equals(NEW_KEYSTORE_STRING_KEY_ALGO) : "string pbe algo changed";
assert fileFactory.getAlgorithm().equals(NEW_KEYSTORE_FILE_KEY_ALGO) : "file pbe algo changed";
ByteArrayOutputStream keystoreBytesStream = new ByteArrayOutputStream();
keystore.get().store(keystoreBytesStream, password);
byte[] keystoreBytes = keystoreBytesStream.toByteArray();
output.writeInt(keystoreBytes.length);
output.writeBytes(keystoreBytes, keystoreBytes.length);
CodecUtil.writeFooter(output);
}
Path keystoreFile = keystorePath(configDir);
Files.move(configDir.resolve(tmpFile), keystoreFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
PosixFileAttributeView attrs = Files.getFileAttributeView(keystoreFile, PosixFileAttributeView.class);
if (attrs != null) {
// don't rely on umask: ensure the keystore has minimal permissions
attrs.setPermissions(PosixFilePermissions.fromString("rw-------"));
}
}
@Override
public Set getSettingNames() {
return settingTypes.keySet();
}
// TODO: make settings accessible only to code that registered the setting
@Override
public SecureString getString(String setting) throws GeneralSecurityException {
KeyStore.Entry entry = keystore.get().getEntry(setting, keystorePassword.get());
if (settingTypes.get(setting) != KeyType.STRING ||
entry instanceof KeyStore.SecretKeyEntry == false) {
throw new IllegalStateException("Secret setting " + setting + " is not a string");
}
// TODO: only allow getting a setting once?
KeyStore.SecretKeyEntry secretKeyEntry = (KeyStore.SecretKeyEntry) entry;
PBEKeySpec keySpec = (PBEKeySpec) stringFactory.getKeySpec(secretKeyEntry.getSecretKey(), PBEKeySpec.class);
SecureString value = new SecureString(keySpec.getPassword());
keySpec.clearPassword();
return value;
}
@Override
public InputStream getFile(String setting) throws GeneralSecurityException {
KeyStore.Entry entry = keystore.get().getEntry(setting, keystorePassword.get());
if (settingTypes.get(setting) != KeyType.FILE ||
entry instanceof KeyStore.SecretKeyEntry == false) {
throw new IllegalStateException("Secret setting " + setting + " is not a file");
}
KeyStore.SecretKeyEntry secretKeyEntry = (KeyStore.SecretKeyEntry) entry;
PBEKeySpec keySpec = (PBEKeySpec) fileFactory.getKeySpec(secretKeyEntry.getSecretKey(), PBEKeySpec.class);
// The PBE keyspec gives us chars, we first convert to bytes, then decode base64 inline.
char[] chars = keySpec.getPassword();
byte[] bytes = new byte[chars.length];
for (int i = 0; i < bytes.length; ++i) {
bytes[i] = (byte)chars[i]; // PBE only stores the lower 8 bits, so this narrowing is ok
}
keySpec.clearPassword(); // wipe the original copy
InputStream bytesStream = new ByteArrayInputStream(bytes) {
@Override
public void close() throws IOException {
super.close();
Arrays.fill(bytes, (byte)0); // wipe our second copy when the stream is exhausted
}
};
return Base64.getDecoder().wrap(bytesStream);
}
/**
* Set a string setting.
*
* @throws IllegalArgumentException if the value is not ASCII
*/
void setString(String setting, char[] value) throws GeneralSecurityException {
if (ASCII_ENCODER.canEncode(CharBuffer.wrap(value)) == false) {
throw new IllegalArgumentException("Value must be ascii");
}
SecretKey secretKey = stringFactory.generateSecret(new PBEKeySpec(value));
keystore.get().setEntry(setting, new KeyStore.SecretKeyEntry(secretKey), keystorePassword.get());
settingTypes.put(setting, KeyType.STRING);
}
/** Set a file setting. */
void setFile(String setting, byte[] bytes) throws GeneralSecurityException {
bytes = Base64.getEncoder().encode(bytes);
char[] chars = new char[bytes.length];
for (int i = 0; i < chars.length; ++i) {
chars[i] = (char)bytes[i]; // PBE only stores the lower 8 bits, so this narrowing is ok
}
SecretKey secretKey = stringFactory.generateSecret(new PBEKeySpec(chars));
keystore.get().setEntry(setting, new KeyStore.SecretKeyEntry(secretKey), keystorePassword.get());
settingTypes.put(setting, KeyType.FILE);
}
/** Remove the given setting from the keystore. */
void remove(String setting) throws KeyStoreException {
keystore.get().deleteEntry(setting);
settingTypes.remove(setting);
}
@Override
public void close() throws IOException {
try {
if (keystorePassword.get() != null) {
keystorePassword.get().destroy();
}
} catch (DestroyFailedException e) {
throw new IOException(e);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy