Skip to content
Draft
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
6 changes: 6 additions & 0 deletions expo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
};
Expand Down Expand Up @@ -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;
});
};
Expand Down
96 changes: 96 additions & 0 deletions test/codesign.ts
Original file line number Diff line number Diff line change
@@ -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");
}
Comment on lines +12 to +49

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved from test.ts, not new code


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<string> {
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);
}
27 changes: 27 additions & 0 deletions test/fixtures/codesigning/test-private-key.pem
Original file line number Diff line number Diff line change
@@ -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-----
9 changes: 9 additions & 0 deletions test/fixtures/codesigning/test-public-key.pem
Original file line number Diff line number Diff line change
@@ -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-----
6 changes: 4 additions & 2 deletions test/template/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-----"
}
}
]
Expand Down
81 changes: 27 additions & 54 deletions test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");

Expand Down Expand Up @@ -69,47 +70,6 @@ function installExpoBundleTooling(projectPath: string): Q.Promise<void> {
).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.

Expand Down Expand Up @@ -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, "</resources>", `<string moduleConfig="true" name="CodePushPublicKey">${codeSigningPublicKey}</string>\n</resources>`);
TestUtil.replaceString(AndroidManifest, "\\${usesCleartextTraffic}", "true");


Expand Down Expand Up @@ -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,
"</dict>\n</plist>",
"<key>CodePushDeploymentKey</key>\n\t<string>" + this.getDefaultDeploymentKey() + "</string>\n\t<key>CodePushServerURL</key>\n\t<string>" + this.getServerUrl() + "</string>\n\t</dict>\n</plist>"))
"<key>CodePushDeploymentKey</key>\n\t<string>" + this.getDefaultDeploymentKey() + "</string>\n\t<key>CodePushServerURL</key>\n\t<string>" + this.getServerUrl() + "</string>\n\t<key>CodePushPublicKey</key>\n\t<string>" + codeSigningPublicKey + "</string>\n\t</dict>\n</plist>"))
// 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
Expand Down Expand Up @@ -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<string>(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff))
.then<string>(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<string>(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff))
.then<string>(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()!
*
Expand Down Expand Up @@ -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,
Expand Down
Loading