@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 +40 -0
- package/README.md +55 -26
- package/RNPinVault.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/consumer-rules.pro +3 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +31 -6
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +15 -1
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +29 -12
- package/ios/Core/ConfigParser.swift +29 -6
- package/ios/Core/NativeSecurity.swift +16 -1
- package/ios/PinVaultBridge.swift +8 -3
- package/ios/PinVaultReactNetworking.swift +16 -0
- package/ios/RNPinVault.mm +6 -0
- package/ios/RNPinVaultWebSocketPinning.h +21 -0
- package/ios/RNPinVaultWebSocketPinning.mm +170 -0
- package/package.json +2 -2
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.
|
|
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.
|
|
32
|
-
uses `io.github.umutcansu:pinvault:2.
|
|
33
|
-
package at tag `v2.
|
|
34
|
-
0.
|
|
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`).
|
|
39
|
-
|
|
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
|
|
254
|
-
|
|
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** (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
377
|
-
platforms
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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.
|
package/RNPinVault.podspec
CHANGED
|
@@ -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.
|
|
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"
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
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
|
|
227
|
-
val healthEndpoint = b
|
|
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
|
|
244
|
-
val clientCertEndpoint = b
|
|
245
|
-
val vaultReportEndpoint = b
|
|
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,
|
|
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
|
package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
234
|
-
let healthEndpoint = try b
|
|
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
|
|
251
|
-
let clientCertEndpoint = try b
|
|
252
|
-
let vaultReportEndpoint = try b
|
|
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",
|
|
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") }
|
package/ios/PinVaultBridge.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
65
|
+
"react-native": ">=0.81.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@jest/globals": "^29.7.0",
|