diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index 0bbe38cb..f5e859a0 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -270,30 +270,26 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN String signaturePath = CodePushUpdateUtils.getSignatureFilePath(newUpdateFolderPath); boolean isSignatureAppearedInBundle = FileUtils.fileAtPathExists(signaturePath); + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + throw new CodePushInvalidUpdateException( + "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + + "Possible reasons, why that might happen: \n" + + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + + "2. You've been released CodePush bundle update without providing --privateKeyPath option." + ); + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CodePushUtils.log( + "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + + "Please ensure that public key is properly configured within your application." + ); + } + + CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); - } else { - throw new CodePushInvalidUpdateException( - "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + - "Possible reasons, why that might happen: \n" + - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + - "2. You've been released CodePush bundle update without providing --privateKeyPath option." - ); - } - } else { - if (isSignatureAppearedInBundle) { - CodePushUtils.log( - "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + - "Please ensure that public key is properly configured within your application." - ); - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } else { - if (isDiffUpdate) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } - } + CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); } CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath); diff --git a/code-push-plugin-testing-framework/script/serverUtil.js b/code-push-plugin-testing-framework/script/serverUtil.js index 30ff5ef3..74333ade 100644 --- a/code-push-plugin-testing-framework/script/serverUtil.js +++ b/code-push-plugin-testing-framework/script/serverUtil.js @@ -22,6 +22,7 @@ function setupServer(targetPlatform) { }); app.get("/v0.1/public/codepush/update_check", function (req, res) { exports.updateCheckCallback && exports.updateCheckCallback(req); + applyKnownPackageHash(); res.send(exports.updateResponse); console.log("Update check called from the app."); console.log("Request: " + JSON.stringify(req.query)); @@ -53,6 +54,40 @@ function setupServer(targetPlatform) { exports.server = app.listen(+targetPlatform.getServerUrl().match(serverPortRegEx)[1]); } exports.setupServer = setupServer; +/** + * The real content hash of each update archive built during this run, keyed by archive path. + * Populated by setPackageHashForPath (called once when an archive is built) and applied to + * exports.updateResponse both immediately (see the updatePackagePath setter below) and again + * right before every update_check response - a test may set updateResponse and updatePackagePath + * in either order, or point updatePackagePath at an already-built archive again (e.g. after an + * app restart) without rebuilding it or recomputing its hash. + */ +var packageHashesByPath = {}; +var _updatePackagePath; +Object.defineProperty(exports, "updatePackagePath", { + enumerable: true, + configurable: true, + get: function () { return _updatePackagePath; }, + set: function (value) { + _updatePackagePath = value; + applyKnownPackageHash(); + } +}); +/** + * Records the real content hash for an update archive, so that any update_check response + * pointing exports.updatePackagePath at this archive gets the matching package_hash instead + * of the arbitrary/random one createUpdateResponse() fills in by default. + */ +function setPackageHashForPath(archivePath, packageHash) { + packageHashesByPath[archivePath] = packageHash; +} +exports.setPackageHashForPath = setPackageHashForPath; +function applyKnownPackageHash() { + var knownHash = _updatePackagePath && packageHashesByPath[_updatePackagePath]; + if (knownHash && exports.updateResponse && exports.updateResponse.update_info) { + exports.updateResponse.update_info.package_hash = knownHash; + } +} /** * Closes the server. */ diff --git a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts index 0692e505..8baaae8c 100644 --- a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts +++ b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts @@ -290,6 +290,11 @@ declare module 'code-push-plugin-testing-framework/script/serverUtil' { * Closes the server. */ export function cleanupServer(): void; + /** + * Records the real content hash for an update archive at archivePath, so any future + * update_check response pointing updatePackagePath at it gets a matching package_hash. + */ + export function setPackageHashForPath(archivePath: string, packageHash: string): void; /** * Class used to mock the codePush.checkForUpdate() response from the server. */ diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index 992b651f..861375a7 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -243,69 +243,50 @@ + (void)downloadPackage:(NSDictionary *)updatePackage NSString *signatureFilePath = [CodePushUpdateUtils getSignatureFilePath:newUpdateFolderPath]; BOOL isSignatureAppearedInBundle = [[NSFileManager defaultManager] fileExistsAtPath:signatureFilePath]; + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + error = [CodePushErrorUtils errorWithMessage: + @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ + "Possible reasons, why that might happen: \n" \ + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ + "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; + failCallback(error); + return; + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ + " because there is no public key configured. " \ + "Please ensure that public key is properly configured within your application."); + } + + if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath + expectedHash:newUpdateHash + error:&error]) { + CPLog(@"The update contents failed the data integrity check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; + } + + failCallback(error); + return; + } else { + CPLog(@"The update contents succeeded the data integrity check."); + } + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); + BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath + expectedHash:newUpdateHash + withPublicKey:publicKey + error:&error]; + if (!isSignatureValid) { + CPLog(@"The update contents failed code signing check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; } - BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath - expectedHash:newUpdateHash - withPublicKey:publicKey - error:&error]; - if (!isSignatureValid) { - CPLog(@"The update contents failed code signing check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; - } - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the code signing check."); - } - } else { - error = [CodePushErrorUtils errorWithMessage: - @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ - "Possible reasons, why that might happen: \n" \ - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ - "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; failCallback(error); return; - } - - } else { - BOOL needToVerifyHash; - if (isSignatureAppearedInBundle) { - CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ - " because there is no public key configured. " \ - "Please ensure that public key is properly configured within your application."); - needToVerifyHash = true; } else { - needToVerifyHash = isDiffUpdate; - } - if(needToVerifyHash){ - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); - } + CPLog(@"The update contents succeeded the code signing check."); } } } else { diff --git a/test/test.ts b/test/test.ts index c117014f..bad5286c 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,6 +2,7 @@ import assert = require("assert"); import childProcess = require("child_process"); +import crypto = require("crypto"); import fs = require("fs"); import mkdirp = require("mkdirp"); import os = require("os"); @@ -68,6 +69,48 @@ function installExpoBundleTooling(projectPath: string): Q.Promise { ).then(() => { return null; }); } +const CODEPUSH_METADATA_FILE_NAME = ".codepushrelease"; + +function isHashIgnored(relativePath: string): boolean { + return relativePath.startsWith("__MACOSX/") + || relativePath === ".DS_Store" + || relativePath.endsWith("/.DS_Store") + || relativePath === CODEPUSH_METADATA_FILE_NAME + || relativePath.endsWith(`/${CODEPUSH_METADATA_FILE_NAME}`); +} + +/** + * Computes the same content hash that the native SDKs compute over an installed update folder + * (see CodePushUpdateUtils.verifyFolderHash on Android / its iOS equivalent), so the mock server + * can hand back a package_hash that will actually match what the client re-derives after download. + */ +function computeUpdateContentsHash(folderPath: string): string { + const manifest: string[] = []; + + const walk = (currentPath: string, relativePrefix: string) => { + for (const entryName of fs.readdirSync(currentPath)) { + const entryPath = path.join(currentPath, entryName); + const relativePath = relativePrefix ? `${relativePrefix}/${entryName}` : entryName; + + if (isHashIgnored(relativePath)) { + continue; + } + + if (fs.statSync(entryPath).isDirectory()) { + walk(entryPath, relativePath); + } else { + const fileHash = crypto.createHash("sha256").update(fs.readFileSync(entryPath)).digest("hex"); + manifest.push(`${relativePath}:${fileHash}`); + } + } + }; + + walk(folderPath, ""); + manifest.sort(); + + return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); +} + ////////////////////////////////////////////////////////////////////////////////////////// // Create the platforms to run the tests on. @@ -498,16 +541,30 @@ class RNProjectManager extends ProjectManager { .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) + .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } else { return deferred.promise .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) + .then(this.updateMockPackageHash.bind(this, bundleFolder, isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } } + // Records the real hash of bundleFolder for this archive, so the mock server can hand back a + // package_hash that matches what the client's verifyFolderHash integrity check will compute - + // instead of the arbitrary/random one ServerUtil.createUpdateResponse() fills in by default. + // Skipped for diff archives, whose installed content is merged with the previous package + // on-device rather than just being bundleFolder's contents. + private updateMockPackageHash(bundleFolder: string, isDiff: boolean, archivePath: string): string { + if (!isDiff) { + ServerUtil.setPackageHashForPath(archivePath, computeUpdateContentsHash(bundleFolder)); + } + return archivePath; + } + /** JSON file containing the platforms the plugin is currently installed for. * Keys must match targetPlatform.getName()! * @@ -1013,16 +1070,9 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); }) .then(() => { - /* restart the app to ensure it was reverted and send it another update */ - ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; - targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); - return ServerUtil.expectTestMessages([ - ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, - ServerUtil.TestMessage.DOWNLOAD_SUCCEEDED, - ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); - }) - .then(() => { - /* restart the app again to ensure it was reverted again and send the same update and expect it to reject it */ + /* restart the app to ensure it was reverted; the native rollback path marks + the failed update's hash as failed immediately, so the same update should + now be rejected outright rather than being re-downloaded and retried */ targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); return ServerUtil.expectTestMessages([ServerUtil.TestMessage.UPDATE_FAILED_PREVIOUSLY]); })