Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/changelog/134349.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 134349
summary: Add `LoadedSecureSettings` for keeping temporary secure settings loaded
area: Security
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import org.elasticsearch.cluster.ClusterChangedEvent;
import org.elasticsearch.cluster.ClusterStateListener;
import org.elasticsearch.cluster.service.ClusterService;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.settings.LoadedSecureSettings;
import org.elasticsearch.common.settings.SecureSetting;
import org.elasticsearch.common.settings.SecureSettings;
import org.elasticsearch.common.settings.SecureString;
Expand All @@ -34,14 +34,10 @@
import org.elasticsearch.tasks.TaskId;
import org.elasticsearch.threadpool.ThreadPool;

import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;

import static org.elasticsearch.ingest.EnterpriseGeoIpTask.ENTERPRISE_GEOIP_DOWNLOADER;
Expand Down Expand Up @@ -178,82 +174,13 @@ public synchronized void reload(Settings settings) {
// `SecureSettings` are available here! cache them as they will be needed
// whenever dynamic cluster settings change and we have to rebuild the accounts
try {
this.cachedSecureSettings = extractSecureSettings(settings, List.of(MAXMIND_LICENSE_KEY_SETTING, IPINFO_TOKEN_SETTING));
this.cachedSecureSettings = LoadedSecureSettings.toLoadedSecureSettings(
settings,
List.of(MAXMIND_LICENSE_KEY_SETTING, IPINFO_TOKEN_SETTING)
);
} catch (GeneralSecurityException e) {
// rethrow as a runtime exception, there's logging higher up the call chain around ReloadablePlugin
throw new ElasticsearchException("Exception while reloading enterprise geoip download task executor", e);
}
}

/**
* Extracts the {@link SecureSettings}` out of the passed in {@link Settings} object. The {@code Setting} argument has to have the
* {@code SecureSettings} open/available. Normally {@code SecureSettings} are available only under specific callstacks (eg. during node
* initialization or during a `reload` call). The returned copy can be reused freely as it will never be closed (this is a bit of
* cheating, but it is necessary in this specific circumstance). Only works for secure settings of type string (not file).
*
* @param source A {@code Settings} object with its {@code SecureSettings} open/available.
* @param securePluginSettings The list of settings to copy.
* @return A copy of the {@code SecureSettings} of the passed in {@code Settings} argument.
*/
private static SecureSettings extractSecureSettings(Settings source, List<Setting<?>> securePluginSettings)
throws GeneralSecurityException {
// get the secure settings out
final SecureSettings sourceSecureSettings = Settings.builder().put(source, true).getSecureSettings();
// filter and cache them...
final Map<String, SecureSettingValue> innerMap = new HashMap<>();
if (sourceSecureSettings != null && securePluginSettings != null) {
for (final String settingKey : sourceSecureSettings.getSettingNames()) {
for (final Setting<?> secureSetting : securePluginSettings) {
if (secureSetting.match(settingKey)) {
innerMap.put(
settingKey,
new SecureSettingValue(
sourceSecureSettings.getString(settingKey),
sourceSecureSettings.getSHA256Digest(settingKey)
)
);
}
}
}
}
return new SecureSettings() {
@Override
public boolean isLoaded() {
return true;
}

@Override
public SecureString getString(String setting) {
return innerMap.get(setting).value();
}

@Override
public Set<String> getSettingNames() {
return innerMap.keySet();
}

@Override
public InputStream getFile(String setting) {
throw new UnsupportedOperationException("A cached SecureSetting cannot be a file");
}

@Override
public byte[] getSHA256Digest(String setting) {
return innerMap.get(setting).sha256Digest();
}

@Override
public void close() throws IOException {}

@Override
public void writeTo(StreamOutput out) throws IOException {
throw new UnsupportedOperationException("A cached SecureSetting cannot be serialized");
}
};
}

/**
* A single-purpose record for the internal implementation of extractSecureSettings
*/
private record SecureSettingValue(SecureString value, byte[] sha256Digest) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.common.settings;

import org.elasticsearch.common.io.stream.StreamOutput;

import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class LoadedSecureSettings {

/**
* Extracts the {@link SecureSettings}` out of the passed in {@link Settings} object. The {@code Setting} argument has to have the
* {@code SecureSettings} open/available. Normally {@code SecureSettings} are available only under specific callstacks (eg. during node
* initialization or during a `reload` call). The returned copy can be reused freely as it will never be closed (this is a bit of
* cheating, but it is necessary in this specific circumstance). Only works for secure settings of type string (not file).
*
* @param source A {@code Settings} object with its {@code SecureSettings} open/available.
* @param settingsToCopy The list of settings to copy.
* @return A copy of the {@code SecureSettings} of the passed in {@code Settings} argument.
*/
public static SecureSettings toLoadedSecureSettings(Settings source, List<Setting<?>> settingsToCopy) throws GeneralSecurityException {
final SecureSettings sourceSecureSettings = Settings.builder().put(source, true).getSecureSettings();
final Map<String, SecureSettingValue> copiedSettings = new HashMap<>();

if (sourceSecureSettings != null && settingsToCopy != null) {
for (final String settingKey : sourceSecureSettings.getSettingNames()) {
for (final Setting<?> secureSetting : settingsToCopy) {
if (secureSetting.match(settingKey)) {
copiedSettings.put(
settingKey,
new SecureSettingValue(
sourceSecureSettings.getString(settingKey),
sourceSecureSettings.getSHA256Digest(settingKey)
)
);
}
}
}
}
return new SecureSettings() {
@Override
public boolean isLoaded() {
return true;
}

@Override
public SecureString getString(String setting) {
return copiedSettings.get(setting).value();
}

@Override
public Set<String> getSettingNames() {
return copiedSettings.keySet();
}

@Override
public InputStream getFile(String setting) {
throw new UnsupportedOperationException("A loaded SecureSetting cannot be a file");
}

@Override
public byte[] getSHA256Digest(String setting) {
return copiedSettings.get(setting).sha256Digest();
}

@Override
public void close() {}

@Override
public void writeTo(StreamOutput out) {
throw new UnsupportedOperationException("A loaded SecureSetting cannot be serialized");
}
};
}

private record SecureSettingValue(SecureString value, byte[] sha256Digest) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.common.settings;

import org.elasticsearch.test.ESTestCase;

import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.List;

import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;

public class LoadedSecureSettingsTests extends ESTestCase {

public void testCopiesMatchingSecureSettings() throws GeneralSecurityException, IOException {
var mockSecureSettings = new MockSecureSettings();
mockSecureSettings.setString("secure.password", "changeme");
mockSecureSettings.setString("secure.api_key", "abcd1234");

var settings = Settings.builder().put("some.other", "value").setSecureSettings(mockSecureSettings).build();

var securePasswordSetting = SecureSetting.secureString("secure.password", null);
var secureApiKeySetting = SecureSetting.secureString("secure.api_key", null);

var loaded = LoadedSecureSettings.toLoadedSecureSettings(settings, List.of(securePasswordSetting, secureApiKeySetting));
mockSecureSettings.close();

assertTrue(loaded.isLoaded());
assertThat(loaded.getSettingNames(), containsInAnyOrder("secure.password", "secure.api_key"));
assertThat(loaded.getString("secure.password").toString(), equalTo("changeme"));
assertThat(loaded.getString("secure.api_key").toString(), equalTo("abcd1234"));

assertThat(loaded.getSHA256Digest("secure.password"), notNullValue());
assertThat(loaded.getSHA256Digest("secure.api_key"), notNullValue());
}

public void testIgnoresNonMatchingSettings() throws GeneralSecurityException {
var mockSecureSettings = new MockSecureSettings();
mockSecureSettings.setString("secure.password", "changeme");

var settings = Settings.builder().setSecureSettings(mockSecureSettings).build();
var differentSetting = SecureSetting.secureString("secure.token", null);

var loaded = LoadedSecureSettings.toLoadedSecureSettings(settings, List.of(differentSetting));
assertThat(loaded.getSettingNames().isEmpty(), equalTo(true));
}

public void testFileSettingThrows() throws GeneralSecurityException {
var mockSecureSettings = new MockSecureSettings();
mockSecureSettings.setFile("secure.file", randomByteArrayOfLength(16));
var settings = Settings.builder().setSecureSettings(mockSecureSettings).build();

var loaded = LoadedSecureSettings.toLoadedSecureSettings(settings, List.of());

UnsupportedOperationException ex = expectThrows(UnsupportedOperationException.class, () -> loaded.getFile("secure.file"));
assertThat(ex.getMessage(), equalTo("A loaded SecureSetting cannot be a file"));
}

public void testWriteToThrows() throws Exception {
var mockSecureSettings = new MockSecureSettings();
mockSecureSettings.setString("secure.secret", "topsecret");

var settings = Settings.builder().setSecureSettings(mockSecureSettings).build();
var loaded = LoadedSecureSettings.toLoadedSecureSettings(settings, List.of());

UnsupportedOperationException ex = expectThrows(UnsupportedOperationException.class, () -> loaded.writeTo(null));
assertThat(ex.getMessage(), equalTo("A loaded SecureSetting cannot be serialized"));
}

public void testNullSourceOrSettingsList() throws Exception {
var empty = Settings.EMPTY;

var loaded = LoadedSecureSettings.toLoadedSecureSettings(empty, null);
assertThat(loaded.isLoaded(), equalTo(true));
assertThat(loaded.getSettingNames().isEmpty(), equalTo(true));

var mockSecureSettings = new MockSecureSettings();
mockSecureSettings.setString("secure.password", "changeme");

var settings = Settings.builder().setSecureSettings(mockSecureSettings).build();

var loaded2 = LoadedSecureSettings.toLoadedSecureSettings(settings, null);
assertTrue(loaded2.getSettingNames().isEmpty());
}
}
Loading