ubriot-updates-rn 0.1.4 → 0.4.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/README.md +136 -17
- package/android/build.gradle +1 -0
- package/android/consumer-rules.pro +4 -0
- package/android/src/main/java/dev/ubriot/updates/UbriotCodeSigning.kt +334 -0
- package/android/src/main/java/dev/ubriot/updates/UbriotUpdatesModule.kt +572 -3
- package/app.plugin.js +1 -0
- package/code-signing.js +1 -0
- package/dist/asset-source.d.ts +28 -0
- package/dist/asset-source.js +58 -0
- package/dist/client.d.ts +97 -0
- package/dist/client.js +319 -0
- package/dist/index.d.ts +11 -24
- package/dist/index.js +54 -204
- package/dist/manifest-headers.d.ts +10 -0
- package/dist/manifest-headers.js +22 -0
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +51 -0
- package/ios/UbriotCodeSigning.h +42 -0
- package/ios/UbriotCodeSigning.m +521 -0
- package/ios/UbriotUpdates.m +593 -2
- package/package.json +11 -4
- package/plugin/__tests__/code-signing.test.js +67 -0
- package/plugin/__tests__/transforms.test.js +185 -0
- package/plugin/code-signing-config.js +75 -0
- package/plugin/transforms.js +57 -0
- package/plugin/withUbriotCodeSigning.js +24 -0
- package/plugin/withUbriotUpdates.js +66 -0
- package/src/asset-source.ts +94 -0
- package/src/client.ts +392 -0
- package/src/index.ts +72 -258
- package/src/manifest-headers.ts +33 -0
- package/src/manifest.ts +64 -0
package/README.md
CHANGED
|
@@ -14,8 +14,21 @@ native + JS runtime that consumes those updates in a bare app.
|
|
|
14
14
|
- Talks to the existing `GET /ubriot/updates/manifest` endpoint (Expo-Updates-style
|
|
15
15
|
manifest contract, including the `expo-runtime-version`, `expo-channel-name`, and
|
|
16
16
|
`expo-current-update-id` request headers).
|
|
17
|
-
- Downloads the manifest's `launchAsset` bundle
|
|
18
|
-
|
|
17
|
+
- Downloads the manifest's `launchAsset` bundle AND every asset in `manifest.assets`
|
|
18
|
+
(fonts, images, anything the bundle references) into one folder per update (iOS
|
|
19
|
+
`Library/ubriot-updates/<id>/`, Android `filesDir/ubriot-updates/<id>/`). Redirects are
|
|
20
|
+
followed and each file's SHA-256 is checked when the manifest carries a `hash`. The
|
|
21
|
+
update is only staged once every file is present and verified; any failure discards
|
|
22
|
+
it and the app keeps running what it has.
|
|
23
|
+
- Keeps every verified file in a content-addressed cache (`ubriot-updates/cache/`, keyed
|
|
24
|
+
by hash), so a download cut short by the user leaving the app resumes where it stopped
|
|
25
|
+
on the next launch, and files that did not change between updates are never fetched
|
|
26
|
+
twice. The cache is trimmed to the last staged update's files. On iOS the download
|
|
27
|
+
also asks for background time, so it keeps going for a short while after the app is
|
|
28
|
+
sent to the background.
|
|
29
|
+
- Resolves the bundle's `require('./font.ttf')` / `require('./image.png')` references to
|
|
30
|
+
the downloaded files (matched by content hash), falling back to the copy embedded in
|
|
31
|
+
the native build for anything the update did not ship.
|
|
19
32
|
- Persists which bundle is `pending`, `active`, `verified`, and `lastKnownGood` in a
|
|
20
33
|
small `state.json` next to the bundles.
|
|
21
34
|
- Boots the verified/active downloaded bundle instead of the embedded one, with
|
|
@@ -47,6 +60,8 @@ UbriotUpdates.configure({
|
|
|
47
60
|
appSlug: "your-app-slug",
|
|
48
61
|
channel: "production",
|
|
49
62
|
runtimeVersion: "your-app-1.0.0",
|
|
63
|
+
nativeBuildId: "358",
|
|
64
|
+
nativeBuildCreatedAt: "2026-09-26T00:00:00.000Z",
|
|
50
65
|
});
|
|
51
66
|
|
|
52
67
|
// Convenience: check -> download -> apply (does not restart by default)
|
|
@@ -57,25 +72,60 @@ const { available, manifestId } = await UbriotUpdates.checkForUpdate();
|
|
|
57
72
|
const { ready } = await UbriotUpdates.downloadUpdate();
|
|
58
73
|
await UbriotUpdates.applyUpdate({ restart: true }); // reload JS now
|
|
59
74
|
|
|
60
|
-
// Call once the
|
|
61
|
-
// "verified" so it becomes the rollback target instead of being
|
|
75
|
+
// Call once the first real screen has rendered (not the splash). Promotes the
|
|
76
|
+
// running bundle to "verified" so it becomes the rollback target instead of being
|
|
77
|
+
// rolled back on the next launch.
|
|
62
78
|
UbriotUpdates.markLaunchSuccessful();
|
|
79
|
+
|
|
80
|
+
// Fallback only: mark the launch after a delay if you cannot hook a render.
|
|
81
|
+
// Returns a cancel function. An explicit markLaunchSuccessful() cancels it.
|
|
82
|
+
const cancel = UbriotUpdates.markLaunchSuccessfulAfter(4000);
|
|
63
83
|
```
|
|
64
84
|
|
|
65
|
-
|
|
85
|
+
Import the package once at the top of your entry file (`index.js` / `App.tsx`) so its
|
|
86
|
+
asset resolver is installed before any font or image is resolved:
|
|
66
87
|
|
|
67
88
|
```ts
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
import * as UbriotUpdates from "ubriot-updates-rn";
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Recommended app-start pattern:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
function Root() {
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
UbriotUpdates.configure({ /* ... */ });
|
|
98
|
+
UbriotUpdates.sync().catch(() => {});
|
|
99
|
+
}, []);
|
|
100
|
+
return <App onFirstScreenRendered={() => UbriotUpdates.markLaunchSuccessful()} />;
|
|
101
|
+
}
|
|
74
102
|
```
|
|
75
103
|
|
|
104
|
+
Call `markLaunchSuccessful()` from the first screen that proves the app works (for
|
|
105
|
+
example in a `useEffect` of the signed-in or signed-out home screen, after fonts have
|
|
106
|
+
loaded). Do not call it from a timer on the splash: a timer marks a hung or broken
|
|
107
|
+
update as good, and then it is never rolled back. `markLaunchSuccessfulAfter(ms)` exists
|
|
108
|
+
only as a fallback for apps that cannot hook a render; it gives crash-on-launch
|
|
109
|
+
protection but not hang protection.
|
|
110
|
+
|
|
76
111
|
`sync()` downloads and stages the update; it applies on the next launch. Use
|
|
77
112
|
`applyUpdate({ restart: true })` if you want to reload immediately.
|
|
78
113
|
|
|
114
|
+
`nativeBuildId` and `nativeBuildCreatedAt` are optional and let the server-side
|
|
115
|
+
staleness guard tell a stale native shell apart from a stale OTA bundle. Expo apps can
|
|
116
|
+
read these from `Constants.expoConfig?.extra?.ubriotBuild` (set by the config plugin at
|
|
117
|
+
build time) and pass them straight into `configure()`. Bare RN apps that don't use Expo
|
|
118
|
+
config can supply their own build metadata the same way. `nativeBuildCreatedAt` must be
|
|
119
|
+
a UTC ISO string (e.g. `2026-09-26T00:00:00.000Z`).
|
|
120
|
+
|
|
121
|
+
Apps that use a dynamic Expo config (`app.config.js` / `app.config.ts` instead of
|
|
122
|
+
`app.json` / `app.config.json`) are not auto-stamped: the Ubriot build worker only
|
|
123
|
+
writes `expo.extra.ubriotBuild` into static JSON config files, and skips dynamic config
|
|
124
|
+
with a warning in the build logs. Those apps must supply `nativeBuildId` and
|
|
125
|
+
`nativeBuildCreatedAt` to `configure()` themselves (for example, by reading build
|
|
126
|
+
metadata baked in through `app.config.js`'s own logic), otherwise per-device OTA
|
|
127
|
+
staleness checks are skipped for that app.
|
|
128
|
+
|
|
79
129
|
## Rollback model
|
|
80
130
|
|
|
81
131
|
- A freshly applied bundle is `active` but unverified.
|
|
@@ -84,8 +134,33 @@ useEffect(() => {
|
|
|
84
134
|
- If the app boots that bundle again while still unverified (meaning the previous launch
|
|
85
135
|
never reached `markLaunchSuccessful()`, e.g. it crashed on load), the client reverts to
|
|
86
136
|
the last known good bundle (or the embedded bundle) instead.
|
|
87
|
-
- `markLaunchSuccessful()` marks the
|
|
88
|
-
known good.
|
|
137
|
+
- `markLaunchSuccessful()` marks the bundle that is actually running verified and records
|
|
138
|
+
it as the new last known good. An update that was downloaded and applied during this
|
|
139
|
+
session (and has not run yet) is never marked verified by it.
|
|
140
|
+
- An update that was rolled back is remembered (`failedUpdateIds`) and is not downloaded
|
|
141
|
+
again, so a bad update cannot loop.
|
|
142
|
+
|
|
143
|
+
## Upgrading from 0.1.x
|
|
144
|
+
|
|
145
|
+
0.2.0 changes native code, so it needs a new native build (TestFlight / Play). On an old
|
|
146
|
+
native build the new JS falls back to the old bundle-only download. Replace any
|
|
147
|
+
`setTimeout(() => markLaunchSuccessful(), 4000)` with a call from your first rendered
|
|
148
|
+
screen. If you added your own asset source transformer as a workaround for missing OTA
|
|
149
|
+
assets, remove it, or register it after importing this package.
|
|
150
|
+
|
|
151
|
+
## React Native 0.88 and Expo SDK 58
|
|
152
|
+
|
|
153
|
+
0.4.0 builds and type-checks against React Native 0.88 and its strict TypeScript API
|
|
154
|
+
(public `react-native` imports only), and still against 0.83. The native module does not
|
|
155
|
+
use app delegate callbacks or the key window, so the SDK 58 scene life cycle
|
|
156
|
+
(`SceneDelegate.swift`) needs no changes: the release bundle is still chosen in
|
|
157
|
+
`ReactNativeDelegate.bundleURL()`. For the R8 minification SDK 58 enables by default, the
|
|
158
|
+
Android library ships `consumer-rules.pro`, which keeps `dev.ubriot.updates.**` and every
|
|
159
|
+
`@ReactMethod`, and apps pick it up automatically. This is expected to work but has not yet
|
|
160
|
+
been confirmed with a real `assembleRelease` build.
|
|
161
|
+
The config plugin now also patches the `ExpoReactHostFactory.getDefaultReactHost` host
|
|
162
|
+
used by Expo templates from SDK 55. Moving to SDK 58 is a native change: give the new
|
|
163
|
+
build a new `runtimeVersion`.
|
|
89
164
|
|
|
90
165
|
## Native integration
|
|
91
166
|
|
|
@@ -166,8 +241,52 @@ The `runtimeVersion` you pass here MUST match the `runtimeVersion` passed to
|
|
|
166
241
|
`configure()`. Only JS-only changes can ship over the air; anything touching native code
|
|
167
242
|
requires a new store/TestFlight build with a bumped runtime version.
|
|
168
243
|
|
|
169
|
-
##
|
|
244
|
+
## Code signing
|
|
245
|
+
|
|
246
|
+
From 0.3.0, updates can be code signed. Verification runs natively on iOS
|
|
247
|
+
(Security.framework) and Android (`java.security`), against certificates embedded in the
|
|
248
|
+
binary at build time, using the same wire format as expo-updates.
|
|
249
|
+
|
|
250
|
+
Set it up with the Ubriot CLI, which writes these plugin options for you:
|
|
251
|
+
|
|
252
|
+
```sh
|
|
253
|
+
ubriot signing init --app my-app
|
|
254
|
+
ubriot signing configure --app my-app
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
```json
|
|
258
|
+
["ubriot-updates-rn", {
|
|
259
|
+
"codeSigningCertificate": "./certs/ubriot-ota-main.pem",
|
|
260
|
+
"codeSigningMetadata": { "keyid": "main", "alg": "rsa-v1_5-sha256" },
|
|
261
|
+
"codeSigningIncludeManifestResponseCertificateChain": false,
|
|
262
|
+
"additionalTrustedCertificates": [{ "certificate": "./certs/ubriot-ota-next.pem", "keyid": "next" }]
|
|
263
|
+
}]
|
|
264
|
+
```
|
|
170
265
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
266
|
+
- `codeSigningCertificate`: the certificate (simple mode) or root certificate (chain mode)
|
|
267
|
+
to trust. Only the public certificate is embedded; a file containing a private key is
|
|
268
|
+
refused at prebuild.
|
|
269
|
+
- `codeSigningIncludeManifestResponseCertificateChain`: `true` for chain mode.
|
|
270
|
+
- `additionalTrustedCertificates`: optional pre-provisioned keys, so a simple mode key can
|
|
271
|
+
be rotated without a new binary.
|
|
272
|
+
- `codeSigningAllowUnsignedManifests` is not supported.
|
|
273
|
+
|
|
274
|
+
With a certificate configured, `checkForUpdate()` asks the native module to fetch and
|
|
275
|
+
verify the manifest (`fetchManifest`), and `downloadUpdate()` passes only the opaque token
|
|
276
|
+
of a verified manifest, so JavaScript cannot hand the downloader an unverified asset list.
|
|
277
|
+
Every asset must carry a hash. Signed updates are also bound to the configured app,
|
|
278
|
+
platform and runtime version, and an update older than the newest one this device accepted
|
|
279
|
+
for that runtime is refused unless the device accepted that update before. Channel
|
|
280
|
+
rollbacks to an update the device already ran therefore still apply, but a device that never
|
|
281
|
+
had the older update stays where it is; to roll those devices back, publish the old code
|
|
282
|
+
again as a new update. A `createdAt` more than ten minutes in the future is never recorded,
|
|
283
|
+
so a wrong developer clock cannot lock a runtime.
|
|
284
|
+
|
|
285
|
+
A rejected update never changes the running code: `checkForUpdate()` returns
|
|
286
|
+
`{ available: false }` and the reason is reported to Ubriot (`missing-signature`,
|
|
287
|
+
`bad-signature`, `untrusted-certificate`, `expired-certificate`, `binding-mismatch`,
|
|
288
|
+
`parse-error`). If a binary has a certificate but its native module is older than 0.3.0,
|
|
289
|
+
updates fail closed. `configure({ ..., requireCodeSigning: true })` forces the same fail
|
|
290
|
+
closed behaviour from JavaScript; set it only in binaries that embed a certificate.
|
|
291
|
+
|
|
292
|
+
Apps without these options behave exactly as before.
|
package/android/build.gradle
CHANGED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
package dev.ubriot.updates
|
|
2
|
+
|
|
3
|
+
import java.security.Signature
|
|
4
|
+
import java.security.cert.CertificateExpiredException
|
|
5
|
+
import java.security.cert.CertificateFactory
|
|
6
|
+
import java.security.cert.CertificateNotYetValidException
|
|
7
|
+
import java.security.cert.X509Certificate
|
|
8
|
+
import java.util.Date
|
|
9
|
+
import kotlin.math.min
|
|
10
|
+
|
|
11
|
+
object UbriotCodeSigning {
|
|
12
|
+
const val ALGORITHM = "rsa-v1_5-sha256"
|
|
13
|
+
const val DEFAULT_KEYID = "root"
|
|
14
|
+
private const val CODE_SIGNING_EKU = "1.3.6.1.5.5.7.3.3"
|
|
15
|
+
private const val BEGIN = "-----BEGIN CERTIFICATE-----"
|
|
16
|
+
private const val END = "-----END CERTIFICATE-----"
|
|
17
|
+
private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1000L
|
|
18
|
+
private val ISO_INSTANT = Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?Z$")
|
|
19
|
+
|
|
20
|
+
data class TrustedCertificate(val pem: String, val keyid: String)
|
|
21
|
+
|
|
22
|
+
data class Config(val trusted: List<TrustedCertificate>, val includeChain: Boolean)
|
|
23
|
+
|
|
24
|
+
class ParsedResponse(val manifestBody: ByteArray, val signature: String?, val certificateChain: String?)
|
|
25
|
+
|
|
26
|
+
sealed class Outcome {
|
|
27
|
+
class Verified(val body: ByteArray) : Outcome()
|
|
28
|
+
class Rejected(val reason: String, val detail: String) : Outcome()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class RejectedException(val reason: String, message: String) : Exception(message)
|
|
32
|
+
|
|
33
|
+
data class SignatureHeader(val sig: String, val keyid: String, val alg: String)
|
|
34
|
+
|
|
35
|
+
fun expectSignatureHeader(keyid: String): String = "sig, keyid=\"${escape(keyid)}\", alg=\"$ALGORITHM\""
|
|
36
|
+
|
|
37
|
+
private fun escape(value: String) = value.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
38
|
+
|
|
39
|
+
fun parseDictionaryStrings(header: String): Map<String, String?> {
|
|
40
|
+
val out = LinkedHashMap<String, String?>()
|
|
41
|
+
var i = 0
|
|
42
|
+
val n = header.length
|
|
43
|
+
fun skipSpaces() { while (i < n && (header[i] == ' ' || header[i] == '\t')) i++ }
|
|
44
|
+
while (true) {
|
|
45
|
+
skipSpaces()
|
|
46
|
+
if (i >= n) break
|
|
47
|
+
val keyStart = i
|
|
48
|
+
while (i < n && (header[i].isLetterOrDigit() || header[i] == '_' || header[i] == '-' || header[i] == '.' || header[i] == '*')) i++
|
|
49
|
+
val key = header.substring(keyStart, i)
|
|
50
|
+
if (key.isEmpty()) throw IllegalArgumentException("Malformed structured header at offset $i")
|
|
51
|
+
var value: String? = null
|
|
52
|
+
if (i < n && header[i] == '=') {
|
|
53
|
+
i++
|
|
54
|
+
if (i < n && header[i] == '"') {
|
|
55
|
+
i++
|
|
56
|
+
val sb = StringBuilder()
|
|
57
|
+
var closed = false
|
|
58
|
+
while (i < n) {
|
|
59
|
+
val c = header[i++]
|
|
60
|
+
if (c == '\\') {
|
|
61
|
+
if (i >= n) throw IllegalArgumentException("Unterminated escape")
|
|
62
|
+
sb.append(header[i++])
|
|
63
|
+
} else if (c == '"') {
|
|
64
|
+
closed = true
|
|
65
|
+
break
|
|
66
|
+
} else {
|
|
67
|
+
sb.append(c)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!closed) throw IllegalArgumentException("Unterminated string")
|
|
71
|
+
value = sb.toString()
|
|
72
|
+
} else {
|
|
73
|
+
while (i < n && header[i] != ',' && header[i] != ';') i++
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
while (i < n && header[i] != ',') {
|
|
77
|
+
if (header[i] == '"') throw IllegalArgumentException("Unexpected quote")
|
|
78
|
+
i++
|
|
79
|
+
}
|
|
80
|
+
out[key] = value
|
|
81
|
+
if (i < n && header[i] == ',') i++
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun parseSignatureHeader(header: String): SignatureHeader {
|
|
87
|
+
val members = parseDictionaryStrings(header)
|
|
88
|
+
val sig = members["sig"] ?: throw IllegalArgumentException("Structured field sig not found in expo-signature header")
|
|
89
|
+
return SignatureHeader(sig, members["keyid"] ?: DEFAULT_KEYID, members["alg"] ?: ALGORITHM)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fun splitCertificates(pem: String): List<String> {
|
|
93
|
+
val out = mutableListOf<String>()
|
|
94
|
+
var index = 0
|
|
95
|
+
while (true) {
|
|
96
|
+
val start = pem.indexOf(BEGIN, index)
|
|
97
|
+
val end = pem.indexOf(END, index)
|
|
98
|
+
if (start == -1 || end == -1 || end < start) break
|
|
99
|
+
out.add(pem.substring(start, end + END.length))
|
|
100
|
+
index = end + END.length
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private fun indexOf(haystack: ByteArray, needle: ByteArray, from: Int): Int {
|
|
106
|
+
if (needle.isEmpty()) return from
|
|
107
|
+
var i = from
|
|
108
|
+
while (i <= haystack.size - needle.size) {
|
|
109
|
+
var match = true
|
|
110
|
+
for (j in needle.indices) {
|
|
111
|
+
if (haystack[i + j] != needle[j]) {
|
|
112
|
+
match = false
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (match) return i
|
|
117
|
+
i++
|
|
118
|
+
}
|
|
119
|
+
return -1
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private fun dispositionName(value: String?): String? {
|
|
123
|
+
if (value == null) return null
|
|
124
|
+
val match = Regex("name\\s*=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE).find(value) ?: return null
|
|
125
|
+
return match.groupValues[1]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fun parseResponse(contentType: String?, headerSignature: String?, body: ByteArray): ParsedResponse {
|
|
129
|
+
val type = contentType?.lowercase() ?: ""
|
|
130
|
+
if (!type.startsWith("multipart/")) return ParsedResponse(body, headerSignature, null)
|
|
131
|
+
val boundary = Regex("boundary=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE).find(contentType!!)?.groupValues?.get(1)
|
|
132
|
+
?: throw RejectedException("parse-error", "multipart response without boundary")
|
|
133
|
+
val delimiter = "--$boundary".toByteArray(Charsets.UTF_8)
|
|
134
|
+
val separator = "\r\n--$boundary".toByteArray(Charsets.UTF_8)
|
|
135
|
+
val headerEnd = "\r\n\r\n".toByteArray(Charsets.UTF_8)
|
|
136
|
+
var manifest: ByteArray? = null
|
|
137
|
+
var signature: String? = null
|
|
138
|
+
var chain: String? = null
|
|
139
|
+
var index = indexOf(body, delimiter, 0)
|
|
140
|
+
while (index != -1) {
|
|
141
|
+
val after = index + delimiter.size
|
|
142
|
+
if (after + 2 <= body.size && body[after] == '-'.code.toByte() && body[after + 1] == '-'.code.toByte()) break
|
|
143
|
+
val headersStart = after + 2
|
|
144
|
+
val headersEnd = indexOf(body, headerEnd, headersStart)
|
|
145
|
+
if (headersEnd == -1) throw RejectedException("parse-error", "malformed multipart part")
|
|
146
|
+
val next = indexOf(body, separator, headersEnd)
|
|
147
|
+
if (next == -1) throw RejectedException("parse-error", "unterminated multipart part")
|
|
148
|
+
val headers = HashMap<String, String>()
|
|
149
|
+
String(body, headersStart, headersEnd - headersStart, Charsets.UTF_8).split("\r\n").forEach { line ->
|
|
150
|
+
val colon = line.indexOf(':')
|
|
151
|
+
if (colon > 0) headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim()
|
|
152
|
+
}
|
|
153
|
+
val partBody = body.copyOfRange(headersEnd + headerEnd.size, next)
|
|
154
|
+
when (dispositionName(headers["content-disposition"])) {
|
|
155
|
+
"manifest" -> {
|
|
156
|
+
manifest = partBody
|
|
157
|
+
signature = headers["expo-signature"]
|
|
158
|
+
}
|
|
159
|
+
"certificate_chain" -> chain = String(partBody, Charsets.UTF_8)
|
|
160
|
+
}
|
|
161
|
+
index = next + 2
|
|
162
|
+
}
|
|
163
|
+
val manifestBody = manifest ?: throw RejectedException("parse-error", "multipart response without a manifest part")
|
|
164
|
+
return ParsedResponse(manifestBody, signature, chain)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fun certificate(pem: String): X509Certificate {
|
|
168
|
+
return CertificateFactory.getInstance("X.509").generateCertificate(pem.byteInputStream()) as X509Certificate
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun X509Certificate.isCodeSigning(): Boolean {
|
|
172
|
+
val usage = keyUsage
|
|
173
|
+
val extended = extendedKeyUsage
|
|
174
|
+
return usage != null && usage.isNotEmpty() && usage[0] && extended != null && extended.contains(CODE_SIGNING_EKU)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun X509Certificate.isCertificateAuthority(): Boolean {
|
|
178
|
+
val usage = keyUsage
|
|
179
|
+
return basicConstraints > -1 && usage != null && usage.size > 5 && usage[5]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private fun validateChain(chain: List<X509Certificate>, now: Date): X509Certificate {
|
|
183
|
+
if (chain.isEmpty()) throw RejectedException("untrusted-certificate", "No certificates")
|
|
184
|
+
for (cert in chain) {
|
|
185
|
+
try {
|
|
186
|
+
cert.checkValidity(now)
|
|
187
|
+
} catch (error: CertificateExpiredException) {
|
|
188
|
+
throw RejectedException("expired-certificate", "Certificate expired")
|
|
189
|
+
} catch (error: CertificateNotYetValidException) {
|
|
190
|
+
throw RejectedException("expired-certificate", "Certificate not yet valid")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
for (i in 0 until chain.size - 1) {
|
|
195
|
+
if (chain[i].issuerX500Principal != chain[i + 1].subjectX500Principal) throw RejectedException("untrusted-certificate", "Certificates do not chain")
|
|
196
|
+
chain[i].verify(chain[i + 1].publicKey)
|
|
197
|
+
}
|
|
198
|
+
val root = chain.last()
|
|
199
|
+
if (root.issuerX500Principal != root.subjectX500Principal) throw RejectedException("untrusted-certificate", "Root certificate not self-signed")
|
|
200
|
+
root.verify(root.publicKey)
|
|
201
|
+
} catch (error: RejectedException) {
|
|
202
|
+
throw error
|
|
203
|
+
} catch (error: Exception) {
|
|
204
|
+
throw RejectedException("untrusted-certificate", "Certificate chain does not verify")
|
|
205
|
+
}
|
|
206
|
+
if (chain.size > 1) {
|
|
207
|
+
val root = chain.last()
|
|
208
|
+
if (!root.isCertificateAuthority()) throw RejectedException("untrusted-certificate", "Root certificate must be a certificate authority")
|
|
209
|
+
var maxPathLength = root.basicConstraints
|
|
210
|
+
for (i in chain.size - 2 downTo 1) {
|
|
211
|
+
val cert = chain[i]
|
|
212
|
+
if (!cert.isCertificateAuthority()) throw RejectedException("untrusted-certificate", "Intermediate certificate must be a certificate authority")
|
|
213
|
+
if (maxPathLength <= 0) throw RejectedException("untrusted-certificate", "pathLenConstraint violated")
|
|
214
|
+
maxPathLength = min(cert.basicConstraints, maxPathLength - 1)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
val leaf = chain.first()
|
|
218
|
+
if (!leaf.isCodeSigning()) throw RejectedException("untrusted-certificate", "Leaf is not a code signing certificate")
|
|
219
|
+
return leaf
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private fun decodeBase64(input: String): ByteArray {
|
|
223
|
+
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
224
|
+
val clean = input.filter { !it.isWhitespace() }.trimEnd('=')
|
|
225
|
+
val out = java.io.ByteArrayOutputStream()
|
|
226
|
+
var buffer = 0
|
|
227
|
+
var bits = 0
|
|
228
|
+
for (c in clean) {
|
|
229
|
+
val value = alphabet.indexOf(c)
|
|
230
|
+
if (value < 0) throw IllegalArgumentException("Invalid base64")
|
|
231
|
+
buffer = (buffer shl 6) or value
|
|
232
|
+
bits += 6
|
|
233
|
+
if (bits >= 8) {
|
|
234
|
+
bits -= 8
|
|
235
|
+
out.write((buffer shr bits) and 0xff)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out.toByteArray()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fun verify(config: Config, response: ParsedResponse, now: Date = Date()): Outcome {
|
|
242
|
+
return try {
|
|
243
|
+
val header = response.signature ?: throw RejectedException("missing-signature", "No expo-signature")
|
|
244
|
+
val info = try {
|
|
245
|
+
parseSignatureHeader(header)
|
|
246
|
+
} catch (error: Exception) {
|
|
247
|
+
throw RejectedException("bad-signature", "Malformed expo-signature")
|
|
248
|
+
}
|
|
249
|
+
if (info.alg != ALGORITHM) throw RejectedException("bad-signature", "Unsupported algorithm")
|
|
250
|
+
val leaf = if (config.includeChain) {
|
|
251
|
+
val served = splitCertificates(response.certificateChain ?: "")
|
|
252
|
+
var lastError: RejectedException? = null
|
|
253
|
+
var accepted: X509Certificate? = null
|
|
254
|
+
for (anchor in config.trusted) {
|
|
255
|
+
try {
|
|
256
|
+
accepted = validateChain((served + anchor.pem).map { certificate(it) }, now)
|
|
257
|
+
break
|
|
258
|
+
} catch (error: RejectedException) {
|
|
259
|
+
lastError = error
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
accepted ?: throw (lastError ?: RejectedException("untrusted-certificate", "No trusted root"))
|
|
263
|
+
} else {
|
|
264
|
+
val anchor = config.trusted.firstOrNull { it.keyid == info.keyid }
|
|
265
|
+
?: throw RejectedException("untrusted-certificate", "keyid ${info.keyid} is not trusted by this binary")
|
|
266
|
+
validateChain(listOf(certificate(anchor.pem)), now)
|
|
267
|
+
}
|
|
268
|
+
val signatureBytes = try {
|
|
269
|
+
decodeBase64(info.sig)
|
|
270
|
+
} catch (error: Exception) {
|
|
271
|
+
throw RejectedException("bad-signature", "Signature is not base64")
|
|
272
|
+
}
|
|
273
|
+
val valid = try {
|
|
274
|
+
Signature.getInstance("SHA256withRSA").run {
|
|
275
|
+
initVerify(leaf.publicKey)
|
|
276
|
+
update(response.manifestBody)
|
|
277
|
+
verify(signatureBytes)
|
|
278
|
+
}
|
|
279
|
+
} catch (error: Exception) {
|
|
280
|
+
false
|
|
281
|
+
}
|
|
282
|
+
if (!valid) throw RejectedException("bad-signature", "Incorrect signature")
|
|
283
|
+
Outcome.Verified(response.manifestBody)
|
|
284
|
+
} catch (error: RejectedException) {
|
|
285
|
+
Outcome.Rejected(error.reason, error.message ?: error.reason)
|
|
286
|
+
} catch (error: Exception) {
|
|
287
|
+
Outcome.Rejected("untrusted-certificate", error.message ?: "Certificate error")
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
fun checkBindings(
|
|
292
|
+
expectedRuntime: String,
|
|
293
|
+
configuredApp: String,
|
|
294
|
+
manifestRuntime: String?,
|
|
295
|
+
boundAppId: String?,
|
|
296
|
+
boundSlugs: List<String>?,
|
|
297
|
+
manifestId: String,
|
|
298
|
+
createdAt: String?,
|
|
299
|
+
newestAcceptedCreatedAt: String?,
|
|
300
|
+
allowedIds: Set<String>,
|
|
301
|
+
devicePlatform: String,
|
|
302
|
+
boundPlatform: String?,
|
|
303
|
+
): String? {
|
|
304
|
+
if (manifestRuntime != expectedRuntime) return "runtime version ${manifestRuntime ?: "missing"} does not match $expectedRuntime"
|
|
305
|
+
if (boundPlatform != null && boundPlatform != devicePlatform) return "manifest was built for $boundPlatform"
|
|
306
|
+
if (boundAppId != null || boundSlugs != null) {
|
|
307
|
+
val matches = boundAppId == configuredApp || (boundSlugs?.contains(configuredApp) == true)
|
|
308
|
+
if (!matches) return "manifest is bound to another app"
|
|
309
|
+
}
|
|
310
|
+
if (createdAt != null && newestAcceptedCreatedAt != null && manifestId !in allowedIds &&
|
|
311
|
+
ISO_INSTANT.matches(createdAt) && ISO_INSTANT.matches(newestAcceptedCreatedAt) && normalizeInstant(createdAt) < normalizeInstant(newestAcceptedCreatedAt)
|
|
312
|
+
) {
|
|
313
|
+
return "manifest is older than one this device already accepted"
|
|
314
|
+
}
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fun laterInstant(current: String?, candidate: String?, now: Date = Date()): String? {
|
|
319
|
+
if (candidate == null || !ISO_INSTANT.matches(candidate)) return current
|
|
320
|
+
val limit = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).apply {
|
|
321
|
+
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
|
322
|
+
}.format(Date(now.time + MAX_CLOCK_SKEW_MS))
|
|
323
|
+
if (normalizeInstant(candidate) > normalizeInstant(limit)) return current
|
|
324
|
+
if (current == null || !ISO_INSTANT.matches(current)) return candidate
|
|
325
|
+
return if (normalizeInstant(candidate) > normalizeInstant(current)) candidate else current
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private fun normalizeInstant(value: String): String {
|
|
329
|
+
val dot = value.indexOf('.')
|
|
330
|
+
if (dot == -1) return value.dropLast(1) + ".000000000Z"
|
|
331
|
+
val fraction = value.substring(dot + 1, value.length - 1).padEnd(9, '0')
|
|
332
|
+
return value.substring(0, dot) + "." + fraction + "Z"
|
|
333
|
+
}
|
|
334
|
+
}
|