@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 +28 -5
- package/android/build.gradle +6 -0
- package/android/src/main/java/dev/shipvo/runtime/Clock.kt +9 -0
- package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +3 -2
- package/android/src/main/java/dev/shipvo/runtime/Rotation.kt +134 -0
- package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +6 -2
- package/android/src/main/java/dev/shipvo/runtime/Store.kt +23 -1
- package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +3 -0
- package/android/src/test/java/dev/shipvo/runtime/RotationTest.kt +141 -0
- package/app.plugin.js +37 -2
- package/ios/Rotation.swift +147 -0
- package/ios/Store.swift +22 -1
- package/ios/Updater.swift +6 -2
- package/package.json +3 -1
- package/skills/shipvo-runtime/SKILL.md +39 -0
- package/src/index.ts +74 -2
- package/src/index.web.ts +3 -0
- package/src/install.ts +74 -0
- package/src/runtimeEvents.ts +38 -0
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
|
|
31
|
+
## Events
|
|
32
32
|
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/android/build.gradle
CHANGED
|
@@ -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"),
|
|
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
|
|
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.
|
|
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
|
+
}
|