diff --git a/README.md b/README.md index 986a6c00..a42a0ee3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,17 @@ It lets you handle in-app purchases on many platforms with a single codebase. cordova plugin add cordova-plugin-purchase ``` +#### Plugin variables +When installing the plugin, you can set plugin variables at plugin installation time. +Note: in order to change the value of a plugin variable for an installed plugin, you must uninstall and reinstall the plugin with the new value. + +Currently the following plugin variables are supported: + +- `LOCAL_RECEIPT_VALIDATION` - whether to perform [local (on-device) receipt validation](doc/ios.md#on-device-validation) + - Defaults to `false` if not specified. + - To enable, set the value to `true` when installing the plugin: + - `cordova plugin add cordova-plugin-purchase --variable LOCAL_RECEIPT_VALIDATION=true` + ### Install the plugin (PhoneGap) Add the following to your `config.xml` file: diff --git a/doc/ios.md b/doc/ios.md index 1ee84ba9..0189085d 100644 --- a/doc/ios.md +++ b/doc/ios.md @@ -168,3 +168,18 @@ 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 using a plugin variable](../README.md#plugin-variables). + - Note: if the plugin is already installed, you'll need to uninstall and re-install it with the new plugin variable value. +- 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 417b3fe1..ae28aafa 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ ], "scripts": { "test": "make test-js", - "coverage": "make test-js-coverage" + "coverage": "make test-js-coverage", + "postinstall": "node ./src/ios/local-receipt-validation/apply-module.js" }, "author": "Jean-Christophe Hoelt ", "license": "MIT", @@ -38,6 +39,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": { "acorn": "^6.3.0", "babel-eslint": "^10.0.3", diff --git a/plugin.xml b/plugin.xml index 0de76b84..5dcbd9a0 100644 --- a/plugin.xml +++ b/plugin.xml @@ -65,17 +65,22 @@ SOFTWARE. - - - - - - - + + + + + + + + + + + + - + diff --git a/src/ios/AppleIncRootCertificate.cer b/src/ios/local-receipt-validation/AppleIncRootCertificate.cer similarity index 100% rename from src/ios/AppleIncRootCertificate.cer rename to src/ios/local-receipt-validation/AppleIncRootCertificate.cer diff --git a/src/ios/RMAppReceipt.h b/src/ios/local-receipt-validation/RMAppReceipt.h similarity index 100% rename from src/ios/RMAppReceipt.h rename to src/ios/local-receipt-validation/RMAppReceipt.h diff --git a/src/ios/RMAppReceipt.m b/src/ios/local-receipt-validation/RMAppReceipt.m similarity index 100% rename from src/ios/RMAppReceipt.m rename to src/ios/local-receipt-validation/RMAppReceipt.m diff --git a/src/ios/RMStore.h b/src/ios/local-receipt-validation/RMStore.h similarity index 100% rename from src/ios/RMStore.h rename to src/ios/local-receipt-validation/RMStore.h diff --git a/src/ios/RMStore.m b/src/ios/local-receipt-validation/RMStore.m similarity index 100% rename from src/ios/RMStore.m rename to src/ios/local-receipt-validation/RMStore.m diff --git a/src/ios/RMStoreAppReceiptVerifier.h b/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h similarity index 100% rename from src/ios/RMStoreAppReceiptVerifier.h rename to src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h diff --git a/src/ios/RMStoreAppReceiptVerifier.m b/src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m similarity index 100% rename from src/ios/RMStoreAppReceiptVerifier.m rename to src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m 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..afc861ac --- /dev/null +++ b/src/ios/local-receipt-validation/apply-module.js @@ -0,0 +1,218 @@ +#!/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(); + (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.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; + projectPackageJsonData = JSON.parse(fs.readFileSync(projectPackageJsonPath)); + return projectPackageJsonData; +}; + +const parseConfigXml = function(){ + if(configXmlData) return configXmlData; + data = parseXmlFileToJson(configXmlPath); + configXmlData = data.xml; + return configXmlData; +}; + +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, ".."); + + parser = require(path.resolve(modulesPath, "xml-js")); + }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 100755 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 100755 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 100755 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 100755 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