@umutcansu/react-native-pinvault 2.4.0 → 2.4.1

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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.1 — 2026-10-10
4
+
5
+ ### Security
6
+
7
+ - **React Native's WebSocket is pinned on iOS.** `wss://` went through
8
+ SocketRocket on CFStream with the platform's CA check only, so a socket.io /
9
+ graphql-ws connection could be intercepted by anyone holding a certificate
10
+ the device trusts (a corporate proxy, a user-installed root), and `start`
11
+ still reported RN's networking as pinned. Every SocketRocket socket to a
12
+ `wss` / `https` URL now gets a security policy that asks PinVault (the
13
+ library's new `evaluateServerTrust(_:host:port:)`) before the upgrade
14
+ request is written; subprotocols are kept. `requirePinnedReactNativeNetworking`
15
+ covers it (`E_NETWORKING_NOT_PINNED` when the hook is not in place), and
16
+ Info.plist `PinVaultPinReactNativeNetworking` = NO turns it off with the
17
+ https handler. A WebSocket to a host that requires a client certificate is
18
+ not supported on iOS yet (the handshake fails).
19
+ - **Android: every WebSocket goes through PinVault's hook of its own.** RN's
20
+ `WebSocketModule` has a separate `setCustomClientBuilder`; up to 0.81 it
21
+ builds each socket's client from a bare `OkHttpClient.Builder()`, outside
22
+ `OkHttpClientProvider`, so there the socket kept the system's trust while
23
+ `start` reported RN's networking as pinned. The plugin now installs that hook
24
+ too and checks it with the other two (`HookStatus.webSocketBuilderIsPinVault`),
25
+ so a library replacing it is also caught on 0.87.
26
+
27
+ - **A release build needs the anchors in the native security file:** a declared
28
+ block must carry `bootstrapPins` (or `allowUnpinnedConfigApi`) and
29
+ `signaturePublicKeys` (or `allowUnsigned`). A file with a bare
30
+ `{"id": …}` satisfied the release rule while the bundle supplied the pins
31
+ and keys; it now rejects `start` with `E_INVALID_CONFIG` naming the field.
32
+ - **`clientCertHosts` from JS only where the file names them** (release
33
+ builds): an OTA bundle could otherwise present the device's mTLS identity
34
+ to another pinned host. `tokenHosts` from JS is still taken: it only
35
+ narrows the library's default.
36
+
37
+ ### Changed
38
+
39
+ - **React Native 0.81+** (was 0.87+): built and run on 0.81.6 and 0.87.1,
40
+ Android and iOS (start with `requirePinnedReactNativeNetworking`, RN `fetch`
41
+ and `WebSocket` to a pinned and an unpinned host).
42
+
3
43
  ## 2.4.0 — 2026-10-09
4
44
 
5
45
  ### 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.0
33
+ uses `io.github.umutcansu:pinvault:2.4.0` from Maven Central and the Swift
34
+ package at tag `v2.4.0`. 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
  |---|---|
@@ -207,11 +211,18 @@ parsed as strictly; a broken file rejects `start` with `E_INVALID_CONFIG`
207
211
  - a field the file declares is the value: JS may leave it out (the native value
208
212
  applies) or repeat it (lists in any order); a different value rejects `start`
209
213
  with `E_INVALID_CONFIG` naming the field;
214
+ - in a release build a declared block must carry its anchors: `bootstrapPins`
215
+ (or `allowUnpinnedConfigApi`) and `signaturePublicKeys` (or `allowUnsigned`);
216
+ a block that only names itself rejects `start` — otherwise the bundle would
217
+ supply the pins and keys;
210
218
  - `allowUnsigned`, `allowUnpinnedConfigApi` and `allowServerGeneratedKey` from
211
219
  JS are refused unless the file allows them for that block;
212
220
  - a block's `url`, `enrollmentUrl`, `renewalUrl`, `tokenHosts` and
213
221
  `clientCertHosts` are fixed where the file gives them (where the block talks to, and who gets its token and identity),
214
222
  and `attestation: true` / `proofOfPossession: true` there cannot be turned off from JS;
223
+ in a release build JS may give `clientCertHosts` only where the file names
224
+ them (the bundle cannot hand the device's identity to another host;
225
+ `tokenHosts` from JS only narrows the library's default);
215
226
  - `require` only tightens: a protection it turns on stays on whatever JS says,
216
227
  its `requireCaTrust` hosts are added to JS's, the expected signer / bundle /
217
228
  team ids and iOS `userAuthStrength` are fixed, and `expiredConfigGraceSeconds`
@@ -250,8 +261,8 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
250
261
  back) on either platform. Request bodies ≤ 10 MiB, responses ≤ 10 MiB by
251
262
  default (`maxResponseBytes`, at most 50 MiB) — enforced while the body is
252
263
  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:
264
+ - **React Native's own `fetch`, `XMLHttpRequest`, `<Image>` and `WebSocket`
265
+ are pinned on both platforms.** The rules are the same on both:
255
266
  - **Before `start()`** — and after a start that failed — every `https`
256
267
  request through RN's networking fails (`PinVault has not started`): fail
257
268
  closed, as the native `getClient()` / `session()` before `init`. Plain
@@ -265,7 +276,7 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
265
276
  not go through PinVault (below); on Android a hook replaced after start
266
277
  then refuses RN's https too (checked every few seconds). With it off a
267
278
  warning is logged.
268
- - **Android** (two hooks of RN 0.87, read from its sources), installed by the
279
+ - **Android** (three hooks, read from RN 0.81 and 0.87's sources), installed by the
269
280
  plugin's content provider before `Application.onCreate`, so every client RN
270
281
  builds is covered:
271
282
  `NetworkingModule.setCustomClientBuilder` is called for every fetch / XHR
@@ -275,9 +286,14 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
275
286
  covers the clients RN builds once — the networking base client, the
276
287
  WebSocket / dev-support singleton and Fresco's image client: their TLS goes
277
288
  through a forwarding socket factory to the current pinned one.
278
- Another library that calls either setter after PinVault (a network inspector,
289
+ `WebSocketModule.setCustomClientBuilder` is called for every `WebSocket`: up
290
+ to 0.81 RN builds each socket's client from a bare `OkHttpClient.Builder()`,
291
+ outside `OkHttpClientProvider`, so without this hook a socket would get the
292
+ system's trust; from 0.87 it derives from the provider's client. Either way
293
+ the socket gets the same forwarding factory.
294
+ Another library that calls any of these setters after PinVault (a network inspector,
279
295
  a crash reporter, another pinning package) replaces the hook: `start()` and
280
- every `PinVault.fetch` check both (`PinVaultNetworking.status()`) and log a
296
+ every `PinVault.fetch` check all three (`PinVaultNetworking.status()`) and log a
281
297
  warning; `requirePinnedReactNativeNetworking` turns it into a failed start.
282
298
  Opt out natively by removing the provider in the app's manifest —
283
299
  `android.pinGlobalNetworking: false` alone is refused while the hooks are in
@@ -302,13 +318,21 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
302
318
  body in chunks and the completion after the body has been read (progress
303
319
  events arrive at the end); cancelling a request cancels it. `start` checks
304
320
  which handler RCTNetworking picks for an `https` request.
321
+ **WebSocket:** RN's `RCTWebSocketModule` uses SocketRocket on CFStream, not
322
+ URLSession. Every SocketRocket initializer ends in
323
+ `-initWithURLRequest:protocols:securityPolicy:`; the plugin replaces that
324
+ method at load (`RNPinVaultWebSocketPinning.mm`), so a socket to a `wss` /
325
+ `https` URL always gets a security policy that asks
326
+ `PinVault.shared.evaluateServerTrust(_:host:port:)` — the same pins, host
327
+ check and fail-closed rule as the https handler — whatever policy its creator
328
+ passed. SocketRocket judges the trust before it writes the upgrade request,
329
+ so a refused chain sends no header or cookie. Subprotocols are kept
330
+ (`RCTSetCustomSRWebSocketProvider` is not used: its block never sees them).
331
+ `start` checks the hook is still the initializer's implementation. A
332
+ WebSocket to a host that requires a client certificate fails on iOS (no
333
+ identity is presented); use it over TLS, or Android.
305
334
  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`.
335
+ = NO (both the https handler and the WebSocket hook).
312
336
  Keep App Transport Security on (no `NSAllowsArbitraryLoads`).
313
337
 
314
338
  ## Enrollment and vault tokens
@@ -373,8 +397,8 @@ as one more signal. The decisive check is server-side attestation
373
397
  ## Security notes (OWASP MASVS)
374
398
 
375
399
  - **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
400
+ redirects are not followed; RN's own fetch / XHR / images / WebSocket are pinned on
401
+ both platforms, fail closed before `start`, and a replaced
378
402
  hook is detected.
379
403
  - **Trust anchors** — release builds take the relaxations only from the native
380
404
  security file; with the file, JS cannot change pins, keys or scopes.
@@ -420,7 +444,9 @@ repository's `:pinvault` (unsigned) into `android/build/pinvault-maven` at every
420
444
  build and resolves `io.github.umutcansu:pinvault` only from there — a composite
421
445
  build is not possible because PinVault builds with AGP 8.7 and React Native 0.87
422
446
  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.
447
+ and the podspec hands `spm_dependency` that local path instead of the git tag. A
448
+ local path needs a React Native whose `spm_dependency` takes one (0.87 does,
449
+ 0.81 takes git URLs only); apps on 0.81 use the git tag, as every app outside the repository does.
424
450
 
425
451
  ## Known limits
426
452
 
@@ -429,10 +455,13 @@ and the podspec hands `spm_dependency` that local path instead of the git tag.
429
455
  `build_auth_list`); the keystore daemon then keeps a dead connection and every
430
456
  Keystore operation fails until it is restarted. Turn `requireUnlockedDevice`
431
457
  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
458
+ - iOS: RN's https answers are delivered after the whole (bounded) body is read,
459
+ and a WebSocket presents no client certificate (above). The WebSocket hook
460
+ expects SocketRocket 0.7's designated initializer; another shape reads as
461
+ not pinned.
462
+ - Android: the networking hooks are detected by reading three private fields
463
+ of React Native (`OkHttpClientProvider.factory`,
464
+ `NetworkingModule.customClientBuilder`, `WebSocketModule.customClientBuilder`;
465
+ the same in 0.81 and 0.87, the consumer R8 rules keep them). A
437
466
  React Native version that renames them reads as "could not be read", which
438
467
  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.1);
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.1",
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
@@ -326,6 +348,9 @@ internal object ConfigParser {
326
348
  throw BridgeInputException("${f.path}.key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
327
349
  }
328
350
  val endpoint = f.requireString("endpoint", 512)
351
+ if (endpoint.contains("://") || endpoint.startsWith("/") || endpoint.startsWith("\\")) {
352
+ throw BridgeInputException("${f.path}.endpoint: must be a relative path, not an absolute URL")
353
+ }
329
354
  val jsSignatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
330
355
  val updateWithPins = f.bool("updateWithPins")
331
356
  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
@@ -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") }
@@ -349,6 +369,9 @@ public enum ConfigParser {
349
369
  static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore, native: NativeSecurity?) throws {
350
370
  let key = try f.requireString("key", maxLength: 128)
351
371
  let endpoint = try f.requireString("endpoint", maxLength: 512)
372
+ if endpoint.contains("://") || endpoint.hasPrefix("/") || endpoint.hasPrefix("\\") {
373
+ throw BridgeInputError("\(f.path).endpoint: must be a relative path, not an absolute URL")
374
+ }
352
375
  let jsSignatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
353
376
  let updateWithPins = try f.bool("updateWithPins")
354
377
  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.1",
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",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "peerDependencies": {
64
64
  "react": "*",
65
- "react-native": ">=0.87.0"
65
+ "react-native": ">=0.81.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@jest/globals": "^29.7.0",