鍍金池/ 教程/ iOS/ 收據(jù)驗(yàn)證
與四軸無人機(jī)的通訊
在沙盒中編寫腳本
結(jié)構(gòu)體和值類型
深入理解 CocoaPods
UICollectionView + UIKit 力學(xué)
NSString 與 Unicode
代碼簽名探析
測試
架構(gòu)
第二期-并發(fā)編程
Metal
自定義控件
iOS 中的行為
行為驅(qū)動(dòng)開發(fā)
Collection View 動(dòng)畫
截圖測試
MVVM 介紹
使 Mac 應(yīng)用數(shù)據(jù)腳本化
一個(gè)完整的 Core Data 應(yīng)用
插件
字符串
為 iOS 建立 Travis CI
先進(jìn)的自動(dòng)布局工具箱
動(dòng)畫
為 iOS 7 重新設(shè)計(jì) App
XPC
從 NSURLConnection 到 NSURLSession
Core Data 網(wǎng)絡(luò)應(yīng)用實(shí)例
GPU 加速下的圖像處理
自定義 Core Data 遷移
子類
與調(diào)試器共舞 - LLDB 的華爾茲
圖片格式
并發(fā)編程:API 及挑戰(zhàn)
IP,TCP 和 HTTP
動(dòng)畫解釋
響應(yīng)式 Android 應(yīng)用
初識 TextKit
客戶端
View-Layer 協(xié)作
回到 Mac
Android
Core Image 介紹
自定義 Formatters
Scene Kit
調(diào)試
項(xiàng)目介紹
Swift 的強(qiáng)大之處
測試并發(fā)程序
Android 通知中心
調(diào)試:案例學(xué)習(xí)
從 UIKit 到 AppKit
iOS 7 : 隱藏技巧和變通之道
安全
底層并發(fā) API
消息傳遞機(jī)制
更輕量的 View Controllers
用 SQLite 和 FMDB 替代 Core Data
字符串解析
終身學(xué)習(xí)的一代人
視頻
Playground 快速原型制作
Omni 內(nèi)部
同步數(shù)據(jù)
設(shè)計(jì)優(yōu)雅的移動(dòng)游戲
繪制像素到屏幕上
相機(jī)與照片
音頻 API 一覽
交互式動(dòng)畫
常見的后臺(tái)實(shí)踐
糟糕的測試
避免濫用單例
數(shù)據(jù)模型和模型對象
Core Data
字符串本地化
View Controller 轉(zhuǎn)場
照片框架
響應(yīng)式視圖
Square Register 中的擴(kuò)張
DTrace
基礎(chǔ)集合類
視頻工具箱和硬件加速
字符串渲染
讓東西變得不那么糟
游戲中的多點(diǎn)互聯(lián)
iCloud 和 Core Data
Views
虛擬音域 - 聲音設(shè)計(jì)的藝術(shù)
導(dǎo)航應(yīng)用
線程安全類的設(shè)計(jì)
置換測試: Mock, Stub 和其他
Build 工具
KVC 和 KVO
Core Image 和視頻
Android Intents
在 iOS 上捕獲視頻
四軸無人機(jī)項(xiàng)目
Mach-O 可執(zhí)行文件
UI 測試
值對象
活動(dòng)追蹤
依賴注入
Swift
項(xiàng)目管理
整潔的 Table View 代碼
Swift 方法的多面性
為什么今天安全仍然重要
Core Data 概述
Foundation
Swift 的函數(shù)式 API
iOS 7 的多任務(wù)
自定義 Collection View 布局
測試 View Controllers
訪談
收據(jù)驗(yàn)證
數(shù)據(jù)同步
自定義 ViewController 容器轉(zhuǎn)場
游戲
調(diào)試核對清單
View Controller 容器
學(xué)無止境
XCTest 測試實(shí)戰(zhàn)
iOS 7
Layer 中自定義屬性的動(dòng)畫
第一期-更輕量的 View Controllers
精通 iCloud 文檔存儲(chǔ)
代碼審查的藝術(shù):Dropbox 的故事
GPU 加速下的圖像視覺
Artsy
照片擴(kuò)展
理解 Scroll Views
使用 VIPER 構(gòu)建 iOS 應(yīng)用
Android 中的 SQLite 數(shù)據(jù)庫支持
Fetch 請求
導(dǎo)入大數(shù)據(jù)集
iOS 開發(fā)者的 Android 第一課
iOS 上的相機(jī)捕捉
語言標(biāo)簽
同步案例學(xué)習(xí)
依賴注入和注解,為什么 Java 比你想象的要好
編譯器
基于 OpenCV 的人臉識別
玩轉(zhuǎn)字符串
相機(jī)工作原理
Build 過程

收據(jù)驗(yàn)證

關(guān)于收據(jù)

收據(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)鍵的概念:

  • 收據(jù)是蘋果公司通過 App Store 創(chuàng)建和簽名的。
  • 收據(jù)是針對某個(gè)指定版本的應(yīng)用和某個(gè)指定的設(shè)備發(fā)放的。
  • 收據(jù)存儲(chǔ)在本地設(shè)備中。
  • 每次的安裝和更新操作都會(huì)發(fā)放新的收據(jù)。

  • 安裝應(yīng)用之后,將會(huì)發(fā)放與應(yīng)用和設(shè)備匹配的收據(jù)。
  • 更新應(yīng)用之后,將會(huì)發(fā)放與最新版本相匹配的收據(jù)。

  • 下列的任何一項(xiàng)交易都會(huì)發(fā)放收據(jù):
    • 當(dāng)你通過內(nèi)購付款購買的時(shí)候,將會(huì)發(fā)放收據(jù)用來驗(yàn)證購買記錄。
    • 當(dāng)你恢復(fù)以前的交易記錄時(shí),將會(huì)發(fā)放用來驗(yàn)證購買記錄的收據(jù)。

關(guān)于驗(yàn)證

驗(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í)踐方案。

收據(jù)解析

讓我們從技術(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ù)字簽名。

  • 負(fù)載區(qū) 是包含著收據(jù)信息的一組屬性,每個(gè)屬性都包含了一個(gè)類型、一個(gè)版本號和一個(gè)值。在屬性的值里,你可以找到這個(gè)收據(jù)所對應(yīng)的 bundle id 和版本號。
  • 證書鏈 是一組用來驗(yàn)證簽名摘要的證書 - 其中的葉證書 (leaf certificate) 是用來對負(fù)載區(qū)進(jìn)行簽名的。
  • 數(shù)字簽名 是對負(fù)載區(qū)加密編碼后的摘要。通過驗(yàn)證這個(gè)摘要,你可以驗(yàn)證負(fù)載區(qū)是否被篡改過。

容器

收據(jù)的容器是一個(gè) PKC #7 信封,它由蘋果通過一個(gè)專門的證書進(jìn)行簽名。容器的簽名保證了負(fù)載區(qū)的可靠性和完整性。

驗(yàn)證這個(gè)簽名需要有以下兩個(gè)步驟:

  • 證書鏈需要通過蘋果證書授權(quán)根證書 (Apple Certificate Authority Root certificate) 的驗(yàn)證 - 用來檢查可靠性
  • 通過證書鏈計(jì)算出一個(gè)簽名,并和容器簽名對比 - 用來檢查完整性

負(fù)載區(qū)

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è)字段組成:

  • 類型域 通過類型區(qū)分各個(gè)屬性。蘋果公布了公開屬性列表,用來從收據(jù)中取出信息。在分析收據(jù)的時(shí)候你有可能也會(huì)遇到不在這個(gè)列表中的屬性,最好的應(yīng)對方案就是忽略它們 (很有可能是蘋果為以后預(yù)留的屬性類型)。
  • 版本域 現(xiàn)在還沒有使用。
  • 值域 包含了一組字節(jié)數(shù)據(jù) (雖然從名字來看是字符串,但其實(shí)并不是)。

負(fù)載區(qū)使用 DER (分布式編碼規(guī)則) 進(jìn)行編碼,這種編碼方式可以生成準(zhǔn)確且壓縮的 ASN.1 結(jié)構(gòu)。 DER 使用一種叫做 TLV 的格式,每個(gè)類型的標(biāo)簽都有字節(jié)常量。

為了更好地說明這個(gè)概念,接下來我們舉一些在收據(jù)中使用 DER 編碼的例子。下面的圖表展示了一個(gè)收據(jù)模塊是如何被編碼的:

  • 第一個(gè)字節(jié)用來標(biāo)記這個(gè) ASN.1 集合。
  • 接下來的三個(gè)字節(jié)對集合內(nèi)容的長度進(jìn)行編碼。
  • 集合的內(nèi)容就是收據(jù)的屬性。

http://wiki.jikexueyuan.com/project/objc/images/17-6.png" alt="" />

接下來的一張圖展示了收據(jù)的屬性是如何進(jìn)行編碼的:

  • 第一個(gè)字節(jié)標(biāo)記這個(gè) ASN.1 序列。
  • 第二個(gè)字節(jié)對序列內(nèi)容的長度進(jìn)行編碼。

  • 序列的內(nèi)容如下:
    • 屬性的類型被編碼成了一個(gè) ASN.1 INTEGER (第一個(gè)字節(jié)用來標(biāo)記一個(gè) ASN.1 INTEGER 類型,第二個(gè)字節(jié)編碼長度,第三個(gè)字節(jié)存放值) 。
    • 屬性的版本也被編碼成了一個(gè) ASN.1 INTEGER (第一個(gè)字節(jié)用來標(biāo)記一個(gè) ASN.1 INTEGER 類型,第二個(gè)字節(jié)編碼長度,第三個(gè)字節(jié)存放值) 。
    • 屬性的值被編碼成了一個(gè) ASN.1 OCTET-STRING (第一個(gè)字節(jié)用來標(biāo)記一個(gè) ASN.1 OCTET-STRING 類型,第二個(gè)字節(jié)編碼長度,剩下來的字節(jié)用來保存數(shù)據(jù)) 。

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ù)

驗(yàn)證收據(jù)的步驟如下:

  • 定位收據(jù)。如果沒有找到收據(jù)。驗(yàn)證失敗。
  • 驗(yàn)證收據(jù)的可靠性和完整性,收據(jù)必須是通過蘋果簽名認(rèn)證且未被篡改。
  • 解析收據(jù),獲取相關(guān)屬性比如 bundle id, bundle 版本等等。
  • 驗(yàn)證收據(jù)中 bundle id 和版本號是否和應(yīng)用中的相匹配。
  • 計(jì)算設(shè)備 GUID 的哈希值,不同的設(shè)備會(huì)有不同的計(jì)算結(jié)果。
  • 如果是大量采購的 (Volume Purchase Program) ,則需要驗(yàn)證收據(jù)的截止日期。

注釋:接下來的部分演示了如何進(jìn)行驗(yàn)證操作。代碼片段是用于演示,并不是唯一的方案。

定位收據(jù)

在 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ù)

加載收據(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)證失敗
}

驗(yàn)證收據(jù)簽名

當(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)證失敗
}

解析收據(jù)

在驗(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)證失敗
}

驗(yàn)證收據(jù)信息

收據(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 文件獲取的:

  • 在 OS X 里,版本號來自 CFBundleShortVersionString 的值。
  • 在 iOS 里,版本號來自 CFBundleVersion 值。

在設(shè)置這些值的時(shí)候千萬要小心,因?yàn)榉职l(fā)收據(jù)的時(shí)候會(huì)用到。

計(jì)算 GUID 哈希值

在分發(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。

設(shè)備的 GUID (OS X)

在 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];

設(shè)備的 GUID (iOS)

在 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];

哈希計(jì)算

現(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)證結(jié)果

到目前為止,如果所有的校驗(yàn)全部通過,那么驗(yàn)證的流程就算是通過了。如果任何一個(gè)步驟驗(yàn)證失敗,那么收據(jù)就是無效的。在完成驗(yàn)證之后,根據(jù)平臺(tái)和時(shí)間的不同,有很多種方法去處理無效的收據(jù):

OS X 中的處理方式

在 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 中的處理方式

在 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)境:

  • 如果應(yīng)用是開發(fā)者證書簽名的,那么收據(jù)請求會(huì)定向到沙盒環(huán)境中。
  • 如果應(yīng)用是蘋果證書簽名的,那么收據(jù)請求會(huì)定向到生產(chǎn)環(huán)境中。

使用有效的開發(fā)者證書是非常重要的,要不然 storeagent 后臺(tái)程序 (負(fù)責(zé)和 App Store 交互)無法確認(rèn)你的應(yīng)用是 App Store 的應(yīng)用。

設(shè)置測試用戶

為了模擬沙盒環(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 上的測試

為了測試 OS X 上的收據(jù)驗(yàn)證,我們需要以下步驟:

  • 在 Finder 中啟動(dòng)應(yīng)用,不要在 Xcode 里啟動(dòng),要不然 launchd 后臺(tái)進(jìn)程無法觸發(fā)和進(jìn)行收據(jù)獲取。
  • 如果沒有收據(jù),應(yīng)該返回 173 的錯(cuò)誤碼。這樣會(huì)觸發(fā)一個(gè)新的收據(jù)請求,會(huì)彈出 App Store 的登陸框,輸入測試用戶的賬號密碼登陸來獲取新的測試收據(jù)。
  • 如果驗(yàn)證通過,并且 bundle 的信息也匹配,那么就會(huì)生成收據(jù)并安裝在應(yīng)用包里。在獲取到收據(jù)之后,應(yīng)用會(huì)自動(dòng)重新啟動(dòng)。

當(dāng)收據(jù)重新獲取之后,你可以在 Xcode 里運(yùn)行你的應(yīng)用,進(jìn)行錯(cuò)誤排查或者驗(yàn)證收據(jù)的代碼的微調(diào)工作。

iOS 上的測試

為了測試 iOS 上的收據(jù)驗(yàn)證,需要以下步驟:

  • 記得在真機(jī)上運(yùn)行應(yīng)用,而不是在虛擬機(jī)上。模擬機(jī)沒有請求證書的 API 。
  • 如果沒有收據(jù),應(yīng)用會(huì)發(fā)起一個(gè)刷新收據(jù)的請求, App Store 會(huì)彈出一個(gè)登錄窗口,使用測試賬號登陸并獲取收據(jù)。
  • 如果驗(yàn)證通過,并且 buldle 的信息也和你輸入的信息相一致,系統(tǒng)將會(huì)生成收據(jù)并且安裝在應(yīng)用的沙盒中。獲取到收據(jù)之后,你可以再次進(jìn)行校驗(yàn)以確認(rè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 等等。

  • 動(dòng)態(tài)分析 是通過監(jiān)測應(yīng)用運(yùn)行時(shí)的行為進(jìn)行分析,比如嵌入 debugger,以及對已知方法嵌入斷點(diǎn)。

分析結(jié)束之后則會(huì)進(jìn)行一些常見的攻擊來繞開或者破解你的收據(jù)驗(yàn)證代碼:

  • 替換收據(jù) - 如果你未能正確地驗(yàn)證收據(jù),黑客可以用其他應(yīng)用的合法收據(jù)。
  • 替換字符串 - 如果你沒有隱藏/混淆驗(yàn)證中用到的字符串 (比如:en0_MASReceipt,bundle id,bundle 版本號),黑客就有機(jī)會(huì)用自己的字符串替換原始字符串。
  • 繞過代碼 - 如果驗(yàn)證收據(jù)的代碼是大家都熟悉的函數(shù)或者模式,黑客可以輕松的定位收據(jù)驗(yàn)證的代碼并且篡改匯編碼,繞過驗(yàn)證。
  • 替換公共庫 - 如果在加密時(shí)使用了一些第三方公共庫 (比如 OpenSSL),黑客可以用自己的庫替換原始庫,從而繞過所有基于這類加密方法的驗(yàn)證。
  • 函數(shù)重載/注入 - 主要是在運(yùn)行時(shí)進(jìn)行攻擊,通過在共有庫目錄里添加自己的庫來給已知函數(shù) (用戶的或者系統(tǒng)的) 添加補(bǔ)丁,這個(gè) mach_override 項(xiàng)目讓這一切都變得簡單。

安全準(zhǔn)則

當(dāng)進(jìn)行收據(jù)驗(yàn)證的時(shí)候,需要在心中牢記以下幾點(diǎn)安全準(zhǔn)則:

必須要做

  • 多次驗(yàn)證 - 在應(yīng)用剛開啟的時(shí)候驗(yàn)證一次,在應(yīng)用運(yùn)行期間也周期性的驗(yàn)證幾次。驗(yàn)證的代碼越多,你被攻擊成功的概率就越低。
  • 混淆字符串 - 千萬不要讓你驗(yàn)證中用到的代碼對外清晰可見,這會(huì)讓黑客輕易的定位并攻擊驗(yàn)證代碼。字符串混淆的手段有很多,xor-ing、value shifting、bit masking,以及很多其他方式,讓字符串變得無法閱讀。
  • 混淆收據(jù)驗(yàn)證結(jié)果 - 不要把驗(yàn)證結(jié)果清晰可見的展示出來 (比如:"en0","AppleCertificateRoot" 這種) ,這樣會(huì)幫助黑客定位到你的驗(yàn)證代碼。為了混淆字符串,你可以用一些前面提到的算法進(jìn)行處理,讓結(jié)果看起來是一些隨機(jī)的字節(jié)。
  • 控制流復(fù)雜化 - 用一個(gè) 無實(shí)際意義的斷言 (比如一個(gè)只有運(yùn)行時(shí)才有的狀態(tài)) 來讓別人很難跟蹤你的你的代碼流。這個(gè)無意義的斷言通常來源于某個(gè)編譯時(shí)不知道的函數(shù)運(yùn)行結(jié)果。你也可以在通常不需要的地方加上一些循環(huán), goto 語句,靜態(tài)變量,或者其他任何控制流的結(jié)構(gòu)。
  • 使用靜態(tài)庫 - 如果你包含第三方的代碼,盡量通過靜態(tài)鏈接的方式加進(jìn)項(xiàng)目中。靜態(tài)代碼很難篡改,更何況你也不需要會(huì)變動(dòng)的代碼。
  • 敏感函數(shù)防止篡改 - 確保你的敏感函數(shù)沒有被替換或者修改。函數(shù)有可能基于輸入的參數(shù)進(jìn)行各種操作,所以要做好參數(shù)的驗(yàn)證工作。如果函數(shù)既沒有返回錯(cuò)誤也沒有返回正確的結(jié)果,那它有可能被替換或者修改過了。

千萬別做

  • 避免 Objective-C - Objective-C 有很多運(yùn)行時(shí)的信息,很容易被利用,進(jìn)行解析/注入/替換。如果還是想要用 Objective-C 的話,記得混淆 selector 和調(diào)用。
  • 使用共享庫來實(shí)現(xiàn)安全代碼 - 共享的類庫有可能會(huì)被替換或者更改。
  • 使用獨(dú)立代碼 - 你應(yīng)該把驗(yàn)證的代碼埋在業(yè)務(wù)邏輯里去,從而加大定位和篡改的難度。
  • 模式化收據(jù)驗(yàn)證 - 最好變化、復(fù)制、倍增驗(yàn)證代碼的實(shí)現(xiàn),避免驗(yàn)證模式被偵測。
  • 低估黑客的決心 - 有了足夠的時(shí)間和資源,黑客肯定會(huì)成功破解你的應(yīng)用。你能做的是讓這個(gè)過程盡量的艱難,增大黑客攻擊的成本。