Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(ios) On-device receipt validation/parsing #1184

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,18 @@
"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",
"bugs": {
"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",
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