Java: Transport Layer Security

Takami Torao Java 11 #SSL #TLS #JSSE
  • このエントリーをはてなブックマークに追加

概要

TLS (transport layer security) は TCP 上で安全な通信を行うためのプロトコル。PKI (public key infrastructure; 公開鍵基盤) に基づいて通信相手を認証し、暗号化されたストリームを通じて改ざん不可能なデータのやり取りを行うことができる。

TLS は Netscape Navigator に実装された SSL (secure socket layer) から派生した仕様である。SSL は Netscape 社による実装であったため、インターネット上でのより中立的な開発主体の必要性から IEFT に移管され SSL 3.0 を改良した TLS 1.0 が最初に実装された。また 1 社による実装であった SSL は検証が十分ではなく、いずれのバージョンにも脆弱性が発見されて 2015 年には IETF よって SSL 3.0 の使用が禁止された。現在ではセキュリティが必要な通信に SSL ではなく TLS を使用しなければならない。このページでは歴史的背景がかかわる部分以外では TLS という言葉を使用している。

Java では J2SE 1.4 から JSSE (Java Secure Socket Extension) が標準となり SSL/TLS を使用したソケット通信が可能になった。

JSSE コンポーネント

JSSE Components
Fig 1. JSSE 主要コンポーネント

JSSE に用意されているコンポーネントは多いがすべての理解が必要というわけではなく、対応すべき状況を絞り込むことで考慮の多くは不要となる。

SSLSocket, SSLServerSocket

SSLSocket, SSLServerSocket クラスはそれぞれ Socket, ServerSocket クラスの SSL/TLS 版である (これらを作成することがこのページのゴールである)。通常のソケット通信と同様に入出力ストリームを使用してデータの送受信を行うことができるが、Channel を使った非同期 I/O には対応していないSSLEngine を使用して非同期 I/O を実装することは可能だが、難易度は非常に高く、現実的にはすでにそれが実装されている Netty などの非同期 RPC フレームワークを利用する方が良いだろう。

SSLSocket, SSLServerSocket はそれぞれ SSLContext を通じて SSLSocketFactory, SSLServerSocketFactory から生成することができる。

SSLContext

SSLContext は TLS 通信のコンフィギュレーションを表している。ローカル側で使用する秘密鍵と証明書の選択を KeyManager に、通信相手から取得したピアの証明書の検証を TrustManager にそれぞれ移譲している。

一般的な TLS 接続、つまり、広く使われている CA が発行したサーバ証明書によるサーバ認証を行う TLS 接続の場合、クライアント側ではデフォルトの SSLContext を使用すれば良い。

SSLContext context = SSLContext.getDefault();
Class Diagram
Fig 1. SSLContext

TLS 通信のサーバ側や、クライアント認証を行うクライアント側ではローカル側の身元を証明するための秘密鍵と証明書を使用した KeyManager を指定する必要がある。

SSLContext context = SSLContext.getInstance("TLS");
context.init(new KeyManager[]{ keyManager }, null, null);

KeyManager

KeyManager は TLS の通信相手がサポートしている暗号化アルゴリズムにマッチするローカル側の秘密鍵と証明書を選択する (現実的な基底クラスは X509ExtendedKeyManager が使用される)

一般的にはローカルの認証に使用する秘密鍵-証明書エントリが保存された KeyStore を使って初期化する。

InputStream in = new FileInputStream("local.p12");
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(in, keyStorePassphrase);
KeyManagerFactory factory = KeyManagerFactory.getInstance("SunX509");
factory.init(keyStore, entryPassphrase);
KeyManager[] keyManagers = factory.getKeyManagers();
Class Diagram
Fig 2. KeyManager

TrustManager

TrustManager は TLS ハンドシェイクで得た通信相手の証明書が信用できるかを検証する役割を持つ。デフォルトでは Java ランタイムにバンドルされている一般的な CA 証明書のいずれかから発行されているかなどを検証する。

通信相手が自己署名証明書を使用している場合や、独自のプライベート CA を使用している場合、期限切れなどの無効な証明書を無理やり通信しなければならないケースではその TLS 通信で使用する TrustManager を差し替えなければならない。

InputStream in = new FileInputStream("trusted.p12");
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(in, keyStorePassphrase);
TrustManagerFactory factory = TrustManagerFactory.getInstance("SunX509");
factory.init(keyStore);
TrustManager[] trustManagers = factory.getTrustManagers();
Class Diagram
Fig 3. TrustManager

上記の例では trusted.p12 に通信相手の証明書に署名した CA 証明書が保存されていることを想定している。

実装パターン

どのような状況での TLS 通信を想定するかによって利用するコンポーネントが決まるため、必ずしもすべての主要コンポーネントの役割を理解する必要はない。以下の 3 点を考慮すればどのような実装が必要かは決まる。

実装したいのは:
  1. クライアント側
  2. サーバ側
認証方法は:
  1. サーバ認証のみ (一般的なSSL/TLS通信)
  2. クライアント認証が必要
相手の証明書は:
  1. 一般的な商用 CA が発行したもの
  2. Private CA が発行したものや自己署名証明書

2 または 1-B の状況では KeyManager を使用する必要があるし、β の状況では TrustManager を使用する必要がある。

一般的なサーバ認証型 TLS 通信

一般的な TLS 通信では接続先が目的のサーバであることを検証するためにサーバ認証を行う。ここでの説明はクライアントとサーバの環境に以下を前提としている。サーバは公的な CA によって署名されたサーバ証明書とその秘密鍵を使用し、クライアントは信頼済み CA 証明書リストのいずれかの CA から発行されたサーバ証明書であることを検証する。

クライアント側の実装 [1-A-α]

一般的な CA から発行されたサーバ証明書を持つサーバと TLS 通信を行うためのクライアントソケットは簡単に作成することができる。以下のように得たソケットは tsl.server.com:8188 との通信が可能である。

SocketFactory factory = SSLSocketFactory.getDefault();
SSLSocket socket = (SSLSocket)factory.createSocket("tls.server.com", 8188);

サーバ側の実装 [2-A-α]

Java では keytool で作成する JKS ファイルか、OpenSSL などで作成した PKCS#12 ファイルを使って秘密鍵-証明書ペアを使用するのが一般的である。以下の例では PKCS#12 形式のファイル server.pk12 からサーバの秘密鍵とサーバ証明書を読み込んで SSLContext を作成している。

import java.io._
import java.nio.charset.StandardCharsets
import java.security.KeyStore._
import java.security.Security

import javax.net.ssl.{KeyManagerFactory, KeyStoreBuilderParameters, SSLContext}

import scala.collection.JavaConverters._

println(s"KeyManagerFactory: ${Security.getAlgorithms("KeyManagerFactory").asScala.toSeq.sorted.mkString(", ")}")
println(s"KeyStore         : ${Security.getAlgorithms("KeyStore").asScala.toSeq.sorted.mkString(", ")}")
println(s"SSLContext       : ${Security.getAlgorithms("SSLContext").asScala.toSeq.sorted.mkString(", ")}")

val pkcs12File = new File("server.p12")
val password = "****"

// Open an SSLServerSocket for TLS communication using the private key and certificate signed by itself
// or by private CA, which stored in PKCS#12 as file "server.pk12".
val sslContext = {
  val keyManagerFactory = {
    val builder = Builder.newInstance("PKCS12", null, pkcs12File, new PasswordProtection(password.toCharArray))
    val params = new KeyStoreBuilderParameters(Array(builder).toList.asJava)
    val kmf = KeyManagerFactory.getInstance("NewSunX509")
    kmf.init(params)
    kmf
  }

  val sc = SSLContext.getInstance("TLS")
  sc.init(keyManagerFactory.getKeyManagers, null, null)
  sc
}

val serverSocketFactory = sslContext.getServerSocketFactory
val serverSocket = serverSocketFactory.createServerSocket(8188)
println(s"HTTPS server listening on port: https://localhost:${serverSocket.getLocalPort}/")
while(true) {
  val socket = serverSocket.accept()
  println(s"Accept connection from: ${socket.getInetAddress.getHostName}")
  val in = new BufferedReader(new InputStreamReader(socket.getInputStream, StandardCharsets.ISO_8859_1))
  Iterator.continually(in.readLine()).takeWhile(_.nonEmpty).foreach(_ => None)
  val out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream, StandardCharsets.UTF_8))
  out.write("HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\n\r\nhello, world")
  out.flush()
  socket.close()
}

プログラムで PKCS#12 ファイルを読み込んで KeyStore オブジェクトを作成してから KeyManagerFactory を初期化することもできる。ファイル以外のストレージに保存した PKCS#12 データを使用する場合にはこの方法の方が良いかもしれない。

val keyManagerFactory = {
  val keyStore = KeyStore.getInstance("PKCS12")
  val in = new FileInputStream(pkcs12File)
  keyStore.load(in, password.toCharArray)
  val factory = KeyManagerFactory.getInstance("SunX509")
  factory.init(keyStore, password.toCharArray)
  factory
}

クライアントとサーバの違いである KeyManager は TLS 通信で使用する秘密鍵と証明書を制御する役割を持つ。もし TLS でクライアント認証を行いたいのであれば、クライアントの秘密鍵とクライアント証明書の KeyManager で初期化した SSLContext を使用する。

上記のサーバ実装を実行しブラウザでアクセスすると以下のように出力される。

KeyManagerFactory: NEWSUNX509, SUNX509
KeyStore         : CASEEXACTJKS, DKS, JCEKS, JKS, PKCS12, WINDOWS-MY, WINDOWS-ROOT
SSLContext       : DEFAULT, DTLS, DTLSV1.0, DTLSV1.2, TLS, TLSV1, TLSV1.1, TLSV1.2, TLSV1.3
HTTPS server listening on port: https://localhost:8188/
Accept connection from: localhost
Accept connection from: localhost
...

このとき server.pk12 に含まれているサーバ証明書が一般的な CA によって発行されたものでなければ (自己署名証明書や private CA によって作成されたものであれば) ブラウザには警告が表示されるだろう。

自己署名証明書やプライベート CA を指定する TLS 通信

Java のランタイムは一般的なブラウザにインストールされているような信頼済み CA 証明書リストを持っており、デフォルトの SSLContext はそれらを使用してサーバ証明書を検証することができる。しかし、目的によっては信頼済み CA 証明書を独自の CA 証明書と置き換えなければならないことがある。

よくある状況は開発テストや社内サーバのような限定された環境で自己署名証明書を使用しているサーバに接続する場合である。他にも公的な CA に頼らず プライベート CA で構築されたネットワークが考えられる。このような環境側ではクライアント側でサーバ証明書に署名した CA 証明書を信頼済み CA 証明書に追加しなければならない。

クライアント側実装 [1-A-β]

クライアント側の信頼済み CA 証明書リストにない CA から発行されたサーバ証明書を使用しているサーバに Java や curl で接続すると以下のようなエラーに遭遇するだろう。

javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
  at sun.security.ssl.Alerts.getSSLException(Alerts.java:192)
  at sun.security.ssl.SSLSocketImpl.fatal(SSLSocketImpl.java:1964)
  ...
Caused by: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
  at sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:397)
...
Caused by: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
  at sun.security.provider.certpath.SunCertPathBuilder.build(SunCertPathBuilder.java:141)
  at sun.security.provider.certpath.SunCertPathBuilder.engineBuild(SunCertPathBuilder.java:126)
  at java.security.cert.CertPathBuilder.build(CertPathBuilder.java:280)
  at sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:392)
  ... 49 more
$ curl https://localhost:8188
curl: (77) schannel: next InitializeSecurityContext failed: SEC_E_UNTRUSTED_ROOT (0x80090325) - 信頼されていない機関によって証明書チェーンが発行されました。

Java では TrustManager を使用して通信相手の証明書が信頼できる CA から発行されたものかの判断を制御することができる。

TrustManager をカスタマイズする

信頼済みCA証明書の置き換え

サーバが自己署名証明書を使用するケースでは、サーバ証明書が CA 証明書も兼ねているため、クライアント側ではサーバ証明書を信頼済み CA 証明書リストに追加しなければならない。同様に、プライベート CA が発行したサーバ証明書を使用している場合はプライベート CA の証明書を信頼済み CA 証明書リストに追加しなければならない。

以下の例は PKCS#7 形式の ca.p7b から読み込んだ証明書を信頼済み CA 証明書とみなすクライアント実装である。前述の TLS サーバ実装の server.p12 を自己署名証明書版に置き換えることで試すことができる。

import java.io._
import java.nio.charset.StandardCharsets._
import java.security.Security
import java.security.cert._

import javax.net.ssl._

import scala.collection.JavaConverters._

println(s"TrustManagerFactory: ${Security.getAlgorithms("TrustManagerFactory").asScala.toSeq.sorted.mkString(", ")}")
println(s"SSLContext         : ${Security.getAlgorithms("SSLContext").asScala.toSeq.sorted.mkString(", ")}")
println(s"CertificateFactory : ${Security.getAlgorithms("CertificateFactory").asScala.toSeq.sorted.mkString(", ")}")

// Start TLS communication with SSLContext that uses the CA certificate that signed the server
// certificate as a trusted CA. This CA certificate is stored in PKCS#7 as file "ca.p7b".
val clientSocket = {
  val trustManagerFactory = {
    val trustAnchors = {
      val factory = CertificateFactory.getInstance("X.509")
      println(s"CertPathEncodings[X.509]: ${factory.getCertPathEncodings.asScala.mkString(", ")}")

      val in = new FileInputStream("ca.p7b")
      val certs = factory.generateCertificates(in).asScala.toList.map(_.asInstanceOf[X509Certificate])
      in.close()
      certs.map(cert => new TrustAnchor(cert, null)).toSet.asJava
    }

    val certSelector = new CertSelector {
      override def `match`(cert:Certificate):Boolean = {
        println(s"match($cert)")
        true
      }

      override def clone():AnyRef = super.clone()
    }
    val factory = TrustManagerFactory.getInstance("PKIX")
    factory.init(new CertPathTrustManagerParameters(new PKIXBuilderParameters(trustAnchors, certSelector)))
    factory
  }

  val sslContext = SSLContext.getInstance("TLS")
  sslContext.init(null, trustManagerFactory.getTrustManagers, null)
  sslContext.getSocketFactory.createSocket("localhost", 8188)
}

val in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream, ISO_8859_1))
val out = new PrintWriter(new OutputStreamWriter(clientSocket.getOutputStream, ISO_8859_1))
out.println("GET / HTTP/1.1")
out.println("Connection: close")
out.println()
out.flush()
Iterator.continually(in.readLine).takeWhile(_ != null).foreach(line => println(s">> $line"))

clientSocket.close()

実行結果は以下の通り。自己署名証明書を使用したサーバと TLS で通信できている。

TrustManagerFactory: PKIX, SUNX509
SSLContext         : DEFAULT, DTLS, DTLSV1.0, DTLSV1.2, TLS, TLSV1, TLSV1.1, TLSV1.2, TLSV1.3
CertificateFactory : X.509
CertPathEncodings[X.509]: PkiPath, PKCS7
>> HTTP/1.1 200 OK
>> Content-Type:text/plain
>>
>> hello, world

プライベート CA から発行されたクライアント証明書を使ってクライアント認証を行う場合は、同様に、クライアントの SSLSocket 構築時にクライアントの身元を証明する KeyManager を、サーバの SSLServerSocket 構築辞にそれを受けれる TrustManager を指定しなければならない。これは使用サイドが異なるだけで上記を応用してと同様に行うことができる。

自己署名証明書やプライベート CA であっても、CA の秘密鍵が秘匿され、信頼済み CA 証明書として安全に運搬/デプロイされている限りは、公的な CA による証明書を使った場合と同等のセキュリティが得られる。

TrustManager の無効化

通信相手のサーバ証明書の有効期限が切れている場合や、そもそも CA 証明書が入手できない場合など、信頼済み CA 証明書を置き換える手段が使えないケースも考えられる。このような場合、最悪の手段として証明書の検証を全く行わない X509ExtendedTrustManager を実装して無効化することで認証を通過させることが可能である。

以下の例はサーバ証明書の有効性を一切検証しない TrustManager を使用している。当然ながらこの方法は鍵のすり替えなどの中間者攻撃に全く抵抗することができない。

import java.io._
import java.net.Socket
import java.nio.charset.StandardCharsets._
import java.security.cert.X509Certificate

import javax.net.ssl.{SSLContext, SSLEngine, SSLSocket, X509ExtendedTrustManager}

val customTrustManager = new X509ExtendedTrustManager {
  override def checkClientTrusted(chain:Array[X509Certificate], authType:String, socket:Socket):Unit = None
  override def checkServerTrusted(chain:Array[X509Certificate], authType:String, socket:Socket):Unit = None
  override def checkClientTrusted(chain:Array[X509Certificate], authType:String, sslEngine:SSLEngine):Unit = None
  override def checkServerTrusted(chain:Array[X509Certificate], authType:String, sslEngine:SSLEngine):Unit = None
  override def checkClientTrusted(chain:Array[X509Certificate], authType:String):Unit = None
  override def checkServerTrusted(chain:Array[X509Certificate], authType:String):Unit = None
  override def getAcceptedIssuers:Array[X509Certificate] = Array.empty
}

val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, Array(customTrustManager), null)

val socket = sslContext.getSocketFactory.createSocket("localhost", 8188).asInstanceOf[SSLSocket]
val out = new OutputStreamWriter(socket.getOutputStream, ISO_8859_1)
out.write("GET / HTTP/1.1\r\nConnection:close\r\n\r\n")
out.flush()

val in = new BufferedReader(new InputStreamReader(socket.getInputStream, ISO_8859_1))
Iterator.continually(in.readLine()).takeWhile(_.nonEmpty).foreach(_ => None)
println(in.readLine())

socket.close()

TLS ハンドシェイクの挙動

TLS ハンドシェイク時の KeyManager, TrustManager の挙動をまとめよう。下記 Alt 部分は TLS クライアント認証が有効なときに行われる処理である。

Custom KeyManager/TrustManager
Fig 1. TLS ハンドシェイク時の KeyManager / TrustManager の挙動

KeyManager はローカル側で使用する秘密鍵と証明書を参照する役割を持つ。KeyManager(Keystore のように) 複数の秘密鍵-証明書チェーンペアを管理しており、それぞれの鍵ペアはエイリアスと呼ばれる名前で区別していると想定している。

1.1) ハンドシェイク開始時にクライアントから利用可能な cipher suite が送信される。クライアントが対応している鍵アルゴリズム名に対して利用可能な鍵のエイリアスを参照するために chooseServerAlias() が呼び出される。鍵アルゴリズム名は EC, RSA, DSA などがあり、複数ある場合は有効なエイリアスが返されるまで繰り返し呼び出される。結果的にすべてのアルゴリズム名に対して null が返されたとき TLS ハンドシェイクは "no cipher suites in common" とみなされて失敗する。

1.2) エイリアス名に対する秘密鍵とサーバ証明書チェーン (ルートCA証明書からサーバ証明書までの一連の証明書) を参照するために getPrivateKey(), getCertificateChain() が呼び出される。

2.1) クライアント認証を行う場合、サーバ側の信頼済みの CA 証明書を参照するために getAcceptedIssuers() が呼び出される。

1.3) サーバ証明書を得たクライアント側ではその証明書が信頼できるかを検証するために checkServerTrusted() が呼び出される。サーバ認証のみの場合はこのステップでハンドシェイクが完了する。

2.2) サーバから渡された cipher suite に対応する鍵アルゴリズム名に対して利用可能な鍵のエイリアスを参照するために chooseClientAlias() が呼び出される。

2.3) エイリアス名に対する秘密鍵とクライアント証明書チェーンを参照するために getPrivateKey()getCertificateChain() が呼び出される。

2.4) クライアント証明書を得たサーバ側ではその証明書が信頼できるかを検証するために checkClientTrusted() が呼び出される。このステップを通過すればクライアント認証での TLS ハンドシェイクは完了する。

Note

SSL Context や SSL Engine の cipher suites を変更していないにもかかわらず、SSL ハンドシェイク時にサーバサイドで "no cipher suites in common" が発生する場合 (クライアントサイドでは handshake_failure として現れる) は、SSLContext に KeyManager が設定されていなかったり、秘密鍵が KeyManager に正しく読み込まれていない可能性がある。これは PKCS#12 のエントリ参照時にパスフレーズに null を指定したケースで発生する。

javax.net.ssl.SSLHandshakeException: no cipher suites in common
  at sun.security.ssl.Alerts.getSSLException(Alerts.java:192)
  at sun.security.ssl.SSLSocketImpl.fatal(SSLSocketImpl.java:1964)
  at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:328)
  at sun.security.ssl.Handshaker.fatalSE(Handshaker.java:318)
  ...