@fireota/react-native 0.1.1 → 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 CHANGED
@@ -200,7 +200,7 @@ By default updates download silently and apply on the next cold start; mandatory
200
200
  ```js
201
201
  const result = await FireOTA.checkForUpdate();
202
202
  if (result.updateAvailable) {
203
- // show your UI with result.release.version / result.release.notes, then:
203
+ // show your UI with result.release.message (what changed) and result.release.id, then:
204
204
  await FireOTA.downloadUpdate(result.release);
205
205
  await FireOTA.installUpdate(result.release, "immediate");
206
206
  }
@@ -220,14 +220,33 @@ if (result.updateAvailable) {
220
220
  | `getCurrentRelease()` | `{ releaseId, pendingReleaseId, installationId }` |
221
221
  | `getUpdateStatus()` | Current status string. |
222
222
 
223
- Options: `appId`, `serverUrl`, `runtimeVersion` (required), `channel`, `checkOnAppStart`, `installMode`, `mandatoryInstallMode`, `onStatusChange(status, detail)`.
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.
224
243
 
225
244
  ## Publishing updates
226
245
 
227
246
  Use [`@fireota/cli`](https://www.npmjs.com/package/@fireota/cli):
228
247
 
229
248
  ```sh
230
- npx fireota publish --app YOUR_APP_ID --platform android --version 1.3.1 --runtime 1.3.0 --channel production --rollout 10
249
+ npx fireota publish --app YOUR_APP_ID --platform all --runtime 1.3.0 --channel production --rollout 10 --message "Fix checkout crash"
231
250
  ```
232
251
 
233
252
  ## License
@@ -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 {
@@ -16,4 +16,5 @@ Pod::Spec.new do |s|
16
16
  s.source_files = "ios/**/*.{h,m}"
17
17
  s.libraries = "z"
18
18
  s.dependency "React-Core"
19
+ s.frameworks = "Security"
19
20
  end
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.1.1",
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 -> download (native, SHA-256 verified) -> install (pending) -> applied on the
5
- * next start (or immediately for mandatory updates) -> the new bundle confirms itself on
6
- * launch; a bundle that never confirms is rolled back to the previous one by the native loader.
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">> & Pick<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
- /** Downloads and verifies (SHA-256) an update. The running bundle is untouched. */
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);