@rovq/react-native 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +76 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +197 -0
- package/ios/Engine/PackageDownloader.swift +231 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +633 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +126 -0
- package/ios/Engine/UpdateService.swift +158 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +253 -0
- package/ios/React/RovqModule.mm +89 -0
- package/package.json +74 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +228 -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,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rovq/react-native
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
To ask the user before downloading, use `checkForUpdate`, `downloadUpdate({ onProgress })`, `installUpdate`, and `restartApp`. Failures throw `RovqError` with a stable `code`.
|
|
72
|
+
|
|
73
|
+
Publish updates with [`@rovq/cli`](https://www.npmjs.com/package/@rovq/cli).
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
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,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,173 @@
|
|
|
1
|
+
package com.rovq.engine
|
|
2
|
+
|
|
3
|
+
import com.google.crypto.tink.subtle.Ed25519Verify
|
|
4
|
+
import java.security.GeneralSecurityException
|
|
5
|
+
import org.json.JSONException
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
|
|
8
|
+
private const val PUBLIC_KEY_SIZE = 32
|
|
9
|
+
private const val SIGNATURE_SIZE = 64
|
|
10
|
+
private const val MANIFEST_VERSION = 1
|
|
11
|
+
private const val PLATFORM = "ANDROID"
|
|
12
|
+
private val MANIFEST_FIELDS = setOf(
|
|
13
|
+
"version",
|
|
14
|
+
"releaseId",
|
|
15
|
+
"deploymentKey",
|
|
16
|
+
"platform",
|
|
17
|
+
"runtimeVersion",
|
|
18
|
+
"binaryVersion",
|
|
19
|
+
"sequence",
|
|
20
|
+
"mandatory",
|
|
21
|
+
"package",
|
|
22
|
+
)
|
|
23
|
+
private val PACKAGE_FIELDS = setOf("hash", "size")
|
|
24
|
+
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}$")
|
|
25
|
+
private val HASH_PATTERN = Regex("^[0-9a-f]{64}$")
|
|
26
|
+
|
|
27
|
+
/** Facts from a manifest whose signature has been verified. Nothing else should be trusted. */
|
|
28
|
+
data class VerifiedManifest(
|
|
29
|
+
val releaseId: String,
|
|
30
|
+
val sequence: Long,
|
|
31
|
+
val mandatory: Boolean,
|
|
32
|
+
val packageHash: String,
|
|
33
|
+
val packageSize: Long,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The device's own security check. It trusts only the public keys built into the
|
|
38
|
+
* app, so a compromised server cannot get code onto the device.
|
|
39
|
+
*
|
|
40
|
+
* @param trustedKeys key IDs and their Base64 Ed25519 public keys, built into the app.
|
|
41
|
+
*/
|
|
42
|
+
class ManifestVerifier(
|
|
43
|
+
private val trustedKeys: Map<String, String>,
|
|
44
|
+
private val deploymentKey: String,
|
|
45
|
+
private val runtimeVersion: String,
|
|
46
|
+
) {
|
|
47
|
+
private val verifiers: Map<String, Ed25519Verify> = trustedKeys.mapValues { (keyId, publicKey) ->
|
|
48
|
+
val bytes = StrictBase64.decode(publicKey, PUBLIC_KEY_SIZE)
|
|
49
|
+
?: throw IllegalArgumentException("Public key $keyId is not a Base64 Ed25519 key")
|
|
50
|
+
Ed25519Verify(bytes)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Public keys, not IDs, so a later version of the app can reuse an ID with a new key.
|
|
54
|
+
@Volatile
|
|
55
|
+
var revokedPublicKeys: Set<String> = emptySet()
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Checks a key's statement that it must no longer be trusted, and returns its public key.
|
|
59
|
+
* Only the key's own signature counts, so a stolen key can revoke only itself. Comparing the
|
|
60
|
+
* exact text means nothing unsigned reaches a parser.
|
|
61
|
+
*
|
|
62
|
+
* @return null if the statement is not valid, in which case it must be ignored.
|
|
63
|
+
*/
|
|
64
|
+
fun verifyRevocation(keyId: String, statement: String, signature: String): String? {
|
|
65
|
+
val publicKey = trustedKeys[keyId] ?: return null
|
|
66
|
+
val expected = """{"version":1,"type":"key-revocation","publicKey":"$publicKey"}"""
|
|
67
|
+
if (statement != expected) return null
|
|
68
|
+
return try {
|
|
69
|
+
verifySignature(statement, signature, keyId)
|
|
70
|
+
publicKey
|
|
71
|
+
} catch (error: RovqException) {
|
|
72
|
+
null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param highestSequence the highest sequence this device has accepted in this stream.
|
|
78
|
+
* @throws RovqException if anything fails. The device must then ignore the update.
|
|
79
|
+
*/
|
|
80
|
+
fun verify(
|
|
81
|
+
manifest: String,
|
|
82
|
+
signature: String,
|
|
83
|
+
keyId: String,
|
|
84
|
+
highestSequence: Long,
|
|
85
|
+
): VerifiedManifest {
|
|
86
|
+
if (trustedKeys[keyId] in revokedPublicKeys) {
|
|
87
|
+
throw RovqException(RovqErrorCode.REVOKED_SIGNING_KEY, "Signing key $keyId was revoked")
|
|
88
|
+
}
|
|
89
|
+
verifySignature(manifest, signature, keyId)
|
|
90
|
+
// Only signed text reaches the JSON parser.
|
|
91
|
+
val verified = parseAndCheck(manifest)
|
|
92
|
+
if (verified.sequence <= highestSequence) {
|
|
93
|
+
throw RovqException(
|
|
94
|
+
RovqErrorCode.STALE_SEQUENCE,
|
|
95
|
+
"Sequence ${verified.sequence} is not above $highestSequence",
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return verified
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private fun verifySignature(manifest: String, signature: String, keyId: String) {
|
|
102
|
+
val verifier = verifiers[keyId]
|
|
103
|
+
?: throw RovqException(RovqErrorCode.UNKNOWN_SIGNING_KEY, "Unknown signing key $keyId")
|
|
104
|
+
val signatureBytes = StrictBase64.decode(signature, SIGNATURE_SIZE)
|
|
105
|
+
?: throw RovqException(RovqErrorCode.INVALID_SIGNATURE, "Malformed signature")
|
|
106
|
+
try {
|
|
107
|
+
verifier.verify(signatureBytes, manifest.toByteArray(Charsets.UTF_8))
|
|
108
|
+
} catch (error: GeneralSecurityException) {
|
|
109
|
+
throw RovqException(RovqErrorCode.INVALID_SIGNATURE, "Signature does not match", error)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun parseAndCheck(manifest: String): VerifiedManifest {
|
|
114
|
+
val root = parseObject(manifest)
|
|
115
|
+
requireOnlyFields(root, MANIFEST_FIELDS)
|
|
116
|
+
if (longField(root, "version") != MANIFEST_VERSION.toLong()) invalid("Unknown manifest version")
|
|
117
|
+
|
|
118
|
+
val releaseId = stringField(root, "releaseId")
|
|
119
|
+
// Later used as a folder name, so it must be exactly a UUID.
|
|
120
|
+
if (!UUID_PATTERN.matches(releaseId)) invalid("releaseId is not a UUID")
|
|
121
|
+
|
|
122
|
+
val sequence = longField(root, "sequence")
|
|
123
|
+
if (sequence <= 0) invalid("sequence must be positive")
|
|
124
|
+
|
|
125
|
+
val mandatory = root.opt("mandatory") as? Boolean ?: invalid("mandatory must be a boolean")
|
|
126
|
+
stringField(root, "binaryVersion")
|
|
127
|
+
|
|
128
|
+
val packageInfo = root.opt("package") as? JSONObject ?: invalid("package must be an object")
|
|
129
|
+
requireOnlyFields(packageInfo, PACKAGE_FIELDS)
|
|
130
|
+
val hash = stringField(packageInfo, "hash")
|
|
131
|
+
if (!HASH_PATTERN.matches(hash)) invalid("package hash is malformed")
|
|
132
|
+
val size = longField(packageInfo, "size")
|
|
133
|
+
if (size <= 0) invalid("package size must be positive")
|
|
134
|
+
|
|
135
|
+
// A valid signature for another deployment, platform or runtime must not install here.
|
|
136
|
+
requireMatch("deploymentKey", stringField(root, "deploymentKey"), deploymentKey)
|
|
137
|
+
requireMatch("platform", stringField(root, "platform"), PLATFORM)
|
|
138
|
+
requireMatch("runtimeVersion", stringField(root, "runtimeVersion"), runtimeVersion)
|
|
139
|
+
|
|
140
|
+
return VerifiedManifest(releaseId, sequence, mandatory, hash, size)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun parseObject(text: String): JSONObject = try {
|
|
144
|
+
JSONObject(text)
|
|
145
|
+
} catch (error: JSONException) {
|
|
146
|
+
throw RovqException(RovqErrorCode.INVALID_MANIFEST, "Manifest is not valid JSON", error)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private fun requireOnlyFields(value: JSONObject, allowed: Set<String>) {
|
|
150
|
+
for (key in value.keys()) {
|
|
151
|
+
if (key !in allowed) invalid("Unknown field $key")
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun stringField(value: JSONObject, name: String): String =
|
|
156
|
+
value.opt(name) as? String ?: invalid("$name must be a string")
|
|
157
|
+
|
|
158
|
+
private fun longField(value: JSONObject, name: String): Long =
|
|
159
|
+
when (val raw = value.opt(name)) {
|
|
160
|
+
is Int -> raw.toLong()
|
|
161
|
+
is Long -> raw
|
|
162
|
+
else -> invalid("$name must be a whole number")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun requireMatch(name: String, actual: String, expected: String) {
|
|
166
|
+
if (actual != expected) {
|
|
167
|
+
throw RovqException(RovqErrorCode.MANIFEST_MISMATCH, "$name does not match this app")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun invalid(message: String): Nothing =
|
|
172
|
+
throw RovqException(RovqErrorCode.INVALID_MANIFEST, message)
|
|
173
|
+
}
|