Skip to content

Commit

Permalink
(ios) feat: Make on-device (local) receipt validation an optional fea…
Browse files Browse the repository at this point in the history
…ture (disabled by default) which is enabled via a plugin variable
  • Loading branch information
dpa99c committed Nov 20, 2023
1 parent 2e57c81 commit cbb7082
Show file tree
Hide file tree
Showing 15 changed files with 2,412 additions and 28 deletions.
16 changes: 16 additions & 0 deletions doc/ios.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
32 changes: 31 additions & 1 deletion plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,37 @@ SOFTWARE.
<source-file src="src/ios/FileUtility.m" />

<framework src="StoreKit.framework" />
</platform>

<preference name="LOCAL_RECEIPT_VALIDATION" default="false" />

<!--BEGIN_MODULE LOCAL_RECEIPT_VALIDATION--><!--
<header-file src="src/ios/local-receipt-validation/RMAppReceipt.h" />
<source-file src="src/ios/local-receipt-validation/RMAppReceipt.m" />
<header-file src="src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h" />
<source-file src="src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m" />
<header-file src="src/ios/local-receipt-validation/RMStore.h" />
<source-file src="src/ios/local-receipt-validation/RMStore.m" />
<resource-file src="src/ios/local-receipt-validation/AppleIncRootCertificate.cer" />
<podspec>
<config>
<source url="https://cdn.cocoapods.org/"/>
</config>
<pods>
<pod name="OpenSSL-Universal" spec="1.1.1100"/>
</pods>
</podspec>
--><!--END_MODULE LOCAL_RECEIPT_VALIDATION-->

<!--BEGIN_MODULE_STUB LOCAL_RECEIPT_VALIDATION-->
<header-file src="src/ios/local-receipt-validation/stub/RMAppReceipt.h" />
<source-file src="src/ios/local-receipt-validation/stub/RMAppReceipt.m" />
<header-file src="src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.h" />
<source-file src="src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.m" />
<!--END_MODULE_STUB LOCAL_RECEIPT_VALIDATION-->

</platform>

<!-- osx -->
<platform name="osx">
Expand Down
102 changes: 75 additions & 27 deletions src/ios/InAppPurchase.m
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//

#import "InAppPurchase.h"
#import "RMAppReceipt.h"
#include <stdio.h>
#include <stdlib.h>

Expand Down Expand Up @@ -245,13 +246,19 @@ @implementation InAppPurchase
@synthesize retainer;
@synthesize unfinishedTransactions;
@synthesize pendingTransactionUpdates;
@synthesize verifier;

// Initialize the plugin state
-(void) pluginInitialize {
self.retainer = [[NSMutableDictionary alloc] init];
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.");
Expand Down Expand Up @@ -691,19 +698,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];
Expand All @@ -730,6 +725,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;
Expand Down Expand Up @@ -795,20 +856,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");
Expand Down
Binary file not shown.
178 changes: 178 additions & 0 deletions src/ios/local-receipt-validation/RMAppReceipt.h
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

/** 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
Loading

0 comments on commit cbb7082

Please sign in to comment.