@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.
- package/android/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +115 -60
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
- package/dist/scalebun.full.js +127 -422
- package/dist/scalebun.slim.js +127 -422
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/lib/commonjs/core/config/schema.js +3 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +108 -60
- package/lib/commonjs/public/ScaleBunFacade.js +5 -41
- package/lib/module/core/config/schema.js +3 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +108 -60
- package/lib/module/public/ScaleBunFacade.js +5 -41
- package/lib/typescript/core/config/schema.d.ts +0 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +5 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/core/config/schema.ts +3 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +116 -70
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/public/ScaleBunFacade.ts +5 -53
- package/src/specs/NativeScaleBunOta.ts +32 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- package/src/features/ota/signature.ts +0 -206
package/android/build.gradle
CHANGED
|
@@ -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
|
}
|
package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt
ADDED
|
@@ -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
|
+
}
|