收據(jù) (Receipts) 是在 OS X 10.6.6 更新后,和 Mac App Store 一起出現(xiàn)的。 iOS 在內(nèi)購的時(shí)候總是需要向服務(wù)器提供收據(jù),而在 iOS 7 之后, iOS 和 OS X 的收據(jù)格式開始統(tǒng)一。
一個(gè)收據(jù)意味著一次可信任的購買記錄,每次應(yīng)用內(nèi)的購買都會(huì)得到一個(gè)收據(jù),就像是去超市購物后會(huì)拿到一張紙質(zhì)的收據(jù)一樣。
關(guān)于收據(jù),有以下幾個(gè)關(guān)鍵的概念:
每次的安裝和更新操作都會(huì)發(fā)放新的收據(jù)。
更新應(yīng)用之后,將會(huì)發(fā)放與最新版本相匹配的收據(jù)。
驗(yàn)證收據(jù)是保障收入的重要途徑,同時(shí)也可以加強(qiáng)應(yīng)用的業(yè)務(wù)模式。
你可能會(huì)感覺到很困惑,為什么蘋果不提供簡單點(diǎn)的 API 來驗(yàn)證收據(jù)。為了便于理解這個(gè)問題,我們不妨假設(shè)存在這樣一個(gè)方法 (比如:[[NSBundle mainBundle] validateReceipt]
) 。黑客可以輕易地搜索到這個(gè)方法,并且通過代碼跳過驗(yàn)證。要是所有開發(fā)者都用同樣的驗(yàn)證方法,那將會(huì)很容易受到攻擊。
所以蘋果并沒有這樣做,而是選擇了使用標(biāo)準(zhǔn)加密和編碼技術(shù)來解決這個(gè)問題,并且在官方文檔和 WWDC 視頻里提供了一些幫助,從而使開發(fā)者可以實(shí)現(xiàn)自己獨(dú)有的的收據(jù)驗(yàn)證代碼。但是,整個(gè)流程并不簡單,并且需要很好的密碼學(xué)基礎(chǔ)和各種信息安全方面的技術(shù)。
當(dāng)然,有一些已經(jīng)實(shí)現(xiàn)好的現(xiàn)成代碼 (比如在 Github 上) ,但是它們只是參考實(shí)現(xiàn),并且如果大家都用同樣的代碼,還是會(huì)有前面的問題:黑客將會(huì)很容易攻擊驗(yàn)證部分的代碼。所以,開發(fā)一套既獨(dú)特又安全從而能夠抵御普通攻擊的安全方案是十分重要的。
補(bǔ)充說明一下,我是 Receigen 這個(gè) Mac 應(yīng)用的作者, Receigen 可以生成安全且變化的收據(jù)驗(yàn)證代碼。在這篇文章里,我們將會(huì)學(xué)習(xí)收據(jù)驗(yàn)證的技巧和最佳實(shí)踐方案。
讓我們從技術(shù)的角度來看一下收據(jù)文件。文件結(jié)構(gòu)大概是這樣的:
http://wiki.jikexueyuan.com/project/objc/images/17-5.png" alt="" />
收據(jù)文件由一個(gè)經(jīng)過簽名的 PKCS #7 容器組成,這個(gè)容器內(nèi)嵌 DER 編碼的 ASN.1 負(fù)載區(qū) (payload),一個(gè)證書鏈,和一個(gè)數(shù)字簽名。
收據(jù)的容器是一個(gè) PKC #7 信封,它由蘋果通過一個(gè)專門的證書進(jìn)行簽名。容器的簽名保證了負(fù)載區(qū)的可靠性和完整性。
驗(yàn)證這個(gè)簽名需要有以下兩個(gè)步驟:
ASN.1 負(fù)載區(qū)的結(jié)構(gòu)如下:
ReceiptModule DEFINITIONS ::=
BEGIN
ReceiptAttribute ::= SEQUENCE {
type INTEGER,
version INTEGER,
value OCTET STRING
}
Payload ::= SET OF ReceiptAttribute
END
收據(jù)的屬性由以下三個(gè)字段組成:
負(fù)載區(qū)使用 DER (分布式編碼規(guī)則) 進(jìn)行編碼,這種編碼方式可以生成準(zhǔn)確且壓縮的 ASN.1 結(jié)構(gòu)。 DER 使用一種叫做 TLV 的格式,每個(gè)類型的標(biāo)簽都有字節(jié)常量。
為了更好地說明這個(gè)概念,接下來我們舉一些在收據(jù)中使用 DER 編碼的例子。下面的圖表展示了一個(gè)收據(jù)模塊是如何被編碼的:
http://wiki.jikexueyuan.com/project/objc/images/17-6.png" alt="" />
接下來的一張圖展示了收據(jù)的屬性是如何進(jìn)行編碼的:
第二個(gè)字節(jié)對序列內(nèi)容的長度進(jìn)行編碼。
http://wiki.jikexueyuan.com/project/objc/images/17-7.png" alt="" />
通過使用 ASN.1 OCTET-STRING 來存儲(chǔ)屬性值,我們很容易嵌入各種各樣的值,比如 UTF-8 、 ASCII 或者數(shù)字。在內(nèi)購中,屬性值也可以包含收據(jù)模塊。下面的是一些圖例:
http://wiki.jikexueyuan.com/project/objc/images/17-8.png" alt="" />
http://wiki.jikexueyuan.com/project/objc/images/17-9.png" alt="" />
http://wiki.jikexueyuan.com/project/objc/images/17-10.png" alt="" />
http://wiki.jikexueyuan.com/project/objc/images/17-11.png" alt="" />
驗(yàn)證收據(jù)的步驟如下:
注釋:接下來的部分演示了如何進(jìn)行驗(yàn)證操作。代碼片段是用于演示,并不是唯一的方案。
在 OS X 和 iOS 中,收據(jù)的位置并不一樣,如下圖所示:
http://wiki.jikexueyuan.com/project/objc/images/17-12.png" alt="" />
在 OS X 中,收據(jù)文件在應(yīng)用程序的 bundle 里,路徑是 Contents/_MASReceipt 。而在 iOS 里,收據(jù)文件在應(yīng)用的數(shù)據(jù)沙盒中,在 StoreKit 文件夾下。
定位時(shí)必須確保收據(jù)存在:如果收據(jù)在正確的目錄下,那么可以正常加載;如果不存在收據(jù),那么就會(huì)驗(yàn)證失敗。
在 OS X 10.7 和 iOS 7 之后,代碼是這樣的:
// OS X 10.7 或之后 / iOS 7 或之后
NSBundle *mainBundle = [NSBundle mainBundle];
NSURL *receiptURL = [mainBundle appStoreReceiptURL];
NSError *receiptError;
BOOL isPresent = [receiptURL checkResourceIsReachableAndReturnError:&receiptError];
if (!isPresent) {
// 驗(yàn)證失敗
}
但是如果在 OS X 10.6 里, appStoreReceiptURL
這個(gè) selector 是不存在的,你需要手動(dòng)構(gòu)建收據(jù)路徑:
// OS X 10.6
NSBundle *mainBundle = [NSBundle mainBundle];
NSURL *bundleURL = [mainBundle bundleURL];
NSURL *receiptURL = [bundleURL URLByAppendingPathComponent:@"Contents/_MASReceipt/receipt"];
NSError *receiptError;
BOOL isPresent = [receiptURL checkResourceIsReachableAndReturnError:&receiptError];
if (!isPresent) {
// 驗(yàn)證失敗
}
加載收據(jù)很簡單,下面是通過 OpenSSL 加載并解析 PKCS #7 包的方法:
// 加載收據(jù)文件
NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
// 創(chuàng)建內(nèi)存緩沖,以提取 PKCS #7 容器
BIO *receiptBIO = BIO_new(BIO_s_mem());
BIO_write(receiptBIO, [receiptData bytes], (int) [receiptData length]);
PKCS7 *receiptPKCS7 = d2i_PKCS7_bio(receiptBIO, NULL);
if (!receiptPKCS7) {
// 驗(yàn)證失敗
}
// 檢查容器是否帶有簽名
if (!PKCS7_type_is_signed(receiptPKCS7)) {
// 驗(yàn)證失敗
}
// 檢查已簽名容器是否含有實(shí)際的數(shù)據(jù)
if (!PKCS7_type_is_data(receiptPKCS7->d.sign->contents)) {
// 驗(yàn)證失敗
}
當(dāng)加載完收據(jù)之后,我們要做的第一件事情是確保它是完整的且未被篡改。下面是通過 OpenSSL 驗(yàn)證 PKCS #7 簽名的方法:
// 加載 Apple 根證書 (從 https://www.apple.com/certificateauthority/ 下載)
NSURL *appleRootURL = [[NSBundle mainBundle] URLForResource:@"AppleIncRootCertificate" withExtension:@"cer"];
NSData *appleRootData = [NSData dataWithContentsOfURL:appleRootURL];
BIO *appleRootBIO = BIO_new(BIO_s_mem());
BIO_write(appleRootBIO, (const void *) [appleRootData bytes], (int) [appleRootData length]);
X509 *appleRootX509 = d2i_X509_bio(appleRootBIO, NULL);
// 創(chuàng)建證書存儲(chǔ)
X509_STORE *store = X509_STORE_new();
X509_STORE_add_cert(store, appleRootX509);
// 確認(rèn)在驗(yàn)證前加載了摘要
OpenSSL_add_all_digests();
// 檢查簽名
int result = PKCS7_verify(receiptPKCS7, NULL, store, NULL, NULL, 0);
if (result != 1) {
// 驗(yàn)證失敗
}
在驗(yàn)證完收據(jù)之后,接下來就是解析收據(jù)的負(fù)載區(qū)了。下面的例子展示了如何通過 OpenSSL 解碼 DER 編碼的 ASB.1 格式負(fù)載區(qū):
// 獲取指向 ASN.1 負(fù)載的指針
ASN1_OCTET_STRING *octets = receiptPKCS7->d.sign->contents->d.data;
const unsigned char *ptr = octets->data;
const unsigned char *end = ptr + octets->length;
const unsigned char *str_ptr;
int type = 0, str_type = 0;
int xclass = 0, str_xclass = 0;
long length = 0, str_length = 0;
// 收據(jù)信息的存儲(chǔ)
NSString *bundleIdString = nil;
NSString *bundleVersionString = nil;
NSData *bundleIdData = nil;
NSData *hashData = nil;
NSData *opaqueData = nil;
NSDate *expirationDate = nil;
// 處理 GMT 時(shí)區(qū) 的 RFC 3339 日期的日期格式器
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setDateFormat:@"yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"];
[formatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]];
// 解碼負(fù)載區(qū) (應(yīng)該得到一個(gè) SET)
ASN1_get_object(&ptr, &length, &type, &xclass, end - ptr);
if (type != V_ASN1_SET) {
// 驗(yàn)證失敗
}
while (ptr < end) {
ASN1_INTEGER *integer;
// 解析屬性序列 (應(yīng)該得到一個(gè) SEQUENCE)
ASN1_get_object(&ptr, &length, &type, &xclass, end - ptr);
if (type != V_ASN1_SEQUENCE) {
// 驗(yàn)證失敗
}
const unsigned char *seq_end = ptr + length;
long attr_type = 0;
long attr_version = 0;
// 解析屬性類型 (應(yīng)該得到一個(gè) INTEGER)
ASN1_get_object(&ptr, &length, &type, &xclass, end - ptr);
if (type != V_ASN1_INTEGER) {
// 驗(yàn)證失敗
}
integer = c2i_ASN1_INTEGER(NULL, &ptr, length);
attr_type = ASN1_INTEGER_get(integer);
ASN1_INTEGER_free(integer);
// 解析屬性版本 (應(yīng)該得到一個(gè) INTEGER)
ASN1_get_object(&ptr, &length, &type, &xclass, end - ptr);
if (type != V_ASN1_INTEGER) {
// 驗(yàn)證失敗
}
integer = c2i_ASN1_INTEGER(NULL, &ptr, length);
attr_version = ASN1_INTEGER_get(integer);
ASN1_INTEGER_free(integer);
// 解析屬性的值 (應(yīng)該得到一個(gè) OCTET STRING)
ASN1_get_object(&ptr, &length, &type, &xclass, end - ptr);
if (type != V_ASN1_OCTET_STRING) {
// 驗(yàn)證失敗
}
switch (attr_type) {
case 2:
// Bundle id
str_ptr = ptr;
ASN1_get_object(&str_ptr, &str_length, &str_type, &str_xclass, seq_end - str_ptr);
if (str_type == V_ASN1_UTF8STRING) {
// 我們同時(shí)存儲(chǔ)了解碼后的字符串和原始數(shù)據(jù)以備后用
// 原始數(shù)據(jù)將用在計(jì)算 GUID 哈希值上
bundleIdString = [[NSString alloc] initWithBytes:str_ptr length:str_length encoding:NSUTF8StringEncoding];
bundleIdData = [[NSData alloc] initWithBytes:(const void *)ptr length:length];
}
break;
case 3:
// Bundle 版本
str_ptr = ptr;
ASN1_get_object(&str_ptr, &str_length, &str_type, &str_xclass, seq_end - str_ptr);
if (str_type == V_ASN1_UTF8STRING) {
// 我們存儲(chǔ)了解碼后的字符串以備后用
bundleVersionString = [[NSString alloc] initWithBytes:str_ptr length:str_length encoding:NSUTF8StringEncoding];
}
break;
case 4:
// 非透明值
opaqueData = [[NSData alloc] initWithBytes:(const void *)ptr length:length];
break;
case 5:
// 計(jì)算得到的 GUID (SHA-1 哈希)
hashData = [[NSData alloc] initWithBytes:(const void *)ptr length:length];
break;
case 21:
// 過期日期
str_ptr = ptr;
ASN1_get_object(&str_ptr, &str_length, &str_type, &str_xclass, seq_end - str_ptr);
if (str_type == V_ASN1_IA5STRING) {
// 日期被存儲(chǔ)為一個(gè)需要被解析的字符串
NSString *dateString = [[NSString alloc] initWithBytes:str_ptr length:str_length encoding:NSASCIIStringEncoding];
expirationDate = [formatter dateFromString:dateString];
}
break;
// 你可以解析更多其他屬性...
default:
break;
}
// 移動(dòng)到下一個(gè)值
ptr += length;
}
// 確保所有值都存在
if (bundleIdString == nil ||
bundleVersionString == nil ||
opaqueData == nil ||
hashData == nil) {
// 驗(yàn)證失敗
}
收據(jù)包含了 bundle id 和版本號,你需要確保這兩個(gè)數(shù)據(jù)和應(yīng)用里的完全一致:
// 檢查 bundle id
if (![bundleIdString isEqualTo:@"io.objc.myapplication"]) {
// 驗(yàn)證失敗
}
// 檢查 bundle 版本
if (![bundleVersionString isEqualTo:@"1.0"]) {
// 驗(yàn)證失敗
}
十分重要:在發(fā)放收據(jù)的時(shí)候,bundle 的版本號是從 Info.plist 文件獲取的:
CFBundleShortVersionString
的值。CFBundleVersion
值。在設(shè)置這些值的時(shí)候千萬要小心,因?yàn)榉职l(fā)收據(jù)的時(shí)候會(huì)用到。
在分發(fā)收據(jù)的時(shí)候,會(huì)用到以下三個(gè)值來生成 SHA-1 哈希值:設(shè)備的 GUID (只能在設(shè)備上獲取) ,一個(gè)非透明值 (type 4),還有 bundle id (type 2)。 SHA-1 哈希值是基于這三個(gè)值計(jì)算出來的,并且存儲(chǔ)在收據(jù)中 (type 5)。
在驗(yàn)證的時(shí)候?qū)?huì)采用相同的計(jì)算方案,如果計(jì)算的哈希值一致,那么這個(gè)收據(jù)是有效的,下圖描述了整個(gè)計(jì)算的流程:
http://wiki.jikexueyuan.com/project/objc/images/17-13.png" alt="" />
為了計(jì)算這個(gè)哈希值,你需要獲取設(shè)備的 GUID。
在 OS X 里,設(shè)備的 GUID 是私有網(wǎng)卡的 Mac 地址。獲取的方法之一是使用 IOKit 框架:
#import <IOKit/IOKitLib.h>
// 打開一個(gè) MACH 端口
mach_port_t master_port;
kern_return_t kernResult = IOMasterPort(MACH_PORT_NULL, &master_port);
if (kernResult != KERN_SUCCESS) {
// 驗(yàn)證失敗
}
// 為主接口創(chuàng)建搜索
CFMutableDictionaryRef matching_dict = IOBSDNameMatching(master_port, 0, "en0");
if (!matching_dict) {
// 驗(yàn)證失敗
}
// 進(jìn)行搜索
io_iterator_t iterator;
kernResult = IOServiceGetMatchingServices(master_port, matching_dict, &iterator);
if (kernResult != KERN_SUCCESS) {
// 驗(yàn)證失敗
}
// 迭代結(jié)果
CFDataRef guid_cf_data = nil;
io_object_t service, parent_service;
while((service = IOIteratorNext(iterator)) != 0) {
kernResult = IORegistryEntryGetParentEntry(service, kIOServicePlane, &parent_service);
if (kernResult == KERN_SUCCESS) {
// 存儲(chǔ)結(jié)果
if (guid_cf_data) CFRelease(guid_cf_data);
guid_cf_data = (CFDataRef) IORegistryEntryCreateCFProperty(parent_service, CFSTR("IOMACAddress"), NULL, 0);
IOObjectRelease(parent_service);
}
IOObjectRelease(service);
if (guid_cf_data) {
break;
}
}
IOObjectRelease(iterator);
NSData *guidData = [NSData dataWithData:(__bridge NSData *) guid_cf_data];
在 iOS 里,設(shè)備的 GUID 是一個(gè)獨(dú)一無二的純字母字符串,和應(yīng)用的開發(fā)者相關(guān):
UIDevice *device = [UIDevice currentDevice];
NSUUID *uuid = [device identifierForVendor];
uuid_t uuid;
[identifier getUUIDBytes:uuid];
NSData *guidData = [NSData dataWithBytes:(const void *)uuid length:16];
現(xiàn)在我們已經(jīng)獲取了設(shè)備的 GUID 值,接下來就可以計(jì)算哈希值了。計(jì)算哈希值需要用到 ASN.1 屬性的原始值 (比如 OCTET-STRING 的二進(jìn)制數(shù)據(jù)),而不是處理后的值。下面是一個(gè) SHA-1 哈希值的計(jì)算過程和一個(gè)與 OpenSSL 的對比:
unsigned char hash[20];
// 為計(jì)算創(chuàng)建哈希上下文
SHA_CTX ctx;
SHA1_Init(&ctx);
SHA1_Update(&ctx, [guidData bytes], (size_t) [guidData length]);
SHA1_Update(&ctx, [opaqueData bytes], (size_t) [opaqueData length]);
SHA1_Update(&ctx, [bundleIdData bytes], (size_t) [bundleIdData length]);
SHA1_Final(hash, &ctx);
// 進(jìn)行比較
NSData *computedHashData = [NSData dataWithBytes:hash length:20];
if (![computedHashData isEqualToData:hashData]) {
// 驗(yàn)證失敗
}
如果應(yīng)用支持批量購買,那么還需要驗(yàn)證另一個(gè)東西:收據(jù)的失效日期。我們可以在 type 21 屬性里找到這個(gè)日期:
// 如果存在過期日期,則檢查之
if (expirationDate) {
NSDate *currentDate = [NSDate date];
if ([expirationDate compare:currentDate] == NSOrderedAscending) {
// 驗(yàn)證失敗
}
}
到目前為止,如果所有的校驗(yàn)全部通過,那么驗(yàn)證的流程就算是通過了。如果任何一個(gè)步驟驗(yàn)證失敗,那么收據(jù)就是無效的。在完成驗(yàn)證之后,根據(jù)平臺(tái)和時(shí)間的不同,有很多種方法去處理無效的收據(jù):
在 OS X 里,收據(jù)的驗(yàn)證過程必須在應(yīng)用剛開始運(yùn)行的時(shí)候完成,也就是說要在 main 方法前面。如果收據(jù)無效 (沒有收據(jù),收據(jù)不正確,收據(jù)被篡改) ,那么應(yīng)用必須退出并返回 173 錯(cuò)誤碼。這個(gè)特殊的值告訴系統(tǒng)這個(gè)應(yīng)用需要獲取收據(jù)。當(dāng)收到了新的收據(jù)的時(shí)候,這個(gè)應(yīng)用將會(huì)重新運(yùn)行。
在收到應(yīng)用退出并返回代碼 173 的時(shí)候, App Store 將會(huì)彈框提示登錄,需要聯(lián)網(wǎng)才能重新獲取到收據(jù)。
你也可以在應(yīng)用的生命周期里進(jìn)行收據(jù)驗(yàn)證,你可以自己決定如何處理無效收據(jù):忽視,禁用功能,或是直接來個(gè)閃退。
在 iOS 里,收據(jù)驗(yàn)證可以在任何時(shí)候進(jìn)行。如果沒找到收據(jù),你可以出發(fā)一個(gè)刷新收據(jù)的請求,告訴系統(tǒng)你的應(yīng)用需要獲取新的收據(jù)。收到了這個(gè)請求之后, App Store 會(huì)彈窗提示登錄,在聯(lián)網(wǎng)狀態(tài)下會(huì)請求并獲取到新的收據(jù)。
你可以決定如何處置無效收據(jù):忽視或是禁用。
在測試的時(shí)候,主要的障礙是如何獲取沙盒中的測試收據(jù)。
蘋果通過應(yīng)用的證書區(qū)分生產(chǎn)環(huán)境和沙盒環(huán)境:
使用有效的開發(fā)者證書是非常重要的,要不然 storeagent 后臺(tái)程序 (負(fù)責(zé)和 App Store 交互)無法確認(rèn)你的應(yīng)用是 App Store 的應(yīng)用。
為了模擬沙盒環(huán)境下的真實(shí)用戶,你需要定義測試用戶。測試用戶的操作和真實(shí)用戶完全一樣,唯一的區(qū)別就是買東西不用掏錢。
測試用戶可以通過 iTunes Connect 創(chuàng)建和設(shè)置。你可以定義任意數(shù)量的測試用戶,每個(gè)測試用戶都需要一個(gè)有效的郵箱地址,并且不能是真實(shí)的 iTunse 賬號。如果郵箱供應(yīng)商支持 + 號,那么你可以用郵箱別名作為測試賬號:foo+us@objc.io 、 foo+uk@objc.io 和 foo+fr@objc.io 都會(huì)發(fā)送到 foo@objc.io 這個(gè)郵箱里。
為了測試 OS X 上的收據(jù)驗(yàn)證,我們需要以下步驟:
當(dāng)收據(jù)重新獲取之后,你可以在 Xcode 里運(yùn)行你的應(yīng)用,進(jìn)行錯(cuò)誤排查或者驗(yàn)證收據(jù)的代碼的微調(diào)工作。
為了測試 iOS 上的收據(jù)驗(yàn)證,需要以下步驟:
當(dāng)收據(jù)重新獲取之后,你可以在 Xcode 里運(yùn)行你的應(yīng)用,進(jìn)行錯(cuò)誤排查或者驗(yàn)證收據(jù)的代碼的微調(diào)工作。
驗(yàn)證收據(jù)的代碼部分必須在安全方面高度敏感。如果被避開或者攻擊,你就失去了核實(shí)用戶權(quán)限的能力,并且無法驗(yàn)證用戶是否購買。因?yàn)椋岒?yàn)證收據(jù)的代碼能夠承受黑客的攻擊變得至關(guān)重要。
注意:攻擊應(yīng)用的方式多種多樣,所以不要嘗試完全抵御所有攻擊。原則很簡單:盡一切可能提高攻擊應(yīng)用的成本。
所有的攻擊都是從分析目標(biāo)開始的:
靜態(tài)分析 是針對應(yīng)用的二進(jìn)制文件,常見的工具有:strings、otool、disassembler 等等。
分析結(jié)束之后則會(huì)進(jìn)行一些常見的攻擊來繞開或者破解你的收據(jù)驗(yàn)證代碼:
en0
,_MASReceipt
,bundle id,bundle 版本號),黑客就有機(jī)會(huì)用自己的字符串替換原始字符串。當(dāng)進(jìn)行收據(jù)驗(yàn)證的時(shí)候,需要在心中牢記以下幾點(diǎn)安全準(zhǔn)則: