@scalebun/react-native 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/android/build.gradle +8 -0
  2. package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +115 -60
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
  8. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
  9. package/dist/scalebun.full.js +127 -422
  10. package/dist/scalebun.slim.js +127 -422
  11. package/ios/Ota/OtaProtocol.swift +242 -0
  12. package/ios/Ota/OtaSlotManager.swift +26 -0
  13. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  14. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  15. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  16. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  17. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  18. package/lib/commonjs/core/config/schema.js +3 -12
  19. package/lib/commonjs/core/constants/version.js +1 -1
  20. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  21. package/lib/commonjs/features/network/thirdParty.js +90 -0
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js +108 -60
  23. package/lib/commonjs/public/ScaleBunFacade.js +5 -41
  24. package/lib/module/core/config/schema.js +3 -12
  25. package/lib/module/core/constants/version.js +1 -1
  26. package/lib/module/features/network/NetworkFeature.js +38 -4
  27. package/lib/module/features/network/thirdParty.js +83 -0
  28. package/lib/module/features/ota/OtaOrchestrator.js +108 -60
  29. package/lib/module/public/ScaleBunFacade.js +5 -41
  30. package/lib/typescript/core/config/schema.d.ts +0 -3
  31. package/lib/typescript/core/constants/version.d.ts +1 -1
  32. package/lib/typescript/features/network/index.d.ts +18 -0
  33. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  34. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +5 -8
  35. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  36. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  37. package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
  38. package/package.json +3 -13
  39. package/scalebun-react-native.podspec +4 -0
  40. package/src/core/config/schema.ts +3 -9
  41. package/src/core/constants/version.ts +1 -1
  42. package/src/features/network/NetworkFeature.ts +41 -4
  43. package/src/features/network/index.ts +18 -0
  44. package/src/features/network/thirdParty.ts +92 -0
  45. package/src/features/ota/OtaOrchestrator.ts +116 -70
  46. package/src/features/ota/OtaTypes.ts +23 -1
  47. package/src/public/ScaleBunFacade.ts +5 -53
  48. package/src/specs/NativeScaleBunOta.ts +32 -20
  49. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  50. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  51. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  52. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  53. package/lib/commonjs/features/ota/signature.js +0 -175
  54. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  55. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  56. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  57. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  58. package/lib/module/features/ota/signature.js +0 -169
  59. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  60. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  61. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  62. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  63. package/lib/typescript/features/ota/signature.d.ts +0 -81
  64. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  65. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  66. package/src/features/ota/crypto/loadSha512.ts +0 -35
  67. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  68. package/src/features/ota/signature.ts +0 -206
@@ -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,12 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
139
154
  return slotManager.slotState().toString()
140
155
  }
141
156
 
157
+ /** JSON array of the trusted key IDs embedded in this app (SDK trust coverage). */
158
+ @ReactMethod(isBlockingSynchronousMethod = true)
159
+ override fun getTrustedKeyIds(): String {
160
+ return org.json.JSONArray(keyRegistry.keyIds().toList()).toString()
161
+ }
162
+
142
163
  /**
143
164
  * Stage a bundle into the `next` slot.
144
165
  *
@@ -154,6 +175,19 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
154
175
  */
155
176
  @ReactMethod
156
177
  override fun stageBundle(sourcePath: String, sha256: String, promise: Promise) {
178
+ // Native-enforced staging (docs §17): once trusted keys are embedded, the
179
+ // ONLY way to stage is verifyAndStageRelease, which proves an RSA signature
180
+ // over the release payload first. A hostile JS bundle can therefore not
181
+ // drive an unauthenticated bundle onto the device by calling this path —
182
+ // SHA-256 integrity alone (what this method checks) is not authenticity.
183
+ if (!keyRegistry.isEmpty()) {
184
+ android.util.Log.w(
185
+ NAME,
186
+ "stageBundle refused: trusted keys are embedded — use verifyAndStageRelease (signature-enforced).",
187
+ )
188
+ promise.resolve(false)
189
+ return
190
+ }
157
191
  var downloaded: File? = null
158
192
  try {
159
193
  val isRemote = sourcePath.startsWith("http://", true) ||
@@ -214,6 +248,16 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
214
248
  expectedSha256: String,
215
249
  promise: Promise,
216
250
  ) {
251
+ // Same native-enforcement as stageBundle: a bsdiff patch staged by hash
252
+ // alone carries no signature, so once trusted keys are embedded this path
253
+ // is closed. Result-anchored signed patches are a native follow-up
254
+ // (verifyAndStagePatch); until then the SDK falls back to the verified
255
+ // full download (verifyAndStageRelease). false = "patch unavailable".
256
+ if (!keyRegistry.isEmpty()) {
257
+ android.util.Log.i(NAME, "stagePatch disabled while trusted keys are embedded — full verified download")
258
+ promise.resolve(false)
259
+ return
260
+ }
217
261
  var downloadedPatch: File? = null
218
262
  var rebuilt: File? = null
219
263
  try {
@@ -284,78 +328,89 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
284
328
  }
285
329
 
286
330
  /**
287
- * Verify a detached ed25519 signature with PLATFORM crypto.
288
- *
289
- * Why native: the JS verifier lives inside the very bundle it protects, so
290
- * one malicious bundle can neuter it for every update after. Platform
291
- * crypto sits outside the bundle's reach. It also drops the runtime need
292
- * for the optional @noble peers on Android 13+.
331
+ * Verify a SB-OTA-RSA-SHA256-V1 release envelope with PLATFORM crypto and the
332
+ * EMBEDDED trusted-key registry — no download, no side effects. Resolves the
333
+ * verdict JSON (see [Verdict.toJson]); a failed verification is a verdict with
334
+ * `ok:false` and a stable `code`, never a rejected promise.
293
335
  *
294
- * Contract (mirrors the JS spec doc):
295
- * - inputs are lowercase hex, pre-validated by the JS caller
296
- * - the message is the RAW 32 BYTES of the bundle SHA-256 — the CLI
297
- * signs the digest bytes, not their hex text
298
- * - resolves 'valid' / 'invalid' / 'unavailable'; never rejects for a
299
- * wrong signature, and any unexpected throw resolves 'unavailable' so
300
- * machinery failure is distinguishable from a forged bundle
336
+ * Native because the JS verifier lives inside the very bundle it protects, and
337
+ * because the trusted key must come from the APK, not from JS or the server.
338
+ * `SHA256withRSA` + RSA `KeyFactory` are available on every supported Android
339
+ * API level, so there is no API-33 floor (the reason RSA replaced ed25519).
340
+ */
341
+ @ReactMethod
342
+ override fun verifyRelease(envelopeJson: String, promise: Promise) {
343
+ try {
344
+ promise.resolve(ScaleBunOtaReleaseVerifier.verify(envelopeJson, buildContext(), keyRegistry).toJson())
345
+ } catch (e: Throwable) {
346
+ promise.resolve(
347
+ Verdict.fail(OtaFailureCode.INTERNAL_VERIFICATION_ERROR, e.message ?: "internal error").toJson(),
348
+ )
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Native-ENFORCED install (docs §5, §17): verify the RSA signature over the
354
+ * release payload against an embedded key, THEN download to a temp file,
355
+ * enforce the signed size + SHA-256, and stage into the `next` slot. JS never
356
+ * gets to stage bytes that were not proven authentic. Does not activate —
357
+ * [applyUpdate] promotes per the install mode, unchanged.
301
358
  *
302
- * Availability: `Signature.getInstance("Ed25519")` ships in Android's
303
- * conscrypt from API 33. Below that the JCA lookup throws
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.
359
+ * Resolves the verdict JSON: `ok:true` once staged, else `ok:false` with the
360
+ * stage that failed as a stable `code`.
307
361
  */
308
362
  @ReactMethod
309
- override fun verifyEd25519(
310
- messageHex: String,
311
- signatureHex: String,
312
- publicKeyHex: String,
313
- promise: Promise,
314
- ) {
363
+ override fun verifyAndStageRelease(envelopeJson: String, promise: Promise) {
364
+ var downloaded: File? = null
315
365
  try {
316
- val message = hexToBytes(messageHex)
317
- val signature = hexToBytes(signatureHex)
318
- val rawKey = hexToBytes(publicKeyHex)
319
- if (message == null || signature == null || rawKey == null || rawKey.size != 32) {
320
- // Malformed input from our own caller is not a forgery verdict.
321
- promise.resolve("unavailable")
366
+ val verdict = ScaleBunOtaReleaseVerifier.verify(envelopeJson, buildContext(), keyRegistry)
367
+ val payload = verdict.payload
368
+ if (!verdict.ok || payload == null) {
369
+ promise.resolve(verdict.toJson())
322
370
  return
323
371
  }
324
372
 
325
- // JCA takes X.509 SubjectPublicKeyInfo, not raw key bytes: the
326
- // 12-byte ed25519 SPKI prefix (OID 1.3.101.112) + the raw 32.
327
- val spkiPrefix = byteArrayOf(
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
373
+ val url = JSONObject(envelopeJson).optString("downloadUrl", "")
374
+ if (url.isEmpty()) {
375
+ promise.resolve(Verdict.fail(OtaFailureCode.DOWNLOAD_FAILED, "envelope has no downloadUrl").toJson())
334
376
  return
335
377
  }
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
378
 
348
- /** Hex bytes; null for odd length or a non-hex character. */
349
- private fun hexToBytes(hex: String): ByteArray? {
350
- if (hex.isEmpty() || hex.length % 2 != 0) return null
351
- val out = ByteArray(hex.length / 2)
352
- for (i in out.indices) {
353
- val hi = Character.digit(hex[i * 2], 16)
354
- val lo = Character.digit(hex[i * 2 + 1], 16)
355
- if (hi < 0 || lo < 0) return null
356
- out[i] = ((hi shl 4) or lo).toByte()
379
+ val temp = File(reactApplicationContext.cacheDir, "scalebun_ota_dl_${System.nanoTime()}")
380
+ downloaded = temp
381
+ if (!BundleDownloader.download(url, temp, ::emitProgress)) {
382
+ promise.resolve(Verdict.fail(OtaFailureCode.DOWNLOAD_FAILED, "download failed").toJson())
383
+ return
384
+ }
385
+ // Size first (cheap), against the SIGNED artifactSize.
386
+ if (temp.length() != payload.artifactSize) {
387
+ promise.resolve(Verdict.fail(OtaFailureCode.ARTIFACT_SIZE_MISMATCH, "downloaded size mismatch").toJson())
388
+ return
389
+ }
390
+
391
+ // SlotManager.stage streams a SHA-256 and compares against the signed
392
+ // artifactSha256 (== resultArtifactSha256 for a full artifact), so a
393
+ // wrong download cannot be staged. meta carries the sequence used by
394
+ // the anti-downgrade floor once the bundle is confirmed healthy.
395
+ val meta = JSONObject()
396
+ .put("sha256", payload.artifactSha256)
397
+ .put("releaseSequence", payload.releaseSequence)
398
+ .put("bundleId", payload.bundleId)
399
+ .put("releaseId", payload.releaseId)
400
+ .put("installedAt", System.currentTimeMillis())
401
+ val staged = slotManager.stage(temp, payload.artifactSha256, meta)
402
+ if (!staged) {
403
+ promise.resolve(Verdict.fail(OtaFailureCode.ARTIFACT_HASH_MISMATCH, "staged bytes failed hash check").toJson())
404
+ return
405
+ }
406
+ promise.resolve(verdict.toJson())
407
+ } catch (e: Throwable) {
408
+ promise.resolve(
409
+ Verdict.fail(OtaFailureCode.INTERNAL_VERIFICATION_ERROR, e.message ?: "internal error").toJson(),
410
+ )
411
+ } finally {
412
+ downloaded?.delete()
357
413
  }
358
- return out
359
414
  }
360
415
 
361
416
  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
@@ -163,6 +179,8 @@ class SlotManager(private val filesDir: File) {
163
179
  bootMarker.delete()
164
180
  Log.i(TAG, "Boot marker cleared — bundle marked healthy")
165
181
  }
182
+ // A bundle is only trusted for anti-downgrade once it has run healthily.
183
+ confirmCurrentSequence()
166
184
  }
167
185
 
168
186
  /**
@@ -17,6 +17,7 @@ abstract class ScaleBunOtaSpec(reactContext: ReactApplicationContext) :
17
17
 
18
18
  abstract fun getActiveBundle(): String?
19
19
  abstract fun getSlotState(): String
20
+ abstract fun getTrustedKeyIds(): String
20
21
  abstract fun stageBundle(sourcePath: String, sha256: String, promise: Promise)
21
22
  abstract fun stagePatch(
22
23
  patchUrl: String,
@@ -32,12 +33,7 @@ abstract class ScaleBunOtaSpec(reactContext: ReactApplicationContext) :
32
33
  // Hand-maintained: this file does NOT regenerate from the JS spec. A method
33
34
  // added to NativeScaleBunOta.ts that is missing here compiles green on New
34
35
  // Arch and breaks EVERY old-arch consumer's build (the ScaleBunCrashModule
35
- // incident). Non-null Strings to match the codegen base's signature, so the
36
- // one concrete override compiles against either architecture.
37
- abstract fun verifyEd25519(
38
- messageHex: String,
39
- signatureHex: String,
40
- publicKeyHex: String,
41
- promise: Promise,
42
- )
36
+ // incident). Keep these in lockstep with src/specs/NativeScaleBunOta.ts.
37
+ abstract fun verifyRelease(envelopeJson: String, promise: Promise)
38
+ abstract fun verifyAndStageRelease(envelopeJson: String, promise: Promise)
43
39
  }