@fireota/react-native 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +254 -119
- package/android/src/main/java/dev/fireota/reactnative/FireOTAModule.kt +21 -0
- package/fireota-react-native.podspec +1 -0
- package/ios/FireOTA.m +34 -0
- package/package.json +1 -4
- package/src/index.ts +101 -5
package/README.md
CHANGED
|
@@ -1,119 +1,254 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Sujay-Dynamisch/FireOTA/main/public/logo-mark.png" alt="FireOTA" width="96" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">FireOTA</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Over-the-air updates for React Native — served from <strong>your own Firebase project</strong>.<br />
|
|
9
|
+
Staged rollouts, checksum verification, automatic rollback, audit logs and a CI-ready CLI.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.npmjs.com/package/@fireota/react-native"><img alt="@fireota/react-native" src="https://img.shields.io/npm/v/@fireota/react-native?label=%40fireota%2Freact-native" /></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/@fireota/cli"><img alt="@fireota/cli" src="https://img.shields.io/npm/v/@fireota/cli?label=%40fireota%2Fcli" /></a>
|
|
15
|
+
<img alt="license" src="https://img.shields.io/badge/license-MIT-blue" />
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What it does
|
|
21
|
+
|
|
22
|
+
Ship JavaScript fixes and features to your React Native app **without a store review**:
|
|
23
|
+
|
|
24
|
+
1. `npx fireota publish` bundles your JS (Hermes bytecode + images) and uploads it to **your** Firebase Storage bucket.
|
|
25
|
+
2. FireOTA records the release — version, runtime, SHA-256, channel, rollout — and writes every action to an audit log.
|
|
26
|
+
3. The SDK in your app checks for a compatible release on launch, downloads it **directly from your bucket**, verifies the checksum, and applies it on the next start.
|
|
27
|
+
4. If the new bundle crashes before starting, the device rolls back to the previous bundle automatically and reports it.
|
|
28
|
+
|
|
29
|
+
You own the artifacts and pay Google directly for storage and bandwidth. FireOTA never proxies downloads.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
| | |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| **Releases** | Strict state machine (draft → ready → published → paused / rolled back / archived), atomic publish and rollback |
|
|
36
|
+
| **Rollouts** | Deterministic per-device buckets, 0–100 %, per-platform channel targets (Android and iOS independent) |
|
|
37
|
+
| **Safety** | Server-side SHA-256, exact runtime matching, native-version constraints, automatic pause on high failure rates |
|
|
38
|
+
| **Teams** | Organizations, roles (owner, admin, release manager, developer, viewer), Supabase Row Level Security |
|
|
39
|
+
| **Integrations** | Scoped API keys, CI/CD API, HMAC-signed webhooks with retries, notifications, analytics |
|
|
40
|
+
| **Firebase** | Google OAuth (PKCE) per customer project, encrypted refresh tokens, live Storage write verification |
|
|
41
|
+
|
|
42
|
+
## Repository layout
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
src/ Web dashboard + API (TanStack Start, React, Tailwind)
|
|
46
|
+
routes/ Pages and HTTP routes (/api/public/v1/*, /health, /docs)
|
|
47
|
+
lib/backend/ Release engine, OTA resolver, Firebase adapter, RBAC, webhooks
|
|
48
|
+
lib/api/ Server functions used by the dashboard
|
|
49
|
+
components/docs/ Documentation site content (/docs)
|
|
50
|
+
supabase/migrations/ Database schema, RLS policies and transactional functions
|
|
51
|
+
packages/react-native/ @fireota/react-native — the app SDK (Kotlin + Objective-C)
|
|
52
|
+
packages/cli/ @fireota/cli — bundle, upload and publish from a terminal or CI
|
|
53
|
+
tests/ Integration, end-to-end and package-format tests
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick start (local)
|
|
57
|
+
|
|
58
|
+
**Prerequisites:** Node.js 20+, a [Supabase](https://supabase.com) project, a Google Cloud OAuth client.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
git clone https://github.com/Sujay-Dynamisch/FireOTA.git
|
|
62
|
+
cd FireOTA
|
|
63
|
+
npm install
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 1. Environment
|
|
67
|
+
|
|
68
|
+
Create `.env` in the project root (never commit it):
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
# Supabase -> Project Settings -> API
|
|
72
|
+
SUPABASE_URL="https://<project-ref>.supabase.co"
|
|
73
|
+
SUPABASE_PUBLISHABLE_KEY="sb_publishable_..."
|
|
74
|
+
VITE_SUPABASE_URL="https://<project-ref>.supabase.co"
|
|
75
|
+
VITE_SUPABASE_PUBLISHABLE_KEY="sb_publishable_..."
|
|
76
|
+
SUPABASE_PROJECT_ID="<project-ref>"
|
|
77
|
+
VITE_SUPABASE_PROJECT_ID="<project-ref>"
|
|
78
|
+
|
|
79
|
+
# Server-only secrets
|
|
80
|
+
SUPABASE_SERVICE_ROLE_KEY="..." # Supabase -> API Keys -> Secret key (bypasses RLS)
|
|
81
|
+
FIREOTA_ENCRYPTION_KEY="..." # random; encrypts Google tokens and webhook secrets — never change it
|
|
82
|
+
FIREOTA_CRON_SECRET="..." # bearer token for the maintenance endpoint
|
|
83
|
+
|
|
84
|
+
# Google Cloud -> APIs & Services -> Credentials -> OAuth client (Web application)
|
|
85
|
+
GOOGLE_CLIENT_ID="..."
|
|
86
|
+
GOOGLE_CLIENT_SECRET="..."
|
|
87
|
+
GOOGLE_REDIRECT_URI="http://localhost:8080/api/public/v1/firebase/callback"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Generate random secrets with:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 2. Database
|
|
97
|
+
|
|
98
|
+
Apply every file in `supabase/migrations/` in order — with the Supabase CLI (`supabase link` then `supabase db push`) or by running them in the SQL editor.
|
|
99
|
+
|
|
100
|
+
### 3. Auth and Google OAuth
|
|
101
|
+
|
|
102
|
+
- **Supabase → Authentication → URL Configuration:** Site URL `http://localhost:8080`; add redirect URLs `http://localhost:8080/dashboard`, `/onboarding`, `/reset-password`.
|
|
103
|
+
- **Supabase → Authentication → Providers → Google:** enable it with your OAuth client for "Continue with Google".
|
|
104
|
+
- **Google Cloud → OAuth client → Authorised redirect URIs:** add `http://localhost:8080/api/public/v1/firebase/callback` and `https://<project-ref>.supabase.co/auth/v1/callback`.
|
|
105
|
+
|
|
106
|
+
### 4. Run
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npm run dev # http://localhost:8080
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Open `/signup`, create your workspace, connect a Firebase project (Cloud Storage must be set up) and register your app.
|
|
113
|
+
|
|
114
|
+
## Deploy to Vercel
|
|
115
|
+
|
|
116
|
+
The repository includes [`vercel.json`](vercel.json). Nitro detects Vercel and builds a Node.js function plus static assets.
|
|
117
|
+
|
|
118
|
+
1. Import the repository in Vercel (no framework preset needed).
|
|
119
|
+
2. Add every variable from the `.env` section under **Project → Settings → Environment Variables**, with production URLs:
|
|
120
|
+
- `GOOGLE_REDIRECT_URI=https://<your-domain>/api/public/v1/firebase/callback`
|
|
121
|
+
- `CRON_SECRET=<random>` — Vercel sends it to the maintenance cron automatically.
|
|
122
|
+
3. Deploy, then update the production URLs in Supabase Auth and in the Google OAuth client.
|
|
123
|
+
4. Check `https://<your-domain>/health/ready` returns `{"status":"ok"}`.
|
|
124
|
+
|
|
125
|
+
The cron in `vercel.json` runs the maintenance job (webhook retries, OAuth state cleanup) daily, which works on the Hobby plan. On Pro, change the schedule to `*/5 * * * *` for faster webhook retries.
|
|
126
|
+
|
|
127
|
+
> **Upload size on Vercel:** Vercel Functions accept request bodies up to **4.5 MB**. Larger update packages must be published against a deployment without that limit (or a direct-to-Storage upload, on the roadmap).
|
|
128
|
+
|
|
129
|
+
## Install
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
npm install @fireota/react-native
|
|
133
|
+
npm install --save-dev @fireota/cli
|
|
134
|
+
cd ios && pod install
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Requires a bare React Native app, 0.73 or newer (Expo: run `npx expo prebuild`).
|
|
138
|
+
|
|
139
|
+
## Android
|
|
140
|
+
|
|
141
|
+
In `MainApplication.kt` (React Native 0.76+ template):
|
|
142
|
+
|
|
143
|
+
```kotlin
|
|
144
|
+
import dev.fireota.reactnative.FireOTA
|
|
145
|
+
|
|
146
|
+
override val reactHost: ReactHost by lazy {
|
|
147
|
+
getDefaultReactHost(
|
|
148
|
+
context = applicationContext,
|
|
149
|
+
packageList = PackageList(this).packages,
|
|
150
|
+
jsBundleFilePath = if (BuildConfig.DEBUG) null else FireOTA.getJSBundleFile(applicationContext),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Older templates that override `reactNativeHost`:
|
|
156
|
+
|
|
157
|
+
```kotlin
|
|
158
|
+
override fun getJSBundleFile(): String? =
|
|
159
|
+
if (BuildConfig.DEBUG) super.getJSBundleFile()
|
|
160
|
+
else FireOTA.getJSBundleFile(applicationContext) ?: super.getJSBundleFile()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## iOS
|
|
164
|
+
|
|
165
|
+
```objc
|
|
166
|
+
// AppDelegate.mm
|
|
167
|
+
#import <FireOTA.h>
|
|
168
|
+
|
|
169
|
+
- (NSURL *)bundleURL
|
|
170
|
+
{
|
|
171
|
+
#if DEBUG
|
|
172
|
+
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
|
|
173
|
+
#else
|
|
174
|
+
return [FireOTA bundleURL] ?: [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
|
|
175
|
+
#endif
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Swift: add `#import <FireOTA.h>` to the bridging header and return `FireOTA.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")` from `bundleURL()` in release builds.
|
|
180
|
+
|
|
181
|
+
## Configure
|
|
182
|
+
|
|
183
|
+
Call once, early and unconditionally (e.g. `index.js`). Reaching this call confirms a new bundle starts; a bundle that never reaches it is rolled back on the next launch.
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
import FireOTA from "@fireota/react-native";
|
|
187
|
+
|
|
188
|
+
if (!__DEV__) {
|
|
189
|
+
FireOTA.configure({
|
|
190
|
+
appId: "YOUR_APP_ID",
|
|
191
|
+
serverUrl: "https://ota.example.com",
|
|
192
|
+
runtimeVersion: "1.3.0", // must equal this store build; bump when native code changes
|
|
193
|
+
channel: "production",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
By default updates download silently and apply on the next cold start; mandatory updates restart immediately. To show your own prompt, set `checkOnAppStart: false` and use the API:
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
const result = await FireOTA.checkForUpdate();
|
|
202
|
+
if (result.updateAvailable) {
|
|
203
|
+
// show your UI with result.release.message (what changed) and result.release.id, then:
|
|
204
|
+
await FireOTA.downloadUpdate(result.release);
|
|
205
|
+
await FireOTA.installUpdate(result.release, "immediate");
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## API
|
|
210
|
+
|
|
211
|
+
| Method | Description |
|
|
212
|
+
| --- | --- |
|
|
213
|
+
| `configure(options)` | Initialize once at startup (required). |
|
|
214
|
+
| `sync()` | Check, download and install, honouring install modes. |
|
|
215
|
+
| `checkForUpdate()` | `{ updateAvailable, reason?, release? }` |
|
|
216
|
+
| `downloadUpdate(release)` | Download and verify; the running bundle is untouched. |
|
|
217
|
+
| `installUpdate(release, mode)` | `"nextRestart"` or `"immediate"`. |
|
|
218
|
+
| `reload()` | Restart now to apply a pending update. |
|
|
219
|
+
| `clearPendingUpdate()` | Drop a downloaded update that is not applied yet. |
|
|
220
|
+
| `getCurrentRelease()` | `{ releaseId, pendingReleaseId, installationId }` |
|
|
221
|
+
| `getUpdateStatus()` | Current status string. |
|
|
222
|
+
|
|
223
|
+
Options: `appId`, `serverUrl`, `runtimeVersion` (required), `channel`, `checkOnAppStart`, `installMode`, `mandatoryInstallMode`, `codeSigningCertificate`, `onStatusChange(status, detail)`.
|
|
224
|
+
|
|
225
|
+
## Versioning
|
|
226
|
+
|
|
227
|
+
Like Expo, an update is identified by its **ID**, not a version number, and belongs to exactly one **runtime version**. The runtime version describes the native code of a store build (Play Store / App Store): the app only receives updates published for the runtime it was built with, so an update can never reach a binary it is incompatible with. Bump `runtimeVersion` (and publish a new store build) whenever native code changes. Publishers may add an optional human label (`--version 1.3.1-hotfix`).
|
|
228
|
+
|
|
229
|
+
## Code signing (optional)
|
|
230
|
+
|
|
231
|
+
Prove every update was built by you. Generate a key pair with `npx fireota signing generate`, register the public key in the dashboard (app → Code signing) and embed it in the app:
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
FireOTA.configure({
|
|
235
|
+
// …
|
|
236
|
+
codeSigningCertificate: `-----BEGIN PUBLIC KEY-----
|
|
237
|
+
MIIBIjANBgkqh…
|
|
238
|
+
-----END PUBLIC KEY-----`,
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
With a certificate set, the SDK verifies the RSA-SHA256 signature natively (Android `java.security`, iOS Security framework) **before** downloading, and rejects unsigned or wrongly signed updates — even if the server or your Firebase bucket were compromised. The signature covers the package's SHA-256, the runtime version and the platform, and the download is then checked against that SHA-256.
|
|
243
|
+
|
|
244
|
+
## Publishing updates
|
|
245
|
+
|
|
246
|
+
Use [`@fireota/cli`](https://www.npmjs.com/package/@fireota/cli):
|
|
247
|
+
|
|
248
|
+
```sh
|
|
249
|
+
npx fireota publish --app YOUR_APP_ID --platform all --runtime 1.3.0 --channel production --rollout 10 --message "Fix checkout crash"
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT
|
|
@@ -2,11 +2,15 @@ package dev.fireota.reactnative
|
|
|
2
2
|
|
|
3
3
|
import android.content.Intent
|
|
4
4
|
import android.os.Process
|
|
5
|
+
import android.util.Base64
|
|
5
6
|
import com.facebook.react.bridge.Arguments
|
|
6
7
|
import com.facebook.react.bridge.Promise
|
|
7
8
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
9
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
9
10
|
import com.facebook.react.bridge.ReactMethod
|
|
11
|
+
import java.security.KeyFactory
|
|
12
|
+
import java.security.Signature
|
|
13
|
+
import java.security.spec.X509EncodedKeySpec
|
|
10
14
|
import java.util.concurrent.Executors
|
|
11
15
|
|
|
12
16
|
class FireOTAModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
|
@@ -47,6 +51,23 @@ class FireOTAModule(private val reactContext: ReactApplicationContext) : ReactCo
|
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Verifies an RSA-SHA256 (PKCS#1 v1.5) update signature against the app's embedded public key
|
|
56
|
+
* (base64 X.509 SubjectPublicKeyInfo). Any parsing problem counts as an invalid signature.
|
|
57
|
+
*/
|
|
58
|
+
@ReactMethod
|
|
59
|
+
fun verifySignature(message: String, signatureBase64: String, publicKeyBase64: String, promise: Promise) {
|
|
60
|
+
try {
|
|
61
|
+
val key = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(Base64.decode(publicKeyBase64, Base64.DEFAULT)))
|
|
62
|
+
val verifier = Signature.getInstance("SHA256withRSA")
|
|
63
|
+
verifier.initVerify(key)
|
|
64
|
+
verifier.update(message.toByteArray(Charsets.UTF_8))
|
|
65
|
+
promise.resolve(verifier.verify(Base64.decode(signatureBase64, Base64.DEFAULT)))
|
|
66
|
+
} catch (e: Throwable) {
|
|
67
|
+
promise.resolve(false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
@ReactMethod
|
|
51
72
|
fun installUpdate(releaseId: String, promise: Promise) {
|
|
52
73
|
try {
|
package/ios/FireOTA.m
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#import "FireOTA.h"
|
|
2
2
|
|
|
3
3
|
#import <CommonCrypto/CommonDigest.h>
|
|
4
|
+
#import <Security/Security.h>
|
|
4
5
|
#import <React/RCTReloadCommand.h>
|
|
5
6
|
#import <zlib.h>
|
|
6
7
|
|
|
@@ -251,6 +252,39 @@ RCT_EXPORT_METHOD(clearFailed:(RCTPromiseResolveBlock)resolve reject:(RCTPromise
|
|
|
251
252
|
resolve(nil);
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/// Verifies an RSA-SHA256 (PKCS#1 v1.5) update signature against the app's embedded public key.
|
|
256
|
+
/// publicKey is base64 PKCS#1 (the JS layer unwraps the PEM's SubjectPublicKeyInfo).
|
|
257
|
+
RCT_EXPORT_METHOD(verifySignature:(NSString *)message
|
|
258
|
+
signature:(NSString *)signatureBase64
|
|
259
|
+
publicKey:(NSString *)publicKeyBase64
|
|
260
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
261
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
262
|
+
{
|
|
263
|
+
NSData *keyData = [[NSData alloc] initWithBase64EncodedString:publicKeyBase64 options:0];
|
|
264
|
+
NSData *signature = [[NSData alloc] initWithBase64EncodedString:signatureBase64 options:0];
|
|
265
|
+
NSData *payload = [message dataUsingEncoding:NSUTF8StringEncoding];
|
|
266
|
+
if (!keyData || !signature || !payload) {
|
|
267
|
+
resolve(@NO);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
NSDictionary *attributes = @{
|
|
271
|
+
(__bridge id)kSecAttrKeyType : (__bridge id)kSecAttrKeyTypeRSA,
|
|
272
|
+
(__bridge id)kSecAttrKeyClass : (__bridge id)kSecAttrKeyClassPublic,
|
|
273
|
+
};
|
|
274
|
+
CFErrorRef error = NULL;
|
|
275
|
+
SecKeyRef key = SecKeyCreateWithData((__bridge CFDataRef)keyData, (__bridge CFDictionaryRef)attributes, &error);
|
|
276
|
+
if (!key) {
|
|
277
|
+
if (error) CFRelease(error);
|
|
278
|
+
resolve(@NO);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
Boolean valid = SecKeyVerifySignature(key, kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA256,
|
|
282
|
+
(__bridge CFDataRef)payload, (__bridge CFDataRef)signature, &error);
|
|
283
|
+
if (error) CFRelease(error);
|
|
284
|
+
CFRelease(key);
|
|
285
|
+
resolve(@(valid));
|
|
286
|
+
}
|
|
287
|
+
|
|
254
288
|
/// Reloads JavaScript; React Native asks AppDelegate.bundleURL again, which applies the pending release.
|
|
255
289
|
RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
|
|
256
290
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fireota/react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Over-the-air JavaScript updates for React Native apps, served from your own Firebase project by FireOTA.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "FireOTA",
|
|
@@ -22,9 +22,6 @@
|
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react-native": ">=0.73.0"
|
|
24
24
|
},
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"@fireota/react-native": "file:fireota-react-native-0.1.0.tgz"
|
|
27
|
-
},
|
|
28
25
|
"keywords": [
|
|
29
26
|
"react-native",
|
|
30
27
|
"ota",
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FireOTA React Native SDK.
|
|
3
3
|
*
|
|
4
|
-
* Flow: check ->
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Flow: check -> (signature verified, when code signing is on) -> download (native, SHA-256
|
|
5
|
+
* verified) -> install (pending) -> applied on the next start (or immediately for mandatory
|
|
6
|
+
* updates) -> the new bundle confirms itself on launch; a bundle that never confirms is
|
|
7
|
+
* rolled back to the previous one by the native loader.
|
|
8
|
+
*
|
|
9
|
+
* Updates are identified by their ID (like Expo) and target exactly one runtime version.
|
|
7
10
|
*/
|
|
8
11
|
import { NativeModules, Platform } from "react-native";
|
|
9
12
|
|
|
@@ -25,6 +28,11 @@ export interface FireOTAConfig {
|
|
|
25
28
|
/** Must equal the runtime version of the release; bump it whenever native code changes. */
|
|
26
29
|
runtimeVersion: string;
|
|
27
30
|
channel?: string;
|
|
31
|
+
/**
|
|
32
|
+
* PEM public key (contents of fireota-public.pem). When set, every update must carry a valid
|
|
33
|
+
* signature from the matching private key or it is rejected before download.
|
|
34
|
+
*/
|
|
35
|
+
codeSigningCertificate?: string;
|
|
28
36
|
/** Check (and download) automatically when configure() runs. Default true. */
|
|
29
37
|
checkOnAppStart?: boolean;
|
|
30
38
|
/** When a normal update is applied. Default "nextRestart". */
|
|
@@ -35,12 +43,20 @@ export interface FireOTAConfig {
|
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export interface AvailableUpdate {
|
|
46
|
+
/** The update's unique ID. */
|
|
38
47
|
id: string;
|
|
48
|
+
/** Optional human label set by the publisher ("" when none). */
|
|
39
49
|
version: string;
|
|
40
50
|
runtimeVersion: string;
|
|
41
51
|
mandatory: boolean;
|
|
42
52
|
notes: string;
|
|
53
|
+
/** What changed, as written by the publisher (same as notes). */
|
|
54
|
+
message?: string;
|
|
55
|
+
/** Shared by the Android and iOS updates published together. */
|
|
56
|
+
groupId?: string | null;
|
|
57
|
+
createdAt?: string;
|
|
43
58
|
bundle: { url: string; hash: string; size: number };
|
|
59
|
+
signature?: { value: string; keyId: string | null; algorithm: string } | null;
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
export interface CheckResult {
|
|
@@ -70,12 +86,14 @@ interface FireOTANative {
|
|
|
70
86
|
clearPending(): Promise<void>;
|
|
71
87
|
clearFailed(): Promise<void>;
|
|
72
88
|
restartApp(): Promise<void>;
|
|
89
|
+
verifySignature(message: string, signatureBase64: string, publicKeyBase64: string): Promise<boolean>;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
const native = NativeModules["FireOTA"] as FireOTANative | undefined;
|
|
76
93
|
const PROTOCOL_VERSION = "1";
|
|
77
94
|
|
|
78
|
-
let config: Required<Omit<FireOTAConfig, "onStatusChange"
|
|
95
|
+
let config: Required<Omit<FireOTAConfig, "onStatusChange" | "codeSigningCertificate">> &
|
|
96
|
+
Pick<FireOTAConfig, "onStatusChange" | "codeSigningCertificate">;
|
|
79
97
|
let status: UpdateStatus = "idle";
|
|
80
98
|
let running: Promise<CheckResult> | null = null;
|
|
81
99
|
|
|
@@ -157,9 +175,87 @@ export async function checkForUpdate(): Promise<CheckResult> {
|
|
|
157
175
|
return result;
|
|
158
176
|
}
|
|
159
177
|
|
|
160
|
-
|
|
178
|
+
// ---------- code signing ----------
|
|
179
|
+
|
|
180
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
181
|
+
|
|
182
|
+
function base64ToBytes(input: string): Uint8Array {
|
|
183
|
+
const clean = input.replace(/[^A-Za-z0-9+/]/g, "");
|
|
184
|
+
const out: number[] = [];
|
|
185
|
+
for (let i = 0; i < clean.length; i += 4) {
|
|
186
|
+
const n = [0, 1, 2, 3].map((k) => (i + k < clean.length ? B64.indexOf(clean[i + k]!) : 0));
|
|
187
|
+
const triple = (n[0]! << 18) | (n[1]! << 12) | (n[2]! << 6) | n[3]!;
|
|
188
|
+
out.push((triple >> 16) & 255);
|
|
189
|
+
if (i + 2 < clean.length) out.push((triple >> 8) & 255);
|
|
190
|
+
if (i + 3 < clean.length) out.push(triple & 255);
|
|
191
|
+
}
|
|
192
|
+
return new Uint8Array(out);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
196
|
+
let out = "";
|
|
197
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
198
|
+
const triple = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
199
|
+
out += B64[(triple >> 18) & 63]! + B64[(triple >> 12) & 63]!;
|
|
200
|
+
out += i + 1 < bytes.length ? B64[(triple >> 6) & 63]! : "=";
|
|
201
|
+
out += i + 2 < bytes.length ? B64[triple & 63]! : "=";
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Reads one DER element header; returns where its content starts and how long it is. */
|
|
207
|
+
function derElement(der: Uint8Array, offset: number) {
|
|
208
|
+
let length = der[offset + 1]!;
|
|
209
|
+
let start = offset + 2;
|
|
210
|
+
if (length & 0x80) {
|
|
211
|
+
const bytes = length & 0x7f;
|
|
212
|
+
length = 0;
|
|
213
|
+
for (let i = 0; i < bytes; i += 1) length = (length << 8) | der[start + i]!;
|
|
214
|
+
start += bytes;
|
|
215
|
+
}
|
|
216
|
+
return { tag: der[offset]!, start, end: start + length };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The certificate is a PEM "PUBLIC KEY" (X.509 SubjectPublicKeyInfo). Android accepts that
|
|
221
|
+
* directly; iOS's SecKeyCreateWithData wants the inner PKCS#1 RSAPublicKey.
|
|
222
|
+
*/
|
|
223
|
+
function publicKeyForPlatform(pem: string): string {
|
|
224
|
+
const spki = base64ToBytes(pem.replace(/-----[^-]+-----/g, ""));
|
|
225
|
+
if (Platform.OS !== "ios") return bytesToBase64(spki);
|
|
226
|
+
const outer = derElement(spki, 0); // SEQUENCE
|
|
227
|
+
const algorithm = derElement(spki, outer.start); // SEQUENCE { oid, null }
|
|
228
|
+
const bitString = derElement(spki, algorithm.end); // BIT STRING
|
|
229
|
+
if (outer.tag !== 0x30 || algorithm.tag !== 0x30 || bitString.tag !== 0x03) throw new Error("Invalid code signing certificate.");
|
|
230
|
+
return bytesToBase64(spki.slice(bitString.start + 1, bitString.end)); // skip the unused-bits byte
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Same message the CLI signs and the server verifies. */
|
|
234
|
+
function signatureMessage(runtimeVersion: string, platform: string, sha256: string) {
|
|
235
|
+
return `fireota-update-v1\n${runtimeVersion}\n${platform}\n${sha256.toLowerCase()}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function assertSigned(release: AvailableUpdate) {
|
|
239
|
+
const certificate = config.codeSigningCertificate;
|
|
240
|
+
if (!certificate) return;
|
|
241
|
+
const signature = release.signature?.value;
|
|
242
|
+
const valid = signature
|
|
243
|
+
? await requireNative().verifySignature(
|
|
244
|
+
signatureMessage(config.runtimeVersion, Platform.OS, release.bundle.hash),
|
|
245
|
+
signature,
|
|
246
|
+
publicKeyForPlatform(certificate),
|
|
247
|
+
)
|
|
248
|
+
: false;
|
|
249
|
+
if (!valid) {
|
|
250
|
+
void report("install_failed", release.id, { reason: signature ? "signature_invalid" : "unsigned" });
|
|
251
|
+
throw new Error(signature ? "The update signature is not valid for this app." : "The update is not signed; this app only accepts signed updates.");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Verifies the signature (when code signing is on), then downloads and checks SHA-256. The running bundle is untouched. */
|
|
161
256
|
export async function downloadUpdate(release: AvailableUpdate) {
|
|
162
257
|
if (!/^[0-9a-f]{64}$/i.test(release.bundle.hash)) throw new Error("The update has no valid checksum.");
|
|
258
|
+
await assertSigned(release);
|
|
163
259
|
setStatus("downloading", { releaseId: release.id });
|
|
164
260
|
void report("download_started", release.id);
|
|
165
261
|
await requireNative().downloadUpdate(release.bundle.url, release.bundle.hash.toLowerCase(), release.id);
|