Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92409e4498 | |||
| b2764203d4 | |||
| 9817657584 | |||
| a0f87dbc46 | |||
| 20d7a7a2ba | |||
| 12d1bb2321 | |||
| cfea40ac9c | |||
| d928914869 | |||
| 476c0636c1 | |||
| 7f9b8b198e | |||
| 18968e62a4 | |||
| 7f6517f4cb | |||
| a59fc1bdda | |||
| 182108841a | |||
| 47d1c00d2b | |||
| 4f12e04b1f | |||
| ab702861a9 | |||
| 209d633a43 | |||
| e04f07c75a | |||
| 8e6be0199d | |||
| 3afdc70c86 | |||
| 6cfcea8355 | |||
| 0e41f9ac40 | |||
| c2c3f2cdec |
@@ -0,0 +1,2 @@
|
||||
Hekate/Hekate/OpenSSL/include-ios/* linguist-vendored
|
||||
Hekate/Hekate/OpenSSL/include-macos/* linguist-vendored
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// ReceiptRefresher+Convenience.swift
|
||||
// Hekate
|
||||
//
|
||||
// Created by Hannes Oud on 19.09.17.
|
||||
// Copyright © 2017 IdeasOnCanvas GmbH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Hekate
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
extension ReceiptRefresher {
|
||||
|
||||
public func logIdentifierAndReceipt() {
|
||||
if let deviceIdentifier = LocalReceiptValidator.Parameters.DeviceIdentifier.currentDevice.getData() {
|
||||
print("Device Identifier (Base64):\n" + deviceIdentifier.base64EncodedString())
|
||||
}
|
||||
guard let data = self.receiptData else {
|
||||
print("No receipt")
|
||||
return
|
||||
}
|
||||
let base64 = data.base64EncodedString()
|
||||
print("ReceiptData (Base64):\n" + base64)
|
||||
}
|
||||
|
||||
public var receiptData: Data? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL else { return nil }
|
||||
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// ReceiptRefresher.swift
|
||||
// Hekate
|
||||
//
|
||||
// Created by Hannes Oud on 19.09.17.
|
||||
// Copyright © 2017 IdeasOnCanvas GmbH. All rights reserved.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
|
||||
@objc
|
||||
public final class ReceiptRefresher: NSObject {
|
||||
|
||||
/// Refreshes the app store receipt using `SKReceiptRefreshRequest(receiptProperties: nil)`.
|
||||
/// The instance of `ReceiptRefresher` on which this is called does not need to be kept around for this to complete.
|
||||
/// - Parameters:
|
||||
/// - queue: Queue on which the completion is called, defaults to main queue.
|
||||
/// - completion: Called after the refresh has completed, gets passed the error on failure.
|
||||
/// - Note: An iTunes Authentication alert will be presented by the system.
|
||||
public func refreshReceipt(queue: DispatchQueue = .main, completion: ((NSError?) -> Void)?) {
|
||||
let handler = RefreshCompletionHandler(queue: queue, completion: completion)
|
||||
let request = SKReceiptRefreshRequest(receiptProperties: nil)
|
||||
request.delegate = handler
|
||||
request.start()
|
||||
}
|
||||
}
|
||||
|
||||
/// Encapsules SKRequestDelegate so it is not exposed at all.
|
||||
///
|
||||
/// - Note: Retains itself until the delegate is called.
|
||||
private final class RefreshCompletionHandler: NSObject, SKRequestDelegate {
|
||||
|
||||
private let completion: ((NSError?) -> Void)?
|
||||
private var retainedSelf: RefreshCompletionHandler?
|
||||
private let queue: DispatchQueue
|
||||
|
||||
init(queue: DispatchQueue, completion: ((NSError?) -> Void)?) {
|
||||
self.completion = completion
|
||||
self.queue = queue
|
||||
super.init()
|
||||
self.retainedSelf = self
|
||||
}
|
||||
|
||||
func requestDidFinish(_ request: SKRequest) {
|
||||
self.queue.async {
|
||||
self.completion?(nil)
|
||||
self.retainedSelf = nil
|
||||
}
|
||||
}
|
||||
|
||||
func request(_ request: SKRequest, didFailWithError error: Error) {
|
||||
self.queue.async {
|
||||
self.completion?(error as NSError)
|
||||
self.retainedSelf = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ struct HekateDemoViewModel {
|
||||
var hasReceipt: Bool { return self.lastReceiptData != nil }
|
||||
var lastReceiptData: Data?
|
||||
var lastValidationResult: LocalReceiptValidator.Result?
|
||||
var refreshError: NSError?
|
||||
var receiptIsValid: Bool {
|
||||
guard let result = self.lastValidationResult else { return false }
|
||||
|
||||
@@ -25,12 +26,16 @@ struct HekateDemoViewModel {
|
||||
}
|
||||
}
|
||||
var descriptionText: String {
|
||||
if let refreshError = refreshError {
|
||||
return "Refresh Issue: " + refreshError.localizedDescription
|
||||
}
|
||||
|
||||
guard let result = self.lastValidationResult else { return "(No result)" }
|
||||
|
||||
switch result {
|
||||
case .success(let receipt):
|
||||
case .success(let receipt, _, _):
|
||||
return "Valid\n" + receipt.description
|
||||
case .error(let error):
|
||||
case .error(let error, _, _):
|
||||
return "Invalid: \(error)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
//
|
||||
// StoreKitHelper.swift
|
||||
// Hekate Demo iOS
|
||||
//
|
||||
// Created by Hannes Oud on 08.09.17.
|
||||
// Copyright © 2017 IdeasOnCanvas GmbH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
@objc
|
||||
final class StoreKitHelper: NSObject {
|
||||
|
||||
@objc static let shared = StoreKitHelper()
|
||||
@objc var refreshCompletedAction: ((NSError?) -> Void)?
|
||||
|
||||
private lazy var delegateHolder: DelegateHolder = {
|
||||
let delegateHolder = DelegateHolder()
|
||||
delegateHolder.refreshCompletedAction = { [weak self] error in
|
||||
self?.refreshCompletedAction?(error)
|
||||
}
|
||||
return delegateHolder
|
||||
}()
|
||||
|
||||
public func refresh() {
|
||||
let request = SKReceiptRefreshRequest(receiptProperties: nil)
|
||||
request.delegate = self.delegateHolder
|
||||
request.start()
|
||||
}
|
||||
|
||||
public func logReceipt() {
|
||||
print("Local Device ID (GUID):\n" + (UIDevice.current.identifierForVendor?.uuidString ?? "nil"))
|
||||
guard let data = self.receiptData else {
|
||||
print("No receipt")
|
||||
return
|
||||
}
|
||||
let base64 = data.base64EncodedString()
|
||||
print("ReceiptData:\n" + base64)
|
||||
}
|
||||
|
||||
public var receiptData: Data? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL else { return nil }
|
||||
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encapsules SKRequestDelegate so it is not exposed if not necessary
|
||||
private final class DelegateHolder: NSObject, SKRequestDelegate {
|
||||
|
||||
var refreshCompletedAction: ((NSError?) -> Void)?
|
||||
|
||||
func requestDidFinish(_ request: SKRequest) {
|
||||
DispatchQueue.main.async {
|
||||
self.refreshCompletedAction?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func request(_ request: SKRequest, didFailWithError error: Error) {
|
||||
DispatchQueue.main.async {
|
||||
self.refreshCompletedAction?(error as NSError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import UIKit
|
||||
|
||||
class ViewController: UIViewController {
|
||||
|
||||
private var storeKitHelper = StoreKitHelper()
|
||||
private var receiptRefresher = ReceiptRefresher()
|
||||
private var viewModel = HekateDemoViewModel() {
|
||||
didSet {
|
||||
self.updateViewFromViewModel()
|
||||
@@ -23,9 +23,6 @@ class ViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.storeKitHelper.refreshCompletedAction = { [weak self] _ in
|
||||
self?.updateViewModel()
|
||||
}
|
||||
updateViewModel()
|
||||
}
|
||||
|
||||
@@ -39,6 +36,9 @@ class ViewController: UIViewController {
|
||||
}
|
||||
|
||||
@IBAction func refreshReceiptFromStoreTapped() {
|
||||
storeKitHelper.refresh()
|
||||
receiptRefresher.refreshReceipt { error in
|
||||
self.viewModel.refreshError = error
|
||||
self.updateViewFromViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// LocalReceiptPropertyValidationTests.swift
|
||||
// Hekate iOS
|
||||
//
|
||||
// Created by Hannes Oud on 14.09.17.
|
||||
// Copyright © 2017 IdeasOnCanvas GmbH. All rights reserved.
|
||||
//
|
||||
|
||||
import Hekate
|
||||
import XCTest
|
||||
|
||||
class LocalReceiptPropertyValidationTests: XCTestCase {
|
||||
|
||||
private let receiptValidator = LocalReceiptValidator()
|
||||
|
||||
func testCorrectMainBundlePropertiesiOS() {
|
||||
let receipt = Receipt(bundleIdentifier: Bundle.main.bundleIdentifier,
|
||||
bundleIdData: nil,
|
||||
appVersion: Bundle.main.infoDictionary?[String(kCFBundleVersionKey)] as? String,
|
||||
opaqueValue: nil,
|
||||
sha1Hash: nil,
|
||||
originalAppVersion: nil,
|
||||
receiptCreationDate: nil,
|
||||
expirationDate: nil,
|
||||
inAppPurchaseReceipts: [])
|
||||
do {
|
||||
try receiptValidator.validateProperties(receipt: receipt, validations: [
|
||||
.bundleIdMatchingMainBundle,
|
||||
.appVersionMatchingMainBundleIOS
|
||||
])
|
||||
} catch {
|
||||
XCTFail("validation failed unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func testCorrectMainBundlePropertiesMacOS() {
|
||||
let receipt = Receipt(bundleIdentifier: Bundle.main.bundleIdentifier,
|
||||
bundleIdData: nil,
|
||||
appVersion: Bundle.main.infoDictionary?[String("CFBundleShortVersionString")] as? String,
|
||||
opaqueValue: nil,
|
||||
sha1Hash: nil,
|
||||
originalAppVersion: nil,
|
||||
receiptCreationDate: nil,
|
||||
expirationDate: nil,
|
||||
inAppPurchaseReceipts: [])
|
||||
do {
|
||||
try receiptValidator.validateProperties(receipt: receipt, validations: [
|
||||
.bundleIdMatchingMainBundle,
|
||||
.appVersionMatchingMainBundleMacOS
|
||||
])
|
||||
} catch {
|
||||
XCTFail("validation failed unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func testSpecificHardcodedPropertyMatches() {
|
||||
let receipt = Receipt(bundleIdentifier: "bundleIdentifier",
|
||||
bundleIdData: nil,
|
||||
appVersion: "appVersion",
|
||||
opaqueValue: nil,
|
||||
sha1Hash: nil,
|
||||
originalAppVersion: "originalAppVersion",
|
||||
receiptCreationDate: nil,
|
||||
expirationDate: nil,
|
||||
inAppPurchaseReceipts: [])
|
||||
do {
|
||||
try receiptValidator.validateProperties(receipt: receipt, validations: [
|
||||
.string(\Receipt.bundleIdentifier, expected: "bundleIdentifier"),
|
||||
.string(\Receipt.appVersion, expected: "appVersion"),
|
||||
.string(\Receipt.originalAppVersion, expected: "originalAppVersion")
|
||||
])
|
||||
} catch {
|
||||
XCTFail("validation failed unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func testMindNodeProMacReceiptPropertyMismatches() {
|
||||
guard let data = assertTestAsset(filename: "hannes_mac_mindnode_pro_receipt") else { return }
|
||||
|
||||
@discardableResult
|
||||
func assertPropertyMismatch(line: UInt = #line, configuration: (inout LocalReceiptValidator.Parameters) -> Void) -> Bool {
|
||||
let result = receiptValidator.validateReceipt {
|
||||
$0.receiptOrigin = .data(data)
|
||||
$0.shouldValidateHash = false // the original device identifier is unknown
|
||||
$0.propertyValidations = [ .string(\.appVersion, expected: "mismatching property"),
|
||||
.string(\.originalAppVersion, expected: "1.10.6")]
|
||||
}
|
||||
guard let error = result.error else {
|
||||
XCTFail("Unexpectedly succeeded validating, but expected a property mismatch)", file: #file, line: line)
|
||||
return false
|
||||
}
|
||||
guard error == LocalReceiptValidator.Error.propertyValueMismatch else {
|
||||
XCTFail("Expected a property mismatch, but found an \(error)", file: #file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.string(\.appVersion, expected: "mismatching property"),
|
||||
.string(\.originalAppVersion, expected: "1.10.6")]
|
||||
}
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.string(\.appVersion, expected: "1.11.5"),
|
||||
.string(\.originalAppVersion, expected: "mismatching property")]
|
||||
}
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.string(\.bundleIdentifier, expected: "mismatching property")]
|
||||
}
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.string(\.bundleIdentifier, expected: "mismatching property"),
|
||||
.string(\.appVersion, expected: "mismatching property") ]
|
||||
}
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.string(\.bundleIdentifier, expected: "mismatching property"),
|
||||
.string(\.appVersion, expected: "mismatching property") ]
|
||||
}
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.bundleIdMatchingMainBundle]
|
||||
}
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.appVersionMatchingMainBundleIOS]
|
||||
}
|
||||
assertPropertyMismatch {
|
||||
$0.propertyValidations = [.appVersionMatchingMainBundleMacOS]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class LocalReceiptValidationInAppPurchaseTests: XCTestCase {
|
||||
$0.receiptOrigin = .data(data)
|
||||
$0.shouldValidateHash = false
|
||||
$0.shouldValidateSignatureAuthenticity = false
|
||||
$0.propertyValidations = []
|
||||
}
|
||||
guard let receipt = result.receipt else {
|
||||
XCTFail("Unexpectedly failed parsing a receipt \(result.error!)")
|
||||
|
||||
@@ -46,6 +46,35 @@ class LocalReceiptValidationTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testMindNodeProMacReceiptPropertyValidation() {
|
||||
guard let data = assertTestAsset(filename: "hannes_mac_mindnode_pro_receipt") else { return }
|
||||
|
||||
let expected = Receipt(
|
||||
bundleIdentifier: "com.mindnode.MindNodePro",
|
||||
bundleIdData: Data(base64Encoded: "DBhjb20ubWluZG5vZGUuTWluZE5vZGVQcm8=")!,
|
||||
appVersion: "1.11.5",
|
||||
opaqueValue: Data(base64Encoded: "/cPmDfuyFyluvodJXQRvig=="),
|
||||
sha1Hash: Data(base64Encoded: "MDBF4hAt6Y+7IlAydxroa/SQeY4="),
|
||||
originalAppVersion: "1.10.6",
|
||||
receiptCreationDate: Date.demoDate(string: "2016-02-12T10:57:42Z"),
|
||||
expirationDate: nil,
|
||||
inAppPurchaseReceipts: []
|
||||
)
|
||||
let result = receiptValidator.validateReceipt {
|
||||
$0.receiptOrigin = .data(data)
|
||||
$0.shouldValidateHash = false // the original device identifier is unknown
|
||||
$0.propertyValidations = [ .string(\.appVersion, expected: "1.11.5"),
|
||||
.string(\.originalAppVersion, expected: "1.10.6")]
|
||||
}
|
||||
guard let receipt = result.receipt else {
|
||||
XCTFail("Unexpectedly failed parsing a receipt \(result.error!)")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(receipt, expected)
|
||||
}
|
||||
|
||||
|
||||
func testMindNodeProMacReceiptParsing() {
|
||||
guard let data = assertTestAsset(filename: "hannes_mac_mindnode_pro_receipt") else { return }
|
||||
|
||||
@@ -63,6 +92,8 @@ class LocalReceiptValidationTests: XCTestCase {
|
||||
let result = receiptValidator.validateReceipt {
|
||||
$0.receiptOrigin = .data(data)
|
||||
$0.shouldValidateHash = false // the original device identifier is unknown
|
||||
$0.propertyValidations = [ .string(\.appVersion, expected: "1.11.5"),
|
||||
.string(\.originalAppVersion, expected: "1.10.6")]
|
||||
}
|
||||
guard let receipt = result.receipt else {
|
||||
XCTFail("Unexpectedly failed parsing a receipt \(result.error!)")
|
||||
|
||||
@@ -134,7 +134,8 @@
|
||||
D19095CC1F601E5D0095729B /* not_a_receipt in Resources */ = {isa = PBXBuildFile; fileRef = D1D6F54F1F5D9E8D00E86FE1 /* not_a_receipt */; };
|
||||
D19095CD1F601E960095729B /* LocalReceiptValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D6F5411F5D8A3800E86FE1 /* LocalReceiptValidationTests.swift */; };
|
||||
D19095CE1F601E980095729B /* LocalReceiptValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D6F5411F5D8A3800E86FE1 /* LocalReceiptValidationTests.swift */; };
|
||||
D1A46B821F62E26900A390EC /* StoreKitHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A46B811F62E26900A390EC /* StoreKitHelper.swift */; };
|
||||
D1AA845C1F6ABB59007F2558 /* LocalReceiptPropertyValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AA845A1F6ABB31007F2558 /* LocalReceiptPropertyValidationTests.swift */; };
|
||||
D1AA845D1F6ABB59007F2558 /* LocalReceiptPropertyValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AA845A1F6ABB31007F2558 /* LocalReceiptPropertyValidationTests.swift */; };
|
||||
D1AB81AA1F696F0200B57E29 /* aes.h in Headers */ = {isa = PBXBuildFile; fileRef = D1D430B61F69627600F7F39D /* aes.h */; settings = {ATTRIBUTES = (Private, ); }; };
|
||||
D1AB81AB1F696F0200B57E29 /* asn1.h in Headers */ = {isa = PBXBuildFile; fileRef = D1D430B71F69627600F7F39D /* asn1.h */; settings = {ATTRIBUTES = (Private, ); }; };
|
||||
D1AB81AC1F696F0200B57E29 /* asn1_mac.h in Headers */ = {isa = PBXBuildFile; fileRef = D1D430B81F69627600F7F39D /* asn1_mac.h */; settings = {ATTRIBUTES = (Private, ); }; };
|
||||
@@ -222,6 +223,10 @@
|
||||
D1D6F4F11F5D691400E86FE1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D1D6F4EF1F5D691400E86FE1 /* LaunchScreen.storyboard */; };
|
||||
D1D6F53F1F5D89D000E86FE1 /* LocalReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D6F53E1F5D89D000E86FE1 /* LocalReceiptValidator.swift */; };
|
||||
D1D6F5401F5D89D800E86FE1 /* LocalReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D6F53E1F5D89D000E86FE1 /* LocalReceiptValidator.swift */; };
|
||||
D1DD22421F711E5E00D111F4 /* ReceiptRefresher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1DD22411F711E5E00D111F4 /* ReceiptRefresher.swift */; };
|
||||
D1DD22431F711E5E00D111F4 /* ReceiptRefresher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1DD22411F711E5E00D111F4 /* ReceiptRefresher.swift */; };
|
||||
D1DD22451F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1DD22441F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift */; };
|
||||
D1DD22461F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1DD22441F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift */; };
|
||||
D1FE343D1F604F020029576B /* Receipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FE343C1F604F020029576B /* Receipt.swift */; };
|
||||
D1FE343E1F604F020029576B /* Receipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FE343C1F604F020029576B /* Receipt.swift */; };
|
||||
D1FE34401F604F540029576B /* LocalReceiptValidator+Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FE343F1F604F540029576B /* LocalReceiptValidator+Parameters.swift */; };
|
||||
@@ -304,7 +309,7 @@
|
||||
D19095BF1F60158B0095729B /* DeviceIdentifier+installedDeviceIdentifier_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceIdentifier+installedDeviceIdentifier_macOS.swift"; sourceTree = "<group>"; };
|
||||
D19095C11F6019E70095729B /* DeviceIdentifier+installedDeviceIdentifier_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceIdentifier+installedDeviceIdentifier_iOS.swift"; sourceTree = "<group>"; };
|
||||
D19095C41F601DEA0095729B /* AppleIncRootCertificate.cer */ = {isa = PBXFileReference; lastKnownFileType = file; path = AppleIncRootCertificate.cer; sourceTree = "<group>"; };
|
||||
D1A46B811F62E26900A390EC /* StoreKitHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitHelper.swift; sourceTree = "<group>"; };
|
||||
D1AA845A1F6ABB31007F2558 /* LocalReceiptPropertyValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalReceiptPropertyValidationTests.swift; sourceTree = "<group>"; };
|
||||
D1D430B21F69627600F7F39D /* libcrypto.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libcrypto.a; sourceTree = "<group>"; };
|
||||
D1D430B31F69627600F7F39D /* libssl.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libssl.a; sourceTree = "<group>"; };
|
||||
D1D430B61F69627600F7F39D /* aes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = aes.h; sourceTree = "<group>"; };
|
||||
@@ -478,6 +483,8 @@
|
||||
D1D6F5471F5D8DF700E86FE1 /* hannes_mac_mindnode_pro_Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = hannes_mac_mindnode_pro_Info.plist; sourceTree = "<group>"; };
|
||||
D1D6F5491F5D9B1F00E86FE1 /* TestAssetLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAssetLoading.swift; sourceTree = "<group>"; };
|
||||
D1D6F54F1F5D9E8D00E86FE1 /* not_a_receipt */ = {isa = PBXFileReference; lastKnownFileType = text; path = not_a_receipt; sourceTree = "<group>"; };
|
||||
D1DD22411F711E5E00D111F4 /* ReceiptRefresher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptRefresher.swift; sourceTree = "<group>"; };
|
||||
D1DD22441F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReceiptRefresher+Convenience.swift"; sourceTree = "<group>"; };
|
||||
D1FE343C1F604F020029576B /* Receipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Receipt.swift; sourceTree = "<group>"; };
|
||||
D1FE343F1F604F540029576B /* LocalReceiptValidator+Parameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocalReceiptValidator+Parameters.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -634,6 +641,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D1D6F5411F5D8A3800E86FE1 /* LocalReceiptValidationTests.swift */,
|
||||
D1AA845A1F6ABB31007F2558 /* LocalReceiptPropertyValidationTests.swift */,
|
||||
D150A0ED1F669A880026ED04 /* LocalReceiptValidationInAppPurchaseTests.swift */,
|
||||
D1D6F5481F5D9B1100E86FE1 /* Tools */,
|
||||
D1D6F5431F5D8DBC00E86FE1 /* Test Assets */,
|
||||
@@ -846,6 +854,7 @@
|
||||
D1D6F4FC1F5D696800E86FE1 /* Hekate */,
|
||||
D1D6F4E51F5D691400E86FE1 /* Hekate Demo iOS */,
|
||||
D19095821F6000A40095729B /* Hekate Demo macOS */,
|
||||
D1DD22401F711E4400D111F4 /* Hekate Demo Shared */,
|
||||
D19095951F6000A40095729B /* Hekate Tests macOS */,
|
||||
D19095AA1F6001800095729B /* Hekate Tests iOS */,
|
||||
D19095B61F6001C20095729B /* Hekate Tests Shared */,
|
||||
@@ -871,7 +880,6 @@
|
||||
children = (
|
||||
D1D6F4E61F5D691400E86FE1 /* AppDelegate.swift */,
|
||||
D1D6F4E81F5D691400E86FE1 /* ViewController.swift */,
|
||||
D1A46B811F62E26900A390EC /* StoreKitHelper.swift */,
|
||||
D15358F01F62D43400F297D0 /* HekateDemoViewModel.swift */,
|
||||
D1D6F4EA1F5D691400E86FE1 /* Main.storyboard */,
|
||||
D1D6F4ED1F5D691400E86FE1 /* Assets.xcassets */,
|
||||
@@ -947,6 +955,15 @@
|
||||
path = Tools;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D1DD22401F711E4400D111F4 /* Hekate Demo Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D1DD22411F711E5E00D111F4 /* ReceiptRefresher.swift */,
|
||||
D1DD22441F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift */,
|
||||
);
|
||||
path = "Hekate Demo Shared";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXHeadersBuildPhase section */
|
||||
@@ -1402,8 +1419,10 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D1DD22431F711E5E00D111F4 /* ReceiptRefresher.swift in Sources */,
|
||||
D19095861F6000A40095729B /* ViewController.swift in Sources */,
|
||||
D19095841F6000A40095729B /* AppDelegate.swift in Sources */,
|
||||
D1DD22461F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -1413,6 +1432,7 @@
|
||||
files = (
|
||||
D190959F1F6000DE0095729B /* TestAssetLoading.swift in Sources */,
|
||||
D19095CD1F601E960095729B /* LocalReceiptValidationTests.swift in Sources */,
|
||||
D1AA845D1F6ABB59007F2558 /* LocalReceiptPropertyValidationTests.swift in Sources */,
|
||||
D150A0EF1F669A880026ED04 /* LocalReceiptValidationInAppPurchaseTests.swift in Sources */,
|
||||
D150A0F01F67E0990026ED04 /* Date+Convenience.swift in Sources */,
|
||||
);
|
||||
@@ -1424,6 +1444,7 @@
|
||||
files = (
|
||||
D19095C71F601E580095729B /* TestAssetLoading.swift in Sources */,
|
||||
D19095CE1F601E980095729B /* LocalReceiptValidationTests.swift in Sources */,
|
||||
D1AA845C1F6ABB59007F2558 /* LocalReceiptPropertyValidationTests.swift in Sources */,
|
||||
D150A0EE1F669A880026ED04 /* LocalReceiptValidationInAppPurchaseTests.swift in Sources */,
|
||||
D150A0F11F67E0990026ED04 /* Date+Convenience.swift in Sources */,
|
||||
);
|
||||
@@ -1465,10 +1486,11 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D1A46B821F62E26900A390EC /* StoreKitHelper.swift in Sources */,
|
||||
D1D6F4E91F5D691400E86FE1 /* ViewController.swift in Sources */,
|
||||
D1D6F4E71F5D691400E86FE1 /* AppDelegate.swift in Sources */,
|
||||
D15358F11F62D43400F297D0 /* HekateDemoViewModel.swift in Sources */,
|
||||
D1DD22421F711E5E00D111F4 /* ReceiptRefresher.swift in Sources */,
|
||||
D1DD22451F7121FE00D111F4 /* ReceiptRefresher+Convenience.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import Foundation
|
||||
public extension LocalReceiptValidator {
|
||||
|
||||
/// Describes how to validate a receipt, and how/where to obtain the dependencies (receipt, deviceIdentifier, apple root certificate)
|
||||
/// Use .allSteps to initialize the standard parameters.
|
||||
/// Use .allSteps to initialize the standard parameters. By default, no `propertyValidations` are active.
|
||||
public struct Parameters {
|
||||
|
||||
public var receiptOrigin: ReceiptOrigin = .installedInMainBundle
|
||||
@@ -19,7 +19,8 @@ public extension LocalReceiptValidator {
|
||||
public var shouldValidateSignatureAuthenticity: Bool = true
|
||||
public var shouldValidateHash: Bool = true
|
||||
public var deviceIdentifier: DeviceIdentifier = .currentDevice
|
||||
public let rootCertificateOrigin: RootCertificateOrigin = .cerFileBundledWithHekate
|
||||
public var rootCertificateOrigin: RootCertificateOrigin = .cerFileBundledWithHekate
|
||||
public var propertyValidations: [PropertyValidation] = []
|
||||
|
||||
/// Configure an instance with a block
|
||||
public func with(block: (inout Parameters) -> Void) -> Parameters {
|
||||
@@ -39,12 +40,12 @@ public extension LocalReceiptValidator {
|
||||
|
||||
// MARK: - ReceiptOrigin
|
||||
|
||||
/// Used for obtaining the receipt data to parse or validate.
|
||||
///
|
||||
/// - installedInMainBundle: Loads it from Bundle.main.appStoreReceiptURL.
|
||||
/// - data: Loads specific data.
|
||||
extension LocalReceiptValidator.Parameters {
|
||||
|
||||
/// Used for obtaining the receipt data to parse or validate.
|
||||
///
|
||||
/// - installedInMainBundle: Loads it from Bundle.main.appStoreReceiptURL.
|
||||
/// - data: Loads specific data.
|
||||
public enum ReceiptOrigin {
|
||||
|
||||
case installedInMainBundle
|
||||
@@ -66,12 +67,12 @@ extension LocalReceiptValidator.Parameters {
|
||||
|
||||
// MARK: - DeviceIdentifier
|
||||
|
||||
/// Used for calculating/validating the SHA1-Hash part of a receipt.
|
||||
///
|
||||
/// - currentDevice: Obtains it from the system location: MAC Adress on macOS, deviceIdentifierForVendor on iOS
|
||||
/// - data: Specific Data to use
|
||||
public extension LocalReceiptValidator.Parameters {
|
||||
|
||||
/// Used for calculating/validating the SHA1-Hash part of a receipt.
|
||||
///
|
||||
/// - currentDevice: Obtains it from the system location: MAC Adress on macOS, deviceIdentifierForVendor on iOS
|
||||
/// - data: Specific Data to use
|
||||
public enum DeviceIdentifier {
|
||||
|
||||
case currentDevice
|
||||
@@ -103,12 +104,12 @@ public extension LocalReceiptValidator.Parameters {
|
||||
|
||||
// MARK: - RootCertificateOrigin
|
||||
|
||||
/// Instructs how to find the Apple root certificate for receipt validation.
|
||||
///
|
||||
/// - cerFileBundledWithHekate: Uses the "AppleIncRootCertificate.cer" bundled with Hekate
|
||||
/// - data: Specific Data to use
|
||||
extension LocalReceiptValidator.Parameters {
|
||||
|
||||
/// Instructs how to find the Apple root certificate for receipt validation.
|
||||
///
|
||||
/// - cerFileBundledWithHekate: Uses the "AppleIncRootCertificate.cer" bundled with Hekate
|
||||
/// - data: Specific Data to use
|
||||
public enum RootCertificateOrigin {
|
||||
case cerFileBundledWithHekate
|
||||
case data(Data)
|
||||
@@ -127,6 +128,71 @@ extension LocalReceiptValidator.Parameters {
|
||||
private class BundleToken {}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - PropertyValidation
|
||||
|
||||
extension LocalReceiptValidator.Parameters {
|
||||
|
||||
/// Compares a String property of a receipt with an info dictionary entry or a provided value.
|
||||
///
|
||||
/// Apple recommends comparing against hard coded values. Note the platform dependence of `Receipt.appVersion`.
|
||||
///
|
||||
/// See convieniences `bundleIdMatchingMainBundle`, `appVersionMatchingMainBundleIOS`, and `appVersionMatchingMainBundleMacOS`.
|
||||
///
|
||||
/// - string: Compare a property with a hardcoded string (as recommended by apple)
|
||||
public enum PropertyValidation {
|
||||
|
||||
case string(KeyPath<Receipt, String?>, expected: String?)
|
||||
|
||||
/// Compares the receipts bundle id with the main bundle's info plist CFBundleIdentifier.
|
||||
public static var bundleIdMatchingMainBundle: PropertyValidation {
|
||||
return compareWithMainBundle(receiptProperty: \Receipt.bundleIdentifier, infoDictionaryKey: String(kCFBundleIdentifierKey))
|
||||
}
|
||||
|
||||
/// Compares the receipts appVersion with the main bundle's info plist CFBundleVersionString, as adequate for iOS
|
||||
public static var appVersionMatchingMainBundleIOS: PropertyValidation {
|
||||
return compareWithMainBundle(receiptProperty: \Receipt.appVersion, infoDictionaryKey: String(kCFBundleVersionKey))
|
||||
}
|
||||
|
||||
/// Compares the receipts appVersion with the main bundle's info plist CFBundleShortVersionString, as adequate for macOS
|
||||
public static var appVersionMatchingMainBundleMacOS: PropertyValidation {
|
||||
return compareWithMainBundle(receiptProperty: \Receipt.appVersion, infoDictionaryKey: "CFBundleShortVersionString")
|
||||
}
|
||||
|
||||
private static func compareWithMainBundle(receiptProperty: KeyPath<Receipt, String?>, infoDictionaryKey: String) -> PropertyValidation {
|
||||
let expected = Bundle.main.infoDictionary?[infoDictionaryKey] as? String
|
||||
return .string(receiptProperty, expected: expected)
|
||||
}
|
||||
|
||||
// MARK: Validation Execution
|
||||
|
||||
/// Validates a receipts property. May throw Error.couldNotGetExpectedPropertyValue or Error.propertyValueMismatch.
|
||||
public func validateProperty(of receipt: Receipt) throws {
|
||||
let expected = self.getExpectedValue()
|
||||
|
||||
if self.propertyValue(of: receipt) != expected {
|
||||
throw LocalReceiptValidator.Error.propertyValueMismatch
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Value and Expected Value
|
||||
|
||||
private func propertyValue(of receipt: Receipt) -> String? {
|
||||
switch self {
|
||||
case .string(let keyPath, _):
|
||||
return receipt[keyPath: keyPath]
|
||||
}
|
||||
}
|
||||
|
||||
private func getExpectedValue() -> String? {
|
||||
switch self {
|
||||
case .string(_, let expected):
|
||||
return expected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UUID + data
|
||||
|
||||
extension UUID {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import Foundation
|
||||
import Hekate.OpenSSL
|
||||
import StoreKit
|
||||
|
||||
/// Apple guide: https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Introduction.html
|
||||
///
|
||||
@@ -31,9 +30,13 @@ public struct LocalReceiptValidator {
|
||||
|
||||
/// Validates a local receipt and returns the result using the passed parameters.
|
||||
public func validateReceipt(parameters: Parameters = Parameters.allSteps) -> Result {
|
||||
var data: Data?
|
||||
var deviceIdData: Data?
|
||||
do {
|
||||
deviceIdData = parameters.deviceIdentifier.getData()
|
||||
guard let receiptData = parameters.receiptOrigin.loadData() else { throw Error.couldNotFindReceipt }
|
||||
|
||||
data = receiptData
|
||||
let receiptContainer = try self.extractPKCS7Container(data: receiptData)
|
||||
|
||||
if parameters.shouldValidateSignaturePresence {
|
||||
@@ -44,17 +47,26 @@ public struct LocalReceiptValidator {
|
||||
|
||||
try self.checkSignatureAuthenticity(pkcs7: receiptContainer, appleRootCertificateData: appleRootCertificateData)
|
||||
}
|
||||
let parsedReceipt = try parseReceipt(pkcs7: receiptContainer)
|
||||
|
||||
let receipt = try self.parseReceipt(pkcs7: receiptContainer)
|
||||
|
||||
try self.validateProperties(receipt: receipt, validations: parameters.propertyValidations)
|
||||
|
||||
if parameters.shouldValidateHash {
|
||||
guard let deviceIdentifierData = parameters.deviceIdentifier.getData() else { throw Error.deviceIdentifierNotDeterminable }
|
||||
guard let deviceIdentifierData = deviceIdData else { throw Error.deviceIdentifierNotDeterminable }
|
||||
|
||||
try self.validateHash(receipt: parsedReceipt, deviceIdentifierData: deviceIdentifierData)
|
||||
try self.validateHash(receipt: receipt, deviceIdentifierData: deviceIdentifierData)
|
||||
}
|
||||
return .success(parsedReceipt)
|
||||
return .success(receipt, receiptData: receiptData, deviceIdentifier: deviceIdData)
|
||||
} catch {
|
||||
assert(error is LocalReceiptValidator.Error)
|
||||
return .error(error as? LocalReceiptValidator.Error ?? .unknown)
|
||||
return .error(error as? LocalReceiptValidator.Error ?? .unknown, receiptData: data, deviceIdentifier: deviceIdData)
|
||||
}
|
||||
}
|
||||
|
||||
public func validateProperties(receipt: Receipt, validations: [Parameters.PropertyValidation]) throws {
|
||||
for validation in validations {
|
||||
try validation.validateProperty(of: receipt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +98,7 @@ public struct LocalReceiptValidator {
|
||||
private extension LocalReceiptValidator {
|
||||
|
||||
func validateHash(receipt: Receipt, deviceIdentifierData: Data) throws {
|
||||
// Make sure that the ParsedReceipt instances has non-nil values needed for hash comparison
|
||||
// Make sure that the Receipt instances has non-nil values needed for hash comparison
|
||||
guard let receiptOpaqueValueData = receipt.opaqueValue else { throw Error.incorrectHash }
|
||||
guard let receiptBundleIdData = receipt.bundleIdData else { throw Error.incorrectHash }
|
||||
guard let receiptHashData = receipt.sha1Hash else { throw Error.incorrectHash }
|
||||
@@ -185,65 +197,65 @@ private extension LocalReceiptValidator {
|
||||
guard let contents = pkcs7.pkcs7.pointee.d.sign.pointee.contents, let octets = contents.pointee.d.data else { throw Error.malformedReceipt }
|
||||
guard let initialPointer = UnsafePointer(octets.pointee.data) else { throw Error.malformedReceipt }
|
||||
let length = Int(octets.pointee.length)
|
||||
var parsedReceipt = Receipt()
|
||||
var receipt = Receipt()
|
||||
|
||||
try self.parseASN1Set(pointer: initialPointer, length: length) { attributeType, value in
|
||||
guard let attribute = KnownReceiptAttribute(rawValue: attributeType) else { return }
|
||||
|
||||
switch attribute {
|
||||
case .bundleIdentifier:
|
||||
parsedReceipt.bundleIdData = value.dataValue
|
||||
parsedReceipt.bundleIdentifier = value.unwrappedStringValue
|
||||
receipt.bundleIdData = value.dataValue
|
||||
receipt.bundleIdentifier = value.unwrappedStringValue
|
||||
case .appVersion:
|
||||
parsedReceipt.appVersion = value.unwrappedStringValue
|
||||
receipt.appVersion = value.unwrappedStringValue
|
||||
case .opaqueValue:
|
||||
parsedReceipt.opaqueValue = value.dataValue
|
||||
receipt.opaqueValue = value.dataValue
|
||||
case .sha1Hash:
|
||||
parsedReceipt.sha1Hash = value.dataValue
|
||||
receipt.sha1Hash = value.dataValue
|
||||
case .inAppPurchaseReceipts:
|
||||
guard let pointer = value.valuePointer else { break }
|
||||
|
||||
let iapReceipt = try parseInAppPurchaseReceipt(pointer: pointer, length: value.length)
|
||||
parsedReceipt.inAppPurchaseReceipts.append(iapReceipt)
|
||||
receipt.inAppPurchaseReceipts.append(iapReceipt)
|
||||
case .receiptCreationDate:
|
||||
parsedReceipt.receiptCreationDate = value.unwrappedDateValue
|
||||
receipt.receiptCreationDate = value.unwrappedDateValue
|
||||
case .originalAppVersion:
|
||||
parsedReceipt.originalAppVersion = value.unwrappedStringValue
|
||||
receipt.originalAppVersion = value.unwrappedStringValue
|
||||
case .expirationDate:
|
||||
parsedReceipt.expirationDate = value.unwrappedDateValue
|
||||
receipt.expirationDate = value.unwrappedDateValue
|
||||
break
|
||||
}
|
||||
}
|
||||
return parsedReceipt
|
||||
return receipt
|
||||
}
|
||||
|
||||
private func parseInAppPurchaseReceipt(pointer: UnsafePointer<UInt8>, length: Int) throws -> InAppPurchaseReceipt {
|
||||
var parsedInAppPurchaseReceipt = InAppPurchaseReceipt()
|
||||
var inAppPurchaseReceipt = InAppPurchaseReceipt()
|
||||
try self.parseASN1Set(pointer: pointer, length: length) { attributeType, value in
|
||||
guard let attribute = KnownInAppPurchaseAttribute(rawValue: attributeType) else { return }
|
||||
|
||||
switch attribute {
|
||||
case .quantity:
|
||||
parsedInAppPurchaseReceipt.quantity = value.intValue
|
||||
inAppPurchaseReceipt.quantity = value.intValue
|
||||
case .productIdentifier:
|
||||
parsedInAppPurchaseReceipt.productIdentifier = value.unwrappedStringValue
|
||||
inAppPurchaseReceipt.productIdentifier = value.unwrappedStringValue
|
||||
case .transactionIdentifier:
|
||||
parsedInAppPurchaseReceipt.transactionIdentifier = value.unwrappedStringValue
|
||||
inAppPurchaseReceipt.transactionIdentifier = value.unwrappedStringValue
|
||||
case .originalTransactionIdentifier:
|
||||
parsedInAppPurchaseReceipt.originalTransactionIdentifier = value.unwrappedStringValue
|
||||
inAppPurchaseReceipt.originalTransactionIdentifier = value.unwrappedStringValue
|
||||
case .purchaseDate:
|
||||
parsedInAppPurchaseReceipt.purchaseDate = value.unwrappedDateValue
|
||||
inAppPurchaseReceipt.purchaseDate = value.unwrappedDateValue
|
||||
case .originalPurchaseDate:
|
||||
parsedInAppPurchaseReceipt.originalPurchaseDate = value.unwrappedDateValue
|
||||
inAppPurchaseReceipt.originalPurchaseDate = value.unwrappedDateValue
|
||||
case .subscriptionExpirationDate:
|
||||
parsedInAppPurchaseReceipt.subscriptionExpirationDate = value.unwrappedDateValue
|
||||
inAppPurchaseReceipt.subscriptionExpirationDate = value.unwrappedDateValue
|
||||
case .cancellationDate:
|
||||
parsedInAppPurchaseReceipt.cancellationDate = value.unwrappedDateValue
|
||||
inAppPurchaseReceipt.cancellationDate = value.unwrappedDateValue
|
||||
case .webOrderLineItemId:
|
||||
parsedInAppPurchaseReceipt.webOrderLineItemId = value.intValue
|
||||
inAppPurchaseReceipt.webOrderLineItemId = value.intValue
|
||||
}
|
||||
}
|
||||
return parsedInAppPurchaseReceipt
|
||||
return inAppPurchaseReceipt
|
||||
}
|
||||
|
||||
private func parseASN1Set(pointer initialPointer: UnsafePointer<UInt8>, length: Int, valueAttributeAction: (_ attributeType: Int32, _ value: ASN1Object) throws -> Void) throws {
|
||||
@@ -276,7 +288,7 @@ private extension LocalReceiptValidator {
|
||||
|
||||
private extension LocalReceiptValidator {
|
||||
|
||||
/// See ParsedReceipt.swift for details and a link to Apple reference
|
||||
/// See Receipt.swift for details and a link to Apple reference
|
||||
enum KnownReceiptAttribute: Int32 {
|
||||
case bundleIdentifier = 2
|
||||
case appVersion = 3
|
||||
@@ -295,7 +307,7 @@ private extension LocalReceiptValidator {
|
||||
// - and of unknown type 14(L=3), 25(L=3), 11(L=4), 13(L=4), 1(L=6), 9(L=6), 16(L=6), 15(L=8), 7(L=66), 6(L=69 variable)
|
||||
}
|
||||
|
||||
/// See ParsedReceipt.swift for details and a link to Apple reference
|
||||
/// See Receipt.swift for details and a link to Apple reference
|
||||
enum KnownInAppPurchaseAttribute: Int32 {
|
||||
case quantity = 1701
|
||||
case productIdentifier = 1702
|
||||
@@ -315,12 +327,12 @@ extension LocalReceiptValidator {
|
||||
|
||||
public enum Result {
|
||||
|
||||
case success(Receipt)
|
||||
case error(LocalReceiptValidator.Error)
|
||||
case success(Receipt, receiptData: Data, deviceIdentifier: Data?)
|
||||
case error(LocalReceiptValidator.Error, receiptData: Data?, deviceIdentifier: Data?)
|
||||
|
||||
public var receipt: Receipt? {
|
||||
switch self {
|
||||
case .success(let receipt):
|
||||
case .success(let receipt, _, _):
|
||||
return receipt
|
||||
case .error:
|
||||
return nil
|
||||
@@ -331,10 +343,30 @@ extension LocalReceiptValidator {
|
||||
switch self {
|
||||
case .success:
|
||||
return nil
|
||||
case .error(let error):
|
||||
case .error(let error, _, _):
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt data if it could be loaded
|
||||
public var receiptData: Data? {
|
||||
switch self {
|
||||
case .success(_, let data, _):
|
||||
return data
|
||||
case .error(_, let data, _):
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/// The device identifier if it could be determined
|
||||
public var deviceIdentifier: Data? {
|
||||
switch self {
|
||||
case .success(_, _, let data):
|
||||
return data
|
||||
case .error(_, _, let data):
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +385,8 @@ extension LocalReceiptValidator {
|
||||
case incorrectHash
|
||||
case deviceIdentifierNotDeterminable
|
||||
case malformedAppleRootCertificate
|
||||
case couldNotGetExpectedPropertyValue
|
||||
case propertyValueMismatch
|
||||
case unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public struct Receipt {
|
||||
/// The app’s bundle identifier as bytes, used, with other data, to compute the SHA-1 hash during validation.
|
||||
public internal(set) var bundleIdData: Data?
|
||||
|
||||
/// The app’s version number.
|
||||
/// The app’s version number. **This is platform dependent!**
|
||||
/// This corresponds to the value of `CFBundleVersion` (in iOS) or `CFBundleShortVersionString` (in macOS) in the Info.plist.
|
||||
/// ASN.1 Field Type 3.
|
||||
public internal(set) var appVersion: String?
|
||||
@@ -76,7 +76,7 @@ extension Receipt: AutoEquatable {}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
|
||||
extension Receipt: CustomStringConvertible {
|
||||
extension Receipt: CustomStringConvertible, CustomDebugStringConvertible {
|
||||
|
||||
public var description: String {
|
||||
let formatter = StringFormatter()
|
||||
@@ -91,11 +91,15 @@ extension Receipt: CustomStringConvertible {
|
||||
("expirationDate", formatter.format(self.expirationDate)),
|
||||
("inAppPurchaseReceipts", formatter.format(self.inAppPurchaseReceipts))
|
||||
]
|
||||
return "ParsedReceipt(\n" + formatter.format(props) + "\n)"
|
||||
return "Receipt(\n" + formatter.format(props) + "\n)"
|
||||
}
|
||||
|
||||
public var debugDescription: String {
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ParsedInAppPurchaseReceipt
|
||||
// MARK: - InAppPurchaseReceipt
|
||||
|
||||
/// An In-App-Purchase Receipt as Parsed from a receipt file.
|
||||
///
|
||||
@@ -169,7 +173,7 @@ public struct InAppPurchaseReceipt {
|
||||
/// This value is a unique ID that identifies purchase events across devices, including subscription renewal purchase events.
|
||||
public internal(set) var webOrderLineItemId: Int?
|
||||
|
||||
/// For documentation see ParsedInAppPurchaseReceipt itself.
|
||||
/// For documentation see InAppPurchaseReceipt itself.
|
||||
public init(quantity: Int?, productIdentifier: String?, transactionIdentifier: String?, originalTransactionIdentifier: String?, purchaseDate: Date?, originalPurchaseDate: Date?, subscriptionExpirationDate: Date?, cancellationDate: Date?, webOrderLineItemId: Int?) {
|
||||
self.quantity = quantity
|
||||
self.productIdentifier = productIdentifier
|
||||
@@ -191,7 +195,7 @@ extension InAppPurchaseReceipt: AutoEquatable {}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
|
||||
extension InAppPurchaseReceipt: CustomStringConvertible {
|
||||
extension InAppPurchaseReceipt: CustomStringConvertible, CustomDebugStringConvertible {
|
||||
|
||||
public var description: String {
|
||||
let formatter = StringFormatter()
|
||||
@@ -206,7 +210,11 @@ extension InAppPurchaseReceipt: CustomStringConvertible {
|
||||
("cancellationDate", formatter.format(self.cancellationDate)),
|
||||
("webOrderLineItemId", formatter.format(self.webOrderLineItemId))
|
||||
]
|
||||
return "ParsedInAppPurchaseReceipt(\n" + formatter.format(props) + "\n)"
|
||||
return "InAppPurchaseReceipt(\n" + formatter.format(props) + "\n)"
|
||||
}
|
||||
|
||||
public var debugDescription: String {
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ private func compareArrays<T>(lhs: [T], rhs: [T], compare: (_ lhs: T, _ rhs: T)
|
||||
}
|
||||
|
||||
// MARK: - AutoEquatable for classes, protocols, structs
|
||||
// MARK: - ParsedInAppPurchaseReceipt AutoEquatable
|
||||
// MARK: - InAppPurchaseReceipt AutoEquatable
|
||||
extension InAppPurchaseReceipt: Equatable {}
|
||||
public func == (lhs: InAppPurchaseReceipt, rhs: InAppPurchaseReceipt) -> Bool {
|
||||
guard compareOptionals(lhs: lhs.quantity, rhs: rhs.quantity, compare: ==) else { return false }
|
||||
@@ -37,7 +37,7 @@ public func == (lhs: InAppPurchaseReceipt, rhs: InAppPurchaseReceipt) -> Bool {
|
||||
guard compareOptionals(lhs: lhs.webOrderLineItemId, rhs: rhs.webOrderLineItemId, compare: ==) else { return false }
|
||||
return true
|
||||
}
|
||||
// MARK: - ParsedReceipt AutoEquatable
|
||||
// MARK: - Receipt AutoEquatable
|
||||
extension Receipt: Equatable {}
|
||||
public func == (lhs: Receipt, rhs: Receipt) -> Bool {
|
||||
guard compareOptionals(lhs: lhs.bundleIdentifier, rhs: rhs.bundleIdentifier, compare: ==) else { return false }
|
||||
|
||||
@@ -1,28 +1,127 @@
|
||||
# Hekate
|
||||
|
||||
An iOS and macOS project intended for dealing with App Store receipts.
|
||||
An iOS and macOS project intended for dealing with App Store receipts, offering basic local retrieval, validation and parsing of receipt files.
|
||||
|
||||
[Hekate](https://en.wikipedia.org/wiki/Hecate) is the goddess of magic, crossroads, ghosts, and necromancy.
|
||||
|
||||
## Integration
|
||||
|
||||
Use carthage `github "IdeasOnCanvas/Hekate"`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Just parsing a receipt
|
||||
|
||||
```swift
|
||||
let receiptValidator = LocalReceiptValidator()
|
||||
|
||||
let installedReceipt = receiptValidator.parseReceipt(origin: .installedInMainBundle)
|
||||
|
||||
let customReceipt = receiptValidator.parseReceipt(origin: .data(dataFromSomewhere))
|
||||
```
|
||||
|
||||
### Validating a receipt's signature and hash
|
||||
|
||||
```swift
|
||||
// Full validation of signature and hash based on installed receipt
|
||||
let result = receiptValidator.validateReceipt()
|
||||
|
||||
switch result {
|
||||
case .success(let receipt):
|
||||
print("receipt validated and parsed: \(receipt)")
|
||||
case .error(let validationError):
|
||||
print("not valid? \(validationError)")
|
||||
}
|
||||
```
|
||||
|
||||
### Customize validation dependencies or steps
|
||||
|
||||
Take `LocalReceiptValidator.Parameters.allSteps` and customize it, then pass it to `validateReceipt(parameters:)`, or use the shortcut which takes a configuration block, like so:
|
||||
|
||||
```swift
|
||||
// Customizing validation parameters with configuration block, base on .allSteps
|
||||
let result = receiptValidator.validateReceipt {
|
||||
$0.receiptOrigin = .data(myData)
|
||||
$0.shouldValidateSignaturePresence = false // skip signature presence validation
|
||||
$0.shouldValidateSignatureAuthenticity = false // skip signature authenticity validation
|
||||
$0.shouldValidateHash = false // skip hash validation
|
||||
$0.deviceIdentifier = .data(myCustomDeviceIdentifierData)
|
||||
$0.rootCertificateOrigin = .data(myAppleRootCertData)
|
||||
|
||||
// validate some string properties, this can also be done
|
||||
// independently with validateProperties(receipt:, validations:)
|
||||
// There are also shorthands for comparing with main bundle's
|
||||
// info.plist, e.g. bundleIdMatchingMainBundle and friends.
|
||||
// Note that appVersion meaning is platform specific.
|
||||
$0.propertyValidations = [
|
||||
.string(\.bundleIdentifier, expected: "my.bundle.identifier"),
|
||||
.string(\.appVersion, expected: "123"),
|
||||
.string(\.originalAppVersion, expected: "1")
|
||||
]
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let receipt):
|
||||
print("receipt validated and parsed: \(receipt)")
|
||||
case .error(let validationError):
|
||||
print("not valid? \(validationError)")
|
||||
}
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
This framework currently doesn't
|
||||
|
||||
- deal with StoreKit at all.
|
||||
- the demo targets are pretty useless
|
||||
|
||||
The receipt file might not exist at all. See resources.
|
||||
|
||||
## How it Works
|
||||
|
||||
### Hekate Uses OpenSSL
|
||||
|
||||
OpenSSL is used for pkcs7 container parsing and signature validation, and then for parsing the ASN1 payload of the pkcs7, which contains the receipts attributes.
|
||||
|
||||
## Other Options
|
||||
|
||||
#### Alternatives to PKCS7 of OpenSSL
|
||||
|
||||
- `Security.framework` - `CMSDecoder` for PKCS7 interaction only available on macOS
|
||||
- `BoringSSL` instead of OpenSSL, Pod, only available on iOS (?)
|
||||
|
||||
#### Alternatives to ASN1 of OpenSSL
|
||||
|
||||
- [decoding-asn1-der-sequences-in-swift](http://nspasteboard.com/2016/10/23/decoding-asn1-der-sequences-in-swift/) implemented [here](https://gist.github.com/Jugale/2daaec0715d4f6d7347534d42bfa7110)
|
||||
- [Asn1Parser.swift](https://github.com/TakeScoop/SwiftyRSA/blob/03250be7319d8c54159234e5258ead395ea4de4c/SwiftyRSA/Asn1Parser.swift)
|
||||
|
||||
#### Validation Server to Server
|
||||
An app can send its receipt file to a backend from where Apples receipt API can be called. See Resources.
|
||||
|
||||
Advantages doing it locally:
|
||||
|
||||
- Works Offline
|
||||
- Validation mechanisms can be adjusted
|
||||
- Can be parsed without validation
|
||||
|
||||
## Resources
|
||||
|
||||
- [Apple guide](https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Introduction.html)
|
||||
- [objc.io guide](https://www.objc.io/issues/17-security/receipt-validation/)
|
||||
- [Andrew Bancroft complete guide](https://www.andrewcbancroft.com/2017/08/01/local-receipt-validation-swift-start-finish/), or directly [ReceiptValidator.swift](https://github.com/andrewcbancroft/SwiftyLocalReceiptValidator/blob/master/ReceiptValidator.swift)
|
||||
- [Andrew Bancroft complete guide](https://www.andrewcbancroft.com/2017/08/01/local-receipt-validation-swift-start-finish/), or directly [ReceiptValidator.swift](https://github.com/andrewcbancroft/SwiftyLocalReceiptValidator/blob/master/ReceiptValidator.swift). This is what the Hekate implementation is loosely based on.
|
||||
- [OpenSSL-Universal Pod](https://github.com/krzyzanowskim/OpenSSL)
|
||||
- WWDC 2013 - 308 Using Receipts to Protect Your Digital Sales
|
||||
- WWDC 2014 - 305 Preventing Unauthorized Purchases with Receipts
|
||||
- WWDC 2016 - 702 Using Store Kit for In-App Purchases with Swift 3
|
||||
- WWDC 2017 - 304 What's New in Storekit
|
||||
- **WWDC 2017 - 304 What's New in Storekit**
|
||||
- **WWDC 2017 - 305 Advanced StoreKit**: Receipt checking and it's internals
|
||||
- [nsomar about Module Maps 1](http://nsomar.com/project-and-private-headers-in-a-swift-and-objective-c-framework/)
|
||||
- [nsomar about Module Maps 2](http://nsomar.com/modular-framework-creating-and-using-them/)
|
||||
|
||||
## Other Options
|
||||
|
||||
#### Alternatives to PKCS7 of OpenSSL
|
||||
- `Security.framework` - `CMSDecoder` for PKCS7 interaction only available on macOS
|
||||
- `BoringSSL` instead of OpenSSL, Pod, only available on iOS
|
||||
|
||||
#### Alternatives to ASN1 of OpenSSL
|
||||
- [decoding-asn1-der-sequences-in-swift](http://nspasteboard.com/2016/10/23/decoding-asn1-der-sequences-in-swift/) implemented [here](https://gist.github.com/Jugale/2daaec0715d4f6d7347534d42bfa7110)
|
||||
- [Asn1Parser.swift](https://github.com/TakeScoop/SwiftyRSA/blob/03250be7319d8c54159234e5258ead395ea4de4c/SwiftyRSA/Asn1Parser.swift)
|
||||
|
||||
## Updating OpenSSL
|
||||
|
||||
1. build or find prebuilt static libraries for iOS and macOS. They can for example be obtained from the [OpenSSL-Universal Pod](https://github.com/krzyzanowskim/OpenSSL).
|
||||
2. Replace the openssl related `.a` and `.h` files in the project
|
||||
3. When copying from the pod, make sure the .h files use direct includes like `#include "asn1.h"` instead of `#include "<OpenSSL/ans1.h>"` (use regex batch replace)
|
||||
4. Make sure the openssl related headers are in the *private* headers of the framework Hekate iOS and Hekate macOS targets respectively
|
||||
5. Make sure the openssl related headers are listed in the [Hekate.modulemap](Hekate/Hekate/Supporting%20Files/Hekate.modulemap) file
|
||||
|
||||
Reference in New Issue
Block a user