001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.apache.hadoop.hbase.thrift; 019 020import static org.apache.hadoop.hbase.thrift.TestThriftServerCmdLine.createBoundServer; 021import static org.junit.jupiter.api.Assertions.assertEquals; 022import static org.junit.jupiter.api.Assertions.assertThrows; 023 024import java.io.BufferedInputStream; 025import java.io.File; 026import java.io.IOException; 027import java.io.InputStream; 028import java.lang.reflect.Method; 029import java.net.HttpURLConnection; 030import java.nio.file.Files; 031import java.security.KeyPair; 032import java.security.KeyStore; 033import java.security.cert.X509Certificate; 034import javax.net.ssl.SSLContext; 035import org.apache.hadoop.conf.Configuration; 036import org.apache.hadoop.hbase.HBaseTestingUtil; 037import org.apache.hadoop.hbase.HConstants; 038import org.apache.hadoop.hbase.testclassification.ClientTests; 039import org.apache.hadoop.hbase.testclassification.LargeTests; 040import org.apache.hadoop.hbase.thrift.generated.Hbase; 041import org.apache.hadoop.hbase.util.EnvironmentEdgeManager; 042import org.apache.hadoop.hbase.util.EnvironmentEdgeManagerTestHelper; 043import org.apache.hadoop.hbase.util.IncrementingEnvironmentEdge; 044import org.apache.hadoop.hbase.util.TableDescriptorChecker; 045import org.apache.hadoop.security.ssl.KeyStoreTestUtil; 046import org.apache.http.client.methods.CloseableHttpResponse; 047import org.apache.http.client.methods.HttpPost; 048import org.apache.http.entity.ByteArrayEntity; 049import org.apache.http.impl.client.CloseableHttpClient; 050import org.apache.http.impl.client.HttpClientBuilder; 051import org.apache.http.impl.client.HttpClients; 052import org.apache.http.ssl.SSLContexts; 053import org.junit.jupiter.api.AfterAll; 054import org.junit.jupiter.api.AfterEach; 055import org.junit.jupiter.api.BeforeAll; 056import org.junit.jupiter.api.BeforeEach; 057import org.junit.jupiter.api.Tag; 058import org.junit.jupiter.api.Test; 059import org.slf4j.Logger; 060import org.slf4j.LoggerFactory; 061 062import org.apache.hbase.thirdparty.org.apache.thrift.protocol.TBinaryProtocol; 063import org.apache.hbase.thirdparty.org.apache.thrift.protocol.TProtocol; 064import org.apache.hbase.thirdparty.org.apache.thrift.transport.TMemoryBuffer; 065 066/** 067 * Exercises the role-scoped {@code hbase.thrift.ssl.server.*} configuration and the new mutual-TLS 068 * activation on the Thrift-over-HTTP transport. Complements {@link TestThriftHttpServerSSL}, which 069 * covers plain (server-only) TLS termination. 070 */ 071@Tag(ClientTests.TAG) 072@Tag(LargeTests.TAG) 073public class TestThriftServerSSLMutualAuth { 074 075 private static final Logger LOG = LoggerFactory.getLogger(TestThriftServerSSLMutualAuth.class); 076 private static final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil(); 077 private static final String KEY_STORE_PASSWORD = "myKSPassword"; 078 private static final String TRUST_STORE_PASSWORD = "myTSPassword"; 079 private static final String CLIENT_KEY_STORE_PASSWORD = "myClientKSPassword"; 080 081 private File keyDir; 082 private ThriftServerRunner tsr; 083 private HttpPost httpPost; 084 085 @BeforeAll 086 public static void setUpBeforeClass() throws Exception { 087 TEST_UTIL.getConfiguration().setBoolean(Constants.USE_HTTP_CONF_KEY, true); 088 TEST_UTIL.getConfiguration().setBoolean(TableDescriptorChecker.TABLE_SANITY_CHECKS, false); 089 TEST_UTIL.startMiniCluster(); 090 EnvironmentEdgeManagerTestHelper.injectEdge(new IncrementingEnvironmentEdge()); 091 } 092 093 @AfterAll 094 public static void tearDownAfterClass() throws Exception { 095 TEST_UTIL.shutdownMiniCluster(); 096 EnvironmentEdgeManager.reset(); 097 } 098 099 @BeforeEach 100 public void setUp() throws Exception { 101 initializeAlgorithmId(); 102 keyDir = initKeystoreDir(); 103 keyDir.deleteOnExit(); 104 105 // Server identity + a truststore holding the server's cert (used by the test client to 106 // trust the server). 107 KeyPair serverKeyPair = KeyStoreTestUtil.generateKeyPair("RSA"); 108 X509Certificate serverCertificate = KeyStoreTestUtil 109 .generateCertificate("CN=localhost, O=server", serverKeyPair, 30, "SHA1withRSA"); 110 generateTrustStore(getServerTruststoreFilePath(), serverCertificate); 111 generateKeyStore(getServerKeystoreFilePath(), serverKeyPair, serverCertificate); 112 113 // Distinct client cert (single-EKU clientAuth in spirit) and a truststore holding that cert 114 // — this is what the server uses to validate presented client certificates. 115 KeyPair clientKeyPair = KeyStoreTestUtil.generateKeyPair("RSA"); 116 X509Certificate clientCertificate = 117 KeyStoreTestUtil.generateCertificate("CN=client, O=client", clientKeyPair, 30, "SHA1withRSA"); 118 generateTrustStore(getClientCaTruststoreFilePath(), clientCertificate); 119 generateKeyStoreWithPassword(getClientKeystoreFilePath(), clientKeyPair, clientCertificate, 120 CLIENT_KEY_STORE_PASSWORD); 121 } 122 123 @AfterEach 124 public void tearDown() throws IOException { 125 if (httpPost != null) { 126 httpPost.releaseConnection(); 127 } 128 if (tsr != null) { 129 tsr.close(); 130 } 131 } 132 133 // --------------------------------------------------------------------------- 134 // Role-scoped keystore configuration. 135 // --------------------------------------------------------------------------- 136 137 /** 138 * With only the role-scoped {@code hbase.thrift.ssl.server.keystore.*} keys set (legacy keys 139 * unset), a plain HTTPS request succeeds — proving the server-scoped keys are the ones the 140 * bootstrap actually reads. 141 */ 142 @Test 143 public void testServerUsesRoleScopedKeystoreWhenSet() throws Exception { 144 Configuration conf = baseConf(); 145 conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_STORE_KEY, getServerKeystoreFilePath()); 146 conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD); 147 conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD); 148 149 startServer(conf); 150 doRequestExpectingSuccess(clientBuilderWithServerTrust()); 151 } 152 153 /** 154 * Backward-compatibility regression: existing deployments that know only about the legacy 155 * unscoped keys must continue to work. 156 */ 157 @Test 158 public void testServerFallsBackToLegacyKeystore() throws Exception { 159 Configuration conf = baseConf(); 160 conf.set(Constants.THRIFT_SSL_KEYSTORE_STORE_KEY, getServerKeystoreFilePath()); 161 conf.set(Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD); 162 conf.set(Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD); 163 164 startServer(conf); 165 doRequestExpectingSuccess(clientBuilderWithServerTrust()); 166 } 167 168 // --------------------------------------------------------------------------- 169 // Mutual TLS (client auth mode). 170 // --------------------------------------------------------------------------- 171 172 /** 173 * With {@code client.auth.mode=NONE} (default), a client presenting no certificate is accepted — 174 * matches today's behavior. 175 */ 176 @Test 177 public void testClientAuthNoneAcceptsClientWithoutCert() throws Exception { 178 Configuration conf = baseConfWithLegacyKeystore(); 179 conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NONE"); 180 181 startServer(conf); 182 doRequestExpectingSuccess(clientBuilderWithServerTrust()); 183 } 184 185 /** {@code WANT} lets anonymous clients through. */ 186 @Test 187 public void testClientAuthWantAllowsAnonymousClient() throws Exception { 188 Configuration conf = baseConfWithLegacyKeystore(); 189 // WANT needs the server to know which CAs it would trust if a client did present a cert. 190 conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath()); 191 conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD); 192 conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "WANT"); 193 194 startServer(conf); 195 doRequestExpectingSuccess(clientBuilderWithServerTrust()); 196 } 197 198 /** 199 * {@code NEED} rejects clients that do not present a valid client certificate. The handshake 200 * fails before any HTTP response is produced. 201 */ 202 @Test 203 public void testClientAuthNeedRejectsClientWithoutCert() throws Exception { 204 Configuration conf = baseConfWithLegacyKeystore(); 205 conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath()); 206 conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD); 207 conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NEED"); 208 209 startServer(conf); 210 // The Apache HttpClient surface may raise either SSLHandshakeException directly or wrap it 211 // in an IOException — both are acceptable evidence that the handshake was refused. 212 assertThrows(IOException.class, () -> doRequest(clientBuilderWithServerTrust())); 213 } 214 215 /** {@code NEED} accepts a client that presents a certificate trusted by the server. */ 216 @Test 217 public void testClientAuthNeedAcceptsClientWithValidCert() throws Exception { 218 Configuration conf = baseConfWithLegacyKeystore(); 219 conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath()); 220 conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD); 221 conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NEED"); 222 223 startServer(conf); 224 doRequestExpectingSuccess(clientBuilderWithMutualTrust()); 225 } 226 227 // --------------------------------------------------------------------------- 228 // Helpers 229 // --------------------------------------------------------------------------- 230 231 private Configuration baseConf() throws Exception { 232 Configuration conf = new Configuration(TEST_UTIL.getConfiguration()); 233 conf.setBoolean(Constants.THRIFT_SSL_ENABLED_KEY, true); 234 return conf; 235 } 236 237 private Configuration baseConfWithLegacyKeystore() throws Exception { 238 Configuration conf = baseConf(); 239 conf.set(Constants.THRIFT_SSL_KEYSTORE_STORE_KEY, getServerKeystoreFilePath()); 240 conf.set(Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD); 241 conf.set(Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD); 242 return conf; 243 } 244 245 private void startServer(Configuration conf) throws Exception { 246 tsr = createBoundServer(() -> new ThriftServer(conf)); 247 String url = "https://" + HConstants.LOCALHOST + ":" + tsr.getThriftServer().listenPort; 248 httpPost = new HttpPost(url); 249 httpPost.setHeader("Content-Type", "application/x-thrift"); 250 httpPost.setHeader("Accept", "application/x-thrift"); 251 httpPost.setHeader("User-Agent", "Java/THttpClient/HC"); 252 } 253 254 private HttpClientBuilder clientBuilderWithServerTrust() throws Exception { 255 KeyStore trustStore = loadJksTrustStore(getServerTruststoreFilePath(), TRUST_STORE_PASSWORD); 256 SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null).build(); 257 return HttpClients.custom().setSSLContext(sslContext); 258 } 259 260 /** 261 * Builds a client that (a) trusts the server's cert and (b) presents the client keystore. Used to 262 * satisfy {@code client.auth.mode=NEED}. 263 */ 264 private HttpClientBuilder clientBuilderWithMutualTrust() throws Exception { 265 KeyStore trustStore = loadJksTrustStore(getServerTruststoreFilePath(), TRUST_STORE_PASSWORD); 266 KeyStore clientKs; 267 try (InputStream in = new BufferedInputStream( 268 Files.newInputStream(new File(getClientKeystoreFilePath()).toPath()))) { 269 clientKs = KeyStore.getInstance("JKS"); 270 clientKs.load(in, CLIENT_KEY_STORE_PASSWORD.toCharArray()); 271 } 272 SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null) 273 .loadKeyMaterial(clientKs, CLIENT_KEY_STORE_PASSWORD.toCharArray()).build(); 274 return HttpClients.custom().setSSLContext(sslContext); 275 } 276 277 private void doRequestExpectingSuccess(HttpClientBuilder builder) throws Exception { 278 try (CloseableHttpClient httpClient = builder.build()) { 279 CloseableHttpResponse response = doOneRoundTrip(httpClient); 280 assertEquals(HttpURLConnection.HTTP_OK, response.getStatusLine().getStatusCode()); 281 } 282 } 283 284 private void doRequest(HttpClientBuilder builder) throws Exception { 285 try (CloseableHttpClient httpClient = builder.build()) { 286 doOneRoundTrip(httpClient); 287 } 288 } 289 290 private CloseableHttpResponse doOneRoundTrip(CloseableHttpClient httpClient) throws Exception { 291 TMemoryBuffer memoryBuffer = new TMemoryBuffer(100); 292 TProtocol prot = new TBinaryProtocol(memoryBuffer); 293 Hbase.Client client = new Hbase.Client(prot); 294 client.send_getClusterId(); 295 httpPost.setEntity(new ByteArrayEntity(memoryBuffer.getArray())); 296 return httpClient.execute(httpPost); 297 } 298 299 private static KeyStore loadJksTrustStore(String path, String password) throws Exception { 300 try (InputStream in = new BufferedInputStream(Files.newInputStream(new File(path).toPath()))) { 301 KeyStore ks = KeyStore.getInstance("JKS"); 302 ks.load(in, password.toCharArray()); 303 return ks; 304 } 305 } 306 307 // Workaround for jdk8 292 bug. See https://github.com/bcgit/bc-java/issues/941 308 // Below is a workaround described in above URL. Issue fingered first in comments in 309 // HBASE-25920 Support Hadoop 3.3.1 310 private static void initializeAlgorithmId() { 311 try { 312 Class<?> algoId = Class.forName("sun.security.x509.AlgorithmId"); 313 Method method = algoId.getMethod("get", String.class); 314 method.setAccessible(true); 315 method.invoke(null, "PBEWithSHA1AndDESede"); 316 } catch (Exception e) { 317 LOG.warn("failed to initialize AlgorithmId", e); 318 } 319 } 320 321 private File initKeystoreDir() { 322 String dataTestDir = TEST_UTIL.getDataTestDir().toString(); 323 File keystoreDir = 324 new File(dataTestDir, TestThriftServerSSLMutualAuth.class.getSimpleName() + "_keys"); 325 keystoreDir.mkdirs(); 326 return keystoreDir; 327 } 328 329 private static void generateKeyStore(String keyStorePath, KeyPair keyPair, X509Certificate cert) 330 throws Exception { 331 KeyStoreTestUtil.createKeyStore(keyStorePath, KEY_STORE_PASSWORD, KEY_STORE_PASSWORD, 332 "serverKS", keyPair.getPrivate(), cert); 333 } 334 335 private static void generateKeyStoreWithPassword(String keyStorePath, KeyPair keyPair, 336 X509Certificate cert, String password) throws Exception { 337 KeyStoreTestUtil.createKeyStore(keyStorePath, password, password, "clientKS", 338 keyPair.getPrivate(), cert); 339 } 340 341 private static void generateTrustStore(String path, X509Certificate cert) throws Exception { 342 KeyStoreTestUtil.createTrustStore(path, TRUST_STORE_PASSWORD, "ts", cert); 343 } 344 345 private String getServerKeystoreFilePath() { 346 return String.format("%s/serverKS.jks", keyDir.getAbsolutePath()); 347 } 348 349 private String getServerTruststoreFilePath() { 350 return String.format("%s/serverTS.jks", keyDir.getAbsolutePath()); 351 } 352 353 private String getClientKeystoreFilePath() { 354 return String.format("%s/clientKS.jks", keyDir.getAbsolutePath()); 355 } 356 357 private String getClientCaTruststoreFilePath() { 358 return String.format("%s/clientCA.jks", keyDir.getAbsolutePath()); 359 } 360 361}