@shipvo/runtime 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -28,15 +28,38 @@ Runtime.markHealthy(); // call once the first screen rendered
28
28
 
29
29
  `shipvo init` wires all of this for you.
30
30
 
31
- ## Events to forward
31
+ ## Events
32
32
 
33
- `Runtime.consumeEvents()` returns strings such as `verify_failed:<release>`. Forward `crash_loop_revert`, `rolled_back` and `verify_failed` to telemetry (`POST /t`), once per event. The sample app does this in `apps/tide-and-tackle/src/runtimeEvents.ts`. `verify_failed` is reported under the failed release id. A rejected or corrupt release then shows on the server, not only as repeated downloads.
33
+ The runtime reports refused and reverted releases for you. `Runtime.markHealthy()` and every `Runtime.checkForUpdate()` drain the device's event queue and send each event to `POST {baseUrl}/t`, using `baseUrl` and `appId` from `expo.extra.shipvo`. A refusal found during a background check is reported in the same session, without a restart.
34
34
 
35
- The runtime does not emit `download_failed` yet. The sample app and the control plane accept it, so it can be forwarded once the runtime raises it.
35
+ Forwarded events: `crash_loop_revert`, `rolled_back`, `verify_failed`, `download_failed`, `hermes_bytecode_mismatch`, `native_module_missing`, `native_module_version`. `verify_failed`, `download_failed` and `hermes_bytecode_mismatch` are reported under the failed release id. The native-module reasons arrive as `pointer_rejected:<reason>` and are reported under the reason name. Sending is best effort: a failed send never throws and never blocks.
36
36
 
37
- `Runtime.consumeEvents()` can also return `pointer_rejected:<reason>` on Android when the control plane says this shell cannot run the newest release. It is queued once per reason. `/t` accepts `hermes_bytecode_mismatch`, `native_module_missing` and `native_module_version`. The sample app forwards the two native-module reasons under those names. `hermes_bytecode_mismatch` is its own event and carries a release id.
37
+ ```ts
38
+ import { reportRuntimeEvents, onRuntimeEvents } from "@shipvo/runtime";
39
+ reportRuntimeEvents(); // drain and send now (markHealthy and checkForUpdate already do)
40
+ onRuntimeEvents((events) => console.log(events)); // see the drained strings, for a log
41
+ ```
42
+
43
+ To turn the automatic report off, pass `reportEvents: false` to the config plugin (it lands in `expo.extra.shipvo`), and call `reportRuntimeEvents()` yourself. `forwardRuntimeEvents(events, send)` and `FORWARDED_EVENTS` are also exported for custom senders. If you call `Runtime.consumeEvents()` yourself, you take events away from the report.
44
+
45
+ The runtime does not emit `download_failed` yet. The control plane accepts it.
46
+
47
+ ## Signed telemetry
48
+
49
+ Call `enableSignedTelemetry` once at startup. The install registers once, keeps a secret key in your storage, and then signs each report. If registration fails, reports go out unsigned and the next launch tries again.
50
+
51
+ ```ts
52
+ import { enableSignedTelemetry } from "@shipvo/runtime";
53
+ import AsyncStorage from "@react-native-async-storage/async-storage";
54
+
55
+ await enableSignedTelemetry({
56
+ storage: AsyncStorage,
57
+ registerUrl: "https://cp.example.com/t/register",
58
+ // crypto: { randomBytes, hmacSha256 } // React Native has no crypto.subtle, so pass your own
59
+ });
60
+ ```
38
61
 
39
- Events are read at launch. A `verify_failed` raised during `checkForUpdate` is reported on the next cold start.
62
+ The sent body is the unsigned JSON with its closing `}` replaced by `,"sig":"<hex>"}`. The HMAC-SHA256 covers the unsigned JSON, closing `}` included. A second call replaces the first identity.
40
63
 
41
64
  ## Crash after `markHealthy`
42
65
 
@@ -13,10 +13,16 @@ android {
13
13
  versionName "0.0.1"
14
14
  consumerProguardFiles "proguard-rules.pro"
15
15
  }
16
+ // The shared key rotation vectors (also used by the CLI and iOS tests) are read as test resources.
17
+ sourceSets {
18
+ test.resources.srcDirs += ["../test-vectors"]
19
+ }
16
20
  }
17
21
 
18
22
  dependencies {
19
23
  testImplementation 'junit:junit:4.13.2'
24
+ // The Android SDK's org.json is a stub in JVM unit tests.
25
+ testImplementation 'org.json:json:20231013'
20
26
  implementation 'com.facebook.react:react-android'
21
27
  // Ed25519 verification on Android 7+ (no platform Ed25519 before API 33). Pure Java, ~60 KB.
22
28
  implementation 'net.i2p.crypto:eddsa:0.3.0'
@@ -0,0 +1,9 @@
1
+ package dev.shipvo.runtime
2
+
3
+ /** Source of wall-clock time for rotation checks. Rotation code never reads the system time directly. */
4
+ internal fun interface Clock {
5
+ /** Milliseconds since the Unix epoch. */
6
+ fun now(): Long
7
+
8
+ object Wall : Clock { override fun now() = System.currentTimeMillis() }
9
+ }
@@ -8,11 +8,12 @@ internal object PointerQuery {
8
8
 
9
9
  private fun enc(v: String) = URLEncoder.encode(v, "UTF-8")
10
10
 
11
- /** `hbc` is "96,98"; `nm` is "name@1.2.3,@scope/pkg@2.0.0". A blank value is treated as not declared. */
12
- fun url(base: String, app: String, shellMajor: Int, band: String, hbc: String?, nm: String?): String {
11
+ /** `hbc` is "96,98"; `hbe` is the engine build id "250829098.0.10"; `nm` is "name@1.2.3,@scope/pkg@2.0.0". A blank value is treated as not declared. */
12
+ fun url(base: String, app: String, shellMajor: Int, band: String, hbc: String?, nm: String?, hbe: String? = null): String {
13
13
  val q = ArrayList<String>()
14
14
  hbc?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() }
15
15
  ?.let { q += "hbc=" + it.joinToString(",") { v -> enc(v) } }
16
+ hbe?.trim()?.takeIf { it.isNotEmpty() }?.let { q += "hbe=" + enc(it) }
16
17
  nm?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() }
17
18
  ?.let { q += "nm=" + it.joinToString(",") { v -> enc(v) } }
18
19
  val path = "$base/p/$app/$shellMajor/$band.json"
@@ -0,0 +1,134 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import android.util.Base64
4
+ import org.json.JSONArray
5
+ import org.json.JSONObject
6
+ import java.text.SimpleDateFormat
7
+ import java.util.Locale
8
+ import java.util.TimeZone
9
+
10
+ /** What Rotation needs from storage. [commit] writes all three values in one atomic step. */
11
+ internal interface RotationState {
12
+ val storedReleaseKey: String?
13
+ val storedSequence: Long
14
+ val storedPinnedKey: String?
15
+ val storedPending: String?
16
+ fun commit(releaseKey: String?, sequence: Long, pinnedKey: String?, pending: String?)
17
+ }
18
+
19
+ /**
20
+ * Root-signed release key rotation (docs/design/key-rotation.md, sections 3 to 9).
21
+ * A statement is an envelope {body, signature}; the signature is by the pinned root key over the exact body string.
22
+ */
23
+ internal class Rotation(
24
+ private val state: RotationState,
25
+ private val appId: String,
26
+ private val pinnedReleaseKey: String,
27
+ private val rootKey: String,
28
+ private val minSequence: Long,
29
+ private val clock: Clock = Clock.Wall,
30
+ private val verify: (ByteArray, String, String) -> Boolean = Verify::ed25519,
31
+ private val decode: (String) -> ByteArray = { Base64.decode(it, Base64.DEFAULT) },
32
+ ) {
33
+ class Result(val ok: Boolean, val reason: String? = null)
34
+
35
+ private class Stmt(val body: String, val signature: String, val sequence: Long, val newKey: String, val validFrom: Long)
36
+
37
+ /** Signature checks made so far (tests count them). */
38
+ var signatureChecks = 0
39
+ private set
40
+
41
+ /** The release key pointers are verified against: the stored one, or the pinned one when nothing usable is stored. */
42
+ fun currentReleaseKey(): String {
43
+ val k = state.storedReleaseKey
44
+ return if (k != null && state.storedPinnedKey == pinnedReleaseKey && keyOk(k)) k else pinnedReleaseKey
45
+ }
46
+
47
+ fun effectiveSequence(): Long = maxOf(state.storedSequence, minSequence)
48
+
49
+ /** Applies due pending statements. Run on launch and before every pointer check. */
50
+ fun applyDue() { handle(null) }
51
+
52
+ /**
53
+ * Handles a delivered `rotations` array (null for none). Returns one result per envelope; empty when the array
54
+ * is refused whole. Verdicts are against the state before this delivery.
55
+ */
56
+ fun handle(rotations: JSONArray?): List<Result> {
57
+ if (rotations != null && rotations.length() > MAX) return emptyList()
58
+ val effective = effectiveSequence()
59
+ val known = HashMap<Long, Stmt>()
60
+ for (s in loadPending()) if (s.sequence > effective) known.putIfAbsent(s.sequence, s)
61
+ val results = ArrayList<Result>()
62
+ if (rotations != null) for (i in 0 until rotations.length()) {
63
+ val env = rotations.optJSONObject(i)
64
+ val body = env?.opt("body") as? String
65
+ val sig = env?.opt("signature") as? String
66
+ if (body == null || sig == null || body.toByteArray(Charsets.UTF_8).size > MAX_BODY) { results.add(Result(false, "malformed")); continue }
67
+ val r = check(body, sig, effective)
68
+ if (r is Stmt) {
69
+ // Same sequence twice: the first accepted stays.
70
+ if (known.containsKey(r.sequence)) results.add(Result(false, "replay")) else { known[r.sequence] = r; results.add(Result(true)) }
71
+ } else results.add(r as Result)
72
+ }
73
+ commitState(known.values.sortedBy { it.sequence }, effective)
74
+ return results
75
+ }
76
+
77
+ /** Section 3 rules 1 to 5 in order. Returns a [Stmt] when valid, else a [Result]. */
78
+ private fun check(body: String, sig: String, effective: Long, reload: Boolean = false): Any {
79
+ signatureChecks++
80
+ if (!verify(body.toByteArray(Charsets.UTF_8), sig, rootKey)) return Result(false, "bad_signature")
81
+ val j = try { JSONObject(body) } catch (_: Exception) { return Result(false, "malformed") }
82
+ if (j.opt("type") != "key-rotation") return Result(false, "bad_type")
83
+ if (j.opt("appId") != appId) return Result(false, "wrong_app")
84
+ val seq = j.opt("sequence").let { if (it is Int) it.toLong() else it as? Long } ?: return Result(false, "malformed")
85
+ if (seq < 1) return Result(false, "malformed")
86
+ if (seq <= effective) return Result(false, "replay")
87
+ val validFrom = (j.opt("validFrom") as? String)?.let { parseTime(it) } ?: return Result(false, "malformed")
88
+ if (!reload && validFrom > clock.now() + NINETY_DAYS_MS) return Result(false, "too_far")
89
+ val newKey = j.opt("newKey") as? String
90
+ if (newKey == null || !keyOk(newKey)) return Result(false, "bad_key")
91
+ return Stmt(body, sig, seq, newKey, validFrom)
92
+ }
93
+
94
+ /** Section 7: apply the due statement with the highest sequence, keep the later ones, at most 8. */
95
+ private fun commitState(all: List<Stmt>, effective: Long) {
96
+ val now = clock.now()
97
+ val due = all.filter { it.validFrom <= now }.maxByOrNull { it.sequence }
98
+ val keep = (if (due == null) all else all.filter { it.sequence > due.sequence }).take(MAX)
99
+ val pending = if (keep.isEmpty()) null else JSONArray(keep.map { JSONObject().put("body", it.body).put("signature", it.signature) }).toString()
100
+ val changed = due != null || pending != state.storedPending || state.storedPinnedKey != pinnedReleaseKey
101
+ if (!changed) return
102
+ // A different pin than the one that wrote the state: drop the stored key, keep the sequence (design section 4).
103
+ val key = due?.newKey ?: if (state.storedPinnedKey == pinnedReleaseKey) state.storedReleaseKey else null
104
+ state.commit(key, maxOf(due?.sequence ?: 0L, state.storedSequence), pinnedReleaseKey, pending)
105
+ }
106
+
107
+ /** A corrupt or unverifiable pending list is cleared by the next commit; key and sequence stay. */
108
+ private fun loadPending(): List<Stmt> {
109
+ val raw = state.storedPending ?: return emptyList()
110
+ return try {
111
+ val arr = JSONArray(raw)
112
+ if (arr.length() > MAX) return emptyList()
113
+ val out = ArrayList<Stmt>()
114
+ for (i in 0 until arr.length()) {
115
+ val o = arr.getJSONObject(i)
116
+ val r = check(o.getString("body"), o.getString("signature"), 0, reload = true)
117
+ if (r is Stmt) out.add(r)
118
+ }
119
+ out
120
+ } catch (_: Exception) { emptyList() }
121
+ }
122
+
123
+ private fun keyOk(k: String): Boolean = try { decode(k).size == 32 } catch (_: Exception) { false }
124
+
125
+ private fun parseTime(s: String): Long? = try {
126
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC"); isLenient = false }.parse(s)?.time
127
+ } catch (_: Exception) { null }
128
+
129
+ companion object {
130
+ const val MAX = 8
131
+ const val MAX_BODY = 1024
132
+ const val NINETY_DAYS_MS = 90L * 24 * 60 * 60 * 1000
133
+ }
134
+ }
@@ -128,8 +128,10 @@ class ShipvoRuntimeModule : Module() {
128
128
  private fun check(): Map<String, Any?> {
129
129
  val s = Store(ctx)
130
130
  require(s.baseUrl.isNotEmpty() && s.publicKey.isNotEmpty() && s.appId.isNotEmpty()) { "runtime not configured" }
131
+ val rotation = s.rotation()
132
+ rotation.applyDue() // due root-signed key rotations apply before any pointer is checked
131
133
  val known = maxOf(s.currentRelease, s.pendingRelease, s.blockedRelease)
132
- val conn = http(PointerQuery.url(s.baseUrl, s.appId, s.shellMajor, s.band, s.hermesBytecodeVersions, s.nativeModules), if (known > 0) "\"$known\"" else null)
134
+ val conn = http(PointerQuery.url(s.baseUrl, s.appId, s.shellMajor, s.band, s.hermesBytecodeVersions, s.nativeModules, s.hermesEngine), if (known > 0) "\"$known\"" else null)
133
135
  val code = conn.responseCode
134
136
  if (code == 404) {
135
137
  // The shell cannot run the newest release. Keep the current one and say why.
@@ -144,9 +146,11 @@ class ShipvoRuntimeModule : Module() {
144
146
  val envelope = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
145
147
  conn.disconnect()
146
148
 
149
+ // Root-signed rotation statements ride along with the pointer; handle them before the pointer is verified.
150
+ envelope.optJSONArray("rotations")?.let { rotation.handle(it) }
147
151
  val t0 = SystemClock.elapsedRealtime()
148
152
  val bodyStr = envelope.getString("body")
149
- require(Verify.ed25519(bodyStr.toByteArray(Charsets.UTF_8), envelope.getString("signature"), s.publicKey)) { "pointer signature invalid" }
153
+ require(Verify.ed25519(bodyStr.toByteArray(Charsets.UTF_8), envelope.getString("signature"), rotation.currentReleaseKey())) { "pointer signature invalid" }
150
154
  val body = JSONObject(bodyStr)
151
155
  require(body.getString("app_id") == s.appId && body.getInt("shell_major") == s.shellMajor) { "pointer for another app/shell" }
152
156
  val release = body.getInt("release")
@@ -8,7 +8,7 @@ import java.io.File
8
8
  import kotlin.random.Random
9
9
 
10
10
  /** Persistent runtime state. All bundle decisions go through here so the host handler and module agree. */
11
- internal class Store(private val ctx: Context) : BootCounts {
11
+ internal class Store(private val ctx: Context) : BootCounts, RotationState {
12
12
  private val p: SharedPreferences = ctx.getSharedPreferences("shipvo.runtime", Context.MODE_PRIVATE)
13
13
  val dir: File get() = File(ctx.filesDir, "shipvo").apply { mkdirs() }
14
14
 
@@ -66,6 +66,22 @@ internal class Store(private val ctx: Context) : BootCounts {
66
66
  override var unhealthyBoots: Int get() = p.getInt("unhealthy_boots", 0); set(v) { p.edit().putInt("unhealthy_boots", v).commit() }
67
67
  override var firstUnhealthyAt: Long get() = p.getLong("first_unhealthy_at", 0); set(v) { p.edit().putLong("first_unhealthy_at", v).commit() }
68
68
 
69
+ override val storedReleaseKey: String? get() = p.getString("rotation.releaseKey", null)
70
+ override val storedSequence: Long get() = try { p.getLong("rotation.sequence", 0) } catch (_: ClassCastException) { 0 }
71
+ override val storedPinnedKey: String? get() = p.getString("rotation.pinnedKey", null)
72
+ override val storedPending: String? get() = p.getString("rotation.pending", null)
73
+ /** One commit() writes key, sequence, pinned key and pending list together. */
74
+ override fun commit(releaseKey: String?, sequence: Long, pinnedKey: String?, pending: String?) {
75
+ p.edit().apply {
76
+ if (releaseKey != null) putString("rotation.releaseKey", releaseKey) else remove("rotation.releaseKey")
77
+ putLong("rotation.sequence", sequence)
78
+ if (pinnedKey != null) putString("rotation.pinnedKey", pinnedKey) else remove("rotation.pinnedKey")
79
+ if (pending != null) putString("rotation.pending", pending) else remove("rotation.pending")
80
+ }.commit()
81
+ }
82
+ /** Key rotation for this shell: root-signed statements can replace the release key (docs/design/key-rotation.md). */
83
+ fun rotation(clock: Clock = Clock.Wall) = Rotation(this, appId, publicKey, rootKey, minSequence, clock)
84
+
69
85
  fun addEvent(e: String) {
70
86
  val list = p.getString("events", "")!!.split('\n').filter { it.isNotBlank() }.takeLast(49) + e
71
87
  p.edit().putString("events", list.joinToString("\n")).commit()
@@ -88,9 +104,15 @@ internal class Store(private val ctx: Context) : BootCounts {
88
104
  } catch (_: Exception) { null }
89
105
  val baseUrl: String get() = (meta("dev.shipvo.BASE_URL") ?: "").trimEnd('/')
90
106
  val publicKey: String get() = meta("dev.shipvo.PUBLIC_KEY") ?: ""
107
+ /** Root public key that signs rotation statements. Empty when the shell does not declare one: rotation is then off. */
108
+ val rootKey: String get() = meta("dev.shipvo.ROOT_KEY") ?: ""
109
+ /** Highest rotation sequence that existed when this shell was built. */
110
+ val minSequence: Long get() = metaAny("dev.shipvo.MIN_SEQUENCE")?.toLongOrNull() ?: 0
91
111
  val appId: String get() = meta("dev.shipvo.APP_ID") ?: ""
92
112
  /** Hermes bytecode versions this shell runs, e.g. "96,98". Null when the shell does not declare them. */
93
113
  val hermesBytecodeVersions: String? get() = metaAny("dev.shipvo.HERMES_BYTECODE_VERSIONS")?.takeIf { it.isNotBlank() }
114
+ /** Hermes engine build id this shell runs, e.g. "250829098.0.10". Null when the shell does not declare it. */
115
+ val hermesEngine: String? get() = metaAny("dev.shipvo.HERMES_ENGINE")?.takeIf { it.isNotBlank() }
94
116
  /** Native modules in this shell, e.g. "react-native-mmkv@3.1.0,@scope/pkg@1.0.0". Null when not declared. */
95
117
  val nativeModules: String? get() = metaAny("dev.shipvo.NATIVE_MODULES")?.takeIf { it.isNotBlank() }
96
118
  val shellMajor: Int get() = (meta("dev.shipvo.SHELL_MAJOR") ?: "1").toIntOrNull() ?: 1
@@ -15,6 +15,9 @@ class PointerQueryTest {
15
15
  @Test fun queryWhenDeclared() {
16
16
  assertEquals("https://x.dev/p/app/1/b2.json?hbc=96,98", url("96,98", null))
17
17
  assertEquals("https://x.dev/p/app/1/b2.json?hbc=96&nm=a%401.0.0", url("96", "a@1.0.0"))
18
+ assertEquals("https://x.dev/p/app/1/b2.json?hbc=96&hbe=250829098.0.10&nm=a%401.0.0", PointerQuery.url("https://x.dev", "app", 1, "b2", "96", "a@1.0.0", "250829098.0.10"))
19
+ assertEquals("https://x.dev/p/app/1/b2.json?hbe=1.2.3", PointerQuery.url("https://x.dev", "app", 1, "b2", null, null, " 1.2.3 "))
20
+ assertEquals("https://x.dev/p/app/1/b2.json", PointerQuery.url("https://x.dev", "app", 1, "b2", null, null, " "))
18
21
  }
19
22
 
20
23
  @Test fun scopedNamesAreEncoded() {
@@ -0,0 +1,141 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import net.i2p.crypto.eddsa.EdDSAEngine
4
+ import net.i2p.crypto.eddsa.EdDSAPrivateKey
5
+ import net.i2p.crypto.eddsa.EdDSAPublicKey
6
+ import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
7
+ import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
8
+ import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
9
+ import org.json.JSONArray
10
+ import org.json.JSONObject
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Test
15
+ import java.security.MessageDigest
16
+ import java.text.SimpleDateFormat
17
+ import java.util.Base64
18
+ import java.util.Locale
19
+ import java.util.TimeZone
20
+
21
+ /** Runs the shared vectors (packages/runtime/test-vectors/rotation.json) against Rotation with an in-memory store. */
22
+ class RotationTest {
23
+ private class Mem : RotationState {
24
+ override var storedReleaseKey: String? = null
25
+ override var storedSequence = 0L
26
+ override var storedPinnedKey: String? = null
27
+ override var storedPending: String? = null
28
+ var commits = 0
29
+ override fun commit(releaseKey: String?, sequence: Long, pinnedKey: String?, pending: String?) {
30
+ storedReleaseKey = releaseKey; storedSequence = sequence; storedPinnedKey = pinnedKey; storedPending = pending; commits++
31
+ }
32
+ }
33
+
34
+ private val spec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519)
35
+ private fun dec(s: String) = Base64.getDecoder().decode(s)
36
+ private fun verify(m: ByteArray, sig: String, key: String): Boolean = try {
37
+ val e = EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm))
38
+ e.initVerify(EdDSAPublicKey(EdDSAPublicKeySpec(dec(key), spec)))
39
+ e.update(m); e.verify(dec(sig))
40
+ } catch (_: Exception) { false }
41
+ private fun sign(m: ByteArray, seed: String): String {
42
+ val e = EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm))
43
+ e.initSign(EdDSAPrivateKey(EdDSAPrivateKeySpec(dec(seed), spec)))
44
+ e.update(m); return Base64.getEncoder().encodeToString(e.sign())
45
+ }
46
+ private fun time(s: String) = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.parse(s)!!.time
47
+
48
+ private val vectors = JSONObject(javaClass.classLoader!!.getResourceAsStream("rotation.json")!!.bufferedReader().readText())
49
+ private val keys = vectors.getJSONObject("keys")
50
+ private fun pub(name: String) = keys.getJSONObject(name).getString("publicKey")
51
+
52
+
53
+ private fun make(state: Mem, clock: Array<Long>, count: IntArray) = Rotation(
54
+ state, vectors.getString("appId"), pub("K1"), vectors.getJSONObject("root").getString("publicKey"),
55
+ vectors.getLong("minSequence"), { clock[0] }, { m, s, k -> count[0]++; verify(m, s, k) }, { dec(it) })
56
+
57
+ @Test fun sharedVectors() {
58
+ val cases = vectors.getJSONArray("cases")
59
+ assertEquals(14, cases.length())
60
+ for (c in 0 until cases.length()) {
61
+ val case = cases.getJSONObject(c)
62
+ val name = "case ${case.getInt("id")}: ${case.getString("name")}"
63
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
64
+ var rot = make(state, clock, count)
65
+ val steps = case.getJSONArray("steps")
66
+ for (i in 0 until steps.length()) {
67
+ val step = steps.getJSONObject(i)
68
+ clock[0] = time(step.getString("now"))
69
+ if (step.optBoolean("relaunch")) rot = make(state, clock, count) // new process, same stored state
70
+ count[0] = 0
71
+ val results = rot.handle(step.getJSONArray("deliver"))
72
+ if (step.optBoolean("rejectedWhole")) {
73
+ assertTrue("$name step $i whole", results.isEmpty())
74
+ assertEquals("$name step $i signature checks", step.getInt("signatureChecks"), count[0])
75
+ } else {
76
+ val want = step.getJSONArray("verify")
77
+ assertEquals("$name step $i result count", want.length(), results.size)
78
+ for (k in 0 until want.length()) {
79
+ val w = want.getJSONObject(k)
80
+ assertEquals("$name step $i env $k", w.getBoolean("ok"), results[k].ok)
81
+ if (!w.getBoolean("ok")) assertEquals("$name step $i env $k reason", w.getString("reason"), results[k].reason)
82
+ }
83
+ }
84
+ val expect = step.getJSONObject("expect")
85
+ assertEquals("$name step $i key", pub(expect.getString("currentKey")), rot.currentReleaseKey())
86
+ assertEquals("$name step $i sequence", expect.getLong("sequence"), rot.effectiveSequence())
87
+ val pending = state.storedPending?.let { JSONArray(it) } ?: JSONArray()
88
+ val seqs = (0 until pending.length()).map { JSONObject(pending.getJSONObject(it).getString("body")).getLong("sequence") }
89
+ val wantSeqs = expect.getJSONArray("pending").let { a -> (0 until a.length()).map { a.getLong(it) } }
90
+ assertEquals("$name step $i pending", wantSeqs, seqs)
91
+ }
92
+ }
93
+ }
94
+
95
+ private fun firstDelivery(caseId: Int): JSONArray {
96
+ val cases = vectors.getJSONArray("cases")
97
+ for (i in 0 until cases.length()) if (cases.getJSONObject(i).getInt("id") == caseId)
98
+ return cases.getJSONObject(i).getJSONArray("steps").getJSONObject(0).getJSONArray("deliver")
99
+ error("no case $caseId")
100
+ }
101
+
102
+ @Test fun pointersFollowTheRotatedKey() {
103
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
104
+ val rot = make(state, clock, count)
105
+ val pointer = "{\"release\":2}".toByteArray()
106
+ val bySeed = { n: String -> sign(pointer, keys.getJSONObject(n).getString("seed")) }
107
+ assertTrue(verify(pointer, bySeed("K1"), rot.currentReleaseKey()))
108
+ assertTrue(rot.handle(firstDelivery(1))[0].ok)
109
+ assertTrue(verify(pointer, bySeed("K2"), rot.currentReleaseKey()))
110
+ assertFalse(verify(pointer, bySeed("K1"), rot.currentReleaseKey()))
111
+ }
112
+
113
+ @Test fun corruptPendingIsClearedAndKeyAndSequenceKept() {
114
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
115
+ val rot = make(state, clock, count)
116
+ assertTrue(rot.handle(firstDelivery(1))[0].ok)
117
+ state.storedPending = "not json {"
118
+ rot.applyDue()
119
+ assertEquals(null, state.storedPending)
120
+ assertEquals(1L, state.storedSequence)
121
+ assertEquals(pub("K2"), rot.currentReleaseKey())
122
+ }
123
+
124
+ @Test fun newPinDiscardsStoredKeyButKeepsSequence() {
125
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime"))); val count = intArrayOf(0)
126
+ assertTrue(make(state, clock, count).handle(firstDelivery(1))[0].ok)
127
+ // A new shell build pins K3 and MIN_SEQUENCE 1: the stored K2 is dropped, and statement 1 cannot come back.
128
+ val rot = Rotation(state, "tide", pub("K3"), vectors.getJSONObject("root").getString("publicKey"), 1, { clock[0] }, ::verify, { dec(it) })
129
+ assertEquals(pub("K3"), rot.currentReleaseKey())
130
+ assertFalse(rot.handle(firstDelivery(1))[0].ok)
131
+ assertEquals(pub("K3"), rot.currentReleaseKey())
132
+ assertEquals(1L, state.storedSequence)
133
+ }
134
+
135
+ @Test fun noRootKeyMeansNothingApplies() {
136
+ val state = Mem(); val clock = arrayOf(time(vectors.getString("startTime")))
137
+ val rot = Rotation(state, "tide", pub("K1"), "", 0, { clock[0] }, ::verify, { dec(it) })
138
+ assertFalse(rot.handle(firstDelivery(1))[0].ok)
139
+ assertEquals(pub("K1"), rot.currentReleaseKey())
140
+ }
141
+ }
package/app.plugin.js CHANGED
@@ -15,6 +15,15 @@ function formatBytecodeVersions(value) {
15
15
  return value.join(",");
16
16
  }
17
17
 
18
+ // Returns the engine build id (like "250829098.0.10"), or undefined when the prop is omitted.
19
+ function formatHermesEngine(value) {
20
+ if (value === undefined) return undefined;
21
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+$/.test(value.trim())) {
22
+ throw new Error(`@shipvo/runtime: hermesEngine must look like 250829098.0.10, got ${JSON.stringify(value)}`);
23
+ }
24
+ return value.trim();
25
+ }
26
+
18
27
  // Returns "name@version,..." for the native modules, or undefined when the prop is omitted.
19
28
  function formatNativeModules(value) {
20
29
  if (value === undefined) return undefined;
@@ -35,18 +44,41 @@ function formatNativeModules(value) {
35
44
  .join(",");
36
45
  }
37
46
 
47
+ // Returns the root key, or undefined when the prop is omitted. Must be base64 of 32 bytes (an Ed25519 public key).
48
+ function checkRootKey(value) {
49
+ if (value === undefined) return undefined;
50
+ const ok = typeof value === "string" && /^[A-Za-z0-9+/]+={0,2}$/.test(value) && value.length % 4 === 0 && Buffer.from(value, "base64").length === 32;
51
+ if (!ok) throw new Error("@shipvo/runtime: rootKey must be a base64 string that decodes to 32 bytes");
52
+ return value;
53
+ }
54
+
55
+ // Returns the minimum rotation sequence, or undefined when the prop is omitted.
56
+ function checkMinSequence(value) {
57
+ if (value === undefined) return undefined;
58
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
59
+ throw new Error(`@shipvo/runtime: minSequence must be a non-negative integer, got ${JSON.stringify(value)}`);
60
+ }
61
+ return value;
62
+ }
63
+
38
64
  module.exports = function withShipvo(config, props) {
39
- const { baseUrl, publicKey, appId, shellMajor = 1 } = props || {};
65
+ const { baseUrl, publicKey, appId, shellMajor = 1, rootKey, minSequence } = props || {};
40
66
  if (!baseUrl || !publicKey || !appId) throw new Error("@shipvo/runtime: baseUrl, publicKey and appId are required");
67
+ checkRootKey(rootKey);
68
+ checkMinSequence(minSequence);
69
+ // minSequence is the replay floor for rotation statements; without a root key no statement is accepted, so it would do nothing.
70
+ if (minSequence !== undefined && rootKey === undefined) throw new Error("@shipvo/runtime: minSequence needs rootKey");
41
71
  const hbc = formatBytecodeVersions(props.hermesBytecodeVersions);
72
+ const hbe = formatHermesEngine(props.hermesEngine);
42
73
  const nm = formatNativeModules(props.nativeModules);
43
- config.extra = { ...(config.extra ?? {}), shipvo: { baseUrl, appId, shellMajor } };
74
+ config.extra = { ...(config.extra ?? {}), shipvo: { baseUrl, appId, shellMajor, ...(props.reportEvents === false ? { reportEvents: false } : {}) } };
44
75
  config = withInfoPlist(config, (cfg) => {
45
76
  cfg.modResults.ShipvoBaseURL = baseUrl;
46
77
  cfg.modResults.ShipvoPublicKey = publicKey;
47
78
  cfg.modResults.ShipvoAppId = appId;
48
79
  cfg.modResults.ShipvoShellMajor = String(shellMajor);
49
80
  if (hbc !== undefined) cfg.modResults.ShipvoHermesBytecodeVersions = hbc;
81
+ if (hbe !== undefined) cfg.modResults.ShipvoHermesEngine = hbe;
50
82
  if (nm !== undefined) cfg.modResults.ShipvoNativeModules = nm;
51
83
  return cfg;
52
84
  });
@@ -57,7 +89,10 @@ module.exports = function withShipvo(config, props) {
57
89
  set("dev.shipvo.PUBLIC_KEY", publicKey);
58
90
  set("dev.shipvo.APP_ID", appId);
59
91
  set("dev.shipvo.SHELL_MAJOR", shellMajor);
92
+ if (rootKey !== undefined) set("dev.shipvo.ROOT_KEY", rootKey);
93
+ if (minSequence !== undefined) set("dev.shipvo.MIN_SEQUENCE", minSequence);
60
94
  if (hbc !== undefined) set("dev.shipvo.HERMES_BYTECODE_VERSIONS", hbc);
95
+ if (hbe !== undefined) set("dev.shipvo.HERMES_ENGINE", hbe);
61
96
  if (nm !== undefined) set("dev.shipvo.NATIVE_MODULES", nm);
62
97
  return cfg;
63
98
  });
@@ -0,0 +1,147 @@
1
+ import Foundation
2
+
3
+ /// Time source for rotation. No rotation code reads the system time directly (design section 9).
4
+ protocol Clock { func now() -> Date }
5
+ struct SystemClock: Clock { func now() -> Date { Date() } }
6
+
7
+ /// Signing key rotation (docs/design/key-rotation.md, sections 3 to 9).
8
+ /// A statement is an envelope `{body, signature}` signed by the pinned root key. It replaces the current release key.
9
+ /// The pointer check in `Updater` uses `Rotation.currentKey`; nothing else about that check changes.
10
+ enum Rotation {
11
+ static let maxEntries = 8
12
+ static let maxBodyBytes = 1024
13
+ static let maxAheadDays = 90.0
14
+
15
+ struct Statement { let body: String, signature: String, sequence: Int, newKey: String, validFrom: Date }
16
+ struct Result: Equatable { let ok: Bool; let reason: String? }
17
+ struct Outcome { var rejectedWhole = false; var results: [Result] = [] }
18
+ struct State { var releaseKey: String, sequence: Int, pinnedKey: String, pending: [Statement] }
19
+
20
+ /// Test hook: how many root signature checks ran (design test 7).
21
+ static var signatureChecks = 0
22
+
23
+ // MARK: reading
24
+
25
+ /// The key that pointer signatures are checked against now: the pinned release key, or the one a root statement named.
26
+ /// Statements that came due since the last call are applied first.
27
+ static func currentKey(_ s: Store) -> String {
28
+ guard enabled(s) else { return s.publicKey }
29
+ return sync(s).releaseKey
30
+ }
31
+
32
+ static func enabled(_ s: Store) -> Bool { !s.rootKey.isEmpty }
33
+
34
+ /// Load the stored state and apply every pending statement that is due. Run on every launch and every fetch.
35
+ @discardableResult
36
+ static func sync(_ s: Store) -> State {
37
+ let before = load(s)
38
+ var st = before
39
+ settle(&st, incoming: [], now: s.clock.now())
40
+ if !same(st, before) { save(s, st) }
41
+ return st
42
+ }
43
+
44
+ // MARK: delivery
45
+
46
+ /// Handle the `rotations` array of a pointer response, before the pointer is looked at.
47
+ /// More than 8 entries are refused whole before any signature check.
48
+ @discardableResult
49
+ static func process(_ s: Store, rotations: Any?) -> Outcome {
50
+ var out = Outcome()
51
+ guard enabled(s), let list = rotations as? [Any] else { return out }
52
+ if list.count > maxEntries { out.rejectedWhole = true; return out }
53
+ let now = s.clock.now()
54
+ var st = load(s)
55
+ let effective = max(st.sequence, s.minSequence)
56
+ var incoming: [Statement] = []
57
+ for item in list {
58
+ let r = check(item, root: s.rootKey, appId: s.appId, ctx: (effective, now))
59
+ switch r {
60
+ case .failure(let f): out.results.append(Result(ok: false, reason: f.reason))
61
+ case .success(let stmt):
62
+ // Rule 4: the same sequence with other bytes does not replace the first one accepted.
63
+ if st.pending.contains(where: { $0.sequence == stmt.sequence }) || incoming.contains(where: { $0.sequence == stmt.sequence }) {
64
+ out.results.append(Result(ok: false, reason: "replay"))
65
+ } else { incoming.append(stmt); out.results.append(Result(ok: true, reason: nil)) }
66
+ }
67
+ }
68
+ let before = st
69
+ settle(&st, incoming: incoming, now: now)
70
+ if !same(st, before) { save(s, st) }
71
+ return out
72
+ }
73
+
74
+ // MARK: checks
75
+
76
+ struct Failure: Error { let reason: String }
77
+
78
+ /// Section 8 order: envelope shape, root signature, then the field rules of section 3.
79
+ /// `ctx` nil skips the replay and 90-day rules (used for statements already accepted and stored).
80
+ static func check(_ item: Any, root: String, appId: String, ctx: (effective: Int, now: Date)?) -> Swift.Result<Statement, Failure> {
81
+ guard let e = item as? [String: Any], let body = e["body"] as? String, let sig = e["signature"] as? String,
82
+ body.utf8.count <= maxBodyBytes else { return .failure(Failure(reason: "bad_envelope")) }
83
+ signatureChecks += 1
84
+ guard Verify.ed25519(Data(body.utf8), signatureB64: sig, publicKeyB64: root) else { return .failure(Failure(reason: "bad_signature")) }
85
+ guard let b = (try? JSONSerialization.jsonObject(with: Data(body.utf8))) as? [String: Any] else { return .failure(Failure(reason: "bad_body")) }
86
+ guard b["type"] as? String == "key-rotation" else { return .failure(Failure(reason: "wrong_type")) }
87
+ guard b["appId"] as? String == appId else { return .failure(Failure(reason: "wrong_app")) }
88
+ guard let seq = b["sequence"] as? Int, seq >= 1, seq <= 9_007_199_254_740_991 else { return .failure(Failure(reason: "bad_sequence")) }
89
+ if let ctx, seq <= ctx.effective { return .failure(Failure(reason: "replay")) }
90
+ guard let vf = (b["validFrom"] as? String).flatMap(parseDate) else { return .failure(Failure(reason: "bad_body")) }
91
+ if let ctx, vf.timeIntervalSince(ctx.now) > maxAheadDays * 86_400 { return .failure(Failure(reason: "too_far")) }
92
+ guard let key = b["newKey"] as? String, Data(base64Encoded: key)?.count == 32 else { return .failure(Failure(reason: "bad_key")) }
93
+ return .success(Statement(body: body, signature: sig, sequence: seq, newKey: key, validFrom: vf))
94
+ }
95
+
96
+ static func parseDate(_ s: String) -> Date? {
97
+ let f = ISO8601DateFormatter()
98
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
99
+ if let d = f.date(from: s) { return d }
100
+ f.formatOptions = [.withInternetDateTime]
101
+ return f.date(from: s)
102
+ }
103
+
104
+ // MARK: section 7
105
+
106
+ /// Apply the due statement with the highest sequence, then keep the later ones (at most 8, lowest sequences first).
107
+ private static func settle(_ st: inout State, incoming: [Statement], now: Date) {
108
+ let known = (st.pending + incoming).sorted { $0.sequence < $1.sequence }
109
+ if let top = known.last(where: { $0.validFrom <= now }), top.sequence > st.sequence {
110
+ st.releaseKey = top.newKey; st.sequence = top.sequence
111
+ }
112
+ st.pending = Array(known.filter { $0.sequence > st.sequence }.prefix(maxEntries))
113
+ }
114
+
115
+ // MARK: storage (one atomic UserDefaults write of a single dictionary)
116
+
117
+ private static func same(_ a: State, _ b: State) -> Bool {
118
+ a.releaseKey == b.releaseKey && a.sequence == b.sequence && a.pinnedKey == b.pinnedKey && a.pending.map { $0.body + $0.signature } == b.pending.map { $0.body + $0.signature }
119
+ }
120
+
121
+ private static func load(_ s: Store) -> State {
122
+ let pin = s.publicKey
123
+ let fresh = State(releaseKey: pin, sequence: 0, pinnedKey: pin, pending: [])
124
+ guard let d = s.rotationRecord else { return fresh }
125
+ // Missing or unreadable values fall back to the pinned key, never to "no check".
126
+ guard let seq = d["rotation.sequence"] as? Int, seq >= 0 else { return fresh }
127
+ var st = fresh; st.sequence = seq
128
+ // A new store build pinning another release key drops the stored key and keeps the sequence.
129
+ if d["rotation.pinnedKey"] as? String == pin, let k = d["rotation.releaseKey"] as? String, Data(base64Encoded: k)?.count == 32 { st.releaseKey = k }
130
+ if let raw = d["rotation.pending"] {
131
+ var list: [Statement] = []
132
+ var good = (raw as? [Any]) != nil
133
+ for item in (raw as? [Any]) ?? [] {
134
+ if case .success(let stmt) = check(item, root: s.rootKey, appId: s.appId, ctx: nil), stmt.sequence > seq { list.append(stmt) } else { good = false; break }
135
+ }
136
+ st.pending = good && list.count <= maxEntries ? list : [] // a corrupt list is cleared, key and sequence are kept
137
+ }
138
+ return st
139
+ }
140
+
141
+ private static func save(_ s: Store, _ st: State) {
142
+ s.rotationRecord = [
143
+ "rotation.releaseKey": st.releaseKey, "rotation.sequence": st.sequence, "rotation.pinnedKey": st.pinnedKey,
144
+ "rotation.pending": st.pending.map { ["body": $0.body, "signature": $0.signature] },
145
+ ]
146
+ }
147
+ }
package/ios/Store.swift CHANGED
@@ -6,7 +6,8 @@ final class Store {
6
6
  /// Replaces what the app's Info.plist and file system would say. Tests use it; the app never does.
7
7
  struct Overrides {
8
8
  var suite: String, dir: URL, baseUrl: String, publicKey: String, appId: String, shellMajor: Int, shellVersion: String, binaryVersion: String
9
- var hermesBytecodeVersions: [Int] = [], nativeModules: [String: String] = [:]
9
+ var hermesBytecodeVersions: [Int] = [], hermesEngine: String? = nil, nativeModules: [String: String] = [:]
10
+ var rootKey: String = "", minSequence: Int = 0, clock: Clock? = nil
10
11
  }
11
12
 
12
13
  private let d: UserDefaults
@@ -98,6 +99,20 @@ final class Store {
98
99
  private func plist(_ key: String) -> String? { bundle.object(forInfoDictionaryKey: key) as? String }
99
100
  var baseUrl: String { var s = o?.baseUrl ?? plist("ShipvoBaseURL") ?? ""; while s.hasSuffix("/") { s.removeLast() }; return s }
100
101
  var publicKey: String { o?.publicKey ?? plist("ShipvoPublicKey") ?? "" }
102
+ /// Pinned root key (Info.plist `ShipvoRootKey`). Empty means key rotation is off for this shell.
103
+ var rootKey: String { o?.rootKey ?? plist("ShipvoRootKey") ?? "" }
104
+ /// Highest rotation statement sequence that existed when this build was made (Info.plist `ShipvoMinSequence`, default 0).
105
+ var minSequence: Int {
106
+ if let o { return o.minSequence }
107
+ let v = bundle.object(forInfoDictionaryKey: "ShipvoMinSequence")
108
+ return max(0, (v as? NSNumber)?.intValue ?? Int((v as? String) ?? "") ?? 0)
109
+ }
110
+ var clock: Clock { o?.clock ?? SystemClock() }
111
+ /// Rotation state: key, sequence, pinned key and pending statements, written as one dictionary so they change together.
112
+ var rotationRecord: [String: Any]? {
113
+ get { d.dictionary(forKey: "rotation") }
114
+ set { if let newValue { d.set(newValue, forKey: "rotation") } else { d.removeObject(forKey: "rotation") } }
115
+ }
101
116
  var appId: String { o?.appId ?? plist("ShipvoAppId") ?? "" }
102
117
  /// Hermes bytecode versions this shell runs (Info.plist `ShipvoHermesBytecodeVersions`: an array of numbers or a "96,98" string).
103
118
  var hermesBytecodeVersions: [Int] {
@@ -106,6 +121,12 @@ final class Store {
106
121
  let list: [Any] = (v as? [Any]) ?? ((v as? String)?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } ?? [])
107
122
  return list.compactMap { ($0 as? NSNumber)?.intValue ?? Int("\($0)") }.filter { $0 > 0 }
108
123
  }
124
+ /// Hermes engine build id this shell runs (Info.plist `ShipvoHermesEngine`), or nil when not declared.
125
+ var hermesEngine: String? {
126
+ let v = o != nil ? o?.hermesEngine : plist("ShipvoHermesEngine")
127
+ let t = v?.trimmingCharacters(in: .whitespaces) ?? ""
128
+ return t.isEmpty ? nil : t
129
+ }
109
130
  /// Native modules in this shell (Info.plist `ShipvoNativeModules`: a name-to-version dictionary).
110
131
  var nativeModules: [String: String] {
111
132
  if let o { return o.nativeModules }
package/ios/Updater.swift CHANGED
@@ -16,13 +16,14 @@ enum Updater {
16
16
 
17
17
  private static func resolve(_ s: Store, _ url: String) -> String { url.hasPrefix("http") ? url : "\(s.baseUrl)/\(url.drop(while: { $0 == "/" }))" }
18
18
 
19
- /// Pointer URL. `?hbc=` and `?nm=` are added only when the shell declares them. Scoped names are percent-encoded.
19
+ /// Pointer URL. `?hbc=`, `?hbe=` and `?nm=` are added only when the shell declares them. Scoped names are percent-encoded.
20
20
  static func pointerURL(_ s: Store) -> String {
21
21
  var url = "\(s.baseUrl)/p/\(s.appId)/\(s.shellMajor)/ios/\(s.band).json"
22
22
  func enc(_ x: String) -> String { x.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")) ?? x }
23
23
  var q: [String] = []
24
24
  let hbc = s.hermesBytecodeVersions
25
25
  if !hbc.isEmpty { q.append("hbc=" + hbc.map(String.init).joined(separator: ",")) }
26
+ if let hbe = s.hermesEngine { q.append("hbe=" + enc(hbe)) }
26
27
  let nm = s.nativeModules
27
28
  if !nm.isEmpty { q.append("nm=" + nm.keys.sorted().map { enc($0) + "%40" + enc(nm[$0]!) }.joined(separator: ",")) }
28
29
  if !q.isEmpty { url += "?" + q.joined(separator: "&") }
@@ -31,6 +32,7 @@ enum Updater {
31
32
 
32
33
  static func check(_ s: Store) async throws -> [String: Any] {
33
34
  try require(!s.baseUrl.isEmpty && !s.publicKey.isEmpty && !s.appId.isEmpty, "runtime not configured")
35
+ Rotation.sync(s) // statements that came due since the last launch apply before any pointer check
34
36
  let known = max(s.currentRelease, s.pendingRelease, s.blockedRelease)
35
37
  let (data, resp) = try await URLSession.shared.data(for: request(pointerURL(s), etag: known > 0 ? "\"\(known)\"" : nil))
36
38
  let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
@@ -46,8 +48,10 @@ enum Updater {
46
48
  guard let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
47
49
  let bodyStr = envelope["body"] as? String, let sig = envelope["signature"] as? String else { throw UpdateError("pointer is not an envelope") }
48
50
 
51
+ // Rotation statements first, so a release signed by a new key can arrive in the same fetch.
52
+ Rotation.process(s, rotations: envelope["rotations"])
49
53
  let t0 = Store.now()
50
- try require(Verify.ed25519(Data(bodyStr.utf8), signatureB64: sig, publicKeyB64: s.publicKey), "pointer signature invalid")
54
+ try require(Verify.ed25519(Data(bodyStr.utf8), signatureB64: sig, publicKeyB64: Rotation.currentKey(s)), "pointer signature invalid")
51
55
  guard let body = try JSONSerialization.jsonObject(with: Data(bodyStr.utf8)) as? [String: Any] else { throw UpdateError("pointer body unreadable") }
52
56
  try require(body["platform"] as? String == "ios", "pointer is not for iOS")
53
57
  try require(body["app_id"] as? String == s.appId && body["shell_major"] as? Int == s.shellMajor, "pointer for another app/shell")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/runtime",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Shipvo native runtime: a tiny Expo module that verifies (Ed25519 + SHA-256), downloads (bsdiff patches), stages and applies signed over-the-air releases, with crash-loop rollback. Plus the config plugin that pins the trust root in the native shell.",
6
6
  "main": "src/index.ts",
@@ -16,6 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "files": [
19
+ "skills",
19
20
  "src",
20
21
  "android",
21
22
  "ios",
@@ -58,6 +59,7 @@
58
59
  "provenance": true
59
60
  },
60
61
  "devDependencies": {
62
+ "typescript": "^5.6.3",
61
63
  "vitest": "^2.1.4"
62
64
  },
63
65
  "scripts": {
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: shipvo-runtime
3
+ description: Use the Shipvo over-the-air update runtime in an Expo or React Native app. Use when the project depends on @shipvo/runtime and you need to check for an update, mark a release healthy or forward update events.
4
+ ---
5
+
6
+ # @shipvo/runtime
7
+
8
+ The native half of Shipvo updates. It verifies every release (Ed25519 and SHA-256), applies patches, and goes back to the previous release after 3 unhealthy boots.
9
+
10
+ `shipvo init --dry-run` then `shipvo init --yes` wires it for you. Never print or commit `.shipvo/seed.txt`.
11
+
12
+ ## In the app
13
+
14
+ ```ts
15
+ import { Runtime } from "@shipvo/runtime";
16
+
17
+ const state = Runtime.getState(); // running, pending and previous release, baseUrl, appId
18
+ await Runtime.checkForUpdate(true); // verify, download, stage, apply
19
+ Runtime.markHealthy(); // once the first screen rendered
20
+ ```
21
+
22
+ The runtime reports its own events (`crash_loop_revert`, `rolled_back`, `verify_failed`) to telemetry. Use `onRuntimeEvents` to log them. `reportEvents: false` turns reporting off. Do not call `Runtime.consumeEvents()` unless you forward events yourself: it drains the queue before the runtime reports it.
23
+
24
+ ## Config plugin
25
+
26
+ ```json
27
+ { "expo": { "plugins": [["@shipvo/runtime", { "baseUrl": "https://<control plane>", "publicKey": "<base64 public key>", "appId": "myshop" }]] } }
28
+ ```
29
+
30
+ Rebuild the native app after you change plugin props.
31
+
32
+ ## Check and release
33
+
34
+ ```bash
35
+ shipvo doctor
36
+ shipvo status --json
37
+ shipvo publish --dry-run
38
+ shipvo rollback --to <release>
39
+ ```
package/src/index.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { requireOptionalNativeModule } from "expo-modules-core";
2
+ import { forwardRuntimeEvents, FORWARDED_EVENTS, type ForwardedEvent } from "./runtimeEvents";
3
+
4
+ import { ensureInstallIdentity, signReport, webCrypto, type InstallIdentity, type TelemetryCrypto, type TelemetryStorage } from "./install";
5
+
6
+ export { forwardRuntimeEvents, FORWARDED_EVENTS, ensureInstallIdentity, signReport, webCrypto };
7
+ export type { InstallIdentity, TelemetryCrypto, TelemetryStorage };
8
+ export type { ForwardedEvent };
2
9
 
3
10
  export type RuntimeState = {
4
11
  cohort: number; band: "b0" | "b1" | "b2";
@@ -58,12 +65,77 @@ function installCrashHook() {
58
65
  });
59
66
  }
60
67
 
68
+ let identity: InstallIdentity | null = null;
69
+ let identityCrypto: TelemetryCrypto = webCrypto;
70
+ /**
71
+ * Turn on signed telemetry. Registers this install once (see `ensureInstallIdentity`); until that finishes, and if it
72
+ * fails, reports are sent unsigned. Pass your own `crypto` on React Native. Never throws. The identity is module-global:
73
+ * calling this again replaces the previous identity and crypto.
74
+ */
75
+ export async function enableSignedTelemetry(opts: { storage: TelemetryStorage; registerUrl: string; crypto?: TelemetryCrypto }): Promise<InstallIdentity | null> {
76
+ identityCrypto = opts.crypto ?? webCrypto;
77
+ identity = await ensureInstallIdentity(opts.storage, identityCrypto, opts.registerUrl);
78
+ return identity;
79
+ }
80
+
81
+ const listeners = new Set<(events: string[]) => void>();
82
+ /** Called with every batch of queued events the runtime drains, in `reportRuntimeEvents`. Returns an unsubscribe function. */
83
+ export function onRuntimeEvents(fn: (events: string[]) => void): () => void {
84
+ listeners.add(fn);
85
+ return () => { listeners.delete(fn); };
86
+ }
87
+
88
+ /**
89
+ * Drain the device's event queue and send each forwarded event to the control plane (`POST {baseUrl}/t`), using
90
+ * `baseUrl` and `appId` from the native state or `expo.extra.shipvo`. Best effort: it never throws, a failed send
91
+ * never blocks the others, and nothing is sent without a `baseUrl`. Returns the drained event strings.
92
+ * `Runtime.markHealthy` and `Runtime.checkForUpdate` call this for you. Set `extra.shipvo.reportEvents` to `false`
93
+ * to turn that off.
94
+ */
95
+ export function reportRuntimeEvents(): string[] {
96
+ let events: string[] = [];
97
+ try { events = native ? native.consumeEvents() : []; } catch { return []; }
98
+ if (!Array.isArray(events) || events.length === 0) return [];
99
+ for (const l of listeners) { try { l(events); } catch {} }
100
+ let state: Partial<RuntimeState> = {};
101
+ try { state = (native ?? fallback).getState() ?? {}; } catch {}
102
+ const baseUrl = state.baseUrl || extra.baseUrl;
103
+ const appId = state.appId || extra.appId;
104
+ if (!baseUrl || !appId) return events;
105
+ let androidApi: number | undefined;
106
+ try {
107
+ const rn = require("react-native");
108
+ if (rn.Platform?.OS === "android" && typeof rn.Platform.Version === "number") androidApi = rn.Platform.Version;
109
+ } catch {}
110
+ forwardRuntimeEvents(events, (event, release) => {
111
+ try {
112
+ // Same payload the sample app sent before this moved here: device_class is fixed to "low", android_api is set on Android only.
113
+ const body = { schema_version: "1.0", app_id: appId, release: release ?? state.runningRelease ?? 0, event,
114
+ device_class: "low", android_api: androidApi };
115
+ const post = (b: string) => Promise.resolve(fetch(`${baseUrl}/t`, { method: "POST", headers: { "content-type": "application/json" },
116
+ body: b })).catch(() => {});
117
+ const id = identity;
118
+ if (!id) { post(JSON.stringify(body)); return; }
119
+ const plain = JSON.stringify(body);
120
+ // `sig` is the HMAC of `signed`, closing `}` included. We send `signed` with that `}` replaced by
121
+ // `,"sig":"<hex>"}`. The server cuts that suffix, appends `}` and checks the HMAC. If signing fails, send unsigned.
122
+ const signed = JSON.stringify({ ...body, install_id: id.install_id });
123
+ signReport(id, signed, identityCrypto).then(
124
+ (r) => post(`${signed.slice(0, -1)},"sig":"${r.sig}"}`),
125
+ () => post(plain));
126
+ } catch {}
127
+ });
128
+ return events;
129
+ }
130
+
131
+ const autoReport = () => { if (extra.reportEvents !== false) reportRuntimeEvents(); };
132
+
61
133
  // Every `Native` method must be listed here, or it is silently dropped from `Runtime`.
62
134
  export const Runtime: Native = native
63
135
  ? {
64
136
  getState: () => native.getState(),
65
- checkForUpdate: (apply) => native.checkForUpdate(apply),
66
- markHealthy: () => { const r = native.markHealthy(); installCrashHook(); return r; },
137
+ checkForUpdate: async (apply) => { try { return await native.checkForUpdate(apply); } finally { autoReport(); } },
138
+ markHealthy: () => { const r = native.markHealthy(); installCrashHook(); autoReport(); return r; },
67
139
  consumeEvents: () => native.consumeEvents(),
68
140
  rollback: () => native.rollback(),
69
141
  reload: () => native.reload(),
package/src/index.web.ts CHANGED
@@ -16,3 +16,6 @@ export const Runtime = {
16
16
  rollback(): void {},
17
17
  reload(): void { (globalThis as any).location?.reload(); },
18
18
  };
19
+ export { FORWARDED_EVENTS, forwardRuntimeEvents } from "./runtimeEvents";
20
+ export function reportRuntimeEvents(): string[] { return []; }
21
+ export function onRuntimeEvents(_fn: (events: string[]) => void): () => void { return () => {}; }
package/src/install.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Install identity for signed telemetry. On first launch the app makes a random install id and registers it once
3
+ * (`POST registerUrl` with `{ install_id }`, answer `{ key }`). The key is a secret string. Reports are signed with
4
+ * HMAC-SHA256, using the UTF-8 bytes of the key, and the signature is lower-case hex.
5
+ *
6
+ * Signing rule: the sent body is `<unsigned>` with its last `}` replaced by `,"sig":"<hex>"}`. The HMAC covers the
7
+ * unsigned JSON string, including its closing `}`. To verify, the server cuts the suffix `,"sig":"<hex>"}` from the
8
+ * received string, appends `}`, and checks the HMAC of that string.
9
+ */
10
+ export type InstallIdentity = { install_id: string; key: string };
11
+
12
+ /** Where the identity lives. Pass an AsyncStorage-style adapter on React Native. */
13
+ export interface TelemetryStorage {
14
+ getItem(key: string): Promise<string | null> | string | null;
15
+ setItem(key: string, value: string): Promise<void> | void;
16
+ }
17
+
18
+ /** React Native has no `crypto.subtle`, so apps can pass their own. */
19
+ export interface TelemetryCrypto {
20
+ /** Random bytes, for the install id. */
21
+ randomBytes(n: number): Uint8Array | Promise<Uint8Array>;
22
+ /** HMAC-SHA256 of `data` under `key`. */
23
+ hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array | Promise<Uint8Array>;
24
+ }
25
+
26
+ export const IDENTITY_STORAGE_KEY = "shipvo.install_identity";
27
+
28
+ const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
29
+
30
+ /** Default for web and Node (needs `globalThis.crypto.subtle`). */
31
+ export const webCrypto: TelemetryCrypto = {
32
+ randomBytes: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
33
+ hmacSha256: async (key, data) => {
34
+ const k = await globalThis.crypto.subtle.importKey("raw", key as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
35
+ return new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", k, data as BufferSource));
36
+ },
37
+ };
38
+
39
+ /**
40
+ * Load the stored identity, or create and register one. Returns `null` when registration fails (offline, server
41
+ * error, bad answer); nothing is stored then and the next launch tries again. Never throws.
42
+ */
43
+ export async function ensureInstallIdentity(
44
+ storage: TelemetryStorage, crypto: TelemetryCrypto, registerUrl: string,
45
+ ): Promise<InstallIdentity | null> {
46
+ try {
47
+ const raw = await storage.getItem(IDENTITY_STORAGE_KEY);
48
+ if (raw) {
49
+ let s: any = null;
50
+ try { s = JSON.parse(raw); } catch {} // corrupted: register again
51
+ if (typeof s?.install_id === "string" && typeof s?.key === "string") return { install_id: s.install_id, key: s.key };
52
+ }
53
+ const install_id = hex(await crypto.randomBytes(16));
54
+ const res = await fetch(registerUrl, { method: "POST", headers: { "content-type": "application/json" },
55
+ body: JSON.stringify({ install_id }) });
56
+ if (!res.ok) return null;
57
+ const out: any = await res.json();
58
+ if (typeof out?.key !== "string" || !out.key) return null;
59
+ const identity = { install_id, key: out.key };
60
+ await storage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(identity));
61
+ return identity;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /** Sign `body`, the unsigned JSON string (with its closing `}`). See the signing rule at the top of this file. */
68
+ export async function signReport(
69
+ identity: InstallIdentity, body: string, crypto: TelemetryCrypto = webCrypto,
70
+ ): Promise<{ install_id: string; sig: string }> {
71
+ const enc = new TextEncoder();
72
+ const mac = await crypto.hmacSha256(enc.encode(identity.key), enc.encode(body));
73
+ return { install_id: identity.install_id, sig: hex(mac) };
74
+ }
@@ -0,0 +1,38 @@
1
+ /** Runtime events the app forwards to telemetry. Each one is sent once per event. */
2
+ export const FORWARDED_EVENTS = [
3
+ "crash_loop_revert",
4
+ "rolled_back",
5
+ "verify_failed",
6
+ "download_failed",
7
+ "hermes_bytecode_mismatch",
8
+ "native_module_missing",
9
+ "native_module_version",
10
+ ] as const;
11
+ export type ForwardedEvent = (typeof FORWARDED_EVENTS)[number];
12
+
13
+ const CARRIES_RELEASE: string[] = ["verify_failed", "download_failed", "hermes_bytecode_mismatch"];
14
+ const NATIVE_MODULE_REASONS: string[] = ["native_module_missing", "native_module_version"];
15
+
16
+ /**
17
+ * Turn runtime event strings ("name" or "name:<release>") into telemetry reports.
18
+ * `verify_failed`, `download_failed` and `hermes_bytecode_mismatch:<release>:...`
19
+ * carry a release id: a failed release is reported under its own id. `crash_loop_revert` and `rolled_back` never override the
20
+ * release, so the sender uses the running release as before. A missing or non-numeric
21
+ * id also falls back to the running release.
22
+ */
23
+ export function forwardRuntimeEvents(
24
+ events: string[],
25
+ send: (event: ForwardedEvent, release?: number) => void,
26
+ ): void {
27
+ for (const e of events) {
28
+ const [name, id] = e.split(":");
29
+ if (name === "pointer_rejected") {
30
+ // "pointer_rejected:<reason>": only the two native-module reasons are reported.
31
+ if (id !== undefined && NATIVE_MODULE_REASONS.includes(id)) send(id as ForwardedEvent);
32
+ continue;
33
+ }
34
+ if (!(FORWARDED_EVENTS as readonly string[]).includes(name)) continue;
35
+ const release = CARRIES_RELEASE.includes(name) && id !== undefined && /^\d+$/.test(id) ? Number(id) : undefined;
36
+ send(name as ForwardedEvent, release);
37
+ }
38
+ }