OkHttpでCertificate Pinning

注：この記事の内容には筆者のセキュリティの知識不足・理解不足等により，間違った内容である可能性があります．お気づきの際は編集リクエストやコメントにてご指摘いただければ幸いです．

追記：コメントにて、TLS通信が生きている状態であればMITM攻撃を防御できると指摘いただきました。ありがとうございました。

サンプルコードはKotlinです．

# はじめに

昨今はほぼすべての通信がTLS（SSL）化されてきており， [Androidでも9 (Pie)からTLS通信がデフォルト化され，クリアテキストで行われる通信は別途定義をしなければならなくなります](https://developers-jp.googleblog.com/2018/05/protecting-users-with-tls-by-default-in.html)．

TLS通信は，言うまでもなくサーバーと通信を行った際に，「その証明書が正規なものか」を判断する処理を行うことが重要ですが，Androidアプリにおいて「その証明書が正規なものか」を利用者が判断することは（ほぼ）できません．それどころか，アプリ利用者は，アプリが本当にTLS通信を行っているのかクリアテキストで行っているのか，さらにはどのサーバと通信しているのかすら判断できないのです．
せめて，アプリケーション自体を信頼してもらって，アプリケーションはその信頼に応える安全な方法で通信を行うしかありません．

さて，アプリケーションが通信を行う際の「その証明書が正規なものか」を判断する根拠は何でしょうか？
「有効期限が切れていない？」「コモンネームが一致している？」「信頼されたルート証明機関から発行されている？」これらはすべてTLSの基本ですね．この内の一要素でも欠けていると，そもそも安全なTLS通信が成り立ちません．
しかし，これらの要素が全て問題ない場合でも利用者が攻撃を受けている場合もあります．

# Man In The Middle(MITM) 攻撃

その一つがMITM攻撃と言われるものです．
攻撃の図解はIPAの図がわかりやすいので引用させていただきます．
上記IPAのプレス発表で指摘されている証明書検証の不備による脆弱性は単純に基本的な証明書のエラーを握りつぶした事例なので，そもそも論外な話ではあります．

![image.png](https://qiita-image-store.s3.amazonaws.com/0/51949/2b7ca555-a353-1e1e-ccfb-b26ceaaec0ff.png)
https://www.ipa.go.jp/about/press/20140919_1.html

訂正（コメント欄参照）

~~MITM攻撃も色々有りますが，一番身近ではこのIPAが紹介しているように，攻撃者が用意したアクセスポイントに接続させる攻撃でしょう．
例えば，スタバなどでフリースポットに接続する時，同じ名前のSSIDが2つあったらどちらにつなぎますか~~

~~この状況の時，それぞれのSSIDの設置者が確実に信頼できなければ **どちらに繋いでもいけません**~~
~~しかし，SSIDさえFree-spotと同じ名前にしたり，それっぽい名前でAPを立てておけばセキュリティに疎い人たちなら容易に引っかかってしまうでしょう．~~
~~攻撃対象に物理的に近づくことさえできれば，MITM攻撃は極めて身近な攻撃手法なのです．~~

## Certificate Pinningの有効性

訂正（コメント欄参照）

~~上のAPIの例では，単純な証明書検証の不備が問題でしたが，検証さえしていれば利用者は保護されるでしょうか？残念ながらそうとは限りません．~~
~~単純にサーバ証明書の有効性のみ検証した場合，攻撃を行う中間者が有効なサーバ証明書を用いて攻撃してきた場合に対応できません．~~
~~例えば， `example.com` への接続を中間者が `example.jp` へリダイレクトし，クライアントには `example.jp` の有効な証明書を返す場合です．~~

Certificate Pinningは，「証明書のピンどめ」と訳されたりしますが，「クライアントで許容するTLS証明書を予め規定しておく」ことを指します．
Certificate Pinningを行うことで，「たとえ有効な証明書であっても見知らぬ証明書の通信は受け入れない」ようにすることが可能になります．

# Certificate Pinningを試してみる

Androidで標準的に使われるOkHttpを使って，実際にCertificate Pinningを試してみます．
サンプルコードはこちらで公開しています
https://gitlab.com/tetsukay/certificate-pinning-sample

なお， **OkHttpのバージョン 3.1.2より前と2.7.4より前のバージョンでは [Certificate Pinningを回避される脆弱性が存在する](https://publicobject.com/2016/02/11/okhttp-certificate-pinning-vulnerability/) ため注意が必要です**

## 公開鍵のハッシュ値を取得する

[OkHttpのAPI](https://github.com/square/okhttp/blob/parent-3.12.0/okhttp/src/main/java/okhttp3/CertificatePinner.java#L330) では以下のように証明書の公開鍵のハッシュ値をBase64でエンコードしたものを渡す必要があります．

```
    /**
     * Pins certificates for {@code pattern}.
     *
     * @param pattern lower-case host name or wildcard pattern such as {@code *.example.com}.
     * @param pins SHA-256 or SHA-1 hashes. Each pin is a hash of a certificate's Subject Public Key
     * Info, base64-encoded and prefixed with either {@code sha256/} or {@code sha1/}.
     */
```

ですので，まずは `openssl` コマンドで必要な情報を証明書から取得します．

```bash
#!/bin/bash
certs=`openssl s_client -servername $1 -host $1 -port 443 -showcerts </dev/null 2>/dev/null | sed -n '/Certificate chain/,/Server certificate/p'`
rest=$certs
while [[ "$rest" =~ '-----BEGIN CERTIFICATE-----' ]]
do
 cert="${rest%%-----END CERTIFICATE-----*}-----END CERTIFICATE-----"
 rest=${rest#*-----END CERTIFICATE-----}
 echo `echo "$cert" | grep 's:' | sed 's/.*s:\(.*\)/\1/'`
 echo "$cert" | openssl x509 -pubkey -noout | 
     openssl rsa -pubin -outform der 2>/dev/null | 
     openssl dgst -sha256 -binary | openssl enc -base64
done
```
via https://medium.com/@appmattus/android-security-ssl-pinning-1db8acb6621e

上記のスクリプトを `./cert.sh www.tetsukay.app` という形で実行すると，

```
tetsukay@tetsukay-Ubuntu:~/$ ./cert.sh www.tetsukay.app
/CN=www.tetsukay.app
AShTllD7az9l8twvu2FdZZgTL0l3u7eJuitLi8GRh68=
/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=
```

という感じで結果を得ることができます．
上の `AShTllD7az9l8twvu2FdZZgTL0l3u7eJuitLi8GRh68=` が `www.tetsukay.app` の公開鍵， `YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=` がLet's EncryptのCA証明書の公開鍵です．

CA署名書の公開鍵を使って一括して認証局ごとPinningすることもできますが，今回は個別の証明書の公開鍵でPinningします．

## OkHttp

OkHttpのコードは以下のとおりです．
OkHttpのクライアントに対して `certificatePinner()` を呼び出してPinningしたい公開鍵を渡してあげるだけですね．

```kotlin
val request = Request.Builder().url("https://www.tetsukay.app/").build()
val pinner = CertificatePinner.Builder()
        .add("www.tetsukay.app", "sha256/AShTllD7az9l8twvu2FdZZgTL0l3u7eJuitLi8GRh68=")
        .build()
val client = OkHttpClient.Builder().certificatePinner(pinner).build()
client.newCall(request).execute().isSuccessful.should be true
```

## テスト

最後に，Pinningのテストを行います．

### Pinningなし

とりあえずPinning無しです．
当然正常に通信可能です．

```kotlin
@Test
fun withoutPinner(): Unit = runBlocking {
    val job = GlobalScope.launch {
        val request = Request.Builder().url("https://example.com/").build()
        val client = OkHttpClient.Builder().build()
        client.newCall(request).execute().isSuccessful.should be true
    }
    job.join()
    return@runBlocking
}
```

### 有効な証明書のPinning

まずは，正常系のテストです．
`www.tetsukay.app` を指定して，そこに配置している公開鍵を指定します．

```kotlin
@Test
fun withValidPinner(): Unit = runBlocking {
    val job = GlobalScope.launch {
        val request = Request.Builder().url("https://www.tetsukay.app/").build()
        val pinner = CertificatePinner.Builder()
                .add("www.tetsukay.app", "sha256/AShTllD7az9l8twvu2FdZZgTL0l3u7eJuitLi8GRh68=")
                .build()
        val client = OkHttpClient.Builder().certificatePinner(pinner).build()
        client.newCall(request).execute().isSuccessful.should be true
    }
    job.join()
    return@runBlocking
}
```

### CA証明書を指定してPinning

次に，CA証明書をPinningしてみます．
指定しているのは Let's Encrypt のCA証明書の公開鍵です．
`www.tetsukay.app` ではLet's Encryptを使用していますので，これも正常に通信が可能です．

```kotlin
@Test
fun withValidCAPinner(): Unit = runBlocking {
    val job = GlobalScope.launch {
        val request = Request.Builder().url("https://www.tetsukay.app/").build()
        val pinner = CertificatePinner.Builder()
                .add("www.tetsukay.app", "sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=")
                .build()
        val client = OkHttpClient.Builder().certificatePinner(pinner).build()
        client.newCall(request).execute().isSuccessful.should be true
    }
    job.join()
    return@runBlocking
}
```

### Let's Encrypt のPinningでLet's Encryptにアクセスしてみる

さらに，同じ公開鍵でPinningしてLet's Encryptにアクセスしてみます．
Let's Encryptも自身の認証局から発行された証明書を使用しているので，これも通ります．
**CA証明書の公開鍵をPinningすると，同じ認証局から発行された証明書を使った証明書をすべて受け入れてしまうこと注意しましょう**

```kotlin
fun withValidCAPinnerOfLetsEncrypt(): Unit = runBlocking {
    val job = GlobalScope.launch {
        val request = Request.Builder().url("https://letsencrypt.org/").build()
        val pinner = CertificatePinner.Builder()
                .add("letsencrypt.org", "sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=")
                .build()
        val client = OkHttpClient.Builder().certificatePinner(pinner).build()
        client.newCall(request).execute().isSuccessful.should be true
    }
    job.join()
    return@runBlocking
}
```

### 無効な公開鍵でPinning

いよいよ本番です．
適当な値でPinningしてみます．
`execute()` を呼び出すと `SSLPeerUnverifiedException` がスローされて通信エラーとなります．

```kotlin
@Test
fun withInvalidPinner(): Unit = runBlocking {
    val job = GlobalScope.launch {
        val request = Request.Builder().url("https://www.tetsukay.app/").build()
        val pinner = CertificatePinner.Builder()
                .add("www.tetsukay.app", "sha256/1234567890ABCDEF=")
                .build()
        val client = OkHttpClient.Builder().certificatePinner(pinner).build()

        try {
            client.newCall(request).execute()
            fail()
        } catch (e: SSLPeerUnverifiedException) {
            // OK
        }
    }
    job.join()
    return@runBlocking
}
```

### 異なる証明書の値でPinning

無効な公開鍵でPinningと変わらないのですが，念の為 `www.tetsukay.app` の公開鍵でPinningして， `www.example.com` へアクセスしてみます．
こちらも `SSLPeerUnverifiedException` をスローして通信に失敗します．

```kotlin
@Test
fun withInvalidPinner2(): Unit = runBlocking {
    val job = GlobalScope.launch {
        val request = Request.Builder().url("https://example.com/").build()
        val pinner = CertificatePinner.Builder()
                .add("example.com", "sha256/AShTllD7az9l8twvu2FdZZgTL0l3u7eJuitLi8GRh68=")
                .build()
        val client = OkHttpClient.Builder().certificatePinner(pinner).build()

        try {
            client.newCall(request).execute()
            fail()
        } catch (e: SSLPeerUnverifiedException) {
            // OK
        }
    }
    job.join()
    return@runBlocking
}
```

# おまけ：Certificate Transparency（証明書の透明性）

WebにおけるHTTP-based Public Key Pinning(HPKP)はすでにGoogleは非推奨としており，代わりにCertificate Transparencyという仕組みに移りつつあります．
今のところ，OkHttpは対応する気無いみたいですね
https://github.com/square/okhttp/issues/2938