@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.
- 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/BundleDownloader.kt +10 -0
- package/android/src/main/java/com/scalebun/rn/ota/DeviceIntegrity.kt +75 -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 +132 -62
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +80 -1
- package/android/src/main/java/com/scalebun/rn/ota/TlsPinning.kt +118 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +6 -8
- package/dist/scalebun.full.js +964 -533
- package/dist/scalebun.slim.js +962 -531
- 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/ios/ReplaySdk.swift +65 -2
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/commonjs/core/config/schema.js +19 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +293 -5
- package/lib/commonjs/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/commonjs/features/journey/autoInstrumentScroll.js +161 -0
- package/lib/commonjs/features/journey/interactionProtocol.js +131 -10
- package/lib/commonjs/features/journey/scrollContext.js +146 -0
- package/lib/commonjs/features/journey/targetGeometry.js +164 -0
- package/lib/commonjs/features/journey/touchTarget.js +152 -24
- package/lib/commonjs/features/journey/uiState.js +81 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +74 -5
- 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 +175 -80
- package/lib/commonjs/features/session/SessionManager.js +63 -0
- package/lib/commonjs/public/ScaleBunFacade.js +23 -57
- package/lib/module/bootstrap/SDKBootstrapper.js +30 -0
- package/lib/module/core/config/schema.js +19 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/journey/ScaleBunDebugRoot.js +295 -7
- package/lib/module/features/journey/ScaleBunScrollView.js +28 -0
- package/lib/module/features/journey/autoInstrumentScroll.js +155 -0
- package/lib/module/features/journey/interactionProtocol.js +128 -8
- package/lib/module/features/journey/scrollContext.js +135 -0
- package/lib/module/features/journey/targetGeometry.js +155 -0
- package/lib/module/features/journey/touchTarget.js +151 -24
- package/lib/module/features/journey/uiState.js +78 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +74 -5
- 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 +175 -80
- package/lib/module/features/session/SessionManager.js +63 -0
- package/lib/module/public/ScaleBunFacade.js +23 -57
- package/lib/typescript/core/config/schema.d.ts +2 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/journey/autoInstrumentScroll.d.ts +46 -0
- package/lib/typescript/features/journey/interactionProtocol.d.ts +139 -1
- package/lib/typescript/features/journey/scrollContext.d.ts +85 -0
- package/lib/typescript/features/journey/targetGeometry.d.ts +114 -0
- package/lib/typescript/features/journey/touchTarget.d.ts +148 -15
- package/lib/typescript/features/journey/uiState.d.ts +39 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +48 -2
- 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 +12 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/features/session/SessionManager.d.ts +55 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/public/types.d.ts +10 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +32 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/bootstrap/SDKBootstrapper.ts +40 -0
- package/src/core/config/schema.ts +21 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/journey/ScaleBunDebugRoot.tsx +288 -7
- package/src/features/journey/ScaleBunScrollView.tsx +29 -0
- package/src/features/journey/autoInstrumentScroll.ts +155 -0
- package/src/features/journey/interactionProtocol.ts +188 -10
- package/src/features/journey/scrollContext.ts +137 -0
- package/src/features/journey/targetGeometry.ts +176 -0
- package/src/features/journey/touchTarget.ts +237 -26
- package/src/features/journey/uiState.ts +80 -1
- package/src/features/navigation/AutoScreenDetector.ts +65 -5
- 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 +190 -90
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/features/session/SessionManager.ts +113 -0
- package/src/public/ScaleBunFacade.ts +23 -69
- package/src/public/types.ts +10 -0
- package/src/specs/NativeScaleBunOta.ts +35 -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
|
@@ -96,6 +96,21 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
96
96
|
|
|
97
97
|
private val slotManager by lazy { SlotManager(reactApplicationContext.filesDir) }
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Trusted RSA public keys embedded in the APK (docs §9). Non-empty once the
|
|
101
|
+
* app ships a `scalebun_ota_keys.json` asset — which is what flips staging
|
|
102
|
+
* into the native-ENFORCED path (see [stageBundle]).
|
|
103
|
+
*/
|
|
104
|
+
private val keyRegistry by lazy { ScaleBunOtaKeyRegistry.fromAssets(reactApplicationContext) }
|
|
105
|
+
|
|
106
|
+
/** Device-derived verification context — never taken from JS. */
|
|
107
|
+
private fun buildContext(): ExpectedContext = ExpectedContext(
|
|
108
|
+
projectId = keyRegistry.projectId,
|
|
109
|
+
applicationId = reactApplicationContext.packageName,
|
|
110
|
+
currentSequence = slotManager.highestConfirmedSequence(),
|
|
111
|
+
nowEpochSeconds = System.currentTimeMillis() / 1000,
|
|
112
|
+
)
|
|
113
|
+
|
|
99
114
|
override fun getName(): String = NAME
|
|
100
115
|
|
|
101
116
|
/**
|
|
@@ -139,6 +154,27 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
139
154
|
return slotManager.slotState().toString()
|
|
140
155
|
}
|
|
141
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Device integrity signals as a JSON array of reason strings.
|
|
159
|
+
*
|
|
160
|
+
* Reported, never enforced here: the SDK does not decide to withhold an
|
|
161
|
+
* update on a rooted device, because that is a product decision a host app
|
|
162
|
+
* makes with knowledge of its own users. Empty array means nothing was
|
|
163
|
+
* detected, which is not proof the device is clean.
|
|
164
|
+
*/
|
|
165
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
166
|
+
override fun getDeviceIntegrity(): String {
|
|
167
|
+
return org.json.JSONArray(
|
|
168
|
+
DeviceIntegrity.rootIndicators(reactApplicationContext),
|
|
169
|
+
).toString()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** JSON array of the trusted key IDs embedded in this app (SDK trust coverage). */
|
|
173
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
174
|
+
override fun getTrustedKeyIds(): String {
|
|
175
|
+
return org.json.JSONArray(keyRegistry.keyIds().toList()).toString()
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
/**
|
|
143
179
|
* Stage a bundle into the `next` slot.
|
|
144
180
|
*
|
|
@@ -154,6 +190,19 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
154
190
|
*/
|
|
155
191
|
@ReactMethod
|
|
156
192
|
override fun stageBundle(sourcePath: String, sha256: String, promise: Promise) {
|
|
193
|
+
// Native-enforced staging (docs §17): once trusted keys are embedded, the
|
|
194
|
+
// ONLY way to stage is verifyAndStageRelease, which proves an RSA signature
|
|
195
|
+
// over the release payload first. A hostile JS bundle can therefore not
|
|
196
|
+
// drive an unauthenticated bundle onto the device by calling this path —
|
|
197
|
+
// SHA-256 integrity alone (what this method checks) is not authenticity.
|
|
198
|
+
if (!keyRegistry.isEmpty()) {
|
|
199
|
+
android.util.Log.w(
|
|
200
|
+
NAME,
|
|
201
|
+
"stageBundle refused: trusted keys are embedded — use verifyAndStageRelease (signature-enforced).",
|
|
202
|
+
)
|
|
203
|
+
promise.resolve(false)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
157
206
|
var downloaded: File? = null
|
|
158
207
|
try {
|
|
159
208
|
val isRemote = sourcePath.startsWith("http://", true) ||
|
|
@@ -163,7 +212,7 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
163
212
|
if (isRemote) {
|
|
164
213
|
val temp = File(reactApplicationContext.cacheDir, "scalebun_ota_dl_${System.nanoTime()}")
|
|
165
214
|
downloaded = temp
|
|
166
|
-
if (!BundleDownloader.download(sourcePath, temp, ::emitProgress)) {
|
|
215
|
+
if (!BundleDownloader.download(reactApplicationContext, sourcePath, temp, ::emitProgress)) {
|
|
167
216
|
promise.resolve(false)
|
|
168
217
|
return
|
|
169
218
|
}
|
|
@@ -214,6 +263,16 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
214
263
|
expectedSha256: String,
|
|
215
264
|
promise: Promise,
|
|
216
265
|
) {
|
|
266
|
+
// Same native-enforcement as stageBundle: a bsdiff patch staged by hash
|
|
267
|
+
// alone carries no signature, so once trusted keys are embedded this path
|
|
268
|
+
// is closed. Result-anchored signed patches are a native follow-up
|
|
269
|
+
// (verifyAndStagePatch); until then the SDK falls back to the verified
|
|
270
|
+
// full download (verifyAndStageRelease). false = "patch unavailable".
|
|
271
|
+
if (!keyRegistry.isEmpty()) {
|
|
272
|
+
android.util.Log.i(NAME, "stagePatch disabled while trusted keys are embedded — full verified download")
|
|
273
|
+
promise.resolve(false)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
217
276
|
var downloadedPatch: File? = null
|
|
218
277
|
var rebuilt: File? = null
|
|
219
278
|
try {
|
|
@@ -230,7 +289,7 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
230
289
|
|
|
231
290
|
val temp = File(reactApplicationContext.cacheDir, "scalebun_ota_patch_${System.nanoTime()}")
|
|
232
291
|
downloadedPatch = temp
|
|
233
|
-
if (!BundleDownloader.download(patchUrl, temp, ::emitProgress)) {
|
|
292
|
+
if (!BundleDownloader.download(reactApplicationContext, patchUrl, temp, ::emitProgress)) {
|
|
234
293
|
promise.resolve(false)
|
|
235
294
|
return
|
|
236
295
|
}
|
|
@@ -284,78 +343,89 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
284
343
|
}
|
|
285
344
|
|
|
286
345
|
/**
|
|
287
|
-
* Verify a
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* crypto sits outside the bundle's reach. It also drops the runtime need
|
|
292
|
-
* for the optional @noble peers on Android 13+.
|
|
346
|
+
* Verify a SB-OTA-RSA-SHA256-V1 release envelope with PLATFORM crypto and the
|
|
347
|
+
* EMBEDDED trusted-key registry — no download, no side effects. Resolves the
|
|
348
|
+
* verdict JSON (see [Verdict.toJson]); a failed verification is a verdict with
|
|
349
|
+
* `ok:false` and a stable `code`, never a rejected promise.
|
|
293
350
|
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
351
|
+
* Native because the JS verifier lives inside the very bundle it protects, and
|
|
352
|
+
* because the trusted key must come from the APK, not from JS or the server.
|
|
353
|
+
* `SHA256withRSA` + RSA `KeyFactory` are available on every supported Android
|
|
354
|
+
* API level, so there is no API-33 floor (the reason RSA replaced ed25519).
|
|
355
|
+
*/
|
|
356
|
+
@ReactMethod
|
|
357
|
+
override fun verifyRelease(envelopeJson: String, promise: Promise) {
|
|
358
|
+
try {
|
|
359
|
+
promise.resolve(ScaleBunOtaReleaseVerifier.verify(envelopeJson, buildContext(), keyRegistry).toJson())
|
|
360
|
+
} catch (e: Throwable) {
|
|
361
|
+
promise.resolve(
|
|
362
|
+
Verdict.fail(OtaFailureCode.INTERNAL_VERIFICATION_ERROR, e.message ?: "internal error").toJson(),
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Native-ENFORCED install (docs §5, §17): verify the RSA signature over the
|
|
369
|
+
* release payload against an embedded key, THEN download to a temp file,
|
|
370
|
+
* enforce the signed size + SHA-256, and stage into the `next` slot. JS never
|
|
371
|
+
* gets to stage bytes that were not proven authentic. Does not activate —
|
|
372
|
+
* [applyUpdate] promotes per the install mode, unchanged.
|
|
301
373
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* NoSuchAlgorithmException — reported as 'unavailable', and JS falls back
|
|
305
|
-
* to its own verifier. Checked by attempting the lookup rather than by
|
|
306
|
-
* SDK_INT, because the algorithm's presence is the fact that matters.
|
|
374
|
+
* Resolves the verdict JSON: `ok:true` once staged, else `ok:false` with the
|
|
375
|
+
* stage that failed as a stable `code`.
|
|
307
376
|
*/
|
|
308
377
|
@ReactMethod
|
|
309
|
-
override fun
|
|
310
|
-
|
|
311
|
-
signatureHex: String,
|
|
312
|
-
publicKeyHex: String,
|
|
313
|
-
promise: Promise,
|
|
314
|
-
) {
|
|
378
|
+
override fun verifyAndStageRelease(envelopeJson: String, promise: Promise) {
|
|
379
|
+
var downloaded: File? = null
|
|
315
380
|
try {
|
|
316
|
-
val
|
|
317
|
-
val
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// Malformed input from our own caller is not a forgery verdict.
|
|
321
|
-
promise.resolve("unavailable")
|
|
381
|
+
val verdict = ScaleBunOtaReleaseVerifier.verify(envelopeJson, buildContext(), keyRegistry)
|
|
382
|
+
val payload = verdict.payload
|
|
383
|
+
if (!verdict.ok || payload == null) {
|
|
384
|
+
promise.resolve(verdict.toJson())
|
|
322
385
|
return
|
|
323
386
|
}
|
|
324
387
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
329
|
-
)
|
|
330
|
-
val verifier = try {
|
|
331
|
-
java.security.Signature.getInstance("Ed25519")
|
|
332
|
-
} catch (_: java.security.NoSuchAlgorithmException) {
|
|
333
|
-
promise.resolve("unavailable") // API < 33
|
|
388
|
+
val url = JSONObject(envelopeJson).optString("downloadUrl", "")
|
|
389
|
+
if (url.isEmpty()) {
|
|
390
|
+
promise.resolve(Verdict.fail(OtaFailureCode.DOWNLOAD_FAILED, "envelope has no downloadUrl").toJson())
|
|
334
391
|
return
|
|
335
392
|
}
|
|
336
|
-
val key = java.security.KeyFactory.getInstance("Ed25519")
|
|
337
|
-
.generatePublic(java.security.spec.X509EncodedKeySpec(spkiPrefix + rawKey))
|
|
338
|
-
|
|
339
|
-
verifier.initVerify(key)
|
|
340
|
-
verifier.update(message)
|
|
341
|
-
promise.resolve(if (verifier.verify(signature)) "valid" else "invalid")
|
|
342
|
-
} catch (_: Throwable) {
|
|
343
|
-
// Fail toward "cannot check", never toward "checked out fine".
|
|
344
|
-
promise.resolve("unavailable")
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
393
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
394
|
+
val temp = File(reactApplicationContext.cacheDir, "scalebun_ota_dl_${System.nanoTime()}")
|
|
395
|
+
downloaded = temp
|
|
396
|
+
if (!BundleDownloader.download(reactApplicationContext, url, temp, ::emitProgress)) {
|
|
397
|
+
promise.resolve(Verdict.fail(OtaFailureCode.DOWNLOAD_FAILED, "download failed").toJson())
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
// Size first (cheap), against the SIGNED artifactSize.
|
|
401
|
+
if (temp.length() != payload.artifactSize) {
|
|
402
|
+
promise.resolve(Verdict.fail(OtaFailureCode.ARTIFACT_SIZE_MISMATCH, "downloaded size mismatch").toJson())
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// SlotManager.stage streams a SHA-256 and compares against the signed
|
|
407
|
+
// artifactSha256 (== resultArtifactSha256 for a full artifact), so a
|
|
408
|
+
// wrong download cannot be staged. meta carries the sequence used by
|
|
409
|
+
// the anti-downgrade floor once the bundle is confirmed healthy.
|
|
410
|
+
val meta = JSONObject()
|
|
411
|
+
.put("sha256", payload.artifactSha256)
|
|
412
|
+
.put("releaseSequence", payload.releaseSequence)
|
|
413
|
+
.put("bundleId", payload.bundleId)
|
|
414
|
+
.put("releaseId", payload.releaseId)
|
|
415
|
+
.put("installedAt", System.currentTimeMillis())
|
|
416
|
+
val staged = slotManager.stage(temp, payload.artifactSha256, meta)
|
|
417
|
+
if (!staged) {
|
|
418
|
+
promise.resolve(Verdict.fail(OtaFailureCode.ARTIFACT_HASH_MISMATCH, "staged bytes failed hash check").toJson())
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
promise.resolve(verdict.toJson())
|
|
422
|
+
} catch (e: Throwable) {
|
|
423
|
+
promise.resolve(
|
|
424
|
+
Verdict.fail(OtaFailureCode.INTERNAL_VERIFICATION_ERROR, e.message ?: "internal error").toJson(),
|
|
425
|
+
)
|
|
426
|
+
} finally {
|
|
427
|
+
downloaded?.delete()
|
|
357
428
|
}
|
|
358
|
-
return out
|
|
359
429
|
}
|
|
360
430
|
|
|
361
431
|
private fun sha256Hex(bytes: ByteArray): String {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
package com.scalebun.rn.ota
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import java.security.Signature
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SB-OTA-RSA-SHA256-V1 native release verifier (docs §5). Runs the authoritative
|
|
8
|
+
* order: strict-decode → registry-only key resolution → RSA-SHA256 verify over
|
|
9
|
+
* the EXACT signed-payload bytes → parse → cross-check the authenticated payload
|
|
10
|
+
* against the device's own identity + the anti-downgrade floor. Fails closed and
|
|
11
|
+
* maps every failure to a stable {@link OtaFailureCode}.
|
|
12
|
+
*
|
|
13
|
+
* The verify call sequence (Signature("SHA256withRSA") over payload bytes) is the
|
|
14
|
+
* same JCA surface proven in tools/rsa-ota-parity/RsaOtaParity.java against the
|
|
15
|
+
* shared fixtures, and available on every supported Android API level (no API-33
|
|
16
|
+
* floor, unlike ed25519).
|
|
17
|
+
*/
|
|
18
|
+
data class ExpectedContext(
|
|
19
|
+
/** The ScaleBun project this binary belongs to (from the embedded config). */
|
|
20
|
+
val projectId: String,
|
|
21
|
+
/** The native application id — the device's own package name. */
|
|
22
|
+
val applicationId: String,
|
|
23
|
+
/** Highest confirmed release sequence for anti-downgrade (0 if none yet). */
|
|
24
|
+
val currentSequence: Long,
|
|
25
|
+
/** Seconds since epoch, for the expiry check. */
|
|
26
|
+
val nowEpochSeconds: Long,
|
|
27
|
+
val platform: String = "android",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
data class Verdict(
|
|
31
|
+
val ok: Boolean,
|
|
32
|
+
val code: OtaFailureCode?,
|
|
33
|
+
val message: String?,
|
|
34
|
+
val payload: SignedRelease?,
|
|
35
|
+
val fingerprint: String?,
|
|
36
|
+
) {
|
|
37
|
+
fun toJson(): String {
|
|
38
|
+
val o = JSONObject()
|
|
39
|
+
o.put("ok", ok)
|
|
40
|
+
if (code != null) o.put("code", code.name)
|
|
41
|
+
if (message != null) o.put("message", message)
|
|
42
|
+
if (fingerprint != null) o.put("fingerprint", fingerprint)
|
|
43
|
+
payload?.let { p ->
|
|
44
|
+
o.put("keyId", p.keyId)
|
|
45
|
+
o.put("releaseId", p.releaseId)
|
|
46
|
+
o.put("bundleId", p.bundleId)
|
|
47
|
+
o.put("releaseSequence", p.releaseSequence)
|
|
48
|
+
o.put("artifactSha256", p.artifactSha256)
|
|
49
|
+
o.put("artifactSize", p.artifactSize)
|
|
50
|
+
o.put("resultArtifactSha256", p.resultArtifactSha256)
|
|
51
|
+
o.put("artifactType", p.artifactType)
|
|
52
|
+
}
|
|
53
|
+
return o.toString()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
companion object {
|
|
57
|
+
fun fail(code: OtaFailureCode, message: String) = Verdict(false, code, message, null, null)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
object ScaleBunOtaReleaseVerifier {
|
|
62
|
+
|
|
63
|
+
fun verify(envelopeJson: String, expected: ExpectedContext, registry: ScaleBunOtaKeyRegistry): Verdict {
|
|
64
|
+
return try {
|
|
65
|
+
val env = JSONObject(envelopeJson)
|
|
66
|
+
val protocol = env.optString("protocol", "")
|
|
67
|
+
if (protocol != OtaProtocol.SIGNATURE_ALGORITHM) {
|
|
68
|
+
return Verdict.fail(OtaFailureCode.UNSUPPORTED_PROTOCOL, "protocol $protocol")
|
|
69
|
+
}
|
|
70
|
+
val signedPayloadB64 = env.optString("signedPayload", "")
|
|
71
|
+
val signatureB64 = env.optString("signature", "")
|
|
72
|
+
val envKeyId = env.optString("keyId", "")
|
|
73
|
+
|
|
74
|
+
val payloadBytes = OtaProtocol.decodeBase64Strict(
|
|
75
|
+
signedPayloadB64, OtaProtocol.MAX_SIGNED_PAYLOAD_BYTES, "signedPayload",
|
|
76
|
+
)
|
|
77
|
+
val signature = OtaProtocol.decodeBase64Strict(
|
|
78
|
+
signatureB64, OtaProtocol.MAX_SIGNATURE_BYTES, "signature",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
val payloadKeyId = OtaProtocol.peekKeyId(payloadBytes)
|
|
82
|
+
if (envKeyId.isNotEmpty() && envKeyId != payloadKeyId) {
|
|
83
|
+
return Verdict.fail(OtaFailureCode.INVALID_ENVELOPE, "envelope keyId disagrees with payload")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
val key = registry.get(payloadKeyId)
|
|
87
|
+
?: return Verdict.fail(OtaFailureCode.UNKNOWN_KEY_ID, "no embedded key with id $payloadKeyId")
|
|
88
|
+
|
|
89
|
+
// RSA-SHA256 over the EXACT received payload bytes, BEFORE parsing.
|
|
90
|
+
val verifier = Signature.getInstance("SHA256withRSA")
|
|
91
|
+
verifier.initVerify(key.publicKey)
|
|
92
|
+
verifier.update(payloadBytes)
|
|
93
|
+
if (!verifier.verify(signature)) {
|
|
94
|
+
return Verdict.fail(OtaFailureCode.INVALID_SIGNATURE, "signature did not verify")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
val payload = OtaProtocol.parse(payloadBytes)
|
|
98
|
+
|
|
99
|
+
// The device only ever verifies a signed FULL release (patches are an
|
|
100
|
+
// out-of-band optimisation anchored to resultArtifactSha256, docs §8.1).
|
|
101
|
+
if (payload.artifactType != "full") {
|
|
102
|
+
return Verdict.fail(OtaFailureCode.INVALID_SIGNED_PAYLOAD, "signed payload is not a full release")
|
|
103
|
+
}
|
|
104
|
+
val projectExpected = if (registry.projectId.isNotEmpty()) registry.projectId else expected.projectId
|
|
105
|
+
if (projectExpected.isNotEmpty() && payload.projectId != projectExpected) {
|
|
106
|
+
return Verdict.fail(OtaFailureCode.PROJECT_MISMATCH, "projectId")
|
|
107
|
+
}
|
|
108
|
+
if (expected.applicationId.isNotEmpty() && payload.applicationId != expected.applicationId) {
|
|
109
|
+
return Verdict.fail(OtaFailureCode.APPLICATION_MISMATCH, "applicationId")
|
|
110
|
+
}
|
|
111
|
+
if (payload.platform != expected.platform) {
|
|
112
|
+
return Verdict.fail(OtaFailureCode.PLATFORM_MISMATCH, "platform")
|
|
113
|
+
}
|
|
114
|
+
if (payload.expiresAtEpochSeconds != 0L && expected.nowEpochSeconds > payload.expiresAtEpochSeconds) {
|
|
115
|
+
return Verdict.fail(OtaFailureCode.RELEASE_EXPIRED, "release expired")
|
|
116
|
+
}
|
|
117
|
+
if (payload.releaseSequence < expected.currentSequence) {
|
|
118
|
+
return Verdict.fail(OtaFailureCode.RELEASE_DOWNGRADE_REJECTED, "sequence below current")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
Verdict(true, null, null, payload, key.fingerprint)
|
|
122
|
+
} catch (e: OtaVerifyException) {
|
|
123
|
+
Verdict.fail(e.code, e.message ?: "verification failed")
|
|
124
|
+
} catch (e: Throwable) {
|
|
125
|
+
// Never let an exception read as success.
|
|
126
|
+
Verdict.fail(OtaFailureCode.INTERNAL_VERIFICATION_ERROR, e.message ?: "internal error")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -47,6 +47,22 @@ class SlotManager(private val filesDir: File) {
|
|
|
47
47
|
private val previousSlot: File get() = File(otaRoot, "previous")
|
|
48
48
|
private val bootMarker: File get() = File(otaRoot, BOOT_MARKER_FILENAME)
|
|
49
49
|
private val revertRecord: File get() = File(otaRoot, REVERT_RECORD_FILENAME)
|
|
50
|
+
private val sequenceFile: File get() = File(otaRoot, "highest_sequence")
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Highest CONFIRMED release sequence (anti-downgrade floor, docs §15). Only
|
|
54
|
+
* updated once a bundle is marked healthy, so a failed/rolled-back higher
|
|
55
|
+
* sequence never raises the floor and never blocks a legitimate re-install.
|
|
56
|
+
*/
|
|
57
|
+
fun highestConfirmedSequence(): Long =
|
|
58
|
+
try { sequenceFile.readText().trim().toLong() } catch (_: Exception) { 0L }
|
|
59
|
+
|
|
60
|
+
private fun confirmCurrentSequence() {
|
|
61
|
+
val seq = readMeta(currentSlot)?.optLong("releaseSequence", -1L) ?: -1L
|
|
62
|
+
if (seq >= 0 && seq > highestConfirmedSequence()) {
|
|
63
|
+
runCatching { otaRoot.mkdirs(); sequenceFile.writeText(seq.toString()) }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
50
66
|
|
|
51
67
|
/**
|
|
52
68
|
* Returns the absolute path to the active JS bundle, or null if no OTA
|
|
@@ -54,7 +70,68 @@ class SlotManager(private val filesDir: File) {
|
|
|
54
70
|
*/
|
|
55
71
|
fun currentBundlePath(): File? {
|
|
56
72
|
val bundle = File(currentSlot, BUNDLE_FILENAME)
|
|
57
|
-
|
|
73
|
+
if (!bundle.exists()) return null
|
|
74
|
+
|
|
75
|
+
// Integrity re-check on execution.
|
|
76
|
+
//
|
|
77
|
+
// The signature and hash are verified when a bundle is STAGED. That
|
|
78
|
+
// protects the download path, but it says nothing about the bytes still
|
|
79
|
+
// being the ones we installed: on a rooted device, anything able to write
|
|
80
|
+
// into app-private storage can replace current/bundle after the fact and
|
|
81
|
+
// the old `exists()` check would happily execute it.
|
|
82
|
+
//
|
|
83
|
+
// meta.json already records the sha256 the slot manager installed, so the
|
|
84
|
+
// comparison costs one hash of the active bundle per process — not per
|
|
85
|
+
// call, hence the cache. On mismatch we refuse the slot and return null,
|
|
86
|
+
// which makes the caller fall back to the bundle compiled into the APK.
|
|
87
|
+
// Failing closed to a known-good baseline is the only safe answer here.
|
|
88
|
+
verifiedBundlePath?.let { return if (it.exists()) it else null }
|
|
89
|
+
if (integrityFailed) return null
|
|
90
|
+
|
|
91
|
+
val expected = readMeta(currentSlot)?.optString("sha256", "").orEmpty()
|
|
92
|
+
if (expected.isEmpty()) {
|
|
93
|
+
// Pre-integrity install, or metadata lost. Nothing to compare against;
|
|
94
|
+
// do not invent a failure for a bundle that may be perfectly good.
|
|
95
|
+
Log.w(TAG, "current/ has no recorded sha256 — skipping integrity re-check")
|
|
96
|
+
verifiedBundlePath = bundle
|
|
97
|
+
return bundle
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
val actual = try {
|
|
101
|
+
sha256(bundle)
|
|
102
|
+
} catch (e: Exception) {
|
|
103
|
+
Log.e(TAG, "Integrity re-check could not hash current/ — refusing the slot", e)
|
|
104
|
+
integrityFailed = true
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!constantTimeEquals(actual, expected)) {
|
|
109
|
+
Log.e(
|
|
110
|
+
TAG,
|
|
111
|
+
"INTEGRITY FAILURE — current/ bundle does not match the sha256 recorded " +
|
|
112
|
+
"at install (expected=${expected.take(12)}… actual=${actual.take(12)}…). " +
|
|
113
|
+
"The bundle was modified after installation. Falling back to the " +
|
|
114
|
+
"binary baseline and clearing the slot.",
|
|
115
|
+
)
|
|
116
|
+
integrityFailed = true
|
|
117
|
+
runCatching { currentSlot.deleteRecursively() }
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
verifiedBundlePath = bundle
|
|
122
|
+
return bundle
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Per-process memo so the active bundle is hashed once, not on every call. */
|
|
126
|
+
private var verifiedBundlePath: File? = null
|
|
127
|
+
private var integrityFailed = false
|
|
128
|
+
|
|
129
|
+
/** Length-independent comparison, so a mismatch leaks nothing through timing. */
|
|
130
|
+
private fun constantTimeEquals(a: String, b: String): Boolean {
|
|
131
|
+
if (a.length != b.length) return false
|
|
132
|
+
var diff = 0
|
|
133
|
+
for (i in a.indices) diff = diff or (a[i].code xor b[i].code)
|
|
134
|
+
return diff == 0
|
|
58
135
|
}
|
|
59
136
|
|
|
60
137
|
/**
|
|
@@ -163,6 +240,8 @@ class SlotManager(private val filesDir: File) {
|
|
|
163
240
|
bootMarker.delete()
|
|
164
241
|
Log.i(TAG, "Boot marker cleared — bundle marked healthy")
|
|
165
242
|
}
|
|
243
|
+
// A bundle is only trusted for anti-downgrade once it has run healthily.
|
|
244
|
+
confirmCurrentSequence()
|
|
166
245
|
}
|
|
167
246
|
|
|
168
247
|
/**
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package com.scalebun.rn.ota
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Base64
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import java.security.MessageDigest
|
|
7
|
+
import java.security.cert.X509Certificate
|
|
8
|
+
import javax.net.ssl.HttpsURLConnection
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Optional certificate pinning for the OTA artifact download.
|
|
13
|
+
*
|
|
14
|
+
* Pins are **SPKI SHA-256 hashes**, not certificates. Pinning a certificate
|
|
15
|
+
* breaks on every renewal even when the key is unchanged, which is how pinning
|
|
16
|
+
* earns its reputation for bricking apps; pinning the public key survives
|
|
17
|
+
* renewal and only needs rotating when the key itself rotates.
|
|
18
|
+
*
|
|
19
|
+
* Configuration is a native asset, `scalebun_tls_pins.json`, alongside the
|
|
20
|
+
* trusted-key registry — JavaScript must not be able to introduce or remove a
|
|
21
|
+
* pin, for the same reason it cannot introduce a signing key:
|
|
22
|
+
*
|
|
23
|
+
* { "pins": {
|
|
24
|
+
* "api.scalebun.com": ["sha256/AAAA…", "sha256/BBBB…"]
|
|
25
|
+
* }}
|
|
26
|
+
*
|
|
27
|
+
* **Absent asset = pinning disabled.** That is deliberate. A pin that ships
|
|
28
|
+
* wrong cannot be fixed over the air — it takes a store release — so this is
|
|
29
|
+
* opt-in, and refuses to arm with fewer than two pins for a host: one live, one
|
|
30
|
+
* backup. An operator who pins a single key has built a single point of failure
|
|
31
|
+
* that a routine key rotation will detonate.
|
|
32
|
+
*/
|
|
33
|
+
object TlsPinning {
|
|
34
|
+
|
|
35
|
+
private const val TAG = "ScaleBunOta"
|
|
36
|
+
private const val ASSET = "scalebun_tls_pins.json"
|
|
37
|
+
private const val MIN_PINS = 2
|
|
38
|
+
|
|
39
|
+
private var loaded = false
|
|
40
|
+
private var pinsByHost: Map<String, Set<String>> = emptyMap()
|
|
41
|
+
|
|
42
|
+
@Synchronized
|
|
43
|
+
private fun load(context: Context) {
|
|
44
|
+
if (loaded) return
|
|
45
|
+
loaded = true
|
|
46
|
+
try {
|
|
47
|
+
val json = context.assets.open(ASSET).use {
|
|
48
|
+
it.readBytes().toString(Charsets.UTF_8)
|
|
49
|
+
}
|
|
50
|
+
val root = JSONObject(json).optJSONObject("pins") ?: return
|
|
51
|
+
val map = mutableMapOf<String, Set<String>>()
|
|
52
|
+
for (host in root.keys()) {
|
|
53
|
+
val arr = root.optJSONArray(host) ?: continue
|
|
54
|
+
val set = (0 until arr.length()).mapNotNull { arr.optString(it, null) }.toSet()
|
|
55
|
+
if (set.size < MIN_PINS) {
|
|
56
|
+
Log.e(
|
|
57
|
+
TAG,
|
|
58
|
+
"TLS pinning NOT armed for $host — ${set.size} pin(s) configured, " +
|
|
59
|
+
"at least $MIN_PINS required (a live pin and a backup). " +
|
|
60
|
+
"Shipping one pin means the next key rotation bricks the app.",
|
|
61
|
+
)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
map[host.lowercase()] = set
|
|
65
|
+
}
|
|
66
|
+
pinsByHost = map
|
|
67
|
+
if (map.isNotEmpty()) {
|
|
68
|
+
Log.i(TAG, "TLS pinning armed for: ${map.keys.joinToString(", ")}")
|
|
69
|
+
}
|
|
70
|
+
} catch (_: Exception) {
|
|
71
|
+
// No asset shipped: pinning is simply not in use.
|
|
72
|
+
pinsByHost = emptyMap()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when this host has pins configured, so callers can log honestly. */
|
|
77
|
+
fun isPinned(context: Context, host: String): Boolean {
|
|
78
|
+
load(context)
|
|
79
|
+
return pinsByHost.containsKey(host.lowercase())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify an established HTTPS connection against the pins for its host.
|
|
84
|
+
*
|
|
85
|
+
* Returns true when the connection is acceptable — either because no pins
|
|
86
|
+
* are configured for that host, or because one of the certificates in the
|
|
87
|
+
* presented chain matches a pin. Returns false ONLY when pins exist and none
|
|
88
|
+
* matched, which is a hard failure the caller must treat as a failed
|
|
89
|
+
* download rather than retrying against the same endpoint.
|
|
90
|
+
*/
|
|
91
|
+
fun verify(context: Context, connection: HttpsURLConnection, host: String): Boolean {
|
|
92
|
+
load(context)
|
|
93
|
+
val expected = pinsByHost[host.lowercase()] ?: return true
|
|
94
|
+
|
|
95
|
+
val chain = try {
|
|
96
|
+
connection.serverCertificates
|
|
97
|
+
} catch (e: Exception) {
|
|
98
|
+
Log.e(TAG, "TLS pin check could not read the certificate chain for $host", e)
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (cert in chain) {
|
|
103
|
+
val x509 = cert as? X509Certificate ?: continue
|
|
104
|
+
val spki = x509.publicKey.encoded ?: continue
|
|
105
|
+
val digest = MessageDigest.getInstance("SHA-256").digest(spki)
|
|
106
|
+
val pin = "sha256/" + Base64.encodeToString(digest, Base64.NO_WRAP)
|
|
107
|
+
if (expected.contains(pin)) return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Log.e(
|
|
111
|
+
TAG,
|
|
112
|
+
"TLS PIN MISMATCH for $host — the presented certificate chain matches none " +
|
|
113
|
+
"of the ${expected.size} configured pins. Refusing the download. This is " +
|
|
114
|
+
"either an interception attempt or a rotated key whose pin was never shipped.",
|
|
115
|
+
)
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -17,6 +17,9 @@ abstract class ScaleBunOtaSpec(reactContext: ReactApplicationContext) :
|
|
|
17
17
|
|
|
18
18
|
abstract fun getActiveBundle(): String?
|
|
19
19
|
abstract fun getSlotState(): String
|
|
20
|
+
abstract fun getTrustedKeyIds(): String
|
|
21
|
+
|
|
22
|
+
abstract fun getDeviceIntegrity(): String
|
|
20
23
|
abstract fun stageBundle(sourcePath: String, sha256: String, promise: Promise)
|
|
21
24
|
abstract fun stagePatch(
|
|
22
25
|
patchUrl: String,
|
|
@@ -32,12 +35,7 @@ abstract class ScaleBunOtaSpec(reactContext: ReactApplicationContext) :
|
|
|
32
35
|
// Hand-maintained: this file does NOT regenerate from the JS spec. A method
|
|
33
36
|
// added to NativeScaleBunOta.ts that is missing here compiles green on New
|
|
34
37
|
// Arch and breaks EVERY old-arch consumer's build (the ScaleBunCrashModule
|
|
35
|
-
// incident).
|
|
36
|
-
|
|
37
|
-
abstract fun
|
|
38
|
-
messageHex: String,
|
|
39
|
-
signatureHex: String,
|
|
40
|
-
publicKeyHex: String,
|
|
41
|
-
promise: Promise,
|
|
42
|
-
)
|
|
38
|
+
// incident). Keep these in lockstep with src/specs/NativeScaleBunOta.ts.
|
|
39
|
+
abstract fun verifyRelease(envelopeJson: String, promise: Promise)
|
|
40
|
+
abstract fun verifyAndStageRelease(envelopeJson: String, promise: Promise)
|
|
43
41
|
}
|