mTLS Java REST

實作能夠存取 mTLS Rest API 的 Java HTTP Client

邱雍翔 Alvin Chiu 2024/04/29 18:49:17
946

本文介紹兩種實作 mTLS Java HTTP Client 的方式,分為以下兩種:

1. 透過載入金鑰庫(Keystore)

2. 透過載入客戶端憑證

使用 Java 實作透過載入金鑰庫 (Keystore) 存取 mTLS Rest API

我們使用 Gradle 建立一個 Java 專案

plugins {
   id 'java'
}

group = 'com.example.mtlsclient'
version = '1.0-SNAPSHOT'

java {
   sourceCompatibility = '17'
}

repositories {
   mavenCentral()
}

dependencies {
   implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1'
   implementation 'org.apache.httpcomponents:httpclient:4.5.14'
   testImplementation platform('org.junit:junit-bom:5.9.1')
   testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
   useJUnitPlatform()
}

以下為本專案的結構,由於 JDK 只認得 keystore 這個格式,因此請務必將前面生成的 PKCS#12 格式的 keystore file 名為 keystore.p12 放入本專案 resources 目錄底下:

mtls-client-demo/
├── build/
├── gradle/
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/
│   │   │       └── example/
│   │   │           └── mtls-client-demo/
│   │   │               ├── MTLsHttpClientAuthViaKeystore.java
│   │   │               └── ...
│   │   └── resources/
│   │       └── keystore.p12
│   └── test/
│       ├── java/
│       │   └── com/
│       │       └── example/
│       │           └── myproject/
│       │               ├── MTLsHttpClientAuthViaKeystoreTest.java
│       │               └── ...
│       └── resources/
│           ├── keystore.p12
│           └── ...
├── .gitignore
├── build.gradle
├── gradlew
├── gradlew.bat
├── README.md
└── settings.gradle

我們在 MTLsHttpClientAuthViaKeystore.java 中加入以下的程式碼:

package com.example.mtlsclient;

import org.apache.hc.core5.ssl.SSLContexts;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.InputStream;
import java.security.KeyStore;

public class MTLsHttpClientAuthViaKeystore {

   private static final String KEYSTORE_TYPE = "PKCS12";
   private static final String KEYSTORE_FILE = "keystore.p12";
   private static final String KEYSTORE_PASSWORD = "password";
   private static final String KEY_PASSWORD = "password";
   private static final String API_ENDPOINT = "https://localhost:8080/api/v1/mtls/connect";

   public static void main(String[] args) throws Exception {

       // 載入 MTLS keystore
       KeyStore mtlsKeyStore = KeyStore.getInstance(KEYSTORE_TYPE);
       InputStream mtlsKeyStoreFile = MTLsHttpClientAuthViaKeystore.class.getClassLoader().getResourceAsStream(KEYSTORE_FILE);
       mtlsKeyStore.load(mtlsKeyStoreFile, KEYSTORE_PASSWORD.toCharArray());

       // 建立 MTLS SSL 環境
       KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
       kmf.init(mtlsKeyStore, KEY_PASSWORD.toCharArray());

       TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
       tmf.init(mtlsKeyStore);

       SSLContext sslContext = SSLContexts.custom()
              .loadTrustMaterial(mtlsKeyStore, null)
              .loadKeyMaterial(mtlsKeyStore, KEY_PASSWORD.toCharArray())
              .build();

       // 將已建立的 MTLS SSL 環境載進 HTTP client
       SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslContext);
       CloseableHttpClient httpClient = HttpClientBuilder.create()
              .setSSLSocketFactory(sslsf)
              .build();

       // 發送 Post 請求
       HttpPost httpPost = new HttpPost(API_ENDPOINT);
       HttpResponse httpResponse = httpClient.execute(httpPost);

       // 處理伺服器的回應
       String response = EntityUtils.toString(httpResponse.getEntity());
       System.out.println(response);
  }
}

執行這支簡易的 Java Client 呼叫我們預先準備好的 mTLS API 出現以下的結果,代表大功告成!

Profiling started
{"message":"Connect Succeed!"}

Process finished with exit code 0

使用 Java 建立透過載入客戶端憑證的 HTTP Client 存取這個 mTLS Rest API

對照上一個版本,透過載入客戶端憑證實作 HTTP Client 較為複雜,在這裡我們使用 Apache HttpClient 和 BouncyCastle 來建立一個 MTLS (Mutual TLS) Client。以下是程式的主要步驟:

  1. 註冊 BouncyCastle 作為 Java Security Provider。

  2. 載入 MTLS 信任憑證 (TrustStore) 和客戶端證書。

  3. 載入客戶端私鑰,支援 PEMEncryptedKeyPair 和 PKCS8EncryptedPrivateKeyInfo 兩種格式。

  4. 將客戶端私鑰添加到 KeyStore。

  5. 初始化 KeyManagerFactory 和 SSLContext。

  6. 建立 MTLS SSL 連線,並發送 POST 請求。

  7. 處理伺服器的回應。

首先我們添加以下的依賴庫到 Gradle file 中

implementation 'org.bouncycastle:bcpkix-jdk18on:1.78'

我們在本專案 resources 目錄加入 rootCA.pem、client.pem、client.key 三個檔案:

mtls-client-demo/
├── build/
├── gradle/
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/
│   │   │       └── example/
│   │   │           └── mtls-client-demo/
│   │   │               ├── MTLsHttpClientAuthViaKeystore.java
│   │   │               ├── MTLsHttpClientAuthViaCertificateAndKey.java
│   │   │               └── ...
│   │   └── resources/
│           ├── keystore.p12
│           ├── rootCA.pem
│           ├── client.pem
│           ├── client.key
│           └── ...
│   └── test/
│       ├── java/
│       │   └── com/
│       │       └── example/
│       │           └── myproject/
│       │               ├── MTLsHttpClientAuthViaKeystoreTest.java
│       │               ├── MTLsHttpClientAuthViaCertificateAndKeyTest.java
│       │               └── ...
│       └── resources/
│           ├── keystore.p12
│           ├── rootCA.pem
│           ├── client.pem
│           ├── client.key
│           └── ...
├── .gitignore
├── build.gradle
├── gradlew
├── gradlew.bat
├── README.md
└── settings.gradle

我們在 MTLsHttpClientAuthViaCertificateAndKey.java 中加入以下的程式碼:

package com.example.mtlsclient;

import org.apache.hc.core5.ssl.SSLContexts;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMDecryptorProvider;
import org.bouncycastle.openssl.PEMEncryptedKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder;
import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder;
import org.bouncycastle.operator.InputDecryptorProvider;
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;

public class MTLsHttpClientAuthViaCertificateAndKey {
   private static final String ROOT_CA_FILE = "rootCA.pem";
   private static final String CLIENT_PEM_FILE = "client.pem";
   private static final String CLIENT_KEY_FILE = "client.key";
   private static final String KEY_PASSWORD = "password";
   private static final String API_ENDPOINT = "https://localhost:8080/api/v1/mtls/connect";

   static {
       // 在類別載入時註冊 BouncyCastle 為 Java Security provider
       Security.addProvider(new BouncyCastleProvider());
  }

   public static void main(String[] args) throws Exception {

       // 載入 MTLS 信任憑證 (TrustStore)
       KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
       trustStore.load(null, null);
       InputStream trustStoreInputStream = MTLsHttpClientAuthViaCertificateAndKey.class.getClassLoader().getResourceAsStream(ROOT_CA_FILE);
       CertificateFactory cf = CertificateFactory.getInstance("X.509");
       X509Certificate caCert = (X509Certificate) cf.generateCertificate(trustStoreInputStream);
       trustStore.setCertificateEntry("ca", caCert);

       // 載入客戶端證書
       KeyStore clientKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
       InputStream clientCertStream = MTLsHttpClientAuthViaCertificateAndKey.class.getClassLoader().getResourceAsStream(CLIENT_PEM_FILE);
       InputStream clientKeyStream = MTLsHttpClientAuthViaCertificateAndKey.class.getClassLoader().getResourceAsStream(CLIENT_KEY_FILE);
       clientKeyStore.load(null, null);
       CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
       X509Certificate clientCert = (X509Certificate) certFactory.generateCertificate(clientCertStream);
       clientKeyStore.setCertificateEntry("client", clientCert);

       // 載入客戶端私鑰
       PEMParser pemParser = new PEMParser(new InputStreamReader(clientKeyStream));
       Object keyObject = pemParser.readObject();
       JcePEMDecryptorProviderBuilder decryptorProviderBuilder = new JcePEMDecryptorProviderBuilder();
       JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
       KeyPair keyPair;

       if (keyObject instanceof PEMEncryptedKeyPair) {
           // 如果私鑰是 PEMEncryptedKeyPair 格式
           PEMDecryptorProvider decProv = decryptorProviderBuilder.build(KEY_PASSWORD.toCharArray());
           keyPair = converter.getKeyPair(((PEMEncryptedKeyPair) keyObject).decryptKeyPair(decProv));
      } else {
           // 如果私鑰是 PKCS8EncryptedPrivateKeyInfo 格式
           PKCS8EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = (PKCS8EncryptedPrivateKeyInfo) keyObject;
           InputDecryptorProvider inputDecProv = new JceOpenSSLPKCS8DecryptorProviderBuilder()
                  .setProvider("BC")
                  .build(KEY_PASSWORD.toCharArray());
           PrivateKeyInfo privateKeyInfo = encryptedPrivateKeyInfo.decryptPrivateKeyInfo(inputDecProv);
           keyPair = new KeyPair(null, converter.getPrivateKey(privateKeyInfo));
      }

       pemParser.close();
       // 將客戶端私鑰添加到 KeyStore
       clientKeyStore.setKeyEntry("client", keyPair.getPrivate(), KEY_PASSWORD.toCharArray(), new Certificate[]{clientCert});

       // 初始化 KeyManagerFactory
       KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
       keyManagerFactory.init(clientKeyStore, KEY_PASSWORD.toCharArray());

       // 建立 MTLS SSL 環境
       SSLContext sslContext = SSLContexts.custom()
              .loadKeyMaterial(clientKeyStore, KEY_PASSWORD.toCharArray())
              .loadTrustMaterial(trustStore, null)
              .build();

       // 建立 MTLS SSL 連線
       CloseableHttpClient httpClient = HttpClients.custom()
              .setSSLContext(sslContext)
              .build();

       // 發送 POST 請求
       HttpPost httpPost = new HttpPost(API_ENDPOINT);
       HttpResponse httpResponse = httpClient.execute(httpPost);

       // 處理伺服器的回應
       String response = EntityUtils.toString(httpResponse.getEntity());
       System.out.println(response);
  }
}

執行本 Java Client 呼叫我們預先準備好的 mTLS API 出現以下結果,大功告成!

Profiling started
{"message":"Connect Succeed!"}

Process finished with exit code 0

效能提升

我們會發現,執行上述 Client 程式時,註冊 BouncyCastle 的過程會佔去15%的時間!明顯影響執行效能。而每一次與伺服器建立 mTLS 連線都要重新註冊也不現實。因此我們可以在正式環境中設定 jre 預先載入,讓執行環境隨著 OS 啟動時一次性的完成這件事。

  1. 將 BouncyCastle 的 jar 放入 {JAVA_HOME}/jre/lib/ext

  2. 編輯 {JAVA_HOME}/jre/lib/security/java.security 加入以下設定:

security.provider.N = org.bouncycastle.jce.provider.BouncyCastleProvider

如此一來,在 jre 隨著 OS 啟動時,就會預先載入 BouncyCastle。至於程式的部分也可以將以下程式碼移除,避免重複註冊。

// 由於已在Java環境啟動階段將BouncyCastle設為預設的安全性提供者,為了避免重複註冊,故移除以下程式碼
static {
   Security.addProvider(new BouncyCastleProvider());
}

 

參考來源:

1. https://www.baeldung.com/java-ssl-debug-logging

2. refactorizando-web/Mutual-TLS: Mutual TLS example with Spring Boot and WebClient (github.com)

3. Introduction to BouncyCastle with Java | Baeldung

4. mTLS: When certificate authentication is done wrong - The GitHub Blog

5. Extract CN From X509 Certificate in Java | Baeldung

邱雍翔 Alvin Chiu