@umutcansu/react-native-pinvault 2.4.0 → 2.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.2 — 2026-10-10
4
+
5
+ ### Added
6
+
7
+ - **Expo config plugin** (`app.plugin.js`): at `expo prebuild` it sets the
8
+ iOS deployment target to 16.0, writes the Info.plist keys
9
+ (`faceIDPermission`, `backgroundUpdates`), adds
10
+ `PinVaultBridge.registerBackgroundTask()` to a Swift `AppDelegate`, copies
11
+ the native security file into Android's assets and the iOS app bundle
12
+ (`nativeSecurityFile`), and writes the two native opt-outs only when asked
13
+ (`allowNoNativeSecurityFile`, `pinReactNativeNetworking: false`). Built and
14
+ run with Expo SDK 54 (React Native 0.81), Android and iOS release builds.
15
+
16
+ ### Changed
17
+
18
+ - **Native libraries 2.4.2:** release builds refuse `allowUnsigned()` /
19
+ `allowUnpinnedConfigApi()` unless the block also calls
20
+ `allowRelaxationsInRelease()`. The bridge calls it only where the native
21
+ security file itself allows the relaxation (`allowUnsigned` /
22
+ `allowUnpinned`), so the native file stays the only place that decides;
23
+ JS cannot turn it on.
24
+
25
+ ## 2.4.1 — 2026-10-10
26
+
27
+ ### Security
28
+
29
+ - **React Native's WebSocket is pinned on iOS.** `wss://` went through
30
+ SocketRocket on CFStream with the platform's CA check only, so a socket.io /
31
+ graphql-ws connection could be intercepted by anyone holding a certificate
32
+ the device trusts (a corporate proxy, a user-installed root), and `start`
33
+ still reported RN's networking as pinned. Every SocketRocket socket to a
34
+ `wss` / `https` URL now gets a security policy that asks PinVault (the
35
+ library's new `evaluateServerTrust(_:host:port:)`) before the upgrade
36
+ request is written; subprotocols are kept. `requirePinnedReactNativeNetworking`
37
+ covers it (`E_NETWORKING_NOT_PINNED` when the hook is not in place), and
38
+ Info.plist `PinVaultPinReactNativeNetworking` = NO turns it off with the
39
+ https handler. A WebSocket to a host that requires a client certificate is
40
+ not supported on iOS yet (the handshake fails).
41
+ - **Android: every WebSocket goes through PinVault's hook of its own.** RN's
42
+ `WebSocketModule` has a separate `setCustomClientBuilder`; up to 0.81 it
43
+ builds each socket's client from a bare `OkHttpClient.Builder()`, outside
44
+ `OkHttpClientProvider`, so there the socket kept the system's trust while
45
+ `start` reported RN's networking as pinned. The plugin now installs that hook
46
+ too and checks it with the other two (`HookStatus.webSocketBuilderIsPinVault`),
47
+ so a library replacing it is also caught on 0.87.
48
+
49
+ - **A release build needs the anchors in the native security file:** a declared
50
+ block must carry `bootstrapPins` (or `allowUnpinnedConfigApi`) and
51
+ `signaturePublicKeys` (or `allowUnsigned`). A file with a bare
52
+ `{"id": …}` satisfied the release rule while the bundle supplied the pins
53
+ and keys; it now rejects `start` with `E_INVALID_CONFIG` naming the field.
54
+ - **`clientCertHosts` from JS only where the file names them** (release
55
+ builds): an OTA bundle could otherwise present the device's mTLS identity
56
+ to another pinned host. `tokenHosts` from JS is still taken: it only
57
+ narrows the library's default.
58
+
59
+ ### Changed
60
+
61
+ - **React Native 0.81+** (was 0.87+): built and run on 0.81.6 and 0.87.1,
62
+ Android and iOS (start with `requirePinnedReactNativeNetworking`, RN `fetch`
63
+ and `WebSocket` to a pinned and an unpinned host).
64
+
3
65
  ## 2.4.0 — 2026-10-09
4
66
 
5
67
  ### Breaking
package/README.md CHANGED
@@ -15,7 +15,8 @@ crypto runs in JavaScript, and JavaScript never sees TLS. Names are the native
15
15
  ones (`InitResult`, `ClientCertEnrollmentResult`, `VaultFileResult`, …), so the
16
16
  [library documentation](https://github.com/umutcansu/PinVault/blob/main/GUIDE.md) applies as it is.
17
17
 
18
- - React Native **0.87+**, New Architecture only (TurboModule, codegen), Hermes.
18
+ - React Native **0.81+** (built and tested on 0.81.6 and 0.87.1), New
19
+ Architecture only (TurboModule, codegen), Hermes.
19
20
  - Android minSdk 24; iOS 16+.
20
21
  - No runtime dependencies: `react` and `react-native` are peers.
21
22
 
@@ -28,15 +29,18 @@ npm install @umutcansu/react-native-pinvault
28
29
  cd ios && pod install
29
30
  ```
30
31
 
31
- The native libraries come with it, at the package's own version: npm 2.3.2
32
- uses `io.github.umutcansu:pinvault:2.3.2` from Maven Central and the Swift
33
- package at tag `v2.3.2`. The New Architecture must stay on (React Native
34
- 0.87's default).
32
+ The native libraries come with it, at the package's own version: npm 2.4.2
33
+ uses `io.github.umutcansu:pinvault:2.4.2` from Maven Central and the Swift
34
+ package at tag `v2.4.2`. The New Architecture must stay on (React Native's
35
+ default since 0.76).
35
36
 
36
37
  **iOS.** The pod depends on the PinVault Swift package through React Native's
37
38
  `spm_dependency` (git URL `https://github.com/umutcansu/PinVault.git`, exact
38
- version = this package's version, product `PinVault`). Info.plist, as for the
39
- native library:
39
+ version = this package's version, product `PinVault`). On React Native 0.81
40
+ with Xcode 26 or later, React Native's own `fmt` pod does not compile from
41
+ source; install the prebuilt dependencies instead
42
+ (`RCT_USE_RN_DEP=1 RCT_USE_PREBUILT_RNCORE=1 pod install`, 0.87's default).
43
+ Info.plist, as for the native library:
40
44
 
41
45
  | Key | Why |
42
46
  |---|---|
@@ -61,6 +65,43 @@ hooks before `Application.onCreate` runs (see [Networking](#networking)).
61
65
  **Release builds.** Put the trust anchors into the app itself, not only into
62
66
  the JS bundle: [Native security file](#native-security-file).
63
67
 
68
+ ### Expo
69
+
70
+ A native module: it runs in a development build or `expo prebuild`, not in
71
+ Expo Go. The package's config plugin does the steps above at prebuild — iOS
72
+ deployment target 16.0 (`Podfile.properties.json` and the Xcode project),
73
+ the Info.plist keys, the background task in `AppDelegate.swift`, and the
74
+ native security file copied into Android's assets and the iOS app bundle:
75
+
76
+ ```bash
77
+ npx expo install @umutcansu/react-native-pinvault
78
+ ```
79
+
80
+ ```json
81
+ {
82
+ "expo": {
83
+ "plugins": [
84
+ ["@umutcansu/react-native-pinvault", {
85
+ "nativeSecurityFile": "./pinvault_security.json",
86
+ "faceIDPermission": "Unlock your protected files",
87
+ "backgroundUpdates": true
88
+ }]
89
+ ]
90
+ }
91
+ }
92
+ ```
93
+
94
+ | Option | Does |
95
+ |---|---|
96
+ | `nativeSecurityFile` | path (from the project root) of your `pinvault_security.json`; checked to be a JSON object at prebuild |
97
+ | `faceIDPermission` | `NSFaceIDUsageDescription`, for vault files behind the screen lock (`userAuth`) |
98
+ | `backgroundUpdates` | `BGTaskSchedulerPermittedIdentifiers`, `UIBackgroundModes` = `fetch` and `PinVaultBridge.registerBackgroundTask()` in a Swift `AppDelegate` (Expo SDK 53+), for `schedulePeriodicUpdates` |
99
+ | `allowNoNativeSecurityFile` | `true` writes the opt-out of the file (Android meta-data, Info.plist): release builds then take anchors from JS — not recommended |
100
+ | `pinReactNativeNetworking` | `false` removes the networking provider and writes `PinVaultPinReactNativeNetworking` = NO: RN's own fetch / WebSocket stay unpinned |
101
+
102
+ Unknown options and wrong types stop prebuild. Tested with Expo SDK 54
103
+ (React Native 0.81) on Android and iOS, release builds.
104
+
64
105
  ## Quick start
65
106
 
66
107
  ```ts
@@ -207,11 +248,21 @@ parsed as strictly; a broken file rejects `start` with `E_INVALID_CONFIG`
207
248
  - a field the file declares is the value: JS may leave it out (the native value
208
249
  applies) or repeat it (lists in any order); a different value rejects `start`
209
250
  with `E_INVALID_CONFIG` naming the field;
251
+ - in a release build a declared block must carry its anchors: `bootstrapPins`
252
+ (or `allowUnpinnedConfigApi`) and `signaturePublicKeys` (or `allowUnsigned`);
253
+ a block that only names itself rejects `start` — otherwise the bundle would
254
+ supply the pins and keys;
210
255
  - `allowUnsigned`, `allowUnpinnedConfigApi` and `allowServerGeneratedKey` from
211
- JS are refused unless the file allows them for that block;
256
+ JS are refused unless the file allows them for that block; a release build
257
+ of the native libraries (2.4.2+) refuses the first two unless the block also
258
+ says `allowRelaxationsInRelease()`, which the bridge adds only where the file
259
+ allows the relaxation;
212
260
  - a block's `url`, `enrollmentUrl`, `renewalUrl`, `tokenHosts` and
213
261
  `clientCertHosts` are fixed where the file gives them (where the block talks to, and who gets its token and identity),
214
262
  and `attestation: true` / `proofOfPossession: true` there cannot be turned off from JS;
263
+ in a release build JS may give `clientCertHosts` only where the file names
264
+ them (the bundle cannot hand the device's identity to another host;
265
+ `tokenHosts` from JS only narrows the library's default);
215
266
  - `require` only tightens: a protection it turns on stays on whatever JS says,
216
267
  its `requireCaTrust` hosts are added to JS's, the expected signer / bundle /
217
268
  team ids and iOS `userAuthStrength` are fixed, and `expiredConfigGraceSeconds`
@@ -250,8 +301,8 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
250
301
  back) on either platform. Request bodies ≤ 10 MiB, responses ≤ 10 MiB by
251
302
  default (`maxResponseBytes`, at most 50 MiB) — enforced while the body is
252
303
  read on both platforms (a declared `Content-Length` over it is refused first).
253
- - **React Native's own `fetch`, `XMLHttpRequest` and `<Image>` are pinned on
254
- both platforms** (WebSocket on Android only). The rules are the same on both:
304
+ - **React Native's own `fetch`, `XMLHttpRequest`, `<Image>` and `WebSocket`
305
+ are pinned on both platforms.** The rules are the same on both:
255
306
  - **Before `start()`** — and after a start that failed — every `https`
256
307
  request through RN's networking fails (`PinVault has not started`): fail
257
308
  closed, as the native `getClient()` / `session()` before `init`. Plain
@@ -265,7 +316,7 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
265
316
  not go through PinVault (below); on Android a hook replaced after start
266
317
  then refuses RN's https too (checked every few seconds). With it off a
267
318
  warning is logged.
268
- - **Android** (two hooks of RN 0.87, read from its sources), installed by the
319
+ - **Android** (three hooks, read from RN 0.81 and 0.87's sources), installed by the
269
320
  plugin's content provider before `Application.onCreate`, so every client RN
270
321
  builds is covered:
271
322
  `NetworkingModule.setCustomClientBuilder` is called for every fetch / XHR
@@ -275,9 +326,14 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
275
326
  covers the clients RN builds once — the networking base client, the
276
327
  WebSocket / dev-support singleton and Fresco's image client: their TLS goes
277
328
  through a forwarding socket factory to the current pinned one.
278
- Another library that calls either setter after PinVault (a network inspector,
329
+ `WebSocketModule.setCustomClientBuilder` is called for every `WebSocket`: up
330
+ to 0.81 RN builds each socket's client from a bare `OkHttpClient.Builder()`,
331
+ outside `OkHttpClientProvider`, so without this hook a socket would get the
332
+ system's trust; from 0.87 it derives from the provider's client. Either way
333
+ the socket gets the same forwarding factory.
334
+ Another library that calls any of these setters after PinVault (a network inspector,
279
335
  a crash reporter, another pinning package) replaces the hook: `start()` and
280
- every `PinVault.fetch` check both (`PinVaultNetworking.status()`) and log a
336
+ every `PinVault.fetch` check all three (`PinVaultNetworking.status()`) and log a
281
337
  warning; `requirePinnedReactNativeNetworking` turns it into a failed start.
282
338
  Opt out natively by removing the provider in the app's manifest —
283
339
  `android.pinGlobalNetworking: false` alone is refused while the hooks are in
@@ -302,13 +358,21 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
302
358
  body in chunks and the completion after the body has been read (progress
303
359
  events arrive at the end); cancelling a request cancels it. `start` checks
304
360
  which handler RCTNetworking picks for an `https` request.
361
+ **WebSocket:** RN's `RCTWebSocketModule` uses SocketRocket on CFStream, not
362
+ URLSession. Every SocketRocket initializer ends in
363
+ `-initWithURLRequest:protocols:securityPolicy:`; the plugin replaces that
364
+ method at load (`RNPinVaultWebSocketPinning.mm`), so a socket to a `wss` /
365
+ `https` URL always gets a security policy that asks
366
+ `PinVault.shared.evaluateServerTrust(_:host:port:)` — the same pins, host
367
+ check and fail-closed rule as the https handler — whatever policy its creator
368
+ passed. SocketRocket judges the trust before it writes the upgrade request,
369
+ so a refused chain sends no header or cookie. Subprotocols are kept
370
+ (`RCTSetCustomSRWebSocketProvider` is not used: its block never sees them).
371
+ `start` checks the hook is still the initializer's implementation. A
372
+ WebSocket to a host that requires a client certificate fails on iOS (no
373
+ identity is presented); use it over TLS, or Android.
305
374
  Opt out natively with the Info.plist key `PinVaultPinReactNativeNetworking`
306
- = NO. **WebSocket is not pinned on iOS:** RN 0.87's `RCTWebSocketModule`
307
- uses SocketRocket on CFStream, not URLSession, so it cannot take PinVault's
308
- session or delegate; its hook (`RCTSetCustomSRWebSocketProvider` with an
309
- `SRSecurityPolicy` whose `evaluateServerTrust:forDomain:` decides) would need
310
- a public trust-evaluation API from the PinVault library, which it does not
311
- have. Keep secrets off `wss://` on iOS, or send them with `PinVault.fetch`.
375
+ = NO (both the https handler and the WebSocket hook).
312
376
  Keep App Transport Security on (no `NSAllowsArbitraryLoads`).
313
377
 
314
378
  ## Enrollment and vault tokens
@@ -373,8 +437,8 @@ as one more signal. The decisive check is server-side attestation
373
437
  ## Security notes (OWASP MASVS)
374
438
 
375
439
  - **NETWORK** — pinned native path only; no unpinned fallback; `https` → `http`
376
- redirects are not followed; RN's own fetch / XHR / images are pinned on both
377
- platforms (WebSocket on Android), fail closed before `start`, and a replaced
440
+ redirects are not followed; RN's own fetch / XHR / images / WebSocket are pinned on
441
+ both platforms, fail closed before `start`, and a replaced
378
442
  hook is detected.
379
443
  - **Trust anchors** — release builds take the relaxations only from the native
380
444
  security file; with the file, JS cannot change pins, keys or scopes.
@@ -420,7 +484,9 @@ repository's `:pinvault` (unsigned) into `android/build/pinvault-maven` at every
420
484
  build and resolves `io.github.umutcansu:pinvault` only from there — a composite
421
485
  build is not possible because PinVault builds with AGP 8.7 and React Native 0.87
422
486
  with AGP 9. iOS: the Podfile sets `PINVAULT_IOS_PACKAGE_PATH` to the repository,
423
- and the podspec hands `spm_dependency` that local path instead of the git tag.
487
+ and the podspec hands `spm_dependency` that local path instead of the git tag. A
488
+ local path needs a React Native whose `spm_dependency` takes one (0.87 does,
489
+ 0.81 takes git URLs only); apps on 0.81 use the git tag, as every app outside the repository does.
424
490
 
425
491
  ## Known limits
426
492
 
@@ -429,10 +495,13 @@ and the podspec hands `spm_dependency` that local path instead of the git tag.
429
495
  `build_auth_list`); the keystore daemon then keeps a dead connection and every
430
496
  Keystore operation fails until it is restarted. Turn `requireUnlockedDevice`
431
497
  on for release builds / real devices only (the sample does).
432
- - iOS: RN's WebSocket is not pinned, and RN's https answers are delivered after
433
- the whole (bounded) body is read (above).
434
- - Android: the networking hooks are detected by reading two private fields of
435
- React Native 0.87 (`OkHttpClientProvider.factory`,
436
- `NetworkingModule.customClientBuilder`; the consumer R8 rules keep them). A
498
+ - iOS: RN's https answers are delivered after the whole (bounded) body is read,
499
+ and a WebSocket presents no client certificate (above). The WebSocket hook
500
+ expects SocketRocket 0.7's designated initializer; another shape reads as
501
+ not pinned.
502
+ - Android: the networking hooks are detected by reading three private fields
503
+ of React Native (`OkHttpClientProvider.factory`,
504
+ `NetworkingModule.customClientBuilder`, `WebSocketModule.customClientBuilder`;
505
+ the same in 0.81 and 0.87, the consumer R8 rules keep them). A
437
506
  React Native version that renames them reads as "could not be read", which
438
507
  counts as not pinned.
@@ -4,7 +4,7 @@ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
4
 
5
5
  # PinVault for iOS is a Swift package (Package.swift at the repository root,
6
6
  # product "PinVault"). React Native's spm_dependency adds it to the Pods project:
7
- # - consumers: the git URL at the tag of this package's version (v2.4.0);
7
+ # - consumers: the git URL at the tag of this package's version (v2.4.2);
8
8
  # - development: PINVAULT_IOS_PACKAGE_PATH = a PinVault checkout (the sample's
9
9
  # Podfile sets it to this repository), the counterpart of pinvault.localPath.
10
10
  pinvault_url = "https://github.com/umutcansu/PinVault.git"
@@ -9,7 +9,7 @@ buildscript {
9
9
  kotlinVersion: "2.2.0",
10
10
  minSdkVersion: 24,
11
11
  compileSdkVersion: 36,
12
- pinvaultVersion: "2.4.0",
12
+ pinvaultVersion: "2.4.2",
13
13
  ]
14
14
  ext.pinvaultRnExt = { prop ->
15
15
  rootProject.ext.has(prop) ? rootProject.ext.get(prop) : PinVaultRN[prop]
@@ -14,6 +14,9 @@
14
14
  -keepclassmembers class com.facebook.react.modules.network.NetworkingModule {
15
15
  private static com.facebook.react.modules.network.CustomClientBuilder customClientBuilder;
16
16
  }
17
+ -keepclassmembers class com.facebook.react.modules.websocket.WebSocketModule {
18
+ private static com.facebook.react.modules.network.CustomClientBuilder customClientBuilder;
19
+ }
17
20
  # Tink (under the PinVault library's encrypted storage) references Error Prone's
18
21
  # compile-time annotations, which no React Native app has on its classpath; R8
19
22
  # stops a release build on the missing classes otherwise.
@@ -152,7 +152,9 @@ internal object ConfigParser {
152
152
  a.finish()
153
153
  }
154
154
  // On by default in a release build: RN's https must go through PinVault there.
155
- val requirePinned = root.bool("requirePinnedReactNativeNetworking") ?: (release && pinGlobal)
155
+ // The native security file can pin this on (RN-3): JS may turn it on, never off.
156
+ val requirePinned = (root.bool("requirePinnedReactNativeNetworking") ?: (release && pinGlobal)) ||
157
+ nativeRequire.requirePinnedReactNativeNetworking
156
158
  if (requirePinned && !pinGlobal) {
157
159
  throw BridgeInputException("config.requirePinnedReactNativeNetworking: contradicts android.pinGlobalNetworking: false")
158
160
  }
@@ -192,6 +194,20 @@ internal object ConfigParser {
192
194
  return url
193
195
  }
194
196
 
197
+ /**
198
+ * An endpoint path, resolved relative to the block's base URL. An absolute
199
+ * value — a scheme (`://`) or a root (`/`, `\`) — would silently retarget the
200
+ * request past the pinned host (`@Url`/`URL(string:relativeTo:)` honour it),
201
+ * so it is refused (RN-2). Returns null when the optional field is absent.
202
+ */
203
+ private fun relEndpoint(f: Fields, key: String, max: Int): String? {
204
+ val v = f.string(key, max) ?: return null
205
+ if (v.contains("://") || v.startsWith("/") || v.startsWith("\\")) {
206
+ throw BridgeInputException("${f.path}.$key: must be a relative path, not an absolute URL")
207
+ }
208
+ return v
209
+ }
210
+
195
211
  fun hostPin(p: Fields): HostPin {
196
212
  val hostname = p.requireString("hostname", 255)
197
213
  val pins = p.stringList("sha256", MAX_PINS_PER_HOST, 128) ?: throw BridgeInputException("${p.path}.sha256: required")
@@ -223,8 +239,8 @@ internal object ConfigParser {
223
239
  val url = httpsUrl(b, "url") ?: throw BridgeInputException("${b.path}.url: required")
224
240
  // Read everything before the builder runs, so a refusal names the JSON path.
225
241
  val bootstrapPins = b.objList("bootstrapPins", MAX_PINS)?.map(::hostPin)
226
- val configEndpoint = b.string("configEndpoint", 512)
227
- val healthEndpoint = b.string("healthEndpoint", 512)
242
+ val configEndpoint = relEndpoint(b, "configEndpoint", 512)
243
+ val healthEndpoint = relEndpoint(b, "healthEndpoint", 512)
228
244
  val oneKey = b.string("signaturePublicKey", KEY_LENGTH, multiline = true)
229
245
  val keys = b.stringList("signaturePublicKeys", 16, KEY_LENGTH, multiline = true)
230
246
  if (oneKey != null && keys != null) {
@@ -240,9 +256,9 @@ internal object ConfigParser {
240
256
  val clientCaPins = b.stringList("clientCaPins", 16, 128)
241
257
  val maxLifetime = b.int("maxClientCertLifetimeDays", 1, 3650)
242
258
  val clientCertHosts = b.stringList("clientCertHosts", 64, 2048)
243
- val enrollmentEndpoint = b.string("enrollmentEndpoint", 512)
244
- val clientCertEndpoint = b.string("clientCertEndpoint", 512)
245
- val vaultReportEndpoint = b.string("vaultReportEndpoint", 512)
259
+ val enrollmentEndpoint = relEndpoint(b, "enrollmentEndpoint", 512)
260
+ val clientCertEndpoint = relEndpoint(b, "clientCertEndpoint", 512)
261
+ val vaultReportEndpoint = relEndpoint(b, "vaultReportEndpoint", 512)
246
262
  val clientCertLabel = b.string("clientCertLabel", 128)
247
263
  val wantPinsFor = b.stringList("wantPinsFor", 256, 255)
248
264
  val renewalUrl = httpsUrl(b, "renewalUrl")
@@ -273,6 +289,12 @@ internal object ConfigParser {
273
289
  val tokenHosts = nativeBlock?.tokenHosts?.also { n ->
274
290
  if (jsTokenHosts != null && !NativeSecurity.sameKeys(jsTokenHosts.map(String::lowercase), n.map(String::lowercase))) fixed("tokenHosts")
275
291
  } ?: jsTokenHosts
292
+ // Who gets the device's identity is the file's call in a release build: JS cannot
293
+ // add a host the file does not name (tokenHosts only narrows the library's default).
294
+ if (release && nativeBlock != null && nativeBlock.clientCertHosts == null && clientCertHosts != null) {
295
+ throw BridgeInputException("${b.path}.clientCertHosts: a release build takes it only from the app's native security file " +
296
+ "(${native?.source}); declare clientCertHosts for Config API '$id' there")
297
+ }
276
298
  val certHosts = nativeBlock?.clientCertHosts?.also { n ->
277
299
  if (clientCertHosts != null && !NativeSecurity.sameKeys(clientCertHosts.map(String::lowercase), n.map(String::lowercase))) fixed("clientCertHosts")
278
300
  } ?: clientCertHosts
@@ -299,6 +321,8 @@ internal object ConfigParser {
299
321
  if (sec.allowUnsigned) this.allowUnsigned()
300
322
  sec.serverScope?.let { this.serverScope(it) }
301
323
  if (sec.allowUnpinned) this.allowUnpinnedConfigApi()
324
+ // The native security file allowed them for this block: the library keeps them in a release build too.
325
+ if (native != null && (sec.allowUnsigned || sec.allowUnpinned)) this.allowRelaxationsInRelease()
302
326
  if (sec.allowServerKey) this.allowServerGeneratedKey()
303
327
  sec.clientCaPins?.let { this.clientCaPins(it) }
304
328
  maxLifetime?.let { this.maxClientCertLifetimeDays(it) }
@@ -326,6 +350,9 @@ internal object ConfigParser {
326
350
  throw BridgeInputException("${f.path}.key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
327
351
  }
328
352
  val endpoint = f.requireString("endpoint", 512)
353
+ if (endpoint.contains("://") || endpoint.startsWith("/") || endpoint.startsWith("\\")) {
354
+ throw BridgeInputException("${f.path}.endpoint: must be a relative path, not an absolute URL")
355
+ }
329
356
  val jsSignatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
330
357
  val updateWithPins = f.bool("updateWithPins")
331
358
  val storage = f.enum("storage", StorageStrategy.values())
@@ -31,7 +31,8 @@ import java.io.IOException
31
31
  * }],
32
32
  * "staticPins": { "pins": [ … ], "version": 1 },
33
33
  * "require": {
34
- * "requireUnlockedDevice": true, "requireHardwareBackedKeys": true, "managedTrustRoots": true,
34
+ * "requireUnlockedDevice": true, "requireHardwareBackedKeys": true,
35
+ * "requirePinnedReactNativeNetworking": true, "managedTrustRoots": true,
35
36
  * "wipeVaultFilesOnRevocation": true, "requireCaTrust": ["api.example.com"],
36
37
  * "expectedSignerSha256": ["…"], "expiredConfigGraceSeconds": 0, "vaultFileMaxOfflineAgeSeconds": 604800
37
38
  * },
@@ -78,6 +79,8 @@ internal class NativeSecurity(
78
79
  class Require(
79
80
  val requireUnlockedDevice: Boolean = false,
80
81
  val requireHardwareBackedKeys: Boolean = false,
82
+ /** Pins `requirePinnedReactNativeNetworking`: JS cannot set it false (RN-3). */
83
+ val requirePinnedReactNativeNetworking: Boolean = false,
81
84
  val managedTrustRoots: Boolean = false,
82
85
  val wipeVaultFilesOnRevocation: Boolean = false,
83
86
  val requireCaTrust: List<String>? = null,
@@ -192,6 +195,7 @@ internal class NativeSecurity(
192
195
  Require(
193
196
  requireUnlockedDevice = r.bool("requireUnlockedDevice") == true,
194
197
  requireHardwareBackedKeys = r.bool("requireHardwareBackedKeys") == true,
198
+ requirePinnedReactNativeNetworking = r.bool("requirePinnedReactNativeNetworking") == true,
195
199
  managedTrustRoots = r.bool("managedTrustRoots") == true,
196
200
  wipeVaultFilesOnRevocation = r.bool("wipeVaultFilesOnRevocation") == true,
197
201
  requireCaTrust = r.stringList("requireCaTrust", 64, 255),
@@ -295,6 +299,16 @@ internal object SecurityPolicy {
295
299
  relaxation("allowUnpinnedConfigApi", js.allowUnpinned, block.allowUnpinnedConfigApi)
296
300
  relaxation("allowServerGeneratedKey", js.allowServerKey, block.allowServerGeneratedKey)
297
301
 
302
+ // A release build's trust anchors come from the file, so a declared block
303
+ // must carry them: a bare `{"id": …}` would hand pins and keys to the bundle.
304
+ if (release) {
305
+ fun anchor(key: String, relaxation: String): Nothing = throw BridgeInputException(
306
+ "$path.$key: a release build takes it only from the app's native security file (${native.source}); " +
307
+ "declare $key for Config API '$id' there (or $relaxation)")
308
+ if (block.bootstrapPins == null && !block.allowUnpinnedConfigApi) anchor("bootstrapPins", "allowUnpinnedConfigApi")
309
+ if (block.signaturePublicKeys == null && !block.allowUnsigned) anchor("signaturePublicKeys", "allowUnsigned")
310
+ }
311
+
298
312
  val pins = block.bootstrapPins?.also { n ->
299
313
  if (js.bootstrapPins != null && !NativeSecurity.samePins(js.bootstrapPins, n)) fixed("bootstrapPins")
300
314
  } ?: js.bootstrapPins
@@ -10,6 +10,7 @@ import com.facebook.react.modules.network.CustomClientBuilder
10
10
  import com.facebook.react.modules.network.NetworkingModule
11
11
  import com.facebook.react.modules.network.OkHttpClientFactory
12
12
  import com.facebook.react.modules.network.OkHttpClientProvider
13
+ import com.facebook.react.modules.websocket.WebSocketModule
13
14
  import io.github.umutcansu.pinvault.PinVault
14
15
  import okhttp3.Cache
15
16
  import okhttp3.CookieJar
@@ -35,7 +36,7 @@ import javax.net.ssl.X509TrustManager
35
36
  * Startup pattern, without the dependency). [install] stays as an explicit
36
37
  * alternative for apps that remove the provider.
37
38
  *
38
- * Two hooks, both verified against React Native 0.87's sources:
39
+ * Three hooks, verified against React Native 0.81 and 0.87's sources:
39
40
  *
40
41
  * - `NetworkingModule.setCustomClientBuilder`: RN calls it for **every**
41
42
  * fetch / XHR request on `client.newBuilder()`. Each request gets the
@@ -50,6 +51,11 @@ import javax.net.ssl.X509TrustManager
50
51
  * singleton, Fresco's image client) get a forwarding socket factory: every
51
52
  * TLS handshake uses the pinned factory of the current start. No disk
52
53
  * cache, no `https` → `http` redirects.
54
+ * - `WebSocketModule.setCustomClientBuilder`: RN calls it for every
55
+ * `WebSocket`. Up to 0.81 the module builds each socket's client from a
56
+ * bare `OkHttpClient.Builder()`, outside `OkHttpClientProvider`; from 0.87
57
+ * it derives it from the provider's client. Either way the socket gets the
58
+ * same forwarding factory, gate and network interceptors.
53
59
  *
54
60
  * Before `start()` — and after a start that failed — every `https` request
55
61
  * through RN's networking fails (`IOException: PinVault has not started`),
@@ -59,7 +65,7 @@ import javax.net.ssl.X509TrustManager
59
65
  * without a pin entry are refused by PinVault's trust manager like
60
66
  * everywhere else.
61
67
  *
62
- * Another library that calls `setOkHttpClientFactory` or
68
+ * Another library that calls `setOkHttpClientFactory` or either
63
69
  * `setCustomClientBuilder` after PinVault replaces these hooks: [status]
64
70
  * tells, `start()` and every plugin `fetch` check it and log a warning, and
65
71
  * `requirePinnedReactNativeNetworking: true` makes `start()` fail.
@@ -71,11 +77,12 @@ object PinVaultNetworking {
71
77
  @Volatile private var installed = false
72
78
  @Volatile private var app: Context? = null
73
79
 
74
- /** The two hooks this object installed; compared by identity in [status]. */
80
+ /** The hooks this object installed; compared by identity in [status]. */
75
81
  private val factory = OkHttpClientFactory {
76
82
  pinning.track(pinning.configureLongLived(OkHttpClientProvider.createClientBuilder()).build())
77
83
  }
78
84
  private val clientBuilder = CustomClientBuilder { builder -> pinning.configurePerRequest(builder) }
85
+ private val webSocketBuilder = CustomClientBuilder { builder -> pinning.configureLongLived(builder) }
79
86
 
80
87
  internal val pinning = NetworkPinning(
81
88
  applier = { builder -> PinVault.applyTo(builder) },
@@ -87,7 +94,7 @@ object PinVaultNetworking {
87
94
  @Volatile private var installedLate = false
88
95
 
89
96
  /**
90
- * Installs both hooks (once per process). The plugin's content provider
97
+ * Installs the hooks (once per process). The plugin's content provider
91
98
  * calls it before `Application.onCreate`; call it yourself in
92
99
  * `MainApplication.onCreate`, before `loadReactNative`, only when the app
93
100
  * removed that provider.
@@ -103,6 +110,7 @@ object PinVaultNetworking {
103
110
  app = context.applicationContext
104
111
  OkHttpClientProvider.setOkHttpClientFactory(factory)
105
112
  NetworkingModule.setCustomClientBuilder(clientBuilder)
113
+ WebSocketModule.setCustomClientBuilder(webSocketBuilder)
106
114
  installed = true
107
115
  installedLate = late
108
116
  }
@@ -110,9 +118,9 @@ object PinVaultNetworking {
110
118
  @JvmStatic
111
119
  val isInstalled: Boolean get() = installed
112
120
 
113
- /** Whether React Native's two networking hooks are still the ones [install] set. */
121
+ /** Whether React Native's networking hooks are still the ones [install] set. */
114
122
  @JvmStatic
115
- fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder, installedLate)
123
+ fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder, webSocketBuilder, installedLate)
116
124
 
117
125
  private val warned = java.util.concurrent.atomic.AtomicReference<HookStatus?>(null)
118
126
 
@@ -135,10 +143,13 @@ data class HookStatus(
135
143
  val factoryIsPinVault: Boolean?,
136
144
  /** `NetworkingModule`'s custom client builder is PinVault's. */
137
145
  val clientBuilderIsPinVault: Boolean?,
146
+ /** `WebSocketModule`'s custom client builder is PinVault's. */
147
+ val webSocketBuilderIsPinVault: Boolean?,
138
148
  /** Installed only by `start()`: clients React Native built before (Fresco's, among them) keep the system's trust. */
139
149
  val installedLate: Boolean = false,
140
150
  ) {
141
- val pinned: Boolean get() = installed && !installedLate && factoryIsPinVault == true && clientBuilderIsPinVault == true
151
+ val pinned: Boolean get() = installed && !installedLate && factoryIsPinVault == true &&
152
+ clientBuilderIsPinVault == true && webSocketBuilderIsPinVault == true
142
153
 
143
154
  fun describe(): String = when {
144
155
  pinned -> "React Native's networking is pinned by PinVault"
@@ -151,20 +162,22 @@ data class HookStatus(
151
162
  else -> buildList {
152
163
  if (factoryIsPinVault != true) add("OkHttpClientProvider's client factory " + if (factoryIsPinVault == null) "could not be read" else "was replaced by another library")
153
164
  if (clientBuilderIsPinVault != true) add("NetworkingModule's custom client builder " + if (clientBuilderIsPinVault == null) "could not be read" else "was replaced by another library")
165
+ if (webSocketBuilderIsPinVault != true) add("WebSocketModule's custom client builder " + if (webSocketBuilderIsPinVault == null) "could not be read" else "was replaced by another library")
154
166
  }.joinToString("; ") + ": React Native's own networking may NOT be pinned"
155
167
  }
156
168
 
157
169
  internal companion object {
158
- fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any, installedLate: Boolean = false): HookStatus = HookStatus(
170
+ fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any, ourWebSocketBuilder: Any, installedLate: Boolean = false): HookStatus = HookStatus(
159
171
  installed,
160
172
  isOurs(OkHttpClientProvider::class.java, "factory", ourFactory),
161
173
  isOurs(NetworkingModule::class.java, "customClientBuilder", ourBuilder),
174
+ isOurs(WebSocketModule::class.java, "customClientBuilder", ourWebSocketBuilder),
162
175
  installedLate,
163
176
  )
164
177
 
165
178
  /**
166
179
  * Whether RN's private static field [name] holds [ours]; null when it
167
- * cannot be read. The plugin's consumer R8 rules keep both fields' names.
180
+ * cannot be read. The plugin's consumer R8 rules keep the fields' names.
168
181
  */
169
182
  private fun isOurs(owner: Class<*>, name: String, ours: Any): Boolean? = try {
170
183
  owner.getDeclaredField(name).apply { isAccessible = true }.get(null) === ours
@@ -281,14 +294,18 @@ internal class NetworkPinning(
281
294
  pinned.networkInterceptors.forEach { if (it !in builder.networkInterceptors()) builder.addNetworkInterceptor(it) }
282
295
  }
283
296
 
284
- /** Clients RN builds once and keeps: TLS goes through the pinned factory of the current start. */
297
+ /**
298
+ * Clients RN builds once and keeps, and each WebSocket's client: TLS goes
299
+ * through the pinned factory of the current start. Idempotent: from RN
300
+ * 0.87 a WebSocket's builder derives from a client already configured here.
301
+ */
285
302
  fun configureLongLived(builder: OkHttpClient.Builder): OkHttpClient.Builder {
286
303
  val forwarding = ForwardingSocketFactory { template?.sslSocketFactory }
287
304
  builder.sslSocketFactory(forwarding, ForwardingTrustManager { template?.x509TrustManager })
288
- builder.addInterceptor(httpsGate)
305
+ if (httpsGate !in builder.interceptors()) builder.addInterceptor(httpsGate)
289
306
  // The current start's network interceptors (the per-request pin check of a pooled
290
307
  // or coalesced connection, among them) run for these clients too.
291
- builder.addNetworkInterceptor(forwardingNetwork)
308
+ if (forwardingNetwork !in builder.networkInterceptors()) builder.addNetworkInterceptor(forwardingNetwork)
292
309
  builder.cache(null)
293
310
  builder.followSslRedirects(false)
294
311
  return builder
package/app.plugin.js ADDED
@@ -0,0 +1,2 @@
1
+ // Expo config plugin: see plugin/withPinVault.js and the README's "Expo" section.
2
+ module.exports = require('./plugin/withPinVault');
@@ -115,7 +115,9 @@ public enum ConfigParser {
115
115
 
116
116
  // On by default in a release build — unless the app opted out of pinning RN's
117
117
  // networking natively (Info.plist PinVaultPinReactNativeNetworking = NO).
118
- let requirePinned = try root.bool("requirePinnedReactNativeNetworking") ?? (release && reactNetworkingEnabled)
118
+ // The native security file can pin this on (RN-3): JS may turn it on, never off.
119
+ let requirePinned = (try root.bool("requirePinnedReactNativeNetworking") ?? (release && reactNetworkingEnabled))
120
+ || require.requirePinnedReactNativeNetworking
119
121
  // Android-only settings: the Android side reads them; here only their shape is checked.
120
122
  _ = try root.object("android")
121
123
  var maxRN = ReactNetworking.defaultMaxResponseBytes
@@ -194,6 +196,18 @@ public enum ConfigParser {
194
196
  return url
195
197
  }
196
198
 
199
+ /// An endpoint path resolved relative to the block's base URL. An absolute
200
+ /// value — a scheme (`://`) or a root (`/`, `\`) — would silently retarget the
201
+ /// request past the pinned host (`URL(string:relativeTo:)` honours it), so it
202
+ /// is refused (RN-2). Returns nil when the optional field is absent.
203
+ static func relEndpoint(_ f: Fields, _ key: String, maxLength: Int) throws -> String? {
204
+ guard let v = try f.string(key, maxLength: maxLength) else { return nil }
205
+ if v.contains("://") || v.hasPrefix("/") || v.hasPrefix("\\") {
206
+ throw BridgeInputError("\(f.path).\(key): must be a relative path, not an absolute URL")
207
+ }
208
+ return v
209
+ }
210
+
197
211
  public static func hostPin(_ p: Fields) throws -> HostPin {
198
212
  let hostname = try p.requireString("hostname", maxLength: 255)
199
213
  guard let pins = try p.stringList("sha256", maxItems: maxPinsPerHost, maxLength: 128) else {
@@ -230,8 +244,8 @@ public enum ConfigParser {
230
244
  let id = try b.requireString("id", maxLength: 128)
231
245
  guard let url = try httpsUrl(b, "url") else { throw BridgeInputError("\(b.path).url: required") }
232
246
  let bootstrapPins = try b.objectList("bootstrapPins", maxItems: maxPins)?.map(hostPin)
233
- let configEndpoint = try b.string("configEndpoint", maxLength: 512)
234
- let healthEndpoint = try b.string("healthEndpoint", maxLength: 512)
247
+ let configEndpoint = try relEndpoint(b, "configEndpoint", maxLength: 512)
248
+ let healthEndpoint = try relEndpoint(b, "healthEndpoint", maxLength: 512)
235
249
  let oneKey = try b.string("signaturePublicKey", maxLength: keyLength, multiline: true)
236
250
  let keys = try b.stringList("signaturePublicKeys", maxItems: 16, maxLength: keyLength, multiline: true)
237
251
  if oneKey != nil && keys != nil {
@@ -247,9 +261,9 @@ public enum ConfigParser {
247
261
  let clientCaPins = try b.stringList("clientCaPins", maxItems: 16, maxLength: 128)
248
262
  let maxLifetime = try b.int("maxClientCertLifetimeDays", min: 1, max: 3650)
249
263
  let clientCertHosts = try b.stringList("clientCertHosts", maxItems: 64, maxLength: 2048)
250
- let enrollmentEndpoint = try b.string("enrollmentEndpoint", maxLength: 512)
251
- let clientCertEndpoint = try b.string("clientCertEndpoint", maxLength: 512)
252
- let vaultReportEndpoint = try b.string("vaultReportEndpoint", maxLength: 512)
264
+ let enrollmentEndpoint = try relEndpoint(b, "enrollmentEndpoint", maxLength: 512)
265
+ let clientCertEndpoint = try relEndpoint(b, "clientCertEndpoint", maxLength: 512)
266
+ let vaultReportEndpoint = try relEndpoint(b, "vaultReportEndpoint", maxLength: 512)
253
267
  let clientCertLabel = try b.string("clientCertLabel", maxLength: 128)
254
268
  let wantPinsFor = try b.stringList("wantPinsFor", maxItems: 256, maxLength: 255)
255
269
  let renewalUrl = try httpsUrl(b, "renewalUrl")
@@ -296,6 +310,12 @@ public enum ConfigParser {
296
310
  if let j = renewalUrl, j != n { throw fixed("renewalUrl") }
297
311
  fixedRenewalUrl = n
298
312
  }
313
+ // Who gets the device's identity is the file's call in a release build: JS cannot
314
+ // add a host the file does not name (tokenHosts only narrows the library's default).
315
+ if release, let nativeBlock, nativeBlock.clientCertHosts == nil, clientCertHosts != nil {
316
+ throw BridgeInputError("\(b.path).clientCertHosts: a release build takes it only from the app's native security file "
317
+ + "(\(native?.source ?? "")); declare clientCertHosts for Config API '\(id)' there")
318
+ }
299
319
  var certHosts = clientCertHosts
300
320
  if let n = nativeBlock?.clientCertHosts {
301
321
  if let j = clientCertHosts, !NativeSecurity.sameKeys(lower(j), lower(n)) { throw fixed("clientCertHosts") }
@@ -326,6 +346,8 @@ public enum ConfigParser {
326
346
  if sec.allowUnsigned { api.allowUnsigned() }
327
347
  if let v = sec.serverScope { api.serverScope(v) }
328
348
  if sec.allowUnpinned { api.allowUnpinnedConfigApi() }
349
+ // The native security file allowed them for this block: the library keeps them in a release build too.
350
+ if native != nil, sec.allowUnsigned || sec.allowUnpinned { api.allowRelaxationsInRelease() }
329
351
  if sec.allowServerKey { api.allowServerGeneratedKey() }
330
352
  if let v = sec.clientCaPins { api.clientCaPins(v) }
331
353
  if let maxLifetime { api.maxClientCertLifetimeDays(maxLifetime) }
@@ -349,6 +371,9 @@ public enum ConfigParser {
349
371
  static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore, native: NativeSecurity?) throws {
350
372
  let key = try f.requireString("key", maxLength: 128)
351
373
  let endpoint = try f.requireString("endpoint", maxLength: 512)
374
+ if endpoint.contains("://") || endpoint.hasPrefix("/") || endpoint.hasPrefix("\\") {
375
+ throw BridgeInputError("\(f.path).endpoint: must be a relative path, not an absolute URL")
376
+ }
352
377
  let jsSignatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
353
378
  let updateWithPins = try f.bool("updateWithPins")
354
379
  let storage = try f.enumValue("storage", StorageStrategy.self)
@@ -11,7 +11,8 @@
11
11
  // "clientCertHosts",
12
12
  // "enrollmentUrl", "renewalUrl" }],
13
13
  // "staticPins": { "pins": [...], "version": 1 },
14
- // "require": { "requireUnlockedDevice", "requireHardwareBackedKeys", "managedTrustRoots",
14
+ // "require": { "requireUnlockedDevice", "requireHardwareBackedKeys",
15
+ // "requirePinnedReactNativeNetworking", "managedTrustRoots",
15
16
  // "wipeVaultFilesOnRevocation", "requireCaTrust", "expectedBundleIds",
16
17
  // "expectedTeamIds", "userAuthStrength", "expiredConfigGraceSeconds",
17
18
  // "vaultFileMaxOfflineAgeSeconds", "expectedSignerSha256" (Android's, read for the shape) },
@@ -62,6 +63,8 @@ public struct NativeSecurity {
62
63
  public struct Require {
63
64
  var requireUnlockedDevice = false
64
65
  var requireHardwareBackedKeys = false
66
+ /// Pins `requirePinnedReactNativeNetworking`: JS cannot set it false (RN-3).
67
+ var requirePinnedReactNativeNetworking = false
65
68
  var managedTrustRoots = false
66
69
  var wipeVaultFilesOnRevocation = false
67
70
  var requireCaTrust: [String]? = nil
@@ -149,6 +152,7 @@ public struct NativeSecurity {
149
152
  if let r = try root.object("require") {
150
153
  require.requireUnlockedDevice = try r.bool("requireUnlockedDevice") == true
151
154
  require.requireHardwareBackedKeys = try r.bool("requireHardwareBackedKeys") == true
155
+ require.requirePinnedReactNativeNetworking = try r.bool("requirePinnedReactNativeNetworking") == true
152
156
  require.managedTrustRoots = try r.bool("managedTrustRoots") == true
153
157
  require.wipeVaultFilesOnRevocation = try r.bool("wipeVaultFilesOnRevocation") == true
154
158
  require.requireCaTrust = try r.stringList("requireCaTrust", maxItems: 64, maxLength: 255)
@@ -255,6 +259,17 @@ enum SecurityPolicy {
255
259
  try relaxation("allowUnpinnedConfigApi", js.allowUnpinned, block.allowUnpinnedConfigApi)
256
260
  try relaxation("allowServerGeneratedKey", js.allowServerKey, block.allowServerGeneratedKey)
257
261
 
262
+ // A release build's trust anchors come from the file, so a declared block
263
+ // must carry them: a bare `{"id": …}` would hand pins and keys to the bundle.
264
+ if release {
265
+ func anchor(_ key: String, _ relaxation: String) -> BridgeInputError {
266
+ BridgeInputError("\(path).\(key): a release build takes it only from the app's native security file (\(native.source)); "
267
+ + "declare \(key) for Config API '\(id)' there (or \(relaxation))")
268
+ }
269
+ if block.bootstrapPins == nil && !block.allowUnpinnedConfigApi { throw anchor("bootstrapPins", "allowUnpinnedConfigApi") }
270
+ if block.signaturePublicKeys == nil && !block.allowUnsigned { throw anchor("signaturePublicKeys", "allowUnsigned") }
271
+ }
272
+
258
273
  var out = js
259
274
  if let n = block.bootstrapPins {
260
275
  if let j = js.bootstrapPins, !NativeSecurity.samePins(j, n) { throw fixed("bootstrapPins") }
@@ -29,6 +29,9 @@ public final class PinVaultBridge: NSObject {
29
29
  /// RNPinVault.mm: whether RCTNetworking hands an https request to
30
30
  /// RNPinVaultURLRequestHandler (true), to another handler (false), or nil = unknown.
31
31
  @objc public var reactNetworkingIsPinned: (() -> NSNumber?)?
32
+ /// RNPinVault.mm: whether SocketRocket hands every wss socket to PinVault
33
+ /// (RNPinVaultWebSocketPinning); nil = treated as not pinned.
34
+ @objc public var reactWebSocketIsPinned: (() -> Bool)?
32
35
 
33
36
  /// `PinVault.shared.registerBackgroundTask()` for the app's AppDelegate (before launch ends):
34
37
  /// the app target does not link the PinVault package itself.
@@ -150,12 +153,14 @@ public final class PinVaultBridge: NSObject {
150
153
  return
151
154
  }
152
155
  let pinned = self.reactNetworkingIsPinned?()?.boolValue
153
- if pinned != true {
156
+ let socketPinned = self.reactWebSocketIsPinned?() ?? false
157
+ if pinned != true || !socketPinned {
154
158
  let why = !PinVaultReactNetworking.enabled
155
159
  ? "Info.plist PinVaultPinReactNativeNetworking is NO"
156
160
  : pinned == nil ? "RCTNetworking's handler could not be checked"
157
- : "RCTNetworking hands https to another RCTURLRequestHandler"
158
- NSLog("PinVault: React Native's own fetch / XMLHttpRequest / images may NOT be pinned (\(why)); README, \"Networking\"")
161
+ : pinned == false ? "RCTNetworking hands https to another RCTURLRequestHandler"
162
+ : "SocketRocket's WebSocket initializer is not PinVault's (wss is not pinned)"
163
+ NSLog("PinVault: React Native's own fetch / XMLHttpRequest / images / WebSocket may NOT be pinned (\(why)); README, \"Networking\"")
159
164
  if parsed.requirePinnedReactNativeNetworking {
160
165
  reject("E_NETWORKING_NOT_PINNED", "requirePinnedReactNativeNetworking: \(why)", nil)
161
166
  return
@@ -2,6 +2,7 @@
2
2
  // requests (fetch, XMLHttpRequest, <Image>) through PinVault's pinned session.
3
3
  // The rules live in Core/ReactNetworking.swift.
4
4
  import Foundation
5
+ import Security
5
6
  import PinVault
6
7
 
7
8
  @objc(RNPinVaultReactNetworking)
@@ -17,6 +18,21 @@ public final class PinVaultReactNetworking: NSObject {
17
18
  enabled && ReactNetworking.handles(request)
18
19
  }
19
20
 
21
+ /// The server-trust check of a `wss` WebSocket (RNPinVaultWebSocketPinning.mm):
22
+ /// the decision of every PinVault session for the host and port the socket
23
+ /// was opened for. Fail-closed: before `start`, after a failed one, or
24
+ /// while a restart resets the library, every handshake is refused.
25
+ @objc public static func acceptsWebSocketTrust(_ trust: SecTrust, host: String, port: Int) -> Bool {
26
+ do {
27
+ try PinVault.shared.evaluateServerTrust(trust, host: host, port: port)
28
+ return true
29
+ } catch {
30
+ let (name, message) = PinVaultBridge.describe(error)
31
+ NSLog("PinVault: WebSocket to %@:%ld refused (%@: %@)", host, port, name, message)
32
+ return false
33
+ }
34
+ }
35
+
20
36
  /// The request token for RN; nothing reaches the blocks before `resume()`.
21
37
  @objc public static func prepare(
22
38
  _ request: URLRequest,
package/ios/RNPinVault.mm CHANGED
@@ -3,6 +3,8 @@
3
3
  #import <objc/message.h>
4
4
  #import <React/RCTBridgeModule.h>
5
5
 
6
+ #import "RNPinVaultWebSocketPinning.h"
7
+
6
8
  #if __has_include(<RNPinVault/RNPinVault-Swift.h>)
7
9
  #import <RNPinVault/RNPinVault-Swift.h>
8
10
  #else
@@ -51,6 +53,10 @@
51
53
  id handler = ((id(*)(id, SEL, NSURLRequest *))objc_msgSend)(networking, selector, probe);
52
54
  return @([handler isKindOfClass:ours]);
53
55
  };
56
+ // Whether SocketRocket's initializer still hands every wss socket to PinVault.
57
+ _bridge.reactWebSocketIsPinned = ^BOOL {
58
+ return [RNPinVaultWebSocketPinning isPinned];
59
+ };
54
60
  }
55
61
  return self;
56
62
  }
@@ -0,0 +1,21 @@
1
+ // RNPinVaultWebSocketPinning — React Native's WebSocket (SocketRocket) through
2
+ // PinVault's pins on iOS. See RNPinVaultWebSocketPinning.mm.
3
+ #import <Foundation/Foundation.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ @interface RNPinVaultWebSocketPinning : NSObject
8
+
9
+ /// Installs the hook when it is not in place yet (it is installed at load;
10
+ /// this covers a SocketRocket image loaded later). NO when it cannot be:
11
+ /// Info.plist opt-out, no SocketRocket initializer of the known shape.
12
+ + (BOOL)install;
13
+
14
+ /// Whether every SocketRocket socket to a `wss` / `https` URL is judged by
15
+ /// PinVault right now: the hook is installed and still the initializer's
16
+ /// implementation (nothing replaced it since). YES without SocketRocket in the app.
17
+ + (BOOL)isPinned;
18
+
19
+ @end
20
+
21
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,170 @@
1
+ // RNPinVaultWebSocketPinning — React Native's WebSocket through PinVault's
2
+ // pins on iOS.
3
+ //
4
+ // RCTWebSocketModule opens every socket with SocketRocket (SRWebSocket on
5
+ // CFStream), outside URLSession, so the URLRequestHandler never sees it. Every
6
+ // SRWebSocket initializer ends in -initWithURLRequest:protocols:securityPolicy:
7
+ // (SocketRocket 0.7.x, SRWebSocket.m). This file replaces that method at load:
8
+ // a socket to a wss / https URL (SocketRocket's SRURLRequiresSSL) always gets a
9
+ // policy that asks PinVault, whatever policy its creator passed.
10
+ //
11
+ // RCTSetCustomSRWebSocketProvider is not used: its block gets only the
12
+ // request, so the subprotocols RN hands the initializer (graphql-ws, MQTT,
13
+ // STOMP) would be lost, and a provider set later by another library would
14
+ // replace ours without a trace.
15
+ //
16
+ // SocketRocket checks the server trust in -stream:handleEvent: before it
17
+ // writes the upgrade request: a refused chain sends no header and no cookie.
18
+ //
19
+ // The policy is a runtime subclass of SRSecurityPolicy, so the plugin needs
20
+ // neither SocketRocket's headers nor a link to it: no chain validation in the
21
+ // stream (pins replace CA trust, as in every PinVault session), the TLS 1.2
22
+ // floor of SocketRocket's default, and -evaluateServerTrust:forDomain: →
23
+ // PinVault for the host and port of the socket's own URL.
24
+ #import "RNPinVaultWebSocketPinning.h"
25
+
26
+ #import <Security/Security.h>
27
+ #import <objc/runtime.h>
28
+ #import <os/lock.h>
29
+
30
+ #if __has_include(<RNPinVault/RNPinVault-Swift.h>)
31
+ #import <RNPinVault/RNPinVault-Swift.h>
32
+ #else
33
+ #import "RNPinVault-Swift.h"
34
+ #endif
35
+
36
+ typedef id (*RNPVSocketInit)(id, SEL, NSURLRequest *, NSArray *, id);
37
+
38
+ static os_unfair_lock gLock = OS_UNFAIR_LOCK_INIT;
39
+ static RNPVSocketInit gOriginalInit = NULL;
40
+ static Class gPolicyClass = Nil;
41
+ static char kHostKey;
42
+ static char kPortKey;
43
+
44
+ static NSString *const kSocketClass = @"SRWebSocket";
45
+ static NSString *const kPolicyClass = @"SRSecurityPolicy";
46
+ static NSString *const kInitSelector = @"initWithURLRequest:protocols:securityPolicy:";
47
+ static NSString *const kUpdateSelector = @"updateSecurityOptionsInStream:";
48
+ static NSString *const kEvaluateSelector = @"evaluateServerTrust:forDomain:";
49
+
50
+ // SocketRocket's SRURLRequiresSSL.
51
+ static BOOL RequiresTLS(NSURL *url)
52
+ {
53
+ NSString *scheme = url.scheme.lowercaseString;
54
+ return [scheme isEqualToString:@"wss"] || [scheme isEqualToString:@"https"];
55
+ }
56
+
57
+ static void PinnedUpdateSecurityOptions(id self, SEL _cmd, NSStream *stream)
58
+ {
59
+ [stream setProperty:(__bridge id)CFSTR("kCFStreamSocketSecurityLevelTLSv1_2")
60
+ forKey:(__bridge NSString *)kCFStreamPropertySocketSecurityLevel];
61
+ [stream setProperty:@{(__bridge NSString *)kCFStreamSSLValidatesCertificateChain : @NO}
62
+ forKey:(__bridge NSString *)kCFStreamPropertySSLSettings];
63
+ }
64
+
65
+ static BOOL PinnedEvaluateServerTrust(id self, SEL _cmd, SecTrustRef trust, NSString *domain)
66
+ {
67
+ NSString *host = objc_getAssociatedObject(self, &kHostKey);
68
+ NSNumber *port = objc_getAssociatedObject(self, &kPortKey);
69
+ if (trust == NULL || host.length == 0 || port == nil) {
70
+ return NO;
71
+ }
72
+ return [RNPinVaultReactNetworking acceptsWebSocketTrust:trust host:host port:port.integerValue];
73
+ }
74
+
75
+ // The policy class, made once; Nil when SocketRocket's is missing or has another shape.
76
+ static Class PolicyClass(void)
77
+ {
78
+ static dispatch_once_t once;
79
+ dispatch_once(&once, ^{
80
+ Class base = NSClassFromString(kPolicyClass);
81
+ SEL update = NSSelectorFromString(kUpdateSelector);
82
+ SEL evaluate = NSSelectorFromString(kEvaluateSelector);
83
+ Method baseUpdate = base ? class_getInstanceMethod(base, update) : NULL;
84
+ Method baseEvaluate = base ? class_getInstanceMethod(base, evaluate) : NULL;
85
+ if (baseUpdate == NULL || baseEvaluate == NULL) {
86
+ return;
87
+ }
88
+ Class sub = objc_allocateClassPair(base, "RNPinVaultSRSecurityPolicy", 0);
89
+ if (sub == Nil) {
90
+ return;
91
+ }
92
+ class_addMethod(sub, update, (IMP)PinnedUpdateSecurityOptions, method_getTypeEncoding(baseUpdate));
93
+ class_addMethod(sub, evaluate, (IMP)PinnedEvaluateServerTrust, method_getTypeEncoding(baseEvaluate));
94
+ objc_registerClassPair(sub);
95
+ gPolicyClass = sub;
96
+ });
97
+ return gPolicyClass;
98
+ }
99
+
100
+ // A policy for one socket: the host and port SocketRocket connects to (port 0 → 443, as SRProxyConnect).
101
+ static id PolicyFor(NSURL *url)
102
+ {
103
+ Class cls = PolicyClass();
104
+ if (cls == Nil) {
105
+ return nil; // SocketRocket asks nil, which answers NO: refused.
106
+ }
107
+ id policy = [[cls alloc] init];
108
+ NSUInteger port = url.port.unsignedIntValue;
109
+ objc_setAssociatedObject(policy, &kHostKey, url.host, OBJC_ASSOCIATION_COPY_NONATOMIC);
110
+ objc_setAssociatedObject(policy, &kPortKey, @(port == 0 ? 443 : port), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
111
+ return policy;
112
+ }
113
+
114
+ static id PinnedInit(id self, SEL _cmd, NSURLRequest *request, NSArray *protocols, id securityPolicy)
115
+ {
116
+ if (RequiresTLS(request.URL)) {
117
+ securityPolicy = PolicyFor(request.URL);
118
+ }
119
+ return gOriginalInit(self, _cmd, request, protocols, securityPolicy);
120
+ }
121
+
122
+ static Method SocketInit(void)
123
+ {
124
+ Class socket = NSClassFromString(kSocketClass);
125
+ return socket ? class_getInstanceMethod(socket, NSSelectorFromString(kInitSelector)) : NULL;
126
+ }
127
+
128
+ // Info.plist PinVaultPinReactNativeNetworking = NO: the native opt-out of the https handler covers this too.
129
+ static BOOL Enabled(void)
130
+ {
131
+ id value = [NSBundle.mainBundle objectForInfoDictionaryKey:@"PinVaultPinReactNativeNetworking"];
132
+ return ![value respondsToSelector:@selector(boolValue)] || [value boolValue];
133
+ }
134
+
135
+ @implementation RNPinVaultWebSocketPinning
136
+
137
+ + (void)load
138
+ {
139
+ [self install];
140
+ }
141
+
142
+ + (BOOL)install
143
+ {
144
+ if (!Enabled()) {
145
+ return NO;
146
+ }
147
+ os_unfair_lock_lock(&gLock);
148
+ BOOL installed = NO;
149
+ Method method = SocketInit();
150
+ if (method != NULL && gOriginalInit != NULL) {
151
+ installed = method_getImplementation(method) == (IMP)PinnedInit;
152
+ } else if (method != NULL && PolicyClass() != Nil &&
153
+ strcmp(method_getTypeEncoding(method) ?: "", "@40@0:8@16@24@32") == 0) {
154
+ gOriginalInit = (RNPVSocketInit)method_getImplementation(method);
155
+ method_setImplementation(method, (IMP)PinnedInit);
156
+ installed = YES;
157
+ }
158
+ os_unfair_lock_unlock(&gLock);
159
+ return installed;
160
+ }
161
+
162
+ + (BOOL)isPinned
163
+ {
164
+ if (NSClassFromString(kSocketClass) == Nil) {
165
+ return YES; // No SocketRocket: React Native has no WebSocket to pin.
166
+ }
167
+ return [self install];
168
+ }
169
+
170
+ @end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umutcansu/react-native-pinvault",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "PinVault for React Native: dynamic certificate pinning with signed remote configs, mTLS enrollment and signed vault files, as a thin bridge over the native PinVault libraries",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -10,7 +10,8 @@
10
10
  "types": "./lib/typescript/src/index.d.ts",
11
11
  "default": "./lib/module/index.js"
12
12
  },
13
- "./package.json": "./package.json"
13
+ "./package.json": "./package.json",
14
+ "./app.plugin.js": "./app.plugin.js"
14
15
  },
15
16
  "files": [
16
17
  "src",
@@ -23,6 +24,8 @@
23
24
  "ios/*.swift",
24
25
  "ios/Core",
25
26
  "*.podspec",
27
+ "app.plugin.js",
28
+ "plugin",
26
29
  "README.md",
27
30
  "CHANGELOG.md",
28
31
  "LICENSE",
@@ -43,7 +46,8 @@
43
46
  "mtls",
44
47
  "security",
45
48
  "ios",
46
- "android"
49
+ "android",
50
+ "expo"
47
51
  ],
48
52
  "repository": {
49
53
  "type": "git",
@@ -62,9 +66,10 @@
62
66
  },
63
67
  "peerDependencies": {
64
68
  "react": "*",
65
- "react-native": ">=0.87.0"
69
+ "react-native": ">=0.81.0"
66
70
  },
67
71
  "devDependencies": {
72
+ "@expo/config-plugins": "^54.0.5",
68
73
  "@jest/globals": "^29.7.0",
69
74
  "@react-native/babel-preset": "0.87.1",
70
75
  "@react-native/jest-preset": "0.87.1",
@@ -0,0 +1,232 @@
1
+ // Expo config plugin for @umutcansu/react-native-pinvault: does at
2
+ // `expo prebuild` what the README's Install and "Native security file"
3
+ // sections ask of a bare React Native app.
4
+ //
5
+ // plugins: [["@umutcansu/react-native-pinvault", {
6
+ // nativeSecurityFile: "./pinvault_security.json", // → Android assets + iOS app bundle
7
+ // faceIDPermission: "Unlock your protected files", // NSFaceIDUsageDescription (userAuth vault files)
8
+ // backgroundUpdates: true, // schedulePeriodicUpdates on iOS
9
+ // allowNoNativeSecurityFile: false, // release builds without the file (not recommended)
10
+ // pinReactNativeNetworking: true // false = RN's own networking stays unpinned
11
+ // }]]
12
+ //
13
+ // Every option is optional. Without `nativeSecurityFile` a release build
14
+ // refuses a config with Config APIs or static pins (README, "Native security
15
+ // file") unless `allowNoNativeSecurityFile` is true.
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ let configPlugins;
20
+ try {
21
+ configPlugins = require('expo/config-plugins');
22
+ } catch (e) {
23
+ configPlugins = require('@expo/config-plugins');
24
+ }
25
+ const {
26
+ AndroidConfig,
27
+ IOSConfig,
28
+ withAndroidManifest,
29
+ withAppDelegate,
30
+ withDangerousMod,
31
+ withInfoPlist,
32
+ withPodfileProperties,
33
+ withXcodeProject,
34
+ createRunOncePlugin,
35
+ } = configPlugins;
36
+
37
+ const pkg = require('../package.json');
38
+
39
+ const SECURITY_FILE = 'pinvault_security.json';
40
+ const BG_TASK_ID = 'io.github.umutcansu.pinvault.refresh';
41
+ const MIN_IOS = '16.0';
42
+ const ALLOW_NO_FILE_META = 'io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE';
43
+ const NETWORKING_PROVIDER = 'io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer';
44
+ const KNOWN_OPTIONS = [
45
+ 'nativeSecurityFile',
46
+ 'faceIDPermission',
47
+ 'backgroundUpdates',
48
+ 'allowNoNativeSecurityFile',
49
+ 'pinReactNativeNetworking',
50
+ ];
51
+
52
+ function checkOptions(options) {
53
+ const props = options || {};
54
+ const unknown = Object.keys(props).filter((k) => !KNOWN_OPTIONS.includes(k));
55
+ if (unknown.length > 0) {
56
+ throw new Error(`[${pkg.name}] unknown plugin option(s): ${unknown.join(', ')} (known: ${KNOWN_OPTIONS.join(', ')})`);
57
+ }
58
+ for (const k of ['backgroundUpdates', 'allowNoNativeSecurityFile', 'pinReactNativeNetworking']) {
59
+ if (props[k] !== undefined && typeof props[k] !== 'boolean') throw new Error(`[${pkg.name}] ${k} must be true or false`);
60
+ }
61
+ for (const k of ['nativeSecurityFile', 'faceIDPermission']) {
62
+ if (props[k] !== undefined && (typeof props[k] !== 'string' || props[k].trim() === '')) {
63
+ throw new Error(`[${pkg.name}] ${k} must be a non-empty string`);
64
+ }
65
+ }
66
+ return props;
67
+ }
68
+
69
+ /** "15.1" < "16.0": dotted numeric versions. */
70
+ function versionLess(a, b) {
71
+ const x = String(a).split('.').map(Number);
72
+ const y = String(b).split('.').map(Number);
73
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
74
+ const d = (x[i] || 0) - (y[i] || 0);
75
+ if (d !== 0) return d < 0;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /** The project's security file, read and parsed (a JSON error stops prebuild, not the app at launch). */
81
+ function readSecurityFile(projectRoot, relative) {
82
+ const source = path.resolve(projectRoot, relative);
83
+ if (!fs.existsSync(source)) throw new Error(`[${pkg.name}] nativeSecurityFile not found: ${source}`);
84
+ const text = fs.readFileSync(source, 'utf8');
85
+ try {
86
+ const parsed = JSON.parse(text);
87
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not a JSON object');
88
+ } catch (e) {
89
+ throw new Error(`[${pkg.name}] nativeSecurityFile ${source} is not a JSON object: ${e.message}`);
90
+ }
91
+ return text;
92
+ }
93
+
94
+ /**
95
+ * AppDelegate.swift: `import RNPinVault` and the background task registration
96
+ * as the first line of didFinishLaunchingWithOptions. Idempotent.
97
+ */
98
+ function addBackgroundTaskToAppDelegate(contents, language) {
99
+ if (language !== 'swift') {
100
+ throw new Error(`[${pkg.name}] backgroundUpdates needs a Swift AppDelegate (Expo SDK 53+); add the call by hand: README, "Install"`);
101
+ }
102
+ if (contents.includes('PinVaultBridge.registerBackgroundTask()')) return contents;
103
+ let out = contents;
104
+ if (!/^import RNPinVault$/m.test(out)) {
105
+ const imports = out.match(/^import .+$/gm);
106
+ if (!imports) throw new Error(`[${pkg.name}] AppDelegate.swift has no import lines`);
107
+ const last = imports[imports.length - 1];
108
+ out = out.replace(last, `${last}\nimport RNPinVault`);
109
+ }
110
+ const launch = /(didFinishLaunchingWithOptions launchOptions:[^{]*\{\n)/;
111
+ if (!launch.test(out)) {
112
+ throw new Error(`[${pkg.name}] application(_:didFinishLaunchingWithOptions:) not found in AppDelegate.swift`);
113
+ }
114
+ return out.replace(
115
+ launch,
116
+ '$1 // @umutcansu/react-native-pinvault: schedulePeriodicUpdates (before launch ends)\n' +
117
+ ' _ = PinVaultBridge.registerBackgroundTask()\n'
118
+ );
119
+ }
120
+
121
+ /** Info.plist entries the options ask for. */
122
+ function applyInfoPlist(plist, props) {
123
+ const out = { ...plist };
124
+ if (props.faceIDPermission) out.NSFaceIDUsageDescription = props.faceIDPermission;
125
+ if (props.backgroundUpdates) {
126
+ const ids = Array.isArray(out.BGTaskSchedulerPermittedIdentifiers) ? [...out.BGTaskSchedulerPermittedIdentifiers] : [];
127
+ if (!ids.includes(BG_TASK_ID)) ids.push(BG_TASK_ID);
128
+ out.BGTaskSchedulerPermittedIdentifiers = ids;
129
+ const modes = Array.isArray(out.UIBackgroundModes) ? [...out.UIBackgroundModes] : [];
130
+ if (!modes.includes('fetch')) modes.push('fetch');
131
+ out.UIBackgroundModes = modes;
132
+ }
133
+ if (props.allowNoNativeSecurityFile === true) out.PinVaultAllowNoNativeSecurityFile = true;
134
+ else delete out.PinVaultAllowNoNativeSecurityFile;
135
+ if (props.pinReactNativeNetworking === false) out.PinVaultPinReactNativeNetworking = false;
136
+ else delete out.PinVaultPinReactNativeNetworking;
137
+ return out;
138
+ }
139
+
140
+ /** AndroidManifest: the allow-no-file meta-data and the networking provider opt-out. */
141
+ function applyAndroidManifest(manifest, props) {
142
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
143
+ if (props.allowNoNativeSecurityFile === true) {
144
+ AndroidConfig.Manifest.addMetaDataItemToMainApplication(app, ALLOW_NO_FILE_META, 'true');
145
+ } else {
146
+ AndroidConfig.Manifest.removeMetaDataItemFromMainApplication(app, ALLOW_NO_FILE_META);
147
+ }
148
+ app.provider = (app.provider || []).filter((p) => p.$['android:name'] !== NETWORKING_PROVIDER);
149
+ if (props.pinReactNativeNetworking === false) {
150
+ manifest.manifest.$['xmlns:tools'] = manifest.manifest.$['xmlns:tools'] || 'http://schemas.android.com/tools';
151
+ app.provider.push({
152
+ $: {
153
+ 'android:name': NETWORKING_PROVIDER,
154
+ 'android:authorities': '${applicationId}.pinvault-networking',
155
+ 'tools:node': 'remove',
156
+ },
157
+ });
158
+ }
159
+ return manifest;
160
+ }
161
+
162
+ function withPinVault(config, options) {
163
+ const props = checkOptions(options);
164
+
165
+ // iOS 16: the PinVault Swift package's minimum.
166
+ config = withPodfileProperties(config, (c) => {
167
+ const current = c.modResults['ios.deploymentTarget'];
168
+ if (!current || versionLess(current, MIN_IOS)) c.modResults['ios.deploymentTarget'] = MIN_IOS;
169
+ return c;
170
+ });
171
+ config = withXcodeProject(config, (c) => {
172
+ const configurations = c.modResults.pbxXCBuildConfigurationSection();
173
+ for (const key of Object.keys(configurations)) {
174
+ const settings = configurations[key].buildSettings;
175
+ if (!settings || settings.IPHONEOS_DEPLOYMENT_TARGET === undefined) continue;
176
+ if (versionLess(String(settings.IPHONEOS_DEPLOYMENT_TARGET).replace(/"/g, ''), MIN_IOS)) {
177
+ settings.IPHONEOS_DEPLOYMENT_TARGET = MIN_IOS;
178
+ }
179
+ }
180
+ return c;
181
+ });
182
+
183
+ config = withInfoPlist(config, (c) => {
184
+ c.modResults = applyInfoPlist(c.modResults, props);
185
+ return c;
186
+ });
187
+ config = withAndroidManifest(config, (c) => {
188
+ c.modResults = applyAndroidManifest(c.modResults, props);
189
+ return c;
190
+ });
191
+
192
+ if (props.backgroundUpdates) {
193
+ config = withAppDelegate(config, (c) => {
194
+ c.modResults.contents = addBackgroundTaskToAppDelegate(c.modResults.contents, c.modResults.language);
195
+ return c;
196
+ });
197
+ }
198
+
199
+ if (props.nativeSecurityFile) {
200
+ // Android: assets (sealed by the APK signature; README, "Native security file").
201
+ config = withDangerousMod(config, [
202
+ 'android',
203
+ async (c) => {
204
+ const text = readSecurityFile(c.modRequest.projectRoot, props.nativeSecurityFile);
205
+ const assets = path.join(c.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets');
206
+ fs.mkdirSync(assets, { recursive: true });
207
+ fs.writeFileSync(path.join(assets, SECURITY_FILE), text);
208
+ return c;
209
+ },
210
+ ]);
211
+ // iOS: a resource of the app target (sealed by the code signature).
212
+ config = withXcodeProject(config, (c) => {
213
+ const text = readSecurityFile(c.modRequest.projectRoot, props.nativeSecurityFile);
214
+ const projectName = c.modRequest.projectName;
215
+ fs.writeFileSync(path.join(c.modRequest.platformProjectRoot, projectName, SECURITY_FILE), text);
216
+ const file = `${projectName}/${SECURITY_FILE}`;
217
+ if (!c.modResults.hasFile(file)) {
218
+ c.modResults = IOSConfig.XcodeUtils.addResourceFileToGroup({
219
+ filepath: file,
220
+ groupName: projectName,
221
+ project: c.modResults,
222
+ isBuildFile: true,
223
+ });
224
+ }
225
+ return c;
226
+ });
227
+ }
228
+ return config;
229
+ }
230
+
231
+ module.exports = createRunOncePlugin(withPinVault, pkg.name, pkg.version);
232
+ module.exports.internal = { addBackgroundTaskToAppDelegate, applyInfoPlist, applyAndroidManifest, checkOptions, versionLess };