@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
@@ -41,6 +41,10 @@ android {
41
41
  // Ship R8 keep-rules to consumer apps so release minification doesn't
42
42
  // strip the native modules / codegen TurboModule specs.
43
43
  consumerProguardFiles "consumer-rules.pro"
44
+ // Runs the OTA RSA verifier instrumentation test on-device
45
+ // (src/androidTest) — proves SHA256withRSA on the device provider across
46
+ // API levels. Affects only this library's connectedAndroidTest task.
47
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
44
48
  }
45
49
 
46
50
  sourceSets {
@@ -83,4 +87,8 @@ dependencies {
83
87
  // class is absent at runtime. Host apps that want push add the real
84
88
  // firebase-messaging dependency in their own app module — additive, no break.
85
89
  compileOnly 'com.google.firebase:firebase-messaging:23.4.1'
90
+
91
+ // On-device OTA verifier test only (src/androidTest) — not shipped to consumers.
92
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
93
+ androidTestImplementation 'androidx.test:runner:1.5.2'
86
94
  }
@@ -0,0 +1,112 @@
1
+ package com.scalebun.rn.ota
2
+
3
+ import androidx.test.ext.junit.runners.AndroidJUnit4
4
+ import org.json.JSONObject
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+ import org.junit.runner.RunWith
9
+
10
+ /**
11
+ * On-device verification for SB-OTA-RSA-SHA256-V1 (PROMPT §22).
12
+ *
13
+ * Proves that the shared committed vector (packages/ota-protocol/fixtures — the
14
+ * SAME bytes the Node codec, backend, and JVM parity harness use) verifies with
15
+ * the DEVICE's `SHA256withRSA` provider and parses with the on-device codec, and
16
+ * that tampering / unknown keys are rejected. RSA verification + RSA KeyFactory
17
+ * ship on every supported Android API level, so this must pass on the repo's
18
+ * minSdk (24), on an API below 33, and on API 33+ — run it on each:
19
+ *
20
+ * ./gradlew :scalebun_react-native:connectedAndroidTest # per running emulator/AVD
21
+ *
22
+ * The constants below are copied verbatim from fixtures/vectors/full.json; the
23
+ * cross-repo parity harness keeps the codec byte-identical, so a drift here shows
24
+ * up as a verification failure.
25
+ */
26
+ @RunWith(AndroidJUnit4::class)
27
+ class ScaleBunOtaVerifierInstrumentedTest {
28
+
29
+ private val signedPayloadB64 =
30
+ "U0JPVEEwMDEAAQAAABRTQi1PVEEtUlNBLVNIQTI1Ni1WMQAAAAdTSEEtMjU2AAAAFXRlc3QtcnNhLTMwNzItMjAyNi0wMQAAAA5wcm9qX3Rlc3RfMDAwMQAAABVvcmcuc2NhbGVidW4ubGVnYXJhZ2UAAAANcmVsX2Z1bGxfMDAwMQAAAA5ibmRsX2Z1bGxfMDAwMQAAAAdhbmRyb2lkAAAABGZ1bGwAAAAFNC41LjkAAAAGMC43NC4xAAAAAjI0AAAAAAAAACoAAAAAaVW5AAAAAAAAAAAAAAAAAAAAAWD6bpV7WCc0zBI9NJ6rivyixtOu66jZ7EYSJniR99yQMwAAAAAAAAAA+m6Ve1gnNMwSPTSeq4r8osbTruuo2exGEiZ4kffckDM="
31
+ private val signatureB64 =
32
+ "jlC7GRLWKXku3cbqtODlFt+qn2CEmS3oHOr9QTopCCzEovUF5a2GRT0PQ5k2C5EX7UGGO0Sv6/WhkznKR9NVEZl6ed0KARHAY8zfwTjin3czx9jpwV9bfsLlxEoKNo9bPfIs3mc0VoHCWOp95hQAotG+GqEwYsXqPruZ43U87qWT0t9/a3QboX+07hPO5vI4fDZQuPeo3W1HmXPKbOB7QGSijtKgKwQNuJHz+etsVsQelDsDofRSpYV92XCheUWWHP7kZKFOCcYUXnVwJcdU0Jhej+22vAESRK2G0CXP3orXE4pASaA3y4wkBQgJAJkcTN/ic1/Wm5BLo7KH+ypK1eo0hnrrLNSh6E+St5NvxlTM3bmxwnwHkPMYVBuOT0fUJy+gbD1/1CPvKakL12vizKwWDYuhfcIGkQUTvP2zCxcqWLfmZ7ZGo6n6WXMX63Vz+IQG0tnx1cp6YqJVIgxqjskQ47sacsfS9QeV5LyJhvBQrDSbpqujo9la8dDzZfDN"
33
+ private val keyId = "test-rsa-3072-2026-01"
34
+ private val projectId = "proj_test_0001"
35
+ private val applicationId = "org.scalebun.legarage"
36
+
37
+ private val spkiPem =
38
+ "-----BEGIN PUBLIC KEY-----\n" +
39
+ "MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAt3tfFTPE+lkEbFeYi6zE\n" +
40
+ "+uI+3oJl0HfKtGTh7sr7pSh/uPJq2giF0Uc09ux3Z0i4NlqpOyLmiIrVOgYcWcN+\n" +
41
+ "/pCl1mh6VmT7nYO3sEhL+HyaIaclCGxvHCJSsaVHH8qQyiCVUypWWKN0L38U4isN\n" +
42
+ "dJ2x+Wv7iLSZu3oM+ZDyGWCjRLE49jKNl0tfaQX6G1YoyBi1aEu5sWIQ6LjGE4IX\n" +
43
+ "1AJtOiNoaO9orjVgphA5FDKFFqguhEJrHoSXt3awHaeCYFm/mt+NI6k4sRf59UNx\n" +
44
+ "hdGh0kVL/6jZncclxueFafOOdfPf+jF3ylTczQhnID7Mg1PdmjXR7cfq7jDdjzfW\n" +
45
+ "VICJqGt+EhUd92pWrMTa5k3SDin3EetYGJsN7iKLbAl0D6Uy8RfE2mdsF2K6D3EP\n" +
46
+ "9waHN7vLURCF//TGy84FxPy7vbFgLO4oT07lxaopuGX7vOdONI8qLcFhEAJ9fRVN\n" +
47
+ "lqPWQGib0iAKE+gLl4CWZen674QZF39fGnOBEz7JsmDfAgMBAAE=\n" +
48
+ "-----END PUBLIC KEY-----\n"
49
+
50
+ private fun registry() = ScaleBunOtaKeyRegistry.fromJson(
51
+ JSONObject()
52
+ .put("projectId", projectId)
53
+ .put("keys", org.json.JSONArray().put(JSONObject().put("keyId", keyId).put("publicKey", spkiPem)))
54
+ .toString(),
55
+ )
56
+
57
+ private fun context(currentSequence: Long = 0L) = ExpectedContext(
58
+ projectId = projectId,
59
+ applicationId = applicationId,
60
+ currentSequence = currentSequence,
61
+ nowEpochSeconds = 1_767_225_600L + 60, // just after the vector's issuedAt
62
+ )
63
+
64
+ private fun envelope(payload: String = signedPayloadB64, sig: String = signatureB64) = JSONObject()
65
+ .put("protocol", OtaProtocol.SIGNATURE_ALGORITHM)
66
+ .put("keyId", keyId)
67
+ .put("signedPayload", payload)
68
+ .put("signature", sig)
69
+ .toString()
70
+
71
+ @Test
72
+ fun genuineReleaseVerifiesOnDevice() {
73
+ val v = ScaleBunOtaReleaseVerifier.verify(envelope(), context(), registry())
74
+ assertTrue("expected ok, got ${v.code}", v.ok)
75
+ assertEquals("bndl_full_0001", v.payload?.bundleId)
76
+ assertEquals(42L, v.payload?.releaseSequence)
77
+ assertEquals("android", v.payload?.platform)
78
+ // result == artifact hash for a full release
79
+ assertEquals(v.payload?.artifactSha256, v.payload?.resultArtifactSha256)
80
+ }
81
+
82
+ @Test
83
+ fun oneBytePayloadMutationRejected() {
84
+ val bad = signedPayloadB64.dropLast(2) + if (signedPayloadB64.endsWith("M=")) "N=" else "M="
85
+ val v = ScaleBunOtaReleaseVerifier.verify(envelope(payload = bad), context(), registry())
86
+ assertTrue(!v.ok)
87
+ // Either the flipped base64 fails to decode canonically or the signature fails.
88
+ assertTrue(v.code == OtaFailureCode.INVALID_SIGNATURE || v.code == OtaFailureCode.INVALID_BASE64)
89
+ }
90
+
91
+ @Test
92
+ fun unknownKeyRejected() {
93
+ val emptyReg = ScaleBunOtaKeyRegistry.fromJson(
94
+ JSONObject().put("projectId", projectId).put("keys", org.json.JSONArray()).toString(),
95
+ )
96
+ val v = ScaleBunOtaReleaseVerifier.verify(envelope(), context(), emptyReg)
97
+ assertEquals(OtaFailureCode.UNKNOWN_KEY_ID, v.code)
98
+ }
99
+
100
+ @Test
101
+ fun downgradeRejected() {
102
+ val v = ScaleBunOtaReleaseVerifier.verify(envelope(), context(currentSequence = 100L), registry())
103
+ assertEquals(OtaFailureCode.RELEASE_DOWNGRADE_REJECTED, v.code)
104
+ }
105
+
106
+ @Test
107
+ fun sha256WithRsaAvailableOnThisApiLevel() {
108
+ // The whole point of RSA over ed25519: this must not throw on any API level.
109
+ java.security.Signature.getInstance("SHA256withRSA")
110
+ java.security.KeyFactory.getInstance("RSA")
111
+ }
112
+ }
@@ -58,6 +58,7 @@ internal object BundleDownloader {
58
58
  * bar overshoot 100% and walk backwards. Unknown is reported as unknown.
59
59
  */
60
60
  fun download(
61
+ context: android.content.Context,
61
62
  url: String,
62
63
  destination: File,
63
64
  onProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? = null,
@@ -79,6 +80,15 @@ internal object BundleDownloader {
79
80
  setRequestProperty("Accept-Encoding", "gzip")
80
81
  }
81
82
 
83
+ // Certificate pinning, when the host app ships pins. Checked after the
84
+ // handshake but before a single byte of the body is read, and before any
85
+ // redirect is followed, so an intercepted endpoint cannot serve content or
86
+ // bounce us somewhere else. No pins configured = no behaviour change.
87
+ val https = connection as? javax.net.ssl.HttpsURLConnection
88
+ if (https != null && !TlsPinning.verify(context, https, current.host)) {
89
+ return false
90
+ }
91
+
82
92
  val code = connection.responseCode
83
93
  if (code == HttpURLConnection.HTTP_MOVED_PERM ||
84
94
  code == HttpURLConnection.HTTP_MOVED_TEMP ||
@@ -0,0 +1,75 @@
1
+ package com.scalebun.rn.ota
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import java.io.File
6
+
7
+ /**
8
+ * Best-effort root detection.
9
+ *
10
+ * Deliberately modest in what it claims. Root detection is a signal, never a
11
+ * boundary: anything running with root can defeat any check made from inside the
12
+ * app, and a determined attacker with a rooted device has already won that
13
+ * argument. What it IS good for is telling an operator that a fleet's telemetry
14
+ * — crash rates, adoption, rollback rates — includes devices whose OTA slot is
15
+ * writable by other processes, and letting a security-sensitive host app decide
16
+ * to withhold updates on those devices.
17
+ *
18
+ * The checks are the cheap, well-understood ones: known su binaries, the
19
+ * test-keys build tag, and the presence of common management packages. No
20
+ * heuristics that produce false positives on ordinary devices, because a false
21
+ * positive here means a paying customer stops receiving updates.
22
+ */
23
+ object DeviceIntegrity {
24
+
25
+ private val SU_PATHS = arrayOf(
26
+ "/system/bin/su",
27
+ "/system/xbin/su",
28
+ "/sbin/su",
29
+ "/system/su",
30
+ "/vendor/bin/su",
31
+ "/data/local/xbin/su",
32
+ "/data/local/bin/su",
33
+ "/system/sd/xbin/su",
34
+ )
35
+
36
+ private val MANAGEMENT_PACKAGES = arrayOf(
37
+ "com.topjohnwu.magisk",
38
+ "eu.chainfire.supersu",
39
+ "com.noshufou.android.su",
40
+ "com.koushikdutta.superuser",
41
+ )
42
+
43
+ /**
44
+ * Reasons this device looks rooted. Empty list = nothing detected, which is
45
+ * NOT the same as "device is clean" — see the class comment.
46
+ */
47
+ fun rootIndicators(context: Context): List<String> {
48
+ val found = mutableListOf<String>()
49
+
50
+ if (SU_PATHS.any { runCatching { File(it).exists() }.getOrDefault(false) }) {
51
+ found.add("su_binary_present")
52
+ }
53
+
54
+ // Release builds signed with Android's public test keys are development
55
+ // images, not retail firmware.
56
+ if (Build.TAGS?.contains("test-keys") == true) {
57
+ found.add("test_keys_build")
58
+ }
59
+
60
+ val pm = context.packageManager
61
+ for (pkg in MANAGEMENT_PACKAGES) {
62
+ val installed = runCatching {
63
+ @Suppress("DEPRECATION")
64
+ pm.getPackageInfo(pkg, 0)
65
+ true
66
+ }.getOrDefault(false)
67
+ if (installed) {
68
+ found.add("root_manager_installed")
69
+ break
70
+ }
71
+ }
72
+
73
+ return found
74
+ }
75
+ }
@@ -0,0 +1,244 @@
1
+ package com.scalebun.rn.ota
2
+
3
+ import java.nio.ByteBuffer
4
+ import java.nio.charset.CodingErrorAction
5
+ import java.nio.charset.StandardCharsets
6
+ import java.util.Base64
7
+
8
+ /**
9
+ * SB-OTA-RSA-SHA256-V1 binary codec — the Android mirror of the TypeScript
10
+ * reference (`packages/ota-protocol/src/codec.ts`) and the JVM parity harness
11
+ * (`tools/rsa-ota-parity/RsaOtaParity.java`, which is run in CI to gate this
12
+ * transliteration byte-for-byte against the shared fixtures).
13
+ *
14
+ * Pure Kotlin + java.* only — no Android or RN dependency — so the parser is
15
+ * unit-testable off-device and cannot be neutered by the JS bundle it protects.
16
+ * See docs/ota/SB-OTA-RSA-SHA256-V1.md for the authoritative wire format.
17
+ */
18
+
19
+ /** Stable verification failure codes (docs §6), surfaced to JS verbatim. */
20
+ enum class OtaFailureCode {
21
+ INVALID_ENVELOPE,
22
+ INVALID_BASE64,
23
+ UNSUPPORTED_PROTOCOL,
24
+ UNSUPPORTED_SCHEMA,
25
+ UNSUPPORTED_SIGNATURE_ALGORITHM,
26
+ UNSUPPORTED_HASH_ALGORITHM,
27
+ UNKNOWN_KEY_ID,
28
+ INVALID_PUBLIC_KEY,
29
+ INVALID_SIGNATURE,
30
+ INVALID_SIGNED_PAYLOAD,
31
+ PROJECT_MISMATCH,
32
+ APPLICATION_MISMATCH,
33
+ PLATFORM_MISMATCH,
34
+ APP_VERSION_MISMATCH,
35
+ RUNTIME_VERSION_MISMATCH,
36
+ RELEASE_DOWNGRADE_REJECTED,
37
+ RELEASE_EXPIRED,
38
+ DOWNLOAD_FAILED,
39
+ DOWNLOAD_TOO_LARGE,
40
+ ARTIFACT_SIZE_MISMATCH,
41
+ ARTIFACT_HASH_MISMATCH,
42
+ PATCH_BASE_MISMATCH,
43
+ PATCH_RESULT_MISMATCH,
44
+ UNSAFE_ARCHIVE,
45
+ STAGING_FAILED,
46
+ ACTIVATION_FAILED,
47
+ INTERNAL_VERIFICATION_ERROR,
48
+ }
49
+
50
+ /** Carries a stable failure code out of the parser/verifier. Never leaks secrets. */
51
+ class OtaVerifyException(val code: OtaFailureCode, message: String) : Exception("$code: $message")
52
+
53
+ /** Decoded ScaleBunSignedReleaseV1 (hashes exposed as lowercase hex). */
54
+ data class SignedRelease(
55
+ val signatureAlgorithm: String,
56
+ val hashAlgorithm: String,
57
+ val keyId: String,
58
+ val projectId: String,
59
+ val applicationId: String,
60
+ val releaseId: String,
61
+ val bundleId: String,
62
+ val platform: String,
63
+ val artifactType: String,
64
+ val targetAppVersion: String,
65
+ val targetRuntimeVersion: String,
66
+ val minimumSdkVersion: String,
67
+ val releaseSequence: Long,
68
+ val issuedAtEpochSeconds: Long,
69
+ val expiresAtEpochSeconds: Long,
70
+ val artifactSize: Long,
71
+ val artifactSha256: String,
72
+ val baseBundleId: String,
73
+ val baseArtifactSha256: String,
74
+ val resultArtifactSha256: String,
75
+ )
76
+
77
+ object OtaProtocol {
78
+ const val MAGIC = "SBOTA001"
79
+ const val SCHEMA_VERSION = 1
80
+ const val SIGNATURE_ALGORITHM = "SB-OTA-RSA-SHA256-V1"
81
+ const val HASH_ALGORITHM = "SHA-256"
82
+ const val MAX_SIGNED_PAYLOAD_BYTES = 8192
83
+ const val MAX_SIGNATURE_BYTES = 1024
84
+ const val MAX_STRING_FIELD_BYTES = 256
85
+ const val SHA256_LEN = 32
86
+ const val MAX_SAFE_UINT64 = 9007199254740991L // 2^53 - 1
87
+
88
+ private val MAGIC_BYTES = MAGIC.toByteArray(StandardCharsets.US_ASCII)
89
+
90
+ /**
91
+ * Strict, canonical standard-Base64 decode with a hard size cap enforced
92
+ * before allocation. Rejects non-canonical encodings by requiring the
93
+ * decode→re-encode round trip to reproduce the input exactly.
94
+ */
95
+ fun decodeBase64Strict(value: String?, maxBytes: Int, what: String): ByteArray {
96
+ if (value.isNullOrEmpty()) throw OtaVerifyException(OtaFailureCode.INVALID_BASE64, "$what: empty")
97
+ if (value.length > (maxBytes * 4) / 3 + 4) {
98
+ throw OtaVerifyException(OtaFailureCode.INVALID_BASE64, "$what: exceeds size limit")
99
+ }
100
+ if (value.length % 4 != 0 || !value.matches(Regex("^[A-Za-z0-9+/]*={0,2}$"))) {
101
+ throw OtaVerifyException(OtaFailureCode.INVALID_BASE64, "$what: not canonical base64")
102
+ }
103
+ val decoded = try {
104
+ Base64.getDecoder().decode(value)
105
+ } catch (e: IllegalArgumentException) {
106
+ throw OtaVerifyException(OtaFailureCode.INVALID_BASE64, "$what: invalid base64")
107
+ }
108
+ if (Base64.getEncoder().encodeToString(decoded) != value) {
109
+ throw OtaVerifyException(OtaFailureCode.INVALID_BASE64, "$what: non-canonical base64")
110
+ }
111
+ if (decoded.size > maxBytes) {
112
+ throw OtaVerifyException(OtaFailureCode.INVALID_BASE64, "$what: decoded exceeds limit")
113
+ }
114
+ return decoded
115
+ }
116
+
117
+ /** Parse ONLY enough to obtain the (still-unauthenticated) keyId (docs §5). */
118
+ fun peekKeyId(bytes: ByteArray): String {
119
+ if (bytes.size > MAX_SIGNED_PAYLOAD_BYTES) {
120
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "payload too large")
121
+ }
122
+ val r = Reader(bytes)
123
+ if (!r.fixed(MAGIC_BYTES.size).contentEquals(MAGIC_BYTES)) {
124
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "bad magic")
125
+ }
126
+ if (r.u16() != SCHEMA_VERSION) throw OtaVerifyException(OtaFailureCode.UNSUPPORTED_SCHEMA, "schema")
127
+ r.str("signatureAlgorithm")
128
+ r.str("hashAlgorithm")
129
+ val keyId = r.str("keyId")
130
+ if (keyId.isEmpty()) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "empty keyId")
131
+ return keyId
132
+ }
133
+
134
+ /** Fully parse + validate an authenticated payload. */
135
+ fun parse(bytes: ByteArray): SignedRelease {
136
+ if (bytes.size > MAX_SIGNED_PAYLOAD_BYTES) {
137
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "payload too large")
138
+ }
139
+ val r = Reader(bytes)
140
+ if (!r.fixed(MAGIC_BYTES.size).contentEquals(MAGIC_BYTES)) {
141
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "bad magic")
142
+ }
143
+ if (r.u16() != SCHEMA_VERSION) throw OtaVerifyException(OtaFailureCode.UNSUPPORTED_SCHEMA, "schema")
144
+ val sigAlg = r.str("signatureAlgorithm")
145
+ if (sigAlg != SIGNATURE_ALGORITHM) throw OtaVerifyException(OtaFailureCode.UNSUPPORTED_SIGNATURE_ALGORITHM, sigAlg)
146
+ val hashAlg = r.str("hashAlgorithm")
147
+ if (hashAlg != HASH_ALGORITHM) throw OtaVerifyException(OtaFailureCode.UNSUPPORTED_HASH_ALGORITHM, hashAlg)
148
+
149
+ val keyId = r.str("keyId")
150
+ val projectId = r.str("projectId")
151
+ val applicationId = r.str("applicationId")
152
+ val releaseId = r.str("releaseId")
153
+ val bundleId = r.str("bundleId")
154
+ for (v in listOf(keyId, projectId, applicationId, releaseId, bundleId)) {
155
+ if (v.isEmpty()) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "empty id")
156
+ }
157
+ val platform = r.str("platform")
158
+ if (platform != "android" && platform != "ios") {
159
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "platform")
160
+ }
161
+ val artifactType = r.str("artifactType")
162
+ if (artifactType != "full" && artifactType != "patch") {
163
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "artifactType")
164
+ }
165
+ val targetAppVersion = r.str("targetAppVersion")
166
+ val targetRuntimeVersion = r.str("targetRuntimeVersion")
167
+ val minimumSdkVersion = r.str("minimumSdkVersion")
168
+ val releaseSequence = r.u64()
169
+ val issuedAt = r.u64()
170
+ val expiresAt = r.u64()
171
+ val artifactSize = r.u64()
172
+ if (issuedAt == 0L) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "issuedAt zero")
173
+ if (artifactSize == 0L) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "artifactSize zero")
174
+ if (expiresAt != 0L && expiresAt < issuedAt) {
175
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "expiresAt < issuedAt")
176
+ }
177
+ val artifactSha = r.fixed(SHA256_LEN)
178
+ val baseBundleId = r.str("baseBundleId")
179
+ val baseSha = r.lenBytes(SHA256_LEN, "baseArtifactSha256")
180
+ val resultSha = r.fixed(SHA256_LEN)
181
+ if (artifactType == "full") {
182
+ if (baseBundleId.isNotEmpty() || baseSha.isNotEmpty()) {
183
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "full has base fields")
184
+ }
185
+ } else {
186
+ if (baseBundleId.isEmpty() || baseSha.size != SHA256_LEN) {
187
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "patch missing base")
188
+ }
189
+ }
190
+ if (r.remaining() != 0) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "trailing bytes")
191
+
192
+ return SignedRelease(
193
+ sigAlg, hashAlg, keyId, projectId, applicationId, releaseId, bundleId, platform, artifactType,
194
+ targetAppVersion, targetRuntimeVersion, minimumSdkVersion,
195
+ releaseSequence, issuedAt, expiresAt, artifactSize,
196
+ toHex(artifactSha), baseBundleId, if (baseSha.size == SHA256_LEN) toHex(baseSha) else "", toHex(resultSha),
197
+ )
198
+ }
199
+
200
+ fun toHex(bytes: ByteArray): String {
201
+ val sb = StringBuilder(bytes.size * 2)
202
+ for (b in bytes) sb.append("%02x".format(b))
203
+ return sb.toString()
204
+ }
205
+
206
+ private class Reader(val b: ByteArray) {
207
+ var pos = 0
208
+ private val utf8 = StandardCharsets.UTF_8.newDecoder()
209
+ .onMalformedInput(CodingErrorAction.REPORT)
210
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
211
+
212
+ fun need(n: Int) {
213
+ if (n < 0 || pos + n > b.size) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "truncated")
214
+ }
215
+ fun fixed(n: Int): ByteArray { need(n); val o = b.copyOfRange(pos, pos + n); pos += n; return o }
216
+ fun u16(): Int { val x = fixed(2); return ((x[0].toInt() and 0xff) shl 8) or (x[1].toInt() and 0xff) }
217
+ fun u32(): Long {
218
+ val x = fixed(4)
219
+ return ((x[0].toLong() and 0xff) shl 24) or ((x[1].toLong() and 0xff) shl 16) or
220
+ ((x[2].toLong() and 0xff) shl 8) or (x[3].toLong() and 0xff)
221
+ }
222
+ fun u64(): Long {
223
+ val x = fixed(8)
224
+ var v = 0L
225
+ for (i in 0 until 8) v = (v shl 8) or (x[i].toLong() and 0xff)
226
+ if (v < 0 || v > MAX_SAFE_UINT64) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "uint64 out of range")
227
+ return v
228
+ }
229
+ fun lenBytes(max: Int, field: String): ByteArray {
230
+ val n = u32()
231
+ if (n > max) throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "$field length $n > $max")
232
+ return fixed(n.toInt())
233
+ }
234
+ fun str(field: String = "string"): String {
235
+ val raw = lenBytes(MAX_STRING_FIELD_BYTES, field)
236
+ return try {
237
+ utf8.reset().decode(ByteBuffer.wrap(raw)).toString()
238
+ } catch (e: Exception) {
239
+ throw OtaVerifyException(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "$field: invalid UTF-8")
240
+ }
241
+ }
242
+ fun remaining(): Int = b.size - pos
243
+ }
244
+ }
@@ -0,0 +1,100 @@
1
+ package com.scalebun.rn.ota
2
+
3
+ import android.content.Context
4
+ import org.json.JSONObject
5
+ import java.security.KeyFactory
6
+ import java.security.MessageDigest
7
+ import java.security.interfaces.RSAPublicKey
8
+ import java.security.spec.X509EncodedKeySpec
9
+ import java.util.Base64
10
+
11
+ /**
12
+ * Native trusted-key registry (docs §9). Trusted RSA public keys are EMBEDDED in
13
+ * the app package — never fetched over the OTA channel — so a compromised server
14
+ * cannot introduce a key the device will trust.
15
+ *
16
+ * Config is a JSON asset shipped in the APK (default `scalebun_ota_keys.json`):
17
+ *
18
+ * ```json
19
+ * { "projectId": "proj_abc",
20
+ * "keys": [ { "keyId": "production-2026-01", "publicKey": "-----BEGIN PUBLIC KEY----- …" } ] }
21
+ * ```
22
+ *
23
+ * `publicKey` may be an SPKI PEM or bare base64 SPKI DER. Keys are validated as
24
+ * RSA with a modulus of at least 2048 bits; duplicates and malformed entries are
25
+ * rejected. An absent/empty asset yields an empty registry — verification then
26
+ * fails closed (UNKNOWN_KEY_ID), never open.
27
+ */
28
+ class TrustedKey(
29
+ val keyId: String,
30
+ val publicKey: RSAPublicKey,
31
+ /** SHA-256 over the SPKI DER, lowercase hex. */
32
+ val fingerprint: String,
33
+ )
34
+
35
+ class ScaleBunOtaKeyRegistry private constructor(
36
+ val projectId: String,
37
+ private val byKeyId: Map<String, TrustedKey>,
38
+ ) {
39
+ fun get(keyId: String): TrustedKey? = byKeyId[keyId]
40
+ fun isEmpty(): Boolean = byKeyId.isEmpty()
41
+ fun keyIds(): Set<String> = byKeyId.keys
42
+
43
+ companion object {
44
+ const val DEFAULT_ASSET = "scalebun_ota_keys.json"
45
+
46
+ /** Load from an APK asset. Returns an empty registry if the asset is absent. */
47
+ fun fromAssets(context: Context, assetName: String = DEFAULT_ASSET): ScaleBunOtaKeyRegistry {
48
+ return try {
49
+ val json = context.assets.open(assetName).use { it.readBytes().toString(Charsets.UTF_8) }
50
+ fromJson(json)
51
+ } catch (_: java.io.FileNotFoundException) {
52
+ ScaleBunOtaKeyRegistry("", emptyMap())
53
+ }
54
+ }
55
+
56
+ fun fromJson(json: String): ScaleBunOtaKeyRegistry {
57
+ val root = JSONObject(json)
58
+ val projectId = root.optString("projectId", "")
59
+ val arr = root.optJSONArray("keys") ?: throw OtaVerifyException(
60
+ OtaFailureCode.INVALID_PUBLIC_KEY, "trusted-key config has no `keys` array",
61
+ )
62
+ val map = LinkedHashMap<String, TrustedKey>()
63
+ for (i in 0 until arr.length()) {
64
+ val entry = arr.getJSONObject(i)
65
+ val keyId = entry.optString("keyId", "").trim()
66
+ val pem = entry.optString("publicKey", "").trim()
67
+ if (keyId.isEmpty() || pem.isEmpty()) {
68
+ throw OtaVerifyException(OtaFailureCode.INVALID_PUBLIC_KEY, "key entry missing keyId/publicKey")
69
+ }
70
+ if (map.containsKey(keyId)) {
71
+ throw OtaVerifyException(OtaFailureCode.INVALID_PUBLIC_KEY, "duplicate keyId $keyId")
72
+ }
73
+ map[keyId] = parseKey(keyId, pem)
74
+ }
75
+ return ScaleBunOtaKeyRegistry(projectId, map)
76
+ }
77
+
78
+ private fun parseKey(keyId: String, pem: String): TrustedKey {
79
+ val b64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(Regex("\\s"), "")
80
+ val der = try {
81
+ Base64.getDecoder().decode(b64)
82
+ } catch (e: IllegalArgumentException) {
83
+ throw OtaVerifyException(OtaFailureCode.INVALID_PUBLIC_KEY, "key $keyId: bad base64")
84
+ }
85
+ val key = try {
86
+ KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(der))
87
+ } catch (e: Exception) {
88
+ throw OtaVerifyException(OtaFailureCode.INVALID_PUBLIC_KEY, "key $keyId: not a valid SPKI RSA key")
89
+ }
90
+ if (key !is RSAPublicKey) {
91
+ throw OtaVerifyException(OtaFailureCode.INVALID_PUBLIC_KEY, "key $keyId: not RSA")
92
+ }
93
+ if (key.modulus.bitLength() < 2048) {
94
+ throw OtaVerifyException(OtaFailureCode.INVALID_PUBLIC_KEY, "key $keyId: modulus < 2048 bits")
95
+ }
96
+ val fp = MessageDigest.getInstance("SHA-256").digest(der)
97
+ return TrustedKey(keyId, key, OtaProtocol.toHex(fp))
98
+ }
99
+ }
100
+ }