Skip to content

Commit 6e406ae

Browse files
committed
addressing more PR comments
1 parent 4e4a40d commit 6e406ae

10 files changed

+129
-63
lines changed

core/src/main/java/org/elasticsearch/bootstrap/Bootstrap.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ protected void validateNodeBeforeAcceptingRequests(
226226
private static KeyStoreWrapper loadKeyStore(Environment env0) throws BootstrapException {
227227
final KeyStoreWrapper keystore;
228228
try {
229-
keystore = KeyStoreWrapper.loadMetadata(env0.configFile());
229+
keystore = KeyStoreWrapper.load(env0.configFile());
230230
} catch (IOException e) {
231231
throw new BootstrapException(e);
232232
}
@@ -235,7 +235,7 @@ private static KeyStoreWrapper loadKeyStore(Environment env0) throws BootstrapEx
235235
}
236236

237237
try {
238-
keystore.loadKeystore(new char[0] /* TODO: read password from stdin */);
238+
keystore.decrypt(new char[0] /* TODO: read password from stdin */);
239239
} catch (Exception e) {
240240
throw new BootstrapException(e);
241241
}

core/src/main/java/org/elasticsearch/common/settings/AddStringKeyStoreCommand.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import java.io.InputStreamReader;
2525
import java.nio.charset.StandardCharsets;
2626
import java.util.Arrays;
27-
import java.util.Locale;
2827

2928
import joptsimple.OptionSet;
3029
import joptsimple.OptionSpec;
@@ -57,12 +56,12 @@ InputStream getStdin() {
5756

5857
@Override
5958
protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
60-
KeyStoreWrapper keystore = KeyStoreWrapper.loadMetadata(env.configFile());
59+
KeyStoreWrapper keystore = KeyStoreWrapper.load(env.configFile());
6160
if (keystore == null) {
6261
throw new UserException(ExitCodes.DATA_ERROR, "Elasticsearch keystore not found. Use 'create' command to create one.");
6362
}
6463

65-
keystore.loadKeystore(new char[0] /* TODO: prompt for password when they are supported */);
64+
keystore.decrypt(new char[0] /* TODO: prompt for password when they are supported */);
6665

6766
String setting = arguments.value(options);
6867
if (keystore.getSettings().contains(setting) && options.has(forceOption) == false) {
@@ -80,7 +79,11 @@ protected void execute(Terminal terminal, OptionSet options, Environment env) th
8079
value = terminal.readSecret("Enter value for " + setting + ": ");
8180
}
8281

83-
keystore.setStringSetting(setting, value);
82+
try {
83+
keystore.setStringSetting(setting, value);
84+
} catch (IllegalArgumentException e) {
85+
throw new UserException(ExitCodes.DATA_ERROR, "String value must contain only ASCII");
86+
}
8487
keystore.save(env.configFile());
8588
}
8689
}

core/src/main/java/org/elasticsearch/common/settings/KeyStoreWrapper.java

Lines changed: 96 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@
2323
import javax.crypto.SecretKeyFactory;
2424
import javax.crypto.spec.PBEKeySpec;
2525
import javax.security.auth.DestroyFailedException;
26+
import java.io.ByteArrayInputStream;
27+
import java.io.ByteArrayOutputStream;
2628
import java.io.Closeable;
27-
import java.io.DataInputStream;
28-
import java.io.DataOutputStream;
2929
import java.io.IOException;
3030
import java.io.InputStream;
31+
import java.nio.CharBuffer;
32+
import java.nio.charset.CharsetEncoder;
33+
import java.nio.charset.StandardCharsets;
3134
import java.nio.file.Files;
3235
import java.nio.file.Path;
36+
import java.nio.file.StandardCopyOption;
3337
import java.nio.file.attribute.PosixFileAttributeView;
3438
import java.nio.file.attribute.PosixFilePermissions;
3539
import java.security.GeneralSecurityException;
@@ -39,27 +43,43 @@
3943
import java.util.Arrays;
4044
import java.util.Enumeration;
4145
import java.util.HashSet;
46+
import java.util.Locale;
4247
import java.util.Set;
4348

49+
import org.apache.lucene.codecs.CodecUtil;
50+
import org.apache.lucene.store.BufferedChecksumIndexInput;
51+
import org.apache.lucene.store.ChecksumIndexInput;
52+
import org.apache.lucene.store.IOContext;
53+
import org.apache.lucene.store.IndexInput;
54+
import org.apache.lucene.store.IndexOutput;
55+
import org.apache.lucene.store.NIOFSDirectory;
4456
import org.apache.lucene.util.SetOnce;
4557

4658
/**
4759
* A wrapper around a Java KeyStore which provides supplements the keystore with extra metadata.
4860
*
49-
* Loading a keystore has 2 phases. First, call {@link #loadMetadata(Path)}. Then call
50-
* {@link #loadKeystore(char[])} with the keystore password, or an empty char array if
51-
* {@link #hasPassword()} is {@code false}.
61+
* Loading a keystore has 2 phases. First, call {@link #load(Path)}. Then call
62+
* {@link #decrypt(char[])} with the keystore password, or an empty char array if
63+
* {@link #hasPassword()} is {@code false}. Loading and decrypting should happen
64+
* in a single thread. Once decrypted, keys may be read with the wrapper in
65+
* multiple threads.
5266
*/
5367
public class KeyStoreWrapper implements Closeable {
5468

69+
/** The name of the keystore file to read and write. */
70+
private static final String KEYSTORE_FILENAME = "elasticsearch.keystore";
71+
5572
/** The version of the metadata written before the keystore data. */
5673
private static final int FORMAT_VERSION = 1;
5774

5875
/** The keystore type for a newly created keystore. */
5976
private static final String NEW_KEYSTORE_TYPE = "PKCS12";
6077

6178
/** The algorithm used to store password for a newly created keystore. */
62-
private static final String NEW_KEYSTORE_SECRET_KEY_ALGO = "PBEWithHmacSHA256AndAES_128";
79+
private static final String NEW_KEYSTORE_SECRET_KEY_ALGO = "PBE";//"PBEWithHmacSHA256AndAES_128";
80+
81+
/** An encoder to check whether string values are ascii. */
82+
private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder();
6383

6484
/** True iff the keystore has a password needed to read. */
6585
private final boolean hasPassword;
@@ -70,32 +90,32 @@ public class KeyStoreWrapper implements Closeable {
7090
/** A factory necessary for constructing instances of secrets in a {@link KeyStore}. */
7191
private final SecretKeyFactory secretFactory;
7292

73-
/** A stream of the actual keystore data. */
74-
private final InputStream input;
93+
/** The raw bytes of the encrypted keystore. */
94+
private final byte[] keystoreBytes;
7595

76-
/** The loaded keystore. See {@link #loadKeystore(char[])}. */
96+
/** The loaded keystore. See {@link #decrypt(char[])}. */
7797
private final SetOnce<KeyStore> keystore = new SetOnce<>();
7898

79-
/** The password for the keystore. See {@link #loadKeystore(char[])}. */
99+
/** The password for the keystore. See {@link #decrypt(char[])}. */
80100
private final SetOnce<KeyStore.PasswordProtection> keystorePassword = new SetOnce<>();
81101

82102
/** The setting names contained in the loaded keystore. */
83103
private final Set<String> settingNames = new HashSet<>();
84104

85-
private KeyStoreWrapper(boolean hasPassword, String type, String secretKeyAlgo, InputStream input) {
105+
private KeyStoreWrapper(boolean hasPassword, String type, String secretKeyAlgo, byte[] keystoreBytes) {
86106
this.hasPassword = hasPassword;
87107
this.type = type;
88108
try {
89109
secretFactory = SecretKeyFactory.getInstance(secretKeyAlgo);
90110
} catch (NoSuchAlgorithmException e) {
91111
throw new RuntimeException(e);
92112
}
93-
this.input = input;
113+
this.keystoreBytes = keystoreBytes;
94114
}
95115

96116
/** Returns a path representing the ES keystore in the given config dir. */
97117
static Path keystorePath(Path configDir) {
98-
return configDir.resolve("elasticsearch.keystore");
118+
return configDir.resolve(KEYSTORE_FILENAME);
99119
}
100120

101121
/** Constructs a new keystore with the given password. */
@@ -111,43 +131,61 @@ static KeyStoreWrapper create(char[] password) throws Exception {
111131
/**
112132
* Loads information about the Elasticsearch keystore from the provided config directory.
113133
*
114-
* {@link #loadKeystore(char[])} must be called before reading or writing any entries.
134+
* {@link #decrypt(char[])} must be called before reading or writing any entries.
115135
* Returns {@code null} if no keystore exists.
116136
*/
117-
public static KeyStoreWrapper loadMetadata(Path configDir) throws IOException {
137+
public static KeyStoreWrapper load(Path configDir) throws IOException {
118138
Path keystoreFile = keystorePath(configDir);
119139
if (Files.exists(keystoreFile) == false) {
120140
return null;
121141
}
122-
DataInputStream inputStream = new DataInputStream(Files.newInputStream(keystoreFile));
123-
int format = inputStream.readInt();
124-
if (format != FORMAT_VERSION) {
125-
throw new IllegalStateException("Unknown keystore metadata format [" + format + "]");
142+
143+
NIOFSDirectory directory = new NIOFSDirectory(configDir);
144+
try (IndexInput indexInput = directory.openInput(KEYSTORE_FILENAME, IOContext.READONCE)) {
145+
ChecksumIndexInput input = new BufferedChecksumIndexInput(indexInput);
146+
CodecUtil.checkHeader(input, KEYSTORE_FILENAME, FORMAT_VERSION, FORMAT_VERSION);
147+
byte hasPasswordByte = input.readByte();
148+
boolean hasPassword = hasPasswordByte == 1;
149+
if (hasPassword == false && hasPasswordByte != 0) {
150+
throw new IllegalStateException("hasPassword boolean is corrupt: "
151+
+ String.format(Locale.ROOT, "%02x", hasPasswordByte));
152+
}
153+
String type = input.readString();
154+
String secretKeyAlgo = input.readString();
155+
byte[] keystoreBytes = new byte[input.readInt()];
156+
input.readBytes(keystoreBytes, 0, keystoreBytes.length);
157+
CodecUtil.checkFooter(input);
158+
return new KeyStoreWrapper(hasPassword, type, secretKeyAlgo, keystoreBytes);
126159
}
127-
boolean hasPassword = inputStream.readBoolean();
128-
String type = inputStream.readUTF();
129-
String secretKeyAlgo = inputStream.readUTF();
130-
return new KeyStoreWrapper(hasPassword, type, secretKeyAlgo, inputStream);
131160
}
132161

133-
/** Returns true iff {@link #loadKeystore(char[])} has been called. */
162+
/** Returns true iff {@link #decrypt(char[])} has been called. */
134163
public boolean isLoaded() {
135164
return keystore.get() != null;
136165
}
137166

138-
/** Return true iff calling {@link #loadKeystore(char[])} requires a non-empty password. */
167+
/** Return true iff calling {@link #decrypt(char[])} requires a non-empty password. */
139168
public boolean hasPassword() {
140169
return hasPassword;
141170
}
142171

143-
/** Loads the keystore this metadata wraps. This may only be called once. */
144-
public void loadKeystore(char[] password) throws GeneralSecurityException, IOException {
145-
this.keystore.set(KeyStore.getInstance(type));
146-
try (InputStream in = input) {
172+
/**
173+
* Decrypts the underlying java keystore.
174+
*
175+
* This may only be called once. The provided password will be zeroed out.
176+
*/
177+
public void decrypt(char[] password) throws GeneralSecurityException, IOException {
178+
if (keystore.get() != null) {
179+
throw new IllegalStateException("Keystore has already been decrypted");
180+
}
181+
keystore.set(KeyStore.getInstance(type));
182+
try (InputStream in = new ByteArrayInputStream(keystoreBytes)) {
147183
keystore.get().load(in, password);
184+
} finally {
185+
Arrays.fill(keystoreBytes, (byte)0);
148186
}
149187

150-
this.keystorePassword.set(new KeyStore.PasswordProtection(password));
188+
keystorePassword.set(new KeyStore.PasswordProtection(password));
151189
Arrays.fill(password, '\0');
152190

153191
// convert keystore aliases enum into a set for easy lookup
@@ -159,15 +197,27 @@ public void loadKeystore(char[] password) throws GeneralSecurityException, IOExc
159197

160198
/** Write the keystore to the given config directory. */
161199
void save(Path configDir) throws Exception {
162-
Path keystoreFile = keystorePath(configDir);
163-
try (DataOutputStream outputStream = new DataOutputStream(Files.newOutputStream(keystoreFile))) {
164-
outputStream.writeInt(FORMAT_VERSION);
165-
char[] password = this.keystorePassword.get().getPassword();
166-
outputStream.writeBoolean(password.length != 0);
167-
outputStream.writeUTF(type);
168-
outputStream.writeUTF(secretFactory.getAlgorithm());
169-
keystore.get().store(outputStream, password);
200+
char[] password = this.keystorePassword.get().getPassword();
201+
202+
NIOFSDirectory directory = new NIOFSDirectory(configDir);
203+
// write to tmp file first, then overwrite
204+
String tmpFile = KEYSTORE_FILENAME + ".tmp";
205+
try (IndexOutput output = directory.createOutput(tmpFile, IOContext.DEFAULT)) {
206+
CodecUtil.writeHeader(output, KEYSTORE_FILENAME, FORMAT_VERSION);
207+
output.writeByte(password.length == 0 ? (byte)0 : (byte)1);
208+
output.writeString(type);
209+
output.writeString(secretFactory.getAlgorithm());
210+
211+
ByteArrayOutputStream keystoreBytesStream = new ByteArrayOutputStream();
212+
keystore.get().store(keystoreBytesStream, password);
213+
byte[] keystoreBytes = keystoreBytesStream.toByteArray();
214+
output.writeInt(keystoreBytes.length);
215+
output.writeBytes(keystoreBytes, keystoreBytes.length);
216+
CodecUtil.writeFooter(output);
170217
}
218+
219+
Path keystoreFile = keystorePath(configDir);
220+
Files.move(configDir.resolve(tmpFile), keystoreFile, StandardCopyOption.REPLACE_EXISTING);
171221
PosixFileAttributeView attrs = Files.getFileAttributeView(keystoreFile, PosixFileAttributeView.class);
172222
if (attrs != null) {
173223
// don't rely on umask: ensure the keystore has minimal permissions
@@ -194,8 +244,15 @@ SecureString getStringSetting(String setting) throws GeneralSecurityException {
194244
return value;
195245
}
196246

197-
/** Set a string setting. */
247+
/**
248+
* Set a string setting.
249+
*
250+
* @throws IllegalArgumentException if the value is not ASCII
251+
*/
198252
void setStringSetting(String setting, char[] value) throws GeneralSecurityException {
253+
if (ASCII_ENCODER.canEncode(CharBuffer.wrap(value)) == false) {
254+
throw new IllegalArgumentException("Value must be ascii");
255+
}
199256
SecretKey secretKey = secretFactory.generateSecret(new PBEKeySpec(value));
200257
keystore.get().setEntry(setting, new KeyStore.SecretKeyEntry(secretKey), keystorePassword.get());
201258
settingNames.add(setting);

core/src/main/java/org/elasticsearch/common/settings/ListKeyStoreCommand.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ class ListKeyStoreCommand extends EnvironmentAwareCommand {
4242

4343
@Override
4444
protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
45-
KeyStoreWrapper keystore = KeyStoreWrapper.loadMetadata(env.configFile());
45+
KeyStoreWrapper keystore = KeyStoreWrapper.load(env.configFile());
4646
if (keystore == null) {
4747
throw new UserException(ExitCodes.DATA_ERROR, "Elasticsearch keystore not found. Use 'create' command to create one.");
4848
}
4949

50-
keystore.loadKeystore(new char[0] /* TODO: prompt for password when they are supported */);
50+
keystore.decrypt(new char[0] /* TODO: prompt for password when they are supported */);
5151

5252
List<String> sortedEntries = new ArrayList<>(keystore.getSettings());
5353
Collections.sort(sortedEntries);

core/src/main/java/org/elasticsearch/common/settings/RemoveSettingKeyStoreCommand.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ protected void execute(Terminal terminal, OptionSet options, Environment env) th
4848
throw new UserException(ExitCodes.USAGE, "Must supply at least one setting to remove");
4949
}
5050

51-
KeyStoreWrapper keystore = KeyStoreWrapper.loadMetadata(env.configFile());
51+
KeyStoreWrapper keystore = KeyStoreWrapper.load(env.configFile());
5252
if (keystore == null) {
5353
throw new UserException(ExitCodes.DATA_ERROR, "Elasticsearch keystore not found. Use 'create' command to create one.");
5454
}
5555

56-
keystore.loadKeystore(new char[0] /* TODO: prompt for password when they are supported */);
56+
keystore.decrypt(new char[0] /* TODO: prompt for password when they are supported */);
5757

5858
for (String setting : arguments.values(options)) {
5959
if (keystore.getSettings().contains(setting) == false) {

core/src/main/java/org/elasticsearch/common/settings/SecureString.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public SecureString(char[] chars) {
4040

4141
/** Constant time equality to avoid potential timing attacks. */
4242
@Override
43-
public boolean equals(Object o) {
43+
public synchronized boolean equals(Object o) {
4444
ensureNotClosed();
4545
if (this == o) return true;
4646
if (o == null || o instanceof CharSequence == false) return false;
@@ -58,18 +58,18 @@ public boolean equals(Object o) {
5858
}
5959

6060
@Override
61-
public int hashCode() {
61+
public synchronized int hashCode() {
6262
return Arrays.hashCode(chars);
6363
}
6464

6565
@Override
66-
public int length() {
66+
public synchronized int length() {
6767
ensureNotClosed();
6868
return chars.length;
6969
}
7070

7171
@Override
72-
public char charAt(int index) {
72+
public synchronized char charAt(int index) {
7373
ensureNotClosed();
7474
return chars[index];
7575
}
@@ -83,15 +83,15 @@ public SecureString subSequence(int start, int end) {
8383
* Convert to a {@link String}. This should only be used with APIs that do not take {@link CharSequence}.
8484
*/
8585
@Override
86-
public String toString() {
86+
public synchronized String toString() {
8787
return new String(chars);
8888
}
8989

9090
/**
9191
* Closes the string by clearing the underlying char array.
9292
*/
9393
@Override
94-
public void close() {
94+
public synchronized void close() {
9595
Arrays.fill(chars, '\0');
9696
chars = null;
9797
}

core/src/main/java/org/elasticsearch/common/settings/Settings.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,7 @@ public void setKeyStore(KeyStoreWrapper keystore) {
642642
if (keystore.isLoaded()) {
643643
throw new IllegalStateException("The keystore wrapper must already be loaded");
644644
}
645-
this.keystore.set(Objects.requireNonNull(keystore));
645+
this.keystore.set(keystore);
646646
}
647647

648648
/**

core/src/test/java/org/elasticsearch/common/settings/AddStringKeyStoreCommandTests.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ public void testStdinLong() throws Exception {
119119
assertSecureString("foo", "secret value 2");
120120
}
121121

122+
public void testNonAsciiValue() throws Exception {
123+
KeyStoreWrapper.create(new char[0]).save(env.configFile());
124+
terminal.addSecretInput("non-äsčîï");
125+
UserException e = expectThrows(UserException.class, () -> execute("foo"));
126+
assertEquals(ExitCodes.DATA_ERROR, e.exitCode);
127+
assertEquals("String value must contain only ASCII", e.getMessage());
128+
}
129+
122130
void setInput(String inputStr) {
123131
input = new ByteArrayInputStream(inputStr.getBytes(StandardCharsets.UTF_8));
124132
}

0 commit comments

Comments
 (0)