はじめに
最近 iOSでアプリ内課金で自動更新のサプスクリプションをの実装を行いました。その時に、Web でどうやってレシートの検証を行うかを検索しました。
• [レシート検証 プログラミングガイド(PDF)]
(https://developer.apple.com/jp/documentation/Receipt-Validation-Programming-Guide-JP.pdf)
• [レシート検証 プログラミングガイド(Web)]
(https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Introduction.html)
• In App Purchaseのレシートをローカルで検証できるようになった話(Qiita)
• [iOSの月額課金レシート検証をサーバーサイドで行うときのTipsまとめ(Qiita)]
(https://qiita.com/joooee0000/items/6cf8ba1d7ba240bef14c)
• [iOS In-App Purchase実装で必ず知っておきたい隠れた罠(Qiita)]
(https://qiita.com/you12724/items/374942dec7b3ea14721f)
• [Apple App Store Receipt Validation with Swift and Go]
(https://www.apptects.de/blog/receipt_validation/)
• Local Receipt Validation for iOS in Swift From Start to Finish
• Validating in-app purchases in your iOS app
• レシートのverifyとSandbox
• 自動購読課金について【iOS編】
いろいろありますが、ほとんどがサーバーでのレシート確認方法です。ローカルで検証する方法は本家のプログラミングガイドでもさらっと書かれているのみで、正直これだけ見て実装するのは骨が折れそうです。
今回の記事はレシートをローカルで検証する知見を紹介したいと思います。サーバーを介して検証する方法に関しての記述はしないつもりですし、うるさく言われているセキュリティの脆弱化については一部許容する方針とします。また、StoreKit の使い方やチップスについては触れません。
レシートの検証
では、レシートの検証
全体の流れ
- Appleのルート証明書(.cer)を取得する
- アプリがレシートを取得する
- アプリのレシートが Apple によって署名されているか確認する
- レシートから購入情報の列を取得する
- 購入情報の有効期限が切れていないか、キャンセルされていないか確認する
- 有効な購入情報があった場合、当該機能またはコンテンツをアンロックする
Appleのルート証明書(.cer)を取得する
以下のサイトから apple のルート証明書をダウンロードします。
https://www.apple.com/certificateauthority/
と言われてもどれをダウンロードしていいかわからないので、これを使いました。
Apple Inc. Root Certificate
https://www.apple.com/appleca/AppleIncRootCertificate.cer
これをアプリに組み込みますが、リソースとして読み込む場合は、さすがにジェイルブレークなどでリソースを入れ替えた偽物をつかまされないように、ハッシュ値を確認しましょう。
$ shasum -a 256 AppleIncRootCertificate.cer
b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024 AppleIncRootCertificate.cer
これを base64 でエンコードすると sLFzDsvH/0UFFCxJ8Slebtpryu1+LGjFvpG1oRAB8CQ=
なので、こんな感じでAppleのルート証明書のバイナリを用意します。こんなバレバレの名前だとコードをクラックされるかもしれませんが、そこは横に置いておく事とします。
var appleIncRootCertificate: Data {
if let url = Bundle.main.url(forResource: "AppleIncRootCertificate", withExtension: "cer"),
let data = try? Data(contentsOf: url) {
// make sure the certificate is not fake one
let sha256 = Data(base64Encoded: "sLFzDsvH/0UFFCxJ8Slebtpryu1+LGjFvpG1oRAB8CQ=")
if data.sha256 == sha256 {
return data
}
}
fatalError("error: failed to read the certificate.")
}
レシートのデータは、ASN.1 のフォーマットという事ですが、多くのアプリはこれを信頼の置ける外部サーバーを経由し、 Apple に投げてそのレスポンスを処理してるようですが、ここではローカルで検証したいと思います。
ここで、アップルはコードのクラックを警戒してか、自分で ASN1C
を使うか、openssl
を static にリンクしてなんとかしろというのですが、openssl
はともかく ASN1C
から始めるにはあまりにもハードルが高いので、良さげなオープンソースのコードを探します。
ASN1Decoder
How to use for AppStore receipt parse
と説明もありますが、レシートがAppleによって署名されているかどうかは書かれていません。
import ASN1Decoder
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
do {
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
let pkcs7 = try PKCS7(data: receiptData)
if let receiptInfo = pkcs7.receipt() {
print(receiptInfo.originalApplicationVersion)
}
} catch {
print(error)
}
}
次にレシートから署名を全て取り出してその公開鍵を調べ、Apple のルート証明書の公開鍵と一つでも一致するか確認します。あとは、Bundle ID がレシート一致するかなどを調べます。
let appleX509cert = try X509Certificate(data: self.appleIncRootCertificate)
guard let appleKey = appleX509cert.publicKey?.key else { fatalError("x509 public key not found.") }
// check if one of these certificates is signed by apple
let signedByApple: Bool = {
print("certificates:")
for certificate in pkcs7.certificates {
if let signedKey = certificate.publicKey?.key {
print(signedKey as NSData)
if signedKey == appleKey {
return true
}
}
}
return false
}()
guard signedByApple
else { fatalError("the receipt is not signed by apple.") }
guard let receipt = pkcs7.receipt()
else { fatalError("receipt not found") }
guard receipt.bundleIdentifier == Bundle.main.bundleIdentifier
else { fatalError("bundle identifier do not match with certificate.")
// 他必要な内容を確認
そして、ASN1Decoder
は購入情報のデコードもしてくれるので、以下のように購入情報を一つ一つ有効期限やキャンセルの有無を確認します。
if let inAppPurchases = receipt.inAppPurchases {
for purchase in inAppPurchases {
guard let productIdentifier = purchase.productId else { continue }
print("product identifier:", productIdentifier)
print("purchase date:", purchase.purchaseDate ?? "n/a")
print("original purchase date:", purchase.originalPurchaseDate ?? "n/a")
print("cancellation date:", purchase.cancellationDate ?? "n/a")
print("expires date:", purchase.expiresDate ?? "n/a")
print("current date:", now)
// 有効なレシートがないか確認
}
}
購入情報の確認方法自体は記事の対象外とさせていただきます。サーバーを介してAppleから戻ってくる JSON とは多少の違いがあるようですが、詳細は未確認です。
未解決事項
レシート検証 プログラミングガイドには、こんな記述があります。
Compute the Hash of the GUID
In macOS, use the method described in Get the GUID in macOS to fetch the computer’s GUID.
In iOS, use the value returned by the identifierForVendor property of UIDevice as the computer’s GUID.
To compute the hash, first concatenate the GUID value with the opaque value (the attribute of type 4) and the bundle identifier. Use the raw bytes from the receipt without performing any UTF-8 string interpretation or normalization. Then compute the SHA-1 hash of this concatenated series of bytes.
...snip...
- Compute the hash of the GUID as described in Compute the Hash of the GUID.
If the result does not match the hash in the receipt, validation fails.
しかし、レシート内には GUID が存在しないような気がします。ASN1Decoder
に漏れがあるのか、こちらの見落としなのか、とにかく GUID が確認できないように思えます。
環境
執筆時の環境に関しては以下の通りです。
Xcode Version 10.3 (10G8)
Apple Swift version 5.0.1 (swiftlang-1001.0.82.4 clang-1001.0.46.5)
Target: x86_64-apple-darwin18.7.0