@scalebun/react-native 1.13.0 → 2.0.0

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 (68) 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/OtaProtocol.kt +244 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +115 -60
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
  8. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
  9. package/dist/scalebun.full.js +127 -422
  10. package/dist/scalebun.slim.js +127 -422
  11. package/ios/Ota/OtaProtocol.swift +242 -0
  12. package/ios/Ota/OtaSlotManager.swift +26 -0
  13. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  14. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  15. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  16. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  17. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  18. package/lib/commonjs/core/config/schema.js +3 -12
  19. package/lib/commonjs/core/constants/version.js +1 -1
  20. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  21. package/lib/commonjs/features/network/thirdParty.js +90 -0
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js +108 -60
  23. package/lib/commonjs/public/ScaleBunFacade.js +5 -41
  24. package/lib/module/core/config/schema.js +3 -12
  25. package/lib/module/core/constants/version.js +1 -1
  26. package/lib/module/features/network/NetworkFeature.js +38 -4
  27. package/lib/module/features/network/thirdParty.js +83 -0
  28. package/lib/module/features/ota/OtaOrchestrator.js +108 -60
  29. package/lib/module/public/ScaleBunFacade.js +5 -41
  30. package/lib/typescript/core/config/schema.d.ts +0 -3
  31. package/lib/typescript/core/constants/version.d.ts +1 -1
  32. package/lib/typescript/features/network/index.d.ts +18 -0
  33. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  34. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +5 -8
  35. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  36. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  37. package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
  38. package/package.json +3 -13
  39. package/scalebun-react-native.podspec +4 -0
  40. package/src/core/config/schema.ts +3 -9
  41. package/src/core/constants/version.ts +1 -1
  42. package/src/features/network/NetworkFeature.ts +41 -4
  43. package/src/features/network/index.ts +18 -0
  44. package/src/features/network/thirdParty.ts +92 -0
  45. package/src/features/ota/OtaOrchestrator.ts +116 -70
  46. package/src/features/ota/OtaTypes.ts +23 -1
  47. package/src/public/ScaleBunFacade.ts +5 -53
  48. package/src/specs/NativeScaleBunOta.ts +32 -20
  49. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  50. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  51. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  52. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  53. package/lib/commonjs/features/ota/signature.js +0 -175
  54. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  55. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  56. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  57. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  58. package/lib/module/features/ota/signature.js +0 -169
  59. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  60. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  61. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  62. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  63. package/lib/typescript/features/ota/signature.d.ts +0 -81
  64. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  65. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  66. package/src/features/ota/crypto/loadSha512.ts +0 -35
  67. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  68. 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
+ }
@@ -291,18 +291,9 @@ const SHAPE = {
291
291
  kind: 'str',
292
292
  opt: true
293
293
  },
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
- },
294
+ // No signing-key fields: signed releases are verified natively against
295
+ // RSA keys embedded in the app package (SB-OTA-RSA-SHA256-V1). JS must
296
+ // not supply a trusted key.
306
297
  mandatoryBlocksUi: bool(false)
307
298
  }
308
299
  }
@@ -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.0';
18
18
  //# sourceMappingURL=version.js.map
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.NetworkFeature = void 0;
7
7
  var _index = require("./index");
8
+ var _thirdParty = require("./thirdParty");
8
9
  var _networkAdapter = require("../replay/integrations/network/networkAdapter");
9
10
  var _SessionManager = require("../session/SessionManager");
10
11
  var _screenTracking = require("../../debug/screenTracking");
@@ -50,12 +51,28 @@ class NetworkFeature {
50
51
  // self-capture loop. Apply the SAME isSdkInternalUrl filter the replay
51
52
  // branch below already uses so genuine app requests still flow through.
52
53
  const isInternal = isSdkInternalUrl(data.url, this.options.sdkBaseUrl);
53
- if (!isInternal) {
54
+ /**
55
+ * Beacons and denied URLs leave NO trace, in any lane.
56
+ *
57
+ * A network request reaches three places from here: the analytics echo
58
+ * (`$network_request`), the local replay pipeline, and the backend network endpoint
59
+ * that becomes the `network_requests` row the session panel renders. Filtering one
60
+ * and not the others is how a request half-disappears and two counts of the same
61
+ * call stop agreeing -- so this is applied to all three.
62
+ *
63
+ * Kept separate from `isInternal` rather than folded into it: the SDK's own uploads
64
+ * are already excluded from two of the three lanes and DELIBERATELY still reach the
65
+ * local replay pipeline. Widening that here would be an unrelated behaviour change
66
+ * riding along on a filter.
67
+ */
68
+ const isBeaconOrDenied = this.options.captureThirdParty !== true && (0, _thirdParty.isThirdPartyBeacon)(data.url, this.options.thirdPartyHosts) || matchesDenyUrl(data.url, this.options.denyUrls);
69
+ const isExcluded = isInternal || isBeaconOrDenied;
70
+ if (!isExcluded) {
54
71
  this.trackFn('$network_request', data);
55
72
  }
56
73
 
57
74
  // Bridge the raw network event into the Session Replay pipeline
58
- if (data.type === 'network') {
75
+ if (data.type === 'network' && !isBeaconOrDenied) {
59
76
  (0, _networkAdapter.reportNetworkRequest)({
60
77
  method: data.method,
61
78
  url: data.url,
@@ -78,8 +95,10 @@ class NetworkFeature {
78
95
  // (correlated by sessionId+timestamp), so we no longer emit
79
96
  // NETWORK_REQUEST_* replay events here (that produced duplicate rows
80
97
  // and count divergence between replay_events and network_requests).
81
- // Skip SDK's own upload calls to prevent recursive event spam.
82
- if (!isInternal) {
98
+ // Skip SDK's own upload calls to prevent recursive event spam, and anything the
99
+ // beacon/denyUrls filter excluded -- this is the lane that becomes the
100
+ // network_requests row a person sees on the session panel.
101
+ if (!isExcluded) {
83
102
  const sessionManager = _SessionManager.SessionManager.getExistingInstance();
84
103
  if (sessionManager) {
85
104
  const backendTransport = sessionManager.getBackendTransport();
@@ -113,9 +132,24 @@ class NetworkFeature {
113
132
  }
114
133
  }
115
134
 
135
+ /**
136
+ * Substring or RegExp, the same rule as the web SDK's `matchUrl`.
137
+ *
138
+ * Spelled out rather than shared because the two SDKs have no common runtime package, and the
139
+ * rule is two lines. What must NOT differ is the beacon list, which is why that one is a
140
+ * byte-identical vendored copy with a hash gate rather than a retyped constant.
141
+ */
142
+ exports.NetworkFeature = NetworkFeature;
143
+ function matchesDenyUrl(url, patterns) {
144
+ if (!patterns || !patterns.length || !url) return false;
145
+ for (const p of patterns) {
146
+ if (typeof p === 'string' ? url.includes(p) : p.test(url)) return true;
147
+ }
148
+ return false;
149
+ }
150
+
116
151
  // Returns true if the URL is a ScaleBun SDK internal endpoint that must not be
117
152
  // captured — doing so would cause recursive network event spam.
118
- exports.NetworkFeature = NetworkFeature;
119
153
  function isSdkInternalUrl(url, sdkBaseUrl) {
120
154
  if (!url) return false;
121
155
  try {
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.THIRD_PARTY_BEACON_HOSTS = void 0;
7
+ exports.isThirdPartyBeacon = isThirdPartyBeacon;
8
+ /**
9
+ * THIRD-PARTY BEACON HOSTS — the one list, and the one matcher, for every layer that drops them.
10
+ *
11
+ * WHAT THIS IS FOR
12
+ * A session on an instrumented site spends most of its network tab on traffic the app did not
13
+ * meaningfully make: analytics collectors, ad pixels, IP-enrichment calls, tag managers. It is
14
+ * captured, uploaded, stored and rendered, and none of it answers a question anyone asks a session
15
+ * replay. One real sign-in screen was 27 requests, most of them beacons.
16
+ *
17
+ * It is not only clutter. Session health scores `failedRequests` and `slowRequests`, so a tracker
18
+ * killed by an ad blocker (a transport error) or one that is simply slow moves a number that is
19
+ * supposed to describe the APP. That is the part that makes this a correctness fix rather than a
20
+ * tidy-up.
21
+ *
22
+ * WHY HOSTS ONLY, NEVER PATHS
23
+ * The obvious rules from looking at a network tab are `/collect`, `/p`, `/api/v2/pixel`. Every one
24
+ * of them is a path that a customer's own API is allowed to have, and dropping a real API call is
25
+ * far worse than keeping a beacon: the noise is visible and annoying, the missing request is
26
+ * invisible and misleading. So matching is on the HOSTNAME, with a dot boundary, and a request to
27
+ * `/collect` on the app's own origin is kept — correctly.
28
+ *
29
+ * WHY IT IS A SUFFIX LIST AND NOT A REGEX
30
+ * Two reasons, both practical. It is applied inside an SDK whose eager bundle is measured in bytes,
31
+ * and a comma-joined string of suffixes compresses to almost nothing next to an equivalent pattern
32
+ * source. And it has to be COPIED, byte for byte, into the React Native SDK and into the backend's
33
+ * ingest path — a list with no syntax is a list that cannot drift in meaning between three
34
+ * languages' worth of tooling. `third-party:parity` hashes all three copies.
35
+ *
36
+ * ZERO IMPORTS, DELIBERATELY. That is what makes the byte-identical copy possible. If an edit here
37
+ * ever needs an import, the parity gate is the thing that will say so — keep the module pure rather
38
+ * than weakening the gate.
39
+ *
40
+ * WHAT IS DELIBERATELY NOT ON THE LIST
41
+ * The rule is: a host whose ONLY purpose is to receive telemetry. Everything else stays, because a
42
+ * dropped request is invisible and a kept one is merely noisy.
43
+ *
44
+ * - Feature-flag and experiment services (LaunchDarkly, Optimizely, Split). Their RESPONSES
45
+ * change what the app does, so a failed flag fetch is an application incident and the single
46
+ * most useful request in the session.
47
+ * - Chat and support widgets (Intercom, Zendesk, Drift). A broken widget is a broken feature the
48
+ * user can see.
49
+ * - `facebook.com` as a whole. `graph.facebook.com` is an API real apps call; only the pixel
50
+ * delivery hosts are listed.
51
+ * - CDNs of any kind, even ones that mostly serve tags: they also serve application assets, and
52
+ * dropping them would hide a real asset failure.
53
+ *
54
+ * When in doubt, leave it out. `denyUrls` stays the per-app escape hatch in both SDKs, and it can
55
+ * do what this list will not.
56
+ */
57
+
58
+ /**
59
+ * Known telemetry, advertising and enrichment endpoints, as hostname suffixes.
60
+ *
61
+ * One comma-joined string rather than an array: identical meaning, materially smaller once gzipped,
62
+ * and it keeps the copy in three repos a single line to compare by eye.
63
+ */
64
+ const THIRD_PARTY_BEACON_HOSTS = exports.THIRD_PARTY_BEACON_HOSTS = 'google-analytics.com,googletagmanager.com,analytics.google.com,doubleclick.net,googlesyndication.com,' + 'googleadservices.com,app-measurement.com,facebook.net,analytics.tiktok.com,' + 'ads-twitter.com,analytics.twitter.com,ct.pinterest.com,bat.bing.com,px.ads.linkedin.com,tr.snapchat.com,' + 'criteo.com,criteo.net,taboola.com,outbrain.com,adroll.com,adsrvr.org,quantserve.com,scorecardresearch.com,' + 'hotjar.com,hotjar.io,clarity.ms,fullstory.com,mouseflow.com,crazyegg.com,luckyorange.com,inspectlet.com,' + 'segment.io,mixpanel.com,amplitude.com,heap.io,heapanalytics.com,rudderstack.com,posthog.com,' + 'matomo.cloud,plausible.io,simpleanalytics.com,statcounter.com,' + 'appsflyer.com,adjust.com,branch.io,' + 'ipify.org,ipinfo.io,ipapi.co,ip-api.com,geolocation-db.com,cloudflareinsights.com,' + 'nr-data.net,browser-intake-datadoghq.com,sentry.io,bugsnag.com,rollbar.com';
65
+
66
+ /**
67
+ * True when `url` is addressed to a known beacon host.
68
+ *
69
+ * Deliberately total: an unparseable or relative URL is NOT third party. A relative URL is
70
+ * same-origin by definition — the app's own call — and guessing on garbage input would drop real
71
+ * requests for no benefit.
72
+ */
73
+ function isThirdPartyBeacon(url, hosts) {
74
+ if (!url) return false;
75
+ let host = '';
76
+ try {
77
+ // A relative URL throws here, which is the answer we want: it is first-party.
78
+ host = new URL(url).hostname.toLowerCase();
79
+ } catch {
80
+ return false;
81
+ }
82
+ if (!host) return false;
83
+ for (const suffix of (hosts ?? THIRD_PARTY_BEACON_HOSTS).split(',')) {
84
+ // Dot boundary, so `notevil-facebook.com` does not match `facebook.com` while
85
+ // `connect.facebook.com` does. The bare-equality arm covers the apex domain itself.
86
+ if (host === suffix || host.endsWith('.' + suffix)) return true;
87
+ }
88
+ return false;
89
+ }
90
+ //# sourceMappingURL=thirdParty.js.map