ubriot-updates-rn 0.2.0 → 2.0.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 +68 -4
- 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 +322 -5
- package/code-signing.js +1 -0
- package/dist/client.d.ts +22 -1
- package/dist/client.js +68 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/manifest-headers.d.ts +1 -0
- package/ios/UbriotCodeSigning.h +42 -0
- package/ios/UbriotCodeSigning.m +521 -0
- package/ios/UbriotUpdates.m +309 -5
- package/package.json +6 -5
- package/plugin/__tests__/code-signing.test.js +67 -0
- package/plugin/__tests__/transforms.test.js +106 -0
- package/plugin/code-signing-config.js +75 -0
- package/plugin/transforms.js +22 -3
- package/plugin/withUbriotCodeSigning.js +24 -0
- package/plugin/withUbriotUpdates.js +22 -2
- package/src/client.ts +74 -17
- package/src/index.ts +4 -1
- package/src/manifest-headers.ts +1 -0
package/README.md
CHANGED
|
@@ -20,6 +20,12 @@ native + JS runtime that consumes those updates in a bare app.
|
|
|
20
20
|
followed and each file's SHA-256 is checked when the manifest carries a `hash`. The
|
|
21
21
|
update is only staged once every file is present and verified; any failure discards
|
|
22
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.
|
|
23
29
|
- Resolves the bundle's `require('./font.ttf')` / `require('./image.png')` references to
|
|
24
30
|
the downloaded files (matched by content hash), falling back to the copy embedded in
|
|
25
31
|
the native build for anything the update did not ship.
|
|
@@ -142,6 +148,20 @@ native build the new JS falls back to the old bundle-only download. Replace any
|
|
|
142
148
|
screen. If you added your own asset source transformer as a workaround for missing OTA
|
|
143
149
|
assets, remove it, or register it after importing this package.
|
|
144
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`.
|
|
164
|
+
|
|
145
165
|
## Native integration
|
|
146
166
|
|
|
147
167
|
Autolinking registers the native module. You only need to point the app at the resolved
|
|
@@ -221,8 +241,52 @@ The `runtimeVersion` you pass here MUST match the `runtimeVersion` passed to
|
|
|
221
241
|
`configure()`. Only JS-only changes can ship over the air; anything touching native code
|
|
222
242
|
requires a new store/TestFlight build with a bumped runtime version.
|
|
223
243
|
|
|
224
|
-
##
|
|
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
|
+
```
|
|
225
265
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|