From acbde65beb5e40a947a99216cb593a19ee0a927c Mon Sep 17 00:00:00 2001 From: doguhan Date: Fri, 15 May 2026 09:02:03 +0200 Subject: [PATCH] feat: implement native bundle hash verification on iOS and Android JS-side response.arrayBuffer() on ~70 MB bundles triggers an engine-level abort in Hermes that bypasses the promise chain entirely. Move the download, SHA-256 hashing, and constant-time comparison into native code on both platforms. --- README.md | 114 ++++++++- SECURITY.md | 10 +- .../BundleLoaderModule.java | 228 +++++++++++++----- .../ConstantsCanaryTest.java | 34 +++ .../DownloadAndHashToCacheTest.java | 149 ++++++++++++ .../LoadVerifiedHelpersTest.java | 113 +++++++++ .../ReactInternalsCanaryTest.java | 68 ------ ios/BundleLoader.m | 95 ++++++-- ios/BundleLoaderTests/BundleLoaderTests.m | 164 ++++++------- package.json | 9 +- src/__tests__/index.test.tsx | 170 +++---------- src/index.tsx | 35 +-- yarn.lock | 36 --- 13 files changed, 780 insertions(+), 445 deletions(-) create mode 100644 android/src/test/java/com/reactnativebundleloader/ConstantsCanaryTest.java create mode 100644 android/src/test/java/com/reactnativebundleloader/DownloadAndHashToCacheTest.java create mode 100644 android/src/test/java/com/reactnativebundleloader/LoadVerifiedHelpersTest.java delete mode 100644 android/src/test/java/com/reactnativebundleloader/ReactInternalsCanaryTest.java diff --git a/README.md b/README.md index 8aa4b0e..40271ba 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Loads a remote React Native JS bundle, with optional **hash-pinned integrity ver Loading a remote JS bundle is, by construction, remote code execution inside the host app. **This library is intended for internal/development builds only — do not ship it in store builds without an out-of-band, statically-stripped feature flag.** See `SECURITY.md`. -The `loadVerified()` API closes the dominant runtime risk: it fetches the bundle bytes itself, hashes them with `@exodus/crypto`, compares the hash to a caller-supplied digest in constant time, and only then asks the bridge to reload from the verified bytes. Anything that mutates the response between fetch and reload is rejected. +The `loadVerified()` API closes the dominant runtime risk: it downloads the bundle natively, hashes the bytes with platform crypto (iOS: `CommonCrypto CC_SHA256`, Android: `MessageDigest SHA-256`), compares the hash to a caller-supplied digest in constant time, and only then loads the verified bytes from app-private storage. Anything that mutates the response between fetch and reload is rejected. ## Installation @@ -23,7 +23,7 @@ iOS: cd ios && pod install ``` -Android: `BundleLoaderPackage` is autolinked. +Android: requires both Gradle wiring and host app changes — see [Android integration](#android-integration) below. ## Usage @@ -43,11 +43,12 @@ Behavior: - The URL must use the `https:` scheme. - The expected sha256 must be a 64-character hex string. -- The bytes are fetched, hashed in JS using `@exodus/crypto/hash`, and compared to the expected hash with a constant-time comparison. -- On match, the bytes are written to the platform's app-private cache (iOS: `NSTemporaryDirectory()` with `NSDataWritingFileProtectionComplete`; Android: `Context.getCacheDir()`) and the bridge is reloaded from the local file path. -- On mismatch, an error is thrown and the bridge is left untouched. +- Download, SHA-256 hashing, and constant-time comparison all happen in native code. This avoids the Hermes `RangeError` that JS-side `response.arrayBuffer()` causes on large bundles (≥ ~70 MB). +- On match, the bytes are written to app-private storage and the bundle is loaded (see platform notes below). +- On mismatch, an error is thrown and the current bundle is left untouched. +- The remote bundle is active for **one session only**. The next cold start returns to the local bundle — matching the behaviour consumers expect from a developer preview tool. -Works on iOS and Android. The hash check happens in JS before any native call, so the integrity contract is identical on both platforms. +Works on iOS and Android. ### Unverified loading @@ -87,12 +88,105 @@ Example: `https://example.ngrok.io/index.bundle?dev=false&platform=ios&excludeSo | `loadVerified(url, sha256)` | ✅ | ✅ | | `runningMode()` | ✅ | ✅ | -### How the in-process swap works +### How bundle loading works -- **iOS** writes the bundle to `NSTemporaryDirectory()` and sets the bridge's `bundleURL` via KVC (`[bridge setValue:url forKey:@"bundleURL"]`), then calls `[bridge reload]`. -- **Android** writes the bundle to `Context.getCacheDir()`, builds a `JSBundleLoader.createFileLoader(path)`, swaps it into the private `mBundleLoader` field on `ReactInstanceManager` via reflection, and calls `recreateReactContextInBackground()`. +**iOS** downloads and verifies the bundle natively via `NSURLSession` + `CommonCrypto CC_SHA256`, writes it to `NSTemporaryDirectory()` with `NSDataWritingFileProtectionComplete`, then sets the bridge's `bundleURL` via KVC (`[bridge setValue:url forKey:@"bundleURL"]`) and calls `[bridge reload]`. This is an in-process reload: the old bridge is torn down and a new one is created with the cached file. Because iOS uses ARC, the old bridge's memory (including the Hermes runtime) is freed immediately when the bridge reference is released, before the new runtime allocates — no double-memory peak. -Both mechanisms touch private/internal React Native surface and could break on a major RN upgrade. See `SECURITY.md`. The Android implementation requires the host app to implement `ReactApplication` (the standard React Native template does). +**Android** uses a process restart instead of an in-process bridge swap. The reason: Android's ART garbage collector is non-deterministic. When a new React context is created alongside an existing one, ART does not guarantee the old Hermes runtime's native heap is freed before the new runtime allocates. On real-world bundle sizes (~50 MB of Hermes bytecode) this causes OOM. The process restart avoids the problem entirely by ensuring only one runtime is ever live. + +After download and hash verification, the module: + +1. Writes the bundle to `Context.getCacheDir()/verified-bundle.jsbundle`. +2. Sets a one-shot flag in `SharedPreferences` (`"BundleLoader"` / `"pending_remote_bundle"`), using a synchronous `commit()` so the flag survives the imminent process kill. +3. Restarts the process via `startActivity` + `Process.killProcess`. + +On the next launch, the host app reads the flag, disables Metro (so `ReactInstanceManager` does not query the packager and ignore the file — confirmed necessary by bytecode analysis of RN 0.78), and serves `verified-bundle.jsbundle` as the JS bundle for this session. The flag is consumed on first use so subsequent restarts return to Metro. + +## Android integration + +Because Android requires host app changes that cannot be encapsulated in the module itself, the following manual steps are required. + +### 1. Gradle wiring + +`settings.gradle` — include the subproject conditionally (the module is a `devDependency`; prod CI runs `yarn install --production` and the directory won't exist): + +```groovy +def bundleLoaderDir = new File(rootProject.projectDir, '../node_modules/@exodus/react-native-bundle-loader/android') +if (bundleLoaderDir.exists()) { + include ':@exodus_react-native-bundle-loader' + project(':@exodus_react-native-bundle-loader').projectDir = bundleLoaderDir +} +``` + +`app/build.gradle` — depend only in debug builds: + +```groovy +if (new File("$rootDir/../node_modules/@exodus/react-native-bundle-loader/android").exists()) { + debugImplementation project(':@exodus_react-native-bundle-loader') +} +``` + +### 2. Register the package + +In `MainApplication.java`, inside `getPackages()`, add the package via reflection so a missing module (absent in prod CI) doesn't cause a compile-time error: + +```java +if (BuildConfig.DEBUG) { + // devDependency absent in prod CI (yarn install --production); reflection avoids a compile-time import + try { + packages.add((ReactPackage) Class.forName("com.reactnativebundleloader.BundleLoaderPackage") + .getDeclaredConstructor().newInstance()); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } +} +``` + +### 3. Hook bundle loading into ReactNativeHost + +Add these three methods to your `ReactNativeHost` anonymous subclass in `MainApplication.java`: + +```java +import java.io.File; + +// ... + +@Override +public boolean getUseDeveloperSupport() { + if (BuildConfig.DEBUG && hasPendingRemoteBundle()) { + // Must disable dev support: when enabled and Metro is reachable, + // ReactInstanceManager queries the packager and ignores getJSBundleFile(). + return false; + } + // No pending bundle — clear the active flag so runningMode() returns LOCAL. + getSharedPreferences("BundleLoader", MODE_PRIVATE) + .edit().remove("active_remote_bundle").apply(); + return BuildConfig.DEBUG; +} + +@Override +protected String getJSBundleFile() { + if (BuildConfig.DEBUG && hasPendingRemoteBundle()) { + File cachedBundle = new File(getCacheDir(), "verified-bundle.jsbundle"); + if (cachedBundle.exists()) { + // Consume the one-shot latch: next restart goes back to Metro. + getSharedPreferences("BundleLoader", MODE_PRIVATE).edit() + .remove("pending_remote_bundle") + .putBoolean("active_remote_bundle", true) + .apply(); + return cachedBundle.getAbsolutePath(); + } + } + return null; +} + +private boolean hasPendingRemoteBundle() { + return getSharedPreferences("BundleLoader", MODE_PRIVATE) + .getBoolean("pending_remote_bundle", false); +} +``` + +The SharedPreferences keys (`"BundleLoader"`, `"pending_remote_bundle"`, `"active_remote_bundle"`) must match the constants defined in `BundleLoaderModule` (`PREFS_NAME`, `PREFS_PENDING_KEY`, `PREFS_ACTIVE_KEY`). ## Provenance diff --git a/SECURITY.md b/SECURITY.md index a08ffda..8322492 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,9 +15,9 @@ This library exists to load and execute a remote JavaScript bundle inside the ho | Surface | Upstream `0.1.0` | This fork | | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Bundle integrity | None — bridge fetches whatever the URL serves | `loadVerified(url, sha256)` fetches bytes in JS, hashes with `@exodus/crypto`, compares constant-time, only then reloads from disk | +| Bundle integrity | None — bridge fetches whatever the URL serves | `loadVerified(url, sha256)` downloads bytes natively (iOS: `NSURLSession`, Android: `HttpURLConnection`), hashes with platform crypto (iOS: `CommonCrypto CC_SHA256`, Android: `MessageDigest SHA-256`), compares in constant-time, writes to app-private storage, and reloads the bridge from the local file — closing the TOCTOU window between fetch and load | | `BundlePrompt` default URL | Hardcoded `cdn.jsdelivr.net/gh/jusbrasil/...` (deleted) | Empty — operator must type a URL | -| Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary; native iOS `load:` re-checks | +| Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary; both native `load` implementations re-check before touching the network | | Verified bundle on-disk protection (iOS) | n/a | Written with `NSDataWritingFileProtectionComplete` | | Lockfile | Not shipped | `yarn.lock` committed; `.yarnrc` enforces `--frozen-lockfile` | | Dependency version pinning | Carets (`^`) | All direct deps pinned to exact versions; `.npmrc` `save-exact=true` | @@ -35,15 +35,15 @@ This library exists to load and execute a remote JavaScript bundle inside the ho - **The bridge `bundleURL` setter is a KVC write** on iOS (`[bridge setValue:url forKey:@"bundleURL"]`) to a non-public RN property. Behavior could change on an RN upgrade and silently no-op the loader. - **The Android bundle swap reflects on a private field.** `ReactInstanceManager.mBundleLoader` has no public setter, so we use `Field.setAccessible(true)` to install a fresh `JSBundleLoader.createFileLoader(...)` before calling `recreateReactContextInBackground()`. The field name has been stable across RN 0.62–0.74 but is not part of the public API; an RN upgrade could rename or remove it, in which case `loadVerified`/`load` will throw `NoSuchFieldException` rather than silently no-op. -- **`@exodus/crypto/hash` runs in JS on the JS thread.** Bundles are typically a few MB; hashing time is acceptable. We deliberately keep hashing in JS so the threat-model contract — "Exodus crypto verifies, and the verified bytes are what we hand to native" — is auditable in TypeScript and identical on iOS and Android. -- **`timingSafeEqual()` is currently inlined** as a small constant-time XOR loop. The threat model anticipates this moving to a future `@exodus/crypto` export. The inlined version is functionally equivalent and lives in `src/index.tsx`. +- **Hash verification runs in native code, not JS.** `loadVerifiedFromUrl` uses `CommonCrypto CC_SHA256` (iOS) and `MessageDigest SHA-256` (Android) with a constant-time XOR comparison loop in native code. This avoids a Hermes `RangeError: Maximum regex stack depth reached` that the previous JS-side `response.arrayBuffer()` path hit on bundles ≥ ~70 MB. The trade-off is that the integrity contract is no longer auditable as TypeScript. +- **`timingSafeEqual` is an inlined XOR loop in native code.** Both `ios/BundleLoader.m` and `android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java` XOR all byte pairs into an accumulator and reject the bundle if the accumulator is non-zero. ## Release process This package has no CI/CD. Maintainers cut releases manually from a developer machine: ```sh -yarn preflight # lint + typecheck + test + verify-pack +yarn preflight # lint + typecheck + JS tests + Android JVM tests + iOS XCTests + verify-pack npm publish --access public ``` diff --git a/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java b/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java index 934bbf6..700433e 100644 --- a/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java +++ b/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java @@ -1,13 +1,12 @@ package com.reactnativebundleloader; -import android.util.Base64; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; -import com.facebook.react.ReactApplication; -import com.facebook.react.ReactInstanceManager; -import com.facebook.react.bridge.JSBundleLoader; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; @@ -17,20 +16,25 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.lang.reflect.Field; import java.net.HttpURLConnection; import java.net.URL; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; public class BundleLoaderModule extends ReactContextBaseJavaModule { private static final String TAG = "BundleLoader"; - private static final String BUNDLE_FILENAME = "verified-bundle.jsbundle"; + // These values are referenced by string literals in the host app's MainApplication. + // Do not rename without updating the host app integration accordingly. + static final String BUNDLE_FILENAME = "verified-bundle.jsbundle"; + static final String PREFS_NAME = "BundleLoader"; + static final String PREFS_PENDING_KEY = "pending_remote_bundle"; + static final String PREFS_ACTIVE_KEY = "active_remote_bundle"; + private static final int CONNECT_TIMEOUT_MS = 30_000; private static final int READ_TIMEOUT_MS = 30_000; static final long MAX_BUNDLE_BYTES = 64L * 1024L * 1024L; - private static volatile boolean remoteLoaded = false; - BundleLoaderModule(ReactApplicationContext context) { super(context); } @@ -55,14 +59,15 @@ public void run() { getReactApplicationContext().getCacheDir(), BUNDLE_FILENAME ); - File bundleFile = downloadToCache( + downloadToCache( url, targetFile, CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS, MAX_BUNDLE_BYTES ); - swapBundleLoaderAndReload(bundleFile); + setPendingFlag(); + restartApp(); } catch (Exception e) { Log.e(TAG, "load(" + url + ") failed", e); } @@ -71,30 +76,82 @@ public void run() { } @ReactMethod - public void loadFromBase64(String base64, Promise promise) { + public void loadVerifiedFromUrl(final String url, final String expectedSha256, final Promise promise) { + if (!isHttps(url)) { + promise.reject("E_INVALID_URL", "Bundle URL must use the https scheme"); + return; + } + + final byte[] expectedDigest; try { - byte[] data = Base64.decode(base64, Base64.DEFAULT); - if (data == null || data.length == 0) { - promise.reject("E_INVALID_BASE64", "Invalid base64 input"); - return; - } - File bundleFile = new File( - getReactApplicationContext().getCacheDir(), - BUNDLE_FILENAME - ); - try (FileOutputStream out = new FileOutputStream(bundleFile)) { - out.write(data); - } - swapBundleLoaderAndReload(bundleFile); - promise.resolve(null); - } catch (Exception e) { - promise.reject("E_LOAD_FAILED", e.getMessage(), e); + expectedDigest = parseHexSha256(expectedSha256); + } catch (IllegalArgumentException e) { + promise.reject("E_INVALID_HASH", e.getMessage()); + return; } + + new Thread(new Runnable() { + @Override + public void run() { + try { + File targetFile = new File( + getReactApplicationContext().getCacheDir(), + BUNDLE_FILENAME + ); + byte[] actualDigest = downloadAndHashToCache( + url, + targetFile, + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS, + MAX_BUNDLE_BYTES + ); + if (!timingSafeEquals(actualDigest, expectedDigest)) { + promise.reject("E_HASH_MISMATCH", "Bundle hash mismatch — refusing to load"); + return; + } + // Resolve before killing the process so the JS side receives the result. + promise.resolve(null); + setPendingFlag(); + restartApp(); + } catch (Exception e) { + promise.reject("E_LOAD_FAILED", e.getMessage(), e); + } + } + }, "BundleLoader-loadVerifiedFromUrl").start(); } @ReactMethod public void runningMode(Promise promise) { - promise.resolve(remoteLoaded ? "REMOTE" : "LOCAL"); + SharedPreferences prefs = getReactApplicationContext() + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + promise.resolve(prefs.getBoolean(PREFS_ACTIVE_KEY, false) ? "REMOTE" : "LOCAL"); + } + + private void setPendingFlag() { + // commit() not apply() — apply() is async and the write may not reach disk + // before killProcess() terminates the process. + getReactApplicationContext() + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(PREFS_PENDING_KEY, true) + .commit(); + } + + /** + * Restarts the app process. On next launch, MainApplication reads the pending + * flag from SharedPreferences and loads the cached bundle instead of Metro. + * A process restart avoids running both the old and new Hermes runtimes + * simultaneously, which would exceed the device heap limit. + */ + private void restartApp() { + Context context = getReactApplicationContext(); + Intent intent = context.getPackageManager() + .getLaunchIntentForPackage(context.getPackageName()); + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(intent); + } + android.os.Process.killProcess(android.os.Process.myPid()); } /** @@ -106,6 +163,95 @@ static boolean isHttps(String url) { return url != null && url.startsWith("https://"); } + /** + * Parses a 64-character lowercase hex string into a 32-byte SHA-256 digest. + * Throws {@link IllegalArgumentException} on invalid input so callers can + * reject the promise before touching the network. + */ + static byte[] parseHexSha256(String hex) { + if (hex == null || hex.length() != 64) { + throw new IllegalArgumentException("Expected SHA-256 must be a 64-character hex string"); + } + byte[] out = new byte[32]; + for (int i = 0; i < 32; i++) { + int hi = Character.digit(hex.charAt(i * 2), 16); + int lo = Character.digit(hex.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) { + throw new IllegalArgumentException("Expected SHA-256 contains invalid hex characters"); + } + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + /** + * Constant-time byte array comparison: XORs all pairs into an accumulator + * and returns true iff the accumulator is zero. Both arrays must be the + * same length; returns false immediately if they differ. + */ + static boolean timingSafeEquals(byte[] a, byte[] b) { + if (a.length != b.length) return false; + int diff = 0; + for (int i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } + + /** + * Downloads {@code urlString} into {@code targetFile}, computing SHA-256 of + * the body in the same streaming pass. Returns the 32-byte digest. + *

+ * Mirrors the security properties of {@link #downloadToCache}: redirects are + * disabled, non-200 responses throw, and the body is capped at {@code maxBytes}. + *

+ * Package-private and static so the JVM unit tests can drive it against a + * MockWebServer without spinning up a ReactApplicationContext. + */ + static byte[] downloadAndHashToCache( + String urlString, + File targetFile, + int connectTimeoutMs, + int readTimeoutMs, + long maxBytes + ) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 not available: " + e.getMessage(), e); + } + + URL url = new URL(urlString); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(connectTimeoutMs); + conn.setReadTimeout(readTimeoutMs); + conn.setInstanceFollowRedirects(false); + try { + int code = conn.getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) { + throw new IOException("Bundle fetch failed: HTTP " + code); + } + long total = 0; + try (InputStream in = conn.getInputStream(); + FileOutputStream out = new FileOutputStream(targetFile)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + total += n; + if (total > maxBytes) { + throw new IOException("Bundle exceeds " + maxBytes + " bytes"); + } + out.write(buf, 0, n); + digest.update(buf, 0, n); + } + } + return digest.digest(); + } finally { + conn.disconnect(); + } + } + /** * Downloads {@code urlString} into {@code targetFile} using HttpURLConnection. * Redirects are not followed and non-200 responses throw IOException with the @@ -153,30 +299,4 @@ static File downloadToCache( conn.disconnect(); } } - - private void swapBundleLoaderAndReload(File bundleFile) throws Exception { - ReactApplication app = (ReactApplication) - getReactApplicationContext().getApplicationContext(); - final ReactInstanceManager instanceManager = - app.getReactNativeHost().getReactInstanceManager(); - - JSBundleLoader bundleLoader = - JSBundleLoader.createFileLoader(bundleFile.getAbsolutePath()); - - // mBundleLoader is private on ReactInstanceManager and there is no public - // setter. The host app's ReactNativeHost wires the initial loader at - // construction; we swap it in-place so the next reload picks up our file. - Field field = ReactInstanceManager.class.getDeclaredField("mBundleLoader"); - field.setAccessible(true); - field.set(instanceManager, bundleLoader); - - remoteLoaded = true; - - getReactApplicationContext().runOnUiQueueThread(new Runnable() { - @Override - public void run() { - instanceManager.recreateReactContextInBackground(); - } - }); - } } diff --git a/android/src/test/java/com/reactnativebundleloader/ConstantsCanaryTest.java b/android/src/test/java/com/reactnativebundleloader/ConstantsCanaryTest.java new file mode 100644 index 0000000..713e413 --- /dev/null +++ b/android/src/test/java/com/reactnativebundleloader/ConstantsCanaryTest.java @@ -0,0 +1,34 @@ +package com.reactnativebundleloader; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * Guards the string constants that are referenced by literal values in the host + * app's MainApplication.java. The module and host app are in separate packages + * so a rename here would silently break the Android integration at runtime. + * These tests catch that at build time instead. + */ +public class ConstantsCanaryTest { + + @Test + public void bundleFilename_hasExpectedValue() { + assertEquals("verified-bundle.jsbundle", BundleLoaderModule.BUNDLE_FILENAME); + } + + @Test + public void prefsName_hasExpectedValue() { + assertEquals("BundleLoader", BundleLoaderModule.PREFS_NAME); + } + + @Test + public void prefsPendingKey_hasExpectedValue() { + assertEquals("pending_remote_bundle", BundleLoaderModule.PREFS_PENDING_KEY); + } + + @Test + public void prefsActiveKey_hasExpectedValue() { + assertEquals("active_remote_bundle", BundleLoaderModule.PREFS_ACTIVE_KEY); + } +} diff --git a/android/src/test/java/com/reactnativebundleloader/DownloadAndHashToCacheTest.java b/android/src/test/java/com/reactnativebundleloader/DownloadAndHashToCacheTest.java new file mode 100644 index 0000000..2d4db78 --- /dev/null +++ b/android/src/test/java/com/reactnativebundleloader/DownloadAndHashToCacheTest.java @@ -0,0 +1,149 @@ +package com.reactnativebundleloader; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class DownloadAndHashToCacheTest { + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 5_000; + private static final long DEFAULT_MAX_BYTES = 64L * 1024L * 1024L; + + private MockWebServer server; + private Path tmpDir; + private File targetFile; + + @Before + public void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + tmpDir = Files.createTempDirectory("bundle-loader-hash-test"); + targetFile = new File(tmpDir.toFile(), "verified-bundle.jsbundle"); + } + + @After + public void tearDown() throws Exception { + server.shutdown(); + if (targetFile.exists()) targetFile.delete(); + if (tmpDir != null) tmpDir.toFile().delete(); + } + + private static byte[] sha256(byte[] data) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + @Test + public void returnsCorrectSha256AndWritesFile() throws Exception { + byte[] body = "console.log('hello verified bundle');".getBytes("UTF-8"); + server.enqueue(new MockResponse().setResponseCode(200).setBody(new Buffer().write(body))); + + byte[] digest = BundleLoaderModule.downloadAndHashToCache( + server.url("/bundle.js").toString(), + targetFile, + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS, + DEFAULT_MAX_BYTES + ); + + assertArrayEquals(sha256(body), digest); + assertTrue(targetFile.exists()); + assertArrayEquals(body, Files.readAllBytes(targetFile.toPath())); + } + + @Test + public void digestMatchesIndependentSha256OfWrittenFile() throws Exception { + byte[] body = new byte[32 * 1024]; // 32 KB across multiple read() chunks + for (int i = 0; i < body.length; i++) body[i] = (byte) (i & 0xFF); + server.enqueue(new MockResponse().setResponseCode(200).setBody(new Buffer().write(body))); + + byte[] digest = BundleLoaderModule.downloadAndHashToCache( + server.url("/bundle.js").toString(), + targetFile, + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS, + DEFAULT_MAX_BYTES + ); + + byte[] fileBytes = Files.readAllBytes(targetFile.toPath()); + assertArrayEquals(sha256(fileBytes), digest); + } + + @Test + public void throwsOnNon200AndDoesNotWriteFile() throws Exception { + server.enqueue(new MockResponse().setResponseCode(404).setBody("not found")); + + try { + BundleLoaderModule.downloadAndHashToCache( + server.url("/missing.js").toString(), + targetFile, + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS, + DEFAULT_MAX_BYTES + ); + fail("expected IOException for 404"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("404")); + } + + assertFalse(targetFile.exists()); + } + + @Test + public void throwsWhenBodyExceedsMaxBytes() throws Exception { + long maxBytes = 1024L; + byte[] body = new byte[(int) maxBytes * 2]; + server.enqueue(new MockResponse().setResponseCode(200).setBody(new Buffer().write(body))); + + try { + BundleLoaderModule.downloadAndHashToCache( + server.url("/big.js").toString(), + targetFile, + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS, + maxBytes + ); + fail("expected IOException when body exceeds maxBytes"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("exceeds") || e.getMessage().contains(String.valueOf(maxBytes))); + } + } + + @Test + public void doesNotFollowRedirects() throws Exception { + server.enqueue(new MockResponse() + .setResponseCode(302) + .addHeader("Location", server.url("/elsewhere").toString())); + server.enqueue(new MockResponse().setResponseCode(200).setBody("REDIRECT_BODY")); + + try { + BundleLoaderModule.downloadAndHashToCache( + server.url("/redirected.js").toString(), + targetFile, + CONNECT_TIMEOUT_MS, + READ_TIMEOUT_MS, + DEFAULT_MAX_BYTES + ); + fail("expected IOException because redirects are disabled"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("302")); + } + + assertFalse(targetFile.exists()); + } +} diff --git a/android/src/test/java/com/reactnativebundleloader/LoadVerifiedHelpersTest.java b/android/src/test/java/com/reactnativebundleloader/LoadVerifiedHelpersTest.java new file mode 100644 index 0000000..f45f4dd --- /dev/null +++ b/android/src/test/java/com/reactnativebundleloader/LoadVerifiedHelpersTest.java @@ -0,0 +1,113 @@ +package com.reactnativebundleloader; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +public class LoadVerifiedHelpersTest { + + // --- parseHexSha256 --- + + @Test + public void parseHexSha256_parsesValidLowercaseHex() { + String hex = "a".repeat(64); + byte[] result = BundleLoaderModule.parseHexSha256(hex); + byte[] expected = new byte[32]; + for (int i = 0; i < 32; i++) expected[i] = (byte) 0xaa; + assertArrayEquals(expected, result); + } + + @Test + public void parseHexSha256_parsesKnownDigest() { + // SHA-256 of empty string + String hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + byte[] result = BundleLoaderModule.parseHexSha256(hex); + byte[] expected = new byte[]{ + (byte)0xe3,(byte)0xb0,(byte)0xc4,(byte)0x42,(byte)0x98,(byte)0xfc,(byte)0x1c,(byte)0x14, + (byte)0x9a,(byte)0xfb,(byte)0xf4,(byte)0xc8,(byte)0x99,(byte)0x6f,(byte)0xb9,(byte)0x24, + (byte)0x27,(byte)0xae,(byte)0x41,(byte)0xe4,(byte)0x64,(byte)0x9b,(byte)0x93,(byte)0x4c, + (byte)0xa4,(byte)0x95,(byte)0x99,(byte)0x1b,(byte)0x78,(byte)0x52,(byte)0xb8,(byte)0x55 + }; + assertArrayEquals(expected, result); + } + + @Test + public void parseHexSha256_rejectsNull() { + try { + BundleLoaderModule.parseHexSha256(null); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("64-character")); + } + } + + @Test + public void parseHexSha256_rejectsTooShort() { + try { + BundleLoaderModule.parseHexSha256("a".repeat(63)); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("64-character")); + } + } + + @Test + public void parseHexSha256_rejectsTooLong() { + try { + BundleLoaderModule.parseHexSha256("a".repeat(65)); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("64-character")); + } + } + + @Test + public void parseHexSha256_rejectsInvalidHexChars() { + String bad = "g".repeat(64); // 'g' is not a valid hex character + try { + BundleLoaderModule.parseHexSha256(bad); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("invalid hex")); + } + } + + // --- timingSafeEquals --- + + @Test + public void timingSafeEquals_returnsTrueForIdenticalArrays() { + byte[] a = {1, 2, 3, 4}; + byte[] b = {1, 2, 3, 4}; + assertTrue(BundleLoaderModule.timingSafeEquals(a, b)); + } + + @Test + public void timingSafeEquals_returnsFalseForDifferentArrays() { + byte[] a = {1, 2, 3, 4}; + byte[] b = {1, 2, 3, 5}; + assertFalse(BundleLoaderModule.timingSafeEquals(a, b)); + } + + @Test + public void timingSafeEquals_returnsFalseForOneBitFlip() { + byte[] a = new byte[32]; + byte[] b = new byte[32]; + b[31] = 1; + assertFalse(BundleLoaderModule.timingSafeEquals(a, b)); + } + + @Test + public void timingSafeEquals_returnsFalseForDifferentLengths() { + byte[] a = {1, 2, 3}; + byte[] b = {1, 2, 3, 4}; + assertFalse(BundleLoaderModule.timingSafeEquals(a, b)); + } + + @Test + public void timingSafeEquals_returnsTrueForEmptyArrays() { + assertTrue(BundleLoaderModule.timingSafeEquals(new byte[0], new byte[0])); + } +} diff --git a/android/src/test/java/com/reactnativebundleloader/ReactInternalsCanaryTest.java b/android/src/test/java/com/reactnativebundleloader/ReactInternalsCanaryTest.java deleted file mode 100644 index 622b1e9..0000000 --- a/android/src/test/java/com/reactnativebundleloader/ReactInternalsCanaryTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.reactnativebundleloader; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import com.facebook.react.ReactInstanceManager; -import com.facebook.react.bridge.JSBundleLoader; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; - -import org.junit.Test; - -/** - * Canary tests that fail loudly if a React Native upgrade renames or removes - * the private symbols that {@code BundleLoaderModule#swapBundleLoaderAndReload} - * reflects on. Without these, a silent rename would surface only at runtime - * on a device, after the bundle swap had already failed. - */ -public class ReactInternalsCanaryTest { - - @Test - public void reactInstanceManager_hasPrivateBundleLoaderField() throws Exception { - Field field = ReactInstanceManager.class.getDeclaredField("mBundleLoader"); - - assertNotNull("mBundleLoader field must exist on ReactInstanceManager", field); - assertEquals( - "mBundleLoader field must be typed JSBundleLoader", - JSBundleLoader.class, - field.getType() - ); - } - - @Test - public void reactInstanceManager_hasRecreateReactContextInBackgroundMethod() - throws Exception { - Method method = - ReactInstanceManager.class.getDeclaredMethod("recreateReactContextInBackground"); - - assertNotNull( - "recreateReactContextInBackground() must exist on ReactInstanceManager", - method - ); - } - - @Test - public void jsBundleLoader_hasPublicStaticCreateFileLoader() throws Exception { - Method method = - JSBundleLoader.class.getDeclaredMethod("createFileLoader", String.class); - - assertNotNull("createFileLoader(String) must exist on JSBundleLoader", method); - assertTrue( - "createFileLoader(String) must be public", - Modifier.isPublic(method.getModifiers()) - ); - assertTrue( - "createFileLoader(String) must be static", - Modifier.isStatic(method.getModifiers()) - ); - assertEquals( - "createFileLoader(String) must return a JSBundleLoader", - JSBundleLoader.class, - method.getReturnType() - ); - } -} diff --git a/ios/BundleLoader.m b/ios/BundleLoader.m index 0d72ad9..87ac68b 100644 --- a/ios/BundleLoader.m +++ b/ios/BundleLoader.m @@ -1,4 +1,5 @@ #import "BundleLoader.h" +#import @implementation BundleLoader @@ -30,32 +31,92 @@ - (void)setBundleURLAndReload:(NSURL *)url }); } -RCT_EXPORT_METHOD(loadFromBase64:(NSString *)base64 +// Downloads the bundle at `urlString`, verifies its SHA-256 digest against +// `expectedHex` using a constant-time byte comparison, then writes it to the +// sandbox temp directory and reloads the bridge — all in native code to avoid +// the Hermes RangeError that JS-side response.arrayBuffer() causes on large +// bundles (~70 MB+). +RCT_EXPORT_METHOD(loadVerifiedFromUrl:(NSString *)urlString + expectedSha256:(NSString *)expectedHex resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 - options:0]; - if (data == nil) { - reject(@"E_INVALID_BASE64", @"Invalid base64 input", nil); + NSURL *url = [NSURL URLWithString:urlString]; + if (![[url scheme] isEqualToString:@"https"]) { + reject(@"E_INVALID_URL", @"Bundle URL must use the https scheme", nil); return; } - NSString *path = [NSTemporaryDirectory() - stringByAppendingPathComponent:@"verified-bundle.jsbundle"]; - NSError *err = nil; - if (![data writeToFile:path - options:NSDataWritingAtomic | NSDataWritingFileProtectionComplete - error:&err]) { - reject(@"E_WRITE_FAILED", err.localizedDescription, err); + if (expectedHex.length != 64) { + reject(@"E_INVALID_HASH", @"Expected SHA-256 must be a 64-character hex string", nil); return; } - NSURL *fileURL = [NSURL fileURLWithPath:path]; - dispatch_async(dispatch_get_main_queue(), ^{ - [self setBundleURLAndReload:fileURL]; - resolve(nil); - }); + // Parse hex string to raw bytes up-front so we can reject early on bad input. + const char *cStr = [expectedHex UTF8String]; + uint8_t expectedDigestBuf[CC_SHA256_DIGEST_LENGTH]; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { + char buf[3] = { cStr[i * 2], cStr[i * 2 + 1], '\0' }; + char *end; + unsigned long val = strtoul(buf, &end, 16); + if (end != buf + 2) { + reject(@"E_INVALID_HASH", @"Expected SHA-256 contains invalid hex characters", nil); + return; + } + expectedDigestBuf[i] = (uint8_t)val; + } + // Wrap in NSData so the block can capture it as an object pointer. + NSData *expectedData = [NSData dataWithBytes:expectedDigestBuf length:CC_SHA256_DIGEST_LENGTH]; + + NSURLSessionDataTask *task = [[NSURLSession sharedSession] + dataTaskWithURL:url + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + reject(@"E_FETCH_FAILED", error.localizedDescription, error); + return; + } + + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + if (http.statusCode < 200 || http.statusCode >= 300) { + reject(@"E_FETCH_FAILED", + [NSString stringWithFormat:@"Bundle fetch failed: HTTP %ld", (long)http.statusCode], + nil); + return; + } + + // Compute SHA-256 of the downloaded bytes. + uint8_t actualDigest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, actualDigest); + + // Constant-time comparison: XOR all byte pairs and check the accumulator. + const uint8_t *expected = (const uint8_t *)expectedData.bytes; + uint8_t diff = 0; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { + diff |= actualDigest[i] ^ expected[i]; + } + if (diff != 0) { + reject(@"E_HASH_MISMATCH", @"Bundle hash mismatch — refusing to load", nil); + return; + } + + NSString *path = [NSTemporaryDirectory() + stringByAppendingPathComponent:@"verified-bundle.jsbundle"]; + NSError *writeError = nil; + if (![data writeToFile:path + options:NSDataWritingAtomic | NSDataWritingFileProtectionComplete + error:&writeError]) { + reject(@"E_WRITE_FAILED", writeError.localizedDescription, writeError); + return; + } + + NSURL *fileURL = [NSURL fileURLWithPath:path]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self setBundleURLAndReload:fileURL]; + resolve(nil); + }); + }]; + + [task resume]; } @end diff --git a/ios/BundleLoaderTests/BundleLoaderTests.m b/ios/BundleLoaderTests/BundleLoaderTests.m index b11bb3c..046c0a3 100644 --- a/ios/BundleLoaderTests/BundleLoaderTests.m +++ b/ios/BundleLoaderTests/BundleLoaderTests.m @@ -6,9 +6,10 @@ @interface BundleLoader (Testing) - (void)setBundleURLAndReload:(NSURL *)url; - (void)load:(NSURL *)url; -- (void)loadFromBase64:(NSString *)base64 - resolver:(void (^)(id))resolve - rejecter:(void (^)(NSString *, NSString *, NSError *))reject; +- (void)loadVerifiedFromUrl:(NSString *)urlString + expectedSha256:(NSString *)expectedHex + resolver:(void (^)(id))resolve + rejecter:(void (^)(NSString *, NSString *, NSError *))reject; @end #pragma mark - Mock bridge @@ -82,7 +83,7 @@ - (void)setUp - (void)tearDown { - // Best-effort cleanup of the file we may have written in test #1. + // Best-effort cleanup of the bundle file any test may have written to disk. NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:@"verified-bundle.jsbundle"]; [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; @@ -104,90 +105,7 @@ - (void)pumpMainRunloop } } -#pragma mark - Test 1: loadFromBase64 happy path - -- (void)testLoadFromBase64HappyPath -{ - NSString *plaintext = @"hello world"; - NSData *expected = [plaintext dataUsingEncoding:NSUTF8StringEncoding]; - NSString *base64 = [expected base64EncodedStringWithOptions:0]; - - XCTestExpectation *resolved = [self expectationWithDescription:@"resolver fires"]; - - __block id resolveValue = (id)@"<>"; - __block BOOL rejected = NO; - - [self.loader loadFromBase64:base64 - resolver:^(id result) { - resolveValue = result; - [resolved fulfill]; - } - rejecter:^(NSString *code, NSString *message, NSError *err) { - rejected = YES; - [resolved fulfill]; - }]; - - [self waitForExpectations:@[resolved] timeout:5.0]; - - XCTAssertFalse(rejected, @"resolver should have fired, not the rejecter"); - XCTAssertTrue(resolveValue == nil || [resolveValue isKindOfClass:[NSNull class]], - @"resolver should be called with nil"); - - NSString *path = [NSTemporaryDirectory() - stringByAppendingPathComponent:@"verified-bundle.jsbundle"]; - XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:path], - @"bundle file should exist on disk"); - - NSData *actual = [NSData dataWithContentsOfFile:path]; - XCTAssertEqualObjects(actual, expected, @"file bytes should round-trip exactly"); - - NSError *attrErr = nil; - NSDictionary *attrs = [[NSFileManager defaultManager] - attributesOfItemAtPath:path - error:&attrErr]; - XCTAssertNil(attrErr); - - // Data Protection is only enforced on real iOS devices; the iOS Simulator - // silently ignores `NSDataWritingFileProtectionComplete` and reports nil - // for `NSFileProtectionKey`. Accept either: nil (simulator) or - // `NSFileProtectionComplete` (device). - id protection = attrs[NSFileProtectionKey]; -#if TARGET_OS_SIMULATOR - XCTAssertTrue(protection == nil - || [protection isEqualToString:NSFileProtectionComplete], - @"on simulator, NSFileProtectionKey is typically nil; saw: %@", - protection); -#else - XCTAssertEqualObjects(protection, NSFileProtectionComplete, - @"file should be written with NSFileProtectionComplete"); -#endif -} - -#pragma mark - Test 2: loadFromBase64 invalid input - -- (void)testLoadFromBase64InvalidInput -{ - XCTestExpectation *rejected = [self expectationWithDescription:@"rejecter fires"]; - __block NSString *seenCode = nil; - __block BOOL resolved = NO; - - [self.loader loadFromBase64:@"!!!not-base64!!!" - resolver:^(id result) { - resolved = YES; - [rejected fulfill]; - } - rejecter:^(NSString *code, NSString *message, NSError *err) { - seenCode = code; - [rejected fulfill]; - }]; - - [self waitForExpectations:@[rejected] timeout:5.0]; - - XCTAssertFalse(resolved, @"resolver should not fire for invalid base64"); - XCTAssertEqualObjects(seenCode, @"E_INVALID_BASE64"); -} - -#pragma mark - Test 3: load: rejects non-https +#pragma mark - Test 1: load rejects non-https - (void)testLoadRejectsNonHttps { @@ -201,7 +119,7 @@ - (void)testLoadRejectsNonHttps @"non-https URL must not trigger reload"); } -#pragma mark - Test 4: load: accepts https +#pragma mark - Test 2: load accepts https - (void)testLoadAcceptsHttps { @@ -217,7 +135,7 @@ - (void)testLoadAcceptsHttps @"exactly one reload expected for https URL"); } -#pragma mark - Test 5: setBundleURLAndReload: directly +#pragma mark - Test 3: setBundleURLAndReload order - (void)testSetBundleURLAndReloadOrder { @@ -238,4 +156,70 @@ - (void)testSetBundleURLAndReloadOrder XCTAssertEqual(self.mockBridge.reloads.count, 1u); } +#pragma mark - Test 4: loadVerifiedFromUrl rejects non-https + +- (void)testLoadVerifiedFromUrlRejectsNonHttps +{ + XCTestExpectation *exp = [self expectationWithDescription:@"rejecter fires for non-https URL"]; + __block NSString *seenCode = nil; + __block BOOL resolved = NO; + + NSString *validHex = [@"" stringByPaddingToLength:64 withString:@"a" startingAtIndex:0]; + [self.loader loadVerifiedFromUrl:@"http://example.com/bundle.js" + expectedSha256:validHex + resolver:^(id r) { resolved = YES; [exp fulfill]; } + rejecter:^(NSString *code, NSString *msg, NSError *err) { + seenCode = code; + [exp fulfill]; + }]; + + [self waitForExpectations:@[exp] timeout:2.0]; + XCTAssertFalse(resolved, @"resolver must not fire"); + XCTAssertEqualObjects(seenCode, @"E_INVALID_URL"); +} + +#pragma mark - Test 5: loadVerifiedFromUrl rejects wrong-length hex + +- (void)testLoadVerifiedFromUrlRejectsWrongLengthHex +{ + XCTestExpectation *exp = [self expectationWithDescription:@"rejecter fires for short hex"]; + __block NSString *seenCode = nil; + __block BOOL resolved = NO; + + [self.loader loadVerifiedFromUrl:@"https://example.com/bundle.js" + expectedSha256:@"abc" + resolver:^(id r) { resolved = YES; [exp fulfill]; } + rejecter:^(NSString *code, NSString *msg, NSError *err) { + seenCode = code; + [exp fulfill]; + }]; + + [self waitForExpectations:@[exp] timeout:2.0]; + XCTAssertFalse(resolved, @"resolver must not fire"); + XCTAssertEqualObjects(seenCode, @"E_INVALID_HASH"); +} + +#pragma mark - Test 6: loadVerifiedFromUrl rejects invalid hex chars + +- (void)testLoadVerifiedFromUrlRejectsInvalidHexChars +{ + XCTestExpectation *exp = [self expectationWithDescription:@"rejecter fires for invalid hex chars"]; + __block NSString *seenCode = nil; + __block BOOL resolved = NO; + + // 'g' is not a valid hex character; 64 of them pass the length check but fail strtoul. + NSString *badHex = [@"" stringByPaddingToLength:64 withString:@"g" startingAtIndex:0]; + [self.loader loadVerifiedFromUrl:@"https://example.com/bundle.js" + expectedSha256:badHex + resolver:^(id r) { resolved = YES; [exp fulfill]; } + rejecter:^(NSString *code, NSString *msg, NSError *err) { + seenCode = code; + [exp fulfill]; + }]; + + [self waitForExpectations:@[exp] timeout:2.0]; + XCTAssertFalse(resolved, @"resolver must not fire"); + XCTAssertEqualObjects(seenCode, @"E_INVALID_HASH"); +} + @end diff --git a/package.json b/package.json index 9cf8640..70c58ad 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,13 @@ ], "scripts": { "test": "jest", + "test:android": "cd android && ./gradlew test", + "test:ios": "xcodebuild test -project ios/BundleLoader.xcodeproj -scheme BundleLoaderTests -destination 'platform=iOS Simulator,OS=18.4,name=iPhone 16' CODE_SIGNING_ALLOWED=NO -quiet", "typescript": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", "prepare": "bob build", "verify-pack": "rm -rf _pack && mkdir _pack && yarn prepare && npm pack --pack-destination _pack && tar -tzf _pack/*.tgz | LC_ALL=C sort | sed 's|^package/||' | diff -u .npm-tarball-allowlist -", - "preflight": "yarn lint && yarn typescript && yarn test --ci --runInBand && yarn verify-pack" + "preflight": "yarn lint && yarn typescript && yarn test --ci --runInBand && yarn test:android && yarn test:ios && yarn verify-pack" }, "engines": { "node": ">=18.18" @@ -74,10 +76,7 @@ "url": "https://github.com/ExodusForks/react-native-bundle-loader/issues" }, "homepage": "https://github.com/ExodusForks/react-native-bundle-loader#readme", - "dependencies": { - "@exodus/bytes": "1.15.0", - "@exodus/crypto": "1.0.0-rc.34" - }, + "dependencies": {}, "devDependencies": { "@react-native-community/bob": "0.16.2", "@react-native-community/eslint-config": "2.0.0", diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index b2a20d4..f126910 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1,180 +1,70 @@ /* eslint-env node, jest */ -// Jest 26 doesn't follow the package `exports` field that @exodus/crypto and -// @exodus/bytes use, so the loader can't resolve the subpaths under test. Stub -// them with Node-native equivalents — the SHA-256 implementation is the same -// one @exodus/crypto/hash uses on Node anyway. -jest.mock( - '@exodus/crypto/hash', - () => ({ - hash: async (algo: string, bytes: Uint8Array): Promise => { - const { createHash } = require('crypto'); - return new Uint8Array( - createHash(algo).update(Buffer.from(bytes)).digest() - ); - }, - }), - { virtual: true } -); - -jest.mock( - '@exodus/bytes/hex.js', - () => ({ - fromHex: (hex: string): Uint8Array => { - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return out; - }, - }), - { virtual: true } -); - -jest.mock( - '@exodus/bytes/base64.js', - () => ({ - toBase64: (bytes: Uint8Array): string => - Buffer.from(bytes).toString('base64'), - }), - { virtual: true } -); - import { NativeModules } from 'react-native'; -import { createHash } from 'crypto'; import { loadVerified } from '../index'; type NativeMock = { load: jest.Mock; - loadFromBase64: jest.Mock; + loadVerifiedFromUrl: jest.Mock; runningMode: jest.Mock; }; const native: NativeMock = { load: jest.fn(), - loadFromBase64: jest.fn(), + loadVerifiedFromUrl: jest.fn(), runningMode: jest.fn(), }; (NativeModules as Record).BundleLoader = native; -const stubFetchOk = (bytes: Uint8Array) => { - const buffer = bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength - ); - ((global as unknown) as Record< - string, - unknown - >).fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(buffer), - }); -}; - -const stubFetchFail = (status: number) => { - ((global as unknown) as Record< - string, - unknown - >).fetch = jest.fn().mockResolvedValue({ - ok: false, - status, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), - }); -}; - -const sha256Hex = (bytes: Uint8Array): string => - createHash('sha256').update(Buffer.from(bytes)).digest('hex'); - const URL_OK = 'https://example.com/bundle.jsbundle'; +const SHA256_OK = 'a'.repeat(64); beforeEach(() => { jest.clearAllMocks(); - native.loadFromBase64.mockResolvedValue(undefined); + native.loadVerifiedFromUrl.mockResolvedValue(undefined); }); describe('loadVerified — input validation', () => { it('rejects a non-https URL', async () => { await expect( - loadVerified('http://example.com/bundle.jsbundle', '0'.repeat(64)) + loadVerified('http://example.com/bundle.jsbundle', SHA256_OK) ).rejects.toThrow(/https scheme/); - expect(native.loadFromBase64).not.toHaveBeenCalled(); + expect(native.loadVerifiedFromUrl).not.toHaveBeenCalled(); }); it('rejects an empty URL', async () => { - await expect(loadVerified('', '0'.repeat(64))).rejects.toThrow(/non-empty/); + await expect(loadVerified('', SHA256_OK)).rejects.toThrow(/non-empty/); }); - it('rejects a sha256 with the wrong length', async () => { + it('rejects a sha256 shorter than 64 chars', async () => { await expect(loadVerified(URL_OK, '0'.repeat(63))).rejects.toThrow( /64-character/ ); + expect(native.loadVerifiedFromUrl).not.toHaveBeenCalled(); + }); + + it('rejects a sha256 longer than 64 chars', async () => { await expect(loadVerified(URL_OK, '0'.repeat(65))).rejects.toThrow( /64-character/ ); + expect(native.loadVerifiedFromUrl).not.toHaveBeenCalled(); }); it('rejects when sha256 is not a string', async () => { await expect( loadVerified(URL_OK, (null as unknown) as string) ).rejects.toThrow(/64-character/); + expect(native.loadVerifiedFromUrl).not.toHaveBeenCalled(); }); }); -describe('loadVerified — network and integrity', () => { - it('throws when fetch returns non-ok', async () => { - stubFetchFail(404); - await expect(loadVerified(URL_OK, '0'.repeat(64))).rejects.toThrow( - /HTTP 404/ - ); - expect(native.loadFromBase64).not.toHaveBeenCalled(); - }); - - it('throws on hash mismatch and does not invoke the native loader', async () => { - const bytes = new Uint8Array([1, 2, 3, 4]); - stubFetchOk(bytes); - await expect(loadVerified(URL_OK, 'a'.repeat(64))).rejects.toThrow( - /hash mismatch/ - ); - expect(native.loadFromBase64).not.toHaveBeenCalled(); - }); - - it('rejects a near-miss hash (one hex digit flipped)', async () => { - const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); - const real = sha256Hex(bytes); - const last = real[real.length - 1]; - const flipped = last === '0' ? '1' : '0'; - const nearMiss = real.slice(0, -1) + flipped; - stubFetchOk(bytes); - - await expect(loadVerified(URL_OK, nearMiss)).rejects.toThrow( - /hash mismatch/ - ); - expect(native.loadFromBase64).not.toHaveBeenCalled(); - }); - - it('passes the verified bytes to native.loadFromBase64 on match', async () => { - const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); - stubFetchOk(bytes); - - await loadVerified(URL_OK, sha256Hex(bytes)); - - expect(native.loadFromBase64).toHaveBeenCalledTimes(1); - const base64Arg = native.loadFromBase64.mock.calls[0][0] as string; - const decoded = Buffer.from(base64Arg, 'base64'); - expect(Array.from(decoded)).toEqual(Array.from(bytes)); - }); -}); - -describe('loadVerified — native module wiring', () => { +describe('loadVerified — native wiring', () => { it('throws when the native module is not linked', async () => { - const bytes = new Uint8Array([1, 2, 3]); - stubFetchOk(bytes); const saved = (NativeModules as Record).BundleLoader; (NativeModules as Record).BundleLoader = undefined; try { - await expect(loadVerified(URL_OK, sha256Hex(bytes))).rejects.toThrow( + await expect(loadVerified(URL_OK, SHA256_OK)).rejects.toThrow( /not linked/ ); } finally { @@ -182,20 +72,38 @@ describe('loadVerified — native module wiring', () => { } }); - it('throws when native.loadFromBase64 is missing', async () => { - const bytes = new Uint8Array([1, 2, 3]); - stubFetchOk(bytes); + it('throws when loadVerifiedFromUrl is not available on the native module', async () => { const saved = (NativeModules as Record).BundleLoader; (NativeModules as Record).BundleLoader = { load: jest.fn(), runningMode: jest.fn(), }; try { - await expect(loadVerified(URL_OK, sha256Hex(bytes))).rejects.toThrow( - /loadFromBase64 not available/ + await expect(loadVerified(URL_OK, SHA256_OK)).rejects.toThrow( + /loadVerifiedFromUrl is not available/ ); } finally { (NativeModules as Record).BundleLoader = saved; } }); + + it('calls loadVerifiedFromUrl with the url and sha256', async () => { + await loadVerified(URL_OK, SHA256_OK); + expect(native.loadVerifiedFromUrl).toHaveBeenCalledTimes(1); + expect(native.loadVerifiedFromUrl).toHaveBeenCalledWith(URL_OK, SHA256_OK); + }); + + it('resolves when loadVerifiedFromUrl resolves', async () => { + native.loadVerifiedFromUrl.mockResolvedValue(undefined); + await expect(loadVerified(URL_OK, SHA256_OK)).resolves.toBeUndefined(); + }); + + it('rejects when loadVerifiedFromUrl rejects', async () => { + native.loadVerifiedFromUrl.mockRejectedValue( + new Error('E_HASH_MISMATCH: Bundle hash mismatch') + ); + await expect(loadVerified(URL_OK, SHA256_OK)).rejects.toThrow( + /hash mismatch/ + ); + }); }); diff --git a/src/index.tsx b/src/index.tsx index f706674..c65a31c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,15 +12,12 @@ import { TouchableOpacity, View, } from 'react-native'; -import { hash } from '@exodus/crypto/hash'; -import { fromHex } from '@exodus/bytes/hex.js'; -import { toBase64 } from '@exodus/bytes/base64.js'; export type RunningMode = 'LOCAL' | 'REMOTE'; type NativeBundleLoader = { load(url: string): void; - loadFromBase64?(base64: string): Promise; + loadVerifiedFromUrl?(url: string, sha256: string): Promise; runningMode(): Promise; }; @@ -46,15 +43,6 @@ function assertSafeUrl(url: string): void { } } -function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - let diff = 0; - /* eslint-disable no-bitwise */ - for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; - /* eslint-enable no-bitwise */ - return diff === 0; -} - export async function loadVerified( url: string, expectedSha256Hex: string @@ -66,27 +54,16 @@ export async function loadVerified( ) { throw new Error('Expected sha256 must be a 64-character hex string'); } - const expected = fromHex(expectedSha256Hex); - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Bundle fetch failed: HTTP ${response.status}`); - } - const buffer = await response.arrayBuffer(); - const bytes = new Uint8Array(buffer); - const actual = await hash('sha256', bytes, 'uint8'); - - if (!timingSafeEqualBytes(actual, expected)) { - throw new Error('Bundle hash mismatch — refusing to load'); - } const native = getNative(); - if (typeof native.loadFromBase64 !== 'function') { + + if (typeof native.loadVerifiedFromUrl !== 'function') { throw new Error( - 'Native loadFromBase64 not available. Rebuild the host app after upgrading.' + 'loadVerifiedFromUrl is not available on this platform. Rebuild the host app with the latest native module.' ); } - await native.loadFromBase64(toBase64(bytes)); + + await native.loadVerifiedFromUrl(url, expectedSha256Hex); } function loadUnverified(url: string): void { diff --git a/yarn.lock b/yarn.lock index d887049..3ef34b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1049,22 +1049,6 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@exodus/bytes@1.15.0", "@exodus/bytes@^1.0.0-rc.4": - version "1.15.0" - resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.0.tgz#54479e0f406cbad024d6fe1c3190ecca4468df3b" - integrity sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ== - -"@exodus/crypto@1.0.0-rc.34": - version "1.0.0-rc.34" - resolved "https://registry.yarnpkg.com/@exodus/crypto/-/crypto-1.0.0-rc.34.tgz#eec87df3d4fea77821657ab52836b1378f2a1428" - integrity sha512-vatlfAs8fBJ7drzM1uRtN4y8Q6CZix6sDwRSyuTMPzPFYWo97Fw/no3yztiVBPtaoXnG57+hpiXu3diltOGsfg== - dependencies: - "@exodus/bytes" "^1.0.0-rc.4" - "@noble/ciphers" "^1.2.1" - "@noble/curves" "^2.0.1" - "@noble/hashes" "^2.0.1" - pako "^1.0.11" - "@hapi/address@2.x.x": version "2.1.4" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -1339,22 +1323,6 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@noble/ciphers@^1.2.1": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-1.3.0.tgz#f64b8ff886c240e644e5573c097f86e5b43676dc" - integrity sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw== - -"@noble/curves@^2.0.1": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.2.0.tgz#981be3aadc3bbfbcdb245e78cc97aa6f759246c2" - integrity sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ== - dependencies: - "@noble/hashes" "2.2.0" - -"@noble/hashes@2.2.0", "@noble/hashes@^2.0.1": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" - integrity sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg== "@nodelib/fs.scandir@2.1.3": version "2.1.3" @@ -6088,10 +6056,6 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -pako@^1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" - integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== parent-module@^1.0.0: version "1.0.1"