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}