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 22, 2023
1 parent 2e57c81 commit c610581
Show file tree
Hide file tree
Showing 17 changed files with 2,434 additions and 30 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**.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>",
"license": "MIT",
Expand All @@ -49,7 +50,8 @@
"typedoc": "^0.23.15",
"typedoc-github-wiki-theme": "^1.0.1",
"typedoc-plugin-markdown": "^3.13.6",
"typescript": "^4.7.4"
"typescript": "^4.7.4",
"xml-js": "^1.6.11"
},
"main": "./www/store.js",
"types": "./www/store.d.ts",
Expand Down
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
4 changes: 4 additions & 0 deletions src/ios/InAppPurchase.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,20 @@
#import "SKProduct+LocalizedPrice.h"
#import "SKProductDiscount+LocalizedPrice.h"
#import "FileUtility.h"
#import "RMStoreAppReceiptVerifier.h"

@interface InAppPurchase : CDVPlugin <SKPaymentTransactionObserver> {
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;

Expand All @@ -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;
Expand Down
106 changes: 79 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 @@ -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];
Expand Down Expand Up @@ -245,13 +250,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 +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];
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Binary file not shown.
Loading

0 comments on commit c610581

Please sign in to comment.