You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hbase.apache.org by pa...@apache.org on 2026/10/20 09:28:01 UTC
(hbase) branch branch-3 updated: HBASE-30387 Support SingleEKU certificates (#8681)
This is an automated email from the ASF dual-hosted git repository.
PDavid pushed a commit to branch branch-3
in repository https://gitbox.apache.org/repos/asf/hbase.git
The following commit(s) were added to refs/heads/branch-3 by this push:
new 2dce109e4cf HBASE-30387 Support SingleEKU certificates (#8681)
2dce109e4cf is described below
commit 2dce109e4cfcb86623eee4062867c63d31c6fb4d
Author: Luca Kovács <ko...@gmail.com>
AuthorDate: Mon Oct 19 13:46:27 2026 +0200
HBASE-30387 Support SingleEKU certificates (#8681)
Signed-off-by: Peter Somogyi <ps...@apache.com>
Signed-off-by: Dávid Paksy <pa...@apache.org>
(cherry picked from commit 09474ab77bfa8bede96131164fd4451f093a1a10)
---
.../apache/hadoop/hbase/ipc/NettyRpcClient.java | 2 +-
.../hadoop/hbase/io/crypto/tls/TLSStore.java | 172 ++++++++++
.../hadoop/hbase/io/crypto/tls/X509Util.java | 136 ++++++--
.../hadoop/hbase/io/TestFileChangeWatcher.java | 4 +-
.../hadoop/hbase/io/crypto/tls/TestX509Util.java | 129 ++++++++
.../org/apache/hadoop/hbase/http/HttpServer.java | 22 +-
.../org/apache/hadoop/hbase/http/InfoServer.java | 63 +++-
.../hadoop/hbase/http/TestInfoServerTLSConfig.java | 93 ++++++
.../org/apache/hadoop/hbase/rest/Constants.java | 25 ++
.../org/apache/hadoop/hbase/rest/RESTServer.java | 49 ++-
.../hadoop/hbase/rest/TestRESTServerSSL.java | 93 ++++++
.../apache/hadoop/hbase/ipc/NettyRpcServer.java | 2 +-
.../org/apache/hadoop/hbase/thrift/Constants.java | 29 ++
.../apache/hadoop/hbase/thrift/ThriftServer.java | 72 +++-
.../thrift/TestThriftServerSSLMutualAuth.java | 361 +++++++++++++++++++++
15 files changed, 1192 insertions(+), 60 deletions(-)
diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java
index ed0c4fffc72..44689d695d9 100644
--- a/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java
+++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java
@@ -116,7 +116,7 @@ public class NettyRpcClient extends AbstractRpcClient<NettyRpcConnection> {
keyStoreWatcher.get() == null && trustStoreWatcher.get() == null
&& conf.getBoolean(X509Util.TLS_CERT_RELOAD, false)
) {
- X509Util.enableCertFileReloading(conf, keyStoreWatcher, trustStoreWatcher,
+ X509Util.enableCertFileReloadingForClient(conf, keyStoreWatcher, trustStoreWatcher,
() -> sslContextForClient.set(null));
}
}
diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/TLSStore.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/TLSStore.java
new file mode 100644
index 00000000000..696f11408e9
--- /dev/null
+++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/TLSStore.java
@@ -0,0 +1,172 @@
+/*
+ * 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.hadoop.hbase.io.crypto.tls;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * One keystore or truststore, resolved from configuration with every attribute drawn from a single
+ * key prefix.
+ * <p/>
+ * Each TLS surface exposes two prefixes for the same store: a role-scoped one added for single-EKU
+ * certificate support (e.g. {@code hbase.rpc.tls.server.}) and the historical unscoped one (e.g.
+ * {@code hbase.rpc.tls.}). Resolving attribute by attribute would let a store's location come from
+ * one prefix while its password or type came from the other, which opens either the wrong file or
+ * the right file with the wrong credentials. {@link #resolve} therefore picks the prefix once, from
+ * the location, and reads the rest of the store from that same prefix.
+ */
+@InterfaceAudience.Private
+public final class TLSStore {
+
+ private static final Logger LOG = LoggerFactory.getLogger(TLSStore.class);
+
+ /** Stores already checked for unused legacy keys, so the scan and the warning happen once. */
+ private static final Set<String> LOGGED_STORES = ConcurrentHashMap.newKeySet();
+
+ /**
+ * The key postfixes making up one store, by surface. The RPC keys use {@code .location} while the
+ * servlet surfaces (REST, Thrift) use {@code .store}, and only the latter have a separate
+ * {@code keypassword}.
+ */
+ public enum Keys {
+ RPC_KEYSTORE("keystore.location", "keystore.password", "keystore.type", null),
+ RPC_TRUSTSTORE("truststore.location", "truststore.password", "truststore.type", null),
+ SERVLET_KEYSTORE("keystore.store", "keystore.password", "keystore.type",
+ "keystore.keypassword"),
+ SERVLET_TRUSTSTORE("truststore.store", "truststore.password", "truststore.type", null);
+
+ private final String location;
+ private final String password;
+ private final String type;
+ private final String keyPassword;
+
+ Keys(String location, String password, String type, String keyPassword) {
+ this.location = location;
+ this.password = password;
+ this.type = type;
+ this.keyPassword = keyPassword;
+ }
+
+ private String[] all() {
+ return keyPassword == null
+ ? new String[] { location, password, type }
+ : new String[] { location, password, type, keyPassword };
+ }
+ }
+
+ private final String location;
+ private final char[] password;
+ private final char[] keyPassword;
+ private final String type;
+
+ private TLSStore(String location, char[] password, char[] keyPassword, String type) {
+ this.location = location;
+ this.password = password;
+ this.keyPassword = keyPassword;
+ this.type = type;
+ }
+
+ /**
+ * Reads one store from {@code config}. The prefix supplying the location supplies every other
+ * attribute too, so the two prefixes are never combined within a single store.
+ * <p/>
+ * Only the location is mandatory under a prefix: a store may have no password at all, and the
+ * type is auto-detected from the file extension when absent (see
+ * {@link KeyStoreFileType#fromPropertyValueOrFileName}). Keeping the legacy keys in place while
+ * migrating is valid -- the ones left unused are logged once so they can be cleaned up.
+ * @param config the configuration to read from
+ * @param rolePrefix role-scoped prefix, e.g. {@code hbase.rpc.tls.server.}
+ * @param legacyPrefix historical unscoped prefix, e.g. {@code hbase.rpc.tls.}
+ * @param keys which store to read, and under which postfixes
+ * @throws IllegalArgumentException if {@code rolePrefix} supplies any attribute but not the
+ * location, since the location would then be taken from
+ * {@code legacyPrefix}
+ */
+ public static TLSStore resolve(Configuration config, String rolePrefix, String legacyPrefix,
+ Keys keys) throws IOException {
+ String roleLocationKey = rolePrefix + keys.location;
+ boolean useRole = config.get(roleLocationKey) != null;
+ if (!useRole) {
+ for (String postfix : keys.all()) {
+ if (config.get(rolePrefix + postfix) != null) {
+ throw new IllegalArgumentException(
+ rolePrefix + postfix + " is set, but " + roleLocationKey + " is not. Once any "
+ + rolePrefix + " key is used, that prefix must supply the store location.");
+ }
+ }
+ }
+ String prefix = useRole ? rolePrefix : legacyPrefix;
+ if (useRole) {
+ logOnce(config, rolePrefix, legacyPrefix, keys);
+ }
+ return new TLSStore(config.get(prefix + keys.location, ""),
+ config.getPassword(prefix + keys.password),
+ keys.keyPassword == null ? null : config.getPassword(prefix + keys.keyPassword),
+ config.get(prefix + keys.type, ""));
+ }
+
+ private static void logOnce(Configuration config, String rolePrefix, String legacyPrefix,
+ Keys keys) {
+ if (!LOGGED_STORES.add(rolePrefix + keys.location)) {
+ return;
+ }
+ List<String> unused = Arrays.stream(keys.all()).map(postfix -> legacyPrefix + postfix)
+ .filter(key -> config.get(key) != null).collect(Collectors.toList());
+ if (!unused.isEmpty()) {
+ LOG.warn("{} supplies this store, so these keys are unused: {}. Remove them once the"
+ + " migration is complete.", rolePrefix, String.join(", ", unused));
+ }
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public String getPassword() {
+ return password == null ? null : new String(password);
+ }
+
+ /** Falls back to the store password when no separate key password is configured. */
+ public String getKeyPassword() {
+ char[] effective = getKeyPasswordChars();
+ return effective == null ? null : new String(effective);
+ }
+
+ /** For the netty paths, whose key/trust manager factories take char[]. */
+ public char[] getPasswordChars() {
+ return password;
+ }
+
+ /** @see #getKeyPassword() */
+ public char[] getKeyPasswordChars() {
+ return keyPassword != null ? keyPassword : password;
+ }
+
+ public String getType() {
+ return type;
+ }
+}
diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java
index d6be0eed844..d321f1d7a9e 100644
--- a/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java
+++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java
@@ -37,6 +37,7 @@ import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509ExtendedTrustManager;
import javax.net.ssl.X509KeyManager;
import javax.net.ssl.X509TrustManager;
+import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.exceptions.KeyManagerException;
import org.apache.hadoop.hbase.exceptions.SSLContextException;
@@ -89,6 +90,43 @@ public final class X509Util {
public static final String TLS_CERT_RELOAD = CONFIG_PREFIX + "certReload";
public static final String TLS_USE_OPENSSL = CONFIG_PREFIX + "useOpenSsl";
+ //
+ // Role-scoped keystore/truststore configs for single-EKU certificate support.
+ //
+
+ /**
+ * When set, these take precedence over the unscoped keys above; when unset, the unscoped keys are
+ * used as a fallback so existing deployments keep working unchanged.
+ */
+ static final String CLIENT_CONFIG_PREFIX = CONFIG_PREFIX + "client.";
+ static final String SERVER_CONFIG_PREFIX = CONFIG_PREFIX + "server.";
+
+ public static final String TLS_CONFIG_CLIENT_KEYSTORE_LOCATION =
+ CLIENT_CONFIG_PREFIX + "keystore.location";
+ public static final String TLS_CONFIG_CLIENT_KEYSTORE_TYPE =
+ CLIENT_CONFIG_PREFIX + "keystore.type";
+ public static final String TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD =
+ CLIENT_CONFIG_PREFIX + "keystore.password";
+ public static final String TLS_CONFIG_CLIENT_TRUSTSTORE_LOCATION =
+ CLIENT_CONFIG_PREFIX + "truststore.location";
+ public static final String TLS_CONFIG_CLIENT_TRUSTSTORE_TYPE =
+ CLIENT_CONFIG_PREFIX + "truststore.type";
+ public static final String TLS_CONFIG_CLIENT_TRUSTSTORE_PASSWORD =
+ CLIENT_CONFIG_PREFIX + "truststore.password";
+
+ public static final String TLS_CONFIG_SERVER_KEYSTORE_LOCATION =
+ SERVER_CONFIG_PREFIX + "keystore.location";
+ public static final String TLS_CONFIG_SERVER_KEYSTORE_TYPE =
+ SERVER_CONFIG_PREFIX + "keystore.type";
+ public static final String TLS_CONFIG_SERVER_KEYSTORE_PASSWORD =
+ SERVER_CONFIG_PREFIX + "keystore.password";
+ public static final String TLS_CONFIG_SERVER_TRUSTSTORE_LOCATION =
+ SERVER_CONFIG_PREFIX + "truststore.location";
+ public static final String TLS_CONFIG_SERVER_TRUSTSTORE_TYPE =
+ SERVER_CONFIG_PREFIX + "truststore.type";
+ public static final String TLS_CONFIG_SERVER_TRUSTSTORE_PASSWORD =
+ SERVER_CONFIG_PREFIX + "truststore.password";
+
//
// Server-side specific configs
//
@@ -165,6 +203,20 @@ public final class X509Util {
}
}
+ // Jetty never leaves the peer-certificate trust anchors empty: with no truststore configured it
+ // falls back to the keystore, or to the JVM cacerts when no keystore is set either. WANT/NEED
+ // would then appear to work while trusting issuers the operator never configured, so fail fast.
+ public static void validateClientAuthTrustStore(ClientAuth clientAuth, String trustStoreLocation,
+ String clientAuthModeKey, String... trustStoreLocationKeys) {
+ if (clientAuth == ClientAuth.NONE || StringUtils.isNotBlank(trustStoreLocation)) {
+ return;
+ }
+ throw new IllegalArgumentException(clientAuthModeKey + "=" + clientAuth
+ + " requires a client-certificate trust root, but no truststore is configured."
+ + " Configure a truststore via [" + String.join(", ", trustStoreLocationKeys)
+ + "], or disable client certificate checking with " + clientAuthModeKey + "=NONE.");
+ }
+
private X509Util() {
// disabled
}
@@ -175,20 +227,25 @@ public final class X509Util {
SslContextBuilder sslContextBuilder = SslContextBuilder.forClient();
configureOpenSslIfAvailable(sslContextBuilder, config);
- String keyStoreLocation = config.get(TLS_CONFIG_KEYSTORE_LOCATION, "");
- char[] keyStorePassword = config.getPassword(TLS_CONFIG_KEYSTORE_PASSWORD);
- String keyStoreType = config.get(TLS_CONFIG_KEYSTORE_TYPE, "");
+ TLSStore keyStore =
+ TLSStore.resolve(config, CLIENT_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_KEYSTORE);
+ String keyStoreLocation = keyStore.getLocation();
+ char[] keyStorePassword = keyStore.getPasswordChars();
+ String keyStoreType = keyStore.getType();
if (keyStoreLocation.isEmpty()) {
- LOG.warn(TLS_CONFIG_KEYSTORE_LOCATION + " not specified");
+ LOG.warn("Neither {} nor {} specified", TLS_CONFIG_CLIENT_KEYSTORE_LOCATION,
+ TLS_CONFIG_KEYSTORE_LOCATION);
} else {
sslContextBuilder
.keyManager(createKeyManager(keyStoreLocation, keyStorePassword, keyStoreType));
}
- String trustStoreLocation = config.get(TLS_CONFIG_TRUSTSTORE_LOCATION, "");
- char[] trustStorePassword = config.getPassword(TLS_CONFIG_TRUSTSTORE_PASSWORD);
- String trustStoreType = config.get(TLS_CONFIG_TRUSTSTORE_TYPE, "");
+ TLSStore trustStore =
+ TLSStore.resolve(config, CLIENT_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_TRUSTSTORE);
+ String trustStoreLocation = trustStore.getLocation();
+ char[] trustStorePassword = trustStore.getPasswordChars();
+ String trustStoreType = trustStore.getType();
boolean sslCrlEnabled = config.getBoolean(TLS_CONFIG_CLR, false);
boolean sslOcspEnabled = config.getBoolean(TLS_CONFIG_OCSP, false);
@@ -198,7 +255,8 @@ public final class X509Util {
boolean allowReverseDnsLookup = config.getBoolean(TLS_CONFIG_REVERSE_DNS_LOOKUP_ENABLED, true);
if (trustStoreLocation.isEmpty()) {
- LOG.warn(TLS_CONFIG_TRUSTSTORE_LOCATION + " not specified");
+ LOG.warn("Neither {} nor {} specified", TLS_CONFIG_CLIENT_TRUSTSTORE_LOCATION,
+ TLS_CONFIG_TRUSTSTORE_LOCATION);
} else {
sslContextBuilder
.trustManager(createTrustManager(trustStoreLocation, trustStorePassword, trustStoreType,
@@ -249,13 +307,15 @@ public final class X509Util {
public static SslContext createSslContextForServer(Configuration config)
throws X509Exception, IOException {
- String keyStoreLocation = config.get(TLS_CONFIG_KEYSTORE_LOCATION, "");
- char[] keyStorePassword = config.getPassword(TLS_CONFIG_KEYSTORE_PASSWORD);
- String keyStoreType = config.get(TLS_CONFIG_KEYSTORE_TYPE, "");
+ TLSStore keyStore =
+ TLSStore.resolve(config, SERVER_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_KEYSTORE);
+ String keyStoreLocation = keyStore.getLocation();
+ char[] keyStorePassword = keyStore.getPasswordChars();
+ String keyStoreType = keyStore.getType();
if (keyStoreLocation.isEmpty()) {
- throw new SSLContextException(
- "Keystore is required for SSL server: " + TLS_CONFIG_KEYSTORE_LOCATION);
+ throw new SSLContextException("Keystore is required for SSL server: set either "
+ + TLS_CONFIG_SERVER_KEYSTORE_LOCATION + " or " + TLS_CONFIG_KEYSTORE_LOCATION);
}
SslContextBuilder sslContextBuilder;
@@ -263,9 +323,11 @@ public final class X509Util {
.forServer(createKeyManager(keyStoreLocation, keyStorePassword, keyStoreType));
configureOpenSslIfAvailable(sslContextBuilder, config);
- String trustStoreLocation = config.get(TLS_CONFIG_TRUSTSTORE_LOCATION, "");
- char[] trustStorePassword = config.getPassword(TLS_CONFIG_TRUSTSTORE_PASSWORD);
- String trustStoreType = config.get(TLS_CONFIG_TRUSTSTORE_TYPE, "");
+ TLSStore trustStore =
+ TLSStore.resolve(config, SERVER_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_TRUSTSTORE);
+ String trustStoreLocation = trustStore.getLocation();
+ char[] trustStorePassword = trustStore.getPasswordChars();
+ String trustStoreType = trustStore.getType();
boolean sslCrlEnabled = config.getBoolean(TLS_CONFIG_CLR, false);
boolean sslOcspEnabled = config.getBoolean(TLS_CONFIG_OCSP, false);
@@ -277,7 +339,8 @@ public final class X509Util {
boolean allowReverseDnsLookup = config.getBoolean(TLS_CONFIG_REVERSE_DNS_LOOKUP_ENABLED, true);
if (trustStoreLocation.isEmpty()) {
- LOG.warn(TLS_CONFIG_TRUSTSTORE_LOCATION + " not specified");
+ LOG.warn("Neither {} nor {} specified", TLS_CONFIG_SERVER_TRUSTSTORE_LOCATION,
+ TLS_CONFIG_TRUSTSTORE_LOCATION);
} else {
sslContextBuilder
.trustManager(createTrustManager(trustStoreLocation, trustStorePassword, trustStoreType,
@@ -422,20 +485,47 @@ public final class X509Util {
}
/**
- * Enable certificate file reloading by creating FileWatchers for keystore and truststore.
- * AtomicReferences will be set with the new instances. resetContext - if not null - will be
- * called when the file has been modified.
+ * Enable certificate file reloading for the RPC <em>client</em> side by creating FileWatchers for
+ * the keystore and truststore whose paths are resolved under {@link #CLIENT_CONFIG_PREFIX}
+ * (role-scoped keys first, legacy keys as fallback). AtomicReferences will be set with the new
+ * instances. {@code resetContext} - if not null - will be called when the file has been modified.
* @param keystoreWatcher Reference to keystoreFileWatcher.
* @param trustStoreWatcher Reference to truststoreFileWatcher.
* @param resetContext Callback for file changes.
*/
- public static void enableCertFileReloading(Configuration config,
+ public static void enableCertFileReloadingForClient(Configuration config,
+ AtomicReference<FileChangeWatcher> keystoreWatcher,
+ AtomicReference<FileChangeWatcher> trustStoreWatcher, Runnable resetContext)
+ throws IOException {
+ enableCertFileReloading(config, CLIENT_CONFIG_PREFIX, keystoreWatcher, trustStoreWatcher,
+ resetContext);
+ }
+
+ /**
+ * Enable certificate file reloading for the RPC <em>server</em> side. See
+ * {@link #enableCertFileReloadingForClient} for parameter semantics; the only difference is that
+ * paths are resolved under {@link #SERVER_CONFIG_PREFIX}, falling back to the legacy unscoped
+ * keys when unset.
+ */
+ public static void enableCertFileReloadingForServer(Configuration config,
+ AtomicReference<FileChangeWatcher> keystoreWatcher,
+ AtomicReference<FileChangeWatcher> trustStoreWatcher, Runnable resetContext)
+ throws IOException {
+ enableCertFileReloading(config, SERVER_CONFIG_PREFIX, keystoreWatcher, trustStoreWatcher,
+ resetContext);
+ }
+
+ private static void enableCertFileReloading(Configuration config, String rolePrefix,
AtomicReference<FileChangeWatcher> keystoreWatcher,
AtomicReference<FileChangeWatcher> trustStoreWatcher, Runnable resetContext)
throws IOException {
- String keyStoreLocation = config.get(TLS_CONFIG_KEYSTORE_LOCATION, "");
+ // Resolve the watched paths through TLSStore so the file we watch is always the one the
+ // SslContext loaded: the prefix (role-scoped or legacy) is chosen once, from the location.
+ String keyStoreLocation =
+ TLSStore.resolve(config, rolePrefix, CONFIG_PREFIX, TLSStore.Keys.RPC_KEYSTORE).getLocation();
+ String trustStoreLocation = TLSStore
+ .resolve(config, rolePrefix, CONFIG_PREFIX, TLSStore.Keys.RPC_TRUSTSTORE).getLocation();
keystoreWatcher.set(newFileChangeWatcher(config, keyStoreLocation, resetContext));
- String trustStoreLocation = config.get(TLS_CONFIG_TRUSTSTORE_LOCATION, "");
// we are using the same callback for both. there's no reason to kick off two
// threads if keystore/truststore are both at the same location
if (!keyStoreLocation.equals(trustStoreLocation)) {
diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java
index 4b822c9d402..48f74cf5780 100644
--- a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java
+++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java
@@ -111,7 +111,7 @@ public class TestFileChangeWatcher {
myConf.set(X509Util.TLS_CONFIG_TRUSTSTORE_LOCATION, sharedPath);
AtomicReference<FileChangeWatcher> keystoreWatcher = new AtomicReference<>();
AtomicReference<FileChangeWatcher> truststoreWatcher = new AtomicReference<>();
- X509Util.enableCertFileReloading(myConf, keystoreWatcher, truststoreWatcher, () -> {
+ X509Util.enableCertFileReloadingForServer(myConf, keystoreWatcher, truststoreWatcher, () -> {
});
assertNotNull(keystoreWatcher.get());
assertThat(keystoreWatcher.get().getWatcherThread().getName(), endsWith("foo.jks"));
@@ -122,7 +122,7 @@ public class TestFileChangeWatcher {
String truststorePath = File.createTempFile("bar", "bar.jks").getAbsolutePath();
myConf.set(X509Util.TLS_CONFIG_TRUSTSTORE_LOCATION, truststorePath);
- X509Util.enableCertFileReloading(myConf, keystoreWatcher, truststoreWatcher, () -> {
+ X509Util.enableCertFileReloadingForServer(myConf, keystoreWatcher, truststoreWatcher, () -> {
});
assertNotNull(keystoreWatcher.get());
diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java
index f2499fc24d7..d715f6bbdb9 100644
--- a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java
+++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java
@@ -20,6 +20,7 @@ package org.apache.hadoop.hbase.io.crypto.tls;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@@ -28,6 +29,7 @@ import static org.mockito.Mockito.mock;
import java.security.Security;
import java.util.Arrays;
import java.util.Collections;
+import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseParameterizedTestTemplate;
import org.apache.hadoop.hbase.exceptions.KeyManagerException;
import org.apache.hadoop.hbase.exceptions.SSLContextException;
@@ -375,4 +377,131 @@ public class TestX509Util extends AbstractTestX509Parameterized {
});
}
+ // ---------------------------------------------------------------------------
+ // Role-scoped configuration resolution (single-EKU certificate support)
+ // ---------------------------------------------------------------------------
+
+ @TestTemplate
+ public void testCreateSSLContextForClientUsesRoleScopedKeystoreWhenSet() throws Exception {
+ // Move the legacy keystore values to the client-scoped keys and clear the legacy keys, so
+ // that a successful context build proves the client-scoped keys were consulted.
+ String location = conf.get(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+ String password = conf.get(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+ String type = conf.get(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+ conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_LOCATION, location);
+ conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD, password);
+ conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_TYPE, type);
+ conf.unset(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+ conf.unset(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+ conf.unset(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+
+ SslContext sslContext = X509Util.createSslContextForClient(conf);
+ ByteBufAllocator byteBufAllocatorMock = mock(ByteBufAllocator.class);
+ // Handshake would fail if the key manager weren't wired; smoke-test that engine creation works.
+ assertTrue(
+ sslContext.newEngine(byteBufAllocatorMock).getSSLParameters().getProtocols().length > 0);
+ }
+
+ @TestTemplate
+ public void testCreateSSLContextForServerUsesRoleScopedKeystoreWhenSet() throws Exception {
+ String location = conf.get(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+ String password = conf.get(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+ String type = conf.get(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+ conf.set(X509Util.TLS_CONFIG_SERVER_KEYSTORE_LOCATION, location);
+ conf.set(X509Util.TLS_CONFIG_SERVER_KEYSTORE_PASSWORD, password);
+ conf.set(X509Util.TLS_CONFIG_SERVER_KEYSTORE_TYPE, type);
+ conf.unset(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+ conf.unset(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+ conf.unset(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+
+ SslContext sslContext = X509Util.createSslContextForServer(conf);
+ ByteBufAllocator byteBufAllocatorMock = mock(ByteBufAllocator.class);
+ assertTrue(
+ sslContext.newEngine(byteBufAllocatorMock).getSSLParameters().getProtocols().length > 0);
+ }
+
+ @TestTemplate
+ public void testCreateSSLContextForServerThrowsWhenNeitherKeystoreSet() {
+ conf.unset(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+ conf.unset(X509Util.TLS_CONFIG_SERVER_KEYSTORE_LOCATION);
+ SSLContextException ex =
+ assertThrows(SSLContextException.class, () -> X509Util.createSslContextForServer(conf));
+ // The error should name both keys so the operator knows what to set.
+ assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_SERVER_KEYSTORE_LOCATION),
+ "message should mention role-scoped key, got: " + ex.getMessage());
+ assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_KEYSTORE_LOCATION),
+ "message should mention legacy key, got: " + ex.getMessage());
+ }
+
+ @TestTemplate
+ public void testValidateClientAuthTrustStoreRejectsNeedWithoutTrustStore() {
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+ () -> X509Util.validateClientAuthTrustStore(X509Util.ClientAuth.NEED, null, "the.mode.key",
+ "the.truststore.key"));
+ assertTrue(ex.getMessage().contains("the.mode.key"), ex.getMessage());
+ assertTrue(ex.getMessage().contains("the.truststore.key"), ex.getMessage());
+ }
+
+ @TestTemplate
+ public void testValidateClientAuthTrustStoreAllowsNoneWithoutTrustStore() {
+ // NONE never requests a peer certificate, so a missing truststore is not a misconfiguration.
+ X509Util.validateClientAuthTrustStore(X509Util.ClientAuth.NONE, null, "mode.key", "ts.key");
+ }
+
+ // A role-scoped password with no role-scoped location would be applied to the legacy keystore.
+ @TestTemplate
+ public void testCreateSSLContextForClientRejectsRoleKeyWithoutRoleLocation() {
+ // The base conf carries a complete legacy keystore, so only the role-scoped location is
+ // missing.
+ conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD, "orphan-pw");
+ IllegalArgumentException ex =
+ assertThrows(IllegalArgumentException.class, () -> X509Util.createSslContextForClient(conf));
+ assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD),
+ ex.getMessage());
+ assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_LOCATION),
+ ex.getMessage());
+ }
+
+ @TestTemplate
+ public void testResolveStoreRejectsRoleKeyWithoutRoleLocation() {
+ Configuration c = new Configuration(false);
+ c.set("p.role.keystore.password", "pw2");
+ c.set("p.keystore.location", "/old.jks");
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+ () -> TLSStore.resolve(c, "p.role.", "p.", TLSStore.Keys.RPC_KEYSTORE));
+ assertTrue(ex.getMessage().contains("p.role.keystore.password"), ex.getMessage());
+ assertTrue(ex.getMessage().contains("p.role.keystore.location"), ex.getMessage());
+ }
+
+ /**
+ * A store is a unit: the prefix that supplies the location supplies every other attribute too.
+ * Resolving per key would open the role-scoped file with the legacy password and type.
+ */
+ @TestTemplate
+ public void testResolveStoreNeverMixesPrefixes() throws Exception {
+ Configuration c = new Configuration(false);
+ c.set("p.role.keystore.location", "/new.p12");
+ c.set("p.keystore.location", "/old.jks");
+ c.set("p.keystore.password", "legacy-pw");
+ c.set("p.keystore.type", "JKS");
+
+ TLSStore store = TLSStore.resolve(c, "p.role.", "p.", TLSStore.Keys.RPC_KEYSTORE);
+ assertEquals("/new.p12", store.getLocation());
+ assertNull(store.getPassword(), "password must not be taken from the legacy prefix");
+ assertEquals("", store.getType(), "type must not be taken from the legacy prefix");
+ }
+
+ // Deployments that never adopt the role-scoped prefix keep resolving from the legacy keys.
+ @TestTemplate
+ public void testResolveStoreFallsBackToLegacyPrefix() throws Exception {
+ Configuration c = new Configuration(false);
+ c.set("p.keystore.location", "/old.jks");
+ c.set("p.keystore.password", "pw");
+ c.set("p.keystore.type", "JKS");
+
+ TLSStore store = TLSStore.resolve(c, "p.role.", "p.", TLSStore.Keys.RPC_KEYSTORE);
+ assertEquals("/old.jks", store.getLocation());
+ assertEquals("pw", store.getPassword());
+ assertEquals("JKS", store.getType());
+ }
}
diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
index 49af0939d5a..7019f530610 100644
--- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
@@ -227,6 +227,7 @@ public class HttpServer implements FilterContainer {
private String usernameConfKey;
private String keytabConfKey;
private boolean needsClientAuth;
+ private boolean wantsClientAuth;
private String includeCiphers;
private String excludeCiphers;
private String includeProtocols;
@@ -311,13 +312,31 @@ public class HttpServer implements FilterContainer {
}
/**
- * Specify whether the server should authorize the client in SSL connections.
+ * Specify whether the server should require a client certificate during the SSL handshake
+ * (mTLS). When true, clients that do not present a valid certificate are rejected.
+ * <p>
+ * Takes precedence over {@link #wantsClientAuth(boolean)} in Jetty when both are set.
+ * <p>
+ * Callers enabling either mode must also configure a truststore, since Jetty otherwise silently
+ * falls back to the keystore or the JVM cacerts as the client-cert trust anchor. See
+ * {@link org.apache.hadoop.hbase.io.crypto.tls.X509Util#validateClientAuthTrustStore}.
*/
public Builder needsClientAuth(boolean value) {
this.needsClientAuth = value;
return this;
}
+ /**
+ * Specify whether the server should request a client certificate during the SSL handshake but
+ * still accept clients that do not present one. Weaker than {@link #needsClientAuth(boolean)}:
+ * use this to signal "opportunistic mTLS" where a client cert is validated when supplied but
+ * its absence is tolerated.
+ */
+ public Builder wantsClientAuth(boolean value) {
+ this.wantsClientAuth = value;
+ return this;
+ }
+
/**
* @see #setAppDir(String)
* @deprecated Since 0.99.0. Use {@link #setAppDir(String)} instead.
@@ -476,6 +495,7 @@ public class HttpServer implements FilterContainer {
httpsConfig.addCustomizer(new SecureRequestCustomizer());
SslContextFactory.Server sslCtxFactory = new SslContextFactory.Server();
sslCtxFactory.setNeedClientAuth(needsClientAuth);
+ sslCtxFactory.setWantClientAuth(wantsClientAuth);
sslCtxFactory.setKeyManagerPassword(keyPassword);
if (keyStore != null) {
diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java
index 5a09315ed77..99cbd5a1652 100644
--- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java
@@ -22,9 +22,11 @@ import java.net.URI;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.CommonConfigurationKeys;
import org.apache.hadoop.hbase.HBaseConfiguration;
+import org.apache.hadoop.hbase.io.crypto.tls.X509Util;
import org.apache.hadoop.security.authorize.AccessControlList;
import org.apache.yetus.audience.InterfaceAudience;
@@ -44,6 +46,17 @@ public class InfoServer {
private static final String HADOOP_WEB_TLS_CONFIG_PREFIX = "ssl.server.";
private static final String HBASE_WEB_TLS_CONFIG_PREFIX = "hbase.ui.ssl.";
+ // Role-scoped prefix for single-EKU certificate support. When set, takes precedence over both
+ // HBASE_WEB_TLS_CONFIG_PREFIX and HADOOP_WEB_TLS_CONFIG_PREFIX. The UI process only ever plays
+ // the TLS-server role, so no parallel .client. prefix is defined.
+ private static final String HBASE_WEB_TLS_SERVER_CONFIG_PREFIX = "hbase.ui.ssl.server.";
+ private static final String TRUSTSTORE_LOCATION = "truststore.location";
+ /**
+ * Config key controlling whether the UI's TLS connector requests or requires a client
+ * certificate. Valid values: {@code NONE} (default), {@code WANT}, {@code NEED}.
+ */
+ static final String HBASE_UI_SSL_CLIENT_AUTH_MODE =
+ HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + "client.auth.mode";
/**
* Create a status server on the given port. The jsp scripts are taken from
@@ -73,16 +86,35 @@ public class InfoServer {
// We are using the Hadoop HTTP server config properties.
// This makes it easy to keep in sync with Hadoop's UI servers, but hard to set this
// separately for HBase.
+ String trustStoreLocation = getTLSProperty(c, TRUSTSTORE_LOCATION);
+
builder.keyPassword(getTLSPassword(c, "keystore.keypassword"))
.keyStore(getTLSProperty(c, "keystore.location"), getTLSPassword(c, "keystore.password"),
getTLSProperty(c, "keystore.type", "jks"))
- .trustStore(getTLSProperty(c, "truststore.location"),
- getTLSPassword(c, "truststore.password"), getTLSProperty(c, "truststore.type", "jks"))
+ .trustStore(trustStoreLocation, getTLSPassword(c, "truststore.password"),
+ getTLSProperty(c, "truststore.type", "jks"))
// The ssl.server.*.protocols properties do not exist in Hadoop at the time of writing.
.setIncludeProtocols(getTLSProperty(c, "include.protocols"))
.setExcludeProtocols(getTLSProperty(c, "exclude.protocols"))
.setIncludeCiphers(getTLSProperty(c, "include.cipher.list"))
.setExcludeCiphers(getTLSProperty(c, "exclude.cipher.list"));
+
+ // Activate mutual TLS if configured. Default is NONE, which preserves today's behavior of
+ // never requesting a client certificate on the UI connector (leaving any configured
+ // truststore inert for peer verification). Set hbase.ui.ssl.server.client.auth.mode to
+ // WANT or NEED to opt in. The client.auth.mode key is looked up directly on
+ // HBASE_WEB_TLS_SERVER_CONFIG_PREFIX; there is no legacy or Hadoop-prefixed fallback.
+ // getTrimmed + defaultIfBlank so that an empty or whitespace-only <value></value> in
+ // hbase-site.xml behaves like an absent key. A bare fromPropertyValue("") would return NEED.
+ X509Util.ClientAuth clientAuth =
+ X509Util.ClientAuth.fromPropertyValue(StringUtils.defaultIfBlank(
+ c.getTrimmed(HBASE_UI_SSL_CLIENT_AUTH_MODE), X509Util.ClientAuth.NONE.name()));
+ X509Util.validateClientAuthTrustStore(clientAuth, trustStoreLocation,
+ HBASE_UI_SSL_CLIENT_AUTH_MODE, HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + TRUSTSTORE_LOCATION,
+ HBASE_WEB_TLS_CONFIG_PREFIX + TRUSTSTORE_LOCATION,
+ HADOOP_WEB_TLS_CONFIG_PREFIX + TRUSTSTORE_LOCATION);
+ builder.needsClientAuth(clientAuth == X509Util.ClientAuth.NEED)
+ .wantsClientAuth(clientAuth == X509Util.ClientAuth.WANT);
}
final String httpAuthType = c.get(HttpServer.HTTP_UI_AUTHENTICATION, "").toLowerCase();
@@ -104,18 +136,31 @@ public class InfoServer {
this.httpServer = builder.build();
}
- private String getTLSPassword(Configuration c, String postfix) throws IOException {
- return HBaseConfiguration.getPassword(c, HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
- HBaseConfiguration.getPassword(c, HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, null));
+ /**
+ * Resolves a TLS-related password with a 3-tier fallback: the role-scoped
+ * {@code hbase.ui.ssl.server.<postfix>} key is checked first, then the unscoped
+ * {@code hbase.ui.ssl.<postfix>} key, and finally Hadoop's {@code ssl.server.<postfix>}.
+ * Package-private for direct testing.
+ */
+ static String getTLSPassword(Configuration c, String postfix) throws IOException {
+ return HBaseConfiguration.getPassword(c, HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + postfix,
+ HBaseConfiguration.getPassword(c, HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
+ HBaseConfiguration.getPassword(c, HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, null)));
}
- private String getTLSProperty(Configuration c, String postfix) {
+ static String getTLSProperty(Configuration c, String postfix) {
return getTLSProperty(c, postfix, null);
}
- private String getTLSProperty(Configuration c, String postfix, String defaultValue) {
- return c.get(HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
- c.get(HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, defaultValue));
+ /**
+ * Resolves a TLS-related property with a 3-tier fallback: role-scoped
+ * {@code hbase.ui.ssl.server.<postfix>} → {@code hbase.ui.ssl.<postfix>} →
+ * {@code ssl.server.<postfix>} → {@code defaultValue}. Package-private for direct testing.
+ */
+ static String getTLSProperty(Configuration c, String postfix, String defaultValue) {
+ return c.get(HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + postfix,
+ c.get(HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
+ c.get(HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, defaultValue)));
}
/**
diff --git a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestInfoServerTLSConfig.java b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestInfoServerTLSConfig.java
new file mode 100644
index 00000000000..8b6bd36de45
--- /dev/null
+++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestInfoServerTLSConfig.java
@@ -0,0 +1,93 @@
+/*
+ * 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.hadoop.hbase.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.testclassification.MiscTests;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link InfoServer}'s TLS-config resolution: the 3-tier fallback chain
+ * ({@code hbase.ui.ssl.server.*} → {@code hbase.ui.ssl.*} → {@code ssl.server.*}) that underpins
+ * single-EKU certificate support on the UI surface.
+ */
+@Tag(MiscTests.TAG)
+@Tag(SmallTests.TAG)
+public class TestInfoServerTLSConfig {
+
+ private static final String POSTFIX = "keystore.location";
+ private static final String ROLE_SCOPED_KEY = "hbase.ui.ssl.server." + POSTFIX;
+ private static final String HBASE_KEY = "hbase.ui.ssl." + POSTFIX;
+ private static final String HADOOP_KEY = "ssl.server." + POSTFIX;
+
+ @Test
+ public void testRoleScopedTakesPrecedenceOverHBasePrefixed() {
+ Configuration c = new Configuration(false);
+ c.set(ROLE_SCOPED_KEY, "role-scoped-value");
+ c.set(HBASE_KEY, "hbase-prefixed-value");
+ c.set(HADOOP_KEY, "hadoop-prefixed-value");
+ assertEquals("role-scoped-value", InfoServer.getTLSProperty(c, POSTFIX));
+ }
+
+ @Test
+ public void testHBasePrefixedTakesPrecedenceOverHadoopPrefixed() {
+ Configuration c = new Configuration(false);
+ // No role-scoped key set — hbase-prefixed key must win.
+ c.set(HBASE_KEY, "hbase-prefixed-value");
+ c.set(HADOOP_KEY, "hadoop-prefixed-value");
+ assertEquals("hbase-prefixed-value", InfoServer.getTLSProperty(c, POSTFIX));
+ }
+
+ @Test
+ public void testFallsBackToHadoopPrefixedWhenNoOthersSet() {
+ Configuration c = new Configuration(false);
+ c.set(HADOOP_KEY, "hadoop-prefixed-value");
+ assertEquals("hadoop-prefixed-value", InfoServer.getTLSProperty(c, POSTFIX));
+ }
+
+ @Test
+ public void testReturnsDefaultWhenNoneSet() {
+ Configuration c = new Configuration(false);
+ assertEquals("jks", InfoServer.getTLSProperty(c, POSTFIX, "jks"));
+ assertNull(InfoServer.getTLSProperty(c, POSTFIX));
+ }
+
+ @Test
+ public void testGetTLSPasswordHonorsThreeTierFallback() throws Exception {
+ Configuration c = new Configuration(false);
+ // Configuration.getPassword returns null when unset; verifying the fallback picks each tier.
+ c.set(ROLE_SCOPED_KEY.replace("keystore.location", "keystore.password"), "role-pw");
+ c.set(HBASE_KEY.replace("keystore.location", "keystore.password"), "hbase-pw");
+ c.set(HADOOP_KEY.replace("keystore.location", "keystore.password"), "hadoop-pw");
+ assertEquals("role-pw", InfoServer.getTLSPassword(c, "keystore.password"));
+
+ c.unset(ROLE_SCOPED_KEY.replace("keystore.location", "keystore.password"));
+ assertEquals("hbase-pw", InfoServer.getTLSPassword(c, "keystore.password"));
+
+ c.unset(HBASE_KEY.replace("keystore.location", "keystore.password"));
+ assertEquals("hadoop-pw", InfoServer.getTLSPassword(c, "keystore.password"));
+
+ c.unset(HADOOP_KEY.replace("keystore.location", "keystore.password"));
+ assertNull(InfoServer.getTLSPassword(c, "keystore.password"));
+ }
+}
diff --git a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java
index b3d603c660e..589c07054f4 100644
--- a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java
+++ b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java
@@ -60,6 +60,31 @@ public interface Constants {
String REST_SSL_EXCLUDE_PROTOCOLS = "hbase.rest.ssl.exclude.protocols";
String REST_SSL_INCLUDE_PROTOCOLS = "hbase.rest.ssl.include.protocols";
+ // ---------------------------------------------------------------------------
+ // Role-scoped SSL configuration for single-EKU certificate support.
+ //
+ // The server-scoped keys are what RESTServer.java actually reads: hbase.rest.ssl.server.*
+ // takes precedence over the corresponding unscoped hbase.rest.ssl.* keys; when unset the
+ // unscoped keys are used as a fallback so existing deployments keep working. RESTServer
+ // only ever plays the TLS-server role on its port, so no parallel .client.* configuration
+ // is defined.
+ // ---------------------------------------------------------------------------
+ String REST_SSL_SERVER_KEYSTORE_STORE = "hbase.rest.ssl.server.keystore.store";
+ String REST_SSL_SERVER_KEYSTORE_PASSWORD = "hbase.rest.ssl.server.keystore.password";
+ String REST_SSL_SERVER_KEYSTORE_KEYPASSWORD = "hbase.rest.ssl.server.keystore.keypassword";
+ String REST_SSL_SERVER_KEYSTORE_TYPE = "hbase.rest.ssl.server.keystore.type";
+ String REST_SSL_SERVER_TRUSTSTORE_STORE = "hbase.rest.ssl.server.truststore.store";
+ String REST_SSL_SERVER_TRUSTSTORE_PASSWORD = "hbase.rest.ssl.server.truststore.password";
+ String REST_SSL_SERVER_TRUSTSTORE_TYPE = "hbase.rest.ssl.server.truststore.type";
+
+ /**
+ * Client-auth mode for the REST server's TLS connector. Valid values: {@code NONE}, {@code WANT},
+ * {@code NEED} (see {@code X509Util.ClientAuth}). Default is {@code NONE}, which preserves the
+ * historical behavior of never requesting client certificates. Set to {@code NEED} to enforce
+ * mTLS.
+ */
+ String REST_SSL_CLIENT_AUTH_MODE = "hbase.rest.ssl.server.client.auth.mode";
+
String REST_THREAD_POOL_THREADS_MAX = "hbase.rest.threads.max";
String REST_THREAD_POOL_THREADS_MIN = "hbase.rest.threads.min";
String REST_THREAD_POOL_TASK_QUEUE_SIZE = "hbase.rest.task.queue.size";
diff --git a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java
index 149d634cbd5..100eaa88e0a 100644
--- a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java
+++ b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java
@@ -37,6 +37,8 @@ import org.apache.hadoop.hbase.HBaseInterfaceAudience;
import org.apache.hadoop.hbase.ServerName;
import org.apache.hadoop.hbase.http.HttpServerUtil;
import org.apache.hadoop.hbase.http.InfoServer;
+import org.apache.hadoop.hbase.io.crypto.tls.TLSStore;
+import org.apache.hadoop.hbase.io.crypto.tls.X509Util;
import org.apache.hadoop.hbase.log.HBaseMarkers;
import org.apache.hadoop.hbase.rest.filter.AuthFilter;
import org.apache.hadoop.hbase.rest.filter.GzipFilter;
@@ -305,11 +307,16 @@ public class RESTServer implements Constants {
httpsConfig.addCustomizer(new SecureRequestCustomizer());
SslContextFactory.Server sslCtxFactory = new SslContextFactory.Server();
- String keystore = conf.get(REST_SSL_KEYSTORE_STORE);
- String keystoreType = conf.get(REST_SSL_KEYSTORE_TYPE);
- String password = HBaseConfiguration.getPassword(conf, REST_SSL_KEYSTORE_PASSWORD, null);
- String keyPassword =
- HBaseConfiguration.getPassword(conf, REST_SSL_KEYSTORE_KEYPASSWORD, password);
+ // Prefer the role-scoped hbase.rest.ssl.server.* keys, falling back to the historical
+ // unscoped hbase.rest.ssl.* keys for backward compatibility with existing deployments. A
+ // store resolves entirely from one prefix, so a role-scoped keystore is never paired with the
+ // legacy keystore's password or type.
+ TLSStore keyStore = TLSStore.resolve(conf, "hbase.rest.ssl.server.", "hbase.rest.ssl.",
+ TLSStore.Keys.SERVLET_KEYSTORE);
+ String keystore = keyStore.getLocation();
+ String keystoreType = keyStore.getType();
+ String password = keyStore.getPassword();
+ String keyPassword = keyStore.getKeyPassword();
sslCtxFactory.setKeyStorePath(keystore);
if (StringUtils.isNotBlank(keystoreType)) {
sslCtxFactory.setKeyStoreType(keystoreType);
@@ -317,20 +324,44 @@ public class RESTServer implements Constants {
sslCtxFactory.setKeyStorePassword(password);
sslCtxFactory.setKeyManagerPassword(keyPassword);
- String trustStore = conf.get(REST_SSL_TRUSTSTORE_STORE);
+ TLSStore trust = TLSStore.resolve(conf, "hbase.rest.ssl.server.", "hbase.rest.ssl.",
+ TLSStore.Keys.SERVLET_TRUSTSTORE);
+ String trustStore = trust.getLocation();
if (StringUtils.isNotBlank(trustStore)) {
sslCtxFactory.setTrustStorePath(trustStore);
}
- String trustStorePassword =
- HBaseConfiguration.getPassword(conf, REST_SSL_TRUSTSTORE_PASSWORD, null);
+ String trustStorePassword = trust.getPassword();
if (StringUtils.isNotBlank(trustStorePassword)) {
sslCtxFactory.setTrustStorePassword(trustStorePassword);
}
- String trustStoreType = conf.get(REST_SSL_TRUSTSTORE_TYPE);
+ String trustStoreType = trust.getType();
if (StringUtils.isNotBlank(trustStoreType)) {
sslCtxFactory.setTrustStoreType(trustStoreType);
}
+ // Activate mTLS if configured. Default is NONE, which preserves today's behavior of never
+ // requesting a client certificate — even when a truststore is configured. Set
+ // hbase.rest.ssl.server.client.auth.mode to WANT or NEED to opt in.
+ // getTrimmed + defaultIfBlank so that an empty or whitespace-only <value></value> in
+ // hbase-site.xml behaves like an absent key. A bare fromPropertyValue("") would return NEED.
+ X509Util.ClientAuth clientAuth =
+ X509Util.ClientAuth.fromPropertyValue(StringUtils.defaultIfBlank(
+ conf.getTrimmed(REST_SSL_CLIENT_AUTH_MODE), X509Util.ClientAuth.NONE.name()));
+ X509Util.validateClientAuthTrustStore(clientAuth, trustStore, REST_SSL_CLIENT_AUTH_MODE,
+ REST_SSL_SERVER_TRUSTSTORE_STORE, REST_SSL_TRUSTSTORE_STORE);
+ switch (clientAuth) {
+ case NEED:
+ sslCtxFactory.setNeedClientAuth(true);
+ break;
+ case WANT:
+ sslCtxFactory.setWantClientAuth(true);
+ break;
+ case NONE:
+ default:
+ // no-op; both flags default to false on SslContextFactory.Server
+ break;
+ }
+
String[] excludeCiphers = servlet.getConfiguration()
.getStrings(REST_SSL_EXCLUDE_CIPHER_SUITES, ArrayUtils.EMPTY_STRING_ARRAY);
if (excludeCiphers.length != 0) {
diff --git a/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java b/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java
index a123f714a01..12905672318 100644
--- a/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java
+++ b/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.File;
+import java.io.IOException;
import java.lang.reflect.Method;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
@@ -160,6 +161,98 @@ public class TestRESTServerSSL {
assertEquals(200, response.getCode());
}
+ // ---------------------------------------------------------------------------
+ // Role-scoped configuration + mTLS (single-EKU certificate support).
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Server started with only the role-scoped hbase.rest.ssl.server.* keys (and legacy keys unset).
+ * A successful SSL connection proves the server-scoped keys were consulted.
+ */
+ @Test
+ public void testSslConnectionUsingRoleScopedServerKeys() throws Exception {
+ // Move the legacy passwords set in beforeEachTest onto the role-scoped keys and clear the
+ // legacy passwords so a successful start proves the server-scoped keys are what the code
+ // actually picked up.
+ conf.unset(Constants.REST_SSL_KEYSTORE_PASSWORD);
+ conf.unset(Constants.REST_SSL_KEYSTORE_KEYPASSWORD);
+ conf.unset(Constants.REST_SSL_TRUSTSTORE_PASSWORD);
+ conf.set(Constants.REST_SSL_SERVER_KEYSTORE_PASSWORD, KEY_STORE_PASSWORD);
+ conf.set(Constants.REST_SSL_SERVER_KEYSTORE_KEYPASSWORD, KEY_STORE_PASSWORD);
+ conf.set(Constants.REST_SSL_SERVER_TRUSTSTORE_PASSWORD, TRUST_STORE_PASSWORD);
+ conf.set(Constants.REST_SSL_SERVER_KEYSTORE_STORE, getKeystoreFilePath("jks"));
+ conf.set(Constants.REST_SSL_SERVER_TRUSTSTORE_STORE, getTruststoreFilePath("jks"));
+
+ REST_TEST_UTIL.startServletContainer(conf);
+ Cluster localCluster = new Cluster().add("localhost", REST_TEST_UTIL.getServletPort());
+ sslClient = new Client(localCluster, getTruststoreFilePath("jks"),
+ Optional.of(TRUST_STORE_PASSWORD), Optional.empty());
+
+ Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+ assertEquals(200, response.getCode());
+ }
+
+ /**
+ * Backward-compatibility regression: existing deployments that know only about the legacy
+ * unscoped keys must continue to work exactly as before. This mirrors {@link #testSslConnection}
+ * but names the intent explicitly.
+ */
+ @Test
+ public void testSslConnectionFallsBackToLegacyKeystoreKeys() throws Exception {
+ // Make sure no role-scoped key is set — the beforeEachTest configures only legacy passwords,
+ // so this is a fresh state check.
+ conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_STORE);
+ conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_PASSWORD);
+ conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_KEYPASSWORD);
+ conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_TYPE);
+ conf.unset(Constants.REST_SSL_SERVER_TRUSTSTORE_STORE);
+ conf.unset(Constants.REST_SSL_SERVER_TRUSTSTORE_PASSWORD);
+ conf.unset(Constants.REST_SSL_SERVER_TRUSTSTORE_TYPE);
+
+ startRESTServerWithDefaultKeystoreType();
+
+ Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+ assertEquals(200, response.getCode());
+ }
+
+ /**
+ * With {@code client.auth.mode=NONE} (the default), a client that presents no client certificate
+ * is accepted — matching today's behavior.
+ */
+ @Test
+ public void testClientAuthNoneAcceptsClientWithoutCert() throws Exception {
+ conf.set(Constants.REST_SSL_CLIENT_AUTH_MODE, "NONE");
+ startRESTServerWithDefaultKeystoreType();
+
+ Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+ assertEquals(200, response.getCode());
+ }
+
+ /**
+ * With {@code client.auth.mode=WANT}, an anonymous client (no client cert) is still accepted; the
+ * server requests a cert but does not require it.
+ */
+ @Test
+ public void testClientAuthWantAllowsAnonymousClient() throws Exception {
+ conf.set(Constants.REST_SSL_CLIENT_AUTH_MODE, "WANT");
+ startRESTServerWithDefaultKeystoreType();
+
+ Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+ assertEquals(200, response.getCode());
+ }
+
+ /**
+ * With {@code client.auth.mode=NEED}, an anonymous client (no client cert) is rejected by the
+ * server. The base {@link Client} configures truststore-only, so it presents no key material to
+ * the server.
+ */
+ @Test
+ public void testClientAuthNeedRejectsClientWithoutCert() throws Exception {
+ conf.set(Constants.REST_SSL_CLIENT_AUTH_MODE, "NEED");
+ startRESTServerWithDefaultKeystoreType();
+ assertThrows(IOException.class, () -> sslClient.get("/version"));
+ }
+
private static File initKeystoreDir() {
String dataTestDir = TEST_UTIL.getDataTestDir().toString();
File keystoreDir = new File(dataTestDir, TestRESTServerSSL.class.getSimpleName() + "_keys");
diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java
index b21b6e19c78..718279cc800 100644
--- a/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java
+++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java
@@ -465,7 +465,7 @@ public class NettyRpcServer extends RpcServer {
keyStoreWatcher.get() == null && trustStoreWatcher.get() == null
&& conf.getBoolean(X509Util.TLS_CERT_RELOAD, false)
) {
- X509Util.enableCertFileReloading(conf, keyStoreWatcher, trustStoreWatcher,
+ X509Util.enableCertFileReloadingForServer(conf, keyStoreWatcher, trustStoreWatcher,
() -> sslContextForServer.set(null));
}
}
diff --git a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java
index 83bb90eecef..5766cfec83a 100644
--- a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java
+++ b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java
@@ -71,6 +71,35 @@ public final class Constants {
public static final String THRIFT_SSL_KEYSTORE_TYPE_KEY = "hbase.thrift.ssl.keystore.type";
public static final String THRIFT_SSL_KEYSTORE_TYPE_DEFAULT = "jks";
+ /**
+ * Role-scoped SSL configuration for single-EKU certificate support. The server-scoped
+ * hbase.thrift.ssl.server.* keys are what ThriftServer reads on the Thrift-over-HTTP transport;
+ * when set they take precedence over the corresponding unscoped hbase.thrift.ssl.* keys; when
+ * unset the unscoped keys are used as a fallback so existing deployments keep working.
+ */
+ public static final String THRIFT_SSL_SERVER_KEYSTORE_STORE_KEY =
+ "hbase.thrift.ssl.server.keystore.store";
+ public static final String THRIFT_SSL_SERVER_KEYSTORE_PASSWORD_KEY =
+ "hbase.thrift.ssl.server.keystore.password";
+ public static final String THRIFT_SSL_SERVER_KEYSTORE_KEYPASSWORD_KEY =
+ "hbase.thrift.ssl.server.keystore.keypassword";
+ public static final String THRIFT_SSL_SERVER_KEYSTORE_TYPE_KEY =
+ "hbase.thrift.ssl.server.keystore.type";
+ public static final String THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY =
+ "hbase.thrift.ssl.server.truststore.store";
+ public static final String THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY =
+ "hbase.thrift.ssl.server.truststore.password";
+ public static final String THRIFT_SSL_SERVER_TRUSTSTORE_TYPE_KEY =
+ "hbase.thrift.ssl.server.truststore.type";
+
+ /**
+ * Client-auth mode for the Thrift server's HTTP-transport TLS connector. Valid values:
+ * {@code NONE}, {@code WANT}, {@code NEED} (see {@code X509Util.ClientAuth}). Default is
+ * {@code NONE}, which preserves the historical behavior of never requesting client certificates.
+ */
+ public static final String THRIFT_SSL_CLIENT_AUTH_MODE_KEY =
+ "hbase.thrift.ssl.server.client.auth.mode";
+
public static final String THRIFT_SUPPORT_PROXYUSER_KEY = "hbase.thrift.support.proxyuser";
// kerberos related configs
diff --git a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java
index 38be5daf77f..dbc438a22a2 100644
--- a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java
+++ b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java
@@ -61,16 +61,16 @@ import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SERVER_SOCKET_READ
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SERVER_SOCKET_READ_TIMEOUT_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SPNEGO_KEYTAB_FILE_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SPNEGO_PRINCIPAL_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_ENABLED_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_EXCLUDE_CIPHER_SUITES_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_EXCLUDE_PROTOCOLS_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_INCLUDE_CIPHER_SUITES_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_INCLUDE_PROTOCOLS_KEY;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_STORE_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_TYPE_DEFAULT;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_TYPE_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_SERVER_TRUSTSTORE_TYPE_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SUPPORT_PROXYUSER_KEY;
import static org.apache.hadoop.hbase.thrift.Constants.USE_HTTP_CONF_KEY;
@@ -91,6 +91,7 @@ import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.sasl.AuthorizeCallback;
import javax.security.sasl.SaslServer;
import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.conf.Configured;
import org.apache.hadoop.hbase.HBaseConfiguration;
@@ -98,6 +99,8 @@ import org.apache.hadoop.hbase.HBaseInterfaceAudience;
import org.apache.hadoop.hbase.filter.ParseFilter;
import org.apache.hadoop.hbase.http.HttpServerUtil;
import org.apache.hadoop.hbase.http.InfoServer;
+import org.apache.hadoop.hbase.io.crypto.tls.TLSStore;
+import org.apache.hadoop.hbase.io.crypto.tls.X509Util;
import org.apache.hadoop.hbase.log.HBaseMarkers;
import org.apache.hadoop.hbase.security.SaslUtil;
import org.apache.hadoop.hbase.security.SecurityUtil;
@@ -408,16 +411,57 @@ public class ThriftServer extends Configured implements Tool {
httpsConfig.addCustomizer(new SecureRequestCustomizer());
SslContextFactory.Server sslCtxFactory = new SslContextFactory.Server();
- String keystore = conf.get(THRIFT_SSL_KEYSTORE_STORE_KEY);
- String password =
- HBaseConfiguration.getPassword(conf, THRIFT_SSL_KEYSTORE_PASSWORD_KEY, null);
- String keyPassword =
- HBaseConfiguration.getPassword(conf, THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, password);
- sslCtxFactory.setKeyStorePath(keystore);
- sslCtxFactory.setKeyStorePassword(password);
- sslCtxFactory.setKeyManagerPassword(keyPassword);
- sslCtxFactory
- .setKeyStoreType(conf.get(THRIFT_SSL_KEYSTORE_TYPE_KEY, THRIFT_SSL_KEYSTORE_TYPE_DEFAULT));
+ // Prefer the role-scoped hbase.thrift.ssl.server.* keys, falling back to the historical
+ // unscoped hbase.thrift.ssl.* keys for backward compatibility with existing deployments. A
+ // store resolves entirely from one prefix, so a role-scoped keystore is never paired with the
+ // legacy keystore's password or type.
+ TLSStore keyStore = TLSStore.resolve(conf, "hbase.thrift.ssl.server.", "hbase.thrift.ssl.",
+ TLSStore.Keys.SERVLET_KEYSTORE);
+ sslCtxFactory.setKeyStorePath(keyStore.getLocation());
+ sslCtxFactory.setKeyStorePassword(keyStore.getPassword());
+ sslCtxFactory.setKeyManagerPassword(keyStore.getKeyPassword());
+ sslCtxFactory.setKeyStoreType(
+ StringUtils.defaultIfBlank(keyStore.getType(), THRIFT_SSL_KEYSTORE_TYPE_DEFAULT));
+
+ // Truststore is entirely new for Thrift — no legacy fallback because there is no historical
+ // hbase.thrift.ssl.truststore.* configuration. Leaving it unset is only valid when client
+ // auth is NONE
+ String trustStore = conf.get(THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY);
+ if (StringUtils.isNotBlank(trustStore)) {
+ sslCtxFactory.setTrustStorePath(trustStore);
+ String trustStorePassword =
+ HBaseConfiguration.getPassword(conf, THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, null);
+ if (StringUtils.isNotBlank(trustStorePassword)) {
+ sslCtxFactory.setTrustStorePassword(trustStorePassword);
+ }
+ String trustStoreType = conf.get(THRIFT_SSL_SERVER_TRUSTSTORE_TYPE_KEY);
+ if (StringUtils.isNotBlank(trustStoreType)) {
+ sslCtxFactory.setTrustStoreType(trustStoreType);
+ }
+ }
+
+ // Activate mTLS if configured. Default is NONE, which preserves today's behavior of never
+ // requesting a client certificate on the Thrift-over-HTTP connector. Set
+ // hbase.thrift.ssl.server.client.auth.mode to WANT or NEED to opt in.
+ // getTrimmed + defaultIfBlank so that an empty or whitespace-only <value></value> in
+ // hbase-site.xml behaves like an absent key. A bare fromPropertyValue("") would return NEED.
+ X509Util.ClientAuth clientAuth =
+ X509Util.ClientAuth.fromPropertyValue(StringUtils.defaultIfBlank(
+ conf.getTrimmed(THRIFT_SSL_CLIENT_AUTH_MODE_KEY), X509Util.ClientAuth.NONE.name()));
+ X509Util.validateClientAuthTrustStore(clientAuth, trustStore, THRIFT_SSL_CLIENT_AUTH_MODE_KEY,
+ THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY);
+ switch (clientAuth) {
+ case NEED:
+ sslCtxFactory.setNeedClientAuth(true);
+ break;
+ case WANT:
+ sslCtxFactory.setWantClientAuth(true);
+ break;
+ case NONE:
+ default:
+ // no-op; both flags default to false on SslContextFactory.Server
+ break;
+ }
String[] excludeCiphers =
conf.getStrings(THRIFT_SSL_EXCLUDE_CIPHER_SUITES_KEY, ArrayUtils.EMPTY_STRING_ARRAY);
diff --git a/hbase-thrift/src/test/java/org/apache/hadoop/hbase/thrift/TestThriftServerSSLMutualAuth.java b/hbase-thrift/src/test/java/org/apache/hadoop/hbase/thrift/TestThriftServerSSLMutualAuth.java
new file mode 100644
index 00000000000..37d7c340c61
--- /dev/null
+++ b/hbase-thrift/src/test/java/org/apache/hadoop/hbase/thrift/TestThriftServerSSLMutualAuth.java
@@ -0,0 +1,361 @@
+/*
+ * 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.hadoop.hbase.thrift;
+
+import static org.apache.hadoop.hbase.thrift.TestThriftServerCmdLine.createBoundServer;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.net.HttpURLConnection;
+import java.nio.file.Files;
+import java.security.KeyPair;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLContext;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.HConstants;
+import org.apache.hadoop.hbase.testclassification.ClientTests;
+import org.apache.hadoop.hbase.testclassification.LargeTests;
+import org.apache.hadoop.hbase.thrift.generated.Hbase;
+import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
+import org.apache.hadoop.hbase.util.EnvironmentEdgeManagerTestHelper;
+import org.apache.hadoop.hbase.util.IncrementingEnvironmentEdge;
+import org.apache.hadoop.hbase.util.TableDescriptorChecker;
+import org.apache.hadoop.security.ssl.KeyStoreTestUtil;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.ssl.SSLContexts;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hbase.thirdparty.org.apache.thrift.protocol.TBinaryProtocol;
+import org.apache.hbase.thirdparty.org.apache.thrift.protocol.TProtocol;
+import org.apache.hbase.thirdparty.org.apache.thrift.transport.TMemoryBuffer;
+
+/**
+ * Exercises the role-scoped {@code hbase.thrift.ssl.server.*} configuration and the new mutual-TLS
+ * activation on the Thrift-over-HTTP transport. Complements {@link TestThriftHttpServerSSL}, which
+ * covers plain (server-only) TLS termination.
+ */
+@Tag(ClientTests.TAG)
+@Tag(LargeTests.TAG)
+public class TestThriftServerSSLMutualAuth {
+
+ private static final Logger LOG = LoggerFactory.getLogger(TestThriftServerSSLMutualAuth.class);
+ private static final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil();
+ private static final String KEY_STORE_PASSWORD = "myKSPassword";
+ private static final String TRUST_STORE_PASSWORD = "myTSPassword";
+ private static final String CLIENT_KEY_STORE_PASSWORD = "myClientKSPassword";
+
+ private File keyDir;
+ private ThriftServerRunner tsr;
+ private HttpPost httpPost;
+
+ @BeforeAll
+ public static void setUpBeforeClass() throws Exception {
+ TEST_UTIL.getConfiguration().setBoolean(Constants.USE_HTTP_CONF_KEY, true);
+ TEST_UTIL.getConfiguration().setBoolean(TableDescriptorChecker.TABLE_SANITY_CHECKS, false);
+ TEST_UTIL.startMiniCluster();
+ EnvironmentEdgeManagerTestHelper.injectEdge(new IncrementingEnvironmentEdge());
+ }
+
+ @AfterAll
+ public static void tearDownAfterClass() throws Exception {
+ TEST_UTIL.shutdownMiniCluster();
+ EnvironmentEdgeManager.reset();
+ }
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ initializeAlgorithmId();
+ keyDir = initKeystoreDir();
+ keyDir.deleteOnExit();
+
+ // Server identity + a truststore holding the server's cert (used by the test client to
+ // trust the server).
+ KeyPair serverKeyPair = KeyStoreTestUtil.generateKeyPair("RSA");
+ X509Certificate serverCertificate = KeyStoreTestUtil
+ .generateCertificate("CN=localhost, O=server", serverKeyPair, 30, "SHA1withRSA");
+ generateTrustStore(getServerTruststoreFilePath(), serverCertificate);
+ generateKeyStore(getServerKeystoreFilePath(), serverKeyPair, serverCertificate);
+
+ // Distinct client cert (single-EKU clientAuth in spirit) and a truststore holding that cert
+ // — this is what the server uses to validate presented client certificates.
+ KeyPair clientKeyPair = KeyStoreTestUtil.generateKeyPair("RSA");
+ X509Certificate clientCertificate =
+ KeyStoreTestUtil.generateCertificate("CN=client, O=client", clientKeyPair, 30, "SHA1withRSA");
+ generateTrustStore(getClientCaTruststoreFilePath(), clientCertificate);
+ generateKeyStoreWithPassword(getClientKeystoreFilePath(), clientKeyPair, clientCertificate,
+ CLIENT_KEY_STORE_PASSWORD);
+ }
+
+ @AfterEach
+ public void tearDown() throws IOException {
+ if (httpPost != null) {
+ httpPost.releaseConnection();
+ }
+ if (tsr != null) {
+ tsr.close();
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Role-scoped keystore configuration.
+ // ---------------------------------------------------------------------------
+
+ /**
+ * With only the role-scoped {@code hbase.thrift.ssl.server.keystore.*} keys set (legacy keys
+ * unset), a plain HTTPS request succeeds — proving the server-scoped keys are the ones the
+ * bootstrap actually reads.
+ */
+ @Test
+ public void testServerUsesRoleScopedKeystoreWhenSet() throws Exception {
+ Configuration conf = baseConf();
+ conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_STORE_KEY, getServerKeystoreFilePath());
+ conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD);
+ conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD);
+
+ startServer(conf);
+ doRequestExpectingSuccess(clientBuilderWithServerTrust());
+ }
+
+ /**
+ * Backward-compatibility regression: existing deployments that know only about the legacy
+ * unscoped keys must continue to work.
+ */
+ @Test
+ public void testServerFallsBackToLegacyKeystore() throws Exception {
+ Configuration conf = baseConf();
+ conf.set(Constants.THRIFT_SSL_KEYSTORE_STORE_KEY, getServerKeystoreFilePath());
+ conf.set(Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD);
+ conf.set(Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD);
+
+ startServer(conf);
+ doRequestExpectingSuccess(clientBuilderWithServerTrust());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Mutual TLS (client auth mode).
+ // ---------------------------------------------------------------------------
+
+ /**
+ * With {@code client.auth.mode=NONE} (default), a client presenting no certificate is accepted —
+ * matches today's behavior.
+ */
+ @Test
+ public void testClientAuthNoneAcceptsClientWithoutCert() throws Exception {
+ Configuration conf = baseConfWithLegacyKeystore();
+ conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NONE");
+
+ startServer(conf);
+ doRequestExpectingSuccess(clientBuilderWithServerTrust());
+ }
+
+ /** {@code WANT} lets anonymous clients through. */
+ @Test
+ public void testClientAuthWantAllowsAnonymousClient() throws Exception {
+ Configuration conf = baseConfWithLegacyKeystore();
+ // WANT needs the server to know which CAs it would trust if a client did present a cert.
+ conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath());
+ conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD);
+ conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "WANT");
+
+ startServer(conf);
+ doRequestExpectingSuccess(clientBuilderWithServerTrust());
+ }
+
+ /**
+ * {@code NEED} rejects clients that do not present a valid client certificate. The handshake
+ * fails before any HTTP response is produced.
+ */
+ @Test
+ public void testClientAuthNeedRejectsClientWithoutCert() throws Exception {
+ Configuration conf = baseConfWithLegacyKeystore();
+ conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath());
+ conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD);
+ conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NEED");
+
+ startServer(conf);
+ // The Apache HttpClient surface may raise either SSLHandshakeException directly or wrap it
+ // in an IOException — both are acceptable evidence that the handshake was refused.
+ assertThrows(IOException.class, () -> doRequest(clientBuilderWithServerTrust()));
+ }
+
+ /** {@code NEED} accepts a client that presents a certificate trusted by the server. */
+ @Test
+ public void testClientAuthNeedAcceptsClientWithValidCert() throws Exception {
+ Configuration conf = baseConfWithLegacyKeystore();
+ conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath());
+ conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD);
+ conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NEED");
+
+ startServer(conf);
+ doRequestExpectingSuccess(clientBuilderWithMutualTrust());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private Configuration baseConf() throws Exception {
+ Configuration conf = new Configuration(TEST_UTIL.getConfiguration());
+ conf.setBoolean(Constants.THRIFT_SSL_ENABLED_KEY, true);
+ return conf;
+ }
+
+ private Configuration baseConfWithLegacyKeystore() throws Exception {
+ Configuration conf = baseConf();
+ conf.set(Constants.THRIFT_SSL_KEYSTORE_STORE_KEY, getServerKeystoreFilePath());
+ conf.set(Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD);
+ conf.set(Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD);
+ return conf;
+ }
+
+ private void startServer(Configuration conf) throws Exception {
+ tsr = createBoundServer(() -> new ThriftServer(conf));
+ String url = "https://" + HConstants.LOCALHOST + ":" + tsr.getThriftServer().listenPort;
+ httpPost = new HttpPost(url);
+ httpPost.setHeader("Content-Type", "application/x-thrift");
+ httpPost.setHeader("Accept", "application/x-thrift");
+ httpPost.setHeader("User-Agent", "Java/THttpClient/HC");
+ }
+
+ private HttpClientBuilder clientBuilderWithServerTrust() throws Exception {
+ KeyStore trustStore = loadJksTrustStore(getServerTruststoreFilePath(), TRUST_STORE_PASSWORD);
+ SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null).build();
+ return HttpClients.custom().setSSLContext(sslContext);
+ }
+
+ /**
+ * Builds a client that (a) trusts the server's cert and (b) presents the client keystore. Used to
+ * satisfy {@code client.auth.mode=NEED}.
+ */
+ private HttpClientBuilder clientBuilderWithMutualTrust() throws Exception {
+ KeyStore trustStore = loadJksTrustStore(getServerTruststoreFilePath(), TRUST_STORE_PASSWORD);
+ KeyStore clientKs;
+ try (InputStream in = new BufferedInputStream(
+ Files.newInputStream(new File(getClientKeystoreFilePath()).toPath()))) {
+ clientKs = KeyStore.getInstance("JKS");
+ clientKs.load(in, CLIENT_KEY_STORE_PASSWORD.toCharArray());
+ }
+ SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null)
+ .loadKeyMaterial(clientKs, CLIENT_KEY_STORE_PASSWORD.toCharArray()).build();
+ return HttpClients.custom().setSSLContext(sslContext);
+ }
+
+ private void doRequestExpectingSuccess(HttpClientBuilder builder) throws Exception {
+ try (CloseableHttpClient httpClient = builder.build()) {
+ CloseableHttpResponse response = doOneRoundTrip(httpClient);
+ assertEquals(HttpURLConnection.HTTP_OK, response.getStatusLine().getStatusCode());
+ }
+ }
+
+ private void doRequest(HttpClientBuilder builder) throws Exception {
+ try (CloseableHttpClient httpClient = builder.build()) {
+ doOneRoundTrip(httpClient);
+ }
+ }
+
+ private CloseableHttpResponse doOneRoundTrip(CloseableHttpClient httpClient) throws Exception {
+ TMemoryBuffer memoryBuffer = new TMemoryBuffer(100);
+ TProtocol prot = new TBinaryProtocol(memoryBuffer);
+ Hbase.Client client = new Hbase.Client(prot);
+ client.send_getClusterId();
+ httpPost.setEntity(new ByteArrayEntity(memoryBuffer.getArray()));
+ return httpClient.execute(httpPost);
+ }
+
+ private static KeyStore loadJksTrustStore(String path, String password) throws Exception {
+ try (InputStream in = new BufferedInputStream(Files.newInputStream(new File(path).toPath()))) {
+ KeyStore ks = KeyStore.getInstance("JKS");
+ ks.load(in, password.toCharArray());
+ return ks;
+ }
+ }
+
+ // Workaround for jdk8 292 bug. See https://github.com/bcgit/bc-java/issues/941
+ // Below is a workaround described in above URL. Issue fingered first in comments in
+ // HBASE-25920 Support Hadoop 3.3.1
+ private static void initializeAlgorithmId() {
+ try {
+ Class<?> algoId = Class.forName("sun.security.x509.AlgorithmId");
+ Method method = algoId.getMethod("get", String.class);
+ method.setAccessible(true);
+ method.invoke(null, "PBEWithSHA1AndDESede");
+ } catch (Exception e) {
+ LOG.warn("failed to initialize AlgorithmId", e);
+ }
+ }
+
+ private File initKeystoreDir() {
+ String dataTestDir = TEST_UTIL.getDataTestDir().toString();
+ File keystoreDir =
+ new File(dataTestDir, TestThriftServerSSLMutualAuth.class.getSimpleName() + "_keys");
+ keystoreDir.mkdirs();
+ return keystoreDir;
+ }
+
+ private static void generateKeyStore(String keyStorePath, KeyPair keyPair, X509Certificate cert)
+ throws Exception {
+ KeyStoreTestUtil.createKeyStore(keyStorePath, KEY_STORE_PASSWORD, KEY_STORE_PASSWORD,
+ "serverKS", keyPair.getPrivate(), cert);
+ }
+
+ private static void generateKeyStoreWithPassword(String keyStorePath, KeyPair keyPair,
+ X509Certificate cert, String password) throws Exception {
+ KeyStoreTestUtil.createKeyStore(keyStorePath, password, password, "clientKS",
+ keyPair.getPrivate(), cert);
+ }
+
+ private static void generateTrustStore(String path, X509Certificate cert) throws Exception {
+ KeyStoreTestUtil.createTrustStore(path, TRUST_STORE_PASSWORD, "ts", cert);
+ }
+
+ private String getServerKeystoreFilePath() {
+ return String.format("%s/serverKS.jks", keyDir.getAbsolutePath());
+ }
+
+ private String getServerTruststoreFilePath() {
+ return String.format("%s/serverTS.jks", keyDir.getAbsolutePath());
+ }
+
+ private String getClientKeystoreFilePath() {
+ return String.format("%s/clientKS.jks", keyDir.getAbsolutePath());
+ }
+
+ private String getClientCaTruststoreFilePath() {
+ return String.format("%s/clientCA.jks", keyDir.getAbsolutePath());
+ }
+
+}