暗号

[Spring boot] mTLS相互認証の実装サンプル

この記事で分かること

  • クライアント証明書でのTLS相互認証を理解するための基礎知識
  • 証明書、ルートCA、キーストア、トラストストアなどの作り方
  • Spring boot でのサーバ側、クライアント側の実装例

この記事では、クライアント証明書を使ったTLS相互認証 (mTLS) について、Spring bootでの実装方法を含めて解説しています。

今回、この記事を書くに当たって海外含めて様々なサイトを回り調査を行いました。
私自身、暗号についての卒論を書くほどこの分野に興味がありましたが、それでもクライアント証明書での認証やPKIの概念は難しいです。

このページでは主に、

  • サーバ間通信でクライアント証明書を使ってくれと言われたけど、どのように実装すればよいか分からない
  • CA、CSR、CRT、キーストア、トラストストアなどの用語が分からない
  • 公開鍵、秘密鍵、クライアント証明書などを作るコマンドが知りたい
  • ローカル環境でmTLSをテストするにはどうすれば良い?

などの疑問に答えます。

少し長いですが、次の章より以下の順に説明しています。

  • mTLSの登場人物について
  • openssl, keytool の鍵、証明書生成コマンドについて
  • Spring bootでのサンプル実装について
ゴイチ

頑張ってまとめたのでぜひ参考にして下さい!!

登場人物

CA(認証局)

暗号において、公開鍵証明書認証局または認証局 (CA、Certificate Authority、Certification Authority) は、他の当事者にデジタル 公開鍵証明書 を発行する実体である。これは、信頼できる第三者機関英語版) (trusted third party, TPP) の例である。

CAは、公開鍵証明書を発行する。
公開鍵証明書には公開鍵と持ち主の記載があり、記載の個人、組織、サーバその他の実体がこの公開鍵に対応した私有鍵(秘密鍵)の持ち主だと証言する。

このスキームにおけるCAの義務は、CAの発行した証明書にある情報を利用者が信頼できるよう、申請者の身分を確認することである
もし利用者がCAを信じ、かつCAの署名が検証できたならば、利用者はその証明書で特定される者がその証明書の公開鍵を所有していると検証できたことになる。(Wikipediaより)

CSR(証明書署名要求)

公開鍵基盤のシステムにおいて、証明書署名要求 (英: CSRcertificate signing request または certification request) とは公開鍵証明書を申し込むために申請者から認証局へ送られるメッセージのことである。(Wikipediaより)

CSR(Certificate Signing Request)は、日本語では「証明書署名要求」と訳され、公開鍵基盤の利用者(申請者)が公開鍵証明書(電子証明書)を申請するために、認証局に対して送るメッセージのことです。

申請者はまず公開鍵と秘密鍵のペアを作成し、秘密鍵を秘匿します。次に公開鍵を含んだ状態でCSRを認証局に送ります。この申請が認められると、認証局は申請者に公開鍵証明書を送り返します。この証明書は認証局の秘密鍵によって電子署名されています。なおCSRには公開鍵の情報の他、組織の正式名称や所在地等の情報で構成される「ディスティングイッシュ ネーム(識別名)」が含まれます。

Certificate Signing Request (CSR) (f5.com)

拡張子(.csr)のファイルは、
「—–BEGIN CERTIFICATE REQUEST—–」から始まり、
「—–END CERTIFICATE REQUEST—–」で終わります。

CRT(デジタル証明書、公開鍵証明書)

デジタル証明書とは、暗号化やデジタル署名に用いる公開鍵暗号公開鍵を配送する際に、受信者が鍵の所有者を確認するために添付される一連のデータセットのこと。一般的には認証局CA:Certificate Authority)と呼ばれる機関が発行する。(IT用語辞典より)

認証局(CA)から発行されたデジタル証明書を利用したデジタル署名により、データの改ざんを検知することができるだけでなく公開鍵が正しいものであると確認できて、さらにデータ作成者を証明することができます。

https://www.infraexpert.com/study/security6.html

.crt ファイル名の拡張子は、主に X.509 デジタルセキュリティ証明書 (.crt) ファイルタイプに属する。デジタルセキュリティ証明書は、X.509 v3 証明書標準(IETF による RFC 5280)に従って、認証、接続、ファイル保護、暗号化、身元確認に使用される一意のバイト列である。証明書ファイルは、.crt.cer.der.pem という拡張子を使用します。

.crt拡張子は証明書ファイルの標準拡張子であり、GNU/LinuxのようなUnixスタイルのOSで一般的に使用されています。Microsoft Windowsは異なる慣習に従っており、代わりに証明書ファイルに.cer拡張子を使用しています。.crt 証明書と .cer 証明書はどちらもバイナリDERかASCIIアーマー(PEM, Base64)エンコーディングを使用することができます。

https://www.filetypeadvisor.com/ja/extension/crt

拡張子(.crt)のファイルは、
「—–BEGIN CERTIFICATE —–」から始まり、
「—–END CERTIFICATE—–」で終わります。

KeyStore/TrustStore(キーストア/トラストストア)

キーストアはクライアント認証に使用され、トラストストアはSSL 認証でサーバーを認証する際に使用されます。

  • キーストアは、秘密鍵と関連する証明書、または秘密鍵と関連する証明書チェーンを含むデータベースから成ります。証明書チェーンは、クライアント証明書と、1 つ以上の証明書発行局 (CA) の証明書から成ります。
  • トラストストア (「信頼」ストア) には、クライアントに信頼されている証明書のみが格納されます。これらの証明書は CA ルート証明書、つまり自己署名付き証明書です。

鍵ストアとトラストストアについて説明します。

鍵ストアおよびトラストストアは、TLS などの暗号プロトコルに使用される証明書や秘密鍵などの暗号成果物を含むリポジトリーです。

鍵ストア には、個人証明書と、それに対応する秘密鍵 (証明書の所有者を識別するために使用される) が含まれています。

TLS の場合、個人証明書 は TLS エンドポイントの ID を表します。 クライアント (例: REST クライアント) とサーバー (例: z/OS® Connect EE サーバー) の両方に、自らを識別するための個人証明書が存在することがあります。

トラストストア には、エンドポイントで信頼される署名者証明書 (認証局証明書とも呼ばれる) が含まれています。

署名者証明書 には公開鍵 が含まれていて、個人証明書の検証に使用されます。サーバーの署名者証明書をクライアントのトラストストアにインストールすることにより、クライアントが TLS 接続の確立時にサーバーを信頼できるようにします。TLS クライアント認証が有効な場合、サーバーがクライアントを信頼するには、同じ原則が当てはまります。

鍵ストアとトラストストア - IBM Documentation

PKCS12(Public Key Cryptography Standards 12)

KeyStore

PKCS#12とは、秘密鍵と証明書を 1つのファイルに格納する形式です。

一般的には、秘密鍵とそのX.509証明書をバンドルしたり、トラストチェーンの全メンバをバンドルするために使用されます。(Wikipediaより)

拡張子は(.p12)で、業界標準のキーストア実装です。

JKS(Java KeyStore)

JKSとは、暗号化の鍵と証明書の格納場所を表現したものです。
つまり役割としては、PKCS12と同じものです。

JKSは Java KeyStore の略で、SUNプロバイダによって提供されている独自のキーストア実装です。(標準アルゴリズム名のドキュメント (oracle.com)

JKS はキーストアとトラストストアの両方に使用できます。

拡張子は(.jks)で、Javaにおけるデフォルトのキーストア実装です。

必要な証明書の準備

ここでは、Spring bootでクライアント認証 (mTLS、Mutual Authentication、相互認証)を実装するために必要なサーバ証明書クライアント証明書を準備します。

主に以下のサイトの手順を参考に記載しています。
X.509 Authentication in Spring Security | Baeldung

opensslライブラリを使用するため、以下の手順を実行する前にライブラリをインストールする必要があります。

自己署名ルートCA

サーバー側とクライアント側の証明書に署名できるようにするには、最初に独自の自己署名ルートCA証明書を作成する必要があります。

いわゆるオレオレ証明書を作るために必要なCA証明書です。(オレオレ証明書に署名するルートCA)

openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt

いわゆる識別名に関する情報を入力する必要があります。
ここでは、PEMパスワードとコモンネーム(CN) 4engineer.net のみ入力し、他の部分は空のままEnterを押してCA証明書を作成します。

入力例

Generating a 4096 bit RSA private key
...............................................................................................................................................................................++
..........................................................................................................++
writing new private key to 'rootCA.key'
Enter PEM pass phrase:
Verifying - Enter PEM pass phrase:
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:
State or Province Name (full name) []:
Locality Name (eg, city) []:
Organization Name (eg, company) []:
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:4engineer.net
Email Address []:

サーバー側の証明書

Spring Bootアプリケーションにサーバー側のX.509認証を実装するには、最初にサーバー側の証明書を作成する必要があります。

openssl req -new -newkey rsa:4096 -keyout localhost.key -out localhost.csr

CA証明書と同様に、秘密鍵のパスワードを入力する必要があります。
さらに、コモンネーム(CN)として localhost を入力しましょう。

先に進む前に、構成ファイル localhost.ext を作成する必要があります。
証明書の署名中に必要ないくつかの追加パラメーターが格納されます。

authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost

すぐに使用できるファイルもここから入手できます。

次に、ルートCA証明書rootCA.crt)とその 秘密鍵rootCA.key)を使用してリクエストに署名します。

openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext

CA証明書を作成したときに使用したものと同じパスワードを入力する必要があることに注意してください。

この段階で、独自の認証局(オレオレ認証局)によって署名された localhost.crt 証明書を使用する準備が整いました。
証明書の詳細を人間が読める形式で印刷するには、次のコマンドを使用できます。

openssl x509 -in localhost.crt -text

キーストア

このセクションでは、署名付き証明書(localhost.crt)対応する秘密鍵(localhost.key)キーストア(keystore.jks)ファイルにインポートする方法を説明します。

PKCS 12アーカイブを使用して、サーバーの秘密鍵を署名付き証明書と一緒にパッケージ化します。
次に、それを新しく作成された keystore.jks にインポートします。

次のコマンドを使用して、.p12ファイルを作成できます。

openssl pkcs12 -export -out localhost.p12 -name "localhost" -inkey localhost.key -in localhost.crt

このコマンドにより、localhost.keylocalhost.crtが単一の localhost.p12 ファイルに格納されました。

ここで、keytoolを使用して keystore.jksリポジトリ を作成し localhost.p12 ファイルをインポートしましょう。

keytool -importkeystore -srckeystore localhost.p12 -srcstoretype PKCS12 -destkeystore keystore.jks -deststoretype JKS

このコマンドで以下の警告が出る場合があります。

JKSキーストアは独自の形式を使用しています。"keytool -importkeystore -srckeystore keystore.jks -destkeystore keystore.jks -deststoretype pkcs12"を使用する業界標準の形式であるPKCS12に移行することをお薦めします。

JKSはJava独自の形式であり、PKCS12の方がデファクト・スタンダードなので、PKCS12形式を使うことを推奨されています。
どちらの形式を使っても問題ありません。

この段階で、サーバー認証部分の準備はすべて揃っています。
次のセクションで、mTLSに必要なその他のファイルや、クライアント側の証明書の作成を進めましょう。

トラストストア

トラストストアはキーストアの反対の役割を持ち、信頼できる外部エンティティの証明書を保持します。

今回は、ルートCA証明書をトラストストアに保持するだけで十分です。

server-truststore.p12ファイルを作成し、 keytoolを使用してrootCA.crtをインポートする方法を見てみましょう。

keytool -importcert -noprompt -alias ca -file rootCA.crt -keystore server-truststore.p12

server-trusstore.p12 にもパスワードを提供する必要があることに注意してください。
ここでは、changeit パスフレーズを使用しました。(changeitはキーストアのパスワードとして一般的によく使われます)

これで、独自のCA証明書(オレオレCA証明書)をインポートし、トラストストアを使用する準備が整いました。

クライアント側の証明書

次に、クライアント側の証明書を作成します。
実行する必要のある手順は、すでに作成したサーバー側の証明書の場合とほとんど同じです。

まず、証明書署名要求(.csr)を作成する必要があります。

openssl req -new -newkey rsa:4096 -nodes -keyout clientBob.key -out clientBob.csr

証明書に組み込まれる情報を提供する必要があります。
ここでは、コモンネーム(CN)Bob のみを入力します。
サンプルアプリケーションで認識されるのはBobのみであるため、コモンネームの値は重要です。

次に、CAで証明書署名要求に署名する必要があります。

openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in clientBob.csr -out clientBob.crt -days 365 -CAcreateserial

ここでも、CAのパスフレーズを入力する必要があります。

実行する必要のある最後の手順は、署名付き証明書秘密鍵PKCS12ファイルにパッケージ化することです。

openssl pkcs12 -export -out clientBob.p12 -name "clientBob" -inkey clientBob.key -in clientBob.crt

ここでも、changeit パスフレーズを使用しました。

以上により、クライアント側の証明書の準備がすべて整いました。

Spring boot サンプルアプリケーション

サーバ側

  • クライアント認証を要求するサーバ側 Spring boot アプリケーションです。
  • 必要なキーストア、トラストストアの作り方は前章に示した通りです。
  • パスワードはすべて changeitに統一しています。

Controller

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ServerController {

    @GetMapping(value = "/server")
    public String server() {
        return "ok";
    }
}

Yaml

spring:
  profiles:
    active: local

server:
  port: 8443
  ssl:
    # TLS settings
    enabled: true
    key-store: keystore.jks
    key-store-password: changeit
    key-alias: localhost
    key-password: changeit

    # mTLS settings
    client-auth: need
    trust-store: server-truststore.p12
    trust-store-password: changeit

クライアント側

  • クライアント証明書を使ってサーバに接続するクライアント側 Spring boot アプリケーションです。
  • Yamlの clientBob.p12 がクライアント証明書に当たります。
  • 自己署名証明書を使っているので、ルートCA証明書をトラストストアにセットしています。
  • ルートCACA証明書を指定する代わりに、予めルートCA証明書を格納したトラストストア(例えば truststore.p12)を使用することも出来るはずです。(未確認)

Controller

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

@RestController
public class ClientController {
    @Autowired
    RestTemplate restTemplate;

    @GetMapping("/client")
    public String client() {
        return restTemplate.getForObject("https://localhost:8443/server", String.class);
    }
}

Configuration (RestTemplate)

import org.apache.http.client.HttpClient;
import org.apache.http.client.HttpRequestRetryHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

import javax.net.ssl.*;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

@Configuration
public class RestTemplateConfig {
    @Autowired
    private RestTemplateBuilder restTemplateBuilder;

    @Bean
    RestTemplate restTemplate(
            @Value("${myapp.keystore.filepath}") String keyStorePath,
            @Value("${myapp.keystore.password}") String keyStorePassword,
            @Value("${myapp.cacert.filepath}") String caCertPath,
            @Value("${myapp.cacert.alias}") String caCertAlias
    ) {
        Supplier<ClientHttpRequestFactory> requestFactory = () -> {
            try {
                SSLContext sslContext =
                        createSslContext("TLSv1.3", keyStorePath, keyStorePassword, caCertPath, caCertAlias);

                HttpClient httpClient = createHttpClient(sslContext);

                return new HttpComponentsClientHttpRequestFactory(httpClient);
            } catch (Exception e) {
                return null;
            }
        };

        return restTemplateBuilder
                .requestFactory(requestFactory)
                .build();
    }

    private SSLContext createSslContext(String protocol, String keyStorePath, String keyStorePassword,
                                        String certificatePath, String alias)
            throws NoSuchAlgorithmException, KeyStoreException, CertificateException, IOException, UnrecoverableKeyException, KeyManagementException {
        SSLContext sslContext = SSLContext.getInstance(protocol);

        // Create Key Manager
        KeyManager[] keyManagers = createKeyManagers(keyStorePath, keyStorePassword);

        // Create Trust Manager
        TrustManager[] trustManagers = createTrustManagers(certificatePath, alias);

        // Init SSLContext
        sslContext.init(keyManagers, trustManagers, new SecureRandom());

        return sslContext;
    }

    private KeyManager[] createKeyManagers(String keyStorePath, String keyStorePassword)
            throws KeyStoreException, CertificateException, IOException, NoSuchAlgorithmException, UnrecoverableKeyException {
        KeyManager[] keyManagers = null;

        if (keyStorePath != null) {
            try (InputStream is = new BufferedInputStream(new FileInputStream(keyStorePath))) {
                // See https://docs.oracle.com/javase/jp/8/docs/technotes/guides/security/StandardNames.html#KeyStore
                KeyStore keyStore = KeyStore.getInstance("pkcs12");
                keyStore.load(is, keyStorePassword.toCharArray());

                KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
                kmf.init(keyStore, keyStorePassword.toCharArray());
                keyManagers = kmf.getKeyManagers();
            }
        }

        return keyManagers;
    }

    private TrustManager[] createTrustManagers(String certificatePath, String alias)
            throws KeyStoreException, CertificateException, IOException, NoSuchAlgorithmException {
        TrustManager[] trustManagers = null;

        if (certificatePath != null) {
            KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
            trustStore.load(null);

            try (InputStream is = new BufferedInputStream(new FileInputStream(certificatePath))) {
                // See https://docs.oracle.com/javase/jp/8/docs/technotes/guides/security/StandardNames.html#CertificateFactory
                Certificate certificate = CertificateFactory.getInstance("X.509")
                        .generateCertificate(is);

                trustStore.setCertificateEntry(alias, certificate);

                TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
                tmf.init(trustStore);
                trustManagers = tmf.getTrustManagers();
            }
        }

        return trustManagers;
    }

    private HttpClient createHttpClient(SSLContext sslContext) {
        String[] supportedProtocols = {"TLSv1.2", "TLSv1.3"};
        String[] supportedCipherSuites = null;
        HostnameVerifier hostnameVerifier = NoopHostnameVerifier.INSTANCE;

        SSLConnectionSocketFactory socketFactory =
                new SSLConnectionSocketFactory(sslContext, supportedProtocols, supportedCipherSuites, hostnameVerifier);

        Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
                .register("http", PlainConnectionSocketFactory.getSocketFactory())
                .register("https", socketFactory)
                .build();

        PoolingHttpClientConnectionManager connectionManager =
                new PoolingHttpClientConnectionManager(registry, null, null, null,
                        3000, TimeUnit.MILLISECONDS);
        connectionManager.setMaxTotal(20);
        connectionManager.setDefaultMaxPerRoute(2);

        RequestConfig requestConfig = RequestConfig.custom().build();
        HttpRequestRetryHandler retryHandler = new DefaultHttpRequestRetryHandler(5, true);

        return HttpClientBuilder.create()
                .setConnectionManager(connectionManager)
                .setDefaultRequestConfig(requestConfig)
                .setRetryHandler(retryHandler)
                .build();
    }
}

Yaml

spring:
  profiles:
    active: local

myapp:
  # Client certificate settings
  keystore:
    filepath: clientBob.p12
    password: changeit
  # Server-side is using self-signed certificate, so root CA certificate is required
  cacert:
    filepath: rootCA.crt
    alias: rootCA

参考

この記事は役に立ちましたか?

  • この記事を書いた人
アバター画像

ゴイチ

ソフトウェアエンジニア歴20年。 C/C++, C#, Java, Kotlinが得意で、組込系・スマホ・大規模なWebサービスなど幅広いプログラミング経験があります。 現在は某SNSの会社でWebエンジニアをしています。

-暗号
-, , ,