@rovq/react-native 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
  19. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  22. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  23. package/ios/Engine/EngineState.swift +119 -0
  24. package/ios/Engine/ManifestVerifier.swift +279 -0
  25. package/ios/Engine/PackageDownloader.swift +249 -0
  26. package/ios/Engine/PackageRules.swift +38 -0
  27. package/ios/Engine/ProgressThrottle.swift +20 -0
  28. package/ios/Engine/RovqEngine.swift +646 -0
  29. package/ios/Engine/RovqError.swift +35 -0
  30. package/ios/Engine/SafePackageExtractor.swift +107 -0
  31. package/ios/Engine/UpdateInstaller.swift +348 -0
  32. package/ios/Engine/UpdateService.swift +185 -0
  33. package/ios/Engine/ZipReader.swift +378 -0
  34. package/ios/React/RovqHost.swift +255 -0
  35. package/ios/React/RovqModule.mm +91 -0
  36. package/package.json +78 -3
  37. package/react-native.config.js +11 -0
  38. package/src/NativeRovq.ts +72 -0
  39. package/src/api.ts +228 -0
  40. package/src/errors.ts +44 -0
  41. package/src/index.ts +10 -0
  42. package/src/react.tsx +158 -0
  43. package/src/update-flow.ts +91 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 moatazMahdi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,121 @@
1
- # Temporary Holding Version
1
+ # @rovq/react-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Over-the-air updates for React Native apps, served by your own ROVQ server. Each release is signed on your computer and verified by the phone, and a release that crashes before it starts rolls itself back.
4
+
5
+ Requires React Native 0.82 or newer, with the New Architecture.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @rovq/react-native
11
+ cd ios && pod install
12
+ ```
13
+
14
+ Then connect the native side, which decides which bundle runs. The setup page of your app in the ROVQ dashboard shows this code with your app's real values.
15
+
16
+ ### Android, in `MainApplication.kt`
17
+
18
+ ```kotlin
19
+ override val reactHost: ReactHost by lazy {
20
+ RovqReactHost.create(this, PackageList(this).packages)
21
+ }
22
+
23
+ override fun onCreate() {
24
+ super.onCreate()
25
+ Rovq.initialize(
26
+ this,
27
+ RovqConfig(
28
+ apiUrl = "https://your-server/api/v1",
29
+ deploymentKey = "rovq_dep_...",
30
+ runtimeVersion = "1",
31
+ trustedKeys = mapOf("2026-11" to "<public key>"),
32
+ ),
33
+ )
34
+ loadReactNative(this)
35
+ }
36
+ ```
37
+
38
+ ### iOS, in `AppDelegate.swift` and `SceneDelegate.swift`
39
+
40
+ ```swift
41
+ RovqHost.initialize(
42
+ config: RovqConfig(
43
+ apiURL: "https://your-server/api/v1",
44
+ deploymentKey: "rovq_dep_...",
45
+ runtimeVersion: "1",
46
+ trustedKeys: ["2026-11": "<public key>"]
47
+ )
48
+ )
49
+
50
+ override func bundleURL() -> URL? {
51
+ #if DEBUG
52
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
53
+ #else
54
+ RovqHost.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
55
+ #endif
56
+ }
57
+ ```
58
+
59
+ ## Use
60
+
61
+ ```ts
62
+ import { notifyApplicationReady, sync } from '@rovq/react-native';
63
+
64
+ useEffect(() => {
65
+ // A release that never calls this is abandoned on the next start.
66
+ notifyApplicationReady();
67
+ sync().catch((error) => console.warn('Update check failed', error.code));
68
+ }, []);
69
+ ```
70
+
71
+ For full control, use `checkForUpdate`, `downloadUpdate({ onProgress })`, `installUpdate`, and `restartApp`. They throw `RovqError` with a stable `code`.
72
+
73
+ ## Updating in the foreground or the background
74
+
75
+ Wrap your app in `RovqUpdateGate`, and choose how updates arrive:
76
+
77
+ ```tsx
78
+ import { RovqUpdateGate } from '@rovq/react-native';
79
+
80
+ export default function Root() {
81
+ return (
82
+ <RovqUpdateGate mode="auto" variant="bar">
83
+ <App />
84
+ </RovqUpdateGate>
85
+ );
86
+ }
87
+ ```
88
+
89
+ | `mode` | What the user sees |
90
+ | ---------------- | --------------------------------------------------------------------------------- |
91
+ | `background` | Nothing. The update downloads while they use the app, and loads on the next start |
92
+ | `foreground` | A progress screen, then the app restarts on the new version at once |
93
+ | `auto` (default) | Foreground for a release published with `--mandatory`, background otherwise |
94
+
95
+ `variant` is `bar` or `percent`. The gate shows your app while it checks, so a slow server never holds it up, and it confirms the running release with `notifyApplicationReady` for you. Pass `title`, `color`, and `backgroundColor`, or draw your own screen with `renderProgress={(state) => ...}`.
96
+
97
+ To build your own interface, use the hook and the progress component:
98
+
99
+ ```tsx
100
+ import { RovqUpdateProgress, useRovqUpdate } from '@rovq/react-native';
101
+
102
+ const { phase, progress, update, error, check } = useRovqUpdate({ mode: 'background' });
103
+ // phase: idle, checking, upToDate, downloading, installing, ready, restarting, or error
104
+ {
105
+ progress !== null && <RovqUpdateProgress progress={progress} variant="percent" />;
106
+ }
107
+ ```
108
+
109
+ A failure never throws. It ends in the `error` phase, and the app keeps running what it has.
110
+
111
+ "Background" means while the app is open. When the user leaves the app, the system can pause the download, and it continues from where it stopped the next time the app runs.
112
+
113
+ Publish updates with [`@rovq/cli`](https://www.npmjs.com/package/@rovq/cli).
114
+
115
+ ## Downloading only what changed
116
+
117
+ A release published by `@rovq/cli` 0.2 lists its files, and the SDK downloads only those it does not already have. Files from the release before, and assets inside the app itself, are checked against the signed hash and reused. A change to one screen downloads only the bundle, however many images and sounds the app has.
118
+
119
+ ## License
120
+
121
+ MIT
package/Rovq.podspec ADDED
@@ -0,0 +1,19 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'Rovq'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.homepage = 'https://github.com/moatazMahdi/rovq'
10
+ s.license = 'MIT'
11
+ s.authors = 'moatazMahdi'
12
+ s.platforms = { ios: min_ios_version_supported }
13
+ s.source = { git: 'https://github.com/moatazMahdi/rovq.git', tag: s.version.to_s }
14
+ s.source_files = 'ios/Engine/**/*.swift', 'ios/React/**/*.{swift,mm}'
15
+ s.swift_version = '5.9'
16
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
17
+
18
+ install_modules_dependencies(s)
19
+ end
@@ -0,0 +1,86 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ }
4
+
5
+ // The engine has no React Native code, so it can be built and tested on its own, in
6
+ // seconds. Inside an app, the React Native integration is added on top of it.
7
+ def insideApp = rootProject.name != 'rovq-engine'
8
+
9
+ // React Native's app template turns off the Kotlin support built into Android Gradle Plugin 9.
10
+ def usesKotlinPlugin = insideApp && findProperty('android.builtInKotlin') == 'false'
11
+
12
+ // Rebuilding only changed files fails with "Cannot access ... it is internal" when this package is
13
+ // linked through a symbolic link, as npm's file: dependencies are. Compiling it fully each time
14
+ // costs seconds. The Kotlin plugin reads this when it is applied, so it must be set first.
15
+ if (insideApp) {
16
+ ext.set('kotlin.incremental', 'false')
17
+ }
18
+
19
+ if (usesKotlinPlugin) {
20
+ apply plugin: 'kotlin-android'
21
+ }
22
+
23
+ // Keeps the two kinds of build from mixing outputs. The build inside an app stays in build/,
24
+ // because React Native's autolinking expects the generated code there.
25
+ if (!insideApp) {
26
+ layout.buildDirectory = layout.projectDirectory.dir('build/standalone')
27
+ }
28
+
29
+ if (insideApp) {
30
+ apply plugin: 'com.facebook.react'
31
+
32
+ react {
33
+ // The app's install holds React Native, not this package's folder.
34
+ reactNativeDir = rootProject.file('../node_modules/react-native')
35
+ codegenDir = rootProject.file('../node_modules/@react-native/codegen')
36
+ }
37
+ }
38
+
39
+ android {
40
+ namespace = 'com.rovq.engine'
41
+ compileSdk = 37
42
+
43
+ defaultConfig {
44
+ minSdk = 24
45
+ testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
46
+ }
47
+
48
+ compileOptions {
49
+ sourceCompatibility = JavaVersion.VERSION_17
50
+ targetCompatibility = JavaVersion.VERSION_17
51
+ }
52
+
53
+ sourceSets {
54
+ if (usesKotlinPlugin) {
55
+ main.java.srcDirs += 'src/react/java'
56
+ } else if (insideApp) {
57
+ main.kotlin.directories.add('src/react/java')
58
+ } else {
59
+ // Test helpers that run both on the computer and on a device.
60
+ test.kotlin.directories.add('src/sharedTest/java')
61
+ androidTest.kotlin.directories.add('src/sharedTest/java')
62
+ }
63
+ }
64
+
65
+ testOptions {
66
+ // Keeps unit tests independent of Android framework classes.
67
+ unitTests.returnDefaultValues = false
68
+ }
69
+ }
70
+
71
+ dependencies {
72
+ // Android includes Ed25519 only from API 33, and the engine supports API 24.
73
+ implementation 'com.google.crypto.tink:tink-android:1.24.0'
74
+
75
+ if (insideApp) {
76
+ // The version is set by the React Native Gradle Plugin.
77
+ implementation 'com.facebook.react:react-android'
78
+ }
79
+
80
+ testImplementation 'junit:junit:4.13.2'
81
+ // Android provides org.json on devices. Unit tests on the computer need their own copy.
82
+ testImplementation 'org.json:json:20260814'
83
+
84
+ androidTestImplementation 'androidx.test:runner:1.7.0'
85
+ androidTestImplementation 'androidx.test.ext:junit:1.3.0'
86
+ }
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <uses-permission android:name="android.permission.INTERNET" />
4
+ </manifest>
@@ -0,0 +1,176 @@
1
+ package com.rovq.engine
2
+
3
+ import java.io.File
4
+ import java.io.FileOutputStream
5
+ import java.io.IOException
6
+ import org.json.JSONArray
7
+ import org.json.JSONException
8
+ import org.json.JSONObject
9
+
10
+ /** A release on the device, identified by its folder name and its signed sequence. */
11
+ data class ReleaseRef(val id: String, val sequence: Long)
12
+
13
+ /** Waiting to be sent to the server. Device events are retried, so each has its own ID. */
14
+ data class QueuedEvent(
15
+ val clientEventId: String,
16
+ val type: String,
17
+ val releaseId: String?,
18
+ val errorCode: String?,
19
+ val occurredAt: String,
20
+ )
21
+
22
+ /** A release that failed its checks after downloading, and when it may be tried again. */
23
+ data class PostponedDownload(val releaseId: String, val attempts: Int, val untilMillis: Long)
24
+
25
+ /**
26
+ * Everything the engine must remember across process deaths.
27
+ *
28
+ * A release moves from [downloaded] to [pending] to [trying] to [current]:
29
+ * - [downloaded]: verified and on disk, but nothing will load it until the app installs it.
30
+ * - [pending]: installed, and loads on the next JavaScript start.
31
+ * - [trying]: being loaded right now. If the next start finds it still here, it never
32
+ * confirmed that it works, so it is abandoned.
33
+ * - [current]: confirmed to work.
34
+ * - [previous]: the confirmed release before [current], kept to go back to.
35
+ */
36
+ data class EngineState(
37
+ val deviceId: String,
38
+ val binaryId: String,
39
+ val runtimeVersion: String,
40
+ val highestSequence: Long = 0,
41
+ val current: ReleaseRef? = null,
42
+ val previous: ReleaseRef? = null,
43
+ val downloaded: ReleaseRef? = null,
44
+ val pending: ReleaseRef? = null,
45
+ val trying: ReleaseRef? = null,
46
+ /** True if the app went to the background while [trying], so a person probably closed it. */
47
+ val backgroundedWhileTrying: Boolean = false,
48
+ /** How many more starts [trying] was given because it was closed before confirming. */
49
+ val tryingRetries: Int = 0,
50
+ val postponed: PostponedDownload? = null,
51
+ val revokedPublicKeys: Set<String> = emptySet(),
52
+ val events: List<QueuedEvent> = emptyList(),
53
+ )
54
+
55
+ /**
56
+ * Saves the state so that a crash at any moment leaves either the old state or the new
57
+ * one, never a mixture. The app's first screen depends on this file being readable.
58
+ */
59
+ internal class EngineStateStore(rootDir: File) {
60
+ private val file = File(rootDir, "state.json")
61
+ private val temporary = File(rootDir, "state.json.tmp")
62
+
63
+ /** Returns null if there is no state, or if it is unreadable. */
64
+ fun read(): EngineState? {
65
+ if (!file.isFile) return null
66
+ return try {
67
+ parse(JSONObject(file.readText()))
68
+ } catch (error: JSONException) {
69
+ null
70
+ } catch (error: IOException) {
71
+ null
72
+ }
73
+ }
74
+
75
+ fun write(state: EngineState) {
76
+ // Writing to another file first means a crash cannot leave a half-written state.
77
+ FileOutputStream(temporary).use { output ->
78
+ output.write(toJson(state).toString().toByteArray(Charsets.UTF_8))
79
+ output.fd.sync()
80
+ }
81
+ if (!temporary.renameTo(file)) throw IOException("Could not save the engine state")
82
+ }
83
+
84
+ private fun parse(json: JSONObject): EngineState {
85
+ val events = json.getJSONArray("events")
86
+ return EngineState(
87
+ deviceId = json.getString("deviceId"),
88
+ binaryId = json.getString("binaryId"),
89
+ runtimeVersion = json.getString("runtimeVersion"),
90
+ highestSequence = json.getLong("highestSequence"),
91
+ current = readRelease(json, "current"),
92
+ previous = readRelease(json, "previous"),
93
+ downloaded = readRelease(json, "downloaded"),
94
+ pending = readRelease(json, "pending"),
95
+ trying = readRelease(json, "trying"),
96
+ backgroundedWhileTrying = json.optBoolean("backgroundedWhileTrying", false),
97
+ tryingRetries = json.optInt("tryingRetries", 0),
98
+ postponed = readPostponed(json),
99
+ revokedPublicKeys = readStrings(json, "revokedPublicKeys"),
100
+ events = (0 until events.length()).map { index ->
101
+ val event = events.getJSONObject(index)
102
+ QueuedEvent(
103
+ clientEventId = event.getString("clientEventId"),
104
+ type = event.getString("type"),
105
+ releaseId = optionalString(event, "releaseId"),
106
+ errorCode = optionalString(event, "errorCode"),
107
+ occurredAt = event.getString("occurredAt"),
108
+ )
109
+ },
110
+ )
111
+ }
112
+
113
+ private fun readPostponed(json: JSONObject): PostponedDownload? {
114
+ if (json.isNull("postponed")) return null
115
+ val value = json.getJSONObject("postponed")
116
+ return PostponedDownload(
117
+ value.getString("releaseId"),
118
+ value.getInt("attempts"),
119
+ value.getLong("untilMillis"),
120
+ )
121
+ }
122
+
123
+ private fun readStrings(json: JSONObject, name: String): Set<String> {
124
+ val list = json.optJSONArray(name) ?: return emptySet()
125
+ return (0 until list.length()).map { list.getString(it) }.toSet()
126
+ }
127
+
128
+ private fun readRelease(json: JSONObject, name: String): ReleaseRef? {
129
+ if (json.isNull(name)) return null
130
+ val release = json.getJSONObject(name)
131
+ return ReleaseRef(release.getString("id"), release.getLong("sequence"))
132
+ }
133
+
134
+ private fun optionalString(json: JSONObject, name: String): String? =
135
+ if (json.isNull(name)) null else json.getString(name)
136
+
137
+ private fun toJson(state: EngineState): JSONObject {
138
+ val events = JSONArray()
139
+ state.events.forEach { event ->
140
+ events.put(
141
+ JSONObject()
142
+ .put("clientEventId", event.clientEventId)
143
+ .put("type", event.type)
144
+ .put("releaseId", event.releaseId ?: JSONObject.NULL)
145
+ .put("errorCode", event.errorCode ?: JSONObject.NULL)
146
+ .put("occurredAt", event.occurredAt),
147
+ )
148
+ }
149
+ return JSONObject()
150
+ .put("deviceId", state.deviceId)
151
+ .put("binaryId", state.binaryId)
152
+ .put("runtimeVersion", state.runtimeVersion)
153
+ .put("highestSequence", state.highestSequence)
154
+ .put("current", releaseJson(state.current))
155
+ .put("previous", releaseJson(state.previous))
156
+ .put("downloaded", releaseJson(state.downloaded))
157
+ .put("pending", releaseJson(state.pending))
158
+ .put("trying", releaseJson(state.trying))
159
+ .put("backgroundedWhileTrying", state.backgroundedWhileTrying)
160
+ .put("tryingRetries", state.tryingRetries)
161
+ .put("postponed", postponedJson(state.postponed))
162
+ .put("revokedPublicKeys", JSONArray(state.revokedPublicKeys.toList()))
163
+ .put("events", events)
164
+ }
165
+
166
+ private fun postponedJson(value: PostponedDownload?): Any =
167
+ value?.let {
168
+ JSONObject()
169
+ .put("releaseId", it.releaseId)
170
+ .put("attempts", it.attempts)
171
+ .put("untilMillis", it.untilMillis)
172
+ } ?: JSONObject.NULL
173
+
174
+ private fun releaseJson(release: ReleaseRef?): Any =
175
+ release?.let { JSONObject().put("id", it.id).put("sequence", it.sequence) } ?: JSONObject.NULL
176
+ }
@@ -0,0 +1,230 @@
1
+ package com.rovq.engine
2
+
3
+ import com.google.crypto.tink.subtle.Ed25519Verify
4
+ import java.security.GeneralSecurityException
5
+ import org.json.JSONArray
6
+ import org.json.JSONException
7
+ import org.json.JSONObject
8
+
9
+ private const val PUBLIC_KEY_SIZE = 32
10
+ private const val SIGNATURE_SIZE = 64
11
+ private const val PLATFORM = "ANDROID"
12
+ private val STREAM_FIELDS = setOf(
13
+ "version",
14
+ "releaseId",
15
+ "deploymentKey",
16
+ "platform",
17
+ "runtimeVersion",
18
+ "binaryVersion",
19
+ "sequence",
20
+ "mandatory",
21
+ )
22
+
23
+ // Version 1 names one ZIP package. Version 2 lists files. See docs/file-packages.md.
24
+ private val ZIP_MANIFEST_FIELDS = STREAM_FIELDS + "package"
25
+ private val FILES_MANIFEST_FIELDS = STREAM_FIELDS + "files"
26
+ private val PACKAGE_FIELDS = setOf("hash", "size")
27
+ private val FILE_FIELDS = setOf("path", "hash", "size")
28
+ private val UUID_PATTERN = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
29
+ private val HASH_PATTERN = Regex("^[0-9a-f]{64}$")
30
+
31
+ /** One file of a release made of files, from a verified manifest. */
32
+ data class ManifestFile(val path: String, val hash: String, val size: Long)
33
+
34
+ /** Facts from a manifest whose signature has been verified. Nothing else should be trusted. */
35
+ data class VerifiedManifest(
36
+ val releaseId: String,
37
+ val sequence: Long,
38
+ val mandatory: Boolean,
39
+ /** The ZIP package's hash. Empty for a release made of files. */
40
+ val packageHash: String,
41
+ /** The ZIP package's size, or all files together. */
42
+ val packageSize: Long,
43
+ /** Set for a release made of files, sorted by path. Null for a ZIP package. */
44
+ val files: List<ManifestFile>? = null,
45
+ )
46
+
47
+ /**
48
+ * The device's own security check. It trusts only the public keys built into the
49
+ * app, so a compromised server cannot get code onto the device.
50
+ *
51
+ * @param trustedKeys key IDs and their Base64 Ed25519 public keys, built into the app.
52
+ */
53
+ class ManifestVerifier(
54
+ private val trustedKeys: Map<String, String>,
55
+ private val deploymentKey: String,
56
+ private val runtimeVersion: String,
57
+ ) {
58
+ private val verifiers: Map<String, Ed25519Verify> = trustedKeys.mapValues { (keyId, publicKey) ->
59
+ val bytes = StrictBase64.decode(publicKey, PUBLIC_KEY_SIZE)
60
+ ?: throw IllegalArgumentException("Public key $keyId is not a Base64 Ed25519 key")
61
+ Ed25519Verify(bytes)
62
+ }
63
+
64
+ // Public keys, not IDs, so a later version of the app can reuse an ID with a new key.
65
+ @Volatile
66
+ var revokedPublicKeys: Set<String> = emptySet()
67
+
68
+ /**
69
+ * Checks a key's statement that it must no longer be trusted, and returns its public key.
70
+ * Only the key's own signature counts, so a stolen key can revoke only itself. Comparing the
71
+ * exact text means nothing unsigned reaches a parser.
72
+ *
73
+ * @return null if the statement is not valid, in which case it must be ignored.
74
+ */
75
+ fun verifyRevocation(keyId: String, statement: String, signature: String): String? {
76
+ val publicKey = trustedKeys[keyId] ?: return null
77
+ val expected = """{"version":1,"type":"key-revocation","publicKey":"$publicKey"}"""
78
+ if (statement != expected) return null
79
+ return try {
80
+ verifySignature(statement, signature, keyId)
81
+ publicKey
82
+ } catch (error: RovqException) {
83
+ null
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param highestSequence the highest sequence this device has accepted in this stream.
89
+ * @throws RovqException if anything fails. The device must then ignore the update.
90
+ */
91
+ fun verify(
92
+ manifest: String,
93
+ signature: String,
94
+ keyId: String,
95
+ highestSequence: Long,
96
+ ): VerifiedManifest {
97
+ if (trustedKeys[keyId] in revokedPublicKeys) {
98
+ throw RovqException(RovqErrorCode.REVOKED_SIGNING_KEY, "Signing key $keyId was revoked")
99
+ }
100
+ verifySignature(manifest, signature, keyId)
101
+ // Only signed text reaches the JSON parser.
102
+ val verified = parseAndCheck(manifest)
103
+ if (verified.sequence <= highestSequence) {
104
+ throw RovqException(
105
+ RovqErrorCode.STALE_SEQUENCE,
106
+ "Sequence ${verified.sequence} is not above $highestSequence",
107
+ )
108
+ }
109
+ return verified
110
+ }
111
+
112
+ private fun verifySignature(manifest: String, signature: String, keyId: String) {
113
+ val verifier = verifiers[keyId]
114
+ ?: throw RovqException(RovqErrorCode.UNKNOWN_SIGNING_KEY, "Unknown signing key $keyId")
115
+ val signatureBytes = StrictBase64.decode(signature, SIGNATURE_SIZE)
116
+ ?: throw RovqException(RovqErrorCode.INVALID_SIGNATURE, "Malformed signature")
117
+ try {
118
+ verifier.verify(signatureBytes, manifest.toByteArray(Charsets.UTF_8))
119
+ } catch (error: GeneralSecurityException) {
120
+ throw RovqException(RovqErrorCode.INVALID_SIGNATURE, "Signature does not match", error)
121
+ }
122
+ }
123
+
124
+ private fun parseAndCheck(manifest: String): VerifiedManifest {
125
+ val root = parseObject(manifest)
126
+ val version = longField(root, "version")
127
+ if (version != 1L && version != 2L) invalid("Unknown manifest version")
128
+ requireOnlyFields(root, if (version == 1L) ZIP_MANIFEST_FIELDS else FILES_MANIFEST_FIELDS)
129
+
130
+ val releaseId = stringField(root, "releaseId")
131
+ // Later used as a folder name, so it must be exactly a UUID.
132
+ if (!UUID_PATTERN.matches(releaseId)) invalid("releaseId is not a UUID")
133
+
134
+ val sequence = longField(root, "sequence")
135
+ if (sequence <= 0) invalid("sequence must be positive")
136
+
137
+ val mandatory = root.opt("mandatory") as? Boolean ?: invalid("mandatory must be a boolean")
138
+ stringField(root, "binaryVersion")
139
+
140
+ // A valid signature for another deployment, platform or runtime must not install here.
141
+ requireMatch("deploymentKey", stringField(root, "deploymentKey"), deploymentKey)
142
+ requireMatch("platform", stringField(root, "platform"), PLATFORM)
143
+ requireMatch("runtimeVersion", stringField(root, "runtimeVersion"), runtimeVersion)
144
+
145
+ if (version == 2L) {
146
+ val files = fileList(root.opt("files"))
147
+ return VerifiedManifest(releaseId, sequence, mandatory, "", files.sumOf { it.size }, files)
148
+ }
149
+
150
+ val packageInfo = root.opt("package") as? JSONObject ?: invalid("package must be an object")
151
+ requireOnlyFields(packageInfo, PACKAGE_FIELDS)
152
+ val hash = stringField(packageInfo, "hash")
153
+ if (!HASH_PATTERN.matches(hash)) invalid("package hash is malformed")
154
+ val size = longField(packageInfo, "size")
155
+ if (size <= 0) invalid("package size must be positive")
156
+
157
+ return VerifiedManifest(releaseId, sequence, mandatory, hash, size)
158
+ }
159
+
160
+ /**
161
+ * The same rules as a ZIP package, because each path is written to disk the same way.
162
+ * Sorted by UTF-16 code units, as JavaScript and Kotlin both sort, so one set of files has
163
+ * one signed form.
164
+ */
165
+ private fun fileList(value: Any?): List<ManifestFile> {
166
+ val list = value as? JSONArray ?: invalid("files must be an array")
167
+ if (list.length() == 0 || list.length() > PackageRules.MAX_ENTRIES) {
168
+ invalid("A package must have from 1 to ${PackageRules.MAX_ENTRIES} files")
169
+ }
170
+ val files = ArrayList<ManifestFile>(list.length())
171
+ val seen = HashSet<String>()
172
+ var total = 0L
173
+ for (index in 0 until list.length()) {
174
+ val entry = list.opt(index) as? JSONObject ?: invalid("Each file must be an object")
175
+ requireOnlyFields(entry, FILE_FIELDS)
176
+ val path = stringField(entry, "path")
177
+ PackageRules.findPathProblem(path)?.let { invalid("The file $path $it") }
178
+ val previous = files.lastOrNull()
179
+ if (previous != null && previous.path >= path) {
180
+ invalid("The files must be sorted by path, each path once")
181
+ }
182
+ if (!seen.add(PackageRules.pathIdentity(path))) {
183
+ invalid("The file $path differs from another only by letter case")
184
+ }
185
+ val hash = stringField(entry, "hash")
186
+ if (!HASH_PATTERN.matches(hash)) invalid("The file $path has a malformed hash")
187
+ val size = longField(entry, "size")
188
+ // Bounded before adding, so the total cannot overflow.
189
+ if (size < 0 || size > PackageRules.MAX_TOTAL_SIZE) invalid("The file $path has an invalid size")
190
+ total += size
191
+ if (total > PackageRules.MAX_TOTAL_SIZE) invalid("The files are larger than the allowed total")
192
+ files += ManifestFile(path, hash, size)
193
+ }
194
+ val bundle = files.firstOrNull { it.path == PackageRules.BUNDLE_FILE_NAME }
195
+ ?: invalid("The bundle file is missing")
196
+ if (bundle.size == 0L) invalid("The bundle file is empty")
197
+ return files
198
+ }
199
+
200
+ private fun parseObject(text: String): JSONObject = try {
201
+ JSONObject(text)
202
+ } catch (error: JSONException) {
203
+ throw RovqException(RovqErrorCode.INVALID_MANIFEST, "Manifest is not valid JSON", error)
204
+ }
205
+
206
+ private fun requireOnlyFields(value: JSONObject, allowed: Set<String>) {
207
+ for (key in value.keys()) {
208
+ if (key !in allowed) invalid("Unknown field $key")
209
+ }
210
+ }
211
+
212
+ private fun stringField(value: JSONObject, name: String): String =
213
+ value.opt(name) as? String ?: invalid("$name must be a string")
214
+
215
+ private fun longField(value: JSONObject, name: String): Long =
216
+ when (val raw = value.opt(name)) {
217
+ is Int -> raw.toLong()
218
+ is Long -> raw
219
+ else -> invalid("$name must be a whole number")
220
+ }
221
+
222
+ private fun requireMatch(name: String, actual: String, expected: String) {
223
+ if (actual != expected) {
224
+ throw RovqException(RovqErrorCode.MANIFEST_MISMATCH, "$name does not match this app")
225
+ }
226
+ }
227
+
228
+ private fun invalid(message: String): Nothing =
229
+ throw RovqException(RovqErrorCode.INVALID_MANIFEST, message)
230
+ }