@scalebun/react-native 1.13.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.
Files changed (68) hide show
  1. package/android/build.gradle +8 -0
  2. package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
  4. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
  5. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +115 -60
  6. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
  7. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
  8. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
  9. package/dist/scalebun.full.js +127 -422
  10. package/dist/scalebun.slim.js +127 -422
  11. package/ios/Ota/OtaProtocol.swift +242 -0
  12. package/ios/Ota/OtaSlotManager.swift +26 -0
  13. package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
  14. package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
  15. package/ios/Ota/ScaleBunOtaModule.swift +125 -53
  16. package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
  17. package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
  18. package/lib/commonjs/core/config/schema.js +3 -12
  19. package/lib/commonjs/core/constants/version.js +1 -1
  20. package/lib/commonjs/features/network/NetworkFeature.js +39 -5
  21. package/lib/commonjs/features/network/thirdParty.js +90 -0
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js +108 -60
  23. package/lib/commonjs/public/ScaleBunFacade.js +5 -41
  24. package/lib/module/core/config/schema.js +3 -12
  25. package/lib/module/core/constants/version.js +1 -1
  26. package/lib/module/features/network/NetworkFeature.js +38 -4
  27. package/lib/module/features/network/thirdParty.js +83 -0
  28. package/lib/module/features/ota/OtaOrchestrator.js +108 -60
  29. package/lib/module/public/ScaleBunFacade.js +5 -41
  30. package/lib/typescript/core/config/schema.d.ts +0 -3
  31. package/lib/typescript/core/constants/version.d.ts +1 -1
  32. package/lib/typescript/features/network/index.d.ts +18 -0
  33. package/lib/typescript/features/network/thirdParty.d.ts +65 -0
  34. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +5 -8
  35. package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
  36. package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
  37. package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
  38. package/package.json +3 -13
  39. package/scalebun-react-native.podspec +4 -0
  40. package/src/core/config/schema.ts +3 -9
  41. package/src/core/constants/version.ts +1 -1
  42. package/src/features/network/NetworkFeature.ts +41 -4
  43. package/src/features/network/index.ts +18 -0
  44. package/src/features/network/thirdParty.ts +92 -0
  45. package/src/features/ota/OtaOrchestrator.ts +116 -70
  46. package/src/features/ota/OtaTypes.ts +23 -1
  47. package/src/public/ScaleBunFacade.ts +5 -53
  48. package/src/specs/NativeScaleBunOta.ts +32 -20
  49. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
  50. package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
  51. package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
  52. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
  53. package/lib/commonjs/features/ota/signature.js +0 -175
  54. package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
  55. package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
  56. package/lib/module/features/ota/crypto/loadSha512.js +0 -34
  57. package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
  58. package/lib/module/features/ota/signature.js +0 -169
  59. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
  60. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
  61. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
  62. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
  63. package/lib/typescript/features/ota/signature.d.ts +0 -81
  64. package/src/features/ota/crypto/builtinVerifier.ts +0 -257
  65. package/src/features/ota/crypto/loadEd25519.ts +0 -41
  66. package/src/features/ota/crypto/loadSha512.ts +0 -35
  67. package/src/features/ota/crypto/nativeVerifier.ts +0 -117
  68. package/src/features/ota/signature.ts +0 -206
@@ -201,12 +201,9 @@ const SHAPE: Record<string, Field> = {
201
201
  enabled: bool(false),
202
202
  checkOnForeground: bool(true),
203
203
  channelOverride: { kind: 'str', opt: true },
204
- publicSigningKey: { kind: 'str', opt: true },
205
- // Rotation support: several pinned keys, any of which may verify a
206
- // bundle. Ship a build pinning [old, new], re-sign server-side with
207
- // new, drop old next release — no emergency store submission when a
208
- // key must be replaced. Merged with publicSigningKey by the facade.
209
- publicSigningKeys: { kind: 'strArr', opt: true },
204
+ // No signing-key fields: signed releases are verified natively against
205
+ // RSA keys embedded in the app package (SB-OTA-RSA-SHA256-V1). JS must
206
+ // not supply a trusted key.
210
207
  mandatoryBlocksUi: bool(false),
211
208
  },
212
209
  },
@@ -405,9 +402,6 @@ export interface ScaleBunConfig {
405
402
  enabled: boolean;
406
403
  checkOnForeground: boolean;
407
404
  channelOverride?: string;
408
- publicSigningKey?: string;
409
- /** Additional pinned keys for rotation; unioned with publicSigningKey. */
410
- publicSigningKeys?: string[];
411
405
  mandatoryBlocksUi: boolean;
412
406
  };
413
407
  }
@@ -8,4 +8,4 @@
8
8
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
9
  * version was introduced to solve.
10
10
  */
11
- export const SDK_VERSION = '1.13.0';
11
+ export const SDK_VERSION = '2.0.0';
@@ -25,6 +25,7 @@
25
25
 
26
26
  import type { IFeature, FeatureContext } from '../../core/contracts/IFeature';
27
27
  import { installNetworkMonitoring, type NetworkMonitoringOptions } from './index';
28
+ import { isThirdPartyBeacon } from './thirdParty';
28
29
  import { reportNetworkRequest } from '../replay/integrations/network/networkAdapter';
29
30
  import { SessionManager } from '../session/SessionManager';
30
31
  import { getTrackedScreen } from '../../debug/screenTracking';
@@ -53,13 +54,32 @@ export class NetworkFeature implements IFeature {
53
54
  // self-capture loop. Apply the SAME isSdkInternalUrl filter the replay
54
55
  // branch below already uses so genuine app requests still flow through.
55
56
  const isInternal = isSdkInternalUrl(data.url, this.options.sdkBaseUrl);
57
+ /**
58
+ * Beacons and denied URLs leave NO trace, in any lane.
59
+ *
60
+ * A network request reaches three places from here: the analytics echo
61
+ * (`$network_request`), the local replay pipeline, and the backend network endpoint
62
+ * that becomes the `network_requests` row the session panel renders. Filtering one
63
+ * and not the others is how a request half-disappears and two counts of the same
64
+ * call stop agreeing -- so this is applied to all three.
65
+ *
66
+ * Kept separate from `isInternal` rather than folded into it: the SDK's own uploads
67
+ * are already excluded from two of the three lanes and DELIBERATELY still reach the
68
+ * local replay pipeline. Widening that here would be an unrelated behaviour change
69
+ * riding along on a filter.
70
+ */
71
+ const isBeaconOrDenied =
72
+ (this.options.captureThirdParty !== true &&
73
+ isThirdPartyBeacon(data.url, this.options.thirdPartyHosts)) ||
74
+ matchesDenyUrl(data.url, this.options.denyUrls);
75
+ const isExcluded = isInternal || isBeaconOrDenied;
56
76
 
57
- if (!isInternal) {
77
+ if (!isExcluded) {
58
78
  this.trackFn('$network_request', data);
59
79
  }
60
80
 
61
81
  // Bridge the raw network event into the Session Replay pipeline
62
- if (data.type === 'network') {
82
+ if (data.type === 'network' && !isBeaconOrDenied) {
63
83
  reportNetworkRequest({
64
84
  method: data.method,
65
85
  url: data.url,
@@ -82,8 +102,10 @@ export class NetworkFeature implements IFeature {
82
102
  // (correlated by sessionId+timestamp), so we no longer emit
83
103
  // NETWORK_REQUEST_* replay events here (that produced duplicate rows
84
104
  // and count divergence between replay_events and network_requests).
85
- // Skip SDK's own upload calls to prevent recursive event spam.
86
- if (!isInternal) {
105
+ // Skip SDK's own upload calls to prevent recursive event spam, and anything the
106
+ // beacon/denyUrls filter excluded -- this is the lane that becomes the
107
+ // network_requests row a person sees on the session panel.
108
+ if (!isExcluded) {
87
109
  const sessionManager = SessionManager.getExistingInstance();
88
110
  if (sessionManager) {
89
111
  const backendTransport = sessionManager.getBackendTransport();
@@ -119,6 +141,21 @@ export class NetworkFeature implements IFeature {
119
141
  }
120
142
  }
121
143
 
144
+ /**
145
+ * Substring or RegExp, the same rule as the web SDK's `matchUrl`.
146
+ *
147
+ * Spelled out rather than shared because the two SDKs have no common runtime package, and the
148
+ * rule is two lines. What must NOT differ is the beacon list, which is why that one is a
149
+ * byte-identical vendored copy with a hash gate rather than a retyped constant.
150
+ */
151
+ function matchesDenyUrl(url: string, patterns?: (string | RegExp)[]): boolean {
152
+ if (!patterns || !patterns.length || !url) return false;
153
+ for (const p of patterns) {
154
+ if (typeof p === 'string' ? url.includes(p) : p.test(url)) return true;
155
+ }
156
+ return false;
157
+ }
158
+
122
159
  // Returns true if the URL is a ScaleBun SDK internal endpoint that must not be
123
160
  // captured — doing so would cause recursive network event spam.
124
161
  function isSdkInternalUrl(url: string, sdkBaseUrl?: string): boolean {
@@ -18,6 +18,24 @@ export interface NetworkMonitoringOptions {
18
18
  redactNetwork?: boolean;
19
19
  /** SDK backend base URL — used to filter out SDK's own upload calls from capture */
20
20
  sdkBaseUrl?: string;
21
+ /**
22
+ * Never capture requests whose URL matches (substring or RegExp).
23
+ *
24
+ * Parity with the web SDK, which has had this since F-NET. Mobile had no way to exclude a
25
+ * URL at all, so an app that wanted one endpoint out of its replays had no answer.
26
+ */
27
+ denyUrls?: (string | RegExp)[];
28
+ /**
29
+ * Keep known telemetry beacons: analytics collectors, ad pixels, IP-enrichment calls.
30
+ *
31
+ * Default false — they are dropped before capture. They are not the app, they cost the
32
+ * device real battery and cellular data to upload, and a beacon that fails counts against
33
+ * the SESSION HEALTH score. Matching is by hostname only, never by path, so an app route
34
+ * called /collect is never touched. See features/network/thirdParty.ts.
35
+ */
36
+ captureThirdParty?: boolean;
37
+ /** Replace the built-in beacon host list wholesale. Comma-joined hostname suffixes. */
38
+ thirdPartyHosts?: string;
21
39
  }
22
40
 
23
41
  export function installNetworkMonitoring(
@@ -0,0 +1,92 @@
1
+ /**
2
+ * THIRD-PARTY BEACON HOSTS — the one list, and the one matcher, for every layer that drops them.
3
+ *
4
+ * WHAT THIS IS FOR
5
+ * A session on an instrumented site spends most of its network tab on traffic the app did not
6
+ * meaningfully make: analytics collectors, ad pixels, IP-enrichment calls, tag managers. It is
7
+ * captured, uploaded, stored and rendered, and none of it answers a question anyone asks a session
8
+ * replay. One real sign-in screen was 27 requests, most of them beacons.
9
+ *
10
+ * It is not only clutter. Session health scores `failedRequests` and `slowRequests`, so a tracker
11
+ * killed by an ad blocker (a transport error) or one that is simply slow moves a number that is
12
+ * supposed to describe the APP. That is the part that makes this a correctness fix rather than a
13
+ * tidy-up.
14
+ *
15
+ * WHY HOSTS ONLY, NEVER PATHS
16
+ * The obvious rules from looking at a network tab are `/collect`, `/p`, `/api/v2/pixel`. Every one
17
+ * of them is a path that a customer's own API is allowed to have, and dropping a real API call is
18
+ * far worse than keeping a beacon: the noise is visible and annoying, the missing request is
19
+ * invisible and misleading. So matching is on the HOSTNAME, with a dot boundary, and a request to
20
+ * `/collect` on the app's own origin is kept — correctly.
21
+ *
22
+ * WHY IT IS A SUFFIX LIST AND NOT A REGEX
23
+ * Two reasons, both practical. It is applied inside an SDK whose eager bundle is measured in bytes,
24
+ * and a comma-joined string of suffixes compresses to almost nothing next to an equivalent pattern
25
+ * source. And it has to be COPIED, byte for byte, into the React Native SDK and into the backend's
26
+ * ingest path — a list with no syntax is a list that cannot drift in meaning between three
27
+ * languages' worth of tooling. `third-party:parity` hashes all three copies.
28
+ *
29
+ * ZERO IMPORTS, DELIBERATELY. That is what makes the byte-identical copy possible. If an edit here
30
+ * ever needs an import, the parity gate is the thing that will say so — keep the module pure rather
31
+ * than weakening the gate.
32
+ *
33
+ * WHAT IS DELIBERATELY NOT ON THE LIST
34
+ * The rule is: a host whose ONLY purpose is to receive telemetry. Everything else stays, because a
35
+ * dropped request is invisible and a kept one is merely noisy.
36
+ *
37
+ * - Feature-flag and experiment services (LaunchDarkly, Optimizely, Split). Their RESPONSES
38
+ * change what the app does, so a failed flag fetch is an application incident and the single
39
+ * most useful request in the session.
40
+ * - Chat and support widgets (Intercom, Zendesk, Drift). A broken widget is a broken feature the
41
+ * user can see.
42
+ * - `facebook.com` as a whole. `graph.facebook.com` is an API real apps call; only the pixel
43
+ * delivery hosts are listed.
44
+ * - CDNs of any kind, even ones that mostly serve tags: they also serve application assets, and
45
+ * dropping them would hide a real asset failure.
46
+ *
47
+ * When in doubt, leave it out. `denyUrls` stays the per-app escape hatch in both SDKs, and it can
48
+ * do what this list will not.
49
+ */
50
+
51
+ /**
52
+ * Known telemetry, advertising and enrichment endpoints, as hostname suffixes.
53
+ *
54
+ * One comma-joined string rather than an array: identical meaning, materially smaller once gzipped,
55
+ * and it keeps the copy in three repos a single line to compare by eye.
56
+ */
57
+ export const THIRD_PARTY_BEACON_HOSTS =
58
+ 'google-analytics.com,googletagmanager.com,analytics.google.com,doubleclick.net,googlesyndication.com,' +
59
+ 'googleadservices.com,app-measurement.com,facebook.net,analytics.tiktok.com,' +
60
+ 'ads-twitter.com,analytics.twitter.com,ct.pinterest.com,bat.bing.com,px.ads.linkedin.com,tr.snapchat.com,' +
61
+ 'criteo.com,criteo.net,taboola.com,outbrain.com,adroll.com,adsrvr.org,quantserve.com,scorecardresearch.com,' +
62
+ 'hotjar.com,hotjar.io,clarity.ms,fullstory.com,mouseflow.com,crazyegg.com,luckyorange.com,inspectlet.com,' +
63
+ 'segment.io,mixpanel.com,amplitude.com,heap.io,heapanalytics.com,rudderstack.com,posthog.com,' +
64
+ 'matomo.cloud,plausible.io,simpleanalytics.com,statcounter.com,' +
65
+ 'appsflyer.com,adjust.com,branch.io,' +
66
+ 'ipify.org,ipinfo.io,ipapi.co,ip-api.com,geolocation-db.com,cloudflareinsights.com,' +
67
+ 'nr-data.net,browser-intake-datadoghq.com,sentry.io,bugsnag.com,rollbar.com';
68
+
69
+ /**
70
+ * True when `url` is addressed to a known beacon host.
71
+ *
72
+ * Deliberately total: an unparseable or relative URL is NOT third party. A relative URL is
73
+ * same-origin by definition — the app's own call — and guessing on garbage input would drop real
74
+ * requests for no benefit.
75
+ */
76
+ export function isThirdPartyBeacon(url: string, hosts?: string): boolean {
77
+ if (!url) return false;
78
+ let host = '';
79
+ try {
80
+ // A relative URL throws here, which is the answer we want: it is first-party.
81
+ host = new URL(url).hostname.toLowerCase();
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (!host) return false;
86
+ for (const suffix of (hosts ?? THIRD_PARTY_BEACON_HOSTS).split(',')) {
87
+ // Dot boundary, so `notevil-facebook.com` does not match `facebook.com` while
88
+ // `connect.facebook.com` does. The bare-equality arm covers the apex domain itself.
89
+ if (host === suffix || host.endsWith('.' + suffix)) return true;
90
+ }
91
+ return false;
92
+ }
@@ -14,7 +14,6 @@ import { otaEventEmitter } from './OtaEventEmitter';
14
14
  import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
15
15
  import { deviceTargetingAttributes } from './deviceAttributes';
16
16
  import { resolveInstallationId } from '../../core/id/installationId';
17
- import { verifyBundleSignature, type SignatureConfig } from './signature';
18
17
  import { detectOtaEnvironment, type OtaEnvironment } from './environment';
19
18
  import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
20
19
  import { createStorageBackend, type StorageBackend } from '../../storage/StorageBackend';
@@ -191,7 +190,6 @@ export class OtaOrchestrator {
191
190
  private isRestartRequiredState = false;
192
191
  private healthyTimer: ReturnType<typeof setTimeout> | null = null;
193
192
  private bootGuardConfig: BootGuardConfig = {};
194
- private signatureConfig: SignatureConfig | undefined;
195
193
  private environment: OtaEnvironment | null = null;
196
194
  /** One-time boot checks have run in this process (init is idempotent). */
197
195
  private initialized = false;
@@ -200,11 +198,12 @@ export class OtaOrchestrator {
200
198
  /**
201
199
  * Initialize the orchestrator. Checks for boot-guard recovery state.
202
200
  *
203
- * `signature` is optional: an app that has not adopted signing keeps working
204
- * on SHA-256 integrity alone. Once a public key IS configured, a bundle that
205
- * cannot be verified is refused rather than installed (OTA-03).
201
+ * Signature enforcement is no longer a JS concern: signed releases are
202
+ * verified and staged in NATIVE code against RSA keys embedded in the app
203
+ * package (SB-OTA-RSA-SHA256-V1), and unsigned releases keep working on
204
+ * SHA-256 integrity alone. There is nothing for the host to configure here.
206
205
  */
207
- init(config?: BootGuardConfig & { signature?: SignatureConfig }): void {
206
+ init(config?: BootGuardConfig): void {
208
207
  noThrow(() => {
209
208
  // Idempotent: init() runs its one-time checks ONCE per process. Found
210
209
  // live — a host that calls init() again after installing an update (any
@@ -215,7 +214,6 @@ export class OtaOrchestrator {
215
214
  // them. Config is still refreshed so late signature setup works.
216
215
  if (this.initialized) {
217
216
  this.bootGuardConfig = config ?? this.bootGuardConfig;
218
- this.signatureConfig = config?.signature ?? this.signatureConfig;
219
217
  return;
220
218
  }
221
219
 
@@ -259,7 +257,6 @@ export class OtaOrchestrator {
259
257
  this.initialized = true;
260
258
  this.enabled = true;
261
259
  this.bootGuardConfig = config ?? {};
262
- this.signatureConfig = config?.signature;
263
260
  __DEV__ && logger.debug(
264
261
  `[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` +
265
262
  `${this.environment.bridgeless ? ', bridgeless' : ''}` +
@@ -595,12 +592,27 @@ export class OtaOrchestrator {
595
592
  // OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
596
593
  const mobileOS = resolveMobileOS();
597
594
  if (!mobileOS) throw new Error('OTA is not supported on this platform');
595
+ // SDK trust coverage (CICDPAGE §12): report which trusted key IDs are
596
+ // embedded in this binary so the dashboard can measure key-rotation
597
+ // adoption. Telemetry only — the server never trusts a key a device claims.
598
+ // Defensive: optional native method, never let it break a check.
599
+ let trustedKeyIds: string[] | undefined;
600
+ try {
601
+ const raw = NativeScaleBunOta?.getTrustedKeyIds?.();
602
+ if (raw) {
603
+ const arr = JSON.parse(raw);
604
+ if (Array.isArray(arr) && arr.length) trustedKeyIds = arr.slice(0, 16);
605
+ }
606
+ } catch {
607
+ /* trust reporting is best-effort — never break the update check */
608
+ }
598
609
  const payload: OtaCheckRequest = {
599
610
  appVersion: requestParams.appVersion,
600
611
  platform: mobileOS,
601
612
  installationId: requestParams.installationId,
602
613
  currentBundleId: this.currentBundle?.id,
603
614
  currentBundleHash: this.currentBundle?.sha256,
615
+ trustedKeyIds,
604
616
  channelName: requestParams.channelName,
605
617
  // Host-supplied country wins; otherwise the cached edge-Worker lookup
606
618
  // fills in (see geoCountry.ts). Undefined when neither knows — the
@@ -747,29 +759,12 @@ export class OtaOrchestrator {
747
759
  version: bundle.version,
748
760
  });
749
761
 
750
- // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
751
- // Before anything touches the disk. SHA-256 proves the bytes arrived
752
- // intact; only the signature proves they came from you. Checking after
753
- // staging would mean writing unverified code to the slot directory first.
754
- const signatureOutcome = await verifyBundleSignature(
755
- bundle.sha256,
756
- bundle.signature,
757
- this.signatureConfig,
758
- );
759
- if (!signatureOutcome.ok) {
760
- otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
761
- releaseId: bundle.releaseId,
762
- error: `Signature check failed: ${signatureOutcome.reason}`,
763
- version: bundle.version,
764
- });
765
- return {
766
- status: 'ERROR',
767
- error: `Bundle signature check failed (${signatureOutcome.reason})`,
768
- bundle,
769
- };
770
- }
771
-
772
762
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
763
+ // Authenticity is enforced NATIVELY now (see the envelope branch below):
764
+ // SHA-256 proves the bytes arrived intact, and for signed releases the
765
+ // RSA signature — checked in native code against keys embedded in the app
766
+ // package — proves they came from you. JS neither pins keys nor verifies
767
+ // signatures.
773
768
  otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
774
769
  releaseId: bundle.releaseId,
775
770
  version: bundle.version,
@@ -824,51 +819,102 @@ export class OtaOrchestrator {
824
819
 
825
820
  let staged = false;
826
821
 
827
- // Sprint 7: Try patch first if offered, fall back to full bundle
828
- if (bundle.patch && NativeScaleBunOta.stagePatch) {
829
- __DEV__ && logger.debug(
830
- `[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` +
831
- `${(bundle.size / 1024).toFixed(0)} KB full) trying patch…`,
832
- );
822
+ if (bundle.envelope) {
823
+ // ── NATIVE-ENFORCED VERIFY + STAGE (SB-OTA-RSA-SHA256-V1) ───────────
824
+ // Hand the whole envelope to native code, which verifies the RSA
825
+ // signature against the keys EMBEDDED in the app package, then downloads
826
+ // the artifact, enforces the SIGNED size + SHA-256, and stages it into
827
+ // the `next` slot — all outside JS. JavaScript never stages bytes it has
828
+ // not proven authentic. No patch path here: a signed offer is always a
829
+ // full verified download (native patch is a documented follow-up).
830
+ const envelopeJson = JSON.stringify({ ...bundle.envelope, downloadUrl: bundle.url });
831
+ let verdict: {
832
+ ok?: boolean;
833
+ code?: string;
834
+ message?: string;
835
+ };
833
836
  try {
834
- staged = await NativeScaleBunOta.stagePatch(
835
- bundle.patch.url,
836
- bundle.patch.sha256,
837
- bundle.sha256, // expected full-bundle hash after patching
837
+ const verdictJson = await NativeScaleBunOta.verifyAndStageRelease(envelopeJson);
838
+ verdict = JSON.parse(verdictJson);
839
+ } catch (err: any) {
840
+ // A rejected promise (or an unparseable verdict) is machinery failure,
841
+ // never a valid result — treat it as a hard failure.
842
+ verdict = { ok: false, code: 'verify_stage_threw', message: err?.message ?? String(err) };
843
+ }
844
+
845
+ if (!verdict.ok) {
846
+ unsubscribeProgress();
847
+ postProgress(0, 'FAILED');
848
+ logger.error(
849
+ `[OTA] Native verify/stage refused the release: ${verdict.code ?? 'unknown'}` +
850
+ `${verdict.message ? ` — ${verdict.message}` : ''}`,
838
851
  );
839
- if (staged) {
840
- patchUsed = true;
841
- __DEV__ && logger.debug('[OTA] Patch applied successfully');
842
- } else {
843
- logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
844
- }
845
- } catch (patchErr: any) {
846
- logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
852
+ otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
853
+ releaseId: bundle.releaseId,
854
+ // `error` is mapped onto the wire `errorCode` by deliverOtaEvents —
855
+ // carry the stable native verdict code there.
856
+ error: verdict.code ?? 'verify_and_stage_release_failed',
857
+ version: bundle.version,
858
+ });
859
+ return {
860
+ status: 'ERROR',
861
+ error: `Release verification failed (${verdict.code ?? 'unknown'})`,
862
+ bundle,
863
+ };
847
864
  }
848
- }
849
865
 
850
- // Full-bundle fallback (or primary path when no patch is available).
851
- // Retried with backoff: on a mobile network a transient drop mid-download
852
- // is the common case, and previously one failure meant waiting for the
853
- // next cold start often hours away, sometimes never.
854
- if (!staged) {
855
- try {
856
- staged = await retryWithBackoff(
857
- async () => {
858
- const ok = await NativeScaleBunOta!.stageBundle(bundle.url, bundle.sha256);
859
- if (!ok) {
860
- // Native returns a bare boolean, so a hash mismatch and a
861
- // dropped connection are indistinguishable here. Retrying a
862
- // genuine mismatch a couple of times is cheap; not retrying a
863
- // dropped connection costs the whole update.
864
- throw new Error('stageBundle returned false');
865
- }
866
- return ok;
867
- },
868
- { label: `download v${bundle.version}`, attempts: 3 },
866
+ staged = true;
867
+ } else {
868
+ // ── LEGACY / UNSIGNED (SHA-256 integrity only) ─────────────────────
869
+ // No envelope means the app embeds no trusted keys and the release is
870
+ // unsigned: keep the pre-RSA behaviour so those apps still update.
871
+
872
+ // Sprint 7: Try patch first if offered, fall back to full bundle
873
+ if (bundle.patch && NativeScaleBunOta.stagePatch) {
874
+ __DEV__ && logger.debug(
875
+ `[OTA] Patch available (${(bundle.patch.size / 1024).toFixed(0)} KB vs ` +
876
+ `${(bundle.size / 1024).toFixed(0)} KB full) trying patch…`,
869
877
  );
870
- } catch {
871
- staged = false;
878
+ try {
879
+ staged = await NativeScaleBunOta.stagePatch(
880
+ bundle.patch.url,
881
+ bundle.patch.sha256,
882
+ bundle.sha256, // expected full-bundle hash after patching
883
+ );
884
+ if (staged) {
885
+ patchUsed = true;
886
+ __DEV__ && logger.debug('[OTA] Patch applied successfully');
887
+ } else {
888
+ logger.warn('[OTA] Patch staging returned false — falling back to full bundle');
889
+ }
890
+ } catch (patchErr: any) {
891
+ logger.warn(`[OTA] Patch staging failed: ${patchErr.message} — falling back to full bundle`);
892
+ }
893
+ }
894
+
895
+ // Full-bundle fallback (or primary path when no patch is available).
896
+ // Retried with backoff: on a mobile network a transient drop mid-download
897
+ // is the common case, and previously one failure meant waiting for the
898
+ // next cold start — often hours away, sometimes never.
899
+ if (!staged) {
900
+ try {
901
+ staged = await retryWithBackoff(
902
+ async () => {
903
+ const ok = await NativeScaleBunOta!.stageBundle(bundle.url, bundle.sha256);
904
+ if (!ok) {
905
+ // Native returns a bare boolean, so a hash mismatch and a
906
+ // dropped connection are indistinguishable here. Retrying a
907
+ // genuine mismatch a couple of times is cheap; not retrying a
908
+ // dropped connection costs the whole update.
909
+ throw new Error('stageBundle returned false');
910
+ }
911
+ return ok;
912
+ },
913
+ { label: `download v${bundle.version}`, attempts: 3 },
914
+ );
915
+ } catch {
916
+ staged = false;
917
+ }
872
918
  }
873
919
  }
874
920
 
@@ -54,6 +54,11 @@ export interface OtaCheckRequest {
54
54
  installationId: string;
55
55
  currentBundleId?: string;
56
56
  currentBundleHash?: string;
57
+ /**
58
+ * Trusted key IDs embedded in this app binary, for SDK trust coverage
59
+ * (CICDPAGE §12). Telemetry only — never a source of server-side trust.
60
+ */
61
+ trustedKeyIds?: string[];
57
62
 
58
63
  // ── C1: channel + targeting context ────────────────────────────────────────
59
64
  // The backend DTO has accepted all of these since Sprint 9. The SDK declared
@@ -112,7 +117,24 @@ export interface OtaBundlePayload {
112
117
  url: string; // presigned GET — short-TTL
113
118
  size: number;
114
119
  sha256: string;
115
- signature?: string; // ed25519 detached signature (hex)
120
+ signature?: string; // ed25519 detached signature (hex) — legacy, unused by native RSA path
121
+ /**
122
+ * SB-OTA-RSA-SHA256-V1 release envelope. Present on signed releases; carries
123
+ * the protocol id, the id of the trusted RSA key the release was signed with,
124
+ * the canonical signed payload, and its signature. When present, the SDK hands
125
+ * the whole envelope to the native module (`verifyAndStageRelease`), which
126
+ * verifies the signature against the RSA public keys EMBEDDED in the app
127
+ * package and then downloads + integrity-checks + stages the artifact — JS
128
+ * never stages bytes it has not proven authentic, and never pins or verifies a
129
+ * key itself. Absent on legacy/unsigned releases, which fall back to
130
+ * `stageBundle` on SHA-256 integrity alone.
131
+ */
132
+ envelope?: {
133
+ protocol: string;
134
+ keyId: string;
135
+ signedPayload: string;
136
+ signature: string;
137
+ };
116
138
  isMandatory: boolean;
117
139
  /**
118
140
  * How to activate the bundle after install:
@@ -275,17 +275,11 @@ class ScaleBunFacade {
275
275
  /**
276
276
  * Boot the OTA orchestrator when the init config asks for it.
277
277
  *
278
- * `publicSigningKey` / `publicSigningKeys` are honoured here so signature
279
- * enforcement is reachable from configuration alone. The list form exists
280
- * for key ROTATION: a build pinning [old, new] keeps verifying while the
281
- * server moves to the new key, so replacing a key never needs an emergency
282
- * store release. Both fields merge (deduplicated) into one pinned set.
283
- *
284
- * Verification comes from `ota.verifySignature` when supplied, else the
285
- * built-in @noble-based verifier (optional peers). Pinning keys with
286
- * NEITHER available is fail-CLOSED by design (signature.ts) — an update
287
- * that cannot be verified is not installed. We say that out loud rather
288
- * than letting the app discover it as a silent no-update condition.
278
+ * Signature enforcement is no longer configured from JS. Signed releases are
279
+ * verified and staged in NATIVE code against RSA keys embedded in the app
280
+ * package (SB-OTA-RSA-SHA256-V1); unsigned releases keep working on SHA-256
281
+ * integrity. There are no signing-key or `verifySignature` options to pass
282
+ * through the host embeds its trusted keys natively.
289
283
  */
290
284
  private _maybeStartOta(rawConfig: any): void {
291
285
  const ota = rawConfig?.ota;
@@ -295,38 +289,6 @@ class ScaleBunFacade {
295
289
  // module (or touch the native spec) at import time.
296
290
  // eslint-disable-next-line @typescript-eslint/no-var-requires
297
291
  const { otaOrchestrator } = require('../features/ota/OtaOrchestrator');
298
- const rawSingle = ota.publicSigningKey;
299
- const rawList = ota.publicSigningKeys;
300
- const publicKeys = Array.from(
301
- new Set(
302
- [
303
- ...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []),
304
- ...(Array.isArray(rawList) ? rawList : []),
305
- ].filter((k: unknown): k is string => typeof k === 'string' && k.trim().length > 0),
306
- ),
307
- );
308
- // Signing was REQUESTED if a usable key exists, or the host passed a
309
- // keys array at all — an empty one included. `publicSigningKeys: []`
310
- // is a broken attempt to enable signing, and it must flow through to
311
- // signature.ts's fail-closed handling, not silently disable signing.
312
- const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
313
- const verifier = ota.verifySignature;
314
- if (signingRequested && typeof verifier !== 'function') {
315
- // A host verifier is optional since the built-in one landed — the
316
- // old unconditional warning here told every correctly-configured
317
- // app that its updates would be rejected. Warn only when neither
318
- // verifier can actually be resolved.
319
- // eslint-disable-next-line @typescript-eslint/no-var-requires
320
- const { getBuiltinVerifier } = require('../features/ota/crypto/builtinVerifier');
321
- if (!getBuiltinVerifier()) {
322
- logger.warn(
323
- '[ScaleBun] A signing key is pinned but no signature verifier is available. ' +
324
- 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' +
325
- 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' +
326
- 'code needed), or supply `ota.verifySignature`.',
327
- );
328
- }
329
- }
330
292
  // `healthyAfterMs` is the field BootGuardConfig actually declares.
331
293
  // This passed `healthyTimeoutMs`, which nothing reads, so a host that
332
294
  // tuned the boot-guard window silently got the 10s default instead.
@@ -339,16 +301,6 @@ class ScaleBunFacade {
339
301
  : undefined;
340
302
 
341
303
  otaOrchestrator.init({
342
- ...(signingRequested
343
- ? {
344
- signature: {
345
- // Single key stays a plain string — the shape every
346
- // existing consumer and test already handles.
347
- publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
348
- verifier,
349
- },
350
- }
351
- : {}),
352
304
  ...(healthyAfterMs !== undefined ? { healthyAfterMs } : {}),
353
305
  });
354
306
  logger.info('[ScaleBun] OTA enabled from init config.');