From 4cda630ccb804cc9a1074b8099f95e4be0466dcd Mon Sep 17 00:00:00 2001 From: Dave Alden Date: Mon, 20 Nov 2023 11:33:41 +0000 Subject: [PATCH] (ios) feat: Make on-device (local) receipt validation an optional feature (disabled by default) which is enabled via a plugin variable --- doc/ios.md | 16 + package.json | 6 +- plugin.xml | 32 +- src/ios/InAppPurchase.h | 4 + src/ios/InAppPurchase.m | 106 ++- .../AppleIncRootCertificate.cer | Bin 0 -> 1215 bytes .../local-receipt-validation/RMAppReceipt.h | 178 ++++ .../local-receipt-validation/RMAppReceipt.m | 392 +++++++++ src/ios/local-receipt-validation/RMStore.h | 308 +++++++ src/ios/local-receipt-validation/RMStore.m | 791 ++++++++++++++++++ .../RMStoreAppReceiptVerifier.h | 49 ++ .../RMStoreAppReceiptVerifier.m | 127 +++ .../local-receipt-validation/apply-module.js | 242 ++++++ .../stub/RMAppReceipt.h | 125 +++ .../stub/RMAppReceipt.m | 25 + .../stub/RMStoreAppReceiptVerifier.h | 33 + .../stub/RMStoreAppReceiptVerifier.m | 44 + 17 files changed, 2449 insertions(+), 29 deletions(-) create mode 100644 src/ios/local-receipt-validation/AppleIncRootCertificate.cer create mode 100644 src/ios/local-receipt-validation/RMAppReceipt.h create mode 100644 src/ios/local-receipt-validation/RMAppReceipt.m create mode 100644 src/ios/local-receipt-validation/RMStore.h create mode 100644 src/ios/local-receipt-validation/RMStore.m create mode 100644 src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h create mode 100644 src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m create mode 100644 src/ios/local-receipt-validation/apply-module.js create mode 100644 src/ios/local-receipt-validation/stub/RMAppReceipt.h create mode 100644 src/ios/local-receipt-validation/stub/RMAppReceipt.m create mode 100644 src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.h create mode 100644 src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.m diff --git a/doc/ios.md b/doc/ios.md index 1ee84ba9..d6d2fc87 100644 --- a/doc/ios.md +++ b/doc/ios.md @@ -168,3 +168,19 @@ store.error(function(e){ // Refresh the store to start everything store.refresh(); ``` + +### Receipt validation +As outlined in the [API documentation](api.md#receipt-validation), validation of app store receipts should be used to prevent users faking in-app purchases in order to access paid-for features for free. + +#### Server-side validation +- If you at all are concerned about security and possibility of exploitation of your paid app features, then you should use server-side (remote) validation as this is more secure and harder defeat than on-device validation. +- You can use an out-of-the-box solution such as [Fovea.Billing](https://billing.fovea.cc/) or implement your own server-side solution. + +#### On-device validation +- In some circumstances where the in-app products are low-value or niche, server-side validation/parsing may seem like overkill and client-side validation/parsing of the app store receipt within the app on the device is sufficient. +- For this use case, this plugin implements on-device validation using the [RMStore](https://github.com/robotmedia/RMStore) library to provide the ability for the plugin to validate/parse app store receipts on the device. +- By default, this is functionality disabled but can be enabled at plugin installation time by setting the `LOCAL_RECEIPT_VALIDATION` plugin variable: + - `cordova plugin add cordova-plugin-purchase --variable LOCAL_RECEIPT_VALIDATION=true` + - Note: if the plugin is already installed, you'll need to uninstall and re-install it with the new plugin variable value: + - `cordova plugin rm cordova-plugin-purchase && cordova plugin add cordova-plugin-purchase --variable LOCAL_RECEIPT_VALIDATION=true` +- Note: the RMStore implementation uses the [OpenSSL crypto library](https://www.openssl.org/) which will be pulled into the app build and therefore **enabling on-device validation will add about 20Mb to the size of your app**. diff --git a/package.json b/package.json index 35c468a3..e2ad8d1c 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "scripts": { "test": "cd tests && jest", "typedoc": "typedoc --hideGenerator --excludePrivate --excludeProtected --excludeInternal --disableSources --readme src/ts/README.md --hideBreadcrumbs true --hideInPageTOC false --out api --pretty --plugin typedoc-plugin-markdown --theme markdown --basePath src/ts src/ts/store.ts", - "typedoc-dev": "typedoc --hideGenerator --readme src/ts/README.md --out api-dev --pretty --plugin typedoc-plugin-markdown --theme markdown --basePath src/ts src/ts/store.ts" + "typedoc-dev": "typedoc --hideGenerator --readme src/ts/README.md --out api-dev --pretty --plugin typedoc-plugin-markdown --theme markdown --basePath src/ts src/ts/store.ts", + "postinstall": "node ./src/ios/local-receipt-validation/apply-module.js" }, "author": "Jean-Christophe Hoelt ", "license": "MIT", @@ -41,6 +42,9 @@ "url": "https://github.com/j3k0/cordova-plugin-purchase/issues" }, "homepage": "https://github.com/j3k0/cordova-plugin-purchase", + "dependencies": { + "xml-js": "^1.6.11" + }, "devDependencies": { "@types/jest": "^29.5.4", "jest": "^29.7.0", diff --git a/plugin.xml b/plugin.xml index f4c277b1..ddc92708 100644 --- a/plugin.xml +++ b/plugin.xml @@ -69,7 +69,37 @@ SOFTWARE. - + + + + + + + + + + + + + diff --git a/src/ios/InAppPurchase.h b/src/ios/InAppPurchase.h index c58d6bdf..69641ea6 100644 --- a/src/ios/InAppPurchase.h +++ b/src/ios/InAppPurchase.h @@ -18,17 +18,20 @@ #import "SKProduct+LocalizedPrice.h" #import "SKProductDiscount+LocalizedPrice.h" #import "FileUtility.h" +#import "RMStoreAppReceiptVerifier.h" @interface InAppPurchase : CDVPlugin { NSMutableDictionary *products; NSMutableDictionary *retainer; NSMutableDictionary *unfinishedTransactions; NSMutableArray *pendingTransactionUpdates; + RMStoreAppReceiptVerifier *verifier; } @property (nonatomic,retain) NSMutableDictionary *products; @property (nonatomic,retain) NSMutableDictionary *retainer; @property (nonatomic, retain) NSMutableDictionary *unfinishedTransactions; @property (nonatomic, retain) NSMutableArray *pendingTransactionUpdates; +@property (nonatomic, retain) RMStoreAppReceiptVerifier *verifier; - (void) canMakePayments: (CDVInvokedUrlCommand*)command; @@ -37,6 +40,7 @@ - (void) purchase: (CDVInvokedUrlCommand*)command; - (void) appStoreReceipt: (CDVInvokedUrlCommand*)command; - (void) appStoreRefreshReceipt: (CDVInvokedUrlCommand*)command; +- (void) setBundleDetails: (CDVInvokedUrlCommand*)command; - (void) processPendingTransactions: (CDVInvokedUrlCommand*)command; - (void) paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions; diff --git a/src/ios/InAppPurchase.m b/src/ios/InAppPurchase.m index a0ae2119..2de4b9ea 100644 --- a/src/ios/InAppPurchase.m +++ b/src/ios/InAppPurchase.m @@ -5,6 +5,7 @@ // #import "InAppPurchase.h" +#import "RMAppReceipt.h" #include #include @@ -191,6 +192,10 @@ static NSInteger jsErrorCode(NSInteger storeKitErrorCode) { return [NSString stringWithFormat:@"%f", [date timeIntervalSince1970] * 1000]; } +static NSString *dateToString(NSDate* date) { + return [NSISO8601DateFormatter stringFromDate:date timeZone:[NSTimeZone systemTimeZone] formatOptions:NSISO8601DateFormatWithInternetDateTime]; +} + @implementation NSArray (JSONSerialize) - (NSString *)JSONSerialize { NSData *jsonData = [NSJSONSerialization dataWithJSONObject:self options:0 error:nil]; @@ -245,6 +250,7 @@ @implementation InAppPurchase @synthesize retainer; @synthesize unfinishedTransactions; @synthesize pendingTransactionUpdates; +@synthesize verifier; // Initialize the plugin state -(void) pluginInitialize { @@ -252,6 +258,11 @@ -(void) pluginInitialize { self.products = [[NSMutableDictionary alloc] init]; self.pendingTransactionUpdates = [[NSMutableArray alloc] init]; self.unfinishedTransactions = [[NSMutableDictionary alloc] init]; + self.verifier = [[RMStoreAppReceiptVerifier alloc] init]; + + [self.verifier setBundleIdentifier:[[NSBundle mainBundle].infoDictionary objectForKey:@"CFBundleIdentifier"]]; + [self.verifier setBundleVersion:[[NSBundle mainBundle].infoDictionary objectForKey:@"CFBundleShortVersionString"]]; + if ([SKPaymentQueue canMakePayments]) { [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; NSLog(@"[CdvPurchase.AppleAppStore.objc] Initialized."); @@ -691,19 +702,7 @@ - (NSData *)appStoreReceipt { - (void) appStoreReceipt: (CDVInvokedUrlCommand*)command { DLog(@"appStoreReceipt:"); - NSString *base64 = nil; - NSData *receiptData = [self appStoreReceipt]; - if (receiptData != nil) { - base64 = [receiptData convertToBase64]; - } - NSBundle *bundle = [NSBundle mainBundle]; - NSArray *callbackArgs = [NSArray arrayWithObjects: - NILABLE(base64), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleIdentifier"]), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleShortVersionString"]), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleNumericVersion"]), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleSignature"]), - nil]; + NSArray *callbackArgs = [self parseAppReceipt]; CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray:callbackArgs]; [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; @@ -730,6 +729,72 @@ - (void) appStoreRefreshReceipt: (CDVInvokedUrlCommand*)command { DLog(@"appStoreRefreshReceipt: Receipt refresh request started"); } +- (void) setBundleDetails: (CDVInvokedUrlCommand*)command { + DLog(@"setBundleDetails: Setting bundle details for local app store receipt verification"); + + NSString *bundleIdentifier = [command.arguments objectAtIndex:0]; + NSString *bundleVersion = [command.arguments objectAtIndex:1]; + + if (![bundleIdentifier isKindOfClass:[NSString class]] || bundleIdentifier == nil || [bundleIdentifier isEqualToString:@""] + || ![bundleVersion isKindOfClass:[NSString class]] || bundleVersion == nil || [bundleVersion isEqualToString:@""] + ) { + DLog(@"setBundleDetails: Not an non-empty NSString"); + CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"Invalid arguments"]; + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; + return; + } + + [self.verifier setBundleIdentifier:bundleIdentifier]; + [self.verifier setBundleVersion:bundleVersion]; + + [self.commandDelegate sendPluginResult:[CDVPluginResult resultWithStatus:CDVCommandStatus_OK] callbackId:command.callbackId]; +} + +- (NSArray*) parseAppReceipt { + NSString *base64 = nil; + NSData *receiptData = [self appStoreReceipt]; + NSDictionary* receiptPayload = nil; + if (receiptData != nil) { + base64 = [receiptData convertToBase64]; + RMAppReceipt* receipt = [RMAppReceipt bundleReceipt]; + if(receipt != nil){ + NSArray* _inAppPurchases = [receipt valueForKey:@"inAppPurchases"]; + NSMutableArray* inAppPurchases = [NSMutableArray new]; + for (RMAppReceiptIAP* _iap in _inAppPurchases) { + [inAppPurchases addObject:[NSDictionary dictionaryWithObjectsAndKeys: + NILABLE([NSNumber numberWithInteger:_iap.quantity]), @"quantity", + NILABLE(_iap.productIdentifier), @"productIdentifier", + NILABLE(_iap.transactionIdentifier), @"transactionIdentifier", + NILABLE(_iap.originalTransactionIdentifier), @"originalTransactionIdentifier", + NILABLE(dateToString(_iap.purchaseDate)), @"purchaseDate", + NILABLE(dateToString(_iap.originalPurchaseDate)), @"originalPurchaseDate", + NILABLE(dateToString(_iap.subscriptionExpirationDate)), @"subscriptionExpirationDate", + NILABLE(dateToString(_iap.cancellationDate)), @"cancellationDate", + NILABLE([NSNumber numberWithInteger:_iap.webOrderLineItemID]), @"webOrderLineItemID", + nil]]; + } + + receiptPayload = [NSDictionary dictionaryWithObjectsAndKeys: + NILABLE(receipt.bundleIdentifier), @"bundleIdentifier", + NILABLE(receipt.appVersion), @"appVersion", + NILABLE(receipt.originalAppVersion), @"originalAppVersion", + NILABLE(dateToString(receipt.expirationDate)), @"expirationDate", + NILABLE(inAppPurchases), @"inAppPurchases", + @([self.verifier verifyAppReceipt]), @"verified", + nil]; + } + } + NSBundle *bundle = [NSBundle mainBundle]; + NSArray *callbackArgs = [NSArray arrayWithObjects: + NILABLE(base64), + NILABLE([bundle.infoDictionary objectForKey:@"CFBundleIdentifier"]), + NILABLE([bundle.infoDictionary objectForKey:@"CFBundleShortVersionString"]), + NILABLE([bundle.infoDictionary objectForKey:@"CFBundleNumericVersion"]), + NILABLE([bundle.infoDictionary objectForKey:@"CFBundleSignature"]), + NILABLE(receiptPayload), + nil]; + return callbackArgs; +} - (void) dispose { g_initialized = NO; g_debugEnabled = NO; @@ -795,20 +860,7 @@ @implementation RefreshReceiptDelegate - (void) requestDidFinish:(SKRequest *)request { DLog(@"RefreshReceiptDelegate.requestDidFinish: Got refreshed receipt"); - NSString *base64 = nil; - NSData *receiptData = [self.plugin appStoreReceipt]; - if (receiptData != nil) { - base64 = [receiptData convertToBase64]; - // DLog(@"base64 receipt: %@", base64); - } - NSBundle *bundle = [NSBundle mainBundle]; - NSArray *callbackArgs = [NSArray arrayWithObjects: - NILABLE(base64), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleIdentifier"]), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleShortVersionString"]), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleNumericVersion"]), - NILABLE([bundle.infoDictionary objectForKey:@"CFBundleSignature"]), - nil]; + NSArray *callbackArgs = [self.plugin parseAppReceipt]; CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray:callbackArgs]; DLog(@"RefreshReceiptDelegate.requestDidFinish: Send new receipt data"); diff --git a/src/ios/local-receipt-validation/AppleIncRootCertificate.cer b/src/ios/local-receipt-validation/AppleIncRootCertificate.cer new file mode 100644 index 0000000000000000000000000000000000000000..8a9ff247419dd22a07c837ba7394aeabdd0f14e2 GIT binary patch literal 1215 zcmXqLV%crb#JqR`GZP~d5E<~YacZ@Bw0-AgWMpM!Fi0}wHsEAq4rO5zW(o~96gCh9 zakzxJ9199^QWZS&lJyML3{*gZ+`_UDLFd$>lFYQsBg2!4D>>yS-j;I@c+L7YuChh5U7`KHvAiEsOqE5wMI&W5Px=0B&b;#hyADPKr1x`dQTTp(jgCTo z!8UtFgP!fq=lSQ_e%AKXkUH`2+}53ZH{)ckownU-we|}?AHyW>jf!G=C0A{DZzqYZ zUR*fIJvj8>dVR;uKYl+hIQwj|k87R0Pjj_t->jT;Rj-bAq&^<-@B zm%W!-{69S|b&uzbviZg$sSC@eoYZAvW@KPo+{9P~43RPeK43h`@-s62XJG-R8#V)e z5MLO?XEk63QUIB4HrbfL%co zBPgB8DzG#$asX{)0b&Md!c0zKWi)8~WT3^yq0I(NqwGwKVsaTJB?ZM+`ugSN<$8&r zl&P1TpQ{gMB`4||G#-X4W-@5pCe^q(C^aWDF)uk)0hmHdGBS%5lHrLqRUxTTAu+E~ zp&+rS1js5bF3n9XR!B@vPAw>b=t%?WNd@6N1&|%Uq@D!K48=g%l*FPGg_6{wT%d-$ z6ouscyp&8(HYirePg5u@PSruNs30Gx7i1YwCER{crYR^&OfJa;IuB@ONosCtUP-YY za{2^jO7!e*{cX?eJDxY@8r; zW8atJ+3zl;@Sm>qH@UIM?q|jS>=W#7YAu_)gB31Y9ND;kmOoeaf9*e!%UL;V#2vx} zUUwk!R<>!CBEM2a=7;zgw~E zguTASugG_6SFxo3)|+Pa2irq$E}yy6$m#cutA+FG76xsX-aFYzMMzw9>OIdRD+ Xyc@&=R&`yy_2kb5PImJRrKO4hMS!&; literal 0 HcmV?d00001 diff --git a/src/ios/local-receipt-validation/RMAppReceipt.h b/src/ios/local-receipt-validation/RMAppReceipt.h new file mode 100644 index 00000000..9b45fe33 --- /dev/null +++ b/src/ios/local-receipt-validation/RMAppReceipt.h @@ -0,0 +1,178 @@ +// +// RMAppReceipt.h +// RMStore +// +// Created by Hermes on 10/12/13. +// Copyright (c) 2013 Robot Media. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +/** Represents the app receipt. + */ +__attribute__((availability(ios,introduced=7.0))) +@interface RMAppReceipt : NSObject + +/** The app’s bundle identifier. + + This corresponds to the value of CFBundleIdentifier in the Info.plist file. + */ +@property (nonatomic, strong, readonly) NSString *bundleIdentifier; + +/** The bundle identifier as data, as contained in the receipt. Used to verifiy the receipt's hash. + @see verifyReceiptHash + */ +@property (nonatomic, strong, readonly) NSData *bundleIdentifierData; + +/** The app’s version number. This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString (in OS X) in the Info.plist. + */ +@property (nonatomic, strong, readonly) NSString *appVersion; + +/** An opaque value used as part of the SHA-1 hash. + */ +@property (nonatomic, strong, readonly) NSData *opaqueValue; + +/** A SHA-1 hash, used to validate the receipt. + */ +@property (nonatomic, strong, readonly) NSData *receiptHash; + +/** Array of in-app purchases contained in the receipt. + @see RMAppReceiptIAP + */ +@property (nonatomic, strong, readonly) NSArray *inAppPurchases; + +/** The version of the app that was originally purchased. This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString (in OS X) in the Info.plist file when the purchase was originally made. In the sandbox environment, the value of this field is always “1.0”. + */ +@property (nonatomic, strong, readonly) NSString *originalAppVersion; + +/** The date that the app receipt expires. Only for apps purchased through the Volume Purchase Program. If nil, the receipt does not expire. When validating a receipt, compare this date to the current date to determine whether the receipt is expired. Do not try to use this date to calculate any other information, such as the time remaining before expiration. + */ +@property (nonatomic, strong, readonly) NSDate *expirationDate; + +/** Returns an initialized app receipt from the given data. + @param asn1Data ASN1 data + @return An initialized app receipt from the given data. + */ +- (instancetype)initWithASN1Data:(NSData*)asn1Data NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +/** Returns whether there is an in-app purchase in the receipt for the given product. + @param productIdentifier The identifier of the product. + @return YES if there is an in-app purchase for the given product, NO otherwise. + */ +- (BOOL)containsInAppPurchaseOfProductIdentifier:(NSString*)productIdentifier; + +/** Returns whether the receipt contains an active auto-renewable subscription for the given product identifier and for the given date. + @param productIdentifier The identifier of the auto-renewable subscription. + @param date The date in which the latest auto-renewable subscription should be active. If you are using the current date, you might not want to take it from the device in case the user has changed it. + @return YES if the latest auto-renewable subscription is active for the given date, NO otherwise. + @warning Auto-renewable subscription lapses are possible. If you are checking against the current date, you might want to deduct some time as tolerance. + @warning If this method fails Apple recommends to refresh the receipt and try again once. + */ +- (BOOL)containsActiveAutoRenewableSubscriptionOfProductIdentifier:(NSString *)productIdentifier forDate:(NSDate *)date; + +/** Returns wheter the receipt hash corresponds to the device's GUID by calcuting the expected hash using the GUID, bundleIdentifierData and opaqueValue. + @return YES if the hash contained in the receipt corresponds to the device's GUID, NO otherwise. + */ +- (BOOL)verifyReceiptHash; + +/** + Returns the app receipt contained in the bundle, if any and valid. Extracts the receipt in ASN1 from the PKCS #7 container, and then parses the ASN1 data into a RMAppReceipt instance. If an Apple Root certificate is available, it will also verify that the signature of the receipt is valid. + @return The app receipt contained in the bundle, or nil if there is no receipt or if it is invalid. + @see refreshReceipt + @see setAppleRootCertificateURL: + */ ++ (RMAppReceipt*)bundleReceipt; + +/** + Sets the url of the Apple Root certificate that will be used to verifiy the signature of the bundle receipt. If none is provided, the resource AppleIncRootCertificate.cer will be used. If no certificate is available, no signature verification will be performed. + @param url The url of the Apple Root certificate. + */ ++ (void)setAppleRootCertificateURL:(NSURL*)url; + +@end + +/** Represents an in-app purchase in the app receipt. + */ +@interface RMAppReceiptIAP : NSObject + +/** The number of items purchased. This value corresponds to the quantity property of the SKPayment object stored in the transaction’s payment property. + */ +@property (nonatomic, readonly) NSInteger quantity; + +/** The product identifier of the item that was purchased. This value corresponds to the productIdentifier property of the SKPayment object stored in the transaction’s payment property. + */ +@property (nonatomic, strong, readonly) NSString *productIdentifier; + +/** + The transaction identifier of the item that was purchased. This value corresponds to the transaction’s transactionIdentifier property. + */ +@property (nonatomic, strong, readonly) NSString *transactionIdentifier; + +/** For a transaction that restores a previous transaction, the transaction identifier of the original transaction. Otherwise, identical to the transaction identifier. + + This value corresponds to the original transaction’s transactionIdentifier property. + + All receipts in a chain of renewals for an auto-renewable subscription have the same value for this field. + */ +@property (nonatomic, strong, readonly) NSString *originalTransactionIdentifier; + +/** The date and time that the item was purchased. This value corresponds to the transaction’s transactionDate property. + + For a transaction that restores a previous transaction, the purchase date is the date of the restoration. Use `originalPurchaseDate` to get the date of the original transaction. + + In an auto-renewable subscription receipt, this is always the date when the subscription was purchased or renewed, regardles of whether the transaction has been restored + */ +@property (nonatomic, strong, readonly) NSDate *purchaseDate; + +/** For a transaction that restores a previous transaction, the date of the original transaction. + + This value corresponds to the original transaction’s transactionDate property. + + In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, even if the subscription has been renewed. + */ +@property (nonatomic, strong, readonly) NSDate *originalPurchaseDate; + +/** + The expiration date for the subscription. + + Only present for auto-renewable subscription receipts. + */ +@property (nonatomic, strong, readonly) NSDate *subscriptionExpirationDate; + +/** For a transaction that was canceled by Apple customer support, the date of the cancellation. + */ +@property (nonatomic, strong, readonly) NSDate *cancellationDate; + +/** The primary key for identifying subscription purchases. + */ +@property (nonatomic, readonly) NSInteger webOrderLineItemID; + +/** Returns an initialized in-app purchase from the given data. + @param asn1Data ASN1 data + @return An initialized in-app purchase from the given data. + */ +- (instancetype)initWithASN1Data:(NSData*)asn1Data NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +/** Returns whether the auto renewable subscription is active for the given date. + @param date The date in which the auto-renewable subscription should be active. If you are using the current date, you might not want to take it from the device in case the user has changed it. +@return YES if the auto-renewable subscription is active for the given date, NO otherwise. + @warning Auto-renewable subscription lapses are possible. If you are checking against the current date, you might want to deduct some time as tolerance. + @warning If this method fails Apple recommends to refresh the receipt and try again once. + */ +- (BOOL)isActiveAutoRenewableSubscriptionForDate:(NSDate*)date; + +@end diff --git a/src/ios/local-receipt-validation/RMAppReceipt.m b/src/ios/local-receipt-validation/RMAppReceipt.m new file mode 100644 index 00000000..68aa7147 --- /dev/null +++ b/src/ios/local-receipt-validation/RMAppReceipt.m @@ -0,0 +1,392 @@ +// +// RMAppReceipt.m +// RMStore +// +// Created by Hermes on 10/12/13. +// Copyright (c) 2013 Robot Media. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "RMAppReceipt.h" +#import +#import +#import +#import +#import + +// From https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW1 +NSInteger const RMAppReceiptASN1TypeBundleIdentifier = 2; +NSInteger const RMAppReceiptASN1TypeAppVersion = 3; +NSInteger const RMAppReceiptASN1TypeOpaqueValue = 4; +NSInteger const RMAppReceiptASN1TypeHash = 5; +NSInteger const RMAppReceiptASN1TypeInAppPurchaseReceipt = 17; +NSInteger const RMAppReceiptASN1TypeOriginalAppVersion = 19; +NSInteger const RMAppReceiptASN1TypeExpirationDate = 21; + +NSInteger const RMAppReceiptASN1TypeQuantity = 1701; +NSInteger const RMAppReceiptASN1TypeProductIdentifier = 1702; +NSInteger const RMAppReceiptASN1TypeTransactionIdentifier = 1703; +NSInteger const RMAppReceiptASN1TypePurchaseDate = 1704; +NSInteger const RMAppReceiptASN1TypeOriginalTransactionIdentifier = 1705; +NSInteger const RMAppReceiptASN1TypeOriginalPurchaseDate = 1706; +NSInteger const RMAppReceiptASN1TypeSubscriptionExpirationDate = 1708; +NSInteger const RMAppReceiptASN1TypeWebOrderLineItemID = 1711; +NSInteger const RMAppReceiptASN1TypeCancellationDate = 1712; + +#pragma mark - ANS1 + +static int RMASN1ReadInteger(const uint8_t **pp, long omax) +{ + int tag, asn1Class; + long length; + int value = 0; + ASN1_get_object(pp, &length, &tag, &asn1Class, omax); + if (tag == V_ASN1_INTEGER) + { + for (int i = 0; i < length; i++) + { + value = value * 0x100 + (*pp)[i]; + } + } + *pp += length; + return value; +} + +static NSData* RMASN1ReadOctectString(const uint8_t **pp, long omax) +{ + int tag, asn1Class; + long length; + NSData *data = nil; + ASN1_get_object(pp, &length, &tag, &asn1Class, omax); + if (tag == V_ASN1_OCTET_STRING) + { + data = [NSData dataWithBytes:*pp length:length]; + } + *pp += length; + return data; +} + +static NSString* RMASN1ReadString(const uint8_t **pp, long omax, int expectedTag, NSStringEncoding encoding) +{ + int tag, asn1Class; + long length; + NSString *value = nil; + ASN1_get_object(pp, &length, &tag, &asn1Class, omax); + if (tag == expectedTag) + { + value = [[NSString alloc] initWithBytes:*pp length:length encoding:encoding]; + } + *pp += length; + return value; +} + +static NSString* RMASN1ReadUTF8String(const uint8_t **pp, long omax) +{ + return RMASN1ReadString(pp, omax, V_ASN1_UTF8STRING, NSUTF8StringEncoding); +} + +static NSString* RMASN1ReadIA5SString(const uint8_t **pp, long omax) +{ + return RMASN1ReadString(pp, omax, V_ASN1_IA5STRING, NSASCIIStringEncoding); +} + +static NSURL *_appleRootCertificateURL = nil; + +@implementation RMAppReceipt + +- (instancetype)initWithASN1Data:(NSData*)asn1Data +{ + if (self = [super init]) + { + NSMutableArray *purchases = [NSMutableArray array]; + // Explicit casting to avoid errors when compiling as Objective-C++ + [RMAppReceipt enumerateASN1Attributes:(const uint8_t*)asn1Data.bytes length:asn1Data.length usingBlock:^(NSData *data, int type) { + const uint8_t *s = (const uint8_t*)data.bytes; + const NSUInteger length = data.length; + switch (type) + { + case RMAppReceiptASN1TypeBundleIdentifier: + _bundleIdentifierData = data; + _bundleIdentifier = RMASN1ReadUTF8String(&s, length); + break; + case RMAppReceiptASN1TypeAppVersion: + _appVersion = RMASN1ReadUTF8String(&s, length); + break; + case RMAppReceiptASN1TypeOpaqueValue: + _opaqueValue = data; + break; + case RMAppReceiptASN1TypeHash: + _receiptHash = data; + break; + case RMAppReceiptASN1TypeInAppPurchaseReceipt: + { + RMAppReceiptIAP *purchase = [[RMAppReceiptIAP alloc] initWithASN1Data:data]; + [purchases addObject:purchase]; + break; + } + case RMAppReceiptASN1TypeOriginalAppVersion: + _originalAppVersion = RMASN1ReadUTF8String(&s, length); + break; + case RMAppReceiptASN1TypeExpirationDate: + { + NSString *string = RMASN1ReadIA5SString(&s, length); + _expirationDate = [RMAppReceipt formatRFC3339String:string]; + break; + } + } + }]; + _inAppPurchases = purchases; + } + return self; +} + +- (BOOL)containsInAppPurchaseOfProductIdentifier:(NSString*)productIdentifier +{ + for (RMAppReceiptIAP *purchase in _inAppPurchases) + { + if ([purchase.productIdentifier isEqualToString:productIdentifier]) return YES; + } + return NO; +} + +-(BOOL)containsActiveAutoRenewableSubscriptionOfProductIdentifier:(NSString *)productIdentifier forDate:(NSDate *)date +{ + RMAppReceiptIAP *lastTransaction = nil; + + for (RMAppReceiptIAP *iap in self.inAppPurchases) + { + if (![iap.productIdentifier isEqualToString:productIdentifier]) continue; + + if (!lastTransaction || [iap.subscriptionExpirationDate compare:lastTransaction.subscriptionExpirationDate] == NSOrderedDescending) + { + lastTransaction = iap; + } + } + + return [lastTransaction isActiveAutoRenewableSubscriptionForDate:date]; +} + +- (BOOL)verifyReceiptHash +{ + // TODO: Getting the uuid in Mac is different. See: https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW5 + NSUUID *uuid = [UIDevice currentDevice].identifierForVendor; + unsigned char uuidBytes[16]; + [uuid getUUIDBytes:uuidBytes]; + + // Order taken from: https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW5 + NSMutableData *data = [NSMutableData data]; + [data appendBytes:uuidBytes length:sizeof(uuidBytes)]; + [data appendData:self.opaqueValue]; + [data appendData:self.bundleIdentifierData]; + + NSMutableData *expectedHash = [NSMutableData dataWithLength:SHA_DIGEST_LENGTH]; + SHA1((const uint8_t*)data.bytes, data.length, (uint8_t*)expectedHash.mutableBytes); // Explicit casting to avoid errors when compiling as Objective-C++ + + return [expectedHash isEqualToData:self.receiptHash]; +} + ++ (RMAppReceipt*)bundleReceipt +{ + NSURL *URL = [NSBundle mainBundle].appStoreReceiptURL; + NSString *path = URL.path; + const BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:nil]; + if (!exists) return nil; + + NSData *data = [RMAppReceipt dataFromPCKS7Path:path]; + if (!data) return nil; + + RMAppReceipt *receipt = [[RMAppReceipt alloc] initWithASN1Data:data]; + return receipt; +} + ++ (void)setAppleRootCertificateURL:(NSURL*)url +{ + _appleRootCertificateURL = url; +} + +#pragma mark - Utils + ++ (NSData*)dataFromPCKS7Path:(NSString*)path +{ + const char *cpath = path.stringByStandardizingPath.fileSystemRepresentation; + FILE *fp = fopen(cpath, "rb"); + if (!fp) return nil; + + PKCS7 *p7 = d2i_PKCS7_fp(fp, NULL); + fclose(fp); + + if (!p7) return nil; + + NSData *data; + NSURL *certificateURL = _appleRootCertificateURL ? : [[NSBundle mainBundle] URLForResource:@"AppleIncRootCertificate" withExtension:@"cer"]; + NSData *certificateData = [NSData dataWithContentsOfURL:certificateURL]; + if (!certificateData || [self verifyPCKS7:p7 withCertificateData:certificateData]) + { + struct pkcs7_st *contents = p7->d.sign->contents; + if (PKCS7_type_is_data(contents)) + { + ASN1_OCTET_STRING *octets = contents->d.data; + data = [NSData dataWithBytes:octets->data length:octets->length]; + } + } + PKCS7_free(p7); + return data; +} + ++ (BOOL)verifyPCKS7:(PKCS7*)container withCertificateData:(NSData*)certificateData +{ // Based on: https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW17 + static int verified = 1; + int result = 0; + OpenSSL_add_all_digests(); // Required for PKCS7_verify to work + X509_STORE *store = X509_STORE_new(); + if (store) + { + const uint8_t *certificateBytes = (uint8_t *)(certificateData.bytes); + X509 *certificate = d2i_X509(NULL, &certificateBytes, (long)certificateData.length); + if (certificate) + { + X509_STORE_add_cert(store, certificate); + + BIO *payload = BIO_new(BIO_s_mem()); + result = PKCS7_verify(container, NULL, store, NULL, payload, 0); + BIO_free(payload); + + X509_free(certificate); + } + } + X509_STORE_free(store); + EVP_cleanup(); // Balances OpenSSL_add_all_digests (), perhttp://www.openssl.org/docs/crypto/OpenSSL_add_all_algorithms.html + + return result == verified; +} + +/* + Based on https://github.com/rmaddy/VerifyStoreReceiptiOS + */ ++ (void)enumerateASN1Attributes:(const uint8_t*)p length:(long)tlength usingBlock:(void (^)(NSData *data, int type))block +{ + int type, tag; + long length; + + const uint8_t *end = p + tlength; + + ASN1_get_object(&p, &length, &type, &tag, end - p); + if (type != V_ASN1_SET) return; + + while (p < end) + { + ASN1_get_object(&p, &length, &type, &tag, end - p); + if (type != V_ASN1_SEQUENCE) break; + + const uint8_t *sequenceEnd = p + length; + + const int attributeType = RMASN1ReadInteger(&p, sequenceEnd - p); + RMASN1ReadInteger(&p, sequenceEnd - p); // Consume attribute version + + NSData *data = RMASN1ReadOctectString(&p, sequenceEnd - p); + if (data) + { + block(data, attributeType); + } + + while (p < sequenceEnd) + { // Skip remaining fields + ASN1_get_object(&p, &length, &type, &tag, sequenceEnd - p); + p += length; + } + } +} + ++ (NSDate*)formatRFC3339String:(NSString*)string +{ + static NSDateFormatter *formatter; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + formatter = [[NSDateFormatter alloc] init]; + formatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]; + formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZ"; + }); + NSDate *date = [formatter dateFromString:string]; + return date; +} + +@end + +@implementation RMAppReceiptIAP + +- (instancetype)initWithASN1Data:(NSData*)asn1Data +{ + if (self = [super init]) + { + // Explicit casting to avoid errors when compiling as Objective-C++ + [RMAppReceipt enumerateASN1Attributes:(const uint8_t*)asn1Data.bytes length:asn1Data.length usingBlock:^(NSData *data, int type) { + const uint8_t *p = (const uint8_t*)data.bytes; + const NSUInteger length = data.length; + switch (type) + { + case RMAppReceiptASN1TypeQuantity: + _quantity = RMASN1ReadInteger(&p, length); + break; + case RMAppReceiptASN1TypeProductIdentifier: + _productIdentifier = RMASN1ReadUTF8String(&p, length); + break; + case RMAppReceiptASN1TypeTransactionIdentifier: + _transactionIdentifier = RMASN1ReadUTF8String(&p, length); + break; + case RMAppReceiptASN1TypePurchaseDate: + { + NSString *string = RMASN1ReadIA5SString(&p, length); + _purchaseDate = [RMAppReceipt formatRFC3339String:string]; + break; + } + case RMAppReceiptASN1TypeOriginalTransactionIdentifier: + _originalTransactionIdentifier = RMASN1ReadUTF8String(&p, length); + break; + case RMAppReceiptASN1TypeOriginalPurchaseDate: + { + NSString *string = RMASN1ReadIA5SString(&p, length); + _originalPurchaseDate = [RMAppReceipt formatRFC3339String:string]; + break; + } + case RMAppReceiptASN1TypeSubscriptionExpirationDate: + { + NSString *string = RMASN1ReadIA5SString(&p, length); + _subscriptionExpirationDate = [RMAppReceipt formatRFC3339String:string]; + break; + } + case RMAppReceiptASN1TypeWebOrderLineItemID: + _webOrderLineItemID = RMASN1ReadInteger(&p, length); + break; + case RMAppReceiptASN1TypeCancellationDate: + { + NSString *string = RMASN1ReadIA5SString(&p, length); + _cancellationDate = [RMAppReceipt formatRFC3339String:string]; + break; + } + } + }]; + } + return self; +} + +- (BOOL)isActiveAutoRenewableSubscriptionForDate:(NSDate*)date +{ + NSAssert(self.subscriptionExpirationDate != nil, @"The product %@ is not an auto-renewable subscription.", self.productIdentifier); + + if (self.cancellationDate) return NO; + + return [self.purchaseDate compare:date] != NSOrderedDescending && [date compare:self.subscriptionExpirationDate] != NSOrderedDescending; +} + +@end diff --git a/src/ios/local-receipt-validation/RMStore.h b/src/ios/local-receipt-validation/RMStore.h new file mode 100644 index 00000000..1a7a6622 --- /dev/null +++ b/src/ios/local-receipt-validation/RMStore.h @@ -0,0 +1,308 @@ +// +// RMStore.h +// RMStore +// +// Created by Hermes Pique on 12/6/09. +// Copyright (c) 2013 Robot Media SL (http://www.robotmedia.net) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + +@protocol RMStoreContentDownloader; +@protocol RMStoreReceiptVerifier; +@protocol RMStoreTransactionPersistor; +@protocol RMStoreObserver; + +extern NSString *const RMStoreErrorDomain; +extern NSInteger const RMStoreErrorCodeDownloadCanceled; +extern NSInteger const RMStoreErrorCodeUnknownProductIdentifier; +extern NSInteger const RMStoreErrorCodeUnableToCompleteVerification; + +/** A StoreKit wrapper that adds blocks and notifications, plus optional receipt verification and purchase management. + */ +@interface RMStore : NSObject + +///--------------------------------------------- +/// @name Getting the Store +///--------------------------------------------- + +/** Returns the singleton store instance. + */ ++ (RMStore*)defaultStore; + +#pragma mark StoreKit Wrapper +///--------------------------------------------- +/// @name Calling StoreKit +///--------------------------------------------- + +/** Returns whether the user is allowed to make payments. + */ ++ (BOOL)canMakePayments; + +/** Request payment of the product with the given product identifier. + @param productIdentifier The identifier of the product whose payment will be requested. + */ +- (void)addPayment:(NSString*)productIdentifier; + +/** Request payment of the product with the given product identifier. `successBlock` will be called if the payment is successful, `failureBlock` if it isn't. + @param productIdentifier The identifier of the product whose payment will be requested. + @param successBlock The block to be called if the payment is sucessful. Can be `nil`. + @param failureBlock The block to be called if the payment fails or there isn't any product with the given identifier. Can be `nil`. + */ +- (void)addPayment:(NSString*)productIdentifier + success:(void (^)(SKPaymentTransaction *transaction))successBlock + failure:(void (^)(SKPaymentTransaction *transaction, NSError *error))failureBlock; + +/** Request payment of the product with the given product identifier. `successBlock` will be called if the payment is successful, `failureBlock` if it isn't. + @param productIdentifier The identifier of the product whose payment will be requested. + @param userIdentifier An opaque identifier of the user’s account, if applicable. Can be `nil`. + @param successBlock The block to be called if the payment is sucessful. Can be `nil`. + @param failureBlock The block to be called if the payment fails or there isn't any product with the given identifier. Can be `nil`. + @see [SKPayment applicationUsername] + */ +- (void)addPayment:(NSString*)productIdentifier + user:(NSString*)userIdentifier + success:(void (^)(SKPaymentTransaction *transaction))successBlock + failure:(void (^)(SKPaymentTransaction *transaction, NSError *error))failureBlock __attribute__((availability(ios,introduced=7.0))); + +/** Request localized information about a set of products from the Apple App Store. + @param identifiers The set of product identifiers for the products you wish to retrieve information of. + */ +- (void)requestProducts:(NSSet*)identifiers; + +/** Request localized information about a set of products from the Apple App Store. `successBlock` will be called if the products request is successful, `failureBlock` if it isn't. + @param identifiers The set of product identifiers for the products you wish to retrieve information of. + @param successBlock The block to be called if the products request is sucessful. Can be `nil`. It takes two parameters: `products`, an array of SKProducts, one product for each valid product identifier provided in the original request, and `invalidProductIdentifiers`, an array of product identifiers that were not recognized by the App Store. + @param failureBlock The block to be called if the products request fails. Can be `nil`. + */ +- (void)requestProducts:(NSSet*)identifiers + success:(void (^)(NSArray *products, NSArray *invalidProductIdentifiers))successBlock + failure:(void (^)(NSError *error))failureBlock; + +/** Request to restore previously completed purchases. + */ +- (void)restoreTransactions; + +/** Request to restore previously completed purchases. `successBlock` will be called if the restore transactions request is successful, `failureBlock` if it isn't. + @param successBlock The block to be called if the restore transactions request is sucessful. Can be `nil`. + @param failureBlock The block to be called if the restore transactions request fails. Can be `nil`. + */ +- (void)restoreTransactionsOnSuccess:(void (^)(NSArray *transactions))successBlock + failure:(void (^)(NSError *error))failureBlock; + + +/** Request to restore previously completed purchases of a certain user. `successBlock` will be called if the restore transactions request is successful, `failureBlock` if it isn't. + @param userIdentifier An opaque identifier of the user’s account. + @param successBlock The block to be called if the restore transactions request is sucessful. Can be `nil`. + @param failureBlock The block to be called if the restore transactions request fails. Can be `nil`. + */ +- (void)restoreTransactionsOfUser:(NSString*)userIdentifier + onSuccess:(void (^)(NSArray *transactions))successBlock + failure:(void (^)(NSError *error))failureBlock __attribute__((availability(ios,introduced=7.0))); + +#pragma mark Receipt +///--------------------------------------------- +/// @name Getting the receipt +///--------------------------------------------- + +/** Returns the url of the bundle’s App Store receipt, or nil if the receipt is missing. + If this method returns `nil` you should refresh the receipt by calling `refreshReceipt`. + @see refreshReceipt + */ ++ (NSURL*)receiptURL __attribute__((availability(ios,introduced=7.0))); + +/** Request to refresh the App Store receipt in case the receipt is invalid or missing. + */ +- (void)refreshReceipt __attribute__((availability(ios,introduced=7.0))); + +/** Request to refresh the App Store receipt in case the receipt is invalid or missing. `successBlock` will be called if the refresh receipt request is successful, `failureBlock` if it isn't. + @param successBlock The block to be called if the refresh receipt request is sucessful. Can be `nil`. + @param failureBlock The block to be called if the refresh receipt request fails. Can be `nil`. + */ +- (void)refreshReceiptOnSuccess:(void (^)())successBlock + failure:(void (^)(NSError *error))failureBlock __attribute__((availability(ios,introduced=7.0))); + +///--------------------------------------------- +/// @name Setting Delegates +///--------------------------------------------- + +/** + The content downloader. Required to download product content from your own server. + @discussion Hosted content from Apple’s server (SKDownload) is handled automatically. You don't need to provide a content downloader for it. + */ +@property (nonatomic, weak) id contentDownloader; + +/** The receipt verifier. You can provide your own or use one of the reference implementations provided by the library. + @see RMStoreAppReceiptVerifier + @see RMStoreTransactionReceiptVerifier + */ +@property (nonatomic, weak) id receiptVerifier; + +/** + The transaction persistor. It is recommended to provide your own obfuscator if piracy is a concern. The store will use weak obfuscation via `NSKeyedArchiver` by default. + @see RMStoreKeychainPersistence + @see RMStoreUserDefaultsPersistence + */ +@property (nonatomic, weak) id transactionPersistor; + + +#pragma mark Product management +///--------------------------------------------- +/// @name Managing Products +///--------------------------------------------- + +- (SKProduct*)productForIdentifier:(NSString*)productIdentifier; + ++ (NSString*)localizedPriceOfProduct:(SKProduct*)product; + +#pragma mark Notifications +///--------------------------------------------- +/// @name Managing Observers +///--------------------------------------------- + +/** Adds an observer to the store. + Unlike `SKPaymentQueue`, it is not necessary to set an observer. + @param observer The observer to add. + */ +- (void)addStoreObserver:(id)observer; + +/** Removes an observer from the store. + @param observer The observer to remove. + */ +- (void)removeStoreObserver:(id)observer; + +@end + +@protocol RMStoreContentDownloader + +/** + Downloads the self-hosted content associated to the given transaction and calls the given success or failure block accordingly. Can also call the given progress block to notify progress. + @param transaction The transaction whose associated content will be downloaded. + @param successBlock Called if the download was successful. Must be called in the main queue. + @param progressBlock Called to notify progress. Provides a number between 0.0 and 1.0, inclusive, where 0.0 means no data has been downloaded and 1.0 means all the data has been downloaded. Must be called in the main queue. + @param failureBlock Called if the download failed. Must be called in the main queue. + @discussion Hosted content from Apple’s server (@c SKDownload) is handled automatically by RMStore. + */ +- (void)downloadContentForTransaction:(SKPaymentTransaction*)transaction + success:(void (^)())successBlock + progress:(void (^)(float progress))progressBlock + failure:(void (^)(NSError *error))failureBlock; + +@end + +@protocol RMStoreTransactionPersistor + +- (void)persistTransaction:(SKPaymentTransaction*)transaction; + +@end + +@protocol RMStoreReceiptVerifier + +/** Verifies the given transaction and calls the given success or failure block accordingly. + @param transaction The transaction to be verified. + @param successBlock Called if the transaction passed verification. Must be called in the main queu. + @param failureBlock Called if the transaction failed verification. If verification could not be completed (e.g., due to connection issues), then error must be of code RMStoreErrorCodeUnableToCompleteVerification to prevent RMStore to finish the transaction. Must be called in the main queu. + */ +- (void)verifyTransaction:(SKPaymentTransaction*)transaction + success:(void (^)())successBlock + failure:(void (^)(NSError *error))failureBlock; + +@end + +@protocol RMStoreObserver +@optional + +/** + Tells the observer that a download has been canceled. + @discussion Only for Apple-hosted downloads. + */ +- (void)storeDownloadCanceled:(NSNotification*)notification __attribute__((availability(ios,introduced=6.0))); + +/** + Tells the observer that a download has failed. Use @c storeError to get the cause. + */ +- (void)storeDownloadFailed:(NSNotification*)notification __attribute__((availability(ios,introduced=6.0))); + +/** + Tells the observer that a download has finished. + */ +- (void)storeDownloadFinished:(NSNotification*)notification __attribute__((availability(ios,introduced=6.0))); + +/** + Tells the observer that a download has been paused. + @discussion Only for Apple-hosted downloads. + */ +- (void)storeDownloadPaused:(NSNotification*)notification __attribute__((availability(ios,introduced=6.0))); + +/** + Tells the observer that a download has been updated. Use @c downloadProgress to get the progress. + */ +- (void)storeDownloadUpdated:(NSNotification*)notification __attribute__((availability(ios,introduced=6.0))); + +- (void)storePaymentTransactionDeferred:(NSNotification*)notification __attribute__((availability(ios,introduced=8.0))); +- (void)storePaymentTransactionFailed:(NSNotification*)notification; +- (void)storePaymentTransactionFinished:(NSNotification*)notification; +- (void)storeProductsRequestFailed:(NSNotification*)notification; +- (void)storeProductsRequestFinished:(NSNotification*)notification; +- (void)storeRefreshReceiptFailed:(NSNotification*)notification __attribute__((availability(ios,introduced=7.0))); +- (void)storeRefreshReceiptFinished:(NSNotification*)notification __attribute__((availability(ios,introduced=7.0))); +- (void)storeRestoreTransactionsFailed:(NSNotification*)notification; +- (void)storeRestoreTransactionsFinished:(NSNotification*)notification; + +@end + +/** + Category on NSNotification to recover store data from userInfo without requiring to know the keys. + */ +@interface NSNotification(RMStore) + +/** + A value that indicates how much of the file has been downloaded. + The value of this property is a floating point number between 0.0 and 1.0, inclusive, where 0.0 means no data has been downloaded and 1.0 means all the data has been downloaded. Typically, your app uses the value of this property to update a user interface element, such as a progress bar, that displays how much of the file has been downloaded. + @discussion Corresponds to [SKDownload progress]. + @discussion Used in @c storeDownloadUpdated:. + */ +@property (nonatomic, readonly) float rm_downloadProgress; + +/** Array of product identifiers that were not recognized by the App Store. Used in @c storeProductsRequestFinished:. + */ +@property (nonatomic, readonly) NSArray *rm_invalidProductIdentifiers; + +/** Used in @c storeDownload*:, @c storePaymentTransactionFinished: and @c storePaymentTransactionFailed:. + */ +@property (nonatomic, readonly) NSString *rm_productIdentifier; + +/** Array of SKProducts, one product for each valid product identifier provided in the corresponding request. Used in @c storeProductsRequestFinished:. + */ +@property (nonatomic, readonly) NSArray *rm_products; + +/** Used in @c storeDownload*:. + */ +@property (nonatomic, readonly) SKDownload *rm_storeDownload __attribute__((availability(ios,introduced=6.0))); + +/** Used in @c storeDownloadFailed:, @c storePaymentTransactionFailed:, @c storeProductsRequestFailed:, @c storeRefreshReceiptFailed: and @c storeRestoreTransactionsFailed:. + */ +@property (nonatomic, readonly) NSError *rm_storeError; + +/** Used in @c storeDownload*:, @c storePaymentTransactionFinished: and in @c storePaymentTransactionFailed:. + */ +@property (nonatomic, readonly) SKPaymentTransaction *rm_transaction; + +/** Used in @c storeRestoreTransactionsFinished:. + */ +@property (nonatomic, readonly) NSArray *rm_transactions; + +@end diff --git a/src/ios/local-receipt-validation/RMStore.m b/src/ios/local-receipt-validation/RMStore.m new file mode 100644 index 00000000..ece43bd9 --- /dev/null +++ b/src/ios/local-receipt-validation/RMStore.m @@ -0,0 +1,791 @@ +// +// RMStore.h +// RMStore +// +// Created by Hermes Pique on 12/6/09. +// Copyright (c) 2013 Robot Media SL (http://www.robotmedia.net) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "RMStore.h" + +NSString *const RMStoreErrorDomain = @"net.robotmedia.store"; +NSInteger const RMStoreErrorCodeDownloadCanceled = 300; +NSInteger const RMStoreErrorCodeUnknownProductIdentifier = 100; +NSInteger const RMStoreErrorCodeUnableToCompleteVerification = 200; + +NSString* const RMSKDownloadCanceled = @"RMSKDownloadCanceled"; +NSString* const RMSKDownloadFailed = @"RMSKDownloadFailed"; +NSString* const RMSKDownloadFinished = @"RMSKDownloadFinished"; +NSString* const RMSKDownloadPaused = @"RMSKDownloadPaused"; +NSString* const RMSKDownloadUpdated = @"RMSKDownloadUpdated"; +NSString* const RMSKPaymentTransactionDeferred = @"RMSKPaymentTransactionDeferred"; +NSString* const RMSKPaymentTransactionFailed = @"RMSKPaymentTransactionFailed"; +NSString* const RMSKPaymentTransactionFinished = @"RMSKPaymentTransactionFinished"; +NSString* const RMSKProductsRequestFailed = @"RMSKProductsRequestFailed"; +NSString* const RMSKProductsRequestFinished = @"RMSKProductsRequestFinished"; +NSString* const RMSKRefreshReceiptFailed = @"RMSKRefreshReceiptFailed"; +NSString* const RMSKRefreshReceiptFinished = @"RMSKRefreshReceiptFinished"; +NSString* const RMSKRestoreTransactionsFailed = @"RMSKRestoreTransactionsFailed"; +NSString* const RMSKRestoreTransactionsFinished = @"RMSKRestoreTransactionsFinished"; + +NSString* const RMStoreNotificationInvalidProductIdentifiers = @"invalidProductIdentifiers"; +NSString* const RMStoreNotificationDownloadProgress = @"downloadProgress"; +NSString* const RMStoreNotificationProductIdentifier = @"productIdentifier"; +NSString* const RMStoreNotificationProducts = @"products"; +NSString* const RMStoreNotificationStoreDownload = @"storeDownload"; +NSString* const RMStoreNotificationStoreError = @"storeError"; +NSString* const RMStoreNotificationStoreReceipt = @"storeReceipt"; +NSString* const RMStoreNotificationTransaction = @"transaction"; +NSString* const RMStoreNotificationTransactions = @"transactions"; + +#if DEBUG +#define RMStoreLog(...) NSLog(@"RMStore: %@", [NSString stringWithFormat:__VA_ARGS__]); +#else +#define RMStoreLog(...) +#endif + +typedef void (^RMSKPaymentTransactionFailureBlock)(SKPaymentTransaction *transaction, NSError *error); +typedef void (^RMSKPaymentTransactionSuccessBlock)(SKPaymentTransaction *transaction); +typedef void (^RMSKProductsRequestFailureBlock)(NSError *error); +typedef void (^RMSKProductsRequestSuccessBlock)(NSArray *products, NSArray *invalidIdentifiers); +typedef void (^RMStoreFailureBlock)(NSError *error); +typedef void (^RMStoreSuccessBlock)(); + +@implementation NSNotification(RMStore) + +- (float)rm_downloadProgress +{ + return [self.userInfo[RMStoreNotificationDownloadProgress] floatValue]; +} + +- (NSArray*)rm_invalidProductIdentifiers +{ + return (self.userInfo)[RMStoreNotificationInvalidProductIdentifiers]; +} + +- (NSString*)rm_productIdentifier +{ + return (self.userInfo)[RMStoreNotificationProductIdentifier]; +} + +- (NSArray*)rm_products +{ + return (self.userInfo)[RMStoreNotificationProducts]; +} + +- (SKDownload*)rm_storeDownload +{ + return (self.userInfo)[RMStoreNotificationStoreDownload]; +} + +- (NSError*)rm_storeError +{ + return (self.userInfo)[RMStoreNotificationStoreError]; +} + +- (SKPaymentTransaction*)rm_transaction +{ + return (self.userInfo)[RMStoreNotificationTransaction]; +} + +- (NSArray*)rm_transactions { + return (self.userInfo)[RMStoreNotificationTransactions]; +} + +@end + +@interface RMProductsRequestDelegate : NSObject + +@property (nonatomic, strong) RMSKProductsRequestSuccessBlock successBlock; +@property (nonatomic, strong) RMSKProductsRequestFailureBlock failureBlock; +@property (nonatomic, weak) RMStore *store; + +@end + +@interface RMAddPaymentParameters : NSObject + +@property (nonatomic, strong) RMSKPaymentTransactionSuccessBlock successBlock; +@property (nonatomic, strong) RMSKPaymentTransactionFailureBlock failureBlock; + +@end + +@implementation RMAddPaymentParameters + +@end + +@interface RMStore() + +@end + +@implementation RMStore { + NSMutableDictionary *_addPaymentParameters; // HACK: We use a dictionary of product identifiers because the returned SKPayment is different from the one we add to the queue. Bad Apple. + NSMutableDictionary *_products; + NSMutableSet *_productsRequestDelegates; + + NSMutableArray *_restoredTransactions; + + NSInteger _pendingRestoredTransactionsCount; + BOOL _restoredCompletedTransactionsFinished; + + SKReceiptRefreshRequest *_refreshReceiptRequest; + void (^_refreshReceiptFailureBlock)(NSError* error); + void (^_refreshReceiptSuccessBlock)(); + + void (^_restoreTransactionsFailureBlock)(NSError* error); + void (^_restoreTransactionsSuccessBlock)(NSArray* transactions); +} + +- (instancetype) init +{ + if (self = [super init]) + { + _addPaymentParameters = [NSMutableDictionary dictionary]; + _products = [NSMutableDictionary dictionary]; + _productsRequestDelegates = [NSMutableSet set]; + _restoredTransactions = [NSMutableArray array]; + [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; + } + return self; +} + +- (void)dealloc +{ + [[SKPaymentQueue defaultQueue] removeTransactionObserver:self]; +} + ++ (RMStore *)defaultStore +{ + static RMStore *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[[self class] alloc] init]; + }); + return sharedInstance; +} + +#pragma mark StoreKit wrapper + ++ (BOOL)canMakePayments +{ + return [SKPaymentQueue canMakePayments]; +} + +- (void)addPayment:(NSString*)productIdentifier +{ + [self addPayment:productIdentifier success:nil failure:nil]; +} + +- (void)addPayment:(NSString*)productIdentifier + success:(void (^)(SKPaymentTransaction *transaction))successBlock + failure:(void (^)(SKPaymentTransaction *transaction, NSError *error))failureBlock +{ + [self addPayment:productIdentifier user:nil success:successBlock failure:failureBlock]; +} + +- (void)addPayment:(NSString*)productIdentifier + user:(NSString*)userIdentifier + success:(void (^)(SKPaymentTransaction *transaction))successBlock + failure:(void (^)(SKPaymentTransaction *transaction, NSError *error))failureBlock +{ + SKProduct *product = [self productForIdentifier:productIdentifier]; + if (product == nil) + { + RMStoreLog(@"unknown product id %@", productIdentifier) + if (failureBlock != nil) + { + NSError *error = [NSError errorWithDomain:RMStoreErrorDomain code:RMStoreErrorCodeUnknownProductIdentifier userInfo:@{NSLocalizedDescriptionKey: NSLocalizedStringFromTable(@"Unknown product identifier", @"RMStore", @"Error description")}]; + failureBlock(nil, error); + } + return; + } + SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product]; + if ([payment respondsToSelector:@selector(setApplicationUsername:)]) + { + payment.applicationUsername = userIdentifier; + } + + RMAddPaymentParameters *parameters = [[RMAddPaymentParameters alloc] init]; + parameters.successBlock = successBlock; + parameters.failureBlock = failureBlock; + _addPaymentParameters[productIdentifier] = parameters; + + [[SKPaymentQueue defaultQueue] addPayment:payment]; +} + +- (void)requestProducts:(NSSet*)identifiers +{ + [self requestProducts:identifiers success:nil failure:nil]; +} + +- (void)requestProducts:(NSSet*)identifiers + success:(RMSKProductsRequestSuccessBlock)successBlock + failure:(RMSKProductsRequestFailureBlock)failureBlock +{ + RMProductsRequestDelegate *delegate = [[RMProductsRequestDelegate alloc] init]; + delegate.store = self; + delegate.successBlock = successBlock; + delegate.failureBlock = failureBlock; + [_productsRequestDelegates addObject:delegate]; + + SKProductsRequest *productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:identifiers]; + productsRequest.delegate = delegate; + + [productsRequest start]; +} + +- (void)restoreTransactions +{ + [self restoreTransactionsOnSuccess:nil failure:nil]; +} + +- (void)restoreTransactionsOnSuccess:(void (^)(NSArray *transactions))successBlock + failure:(void (^)(NSError *error))failureBlock +{ + _restoredCompletedTransactionsFinished = NO; + _pendingRestoredTransactionsCount = 0; + _restoredTransactions = [NSMutableArray array]; + _restoreTransactionsSuccessBlock = successBlock; + _restoreTransactionsFailureBlock = failureBlock; + [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; +} + +- (void)restoreTransactionsOfUser:(NSString*)userIdentifier + onSuccess:(void (^)(NSArray *transactions))successBlock + failure:(void (^)(NSError *error))failureBlock +{ + NSAssert([[SKPaymentQueue defaultQueue] respondsToSelector:@selector(restoreCompletedTransactionsWithApplicationUsername:)], @"restoreCompletedTransactionsWithApplicationUsername: not supported in this iOS version. Use restoreTransactionsOnSuccess:failure: instead."); + _restoredCompletedTransactionsFinished = NO; + _pendingRestoredTransactionsCount = 0; + _restoreTransactionsSuccessBlock = successBlock; + _restoreTransactionsFailureBlock = failureBlock; + [[SKPaymentQueue defaultQueue] restoreCompletedTransactionsWithApplicationUsername:userIdentifier]; +} + +#pragma mark Receipt + ++ (NSURL*)receiptURL +{ + // The general best practice of weak linking using the respondsToSelector: method cannot be used here. Prior to iOS 7, the method was implemented as private API, but that implementation called the doesNotRecognizeSelector: method. + NSAssert(floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_6_1, @"appStoreReceiptURL not supported in this iOS version."); + NSURL *url = [NSBundle mainBundle].appStoreReceiptURL; + return url; +} + +- (void)refreshReceipt +{ + [self refreshReceiptOnSuccess:nil failure:nil]; +} + +- (void)refreshReceiptOnSuccess:(RMStoreSuccessBlock)successBlock + failure:(RMStoreFailureBlock)failureBlock +{ + _refreshReceiptFailureBlock = failureBlock; + _refreshReceiptSuccessBlock = successBlock; + _refreshReceiptRequest = [[SKReceiptRefreshRequest alloc] initWithReceiptProperties:@{}]; + _refreshReceiptRequest.delegate = self; + [_refreshReceiptRequest start]; +} + +#pragma mark Product management + +- (SKProduct*)productForIdentifier:(NSString*)productIdentifier +{ + return _products[productIdentifier]; +} + ++ (NSString*)localizedPriceOfProduct:(SKProduct*)product +{ + NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; + numberFormatter.numberStyle = NSNumberFormatterCurrencyStyle; + numberFormatter.locale = product.priceLocale; + NSString *formattedString = [numberFormatter stringFromNumber:product.price]; + return formattedString; +} + +#pragma mark Observers + +- (void)addStoreObserver:(id)observer +{ + [self addStoreObserver:observer selector:@selector(storeDownloadCanceled:) notificationName:RMSKDownloadCanceled]; + [self addStoreObserver:observer selector:@selector(storeDownloadFailed:) notificationName:RMSKDownloadFailed]; + [self addStoreObserver:observer selector:@selector(storeDownloadFinished:) notificationName:RMSKDownloadFinished]; + [self addStoreObserver:observer selector:@selector(storeDownloadPaused:) notificationName:RMSKDownloadPaused]; + [self addStoreObserver:observer selector:@selector(storeDownloadUpdated:) notificationName:RMSKDownloadUpdated]; + [self addStoreObserver:observer selector:@selector(storeProductsRequestFailed:) notificationName:RMSKProductsRequestFailed]; + [self addStoreObserver:observer selector:@selector(storeProductsRequestFinished:) notificationName:RMSKProductsRequestFinished]; + [self addStoreObserver:observer selector:@selector(storePaymentTransactionDeferred:) notificationName:RMSKPaymentTransactionDeferred]; + [self addStoreObserver:observer selector:@selector(storePaymentTransactionFailed:) notificationName:RMSKPaymentTransactionFailed]; + [self addStoreObserver:observer selector:@selector(storePaymentTransactionFinished:) notificationName:RMSKPaymentTransactionFinished]; + [self addStoreObserver:observer selector:@selector(storeRefreshReceiptFailed:) notificationName:RMSKRefreshReceiptFailed]; + [self addStoreObserver:observer selector:@selector(storeRefreshReceiptFinished:) notificationName:RMSKRefreshReceiptFinished]; + [self addStoreObserver:observer selector:@selector(storeRestoreTransactionsFailed:) notificationName:RMSKRestoreTransactionsFailed]; + [self addStoreObserver:observer selector:@selector(storeRestoreTransactionsFinished:) notificationName:RMSKRestoreTransactionsFinished]; +} + +- (void)removeStoreObserver:(id)observer +{ + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKDownloadCanceled object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKDownloadFailed object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKDownloadFinished object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKDownloadPaused object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKDownloadUpdated object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKProductsRequestFailed object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKProductsRequestFinished object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKPaymentTransactionDeferred object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKPaymentTransactionFailed object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKPaymentTransactionFinished object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKRefreshReceiptFailed object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKRefreshReceiptFinished object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKRestoreTransactionsFailed object:self]; + [[NSNotificationCenter defaultCenter] removeObserver:observer name:RMSKRestoreTransactionsFinished object:self]; +} + +// Private + +- (void)addStoreObserver:(id)observer selector:(SEL)aSelector notificationName:(NSString*)notificationName +{ + if ([observer respondsToSelector:aSelector]) + { + [[NSNotificationCenter defaultCenter] addObserver:observer selector:aSelector name:notificationName object:self]; + } +} + +#pragma mark SKPaymentTransactionObserver + +- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions +{ + for (SKPaymentTransaction *transaction in transactions) + { + switch (transaction.transactionState) + { + case SKPaymentTransactionStatePurchased: + [self didPurchaseTransaction:transaction queue:queue]; + break; + case SKPaymentTransactionStateFailed: + [self didFailTransaction:transaction queue:queue error:transaction.error]; + break; + case SKPaymentTransactionStateRestored: + [self didRestoreTransaction:transaction queue:queue]; + break; + case SKPaymentTransactionStateDeferred: + [self didDeferTransaction:transaction]; + break; + default: + break; + } + } +} + +- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue +{ + RMStoreLog(@"restore transactions finished"); + _restoredCompletedTransactionsFinished = YES; + + [self notifyRestoreTransactionFinishedIfApplicableAfterTransaction:nil]; +} + +- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error +{ + RMStoreLog(@"restored transactions failed with error %@", error.debugDescription); + if (_restoreTransactionsFailureBlock != nil) + { + _restoreTransactionsFailureBlock(error); + _restoreTransactionsFailureBlock = nil; + } + NSDictionary *userInfo = nil; + if (error) + { // error might be nil (e.g., on airplane mode) + userInfo = @{RMStoreNotificationStoreError: error}; + } + [[NSNotificationCenter defaultCenter] postNotificationName:RMSKRestoreTransactionsFailed object:self userInfo:userInfo]; +} + +- (void)paymentQueue:(SKPaymentQueue *)queue updatedDownloads:(NSArray *)downloads +{ + for (SKDownload *download in downloads) + { + switch (download.downloadState) + { + case SKDownloadStateActive: + [self didUpdateDownload:download queue:queue]; + break; + case SKDownloadStateCancelled: + [self didCancelDownload:download queue:queue]; + break; + case SKDownloadStateFailed: + [self didFailDownload:download queue:queue]; + break; + case SKDownloadStateFinished: + [self didFinishDownload:download queue:queue]; + break; + case SKDownloadStatePaused: + [self didPauseDownload:download queue:queue]; + break; + case SKDownloadStateWaiting: + // Do nothing + break; + } + } +} + +#pragma mark Download State + +- (void)didCancelDownload:(SKDownload*)download queue:(SKPaymentQueue*)queue +{ + SKPaymentTransaction *transaction = download.transaction; + RMStoreLog(@"download %@ for product %@ canceled", download.contentIdentifier, download.transaction.payment.productIdentifier); + + [self postNotificationWithName:RMSKDownloadCanceled download:download userInfoExtras:nil]; + + NSError *error = [NSError errorWithDomain:RMStoreErrorDomain code:RMStoreErrorCodeDownloadCanceled userInfo:@{NSLocalizedDescriptionKey: NSLocalizedStringFromTable(@"Download canceled", @"RMStore", @"Error description")}]; + + const BOOL hasPendingDownloads = [self.class hasPendingDownloadsInTransaction:transaction]; + if (!hasPendingDownloads) + { + [self didFailTransaction:transaction queue:queue error:error]; + } +} + +- (void)didFailDownload:(SKDownload*)download queue:(SKPaymentQueue*)queue +{ + NSError *error = download.error; + SKPaymentTransaction *transaction = download.transaction; + RMStoreLog(@"download %@ for product %@ failed with error %@", download.contentIdentifier, transaction.payment.productIdentifier, error.debugDescription); + + NSDictionary *extras = error ? @{RMStoreNotificationStoreError : error} : nil; + [self postNotificationWithName:RMSKDownloadFailed download:download userInfoExtras:extras]; + + const BOOL hasPendingDownloads = [self.class hasPendingDownloadsInTransaction:transaction]; + if (!hasPendingDownloads) + { + [self didFailTransaction:transaction queue:queue error:error]; + } +} + +- (void)didFinishDownload:(SKDownload*)download queue:(SKPaymentQueue*)queue +{ + SKPaymentTransaction *transaction = download.transaction; + RMStoreLog(@"download %@ for product %@ finished", download.contentIdentifier, transaction.payment.productIdentifier); + + [self postNotificationWithName:RMSKDownloadFinished download:download userInfoExtras:nil]; + + const BOOL hasPendingDownloads = [self.class hasPendingDownloadsInTransaction:transaction]; + if (!hasPendingDownloads) + { + [self finishTransaction:download.transaction queue:queue]; + } +} + +- (void)didPauseDownload:(SKDownload*)download queue:(SKPaymentQueue*)queue +{ + RMStoreLog(@"download %@ for product %@ paused", download.contentIdentifier, download.transaction.payment.productIdentifier); + [self postNotificationWithName:RMSKDownloadPaused download:download userInfoExtras:nil]; +} + +- (void)didUpdateDownload:(SKDownload*)download queue:(SKPaymentQueue*)queue +{ + RMStoreLog(@"download %@ for product %@ updated", download.contentIdentifier, download.transaction.payment.productIdentifier); + NSDictionary *extras = @{RMStoreNotificationDownloadProgress : @(download.progress)}; + [self postNotificationWithName:RMSKDownloadUpdated download:download userInfoExtras:extras]; +} + ++ (BOOL)hasPendingDownloadsInTransaction:(SKPaymentTransaction*)transaction +{ + for (SKDownload *download in transaction.downloads) + { + switch (download.downloadState) + { + case SKDownloadStateActive: + case SKDownloadStatePaused: + case SKDownloadStateWaiting: + return YES; + case SKDownloadStateCancelled: + case SKDownloadStateFailed: + case SKDownloadStateFinished: + continue; + } + } + return NO; +} + +#pragma mark Transaction State + +- (void)didPurchaseTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue +{ + RMStoreLog(@"transaction purchased with product %@", transaction.payment.productIdentifier); + + if (self.receiptVerifier != nil) + { + [self.receiptVerifier verifyTransaction:transaction success:^{ + [self didVerifyTransaction:transaction queue:queue]; + } failure:^(NSError *error) { + [self didFailTransaction:transaction queue:queue error:error]; + }]; + } + else + { + RMStoreLog(@"WARNING: no receipt verification"); + [self didVerifyTransaction:transaction queue:queue]; + } +} + +- (void)didFailTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue error:(NSError*)error +{ + SKPayment *payment = transaction.payment; + NSString* productIdentifier = payment.productIdentifier; + RMStoreLog(@"transaction failed with product %@ and error %@", productIdentifier, error.debugDescription); + + if (error.code != RMStoreErrorCodeUnableToCompleteVerification) + { // If we were unable to complete the verification we want StoreKit to keep reminding us of the transaction + [queue finishTransaction:transaction]; + } + + RMAddPaymentParameters *parameters = [self popAddPaymentParametersForIdentifier:productIdentifier]; + if (parameters.failureBlock != nil) + { + parameters.failureBlock(transaction, error); + } + + NSDictionary *extras = error ? @{RMStoreNotificationStoreError : error} : nil; + [self postNotificationWithName:RMSKPaymentTransactionFailed transaction:transaction userInfoExtras:extras]; + + if (transaction.transactionState == SKPaymentTransactionStateRestored) + { + [self notifyRestoreTransactionFinishedIfApplicableAfterTransaction:transaction]; + } +} + +- (void)didRestoreTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue +{ + RMStoreLog(@"transaction restored with product %@", transaction.originalTransaction.payment.productIdentifier); + + _pendingRestoredTransactionsCount++; + if (self.receiptVerifier != nil) + { + [self.receiptVerifier verifyTransaction:transaction success:^{ + [self didVerifyTransaction:transaction queue:queue]; + } failure:^(NSError *error) { + [self didFailTransaction:transaction queue:queue error:error]; + }]; + } + else + { + RMStoreLog(@"WARNING: no receipt verification"); + [self didVerifyTransaction:transaction queue:queue]; + } +} + +- (void)didDeferTransaction:(SKPaymentTransaction *)transaction +{ + [self postNotificationWithName:RMSKPaymentTransactionDeferred transaction:transaction userInfoExtras:nil]; +} + +- (void)didVerifyTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue +{ + if (self.contentDownloader != nil) + { + [self.contentDownloader downloadContentForTransaction:transaction success:^{ + [self postNotificationWithName:RMSKDownloadFinished transaction:transaction userInfoExtras:nil]; + [self didDownloadSelfHostedContentForTransaction:transaction queue:queue]; + } progress:^(float progress) { + NSDictionary *extras = @{RMStoreNotificationDownloadProgress : @(progress)}; + [self postNotificationWithName:RMSKDownloadUpdated transaction:transaction userInfoExtras:extras]; + } failure:^(NSError *error) { + NSDictionary *extras = error ? @{RMStoreNotificationStoreError : error} : nil; + [self postNotificationWithName:RMSKDownloadFailed transaction:transaction userInfoExtras:extras]; + [self didFailTransaction:transaction queue:queue error:error]; + }]; + } + else + { + [self didDownloadSelfHostedContentForTransaction:transaction queue:queue]; + } +} + +- (void)didDownloadSelfHostedContentForTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue +{ + NSArray *downloads = [transaction respondsToSelector:@selector(downloads)] ? transaction.downloads : @[]; + if (downloads.count > 0) + { + RMStoreLog(@"starting downloads for product %@ started", transaction.payment.productIdentifier); + [queue startDownloads:downloads]; + } + else + { + [self finishTransaction:transaction queue:queue]; + } +} + +- (void)finishTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue +{ + SKPayment *payment = transaction.payment; + NSString* productIdentifier = payment.productIdentifier; + [queue finishTransaction:transaction]; + [self.transactionPersistor persistTransaction:transaction]; + + RMAddPaymentParameters *wrapper = [self popAddPaymentParametersForIdentifier:productIdentifier]; + if (wrapper.successBlock != nil) + { + wrapper.successBlock(transaction); + } + + [self postNotificationWithName:RMSKPaymentTransactionFinished transaction:transaction userInfoExtras:nil]; + + if (transaction.transactionState == SKPaymentTransactionStateRestored) + { + [self notifyRestoreTransactionFinishedIfApplicableAfterTransaction:transaction]; + } +} + +- (void)notifyRestoreTransactionFinishedIfApplicableAfterTransaction:(SKPaymentTransaction*)transaction +{ + if (transaction != nil) + { + [_restoredTransactions addObject:transaction]; + _pendingRestoredTransactionsCount--; + } + if (_restoredCompletedTransactionsFinished && _pendingRestoredTransactionsCount == 0) + { // Wait until all restored transations have been verified + NSArray *restoredTransactions = [_restoredTransactions copy]; + if (_restoreTransactionsSuccessBlock != nil) + { + _restoreTransactionsSuccessBlock(restoredTransactions); + _restoreTransactionsSuccessBlock = nil; + } + NSDictionary *userInfo = @{ RMStoreNotificationTransactions : restoredTransactions }; + [[NSNotificationCenter defaultCenter] postNotificationName:RMSKRestoreTransactionsFinished object:self userInfo:userInfo]; + } +} + +- (RMAddPaymentParameters*)popAddPaymentParametersForIdentifier:(NSString*)identifier +{ + RMAddPaymentParameters *parameters = _addPaymentParameters[identifier]; + [_addPaymentParameters removeObjectForKey:identifier]; + return parameters; +} + +#pragma mark SKRequestDelegate + +- (void)requestDidFinish:(SKRequest *)request +{ + RMStoreLog(@"refresh receipt finished"); + _refreshReceiptRequest = nil; + if (_refreshReceiptSuccessBlock) + { + _refreshReceiptSuccessBlock(); + _refreshReceiptSuccessBlock = nil; + } + [[NSNotificationCenter defaultCenter] postNotificationName:RMSKRefreshReceiptFinished object:self]; +} + +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error +{ + RMStoreLog(@"refresh receipt failed with error %@", error.debugDescription); + _refreshReceiptRequest = nil; + if (_refreshReceiptFailureBlock) + { + _refreshReceiptFailureBlock(error); + _refreshReceiptFailureBlock = nil; + } + NSDictionary *userInfo = nil; + if (error) + { // error might be nil (e.g., on airplane mode) + userInfo = @{RMStoreNotificationStoreError: error}; + } + [[NSNotificationCenter defaultCenter] postNotificationName:RMSKRefreshReceiptFailed object:self userInfo:userInfo]; +} + +#pragma mark Private + +- (void)addProduct:(SKProduct*)product +{ + _products[product.productIdentifier] = product; +} + +- (void)postNotificationWithName:(NSString*)notificationName download:(SKDownload*)download userInfoExtras:(NSDictionary*)extras +{ + NSMutableDictionary *mutableExtras = extras ? [NSMutableDictionary dictionaryWithDictionary:extras] : [NSMutableDictionary dictionary]; + mutableExtras[RMStoreNotificationStoreDownload] = download; + [self postNotificationWithName:notificationName transaction:download.transaction userInfoExtras:mutableExtras]; +} + +- (void)postNotificationWithName:(NSString*)notificationName transaction:(SKPaymentTransaction*)transaction userInfoExtras:(NSDictionary*)extras +{ + NSString *productIdentifier = transaction.payment.productIdentifier; + NSMutableDictionary *userInfo = [NSMutableDictionary dictionary]; + userInfo[RMStoreNotificationTransaction] = transaction; + userInfo[RMStoreNotificationProductIdentifier] = productIdentifier; + if (extras) + { + [userInfo addEntriesFromDictionary:extras]; + } + [[NSNotificationCenter defaultCenter] postNotificationName:notificationName object:self userInfo:userInfo]; +} + +- (void)removeProductsRequestDelegate:(RMProductsRequestDelegate*)delegate +{ + [_productsRequestDelegates removeObject:delegate]; +} + +@end + +@implementation RMProductsRequestDelegate + +- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response +{ + RMStoreLog(@"products request received response"); + NSArray *products = [NSArray arrayWithArray:response.products]; + NSArray *invalidProductIdentifiers = [NSArray arrayWithArray:response.invalidProductIdentifiers]; + + for (SKProduct *product in products) + { + RMStoreLog(@"received product with id %@", product.productIdentifier); + [self.store addProduct:product]; + } + + [invalidProductIdentifiers enumerateObjectsUsingBlock:^(NSString *invalid, NSUInteger idx, BOOL *stop) { + RMStoreLog(@"invalid product with id %@", invalid); + }]; + + if (self.successBlock) + { + self.successBlock(products, invalidProductIdentifiers); + } + NSDictionary *userInfo = @{RMStoreNotificationProducts: products, RMStoreNotificationInvalidProductIdentifiers: invalidProductIdentifiers}; + [[NSNotificationCenter defaultCenter] postNotificationName:RMSKProductsRequestFinished object:self.store userInfo:userInfo]; +} + +- (void)requestDidFinish:(SKRequest *)request +{ + [self.store removeProductsRequestDelegate:self]; +} + +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error +{ + RMStoreLog(@"products request failed with error %@", error.debugDescription); + if (self.failureBlock) + { + self.failureBlock(error); + } + NSDictionary *userInfo = nil; + if (error) + { // error might be nil (e.g., on airplane mode) + userInfo = @{RMStoreNotificationStoreError: error}; + } + [[NSNotificationCenter defaultCenter] postNotificationName:RMSKProductsRequestFailed object:self.store userInfo:userInfo]; + [self.store removeProductsRequestDelegate:self]; +} + +@end diff --git a/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h b/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h new file mode 100644 index 00000000..204c15f2 --- /dev/null +++ b/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h @@ -0,0 +1,49 @@ +// +// RMStoreAppReceiptVerifier.h +// RMStore +// +// Created by Hermes on 10/15/13. +// Copyright (c) 2013 Robot Media. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "RMStore.h" + +/** + Reference implementation of an app receipt verifier. If security is a concern you might want to avoid using a verifier whose code is open source. + */ +__attribute__((availability(ios,introduced=7.0))) +@interface RMStoreAppReceiptVerifier : NSObject + +/** + The value that will be used to validate the bundle identifier included in the app receipt. Given that it is possible to modify the app bundle in jailbroken devices, setting this value from a hardcoded string might provide better protection. + @return The given value, or the app's bundle identifier by defult. + */ +@property (nonatomic, strong) NSString *bundleIdentifier; + +/** + The value that will be used to validate the bundle version included in the app receipt. Given that it is possible to modify the app bundle in jailbroken devices, setting this value from a hardcoded string might provide better protection. + @return The given value, or the app's bundle version by defult. + */ +@property (nonatomic, strong) NSString *bundleVersion; + +/** + Verifies the app receipt by checking the integrity of the receipt, comparing its bundle identifier and bundle version to the values returned by the corresponding properties and verifying the receipt hash. + @return YES if the receipt is verified, NO otherwise. + @discussion If validation fails in iOS, Apple recommends to refresh the receipt and try again. + */ +- (BOOL)verifyAppReceipt; + +@end diff --git a/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m b/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m new file mode 100644 index 00000000..bcad2226 --- /dev/null +++ b/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m @@ -0,0 +1,127 @@ +// +// RMStoreAppReceiptVerifier.m +// RMStore +// +// Created by Hermes on 10/15/13. +// Copyright (c) 2013 Robot Media. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "RMStoreAppReceiptVerifier.h" +#import "RMAppReceipt.h" + +@implementation RMStoreAppReceiptVerifier + +- (void)verifyTransaction:(SKPaymentTransaction*)transaction + success:(void (^)())successBlock + failure:(void (^)(NSError *error))failureBlock +{ + RMAppReceipt *receipt = [RMAppReceipt bundleReceipt]; + const BOOL verified = [self verifyTransaction:transaction inReceipt:receipt success:successBlock failure:nil]; // failureBlock is nil intentionally. See below. + if (verified) return; + + // Apple recommends to refresh the receipt if validation fails on iOS + [[RMStore defaultStore] refreshReceiptOnSuccess:^{ + RMAppReceipt *receipt = [RMAppReceipt bundleReceipt]; + [self verifyTransaction:transaction inReceipt:receipt success:successBlock failure:failureBlock]; + } failure:^(NSError *error) { + [self failWithBlock:failureBlock error:error]; + }]; +} + +- (BOOL)verifyAppReceipt +{ + RMAppReceipt *receipt = [RMAppReceipt bundleReceipt]; + return [self verifyAppReceipt:receipt]; +} + +#pragma mark - Properties + +- (NSString*)bundleIdentifier +{ + if (!_bundleIdentifier) + { + return [NSBundle mainBundle].bundleIdentifier; + } + return _bundleIdentifier; +} + +- (NSString*)bundleVersion +{ + if (!_bundleVersion) + { +#if TARGET_OS_IPHONE + return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; +#else + return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; +#endif + } + return _bundleVersion; +} + +#pragma mark - Private + +- (BOOL)verifyAppReceipt:(RMAppReceipt*)receipt +{ + if (!receipt) return NO; + + if (![receipt.bundleIdentifier isEqualToString:self.bundleIdentifier]) return NO; + + if (![receipt.appVersion isEqualToString:self.bundleVersion]) return NO; + + if (![receipt verifyReceiptHash]) return NO; + + return YES; +} + +- (BOOL)verifyTransaction:(SKPaymentTransaction*)transaction + inReceipt:(RMAppReceipt*)receipt + success:(void (^)())successBlock + failure:(void (^)(NSError *error))failureBlock +{ + const BOOL receiptVerified = [self verifyAppReceipt:receipt]; + if (!receiptVerified) + { + [self failWithBlock:failureBlock message:NSLocalizedStringFromTable(@"The app receipt failed verification", @"RMStore", nil)]; + return NO; + } + SKPayment *payment = transaction.payment; + const BOOL transactionVerified = [receipt containsInAppPurchaseOfProductIdentifier:payment.productIdentifier]; + if (!transactionVerified) + { + [self failWithBlock:failureBlock message:NSLocalizedStringFromTable(@"The app receipt does not contain the given product", @"RMStore", nil)]; + return NO; + } + if (successBlock) + { + successBlock(); + } + return YES; +} + +- (void)failWithBlock:(void (^)(NSError *error))failureBlock message:(NSString*)message +{ + NSError *error = [NSError errorWithDomain:RMStoreErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey : message}]; + [self failWithBlock:failureBlock error:error]; +} + +- (void)failWithBlock:(void (^)(NSError *error))failureBlock error:(NSError*)error +{ + if (failureBlock) + { + failureBlock(error); + } +} + +@end diff --git a/src/ios/local-receipt-validation/apply-module.js b/src/ios/local-receipt-validation/apply-module.js new file mode 100644 index 00000000..7b92ab8c --- /dev/null +++ b/src/ios/local-receipt-validation/apply-module.js @@ -0,0 +1,242 @@ +#!/usr/bin/env node + +/********** + * Globals + **********/ + +const PLUGIN_NAME = "Purchase plugin"; +const PLUGIN_ID = "cordova-plugin-purchase"; + +const MODULE_NAME = "LOCAL_RECEIPT_VALIDATION"; + +const COMMENT_START = ""; + +// Node dependencies +let path, cwd, fs; + +// External dependencies +let parser; + +// Global vars +let projectPath, modulesPath, pluginNodePath, + projectPackageJsonPath, projectPackageJsonData, + configXmlPath, configXmlData, + pluginXmlPath, pluginXmlText, pluginXmlData; + + +/********************* + * Internal functions + *********************/ + +const run = function (){ + if(shouldModuleBeEnabled()){ + enableModule(); + }else{ + disableModule(); + } + writePluginXmlText(); +}; + + +const handleError = function (errorMsg, errorObj) { + errorMsg = PLUGIN_NAME + " - ERROR: " + errorMsg; + console.error(errorMsg); + console.dir(errorObj); + return errorMsg; + throw errorObj; +}; + +const shouldModuleBeEnabled = function(){ + const pluginVariables = parsePluginVariables(); + return resolveBoolean(pluginVariables[MODULE_NAME]); +}; + +const resolveBoolean = function(value){ + if(typeof value === 'undefined' || value === null) return false; + if(value === true || value === false) return value; + return !isNaN(value) ? parseFloat(value) : /^\s*(true|false)\s*$/i.exec(value) ? RegExp.$1.toLowerCase() === "true" : value; +}; + +const enableModule = function(){ + console.log(`Enabling ${MODULE_NAME} module in ${PLUGIN_ID}`); + const commentedStartRegExp = new RegExp(getModuleStart(MODULE_NAME)+COMMENT_START, "g"); + const commentedEndRegExp = new RegExp(COMMENT_END+getModuleEnd(MODULE_NAME), "g"); + if(pluginXmlText.match(commentedStartRegExp)){ + pluginXmlText = pluginXmlText.replace(commentedStartRegExp, getModuleStart(MODULE_NAME)); + pluginXmlText = pluginXmlText.replace(commentedEndRegExp, getModuleEnd(MODULE_NAME)); + } + + const commentedStubStart = getModuleStubStart(MODULE_NAME)+COMMENT_START; + const commentedStubEnd = COMMENT_END+getModuleStubEnd(MODULE_NAME); + if(!pluginXmlText.match(commentedStubStart)){ + pluginXmlText = pluginXmlText.replace(new RegExp(getModuleStubStart(MODULE_NAME), "g"), commentedStubStart); + pluginXmlText = pluginXmlText.replace(new RegExp(getModuleStubEnd(MODULE_NAME), "g"), commentedStubEnd); + } +}; + +const disableModule = function(MODULE_NAME){ + console.log(`Disabling ${MODULE_NAME} module in ${PLUGIN_ID}`); + const commentedStart = getModuleStart(MODULE_NAME)+COMMENT_START; + const commentedEnd = COMMENT_END+getModuleEnd(MODULE_NAME); + if(!pluginXmlText.match(commentedStart)){ + pluginXmlText = pluginXmlText.replace(new RegExp(getModuleStart(MODULE_NAME), "g"), commentedStart); + pluginXmlText = pluginXmlText.replace(new RegExp(getModuleEnd(MODULE_NAME), "g"), commentedEnd); + } + + const commentedStubStartRegExp = new RegExp(getModuleStubStart(MODULE_NAME)+COMMENT_START, "g"); + const commentedStubEndRegExp = new RegExp(COMMENT_END+getModuleStubEnd(MODULE_NAME), "g"); + if(pluginXmlText.match(commentedStubStartRegExp)){ + pluginXmlText = pluginXmlText.replace(commentedStubStartRegExp, getModuleStubStart(MODULE_NAME)); + pluginXmlText = pluginXmlText.replace(commentedStubEndRegExp, getModuleStubEnd(MODULE_NAME)); + } +}; + +const getModuleStart = function(){ + return ""; +}; + +const getModuleEnd = function(){ + return ""; +}; + +const getModuleStubStart = function(){ + return ""; +}; + +const getModuleStubEnd = function(){ + return ""; +}; + +const parsePluginVariables = function(){ + + const pluginVariables = {}; + // Parse plugin.xml + const plugin = parsePluginXml(); + let prefs = []; + if(plugin.plugin.preference){ + prefs = prefs.concat(plugin.plugin.preference); + } + if(typeof plugin.plugin.platform.length === 'undefined') plugin.plugin.platform = [plugin.plugin.platform]; + plugin.plugin.platform.forEach(function(platform){ + if(platform.preference){ + prefs = prefs.concat(platform.preference); + } + }); + prefs.forEach(function(pref){ + if (pref._attributes){ + pluginVariables[pref._attributes.name] = pref._attributes.default; + } + }); + + // Parse config.xml + const config = parseConfigXml(); + if(config) { + (config.widget.plugin ? [].concat(config.widget.plugin) : []).forEach(function (plugin) { + (plugin.variable ? [].concat(plugin.variable) : []).forEach(function (variable) { + if ((plugin._attributes.name === PLUGIN_ID || plugin._attributes.id === PLUGIN_ID) && variable._attributes.name && variable._attributes.value) { + pluginVariables[variable._attributes.name] = variable._attributes.value; + } + }); + }); + } + + // Parse package.json + const packageJSON = parsePackageJson(); + if(packageJSON && packageJSON.cordova && packageJSON.cordova.plugins){ + for(const pluginId in packageJSON.cordova.plugins){ + if(pluginId === PLUGIN_ID){ + for(const varName in packageJSON.cordova.plugins[pluginId]){ + const varValue = packageJSON.cordova.plugins[pluginId][varName]; + pluginVariables[varName] = varValue; + } + } + } + } + + return pluginVariables; +}; + +const parsePackageJson = function(){ + if(projectPackageJsonData) return projectPackageJsonData; + try{ + projectPackageJsonData = JSON.parse(fs.readFileSync(projectPackageJsonPath)); + return projectPackageJsonData; + }catch(e){ + console.warn("Failed to parse package.json: " + e.message); + } +}; + +const parseConfigXml = function(){ + if(configXmlData) return configXmlData; + try{ + data = parseXmlFileToJson(configXmlPath); + configXmlData = data.xml; + return configXmlData; + }catch (e){ + console.warn("Failed to parse config.xml: " + e.message); + } +}; + +const parsePluginXml = function(){ + if(pluginXmlData) return pluginXmlData; + const data = parseXmlFileToJson(pluginXmlPath); + pluginXmlText = data.text; + pluginXmlData = data.xml; + return pluginXmlData; +}; + +const parseXmlFileToJson = function(filepath, parseOpts){ + parseOpts = parseOpts || {compact: true}; + const text = fs.readFileSync(path.resolve(filepath), 'utf-8'); + const xml = JSON.parse(parser.xml2json(text, parseOpts)); + return {text, xml}; +}; + +const writePluginXmlText = function(){ + fs.writeFileSync(pluginXmlPath, pluginXmlText, 'utf-8'); +}; + +/********** + * Main + **********/ +const main = function() { + try{ + fs = require('fs'); + path = require('path'); + + cwd = path.resolve(); + pluginNodePath = cwd; + + modulesPath = path.resolve(pluginNodePath, ".."); + projectPath = path.resolve(modulesPath, ".."); + + try{ + parser = require("xml-js"); + }catch (e){ + console.warn("Failed to load 'xml-js' module. Trying using modulesPath: "+modulesPath); + } + if(!parser){ + try{ + parser = require(path.resolve(modulesPath, "xml-js")); + }catch (e){ + console.warn("Failed to load 'xml-js' module using modulesPath"); + } + } + if(!parser){ + throw new Error("Failed to load 'xml-js' module"); + } + }catch(e){ + handleError("Failed to load dependencies for "+PLUGIN_ID+"': " + e.message, e); + } + + try{ + projectPackageJsonPath = path.join(projectPath, 'package.json'); + configXmlPath = path.join(projectPath, 'config.xml'); + pluginXmlPath = path.join(pluginNodePath, "plugin.xml"); + run(); + }catch(e){ + handleError(e.message, e); + } +}; +main(); diff --git a/src/ios/local-receipt-validation/stub/RMAppReceipt.h b/src/ios/local-receipt-validation/stub/RMAppReceipt.h new file mode 100644 index 00000000..3db4db30 --- /dev/null +++ b/src/ios/local-receipt-validation/stub/RMAppReceipt.h @@ -0,0 +1,125 @@ +// +// RMAppReceipt.h +// +// Stub: mocks out public properties and methods + +#import + +/** Represents the app receipt. + */ +@interface RMAppReceipt : NSObject + +/** The app’s bundle identifier. + + This corresponds to the value of CFBundleIdentifier in the Info.plist file. + */ +@property (nonatomic, strong, readonly) NSString *bundleIdentifier; + +/** The bundle identifier as data, as contained in the receipt. Used to verifiy the receipt's hash. + @see verifyReceiptHash + */ +@property (nonatomic, strong, readonly) NSData *bundleIdentifierData; + +/** The app’s version number. This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString (in OS X) in the Info.plist. + */ +@property (nonatomic, strong, readonly) NSString *appVersion; + +/** An opaque value used as part of the SHA-1 hash. + */ +@property (nonatomic, strong, readonly) NSData *opaqueValue; + +/** A SHA-1 hash, used to validate the receipt. + */ +@property (nonatomic, strong, readonly) NSData *receiptHash; + +/** Array of in-app purchases contained in the receipt. + @see RMAppReceiptIAP + */ +@property (nonatomic, strong, readonly) NSArray *inAppPurchases; + +/** The version of the app that was originally purchased. This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString (in OS X) in the Info.plist file when the purchase was originally made. In the sandbox environment, the value of this field is always “1.0”. + */ +@property (nonatomic, strong, readonly) NSString *originalAppVersion; + +/** The date that the app receipt expires. Only for apps purchased through the Volume Purchase Program. If nil, the receipt does not expire. When validating a receipt, compare this date to the current date to determine whether the receipt is expired. Do not try to use this date to calculate any other information, such as the time remaining before expiration. + */ +@property (nonatomic, strong, readonly) NSDate *expirationDate; + + +- (instancetype)init NS_UNAVAILABLE; + + +/** + Returns the app receipt contained in the bundle, if any and valid. Extracts the receipt in ASN1 from the PKCS #7 container, and then parses the ASN1 data into a RMAppReceipt instance. If an Apple Root certificate is available, it will also verify that the signature of the receipt is valid. + @return The app receipt contained in the bundle, or nil if there is no receipt or if it is invalid. + @see refreshReceipt + @see setAppleRootCertificateURL: + */ ++ (RMAppReceipt*)bundleReceipt; + +@end + +/** Represents an in-app purchase in the app receipt. + */ +@interface RMAppReceiptIAP : NSObject + +/** The number of items purchased. This value corresponds to the quantity property of the SKPayment object stored in the transaction’s payment property. + */ +@property (nonatomic, readonly) NSInteger quantity; + +/** The product identifier of the item that was purchased. This value corresponds to the productIdentifier property of the SKPayment object stored in the transaction’s payment property. + */ +@property (nonatomic, strong, readonly) NSString *productIdentifier; + +/** + The transaction identifier of the item that was purchased. This value corresponds to the transaction’s transactionIdentifier property. + */ +@property (nonatomic, strong, readonly) NSString *transactionIdentifier; + +/** For a transaction that restores a previous transaction, the transaction identifier of the original transaction. Otherwise, identical to the transaction identifier. + + This value corresponds to the original transaction’s transactionIdentifier property. + + All receipts in a chain of renewals for an auto-renewable subscription have the same value for this field. + */ +@property (nonatomic, strong, readonly) NSString *originalTransactionIdentifier; + +/** The date and time that the item was purchased. This value corresponds to the transaction’s transactionDate property. + + For a transaction that restores a previous transaction, the purchase date is the date of the restoration. Use `originalPurchaseDate` to get the date of the original transaction. + + In an auto-renewable subscription receipt, this is always the date when the subscription was purchased or renewed, regardles of whether the transaction has been restored + */ +@property (nonatomic, strong, readonly) NSDate *purchaseDate; + +/** For a transaction that restores a previous transaction, the date of the original transaction. + + This value corresponds to the original transaction’s transactionDate property. + + In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, even if the subscription has been renewed. + */ +@property (nonatomic, strong, readonly) NSDate *originalPurchaseDate; + +/** + The expiration date for the subscription. + + Only present for auto-renewable subscription receipts. + */ +@property (nonatomic, strong, readonly) NSDate *subscriptionExpirationDate; + +/** For a transaction that was canceled by Apple customer support, the date of the cancellation. + */ +@property (nonatomic, strong, readonly) NSDate *cancellationDate; + +/** The primary key for identifying subscription purchases. + */ +@property (nonatomic, readonly) NSInteger webOrderLineItemID; + +/** Returns an initialized in-app purchase from the given data. + @param asn1Data ASN1 data + @return An initialized in-app purchase from the given data. + */ +- (instancetype)init NS_UNAVAILABLE; + + +@end diff --git a/src/ios/local-receipt-validation/stub/RMAppReceipt.m b/src/ios/local-receipt-validation/stub/RMAppReceipt.m new file mode 100644 index 00000000..ab7e0515 --- /dev/null +++ b/src/ios/local-receipt-validation/stub/RMAppReceipt.m @@ -0,0 +1,25 @@ +// +// RMAppReceipt.m +// +// Stub: mocks out public properties and methods +// + +#import "RMAppReceipt.h" + + +@implementation RMAppReceipt + + + ++ (RMAppReceipt*)bundleReceipt +{ + return nil; +} + + +@end + +@implementation RMAppReceiptIAP + + +@end diff --git a/src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.h b/src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.h new file mode 100644 index 00000000..9d426ef6 --- /dev/null +++ b/src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.h @@ -0,0 +1,33 @@ +// +// RMStoreAppReceiptVerifier.h +// +// Stub: mocks out public properties and methods +// + +#import + +/** + Reference implementation of an app receipt verifier. If security is a concern you might want to avoid using a verifier whose code is open source. + */ +@interface RMStoreAppReceiptVerifier : NSObject + +/** + The value that will be used to validate the bundle identifier included in the app receipt. Given that it is possible to modify the app bundle in jailbroken devices, setting this value from a hardcoded string might provide better protection. + @return The given value, or the app's bundle identifier by defult. + */ +@property (nonatomic, strong) NSString *bundleIdentifier; + +/** + The value that will be used to validate the bundle version included in the app receipt. Given that it is possible to modify the app bundle in jailbroken devices, setting this value from a hardcoded string might provide better protection. + @return The given value, or the app's bundle version by defult. + */ +@property (nonatomic, strong) NSString *bundleVersion; + +/** + Verifies the app receipt by checking the integrity of the receipt, comparing its bundle identifier and bundle version to the values returned by the corresponding properties and verifying the receipt hash. + @return YES if the receipt is verified, NO otherwise. + @discussion If validation fails in iOS, Apple recommends to refresh the receipt and try again. + */ +- (BOOL)verifyAppReceipt; + +@end diff --git a/src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.m b/src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.m new file mode 100644 index 00000000..41bda29b --- /dev/null +++ b/src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.m @@ -0,0 +1,44 @@ +// +// RMStoreAppReceiptVerifier.m +// +// Stub: mocks out public properties and methods +// + +#import "RMStoreAppReceiptVerifier.h" +#import "RMAppReceipt.h" + +@implementation RMStoreAppReceiptVerifier + + +- (BOOL)verifyAppReceipt +{ + return YES; +} + +#pragma mark - Properties + +- (NSString*)bundleIdentifier +{ + if (!_bundleIdentifier) + { + return [NSBundle mainBundle].bundleIdentifier; + } + return _bundleIdentifier; +} + +- (NSString*)bundleVersion +{ + if (!_bundleVersion) + { +#if TARGET_OS_IPHONE + return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; +#else + return [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; +#endif + } + return _bundleVersion; +} + + + +@end