header image

枝折

証明書検証して公開鍵取り出して JWS 検証するやつ

CREATED: 2024 / 03 / 02 Sat

UPDATED: 2024 / 03 / 03 Sun

AppStore サーバー通知 v2 対応が勉強になった。

証明書検証して公開鍵取り出してみる

巷にあるサービスからの通知を受けるとかする場合に、証明書を検証するようなパターンがある。

これは現状主に AppStore のサーバー通知をイメージしているのだが、それに限ったことでもないだろうし、そういうパターンから学びになることがあると思ったので、これに関する対応についてちょっと記録しておきたかった。

ルート証明書は公開されていて、公式のドキュメントから取得することができる前提で、別口で受け取った JWS のヘッダーから証明書チェーンを取り出し、公開されているルート証明書でそれを検証する。

で、検証が成功したら JWS の証明書チェーンの一番最初の証明書から公開鍵を取り出す。

公開鍵を使ってハッシュアルゴリズムからペイロードをハッシュ値化し、それが JWS シグネチャーと一致するかを確認、つまり改竄を検知するわけだ。

JWS に含まれる証明書チェーンの検証し、公開鍵を取り出す

AppStore のサーバー通知 V2 の仕様を前提に話すことになるが、iOS アプリで何かしらの購入処理を行うと、JWS 仕様に従って作られた文字列を含めたリクエストが事前に設定したエンドポイントへ通知される。

エンドポイント部分でリクエストボディの中身を覗くと、signedPayload なる要素を見つけることができる。 こいつが JWS の仕様によって作られた文字列で、. を境に3つの要素に分割されている。 つまり、ヘッダー部、ペイロード部、署名部である。

これを検証するためにやることの順序を先に並べておくとこんな感じ。

1. ヘッダー部から証明書チェインを取り出す
2. 公開されたルート証明書を利用して証明書チェインを検証する
3. 公開鍵を取り出す

まず、ヘッダー部から証明書チェインを取り出すところを考えてみる。 言語は Ruby を使う。

早速、AppStore のサーバー通知のエンドポイントとして自前のサーバーを指定してあげた上でテストフライトなどで購入処理を行ってみる。 すると Apple 側からリクエストが届くで、この中から signedPayload を取得する。 こいつが JWS の仕様に従って作られた文字列にあたるので、これを . を境に分解してみる。 分解した結果は3つの要素を持つ配列になるはずで、これが頭からヘッダー部、ペイロード部、署名部とすると、一つ目の要素に証明書チェインが入っていることになる。 JWS の各部は Base64 エンコードされているので、中身を覗くにはデコードする必要がある、 以下のコードではさらに JSON パースして取り扱いやすいようにしている。

jws = params['signedPayload']
jws_parts = jws.split('.')
headers = JSON.parse(Base64.urlsafe_decode64(jws_parts[0]))

この headers には概ね以下のような要素が入っている。

{
  "alg": "ES256",
  "x5c": [
    "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"
  ]
}

alg は JWS のハッシュアルゴリズムを指すが、AppStore サーバー通知では ES256 が使われていることがわかる。 ES256 は ECDSA using P-256 and SHA-256 のことだが、x5c に含まれる証明書チェーンの検証にはこれと同じアルゴリズムを用いて署名されているルート証明書を利用する必要がある。

つまり、Apple PKI で提供されているルート証明書のうち、ECDSA に対応しているものは G3(Apple Root CA - G3 Root) なので、こちらをダウンロードして使う必要がある(openssl x509 -in AppleRootCA-G3.cer -inform DER -text -noout で中を除いて Algorithm を確認すると、G3 Root のみが ECDSA に対応していることがわかる)。 Apple PKI

JWS ヘッダー部の x5c の仕様は以下で確認できる。 “x5c” (X.509 Certificate Chain) Header Parameter

Each string in the array is a base64-encoded (Section 4 of [RFC4648] -- not base64url-encoded) DER [ITU.X690.2008] PKIX certificate value. The certificate containing the public key corresponding to the key used to digitally sign the JWS MUST be the first certificate.

とあるように、x5c に含まれる証明書のうち最初のものにデジタル署名に利用された公開鍵が含まれることになっている。 ちなみに PKIX とは、X.509 を利用した PKI(公開鍵暗号方式を利用したセキュリティの技術基盤)そのものを指すようだ(PKIX is a short name for the Internet Public Key Infrastructure using X.509 defined in RFC 5280)。

Ruby で証明書チェーンの有効性を確認し、先頭の証明書から公開鍵を取り出してみるとこんな感じになる。

# 上述の headers から x5c を取り出す
certificate, *chain = headers['x5c'].map { |c| OpenSSL::X509::Certificate.new(Base64.strict_decode64(c)) }
root_certificate = OpenSSL::X509::Certificate.new(File.read('/path/to/AppleRootCA-G3.cer'))
store = OpenSSL::X509::Store.new
store.add_cert(root_certificate)

if store.verify(certificate, chain)
  certificate.public_key # 公開鍵を取得する
end

x5c から取り出した証明書を OpenSSL::X509::Certificate インスタンスに置き換え、最初の証明書を certificate に含め、それ以降の証明書を chain に含めている。 で、ローカルの AppleRootCA-G3.cer を利用して chain を証明している。 OpenSSL::X509::Store は信頼された証明書のリストを管理しますが、add_certAppleRootCA-G3.cer を追加し、verify でこれを用いて certificate 及び chain を検証している。 これが成功すれば、あとは public_key を取得して JWS の署名部を検証するだけだ。

ところで、x5c の中に入っている証明書の中身をのぞいてみたいと思いませんか? 仕様によると、x5c には Base64 エンコードされた DER 形式の文字列が配列で入っている。 これは上記に挙げた JSON のとおりだが、これを取り出してローカルの openssl で中をのぞいてみる。

$ echo MIIEMDCCA7agAwIBAgIQfTlfd0fNvFWvzC1YIANsXjAKBggqhkjOPQQDAzB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTIzMDkxMjE5NTE1M1oXDTI1MTAxMTE5NTE1MlowgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5lcyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEFEYe/JqTqyQv/dtXkauDHCScV129FYRV/0xiB24nCQkzQf3asHJONR5r0RA0aLvJ432hy1SZMouvyfpm26jXSjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig97bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2NlcnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggENBgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFAMs8Pjs6VhWGQlzE2ZOE+GX4Oo/MA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAwNoADBlAjEA8yRNdskp506DFdPLghLLJwAv5J8hBGLaI8DExdcPX+aBKjjO8eUo9KpfpcNYUY5YAjAPXmMXEZL+Q02adrmmshNxz3NnKm+ouQwU7vBTn0LvlM7vps2YslVTamRYL4aSs5k= | base64 --decode >> first.der
$ openssl x509 -in first.der -inform DER -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            7d:39:5f:77:47:cd:bc:55:af:cc:2d:58:20:03:6c:5e
    Signature Algorithm: ecdsa-with-SHA384
        Issuer: CN=Apple Worldwide Developer Relations Certification Authority, OU=G6, O=Apple Inc., C=US
        Validity
            Not Before: Sep 12 19:51:53 2023 GMT
            Not After : Oct 11 19:51:52 2025 GMT
        Subject: CN=Prod ECC Mac App Store and iTunes Store Receipt Signing, OU=Apple Worldwide Developer Relations, O=Apple Inc., C=US
# 省略...

この証明書は x5c の最初の要素なので、こいつから公開鍵を取り出して JWS の署名を検証する必要があるわけだ。 これ以外の証明書ものぞいてみる。

$ openssl x509 -in second.der -inform DER -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            22:c1:a1:47:0a:74:73:69:ef:53:86:12:c9:c6:9f:3d:38:f3:6c:d7
    Signature Algorithm: ecdsa-with-SHA384
        Issuer: CN=Apple Root CA - G3, OU=Apple Certification Authority, O=Apple Inc., C=US
        Validity
            Not Before: Mar 17 20:37:10 2021 GMT
            Not After : Mar 19 00:00:00 2036 GMT
        Subject: CN=Apple Worldwide Developer Relations Certification Authority, OU=G6, O=Apple Inc., C=US
# 省略...
$ openssl x509 -in third.der -inform DER -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 3298319966700653461 (0x2dc5fc88d2c54b95)
    Signature Algorithm: ecdsa-with-SHA384
        Issuer: CN=Apple Root CA - G3, OU=Apple Certification Authority, O=Apple Inc., C=US
        Validity
            Not Before: Apr 30 18:19:06 2014 GMT
            Not After : Apr 30 18:19:06 2039 GMT
        Subject: CN=Apple Root CA - G3, OU=Apple Certification Authority, O=Apple Inc., C=US
# 省略...

ちゃんとチェーンになってますね。 third.derIssuerSubject は一致し、second.derIssuerthird.derSubject と一致、first.derIssuersecond.derSubject と一致している。 どうやら3番目の証明書がルートとなっていて、2番目、1番目という順に子が連なっているようだ。 Apple PKI で取得したルート証明書が3番目の証明書と一致するのも確認できるだろう。

証明書から公開鍵を取り出して JWS を検証

JWS の署名部の検証は取り出した公開鍵を用いて行う。 Ruby でやるとこれだけ。

# ruby-jwt を利用する(https://github.com/jwt/ruby-jwt)
# Decode(https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/decode.rb)
# 第1引数に JWS
# 第2引数に公開鍵
# 第3引数に検証の有無
# 第4引数に対応するアルゴリズム
JWT.decode jws, public_key, true, { algorithm: 'ES256' }

この検証に成功すると配列が返ってきて、その一つ目の要素にペイロードを確認できるだろう。 AppStore サーバー通知ではそのペイロードの中身に従ってサーバー上で必要な処理を行うことができる。

を仕舞い

他は特にありません、仕舞いです。