はじめに
ラクス Advent Calendar 2020の21日目の記事です。
投稿日と研修の日が見事に重なった@rs_tukkiがお届けします。
昨日は@ijikeman さんのメール送信サーバ負荷試験の為、受信サーバのI/Oを0にした話でした。
さて今回ですが、**「AndroidアプリでもSSLクライアント認証がしたい!(某アニメ風味)」**という時のためにKotlinでの実装方法についてお話します。
SSLクライアント認証について
まずはSSLクライアント認証の仕組みについて軽く確認しておきましょう。
一般的なSSL/TLS通信の場合、クライアント(接続元)がサーバ(接続先)から渡された電子証明書を確認することで、そのサーバが第三者によって認証された接続先かどうかを認証するプロセスが必要となります。
このプロセスを踏むことで、クライアントは「サーバが信頼のおける接続先かどうか」を判断することができるわけですが、それとは逆に、サーバ側が「クライアントが信頼のおける接続元かどうか」を判断したいケースもあります。
そのような場合は、サーバ側がクライアントから渡された電子証明書を確認することで認証を行うことができます。
この手順を踏むと、あらかじめサーバ側が発行したクライアント証明書を持たないデバイスからは接続ができないため、
- 一般的なID/PASSによる認証より堅牢になる
- ユーザ単位ではなく、デバイス単位での制限がかけられる(自宅PCからのアクセスを遮断する等)
といったメリットがあります。
今回の実装内容
さて、アプリにSSLクライアント認証を組み込むには、当然全ての通信に証明書を付与するような実装をする必要があります。
KotlinからHTTPS通信を行う方法は様々あるかと思いますが、今回は以下2点に絞って解説していきます。
- Okhttp3 & Retrofit2によるAPI通信
- WebViewによる画面表示
Okhttp3&Retrofit2でSSLクライアント認証
WebViewを返さない、HttpClientによるAPI通信でSSLクライアント認証を行いたい場合は、
OkHttpClient.Builder()
で生成したBuilderに対して sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager)
メソッドをコールして通信方式を定義します。
private fun buildHttpClient(repository: AppRepository): OkHttpClient {
val dispatcher = Dispatcher()
val builder = OkHttpClient.Builder()
// SharedPreferencesであらかじめ保存しておいた証明書とパスワードを取り出す
val encodingCert = repository.findCert()
val pass = repository.findPass()
if (encodingCert != null && pass != null) {
builder.sslSocketFactory(addSSLClientCert(encodingCert, pass), addTrustManager())
}
return builder.build()
}
// クライアント証明書を追加してSSLSocketFactoryを生成する
private fun addSSLClientCert(encodingCert: String, pass: String): SSLSocketFactory {
val inputStream = Base64.decode(encodingCert, 0).inputStream() // 証明書ファイルの取得
val password = pass.toCharArray() // 証明書パスワードの取得
val keyStore = KeyStore.getInstance("PKCS12") // 証明書の形式
keyStore.load(inputStream, password)
val keyManagerFactory = KeyManagerFactory.getInstance("X509") // 証明書用のアルゴリズムを指定
keyManagerFactory.init(keyStore, password)
val sslContext = SSLContext.getInstance("TLS") // 通信方式の指定
sslContext.init(keyManagerFactory.keyManagers, null, null)
inputStream.close()
return sslContext.socketFactory
}
// X509TrustManagerを生成する(デフォルトのコードと同じもの)
private fun addTrustManager(): X509TrustManager {
val trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()
)
trustManagerFactory.init(null as KeyStore?)
val trustManagers = trustManagerFactory.trustManagers
if (trustManagers.size != 1 || trustManagers[0] !is X509TrustManager) {
throw IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers))
}
return trustManagers[0] as X509TrustManager
}
// Retrofit2によるHTTPS通信の定義
private fun buildRetrofit(
baseUrl: HttpUrl, // APIのベースとなるURL
client: OkHttpClient // buildHttpClientメソッドで生成したOkHttpClient
): Retrofit = Retrofit.Builder()
.client(client)
.baseUrl(baseUrl)
.build()
sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager)
メソッドをコールしない場合、socket factoryとtrust managerはそれぞれシステムのデフォルトが使われます。
メソッドを使用する場合はそれぞれを独自で定義しなければいけませんが、trust managerについては独自実装の必要がないためデフォルトのコードを流用して生成しています。
重要なのはsslSocketFactoryの方で、ここでは
- 証明書とパスワードを利用してkeyStroreを読み込む
- 読み込んだkeyStroreを利用してkeyManagerFactoryを初期化
- 更にkeyManagerFactoryを利用して初期化されたContextからSocketFactoryの値を返す
という手順を踏んで証明書を設定しています。
あとはそのままビルドしたOkHttpClientをRetrofitのclientとして使用すればOKです。
ちなみに、SSLクライアント認証が必要な環境に対して証明書の設定をしていなかった場合はSSLHandshakeException
が、証明書を設定していてもそのパスワードが間違っていた場合はBeanInstanceCreationException
がスローされますので、
それぞれハンドリングしておくようにしましょう。
WebViewでSSLクライアント認証
続いてWebViewでの認証手順ですが、こちらは上記の手順ほど難しくはありません。
WebViewClientクラスにonReceivedClientCertRequest
というメソッドが定義されていて、WebViewでの通信時にクライアント証明書を求められた場合の挙動を実装できるため、このクラスを継承した内部クラスをFragment内に作成します。
private inner class MyWebViewClient(val isOtherWindow: Boolean) : WebViewClient() {
private var mPrivateKey: PrivateKey? = null
private var mCertificates = arrayOf<X509Certificate?>()
// 中略
// クライアント証明書による認証を求められた場合に呼ばれる
override fun onReceivedClientCertRequest(view: WebView?, request: ClientCertRequest?) {
// SharedPreferencesであらかじめ保存しておいた証明書とパスワードを取り出す
val encodingCert = viewModel.getClientCert()
val clientCertPass = viewModel.getClientCertPass()
if (mPrivateKey == null || mCertificates.isEmpty() && (encodingCert != null && clientCertPass != null) ) {
val inputStream = Base64.decode(encodingCert, 0).inputStream()
val keyStore = KeyStore.getInstance("PKCS12")
val password = clientCertPass!!.toCharArray()
keyStore.load(inputStream, password) //証明書の読み込み
val alias = keyStore.aliases().nextElement()
val key = keyStore.getKey(alias, password)
// 読み込んだ証明書をX509Certificateクラスの配列として取り出す
if (key is PrivateKey) {
mPrivateKey = key
val cert = keyStore.getCertificate(alias)
mCertificates = arrayOfNulls(1)
mCertificates[0] = cert as X509Certificate
}
inputStream.close()
}
// 証明書を利用した通信を行う
request!!.proceed(mPrivateKey, mCertificates)
}
}
こちらは一度keyStroreに読み込んだファイルをX509Certificate
クラスの配列として取り出す、というやや回りくどい方法をとっていますが、
先ほどの例よりは分かりやすいかと思います。
あとは署名キーと組み合わせて通信を続行させてやればOKです。
おわりに
今回はKotlinでSSLクライアント認証を実現する方法について話しました。
通信周りは複雑な仕様が多く実装時にも悩まされましたが、他にも悩んでいる方の一助になれば幸いです。
さて、明日は@aizkoさんです。お楽しみに!
(去年もこの並びだったような…)
参考
【図解】クライアント証明書(https,eap-tls)の仕組み ~シーケンス,クライアント認証,メリット~
OkHttpClient.Builder (OkHttp 3.14.0 API)
Okhttp3でpfx証明書を使う
ssl - Android WebView handle onReceivedClientCertRequest - Stack Overflow