phpでSafetyNet APIのレスポンスの証明書を検証する

こんにちは。サーバーサイドエンジニアの鑓水です。

今回はGoogleが提供している不正利用防止APIである、SafetyNet Attestation APIを用いたAndroid デバイスの不正利用検出を、phpでやってみたいと思います。
その中でも証明書チェーンの検証に関してはドキュメントが少ないのでその部分を今回は説明しようと思います。

SafetyNet Attestation APIとは

SafetyNet Attestation API は、アプリが動作している Android デバイスをアプリのデベロッパーが評価するための不正利用防止 API です。
詳細は公式のドキュメントを参照してください。

証明書検証の流れ

  1. JWT形式のレスポンスをデコード
  2. 証明書情報のパース
  3. 証明書情報の検証

JWT形式のレスポンスをデコード

SafetyNet Attestation APIのレスポンスをアプリ側からサーバーに送ってもらいます。
形式はJWT(JWS)で受け取ります。形式はアプリ側の利用するライブラリに依存します。
今回はJWTで受け取る場合を想定します。
phpでJWTを扱う場合、いくつかライブラリがあります。
メジャーなものだとphp-jwtでしょうか。
ただし、現在php-jwtでは証明書チェーンの検証をサポートしていないため、
検証部分は独自で実装する必要があります。

JWTは[ヘッダ.ペイロード.署名]の形式で連結しているので、まずはそれぞれ分割します。

[$headb64, $bodyb64, $cryptob64] = explode('.', $jwt);

分割できない場合はJWTとして扱えないので先に不正検出しても良いかもしれません。
※ 以降 echo の部分は適宜Exceptionをthrowしたり、ログを検出したりしてみてください。

$jwt_array = explode('.', $jwt);
if (count($jwt_array) != 3) {
   echo "JWT形式ではありません";
}

分割したらヘッダをデコードします。証明書情報がヘッダに含まれているため、デコードして取り出します。
URL-safeなBase64 でデコードします。

$decoded_header = base64_decode(str_replace(array('_','-', '.'), array('+', '/', '='), $headb64));

結果がJson形式で取得できるので、Json形式のデータをデコードします。

$header = json_decode($decoded_header, false, 512, JSON_BIGINT_AS_STRING);

デコードできない場合も不正利用として検知します。

if (json_last_error() !== JSON_ERROR_NONE) {
   echo "Json_decodeできません";
}

ヘッダに証明書がない場合、x5cプロパティが存在しないため有無を確認します。

if (empty($header->x5c)) {
   echo "証明書チェーンがありません";
}

これにて証明書チェーンの検証のための準備が整いました。

証明書情報のパース

証明書チェーンの検証項目は多岐に渡るので、それはここでは解説しません。
今回は前節で取得したJWTのヘッダ情報からphpで扱えるデータ形式まで変換して、 簡易な検証を行うところまでを紹介します。

前節で取得した証明書情報は配列で格納されています。
わかりやすいように変数を初期化しましょう。

$chain_array = $header->x5c;

$chain_arrayの中身はDER形式の証明書をBase64でエンコードされたものが格納されています。
この証明書を中身を解析して検証していけば良いわけです。
しかし、PHPのopenssl関数はPEM形式しかサポートしていないためPEM形式に変換してあげる必要があります。
PEM形式に変換するには以下のようにヘッダ、フッタをつけるだけで良いです。
($chain_arrayの中身を$chain_array_itemと定義しています。)

$pem_from_x5c_cert = 
   (
        "-----BEGIN CERTIFICATE-----\n" .
          chunk_split($chain_array_item, 64, "\n") .
          "-----END CERTIFICATE-----\n"
     );

上記のようにPEM形式に変化することでopenssl_x509_read関数でリソースIDを取得できます。
また、そこから証明書情報を配列に格納するためにopenssl_x509_parse関数をかませます。

$chain_cert = openssl_x509_parse(openssl_x509_read($pem_from_x5c_cert));

これにてphpでサーバ証明書が検証可能な状態になりました。

証明書情報の検証

ここでは証明書情報の簡単な検証方法を紹介します。
まずは、サーバ証明書の検証を行います。
証明書チェーンの最下位がサーバ証明書です。
基本的に、サーバ証明書のSubjectフィールドのCNがホスト名を表します。
例えばSafetyNet Attestation API場合は、以下のようにSubjectフィールドが正しいかを検証します

if ($chain_cert['subject']['CN'] !== "attest.android.com") {
   echo "SafetyNet Attestation APIからの証明書ではありません";
}

次に署名の整合性の検証方法を紹介します。
ここでは下位証明書の本文と署名フィールドの組み合わせを、上位証明書の公開鍵で検証します。
php >= 7.4 ではopenssl_x509_verifyという関数が実装されているため、
証明書チェーンの署名の検証が可能になっています。
※ $x509は下位証明書です。

$result = openssl_x509_verify($x509, openssl_get_publickkey($chain_cert));

openssl_x509_verify関数は署名が正しければ 1 を。そうでなければ 0 を返し、 エラーが発生した場合は -1 を返すので、
それを判定すれば署名の整合性は検証できます。

おわりに

いかがでしたでしょうか?
今回はphpでのSafetyNet Attestation APIの検証方法を簡単に紹介しました。
ここで紹介した検証内容はごく一部のものですが、検証の流れが理解できるようでしたら幸いです。