diff --git a/expo.js b/expo.js index d453377f..49b3cf25 100644 --- a/expo.js +++ b/expo.js @@ -89,6 +89,9 @@ const withCodePushInfoPlist = (config, options = {}) => { if (options.ios && options.ios.CodePushServerURL) { config.modResults.CodePushServerURL = options.ios.CodePushServerURL; } + if (options.ios && options.ios.CodePushPublicKey) { + config.modResults.CodePushPublicKey = options.ios.CodePushPublicKey; + } return config; }); }; @@ -324,6 +327,9 @@ const withAndroidStrings = (config, options) => { if (options.android?.CodePushServerURL) { setString('CodePushServerUrl', options.android.CodePushServerURL); } + if (options.android?.CodePushPublicKey) { + setString('CodePushPublicKey', options.android.CodePushPublicKey); + } return config; }); }; diff --git a/test/codesign.ts b/test/codesign.ts new file mode 100644 index 00000000..8f222e17 --- /dev/null +++ b/test/codesign.ts @@ -0,0 +1,96 @@ +"use strict"; + +import crypto = require("crypto"); +import fs = require("fs"); +import mkdirp = require("mkdirp"); +import path = require("path"); + +import { Platform, ProjectManager, ServerUtil, setupUpdateScenario, TestConfig, TestUtil } from "code-push-plugin-testing-framework"; + +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, so the mock server + * can hand back a package_hash that will actually match what the client expects. + */ +export 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"); +} + +const codeSigningPrivateKey = fs.readFileSync(path.join(__dirname, "../test/fixtures/codesigning/test-private-key.pem"), "utf8"); +export const codeSigningPublicKey = fs.readFileSync(path.join(__dirname, "../test/fixtures/codesigning/test-public-key.pem"), "utf8").trim(); + +function base64UrlEncode(input: Buffer): string { + return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Builds the RS256-signed ".codepushrelease" JWT that the native SDKs look for inside an update + * archive's "CodePush/" folder. + */ +function signUpdateContentsHash(contentHash: string): string { + const header = base64UrlEncode(Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" }))); + const payload = base64UrlEncode(Buffer.from(JSON.stringify({ contentHash }))); + const signature = base64UrlEncode(crypto.sign("RSA-SHA256", Buffer.from(`${header}.${payload}`), codeSigningPrivateKey)); + return `${header}.${payload}.${signature}`; +} + +/** + * Code-signs the update contents with the test key pair and records the real hash, so the mock + * server hands back a package_hash that matches what the client's data-integrity check computes. + */ +export function signAndRecordUpdateArchive(bundleFolder: string, isDiff: boolean): void { + // TODO(RA-4875): Diff updates clear it instead, since they are poorly implemented in the entire test harness. + // It's going to be a bigger refactor, so for now we just skip signing/hashing to avoid using a stale value in diff tests. + if (isDiff) { + ServerUtil.setKnownPackageHash(undefined); + return; + } + + const contentHash = computeUpdateContentsHash(bundleFolder); + const signatureFolder = path.join(bundleFolder, "CodePush"); + mkdirp.sync(signatureFolder); + fs.writeFileSync(path.join(signatureFolder, CODEPUSH_METADATA_FILE_NAME), signUpdateContentsHash(contentHash)); + ServerUtil.setKnownPackageHash(contentHash); +} + +export async function setupTamperedSignatureUpdateScenario(projectManager: ProjectManager, targetPlatform: Platform.IPlatform, scenarioJsPath: string, version: string): Promise { + const updatePath = await setupUpdateScenario(projectManager, targetPlatform, scenarioJsPath, version); + + const bundleFolder = path.join(TestConfig.updatesDirectory, TestConfig.TestAppName, "CodePush/"); + const tamperedHash = "0".repeat(64); + fs.writeFileSync(path.join(bundleFolder, "CodePush", CODEPUSH_METADATA_FILE_NAME), signUpdateContentsHash(tamperedHash)); + + return await TestUtil.archiveFolder(bundleFolder, "", updatePath, false); +} diff --git a/test/fixtures/codesigning/test-private-key.pem b/test/fixtures/codesigning/test-private-key.pem new file mode 100644 index 00000000..57c07622 --- /dev/null +++ b/test/fixtures/codesigning/test-private-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEApZtuvtGcQmmeUh81n/jAjeDkktkX1QryINqRZrcoofjw+w89 +FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGivmW1O0HBuCsAeiVHyiTq/t+sEiKP +0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3KEZy+Zhx54gaM5iz7ErqavpDADaW +DWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHTi6Whq/YUPWs2+7KXyLnvKyO+33YF +WnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0OcEagfdlM8YwfyOUSWn7LB0VCJaz +ltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj7wIDAQABAoIBAAIEOH7+UmbEnnbl +hmOiRcX0fRQErLOdZIFd5/NWO5ptS5HjB51ics8nkV22yCkaVbwgHBQFyBQQoVAb +rOPeJrsmxeQo0tEJRQI3vf4KIQplctTtss6bvJNrwVkzmDWU5eWuTzzM4TGJpo0T +nlta8L9+zBuZ7ZkiIR+LtnUkHGKdEHKFD4melmeZrvMCDNTGrpa2Y2bP9I11a6xZ +V1XJIxvAQRftorz03vsQXFiIcscEaho65DiAObSqpn1tfFnTbPOl+z4XjKncAVF8 +5vmkVHvERnvUuuBmSTEgGrEcCgwmYKxtOGqWw3MpeP9DWroU5T1N/Mx5xTr3GID5 +6T3+bMECgYEA6Kyg43UCouS7LD/lB5z0dflK8Wu0hzdYUR58r4e2S/WhzkiyX1rZ +aJNkrxyC8/39lGrYdfJHc+RNbViC1TMfH7Zao1lNFVP7vF70v2HzishCL95wSXNz +KZnSY3Yac1ONVP8ZbxX20QBXh9aoTsQxtOs15+XBvOXA5kJwtaaPgTECgYEAtjWW +UiLznuODzIyOQugysXN9bU0UTqUxr3QDt0yqCjA0URVlf3Ehi7OVUPerVSwgCD5m +4RXltW8pqMCLY/qPLkVFH+uZJ/Oo495TEMbyk2/4GXEX4klBMW40YI5srFtP95k+ +HrW5+gFOoVLcpwxRHcW6JateA7RlZDReruAt7x8CgYEAgcQdqx4MTVsyRNiR3LAd +61oRARpnwe4NFJjjQ2Z2NmEVUB5dVS8vB9MEmWFWa8whTFBWz1lDnpAa2rw9o7hy +SFaEsIvSoO2I/aMb700q7iEIQPhXOa/o76+5lf09fUqBDYGE5t6iHCiLqNgAYIWt +j1CLbP1IExk0f3dYswblDFECgYEAkib5pHiUoWYtWe2ETvahcuUIPpwNJegrqmiM +coL0AagYztEy0L6WAdDSfFes/myeZP5o1zMRRi8cY1fOdyuLnbnCcJAyEXHIjr7O +Mi7idJDjmMS2O7Q2rsePC8QyNy4nPpuU0F1EB9z0jUJB61xd1Fu9rGmAx8fzbCT1 +rZ/0OFECgYEAwQHaF2oxQMxd/WzuTHtKYuPAJDvONQj+hcEAEVFUGEVnauRoUuta +rhgnWrD2rte/6JqGPnfJouW+w1Y+2Mnfp4io8/cyysiEX7VWTeZL+nUERczTserl +cyujq12LNdD+YPQwJTxHguP0rbMsQ2lHlxwys1+74GGq7GqR3dIJlqc= +-----END RSA PRIVATE KEY----- diff --git a/test/fixtures/codesigning/test-public-key.pem b/test/fixtures/codesigning/test-public-key.pem new file mode 100644 index 00000000..9137b5e4 --- /dev/null +++ b/test/fixtures/codesigning/test-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZtuvtGcQmmeUh81n/jA +jeDkktkX1QryINqRZrcoofjw+w89FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGi +vmW1O0HBuCsAeiVHyiTq/t+sEiKP0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3 +KEZy+Zhx54gaM5iz7ErqavpDADaWDWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHT +i6Whq/YUPWs2+7KXyLnvKyO+33YFWnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0 +OcEagfdlM8YwfyOUSWn7LB0VCJazltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj +7wIDAQAB +-----END PUBLIC KEY----- diff --git a/test/template/app.json b/test/template/app.json index faf5779d..77cf6de0 100644 --- a/test/template/app.json +++ b/test/template/app.json @@ -19,11 +19,13 @@ { "ios": { "CodePushDeploymentKey": "mock-ios-deployment-key", - "CodePushServerURL": "http://127.0.0.1:3000" + "CodePushServerURL": "http://127.0.0.1:3000", + "CodePushPublicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZtuvtGcQmmeUh81n/jA\njeDkktkX1QryINqRZrcoofjw+w89FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGi\nvmW1O0HBuCsAeiVHyiTq/t+sEiKP0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3\nKEZy+Zhx54gaM5iz7ErqavpDADaWDWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHT\ni6Whq/YUPWs2+7KXyLnvKyO+33YFWnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0\nOcEagfdlM8YwfyOUSWn7LB0VCJazltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj\n7wIDAQAB\n-----END PUBLIC KEY-----" }, "android": { "CodePushDeploymentKey": "mock-android-deployment-key", - "CodePushServerURL": "http://10.0.2.2:3001" + "CodePushServerURL": "http://10.0.2.2:3001", + "CodePushPublicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZtuvtGcQmmeUh81n/jA\njeDkktkX1QryINqRZrcoofjw+w89FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGi\nvmW1O0HBuCsAeiVHyiTq/t+sEiKP0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3\nKEZy+Zhx54gaM5iz7ErqavpDADaWDWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHT\ni6Whq/YUPWs2+7KXyLnvKyO+33YFWnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0\nOcEagfdlM8YwfyOUSWn7LB0VCJazltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj\n7wIDAQAB\n-----END PUBLIC KEY-----" } } ] diff --git a/test/test.ts b/test/test.ts index 83cbf274..55620864 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,7 +2,6 @@ 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"); @@ -15,6 +14,8 @@ import Q = require("q"); import del = require("del"); +import { codeSigningPublicKey, signAndRecordUpdateArchive, setupTamperedSignatureUpdateScenario } from "./codesign"; + function ensureAndroidCleartextTraffic(androidManifestPath: string): void { const androidManifestContents = fs.readFileSync(androidManifestPath, "utf8"); @@ -69,47 +70,6 @@ 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, so the mock server - * can hand back a package_hash that will actually match what the client expects. - */ -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. @@ -209,6 +169,7 @@ class RNAndroid extends Platform.Android implements RNPlatform { const string = path.join(innerprojectDirectory, "android", "app", "src", "main", "res", "values", "strings.xml"); TestUtil.replaceString(string, TestUtil.SERVER_URL_PLACEHOLDER, this.getServerUrl()); TestUtil.replaceString(string, TestUtil.ANDROID_KEY_PLACEHOLDER, this.getDefaultDeploymentKey()); + TestUtil.replaceString(string, "", `${codeSigningPublicKey}\n`); TestUtil.replaceString(AndroidManifest, "\\${usesCleartextTraffic}", "true"); @@ -283,7 +244,7 @@ class RNIOS extends Platform.IOS implements RNPlatform { // Put the IOS deployment key in the Info.plist .then(TestUtil.replaceString.bind(undefined, infoPlistPath, "\n", - "CodePushDeploymentKey\n\t" + this.getDefaultDeploymentKey() + "\n\tCodePushServerURL\n\t" + this.getServerUrl() + "\n\t\n")) + "CodePushDeploymentKey\n\t" + this.getDefaultDeploymentKey() + "\n\tCodePushServerURL\n\t" + this.getServerUrl() + "\n\tCodePushPublicKey\n\t" + codeSigningPublicKey + "\n\t\n")) // Set the app version to 1.0.0 instead of 1.0 in the Info.plist .then(TestUtil.replaceString.bind(undefined, infoPlistPath, "1.0", "1.0.0")) // Remove dependence of CFBundleShortVersionString from project.pbxproj @@ -539,28 +500,19 @@ class RNProjectManager extends ProjectManager { .then(TestUtil.getProcessOutput.bind(undefined, "npx expo prebuild --platform " + targetPlatform.getName(), { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .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(() => signAndRecordUpdateArchive(bundleFolder, isDiff)) .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(() => signAndRecordUpdateArchive(bundleFolder, isDiff)) .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 of an archive, so the mock server can hand back a - // package_hash that matches what the client's verifyFolderHash integrity check will compute. - private updateMockPackageHash(bundleFolder: string, isDiff: boolean, archivePath: string): string { - // TODO(RA-4875): Diff updates clear it instead, since they are poorly implemented in the entire test harness. - // It's going to be a bigger refactor, so for now we just clear the known package hash to avoid using a stale value in diff tests. - ServerUtil.setKnownPackageHash(isDiff ? undefined : computeUpdateContentsHash(bundleFolder)); - return archivePath; - } - /** JSON file containing the platforms the plugin is currently installed for. * Keys must match targetPlatform.getName()! * @@ -1050,6 +1002,27 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl }); }, ScenarioInstall); + TestBuilder.describe("#localPackage.install.codeSigning", + () => { + TestBuilder.it("localPackage.install.codeSigning.tamperedSignature", false, + async (done: Mocha.Done) => { + try { + ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; + + /* create a normal update, then tamper with its signature after it's been signed */ + const updatePath = await setupTamperedSignatureUpdateScenario(projectManager, targetPlatform, UpdateNotifyApplicationReady, "Tampered Update"); + ServerUtil.updatePackagePath = updatePath; + projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); + await ServerUtil.expectTestMessages([ + ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, + ServerUtil.TestMessage.DOWNLOAD_ERROR]); + done(); + } catch (e) { + done(e); + } + }); + }, ScenarioInstall); + TestBuilder.describe("#localPackage.install.revert", () => { TestBuilder.it("localPackage.install.revert.dorevert", false,