HTTPS で API を呼び出すっていうシーンは頻繁にあって、その API を使うには、通常何らかの認証が求められます。 認証にも色々あるんだけど、そのうちの一つが HTTPS のクライアント認証です。 普通に HTTPS 通信をしたいだけだと意識しないことも多いのですが、宛先サーバ毎にクライアント証明書を使い分ける、なんてニーズが生じたときにはこのあたりの理解をしておくことは不可避になります。
こういうのを Java でやれって言われたときに、エッどうやって実現するの、みたいなかんじだったので、調べたり実装してみて調べた結果をまとめてみます。
前提知識としては、以下のようなことが分かっていれば良いと思います。
知識編
2 つの証明書ストア (TrustStore と KeyStore)
まず、Java における SSL/TLS の実装は JSSE (Java Secure Socket Extension) によって提供されるのですが、ここで KeyStore と TrustStore という用語が出てきます。 KeyStore と TrustStore はすごく重要な概念なんですが、異常に混乱を招きがちだし、そもそも JSSE のリファレンスガイドからしてこんなの分かんねーよという説明しかされていない。なんだこれ、暗号か?
キーストアは、鍵データのデータベースです。鍵データにはさまざまな用途があり、それには認証やデータ整合性も含まれます。利用できるキーストアには様々なタイプがあり、その中にはPKCS12やOracleのJKSも含まれます。 (略) トラストストアとは、トラストの対象を決めるときに使用するキーストアです。すでに信頼しているエンティティからデータを受け取る場合、およびそのエンティティが発信元を名乗るエンティティであることを確認できる場合は、データは実際にそのエンティティから届いたものであると仮定できます。
でも、これらが分かっていないとクライアント認証だったりは実現できないし、そもそも JSSE がなかなか読み解けない。 そういうわけですから、まずは TrustStore と KeyStore を整理してみます。
SSL においては、通常、サーバ証明書によって、通信を行うサーバが「間違いなく自分の通信したいサーバであること」(逆に言えば、悪意のある第三者が用意したサーバでないこと)を確認することになります。PKI の仕組み上、このためには、サーバがクライアントにサーバ証明書を提示し、クライアントが「信頼してる機関 (CA) がこのサーバ証明書を(少なくとも間接的に)発行している」という判断が必要です。 逆にクライアント証明では、クライアントがサーバに対してクライアント証明書を提示し、サーバが「信頼してる機関 (CA) がこのクライアント証明書を(少なくとも間接的に)発行している」と判断することが必要になります。
このために、クライアント側では以下の情報を保持しておく必要があります。
- 自分がどの証明書(CA 証明書、サーバ証明書) を信じるのか
- 自分がどのクライアント証明書をサーバに提示するのか
この 1. の情報を保持しておくのが TrustStore、2. の情報を保持しておくのが KeyStore です。TrustStore は Trust、つまり自分の信頼するものが何かを蓄えるもの、KeyStore は自分が提示する秘密情報 (Key) を蓄えるもの、って考えるとちょっと覚えやすくなるかもしれません。 ちなみに、この TrustStore と KeyStore はどちらも Java でいう "KeyStore" とよばれるファイル(実際にはファイルでなくても良いですが、ファイルの形を取ることが多い) に保存されたりするのが話をややこしくする元凶なんじゃないかと思います。
まぁこのあたりは JSSE よりも キーストアとトラストストア (SSL をサポートする Java CAPS の構成) の記述もわかりやすいですね。
JSSE では、「キーストア」および「トラストストア」と呼ばれるファイルを使用します。キーストアは、アダプタでクライアント認証に使用され、トラストストアは、SSL 認証でサーバーを認証する際に使用されます。
要するに、TrustStore が相手が正しいかを検証するのに使用するもので、KeyStore は自分が正しいかを相手に伝えるのに使用するものです。どちらも証明書を保持しています。
TrustManager と KeyManager
TrustStore、KeyStore はともにストレージみたいに証明書を保存するためのものなので、それを使う人が必要です。
TrustStore を使って相手から送られてきたサーバ証明書が信頼できるかを検証する責務を負うのが TrustManager、 KeyStore を使って、どのクライアント証明書を相手に送付するかを決めるのが KeyManager です。
これらはともにインタフェースとして定義されていて、自由に実装をつくることができます。 ただ、実際にこれらの実装を自分でインスタンス化するってことは滅多になくて、これらのファクトリクラスである KeyManagerFactory や TrustManagerFactory を作成し、これらから KeyManager、TrustManager を作成するってことが大半です。
SSL についての情報を保持する SSLContext
この図は、JSSE のリファレンスガイドに登場する図なんですが、前述の TrustManager や KeyManager を使って、SSLSocket (名前の通り SSL のソケットを表現するクラス) を作る SSLSocketFactory を構築するのが SSLContext クラスです。この SSLContext は、SSL 実装に関する情報を保持しています。
クライアント認証に関する何らかの操作を実現しないといけない場合、往々にして KeyManager をカスタマイズする必要がありますが、カスタマイズした KeyManager を使ってくれ、ということを SSLContext に教える必要があります。 Apache の httpComponent などの HTTP クライアント実装も、だいたいこの SSLContext を差し替えることができるようになっているので、その差し替えによってクライアント証明書の選択とかを HTTP クライアントライブラリ側に反映させることができます。
httpComponent だと以下のようなかたちで、httpClient を作成する段階で SSLContext を自作のものに設定できます。
try (CloseableHttpClient client = HttpClients.custom().setSSLContext(sslContext).build()) { HttpGet getMethod = new HttpGet("https://server.kiririmode.com:14433/index.html"); client.execute(getMethod); }
デフォルトのクライアント証明書の選択
さて、それではクライアント証明書がデフォルト実装でどのように実装されるかを見てみました。
あくまで java.security
をカスタマイズしていない前提ですが、何も意識しないで HTTPS 通信を行った場合、クライアント証明書は以下の条件に合致するものが選択されます。
- KeyManager が読み込む KeyStore に格納されているクライアント証明書である
- サーバが
Certificate Request
の中で送信してくる信頼する CA が発行している - 鍵のアルゴリズムも合致する
複数のクライアント証明書が上記の条件に合致した場合は、最初に発見されたものが選択されます。 つまり、同じ CA が発行しているようなクライアント証明書が複数あった場合、対向先システムとか関係なしにそのうちの 1 つが選択されてしまうってことになります。 この振舞いについては、割と不都合あるケースが多いんじゃないでしょうか。
実装編
というわけで、上記のような基本知識を元にして、「クライアント証明書を対向サーバ毎に別々に選択する」っていうのを作ってみます。
クライアント証明書を自由に選択できる KeyManager
最初に作らないといけないのは、KeyManager の実装なんですが、実際にはクライアント証明書の形式としては通常 X.509 しかないはずなので、X509Keymanager インタフェースを実装する形で良いと思います。 クライアント証明書を選択するのは chooseClientAlias メソッドなので、これのみ実装を差し替えれば良い。というわけで、移譲させるようにします。
実装自体は、以下のとおりクライアント証明書を選択するのは Strategy パターンにしておきました。
@FunctionalInterface public interface AliasSelectionStrategy { String selectAlias(String[] keyType, Principal[] issuers, Socket socket); }
public class StrategyKeyManagger implements X509KeyManager { private X509KeyManager defaultKeyManager; private AliasSelectionStrategy aliasSelectionStrategy; public StrategyKeyManagger(X509KeyManager defaultKeyManager, AliasSelectionStrategy strategy) { this.defaultKeyManager = defaultKeyManager; this.aliasSelectionStrategy = strategy; } @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { String alias = aliasSelectionStrategy.selectAlias(keyType, issuers, socket); return (alias != null)? alias : defaultKeyManager.chooseClientAlias(keyType, issuers, socket); } @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { return defaultKeyManager.chooseServerAlias(keyType, issuers, socket); } // 以下略
SSLContext の差し替え
あとは、この KeyManager を使用して SSL 通信を行うように SSLContext を差し替えれば良いです。 これは以下のステップで実施します。
- デフォルトで使用される KeyManager を取得する
- そのうち、
X509KeyManager
の実装クラスを上記で作成したStrategyKeymanager
に差し替える - 差し替えた KeyManager で SSLContext を初期化する
private static SSLContext getSSLContext( String keyStorePath, String keyStoreType, char[] keyStorePassword, AliasSelectionStrategy strategy, String trustStorePath, String trustStoreType, char[] trustStorePassword ) throws IOException, GeneralSecurityException { TrustManager[] trustManagers = getTrustManager(trustStorePath, trustStoreType, trustStorePassword); // 1. デフォルトで使用される KeyManager を取得する KeyManager[] keyManagers = getKeyManagers(keyStorePath, keyStoreType, keyStorePassword); // 2. KeyManager を差し替える for (int i = 0; i < keyManagers.length; i++) { if (keyManagers[i] instanceof X509KeyManager) { keyManagers[i] = new StrategyKeyManagger((X509KeyManager)keyManagers[i], strategy); break; } } // 3. 差し替えた KeyManager で SSLContext を初期化する SSLContext sslContext = SSLContext.getInstance("SSL"); sslContext.init(keyManagers, trustManagers, null); return sslContext; } private static KeyManager[] getKeyManagers(String keyStorePath, String keyStoreType, char[] keyStorePassword) throws IOException, GeneralSecurityException { String algorithm = KeyManagerFactory.getDefaultAlgorithm(); KeyManagerFactory kmf = KeyManagerFactory.getInstance(algorithm); KeyStore keyStore = null; try (InputStream is = Files.newInputStream(Paths.get(keyStorePath))) { keyStore = KeyStore.getInstance(keyStoreType); keyStore.load(is, keyStorePassword); } kmf.init(keyStore, keyStorePassword); return kmf.getKeyManagers(); }
クライアント証明書の選択の例
ここでは、対向サーバのホスト名毎にクライアント証明書を切り替える実装を作ってみました。
public class HostBasedAliasSelectionStrategy implements AliasSelectionStrategy { private Map<String, String> aliasMap; public HostBasedAliasSelectionStrategy(Map<String, String> hostAliasMap) { this.aliasMap = hostAliasMap; } @Override public String selectAlias(String[] keyType, Principal[] issuers, Socket socket) { // 対向ホスト名のクライアント証明書用 alias が Map に登録されていれば、それを使用する String hostName = socket.getInetAddress().getHostName(); if (aliasMap.containsKey(hostName)) { return aliasMap.get(hostName); } return null; } }
組み合わせる
あとはこれらを使って、HTTPS 通信をすれば良いでしょう。
ここでは、server.kiririmode.com 宛の通信には、client1
という alias で登録されたクライアント証明書が使用されます
public static void main(String[] args) throws Exception{ // server.kiririmode.com 宛の通信には、`client1` という alias で登録されたクライアント証明書を使用する Map<String, String> aliasMap = Collections.singletonMap("server.kiririmode.com", "client1"); HostBasedAliasSelectionStrategy strategy = new HostBasedAliasSelectionStrategy(aliasMap); SSLContext sslContext = getSSLContext( "/path/to/keystore.ks", "JKS", "password".toCharArray(), strategy, "/path/to/truststore.ks", "JKS", "password".toCharArray() ); try (CloseableHttpClient client = HttpClients.custom().setSSLContext(sslContext).build()) { HttpGet getMethod = new HttpGet("https://server.kiririmode.com:14433/index.html"); client.execute(getMethod); }