Compare commits

...

19 Commits

Author SHA1 Message Date
Michael Schwarz 12d1bb2321 Merge pull request #18 from IdeasOnCanvas/core/feature/informlinguist
Make github know it is a swift not a c repo
2017-09-19 14:29:10 +02:00
Hannes Oud cfea40ac9c Add .gitattributes informing github linguist about dependencies 2017-09-19 14:25:43 +02:00
Michael Schwarz d928914869 Merge pull request #17 from IdeasOnCanvas/feature/core/storekitcallback
Add dedicated ReceiptRefresher class with block based callback
2017-09-19 12:37:07 +02:00
Hannes Oud 476c0636c1 Make class public final 2017-09-19 12:32:16 +02:00
Hannes Oud 7f9b8b198e Add comment about retaining 2017-09-19 12:23:04 +02:00
Hannes Oud 18968e62a4 Replace usage of StoreKitHelper with ReceiptRefresher in iOS Demo 2017-09-19 12:20:06 +02:00
Hannes Oud 7f6517f4cb Add ReceiptRefresher 2017-09-19 12:14:45 +02:00
Hannes Oud a59fc1bdda Remove unnecessary StoreKit import 2017-09-19 11:41:18 +02:00
Michael Schwarz 182108841a Merge pull request #14 from IdeasOnCanvas/feature/core/propertyValidation
Add Property Validation
2017-09-15 11:24:53 +02:00
Hannes Oud 47d1c00d2b Fix whitespaces 2017-09-14 23:23:08 +02:00
Hannes Oud 4f12e04b1f Add property Validations to readme 2017-09-14 23:21:12 +02:00
Hannes Oud ab702861a9 Rename PropertyValidations again 2017-09-14 23:20:39 +02:00
Hannes Oud 209d633a43 Remove a case and rename it 2017-09-14 23:20:39 +02:00
Hannes Oud e04f07c75a Add optional property validation 2017-09-14 23:20:38 +02:00
Hannes Oud 8e6be0199d Make root cert var instead of accidental let 2017-09-14 23:20:38 +02:00
Hannes Oud 3afdc70c86 Fix Comments in Parameters 2017-09-14 23:20:38 +02:00
Michael Schwarz 6cfcea8355 Merge pull request #12 from IdeasOnCanvas/feature/core/updateReadme
Update readme
2017-09-14 16:32:56 +02:00
Hannes Oud 0e41f9ac40 Fix Readme after Code Review 2017-09-14 15:48:26 +02:00
Hannes Oud c2c3f2cdec Update readme with more resources, installation, examples, openssl update instructions 2017-09-14 12:22:52 +02:00
14 changed files with 496 additions and 106 deletions
+2
View File
@@ -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,6 +26,10 @@ 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 {
@@ -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)
}
}
}
+5 -5
View File
@@ -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!)")
+26 -4
View File
@@ -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 {
+14 -4
View File
@@ -8,7 +8,6 @@
import Foundation
import Hekate.OpenSSL
import StoreKit
/// Apple guide: https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Introduction.html
///
@@ -44,20 +43,29 @@ 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 }
try self.validateHash(receipt: parsedReceipt, deviceIdentifierData: deviceIdentifierData)
try self.validateHash(receipt: receipt, deviceIdentifierData: deviceIdentifierData)
}
return .success(parsedReceipt)
return .success(receipt)
} catch {
assert(error is LocalReceiptValidator.Error)
return .error(error as? LocalReceiptValidator.Error ?? .unknown)
}
}
public func validateProperties(receipt: Receipt, validations: [Parameters.PropertyValidation]) throws {
for validation in validations {
try validation.validateProperty(of: receipt)
}
}
/// Parse a local receipt without any validation.
///
/// - Parameter origin: How to load the receipt.
@@ -353,6 +361,8 @@ extension LocalReceiptValidator {
case incorrectHash
case deviceIdentifierNotDeterminable
case malformedAppleRootCertificate
case couldNotGetExpectedPropertyValue
case propertyValueMismatch
case unknown
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ public struct Receipt {
/// The apps bundle identifier as bytes, used, with other data, to compute the SHA-1 hash during validation.
public internal(set) var bundleIdData: Data?
/// The apps version number.
/// The apps 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?
+112 -13
View File
@@ -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