iOSで課金のレシートをローカルで判定する方法

# はじめに

最近 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)](https://qiita.com/fmtonakai/items/6d13c8e535c5d8904c60)
• [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](https://www.andrewcbancroft.com/2017/08/01/local-receipt-validation-swift-start-finish/)
• [Validating in-app purchases in your iOS app](https://www.futurice.com/blog/validating-in-app-purchases-in-your-ios-app/)
• [レシートのverifyとSandbox](https://nantekottai.com/2012/08/27/verifying-receipts/)
• [自動購読課金について【iOS編】](https://ameblo.jp/principia-ca/entry-12071724382.html)

いろいろありますが、ほとんどがサーバーでのレシート確認方法です。ローカルで検証する方法は本家のプログラミングガイドでもさらっと書かれているのみで、正直これだけ見て実装するのは骨が折れそうです。

今回の記事はレシートをローカルで検証する知見を紹介したいと思います。サーバーを介して検証する方法に関しての記述はしないつもりですし、うるさく言われているセキュリティの脆弱化については一部許容する方針とします。また、StoreKit の使い方やチップスについては触れません。

# レシートの検証

では、レシートの検証

## 全体の流れ

1. Appleのルート証明書(.cer)を取得する
2. アプリがレシートを取得する
3. アプリのレシートが Apple によって署名されているか確認する
4. レシートから購入情報の列を取得する
5. 購入情報の有効期限が切れていないか、キャンセルされていないか確認する
6. 有効な購入情報があった場合、当該機能またはコンテンツをアンロックする

### Appleのルート証明書(.cer)を取得する

以下のサイトから apple のルート証明書をダウンロードします。
https://www.apple.com/certificateauthority/

と言われてもどれをダウンロードしていいかわからないので、これを使いました。
Apple Inc. Root Certificate
https://www.apple.com/appleca/AppleIncRootCertificate.cer

これをアプリに組み込みますが、リソースとして読み込む場合は、さすがにジェイルブレークなどでリソースを入れ替えた偽物をつかまされないように、ハッシュ値を確認しましょう。

```console
$ shasum -a 256 AppleIncRootCertificate.cer 
b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024  AppleIncRootCertificate.cer
```

これを base64 でエンコードすると `sLFzDsvH/0UFFCxJ8Slebtpryu1+LGjFvpG1oRAB8CQ=` なので、こんな感じでAppleのルート証明書のバイナリを用意します。こんなバレバレの名前だとコードをクラックされるかもしれませんが、そこは横に置いておく事とします。

```.swift
	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
https://github.com/filom/ASN1Decoder

`How to use for AppStore receipt parse` と説明もありますが、レシートがAppleによって署名されているかどうかは書かれていません。

```.swift
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 がレシート一致するかなどを調べます。

```.swift
	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` は購入情報のデコードもしてくれるので、以下のように購入情報を一つ一つ有効期限やキャンセルの有無を確認します。

```.swift
	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...

> 5) 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
```