@scalebun/react-native 1.13.0 → 2.0.1

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.
Files changed (115) hide show
  1. package/android/build.gradle +8 -0
  2. package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +10 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +132 -62
  8. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  9. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
  10. package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
  11. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
  12. package/dist/scalebun.full.js +964 -533
  13. package/dist/scalebun.slim.js +962 -531
  14. package/ios/Ota/OtaProtocol.swift +242 -0
  15. package/ios/Ota/OtaSlotManager.swift +26 -0
  16. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  17. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  18. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  19. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  20. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  21. package/ios/ReplaySdk.swift +65 -2
  22. package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
  23. package/lib/commonjs/core/config/schema.js +19 -12
  24. package/lib/commonjs/core/constants/version.js +1 -1
  25. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
  26. package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
  27. package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
  28. package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
  29. package/lib/commonjs/features/journey/scrollContext.js +146 -0
  30. package/lib/commonjs/features/journey/targetGeometry.js +164 -0
  31. package/lib/commonjs/features/journey/touchTarget.js +152 -24
  32. package/lib/commonjs/features/journey/uiState.js +81 -1
  33. package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
  34. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  35. package/lib/commonjs/features/network/thirdParty.js +90 -0
  36. package/lib/commonjs/features/ota/OtaOrchestrator.js +175 -80
  37. package/lib/commonjs/features/session/SessionManager.js +63 -0
  38. package/lib/commonjs/public/ScaleBunFacade.js +23 -57
  39. package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
  40. package/lib/module/core/config/schema.js +19 -12
  41. package/lib/module/core/constants/version.js +1 -1
  42. package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
  43. package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
  44. package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
  45. package/lib/module/features/journey/interactionProtocol.js +128 -8
  46. package/lib/module/features/journey/scrollContext.js +135 -0
  47. package/lib/module/features/journey/targetGeometry.js +155 -0
  48. package/lib/module/features/journey/touchTarget.js +151 -24
  49. package/lib/module/features/journey/uiState.js +78 -1
  50. package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
  51. package/lib/module/features/network/NetworkFeature.js +38 -4
  52. package/lib/module/features/network/thirdParty.js +83 -0
  53. package/lib/module/features/ota/OtaOrchestrator.js +175 -80
  54. package/lib/module/features/session/SessionManager.js +63 -0
  55. package/lib/module/public/ScaleBunFacade.js +23 -57
  56. package/lib/typescript/core/config/schema.d.ts +2 -3
  57. package/lib/typescript/core/constants/version.d.ts +1 -1
  58. package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
  59. package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
  60. package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
  61. package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
  62. package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
  63. package/lib/typescript/features/journey/uiState.d.ts +39 -0
  64. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
  65. package/lib/typescript/features/network/index.d.ts +18 -0
  66. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  67. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +12 -8
  68. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  69. package/lib/typescript/features/session/SessionManager.d.ts +55 -0
  70. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  71. package/lib/typescript/public/types.d.ts +10 -0
  72. package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
  73. package/package.json +3 -13
  74. package/scalebun-react-native.podspec +4 -0
  75. package/src/bootstrap/SDKBootstrapper.ts +40 -0
  76. package/src/core/config/schema.ts +21 -9
  77. package/src/core/constants/version.ts +1 -1
  78. package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
  79. package/src/features/journey/ScaleBunScrollView.tsx +29 -0
  80. package/src/features/journey/autoInstrumentScroll.ts +155 -0
  81. package/src/features/journey/interactionProtocol.ts +188 -10
  82. package/src/features/journey/scrollContext.ts +137 -0
  83. package/src/features/journey/targetGeometry.ts +176 -0
  84. package/src/features/journey/touchTarget.ts +237 -26
  85. package/src/features/journey/uiState.ts +80 -1
  86. package/src/features/navigation/AutoScreenDetector.ts +65 -5
  87. package/src/features/network/NetworkFeature.ts +41 -4
  88. package/src/features/network/index.ts +18 -0
  89. package/src/features/network/thirdParty.ts +92 -0
  90. package/src/features/ota/OtaOrchestrator.ts +190 -90
  91. package/src/features/ota/OtaTypes.ts +23 -1
  92. package/src/features/session/SessionManager.ts +113 -0
  93. package/src/public/ScaleBunFacade.ts +23 -69
  94. package/src/public/types.ts +10 -0
  95. package/src/specs/NativeScaleBunOta.ts +35 -20
  96. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  97. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  98. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  99. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  100. package/lib/commonjs/features/ota/signature.js +0 -175
  101. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  102. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  103. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  104. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  105. package/lib/module/features/ota/signature.js +0 -169
  106. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  107. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  108. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  109. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  110. package/lib/typescript/features/ota/signature.d.ts +0 -81
  111. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  112. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  113. package/src/features/ota/crypto/loadSha512.ts +0 -35
  114. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  115. package/src/features/ota/signature.ts +0 -206
@@ -1,7 +1,6 @@
1
1
  import Foundation
2
2
  import React
3
3
  import CommonCrypto
4
- import CryptoKit
5
4
 
6
5
  /**
7
6
  * React Native bridge for OTA bundle management (`ScaleBunOta`) on iOS.
@@ -21,6 +20,21 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
21
20
 
22
21
  private static let slotManager = OtaSlotManager()
23
22
 
23
+ /// Trusted RSA public keys embedded in the app bundle (docs §9). Non-empty
24
+ /// once the app ships a `ScaleBunOtaKeys.json` resource — which flips staging
25
+ /// into the native-ENFORCED path (see `stageBundle`).
26
+ private static let keyRegistry = ScaleBunOtaKeyRegistry.fromBundle()
27
+
28
+ /// Device-derived verification context — never taken from JS.
29
+ private static func buildContext() -> ExpectedContext {
30
+ ExpectedContext(
31
+ projectId: keyRegistry.projectId,
32
+ applicationId: Bundle.main.bundleIdentifier ?? "",
33
+ currentSequence: slotManager.highestConfirmedSequence(),
34
+ nowEpochSeconds: Int64(Date().timeIntervalSince1970)
35
+ )
36
+ }
37
+
24
38
  static func moduleName() -> String! { "ScaleBunOta" }
25
39
 
26
40
  @objc static func requiresMainQueueSetup() -> Bool { false }
@@ -73,6 +87,17 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
73
87
  return ScaleBunOtaModule.slotManager.slotStateJSON() as NSString
74
88
  }
75
89
 
90
+ /// JSON array of the trusted key IDs embedded in this app (SDK trust coverage).
91
+ @objc(getTrustedKeyIds)
92
+ func getTrustedKeyIds() -> NSString {
93
+ let ids = ScaleBunOtaModule.keyRegistry.keyIds()
94
+ if let data = try? JSONSerialization.data(withJSONObject: ids),
95
+ let s = String(data: data, encoding: .utf8) {
96
+ return s as NSString
97
+ }
98
+ return "[]"
99
+ }
100
+
76
101
  @objc(stageBundle:sha256:resolve:reject:)
77
102
  /// Stage a bundle into the `next` slot.
78
103
  ///
@@ -85,6 +110,16 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
85
110
  sha256: NSString,
86
111
  resolve: @escaping RCTPromiseResolveBlock,
87
112
  reject: @escaping RCTPromiseRejectBlock) {
113
+ // Native-enforced staging (docs §17): once trusted keys are embedded, the
114
+ // ONLY way to stage is verifyAndStageRelease, which proves an RSA signature
115
+ // over the release payload first. SHA-256 integrity alone (what this path
116
+ // checks) is not authenticity, so a hostile JS bundle cannot use it to
117
+ // drive an unauthenticated bundle onto the device.
118
+ if !ScaleBunOtaModule.keyRegistry.isEmpty {
119
+ NSLog("[ScaleBunOta] stageBundle refused: trusted keys embedded — use verifyAndStageRelease")
120
+ resolve(false)
121
+ return
122
+ }
88
123
  let source = sourcePath as String
89
124
  let meta: [String: Any] = [
90
125
  "sha256": sha256 as String,
@@ -140,6 +175,16 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
140
175
  expectedSha256: NSString,
141
176
  resolve: @escaping RCTPromiseResolveBlock,
142
177
  reject: @escaping RCTPromiseRejectBlock) {
178
+ // Same native-enforcement as stageBundle: a bsdiff patch staged by hash
179
+ // alone carries no signature, so once trusted keys are embedded this path
180
+ // is closed. Result-anchored signed patches are a native follow-up; until
181
+ // then the SDK falls back to the verified full download. false = "patch
182
+ // unavailable".
183
+ if !ScaleBunOtaModule.keyRegistry.isEmpty {
184
+ NSLog("[ScaleBunOta] stagePatch disabled while trusted keys are embedded — full verified download")
185
+ resolve(false)
186
+ return
187
+ }
143
188
  // Patching is relative to the bundle already on disk. With no OTA bundle
144
189
  // installed the source would be the app's built-in bundle, which the
145
190
  // server did not diff against — so a full download is the correct path.
@@ -255,68 +300,95 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
255
300
  }
256
301
 
257
302
  /**
258
- * Verify a detached ed25519 signature with PLATFORM crypto (CryptoKit).
259
- *
260
- * Why native: the JS verifier lives inside the very bundle it protects —
261
- * one malicious bundle can neuter it for every update after. CryptoKit is
262
- * outside the bundle's reach, and unlike Android there is no OS floor to
263
- * worry about: Curve25519 signing shipped in iOS 13 and the podspec
264
- * already requires 13.4.
265
- *
266
- * Contract (mirrors `src/specs/NativeScaleBunOta.ts`): inputs are
267
- * lowercase hex pre-validated by the JS caller; the message is the RAW 32
268
- * BYTES of the bundle SHA-256 (the CLI signs digest bytes, not hex text);
269
- * resolves 'valid' / 'invalid' / 'unavailable'. A wrong signature is
270
- * 'invalid', never a rejection; anything unexpected is 'unavailable' so
271
- * machinery failure can never read as a pass.
303
+ * Verify a SB-OTA-RSA-SHA256-V1 release envelope with PLATFORM crypto
304
+ * (`SecKeyVerifySignature`) and the EMBEDDED trusted-key registry — no
305
+ * download, no side effects. Resolves the verdict JSON (`{ ok, code?, keyId?,
306
+ * releaseId?, bundleId?, releaseSequence?, artifactSha256?, artifactSize?,
307
+ * resultArtifactSha256?, fingerprint? }`). A failed check is `ok:false` with a
308
+ * stable `code`, never a rejected promise. Native because the JS verifier
309
+ * lives inside the very bundle it protects, and the trusted key must come from
310
+ * the app bundle, not from JS or the server.
272
311
  *
273
- * ⚪ UNCOMPILED no macOS toolchain has been available in this workspace
274
- * (the standing gap: BsPatch.swift shipped the same way). The algorithm is
275
- * pinned by `tools/ed25519-parity` against the live production fixture;
276
- * this file still needs one Xcode build before an iOS consumer ships it.
312
+ * ⚪ Not compiled in this workspace (no macOS toolchain). The RSA/SHA-256
313
+ * algorithm + wire format are gated by tools/rsa-ota-parity against the shared
314
+ * fixtures, and ScaleBunOtaVerifierTests is provided to run on a simulator.
277
315
  */
278
- @objc(verifyEd25519:signatureHex:publicKeyHex:resolve:reject:)
279
- func verifyEd25519(_ messageHex: NSString,
280
- signatureHex: NSString,
281
- publicKeyHex: NSString,
316
+ @objc(verifyRelease:resolve:reject:)
317
+ func verifyRelease(_ envelopeJson: NSString,
282
318
  resolve: @escaping RCTPromiseResolveBlock,
283
319
  reject: @escaping RCTPromiseRejectBlock) {
284
- guard let message = ScaleBunOtaModule.bytesFromHex(messageHex as String),
285
- let signature = ScaleBunOtaModule.bytesFromHex(signatureHex as String),
286
- let rawKey = ScaleBunOtaModule.bytesFromHex(publicKeyHex as String),
287
- rawKey.count == 32 else {
288
- // Malformed input from our own caller is not a forgery verdict.
289
- resolve("unavailable")
320
+ let verdict = ScaleBunOtaReleaseVerifier.verify(
321
+ envelopeJson: envelopeJson as String,
322
+ expected: ScaleBunOtaModule.buildContext(),
323
+ registry: ScaleBunOtaModule.keyRegistry
324
+ )
325
+ resolve(verdict.toJson())
326
+ }
327
+
328
+ /**
329
+ * Native-ENFORCED install (docs §5, §17): verify the RSA signature against an
330
+ * embedded key, THEN download, enforce the SIGNED size + SHA-256, and stage
331
+ * into the `next` slot. JS never stages bytes that were not proven authentic
332
+ * (once keys are embedded, `stageBundle` refuses). Does not activate —
333
+ * `applyUpdate` promotes per the install mode. Resolves the verdict JSON.
334
+ */
335
+ @objc(verifyAndStageRelease:resolve:reject:)
336
+ func verifyAndStageRelease(_ envelopeJson: NSString,
337
+ resolve: @escaping RCTPromiseResolveBlock,
338
+ reject: @escaping RCTPromiseRejectBlock) {
339
+ let verdict = ScaleBunOtaReleaseVerifier.verify(
340
+ envelopeJson: envelopeJson as String,
341
+ expected: ScaleBunOtaModule.buildContext(),
342
+ registry: ScaleBunOtaModule.keyRegistry
343
+ )
344
+ guard verdict.ok, let payload = verdict.payload else {
345
+ resolve(verdict.toJson())
290
346
  return
291
347
  }
292
- do {
293
- let key = try Curve25519.Signing.PublicKey(rawRepresentation: rawKey)
294
- resolve(key.isValidSignature(signature, for: message) ? "valid" : "invalid")
295
- } catch {
296
- // Bytes that are not a curve point cannot have signed anything.
297
- resolve("invalid")
348
+
349
+ guard let data = (envelopeJson as String).data(using: .utf8),
350
+ let env = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
351
+ let url = env["downloadUrl"] as? String, !url.isEmpty else {
352
+ resolve(Verdict.fail(.DOWNLOAD_FAILED, "envelope has no downloadUrl").toJson())
353
+ return
298
354
  }
299
- }
300
355
 
301
- /// Hex Data; nil for odd length or a non-hex character.
302
- private static func bytesFromHex(_ hex: String) -> Data? {
303
- let chars = Array(hex.utf8)
304
- guard !chars.isEmpty, chars.count % 2 == 0 else { return nil }
305
- var out = Data(capacity: chars.count / 2)
306
- for i in stride(from: 0, to: chars.count, by: 2) {
307
- guard let hi = ScaleBunOtaModule.hexNibble(chars[i]),
308
- let lo = ScaleBunOtaModule.hexNibble(chars[i + 1]) else { return nil }
309
- out.append((hi << 4) | lo)
356
+ let temp = FileManager.default.temporaryDirectory
357
+ .appendingPathComponent("scalebun_ota_dl_\(UUID().uuidString)")
358
+ defer { try? FileManager.default.removeItem(at: temp) }
359
+
360
+ guard BundleDownloader.download(urlString: url, destination: temp) else {
361
+ resolve(Verdict.fail(.DOWNLOAD_FAILED, "download failed").toJson())
362
+ return
363
+ }
364
+ // Size first (cheap) against the SIGNED artifactSize.
365
+ let attrs = try? FileManager.default.attributesOfItem(atPath: temp.path)
366
+ let fileSize = (attrs?[.size] as? NSNumber)?.int64Value ?? -1
367
+ if fileSize != payload.artifactSize {
368
+ resolve(Verdict.fail(.ARTIFACT_SIZE_MISMATCH, "downloaded size mismatch").toJson())
369
+ return
310
370
  }
311
- return out
312
- }
313
371
 
314
- private static func hexNibble(_ c: UInt8) -> UInt8? {
315
- switch c {
316
- case 0x30...0x39: return c - 0x30 // 0-9
317
- case 0x61...0x66: return c - 0x61 + 10 // a-f
318
- case 0x41...0x46: return c - 0x41 + 10 // A-F
319
- default: return nil
372
+ // The slot manager streams a SHA-256 and compares against the signed
373
+ // artifactSha256 (== resultArtifactSha256 for a full artifact), so a wrong
374
+ // download cannot be staged. meta carries the sequence used by the
375
+ // anti-downgrade floor once the bundle is confirmed healthy.
376
+ let meta: [String: Any] = [
377
+ "sha256": payload.artifactSha256,
378
+ "releaseSequence": payload.releaseSequence,
379
+ "bundleId": payload.bundleId,
380
+ "releaseId": payload.releaseId,
381
+ "installedAt": Int(Date().timeIntervalSince1970 * 1000)
382
+ ]
383
+ let staged = ScaleBunOtaModule.slotManager.stage(
384
+ sourcePath: temp.path,
385
+ expectedSha256: payload.artifactSha256,
386
+ meta: meta
387
+ )
388
+ if !staged {
389
+ resolve(Verdict.fail(.ARTIFACT_HASH_MISMATCH, "staged bytes failed hash check").toJson())
390
+ return
320
391
  }
392
+ resolve(verdict.toJson())
321
393
  }
322
394
  }
@@ -0,0 +1,115 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ /**
5
+ * SB-OTA-RSA-SHA256-V1 native release verifier for iOS (docs §5) — the mirror of
6
+ * Android's ScaleBunOtaReleaseVerifier. Runs the authoritative order: strict
7
+ * decode → registry-only key resolution → `SecKeyVerifySignature` with
8
+ * `.rsaSignatureMessagePKCS1v15SHA256` over the EXACT signed-payload bytes →
9
+ * parse → cross-check against the device's own identity + the anti-downgrade
10
+ * floor. Fails closed and maps every failure to a stable OtaFailureCode.
11
+ *
12
+ * The message-level algorithm hashes the payload internally, so the payload is
13
+ * passed RAW (never pre-hashed — pre-hashing then using a message-level API would
14
+ * hash twice, PROMPT §11.2).
15
+ */
16
+ struct ExpectedContext {
17
+ let projectId: String
18
+ let applicationId: String // the app's own bundle identifier
19
+ let currentSequence: Int64 // anti-downgrade floor
20
+ let nowEpochSeconds: Int64
21
+ var platform: String = "ios"
22
+ }
23
+
24
+ struct Verdict {
25
+ let ok: Bool
26
+ let code: OtaFailureCode?
27
+ let message: String?
28
+ let payload: SignedRelease?
29
+ let fingerprint: String?
30
+
31
+ static func fail(_ code: OtaFailureCode, _ message: String) -> Verdict {
32
+ Verdict(ok: false, code: code, message: message, payload: nil, fingerprint: nil)
33
+ }
34
+
35
+ func toJson() -> String {
36
+ var o: [String: Any] = ["ok": ok]
37
+ if let c = code { o["code"] = c.rawValue }
38
+ if let m = message { o["message"] = m }
39
+ if let f = fingerprint { o["fingerprint"] = f }
40
+ if let p = payload {
41
+ o["keyId"] = p.keyId
42
+ o["releaseId"] = p.releaseId
43
+ o["bundleId"] = p.bundleId
44
+ o["releaseSequence"] = p.releaseSequence
45
+ o["artifactSha256"] = p.artifactSha256
46
+ o["artifactSize"] = p.artifactSize
47
+ o["resultArtifactSha256"] = p.resultArtifactSha256
48
+ o["artifactType"] = p.artifactType
49
+ }
50
+ guard let data = try? JSONSerialization.data(withJSONObject: o),
51
+ let s = String(data: data, encoding: .utf8) else { return "{\"ok\":false,\"code\":\"INTERNAL_VERIFICATION_ERROR\"}" }
52
+ return s
53
+ }
54
+ }
55
+
56
+ enum ScaleBunOtaReleaseVerifier {
57
+
58
+ static func verify(envelopeJson: String, expected: ExpectedContext, registry: ScaleBunOtaKeyRegistry) -> Verdict {
59
+ do {
60
+ guard let data = envelopeJson.data(using: .utf8),
61
+ let env = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
62
+ return .fail(.INVALID_ENVELOPE, "envelope is not JSON")
63
+ }
64
+ let proto = (env["protocol"] as? String) ?? ""
65
+ if proto != OtaProtocol.signatureAlgorithm { return .fail(.UNSUPPORTED_PROTOCOL, "protocol \(proto)") }
66
+ let signedPayloadB64 = (env["signedPayload"] as? String) ?? ""
67
+ let signatureB64 = (env["signature"] as? String) ?? ""
68
+ let envKeyId = (env["keyId"] as? String) ?? ""
69
+
70
+ let payloadBytes = try OtaProtocol.decodeBase64Strict(signedPayloadB64, OtaProtocol.maxSignedPayloadBytes, "signedPayload")
71
+ let signature = try OtaProtocol.decodeBase64Strict(signatureB64, OtaProtocol.maxSignatureBytes, "signature")
72
+
73
+ let payloadKeyId = try OtaProtocol.peekKeyId(payloadBytes)
74
+ if !envKeyId.isEmpty && envKeyId != payloadKeyId {
75
+ return .fail(.INVALID_ENVELOPE, "envelope keyId disagrees with payload")
76
+ }
77
+ guard let key = registry.get(payloadKeyId) else {
78
+ return .fail(.UNKNOWN_KEY_ID, "no embedded key with id \(payloadKeyId)")
79
+ }
80
+
81
+ // RSA-SHA256 over the EXACT payload bytes, BEFORE parsing.
82
+ var error: Unmanaged<CFError>?
83
+ let valid = SecKeyVerifySignature(
84
+ key.secKey,
85
+ .rsaSignatureMessagePKCS1v15SHA256,
86
+ Data(payloadBytes) as CFData,
87
+ Data(signature) as CFData,
88
+ &error
89
+ )
90
+ if !valid { return .fail(.INVALID_SIGNATURE, "signature did not verify") }
91
+
92
+ let payload = try OtaProtocol.parse(payloadBytes)
93
+
94
+ if payload.artifactType != "full" { return .fail(.INVALID_SIGNED_PAYLOAD, "signed payload is not a full release") }
95
+ let projectExpected = registry.projectId.isEmpty ? expected.projectId : registry.projectId
96
+ if !projectExpected.isEmpty && payload.projectId != projectExpected { return .fail(.PROJECT_MISMATCH, "projectId") }
97
+ if !expected.applicationId.isEmpty && payload.applicationId != expected.applicationId {
98
+ return .fail(.APPLICATION_MISMATCH, "applicationId")
99
+ }
100
+ if payload.platform != expected.platform { return .fail(.PLATFORM_MISMATCH, "platform") }
101
+ if payload.expiresAtEpochSeconds != 0 && expected.nowEpochSeconds > payload.expiresAtEpochSeconds {
102
+ return .fail(.RELEASE_EXPIRED, "release expired")
103
+ }
104
+ if payload.releaseSequence < expected.currentSequence {
105
+ return .fail(.RELEASE_DOWNGRADE_REJECTED, "sequence below current")
106
+ }
107
+
108
+ return Verdict(ok: true, code: nil, message: nil, payload: payload, fingerprint: key.fingerprint)
109
+ } catch let e as OtaVerifyError {
110
+ return .fail(e.code, e.message)
111
+ } catch {
112
+ return .fail(.INTERNAL_VERIFICATION_ERROR, "\(error)")
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,92 @@
1
+ import XCTest
2
+ @testable import scalebun_react_native
3
+
4
+ /**
5
+ * On-device / simulator verification for SB-OTA-RSA-SHA256-V1 (PROMPT §23).
6
+ *
7
+ * Proves the shared committed vector (packages/ota-protocol/fixtures — the SAME
8
+ * bytes the Node codec, backend, JVM harness, and Android test use) verifies with
9
+ * `SecKeyVerifySignature(.rsaSignatureMessagePKCS1v15SHA256)` and the SPKI→PKCS#1
10
+ * import path, and that tampering / unknown keys / downgrades are rejected.
11
+ *
12
+ * ⚪ Not run in this workspace (no macOS toolchain). Add this file to the host
13
+ * app's iOS unit-test target and run: xcodebuild test -scheme <App> -destination
14
+ * 'platform=iOS Simulator,name=iPhone 15'. Constants are copied verbatim from
15
+ * fixtures/vectors/full.json.
16
+ */
17
+ final class ScaleBunOtaVerifierTests: XCTestCase {
18
+
19
+ private let signedPayloadB64 =
20
+ "U0JPVEEwMDEAAQAAABRTQi1PVEEtUlNBLVNIQTI1Ni1WMQAAAAdTSEEtMjU2AAAAFXRlc3QtcnNhLTMwNzItMjAyNi0wMQAAAA5wcm9qX3Rlc3RfMDAwMQAAABVvcmcuc2NhbGVidW4ubGVnYXJhZ2UAAAANcmVsX2Z1bGxfMDAwMQAAAA5ibmRsX2Z1bGxfMDAwMQAAAAdhbmRyb2lkAAAABGZ1bGwAAAAFNC41LjkAAAAGMC43NC4xAAAAAjI0AAAAAAAAACoAAAAAaVW5AAAAAAAAAAAAAAAAAAAAAWD6bpV7WCc0zBI9NJ6rivyixtOu66jZ7EYSJniR99yQMwAAAAAAAAAA+m6Ve1gnNMwSPTSeq4r8osbTruuo2exGEiZ4kffckDM="
21
+ private let signatureB64 =
22
+ "jlC7GRLWKXku3cbqtODlFt+qn2CEmS3oHOr9QTopCCzEovUF5a2GRT0PQ5k2C5EX7UGGO0Sv6/WhkznKR9NVEZl6ed0KARHAY8zfwTjin3czx9jpwV9bfsLlxEoKNo9bPfIs3mc0VoHCWOp95hQAotG+GqEwYsXqPruZ43U87qWT0t9/a3QboX+07hPO5vI4fDZQuPeo3W1HmXPKbOB7QGSijtKgKwQNuJHz+etsVsQelDsDofRSpYV92XCheUWWHP7kZKFOCcYUXnVwJcdU0Jhej+22vAESRK2G0CXP3orXE4pASaA3y4wkBQgJAJkcTN/ic1/Wm5BLo7KH+ypK1eo0hnrrLNSh6E+St5NvxlTM3bmxwnwHkPMYVBuOT0fUJy+gbD1/1CPvKakL12vizKwWDYuhfcIGkQUTvP2zCxcqWLfmZ7ZGo6n6WXMX63Vz+IQG0tnx1cp6YqJVIgxqjskQ47sacsfS9QeV5LyJhvBQrDSbpqujo9la8dDzZfDN"
23
+ private let keyId = "test-rsa-3072-2026-01"
24
+ private let projectId = "proj_test_0001"
25
+ private let applicationId = "org.scalebun.legarage"
26
+
27
+ private let spkiPem = """
28
+ -----BEGIN PUBLIC KEY-----
29
+ MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAt3tfFTPE+lkEbFeYi6zE
30
+ +uI+3oJl0HfKtGTh7sr7pSh/uPJq2giF0Uc09ux3Z0i4NlqpOyLmiIrVOgYcWcN+
31
+ /pCl1mh6VmT7nYO3sEhL+HyaIaclCGxvHCJSsaVHH8qQyiCVUypWWKN0L38U4isN
32
+ dJ2x+Wv7iLSZu3oM+ZDyGWCjRLE49jKNl0tfaQX6G1YoyBi1aEu5sWIQ6LjGE4IX
33
+ 1AJtOiNoaO9orjVgphA5FDKFFqguhEJrHoSXt3awHaeCYFm/mt+NI6k4sRf59UNx
34
+ hdGh0kVL/6jZncclxueFafOOdfPf+jF3ylTczQhnID7Mg1PdmjXR7cfq7jDdjzfW
35
+ VICJqGt+EhUd92pWrMTa5k3SDin3EetYGJsN7iKLbAl0D6Uy8RfE2mdsF2K6D3EP
36
+ 9waHN7vLURCF//TGy84FxPy7vbFgLO4oT07lxaopuGX7vOdONI8qLcFhEAJ9fRVN
37
+ lqPWQGib0iAKE+gLl4CWZen674QZF39fGnOBEz7JsmDfAgMBAAE=
38
+ -----END PUBLIC KEY-----
39
+ """
40
+
41
+ private func registry() throws -> ScaleBunOtaKeyRegistry {
42
+ let json = "{\"projectId\":\"\(projectId)\",\"keys\":[{\"keyId\":\"\(keyId)\",\"publicKey\":\"\(spkiPem.replacingOccurrences(of: "\n", with: "\\n"))\"}]}"
43
+ return try ScaleBunOtaKeyRegistry.fromJson(json)
44
+ }
45
+
46
+ private func context(_ currentSequence: Int64 = 0) -> ExpectedContext {
47
+ ExpectedContext(projectId: projectId, applicationId: applicationId,
48
+ currentSequence: currentSequence, nowEpochSeconds: 1_767_225_660)
49
+ }
50
+
51
+ private func envelope(payload: String? = nil, sig: String? = nil) -> String {
52
+ "{\"protocol\":\"\(OtaProtocol.signatureAlgorithm)\",\"keyId\":\"\(keyId)\"," +
53
+ "\"signedPayload\":\"\(payload ?? signedPayloadB64)\",\"signature\":\"\(sig ?? signatureB64)\"}"
54
+ }
55
+
56
+ func testGenuineReleaseVerifies() throws {
57
+ let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: context(), registry: try registry())
58
+ XCTAssertTrue(v.ok, "expected ok, got \(String(describing: v.code))")
59
+ XCTAssertEqual(v.payload?.bundleId, "bndl_full_0001")
60
+ XCTAssertEqual(v.payload?.releaseSequence, 42)
61
+ XCTAssertEqual(v.payload?.platform, "android") // vector is an android full release
62
+ XCTAssertEqual(v.payload?.artifactSha256, v.payload?.resultArtifactSha256)
63
+ }
64
+
65
+ func testPlatformMismatchRejected() throws {
66
+ // Device context is iOS by default; the vector payload is android.
67
+ var ctx = context(); ctx.platform = "ios"
68
+ let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: ctx, registry: try registry())
69
+ XCTAssertEqual(v.code, .PLATFORM_MISMATCH)
70
+ }
71
+
72
+ func testTamperedPayloadRejected() throws {
73
+ let bad = String(signedPayloadB64.dropLast(2)) + (signedPayloadB64.hasSuffix("M=") ? "N=" : "M=")
74
+ var ctx = context(); ctx.platform = "android"
75
+ let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(payload: bad), expected: ctx, registry: try registry())
76
+ XCTAssertFalse(v.ok)
77
+ XCTAssertTrue(v.code == .INVALID_SIGNATURE || v.code == .INVALID_BASE64)
78
+ }
79
+
80
+ func testUnknownKeyRejected() throws {
81
+ let empty = try ScaleBunOtaKeyRegistry.fromJson("{\"projectId\":\"\(projectId)\",\"keys\":[]}")
82
+ var ctx = context(); ctx.platform = "android"
83
+ let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: ctx, registry: empty)
84
+ XCTAssertEqual(v.code, .UNKNOWN_KEY_ID)
85
+ }
86
+
87
+ func testDowngradeRejected() throws {
88
+ var ctx = context(100); ctx.platform = "android"
89
+ let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: ctx, registry: try registry())
90
+ XCTAssertEqual(v.code, .RELEASE_DOWNGRADE_REJECTED)
91
+ }
92
+ }
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import React
2
3
  import UIKit
3
4
 
4
5
  /// Main ReplaySdk native module (RCTEventEmitter subclass).
@@ -32,12 +33,57 @@ class ReplaySdk: RCTEventEmitter {
32
33
  }
33
34
 
34
35
  /// Attach native touch tracking to the key window.
35
- /// Called from initialize() after a short delay to ensure the window is ready.
36
+ ///
37
+ /// RETRIED, AND RE-ATTACHED ON FOREGROUND — because the previous one-shot version is the most
38
+ /// likely explanation for iOS capturing coordinates on 0.4% of interactions where Android
39
+ /// manages 15.3%.
40
+ ///
41
+ /// It ran exactly once, 500 ms after `initialize()`. If `getKeyWindow()` returned nil at that
42
+ /// instant it logged a warning and never tried again, so native touch tracking was off for the
43
+ /// WHOLE app session. A cold start behind a heavy JS bundle, a slow device, or a scene-based
44
+ /// app whose key window is not the one that existed at +500 ms — any of those and no touch is
45
+ /// ever tracked. Losing that race once lost it permanently.
46
+ ///
47
+ /// Android never had the problem because it attaches from a lifecycle hook
48
+ /// (`ReplaySdkModule.onHostResume`), so every foreground corrects an attach that was too early.
49
+ /// This mirrors that: bounded retries for the cold-start race, plus a foreground observer that
50
+ /// attaches if a previous attempt never succeeded. `attach` is idempotent —
51
+ /// `guard !isActive else { return }` — so a redundant call costs nothing and cannot install a
52
+ /// second gesture recognizer.
53
+ ///
54
+ /// WHAT THIS DOES NOT FIX, stated because that same guard is why: a key window that CHANGES
55
+ /// after a successful attach. `attach` returns early on `isActive` without comparing the
56
+ /// window, so the tracker stays bound to the original one and the foreground observer cannot
57
+ /// help. That needs a detach-and-rebind inside `InteractionTracker`, which is a larger change
58
+ /// than the race this addresses. The cold-start race is the dominant cause — it costs the whole
59
+ /// session every time it is lost — and it is fixed here; the window-change case remains open.
36
60
  private func activateNativeTracking() {
61
+ scheduleAttach(attemptsRemaining: 10)
62
+ NotificationCenter.default.addObserver(
63
+ self,
64
+ selector: #selector(reattachNativeTracking),
65
+ name: UIApplication.didBecomeActiveNotification,
66
+ object: nil
67
+ )
68
+ }
69
+
70
+ /// Try to attach, and try again shortly if there is no key window yet.
71
+ ///
72
+ /// Ten attempts at 500 ms covers about five seconds of startup, which is far longer than a
73
+ /// window takes to appear and short enough that a genuinely window-less process stops asking.
74
+ /// The last failure is logged at warn so a host that never gets tracking is told, rather than
75
+ /// discovering it as an empty heatmap.
76
+ private func scheduleAttach(attemptsRemaining: Int) {
37
77
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
38
78
  guard let self = self else { return }
39
79
  guard let window = Self.getKeyWindow() else {
40
- ReplayLogger.warn("Native tracking: no key window available")
80
+ if attemptsRemaining > 1 {
81
+ self.scheduleAttach(attemptsRemaining: attemptsRemaining - 1)
82
+ } else {
83
+ ReplayLogger.warn(
84
+ "Native tracking: no key window after repeated attempts — touch capture is off"
85
+ )
86
+ }
41
87
  return
42
88
  }
43
89
  self.interactionTracker.attach(to: window, sendEvent: { [weak self] name, payload in
@@ -46,6 +92,23 @@ class ReplaySdk: RCTEventEmitter {
46
92
  }
47
93
  }
48
94
 
95
+ /// Re-attach when the app becomes active, mirroring Android's `onHostResume`.
96
+ @objc private func reattachNativeTracking() {
97
+ guard let window = Self.getKeyWindow() else { return }
98
+ interactionTracker.attach(to: window, sendEvent: { [weak self] name, payload in
99
+ self?.sendEvent(withName: name, body: payload)
100
+ })
101
+ }
102
+
103
+ /// Matches what the other two observer-registering modules here do
104
+ /// (`SessionManager`, `ScaleBunProfilerModule`). A native module usually outlives the app's
105
+ /// interesting lifetime and selector observers have been zeroing-weak since iOS 9, so this is
106
+ /// hygiene rather than a crash fix — but being the one file that registers and never removes is
107
+ /// how a convention quietly stops being one.
108
+ deinit {
109
+ NotificationCenter.default.removeObserver(self)
110
+ }
111
+
49
112
  private static func getKeyWindow() -> UIWindow? {
50
113
  return WindowResolver.keyWindow()
51
114
  }
@@ -80,6 +80,15 @@ class SDKBootstrapper {
80
80
  // apiBaseUrl is LOCKED: whatever an integrator passed (or the schema default) is
81
81
  // discarded here so the SDK can never be repointed away from the ScaleBun cloud.
82
82
  // Change DEFAULT_API_BASE_URL at build time to target a different backend.
83
+ // Say so when we discard it. The value was accepted by the schema, silently
84
+ // overwritten here, and the SDK then talked to a different server than the
85
+ // integrator configured — with no log, no warning, and no error. Someone
86
+ // pointing the SDK at a staging or local backend would watch requests arrive
87
+ // at production and have nothing to go on.
88
+ const requestedApiBaseUrl = rawConfig?.apiBaseUrl;
89
+ if (typeof requestedApiBaseUrl === 'string' && requestedApiBaseUrl && requestedApiBaseUrl !== _endpoints.DEFAULT_API_BASE_URL) {
90
+ _internalLogger.logger.warn(`[ScaleBun] Ignoring apiBaseUrl '${requestedApiBaseUrl}' — the endpoint is ` + `locked at build time and this build targets ${_endpoints.DEFAULT_API_BASE_URL}. To ` + `target another backend, change DEFAULT_API_BASE_URL in the SDK source and ` + `rebuild.`);
91
+ }
83
92
  this.config.apiBaseUrl = _endpoints.DEFAULT_API_BASE_URL;
84
93
  // Explicit logLevel wins; otherwise debug:true implies verbose 'debug'.
85
94
  if (this.config.logLevel) {
@@ -319,6 +328,27 @@ class SDKBootstrapper {
319
328
  await this.featureRegistry.initializeAll(featureContext);
320
329
 
321
330
  // ── Session Replay Initialization (Core) ───────────────
331
+ /**
332
+ * RN-3 zero-config scroll instrumentation, opt-in.
333
+ *
334
+ * Placed before replay init because it patches a module export and must be in place before the
335
+ * host renders its first scroll view. Reports rather than assumes: the equivalent React
336
+ * Navigation hook once claimed success it had not verified and left a whole pipeline silently
337
+ * empty, so a failure here is said out loud with a reason an integrator can act on.
338
+ */
339
+ if (this.config.autoInstrumentScrollViews === true) {
340
+ try {
341
+ const {
342
+ autoInstrumentScrollViews
343
+ } = require('../features/journey/autoInstrumentScroll');
344
+ const outcome = autoInstrumentScrollViews();
345
+ if (!outcome.installed) {
346
+ _internalLogger.logger.warn('[Bootstrap] autoInstrumentScrollViews was requested but could not be ' + 'installed: ' + outcome.reason + ' — taps will carry no content depth.');
347
+ } else {
348
+ __DEV__ && _internalLogger.logger.debug('[Bootstrap] ScrollView auto-instrumentation installed');
349
+ }
350
+ } catch {/* no-throw */}
351
+ }
322
352
  if (features.replay !== false) {
323
353
  try {
324
354
  __DEV__ && _internalLogger.logger.debug('[Bootstrap] Initializing unified SessionManager');
@@ -275,6 +275,22 @@ const SHAPE = {
275
275
  }
276
276
  },
277
277
  captureInteractionHeatmap: bool(true),
278
+ /**
279
+ * RN-3 zero-config: patch React Native's ScrollView export so EVERY scroll view reports its
280
+ * offset, not just ones wrapped in ScaleBunScrollView.
281
+ *
282
+ * DEFAULT FALSE, unlike captureInteractionHeatmap above, and the difference is deliberate.
283
+ * That flag changes what the SDK records about the host; this one changes what the HOST
284
+ * RENDERS — every scroll view in the app, including ones inside third-party libraries the app
285
+ * did not write, starts routing through an SDK component. The failure mode is not a wrong
286
+ * number in a dashboard, it is the host's UI behaving differently because an analytics SDK
287
+ * replaced a primitive. That is a decision an integrator makes deliberately, not one they
288
+ * discover afterwards.
289
+ *
290
+ * With it off, a plain <ScrollView> yields no content depth and the tap says so
291
+ * (scroll_source: 'none') rather than defaulting its offset to 0.
292
+ */
293
+ autoInstrumentScrollViews: bool(false),
278
294
  // Opt-in: intercept console.* and ship the lines to the Diagnose → Logs lane in SaaS
279
295
  // mode. OFF by default — console output can carry PII/secrets and adds ingest volume,
280
296
  // so capturing it is the host's explicit choice, not a silent default. When true and a
@@ -291,18 +307,9 @@ const SHAPE = {
291
307
  kind: 'str',
292
308
  opt: true
293
309
  },
294
- publicSigningKey: {
295
- kind: 'str',
296
- opt: true
297
- },
298
- // Rotation support: several pinned keys, any of which may verify a
299
- // bundle. Ship a build pinning [old, new], re-sign server-side with
300
- // new, drop old next release — no emergency store submission when a
301
- // key must be replaced. Merged with publicSigningKey by the facade.
302
- publicSigningKeys: {
303
- kind: 'strArr',
304
- opt: true
305
- },
310
+ // No signing-key fields: signed releases are verified natively against
311
+ // RSA keys embedded in the app package (SB-OTA-RSA-SHA256-V1). JS must
312
+ // not supply a trusted key.
306
313
  mandatoryBlocksUi: bool(false)
307
314
  }
308
315
  }
@@ -14,5 +14,5 @@ exports.SDK_VERSION = void 0;
14
14
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
15
15
  * version was introduced to solve.
16
16
  */
17
- const SDK_VERSION = exports.SDK_VERSION = '1.13.0';
17
+ const SDK_VERSION = exports.SDK_VERSION = '2.0.1';
18
18
  //# sourceMappingURL=version.js.map