@onekeyfe/react-native-split-bundle-loader 3.0.20 → 3.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,10 @@ import com.facebook.react.bridge.Promise
7
7
  import com.facebook.react.bridge.ReactApplicationContext
8
8
  import com.facebook.react.module.annotations.ReactModule
9
9
  import java.io.File
10
+ import java.io.FileInputStream
10
11
  import java.io.FileOutputStream
11
12
  import java.io.IOException
13
+ import java.security.MessageDigest
12
14
  import java.util.concurrent.Semaphore
13
15
 
14
16
  /**
@@ -171,6 +173,13 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
171
173
  try {
172
174
  ensureExtractDirFreshForCurrentInstall(reactApplicationContext)
173
175
  val absolutePath = resolveSegmentPath(relativePath, sha256)
176
+ if (absolutePath != null && !verifySha256(absolutePath, sha256)) {
177
+ promise.reject(
178
+ "SPLIT_BUNDLE_SHA256_MISMATCH",
179
+ "Segment SHA-256 mismatch: $relativePath"
180
+ )
181
+ return
182
+ }
174
183
  if (absolutePath != null) {
175
184
  promise.resolve(absolutePath)
176
185
  } else {
@@ -195,10 +204,6 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
195
204
  sha256: String,
196
205
  promise: Promise
197
206
  ) {
198
- // NOTE (#44): sha256 param is not verified at load time by design.
199
- // Per §6.4.1, runtime trusts that OTA install has already verified
200
- // segment integrity. Builtin segments are signed as part of the APK/IPA.
201
- // If runtime SHA-256 verification is needed, add it here.
202
207
  try {
203
208
  ensureExtractDirFreshForCurrentInstall(reactApplicationContext)
204
209
  val segId = segmentId.toInt()
@@ -212,6 +217,18 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
212
217
  return
213
218
  }
214
219
 
220
+ // Per-segment integrity check at load time. Replaces the legacy
221
+ // assumption that install-time validation is sufficient: now
222
+ // BundleUpdate skips full-tree sha256 on startup hot path, so
223
+ // segment integrity is enforced lazily here.
224
+ if (!verifySha256(absolutePath, sha256)) {
225
+ promise.reject(
226
+ "SPLIT_BUNDLE_SHA256_MISMATCH",
227
+ "Segment SHA-256 mismatch: $relativePath (key=$segmentKey)"
228
+ )
229
+ return
230
+ }
231
+
215
232
  // Use ReactContext.registerSegment which works in both bridge
216
233
  // and bridgeless modes. In bridge mode it delegates to
217
234
  // CatalystInstance; in bridgeless mode it delegates to ReactHost.
@@ -409,4 +426,70 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
409
426
  null
410
427
  }
411
428
  }
429
+
430
+ /**
431
+ * Reflectively asks bundle-update whether the current bundle's manifest
432
+ * is required to ship per-segment SHA-256s (three-bundle / split-thread
433
+ * format). When this returns true, an empty [expected] in [verifySha256]
434
+ * is treated as a hard fail instead of the back-compat skip.
435
+ *
436
+ * Returns false (back-compat allowed) if the symbol is absent — older
437
+ * bundle-update versions don't expose this and we don't want to wedge
438
+ * the loader on an upgrade race.
439
+ */
440
+ private fun currentBundleRequiresPerSegmentHash(): Boolean {
441
+ return try {
442
+ val bundleUpdateStore = Class.forName(
443
+ "com.margelo.nitro.reactnativebundleupdate.BundleUpdateStoreAndroid"
444
+ )
445
+ val method = bundleUpdateStore.getMethod(
446
+ "currentBundleRequiresPerSegmentHash",
447
+ Context::class.java
448
+ )
449
+ // Kotlin `object` instance methods need INSTANCE; @JvmStatic
450
+ // exposes a static method with the same name. Try the static
451
+ // call first, then fall back to INSTANCE so we work either way
452
+ // if the bundle-update side toggles @JvmStatic in the future.
453
+ val result = try {
454
+ method.invoke(null, reactApplicationContext)
455
+ } catch (_: NullPointerException) {
456
+ val instance = bundleUpdateStore.getField("INSTANCE").get(null)
457
+ method.invoke(instance, reactApplicationContext)
458
+ } catch (_: IllegalArgumentException) {
459
+ val instance = bundleUpdateStore.getField("INSTANCE").get(null)
460
+ method.invoke(instance, reactApplicationContext)
461
+ }
462
+ (result as? Boolean) ?: false
463
+ } catch (_: Exception) {
464
+ false
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Streams [path] and compares its SHA-256 against [expected] (hex,
470
+ * case-insensitive). When [expected] is empty, defers to bundle-update:
471
+ * three-bundle format manifests must ship per-segment hashes (fail
472
+ * closed); older formats are allowed to omit them (back-compat).
473
+ */
474
+ private fun verifySha256(path: String, expected: String): Boolean {
475
+ if (expected.isEmpty()) {
476
+ return !currentBundleRequiresPerSegmentHash()
477
+ }
478
+ return try {
479
+ val md = MessageDigest.getInstance("SHA-256")
480
+ FileInputStream(path).use { input ->
481
+ val buf = ByteArray(64 * 1024)
482
+ while (true) {
483
+ val n = input.read(buf)
484
+ if (n <= 0) break
485
+ md.update(buf, 0, n)
486
+ }
487
+ }
488
+ val actual = md.digest().joinToString("") { "%02x".format(it) }
489
+ actual.equals(expected, ignoreCase = true)
490
+ } catch (e: Exception) {
491
+ SBLLogger.warn("verifySha256 failed for $path: ${e.message}")
492
+ false
493
+ }
494
+ }
412
495
  }
@@ -17,7 +17,7 @@
17
17
 
18
18
  /// Evaluate a JS bundle file inside the given RCTHost's runtime.
19
19
  /// Used for the common + entry split-bundle loading strategy:
20
- /// 1. RCTHost boots with common.jsbundle (polyfills + shared modules)
20
+ /// 1. RCTHost boots with common.bundle (polyfills + shared modules)
21
21
  /// 2. After the runtime is ready, this evaluates the entry-specific bundle
22
22
  /// (main.jsbundle or background.bundle) via jsi::Runtime::evaluateJavaScript.
23
23
  ///
@@ -4,10 +4,76 @@
4
4
  #import <ReactCommon/RCTHost+Internal.h>
5
5
  #import <ReactCommon/RCTInstance.h>
6
6
  #import <objc/runtime.h>
7
+ #import <CommonCrypto/CommonDigest.h>
7
8
  #include <jsi/jsi.h>
8
9
 
9
10
  @implementation SplitBundleLoader
10
11
 
12
+ // Reflectively asks bundle-update whether the current bundle's manifest is
13
+ // required to ship per-segment SHA-256s (three-bundle / split-thread format).
14
+ // When this returns YES, an empty `expected` in verifySha256OfFile:expected:
15
+ // is treated as a hard fail instead of the back-compat skip. Returns NO when
16
+ // the symbol is absent so older bundle-update versions don't wedge the loader.
17
+ + (BOOL)currentBundleRequiresPerSegmentHash
18
+ {
19
+ Class cls = NSClassFromString(@"ReactNativeBundleUpdate.BundleUpdateStore");
20
+ if (!cls) return NO;
21
+ SEL sel = NSSelectorFromString(@"currentBundleRequiresPerSegmentHash");
22
+ if (![cls respondsToSelector:sel]) return NO;
23
+ NSMethodSignature *sig = [cls methodSignatureForSelector:sel];
24
+ if (!sig) return NO;
25
+ // BOOL on Apple platforms is signed char (or bool on arm64); accept both.
26
+ const char *retType = sig.methodReturnType;
27
+ if (strcmp(retType, @encode(BOOL)) != 0 &&
28
+ strcmp(retType, @encode(char)) != 0 &&
29
+ strcmp(retType, @encode(bool)) != 0) {
30
+ return NO;
31
+ }
32
+ NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
33
+ inv.target = cls;
34
+ inv.selector = sel;
35
+ [inv invoke];
36
+ BOOL result = NO;
37
+ [inv getReturnValue:&result];
38
+ return result;
39
+ }
40
+
41
+ // Segment-time SHA-256 verification helper. Returns YES on match, NO on
42
+ // mismatch or read failure. Streams the file with a fixed-size buffer so a
43
+ // large segment doesn't pull the whole bundle into memory just to hash it.
44
+ + (BOOL)verifySha256OfFile:(NSString *)path expected:(NSString *)expected
45
+ {
46
+ if (expected.length == 0) {
47
+ // Defer to bundle-update: three-bundle format manifests must ship
48
+ // per-segment hashes (fail closed); older formats are allowed to omit
49
+ // them (back-compat).
50
+ return ![SplitBundleLoader currentBundleRequiresPerSegmentHash];
51
+ }
52
+ NSFileHandle *handle = [NSFileHandle fileHandleForReadingAtPath:path];
53
+ if (!handle) {
54
+ return NO;
55
+ }
56
+ CC_SHA256_CTX ctx;
57
+ CC_SHA256_Init(&ctx);
58
+ @try {
59
+ const NSUInteger chunk = 64 * 1024;
60
+ while (1) {
61
+ NSData *data = [handle readDataOfLength:chunk];
62
+ if (data.length == 0) break;
63
+ CC_SHA256_Update(&ctx, data.bytes, (CC_LONG)data.length);
64
+ }
65
+ } @finally {
66
+ [handle closeFile];
67
+ }
68
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
69
+ CC_SHA256_Final(digest, &ctx);
70
+ NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
71
+ for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) {
72
+ [hex appendFormat:@"%02x", digest[i]];
73
+ }
74
+ return [hex caseInsensitiveCompare:expected] == NSOrderedSame;
75
+ }
76
+
11
77
  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
12
78
  (const facebook::react::ObjCTurboModule::InitParams &)params
13
79
  {
@@ -175,13 +241,19 @@
175
241
  }
176
242
 
177
243
  NSString *absolutePath = [SplitBundleLoader resolveAbsolutePath:relativePath];
178
- if (absolutePath) {
179
- resolve(absolutePath);
180
- } else {
244
+ if (!absolutePath) {
181
245
  reject(@"SPLIT_BUNDLE_NOT_FOUND",
182
246
  [NSString stringWithFormat:@"Segment file not found: %@", relativePath],
183
247
  nil);
248
+ return;
184
249
  }
250
+ if (![SplitBundleLoader verifySha256OfFile:absolutePath expected:sha256]) {
251
+ reject(@"SPLIT_BUNDLE_SHA256_MISMATCH",
252
+ [NSString stringWithFormat:@"Segment SHA-256 mismatch: %@", relativePath],
253
+ nil);
254
+ return;
255
+ }
256
+ resolve(absolutePath);
185
257
  } @catch (NSException *exception) {
186
258
  reject(@"SPLIT_BUNDLE_RESOLVE_ERROR", exception.reason, nil);
187
259
  }
@@ -263,6 +335,13 @@
263
335
  return;
264
336
  }
265
337
 
338
+ if (![SplitBundleLoader verifySha256OfFile:absolutePath expected:sha256]) {
339
+ reject(@"SPLIT_BUNDLE_SHA256_MISMATCH",
340
+ [NSString stringWithFormat:@"Segment SHA-256 mismatch: %@ (key=%@)", relativePath, segmentKey],
341
+ nil);
342
+ return;
343
+ }
344
+
266
345
  // Register segment (#13: supports both bridge and bridgeless)
267
346
  NSError *regError = nil;
268
347
  if ([SplitBundleLoader registerSegment:segId path:absolutePath error:&regError]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-split-bundle-loader",
3
- "version": "3.0.20",
3
+ "version": "3.0.22",
4
4
  "description": "react-native-split-bundle-loader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -82,6 +82,7 @@
82
82
  "typescript": "^5.9.2"
83
83
  },
84
84
  "peerDependencies": {
85
+ "@onekeyfe/react-native-bundle-update": ">=3.0.22",
85
86
  "react": "*",
86
87
  "react-native": "*"
87
88
  },