こんにちは、エンジニアの id:kaoru-k_0106 です。
駅奪取のサブスク機能である「駅奪取er定期券」は、App Storeのサーバ通知の実装の際に App Store Server Notification V2 を用いました。
他の言語での Server Notification V2 の実装例は見つかりますが、Perl のものはありませんでした。
そこで、今回は Perl での検証部分の実装方法について触れようと思います。
App Store Server Notification V2 について
V1 のときは、通常の App 内課金と同じように、サーバ通知で送られてきたレシートを、App Store サーバの verifyReceipt エンドポイントに送信して検証する必要がありました。
参考: App Storeを使用してレシートを検証する - 日本語ドキュメント - Apple Developer
ですが、V2 では署名されたレシートが送られてくるようになったため、App Store サーバに問い合わせる必要がなくなり、アプリケーションサーバで検証処理が完結します。
また、通知タイプが追加されたりと、サブスクリプションの状態判定が V1 よりも簡単にできるようになっています。
2023 年 6 月に V1 のサーバ通知は Deprecated になったので、新規で採用することはないと思いますが、V2 によって実装がしやすくなったと思います。
詳しくは Apple Developer Documentation を参照してください。
参考: App Store Server Notifications | Apple Developer Documentation
Server Notification V2 の JWS を Perl で検証する
V2 のサーバ通知は、JWS (JSON Web Signature) で送られてきます。
この JWS の署名を検証することで、レシートの検証が完了します。
それでは、サーバ通知を受け取ってから、JWS の署名検証が完了するまでの手順を順に追っていきます。
1. JWS のヘッダから証明書を取り出す
JWS を扱うモジュールは CPAN でいくつか見つかりますが、一番更新が新しい Crypt::JWT を使うことにしました。
ペイロードの署名検証に必要な証明書は、ヘッダの x5c フィールドに含まれていますが、このモジュールは x5c フィールドに対応していません。
そのため、証明書を手動で取り出す必要がありますが、JWS のフォーマットはシンプルなので簡単にできます。
JWS は、以下のように「ヘッダ」「ペイロード」「ヘッダとペイロードの署名」が.
(ピリオド)で繋がれた形になっています。
${header}.${payload}.${signature}
これを split
すればヘッダを取り出せます。
ただし、JWS の各パートは通常の Base64 ではなく、URL Safe な Base64 (base64url) でエンコードされているので、その点は注意が必要です。
# 証明書が欲しいのでdecode_jwtする前にヘッダを取得 my ($header,,) = map { decode_base64url($_) } split(/\./, $token);
ヘッダを base64url でデコードすると JSON になっており、そのうちの x5c フィールドに以下の 3 つの証明書が含まれています。
- サーバ証明書: 署名に使ったサーバ証明書
- 中間証明書: サーバ証明書を署名した公開鍵を含む証明書
- ルート証明書: 中間証明書を署名した公開鍵を含む証明書、自己証明書
参考: JWSDecodedHeader | Apple Developer Documentation
証明書は、通常の Base64 でエンコードされた DER 形式で格納されています。
次の証明書チェーンの検証で PEM 形式の証明書が必要になるので、ここで変換しておきます。
PEM 形式は DER 形式の証明書を Base64 でエンコードしたものにヘッダとフッタをつけたものなので、簡単に変換ができます。
ただ、RFC 7468 に、最終行以外はちょうど 64 文字にする必要があると書かれていたので、仕様に準拠させるため改行を入れるようにしました。
# ヘッダのx5cから証明書を取り出してPEMにする} my @cert_chain = map { _base64encoded_der_to_pem($_) } @{ decode_json($header)->{x5c} };
sub _base64encoded_der_to_pem { # certはbase64エンコードされたDER my $cert = shift; # RFC 7468 の仕様に準拠させるため、64文字ごとに改行を入れる $cert =~ s/(.{64})/$1\n/g; # ヘッダとフッタをつければPEMになる return "-----BEGIN CERTIFICATE-----\n$cert\n-----END CERTIFICATE-----\n\n" }
2. 証明書チェーンの検証
次に、証明書チェーンが正しいものか検証します。
証明書チェーンの検証には Crypt::OpenSSL::CA を使いました。
また、検証には Apple のルート証明書が必要であるため Apple PKI から「Apple Root CA - G3 Root」をダウンロードして、サーバ内に配置しました。
X.509 証明書についての詳しい解説は割愛しますが、ルート証明書は自己証明書なので、ローカルに保存したルート証明書を用いることで検証できます。
x5c フィールドから取り出した証明書チェーンとサーバ内に配置したローカルのルート証明書を用いて以下の流れで検証を行います。
- 中間証明書でサーバ証明書を検証
- チェーン内のルート証明書で中間証明書を検証
- ローカルのルート証明書でチェーン内のルート証明書を検証
中間証明書も Apple PKI で公開されているため、中間証明書の検証までで完了とすることもできますが、期限がルート証明書より短く、管理コストがかかるため、ルート証明書で検証するのがいいと思います。
検証に失敗した場合は例外が発生するので、その場合は処理を中断させます。
コードは以下のようになります。
# ローカルの証明書を読み込んでPEMに変換する my $root_cert = Crypt::OpenSSL::X509->new_from_file('/path/to/AppleRootCA-G3.cer', Crypt::OpenSSL::X509::FORMAT_ASN1)->as_string(Crypt::OpenSSL::X509::FORMAT_PEM); my @certs = map { Crypt::OpenSSL::CA::X509->parse($_); } (@cert_chain, $root_cert); for my $i ( 0 .. ($#certs - 1) ) { ($certs[$i])->verify(($certs[$i + 1]->get_public_key)); }
3. JWS のデコード
証明書が正しいことを検証できたので、 Crypt::JWT の decode_jwt で署名を検証しつつデコードして完了です。
# 証明書チェーンの先頭の証明書で署名検証 my $decoded_payload = decode_jwt(token => $token, key => \$cert_chain[0] );
おわりに
Ruby や Go など他の言語では、OpenSSL 周りが標準機能として提供されていますが、Perl では外部モジュールを使う必要があり、少し手間がかかりました。 とはいえ、ひととおりは必要なモジュールが揃っているので、問題なく実装ができます。
また、実装に当たっては、他の言語での実装がとても参考になりました。ありがとうございました。