@scalebun/react-native 1.11.1 → 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
@@ -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
+ }
@@ -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
+ }