ubriot-updates-rn 0.2.0 → 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 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
- ## Signature verification
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
- Manifest signature verification is stubbed (`verifyManifestSignature` currently returns
227
- `true`) and structured as a single async gate in `checkForUpdate`, ready to enforce a
228
- signed-manifest policy later.
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.
@@ -12,6 +12,7 @@ android {
12
12
  defaultConfig {
13
13
  minSdkVersion safeExtGet("minSdkVersion", 24)
14
14
  targetSdkVersion safeExtGet("targetSdkVersion", 36)
15
+ consumerProguardFiles "consumer-rules.pro"
15
16
  }
16
17
 
17
18
  buildFeatures {
@@ -0,0 +1,4 @@
1
+ -keep class dev.ubriot.updates.** { *; }
2
+ -keepclassmembers class * {
3
+ @com.facebook.react.bridge.ReactMethod <methods>;
4
+ }
@@ -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
+ }