@umutcansu/react-native-pinvault 0.0.0-stage → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -2
  4. package/RNPinVault.podspec +43 -0
  5. package/android/build.gradle +68 -0
  6. package/android/consumer-rules.pro +25 -0
  7. package/android/src/main/AndroidManifest.xml +16 -0
  8. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +396 -0
  9. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
  10. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +354 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -0
  13. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
  14. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
  15. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
  16. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
  17. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
  18. package/ios/Core/ConfigParser.swift +437 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +303 -0
  21. package/ios/Core/PinnedFetch.swift +119 -0
  22. package/ios/Core/ReactNetworking.swift +172 -0
  23. package/ios/Core/ResultMapper.swift +163 -0
  24. package/ios/Core/StrictJSON.swift +214 -0
  25. package/ios/Core/VaultTokenStore.swift +66 -0
  26. package/ios/PinVaultBridge.swift +424 -0
  27. package/ios/PinVaultReactNetworking.swift +47 -0
  28. package/ios/RNPinVault.h +7 -0
  29. package/ios/RNPinVault.mm +293 -0
  30. package/ios/RNPinVaultURLRequestHandler.mm +92 -0
  31. package/lib/module/NativePinVault.js +13 -0
  32. package/lib/module/NativePinVault.js.map +1 -0
  33. package/lib/module/index.js +331 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/types.js +2 -0
  37. package/lib/module/types.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/NativePinVault.d.ts +50 -0
  40. package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +150 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/lib/typescript/src/types.d.ts +359 -0
  44. package/lib/typescript/src/types.d.ts.map +1 -0
  45. package/package.json +128 -4
  46. package/src/NativePinVault.ts +72 -0
  47. package/src/index.ts +398 -0
  48. package/src/types.ts +362 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,84 @@
1
+ # Changelog
2
+
3
+ ## 2.4.0 — 2026-10-09
4
+
5
+ ### Breaking
6
+
7
+ - **Release builds need the native security file** when the config has Config
8
+ APIs or static pins (Android manifest meta-data
9
+ `io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE` / iOS Info.plist
10
+ `PinVaultAllowNoNativeSecurityFile` = YES opts out).
11
+ - **`requirePinnedReactNativeNetworking` is on by default in release builds.**
12
+ - **`expiredConfigGrace` is at most 7 days in release builds.**
13
+ - **Vault file keys** must match `[A-Za-z0-9._-]{1,64}` (not only dots).
14
+
15
+ ### Security
16
+
17
+ - The native security file may also fix a block's `url`, `tokenHosts`,
18
+ `clientCertHosts` and `attestation`, carry a `require` section (protections
19
+ JS cannot switch off) and `vaultFiles` (signature key, encryption, lock).
20
+ - `start` answers `nativeSecurityApplied`.
21
+ - `proofOfPossession` on a Config API (JS or the native security file, where
22
+ it cannot be switched off): the native clients add a `PinVault-Proof` signed
23
+ by the device key to every request that carries the token (ATTESTATION.md §5.1).
24
+ - Android: React Native's long-lived clients (WebSocket, images) run the
25
+ current start's network interceptors (the per-request pin check) and their
26
+ pooled connections are closed on every start and reset; a hook installed only
27
+ by `start()` is reported as not pinned; a hook replaced after start refuses
28
+ https while `requirePinnedReactNativeNetworking` is on.
29
+ - The JS guard of a start the native side refuses is put back.
30
+ - `LOAD_FILE` is a guarded operation (`loadFile`).
31
+
32
+ ## 2.3.2 — 2026-10-08 — first release
33
+
34
+ First published version (2.3.1 was never published). Needs PinVault 2.3.2 on both platforms.
35
+
36
+ ### MASVS audit fixes
37
+
38
+ - **Native security file** (`pinvault_security.json`: Android assets, iOS app
39
+ bundle): bootstrap pins, signing / recovery keys, `requiredSignatures`,
40
+ `serverScope`, `clientCaPins` and the three relaxations declared outside the
41
+ JS bundle. With it, JS may only repeat them (a different value, an undeclared
42
+ Config API or undeclared `staticPins` → `E_INVALID_CONFIG`); without it,
43
+ release builds refuse `allowUnsigned`, `allowUnpinnedConfigApi` and
44
+ `allowServerGeneratedKey` from JS.
45
+ - **Android networking hook installed automatically** by a content provider
46
+ before `Application.onCreate` (`PinVaultNetworking.install` stays as the
47
+ alternative). `start` and every `fetch` detect a replaced OkHttp factory /
48
+ custom client builder (`PinVaultNetworking.status()`) and warn;
49
+ `requirePinnedReactNativeNetworking: true` fails `start` with
50
+ `E_NETWORKING_NOT_PINNED`. RN's fetch / XHR get no disk cache and no cookie
51
+ jar by default (`android.keepReactNativeHttpCache` / `keepReactNativeCookies`),
52
+ no `https` → `http` redirects; Fresco's image client is covered too.
53
+ `android.pinGlobalNetworking: false` is refused while the hooks are installed
54
+ (opt out natively). Consumer R8 rules for the release build of a React Native
55
+ 0.87 app (Error Prone annotations, WorkManager's Room database).
56
+ - **iOS: React Native's own fetch / XHR / `<Image>` pinned** by
57
+ `RNPinVaultURLRequestHandler` (an `RCTURLRequestHandler`, priority over
58
+ `RCTHTTPRequestHandler` for https) on `PinVault.shared.session()`: fail closed
59
+ before `start`, bounded answers (`ios.reactNativeMaxResponseBytes`), no
60
+ `https` → `http` redirects, cancellation. Opt out with the Info.plist key
61
+ `PinVaultPinReactNativeNetworking` = NO. WebSocket stays unpinned on iOS
62
+ (README).
63
+ - iOS `fetch` enforces `maxResponseBytes` while reading (needs the library's new
64
+ `PinnedSession.data(for:maxResponseBytes:)`).
65
+ - Deep nesting refused before the JSON parsers recurse (JS, Kotlin, Swift);
66
+ Android's `StackOverflowError` can no longer escape.
67
+ - A refused second `start` keeps the running `environmentGuard`; the iOS guard
68
+ asks JS before the library call, so no thread waits for JS.
69
+ - README: token handling described as it is (JS values until garbage
70
+ collection; native memory only on the plugin's side).
71
+
72
+ ### Initial bridge
73
+
74
+ First release of the React Native plugin, aligned with PinVault 2.3.1
75
+ (Android `io.github.umutcansu:pinvault:2.3.1`, iOS Swift package `v2.3.1`).
76
+
77
+ - TurboModule (New Architecture, React Native 0.87+): `start`, pinned `fetch`,
78
+ enrollment, vault files, attestation and connection events, with the native
79
+ names and result shapes.
80
+ - Strict native config parsing (unknown keys, wrong types and oversized input are
81
+ refused), tokens in native memory only, redacted messages.
82
+ - `environmentGuard` as an async JS callback with a native deadline (fail closed).
83
+ - Android: React Native's own `fetch` / `XMLHttpRequest` / `WebSocket` pinned with
84
+ `PinVault.applyTo` (`PinVaultNetworking`). iOS: not pinned, warned at `start`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Umut Cansu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,438 @@
1
- # Temporary Holding Version
1
+ # PinVault for React Native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@umutcansu/react-native-pinvault)](https://www.npmjs.com/package/@umutcansu/react-native-pinvault)
4
+
5
+ `@umutcansu/react-native-pinvault` — dynamic certificate pinning with a signed,
6
+ remotely updated pin config, mTLS enrollment with a hardware identity key,
7
+ signed and encrypted vault files, and device attestation, for React Native
8
+ apps on Android and iOS.
9
+
10
+ It is a thin bridge over the two native libraries — Android
11
+ [`io.github.umutcansu:pinvault`](https://github.com/umutcansu/PinVault/blob/main/pinvault) and the iOS Swift package
12
+ [`PinVault`](https://github.com/umutcansu/PinVault/blob/main/pinvault-ios/README.md). Pinning, keys (Android Keystore /
13
+ Secure Enclave), signature checks and vault decryption all stay native: no
14
+ crypto runs in JavaScript, and JavaScript never sees TLS. Names are the native
15
+ ones (`InitResult`, `ClientCertEnrollmentResult`, `VaultFileResult`, …), so the
16
+ [library documentation](https://github.com/umutcansu/PinVault/blob/main/GUIDE.md) applies as it is.
17
+
18
+ - React Native **0.87+**, New Architecture only (TurboModule, codegen), Hermes.
19
+ - Android minSdk 24; iOS 16+.
20
+ - No runtime dependencies: `react` and `react-native` are peers.
21
+
22
+ ## Install
23
+
24
+ From npm ([`@umutcansu/react-native-pinvault`](https://www.npmjs.com/package/@umutcansu/react-native-pinvault)):
25
+
26
+ ```bash
27
+ npm install @umutcansu/react-native-pinvault
28
+ cd ios && pod install
29
+ ```
30
+
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).
35
+
36
+ **iOS.** The pod depends on the PinVault Swift package through React Native's
37
+ `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:
40
+
41
+ | Key | Why |
42
+ |---|---|
43
+ | `NSFaceIDUsageDescription` | vault files behind the screen lock (`userAuth`) |
44
+ | `BGTaskSchedulerPermittedIdentifiers` = `io.github.umutcansu.pinvault.refresh`, `UIBackgroundModes` = `fetch` | `schedulePeriodicUpdates` |
45
+
46
+ Register the background task in `AppDelegate` before launch ends (the app target
47
+ does not link the Swift package itself, the pod exposes the call):
48
+
49
+ ```swift
50
+ import RNPinVault
51
+ // application(_:didFinishLaunchingWithOptions:)
52
+ _ = PinVaultBridge.registerBackgroundTask()
53
+ ```
54
+
55
+ **Android.** The module depends on `io.github.umutcansu:pinvault` (same version
56
+ as this package; override with the Gradle property `pinvault.version`). React
57
+ Native's own networking is pinned with no code of yours: the plugin's manifest
58
+ declares a content provider (`PinVaultNetworkingInitializer`) that installs the
59
+ hooks before `Application.onCreate` runs (see [Networking](#networking)).
60
+
61
+ **Release builds.** Put the trust anchors into the app itself, not only into
62
+ the JS bundle: [Native security file](#native-security-file).
63
+
64
+ ## Quick start
65
+
66
+ ```ts
67
+ import PinVault from '@umutcansu/react-native-pinvault';
68
+
69
+ const result = await PinVault.start({
70
+ configApis: [{
71
+ id: 'default-tls',
72
+ url: 'https://api.example.com:8081/',
73
+ bootstrapPins: [{ hostname: 'api.example.com', sha256: [primaryPin, backupPin] }],
74
+ signaturePublicKeys: [signingKey, backupSigningKey],
75
+ requiredSignatures: 2,
76
+ serverScope: 'default-tls',
77
+ }],
78
+ requireCaTrust: ['api.example.com'],
79
+ });
80
+ if (result.type !== 'ready') {
81
+ // Fail closed: until start() returns ready, every pinned request is refused.
82
+ return;
83
+ }
84
+
85
+ const res = await PinVault.fetch('https://api.example.com/v1/me', { headers: { Accept: 'application/json' } });
86
+ console.log(res.status, JSON.parse(res.body));
87
+ ```
88
+
89
+ `start` fails closed exactly like the native `init` / `start(config:)`. Calling
90
+ `start` again applies the new config (the bridge calls `reset()` first, as the
91
+ native samples do when they restart).
92
+
93
+ ## Configuration
94
+
95
+ `start(config)` takes the builders as JSON: the keys are the builder method
96
+ names of `PinVaultConfig.Builder`, `ConfigApiBlock.Builder` and
97
+ `VaultFileConfig.Builder`, durations are `{ amount, unit }` (`TimeUnit`
98
+ names), enum values are the Kotlin constant names (`'TOKEN_MTLS'`,
99
+ `'USER_AUTH'`, …). The full shape is the `PinVaultConfig` type in
100
+ [`src/types.ts`](src/types.ts).
101
+
102
+ ```ts
103
+ {
104
+ configApis: [{ id, url, bootstrapPins, signaturePublicKeys, requiredSignatures, recoveryPublicKeys,
105
+ serverScope, clientCaPins, clientCertHosts, renewalUrl, attestation, tokenHosts, proofOfPossession, … }],
106
+ vaultFiles: [{ key, endpoint, configApi, accessPolicy, encryption, userAuth, storage,
107
+ maxOfflineAge: { amount: 7, unit: 'DAYS' }, … }],
108
+ requireCaTrust, requireUnlockedDevice, requireHardwareBackedKeys, expectedSignerSha256,
109
+ wipeVaultFilesOnRevocation, vaultFileMaxOfflineAge, updateIntervalMinutes, deviceAlias, …
110
+ environmentGuard: async (operation) => boolean, // JS, see below
111
+ requirePinnedReactNativeNetworking, // see Networking
112
+ android: { requireUnlockedDeviceAllowFallback, pinGlobalNetworking,
113
+ keepReactNativeHttpCache, keepReactNativeCookies },
114
+ ios: { resolve: { 'mock-tls.sample': '10.0.0.12' }, expectedBundleIds, expectedTeamIds, userAuthStrength,
115
+ reactNativeMaxResponseBytes },
116
+ }
117
+ ```
118
+
119
+ **The native parsers are strict.** An unknown key at any level, a wrong type
120
+ (`"3"` for a number, `1` for a boolean), a value out of range, a non-`https`
121
+ Config API URL, an oversized input (256 KiB config, bounded lists and strings)
122
+ rejects `start` with `PinVaultError` code `E_INVALID_CONFIG` and a message that
123
+ names the path (`config.configApis[0]: unknown key 'bootstrapPinz'`). The
124
+ library's own builder rules still apply on top (two pins per host, a signing
125
+ key for a signed Config API, `userAuth` for `USER_AUTH` files, …). Nothing is
126
+ silently ignored; the other platform's section (`ios` on Android, `android` on
127
+ iOS) is shape-checked and left to that platform.
128
+
129
+ Not exposed, on purpose: `clientKeystore(bytes, password)` (a P12 and its
130
+ password would cross the JS bridge — enroll instead), custom
131
+ `CertificateConfigApi`, storage, integrity-token and verdict providers (native
132
+ objects; iOS adds its App Attest verdict provider by itself), and
133
+ `reportToPinVaultBackend`.
134
+
135
+ ## API
136
+
137
+ All functions return promises; `PinVault.x` and the named export `x` are the same.
138
+
139
+ | Area | Functions |
140
+ |---|---|
141
+ | Start / config | `start`, `updateNow`, `currentVersion`, `hostPinVersions`, `pinsForHost`, `signingStatus`, `isForceUpdate`, `reset`, `schedulePeriodicUpdates`, `cancelPeriodicUpdates`, `enableDebugLogging` (debug builds only) |
142
+ | HTTP | `fetch(url, { method, headers, body, bodyEncoding, responseEncoding, timeoutMs, maxResponseBytes, settings })` → `{ status, url, headers, body, bodyEncoding, ok }` |
143
+ | Enrollment | `deviceId`, `enrollForResult(token)`, `autoEnrollForResult`, `checkPendingEnrollment`, `isEnrolled`, `isEnrollmentPending`, `enrollmentVerificationCode`, `enrolledClientCN`, `enrolledClientNotAfter`, `unenroll(label, { wipeVaultFiles })`, `identityKeySecurityLevel` |
144
+ | Vault | `setVaultToken(key, token)`, `clearVaultTokens`, `fetchFile(key, { token })`, `loadFile(key, encoding)`, `fileStatus`, `unlockFile(key, prompt)`, `isFileLocked`, `hasFile`, `fileVersion`, `clearFile`, `syncAllFiles` |
145
+ | Attestation | `attestNow`, `fetchAttestationToken(host)`, `attestationStatus`, `attestationHeaderName` |
146
+ | Events | `addConnectionListener(event => …)` → `{ remove() }` |
147
+
148
+ Results are plain objects with a `type` (the sealed subclass in lowerCamel):
149
+ `{ type: 'ready', version }`, `{ type: 'failed', reason, exception: { name, message } }`,
150
+ `{ type: 'refused', reason: 'INVALID_TOKEN', httpStatus, … }`, `{ type: 'updated', key, version }`, …
151
+ A refused operation is a result, not an exception — the same as natively.
152
+ Rejected promises are `PinVaultError` with `code` (`E_INVALID_CONFIG`,
153
+ `E_INVALID_ARGUMENT`, `E_NOT_STARTED`, `E_FETCH`, `E_NO_ACTIVITY`, `E_NATIVE`,
154
+ `E_NETWORKING_NOT_PINNED`)
155
+ and `exception` (the native class name and message, e.g.
156
+ `SSLPeerUnverifiedException` for a pin mismatch).
157
+
158
+ ## Native security file
159
+
160
+ The JS bundle is not a safe home for trust anchors: an OTA update (CodePush,
161
+ Expo Updates) or an edited bundle changes it without touching the app's
162
+ signature. Ship them natively as well, in `pinvault_security.json`:
163
+
164
+ - **Android:** `android/app/src/main/assets/pinvault_security.json` (or a build
165
+ type's own `src/release/assets/`). Assets, not `res/raw`: resource shrinking
166
+ cannot drop them and no `R` reference is needed. The APK signature seals it.
167
+ - **iOS:** a resource of the app bundle named `pinvault_security.json` (add it to
168
+ the app target, or copy it into the bundle in a build phase that runs before
169
+ signing). The code signature seals it.
170
+
171
+ ```json
172
+ {
173
+ "configApis": [{
174
+ "id": "default-tls",
175
+ "bootstrapPins": [{ "hostname": "api.example.com", "sha256": ["…", "…"] }],
176
+ "signaturePublicKeys": ["…", "…", "…"], "requiredSignatures": 2,
177
+ "recoveryPublicKeys": ["…"], "requiredRecoverySignatures": 1,
178
+ "serverScope": "default-tls", "clientCaPins": ["…"],
179
+ "url": "https://config.example.com:8081/", "attestation": true,
180
+ "tokenHosts": ["api.example.com"], "proofOfPossession": true, "clientCertHosts": ["api.example.com:443"],
181
+ "enrollmentUrl": "https://enroll.example.com/", "renewalUrl": "https://renew.example.com/",
182
+ "allowUnsigned": false, "allowUnpinnedConfigApi": false, "allowServerGeneratedKey": false
183
+ }],
184
+ "staticPins": { "pins": [ … ], "version": 1 },
185
+ "require": {
186
+ "requireUnlockedDevice": true, "requireHardwareBackedKeys": true,
187
+ "managedTrustRoots": true, "wipeVaultFilesOnRevocation": true,
188
+ "requireCaTrust": ["api.example.com"],
189
+ "expectedSignerSha256": ["…"],
190
+ "expectedBundleIds": ["com.example.app"], "expectedTeamIds": ["ABCDE12345"],
191
+ "expiredConfigGraceSeconds": 0, "vaultFileMaxOfflineAgeSeconds": 604800,
192
+ "userAuthStrength": "BIOMETRIC_CURRENT_SET"
193
+ },
194
+ "vaultFiles": [
195
+ { "key": "statement", "signaturePublicKey": "…", "encryption": "USER_AUTH", "userAuth": "REQUIRED",
196
+ "maxOfflineAgeSeconds": 86400 }
197
+ ]
198
+ }
199
+ ```
200
+
201
+ The keys are the JS config's (a subset of `configApis[]` plus `staticPins`),
202
+ parsed as strictly; a broken file rejects `start` with `E_INVALID_CONFIG`
203
+ (fail closed). When the file is there:
204
+
205
+ - every Config API of the JS config must be declared in it (a bundle cannot add
206
+ a block with keys of its own), and JS `staticPins` must be declared too;
207
+ - a field the file declares is the value: JS may leave it out (the native value
208
+ applies) or repeat it (lists in any order); a different value rejects `start`
209
+ with `E_INVALID_CONFIG` naming the field;
210
+ - `allowUnsigned`, `allowUnpinnedConfigApi` and `allowServerGeneratedKey` from
211
+ JS are refused unless the file allows them for that block;
212
+ - a block's `url`, `enrollmentUrl`, `renewalUrl`, `tokenHosts` and
213
+ `clientCertHosts` are fixed where the file gives them (where the block talks to, and who gets its token and identity),
214
+ and `attestation: true` / `proofOfPossession: true` there cannot be turned off from JS;
215
+ - `require` only tightens: a protection it turns on stays on whatever JS says,
216
+ its `requireCaTrust` hosts are added to JS's, the expected signer / bundle /
217
+ team ids and iOS `userAuthStrength` are fixed, and `expiredConfigGraceSeconds`
218
+ / `vaultFileMaxOfflineAgeSeconds` are the most JS may ask for (the value when
219
+ JS asks for none)
220
+ (Android reads `expectedSignerSha256`, iOS the bundle and team ids; one file
221
+ can serve both);
222
+ - `vaultFiles` fixes those files' signature key, encryption and screen lock, and
223
+ `maxOfflineAgeSeconds` is the longest offline lifetime JS may give them;
224
+ with the section present, a JS vault file it does not name is refused.
225
+
226
+ `start` answers `nativeSecurityApplied: true` when the file was applied.
227
+
228
+ Without the file, **release builds** (Android: the app is not
229
+ `android:debuggable`; iOS: compiled without `DEBUG`) refuse to start a config
230
+ with Config APIs or static pins: its trust anchors would come from JS alone.
231
+ An app that accepts that says so natively — Android:
232
+ `<meta-data android:name="io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE" android:value="true"/>`
233
+ in the manifest's `<application>`; iOS: `PinVaultAllowNoNativeSecurityFile` =
234
+ YES in Info.plist — and the three relaxations stay refused from JS then. Debug
235
+ builds take everything as before. Whatever the file says, a release build
236
+ takes an `expiredConfigGrace` of at most 7 days. The sample app writes
237
+ the file for its release builds from `sample-host.properties`
238
+ (`scripts/gen-host-config.js --native-out`).
239
+
240
+ A repackaged app can change the file too — but only by re-signing, which the
241
+ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
242
+
243
+ ## Networking
244
+
245
+ - **`PinVault.fetch`** goes through the native pinned client on both platforms
246
+ (`PinVault.getClient()` / `PinVault.shared.session()`; `settings` →
247
+ `getClient(settings)` / `session(settings:)`, pinning without pin-mismatch
248
+ recovery): pin check, attestation token, pin-mismatch recovery. HTTPS only;
249
+ a redirect from `https` into plain `http` is not followed (the 3xx comes
250
+ back) on either platform. Request bodies ≤ 10 MiB, responses ≤ 10 MiB by
251
+ default (`maxResponseBytes`, at most 50 MiB) — enforced while the body is
252
+ 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:
255
+ - **Before `start()`** — and after a start that failed — every `https`
256
+ request through RN's networking fails (`PinVault has not started`): fail
257
+ closed, as the native `getClient()` / `session()` before `init`. Plain
258
+ `http` is not TLS and stays with RN (Metro in debug builds; ATS / the
259
+ network security config decide).
260
+ - Hosts without a pin entry are refused like everywhere else in PinVault.
261
+ - No `https` → `http` redirects; no disk HTTP cache and no cookie jar (Android:
262
+ `android.keepReactNativeHttpCache` / `keepReactNativeCookies` keep RN's own).
263
+ - `requirePinnedReactNativeNetworking` (on by default in release builds)
264
+ makes `start` fail with `E_NETWORKING_NOT_PINNED` when RN's networking does
265
+ not go through PinVault (below); on Android a hook replaced after start
266
+ then refuses RN's https too (checked every few seconds). With it off a
267
+ warning is logged.
268
+ - **Android** (two hooks of RN 0.87, read from its sources), installed by the
269
+ plugin's content provider before `Application.onCreate`, so every client RN
270
+ builds is covered:
271
+ `NetworkingModule.setCustomClientBuilder` is called for every fetch / XHR
272
+ request; each one gets the socket factory and interceptors of one client that
273
+ `PinVault.applyTo(builder)` configured after `start()` (one per start, so
274
+ pooled connections are reused). `OkHttpClientProvider.setOkHttpClientFactory`
275
+ covers the clients RN builds once — the networking base client, the
276
+ WebSocket / dev-support singleton and Fresco's image client: their TLS goes
277
+ through a forwarding socket factory to the current pinned one.
278
+ Another library that calls either setter after PinVault (a network inspector,
279
+ a crash reporter, another pinning package) replaces the hook: `start()` and
280
+ every `PinVault.fetch` check both (`PinVaultNetworking.status()`) and log a
281
+ warning; `requirePinnedReactNativeNetworking` turns it into a failed start.
282
+ Opt out natively by removing the provider in the app's manifest —
283
+ `android.pinGlobalNetworking: false` alone is refused while the hooks are in
284
+ place, so a JS bundle cannot unpin RN's networking:
285
+
286
+ ```xml
287
+ <provider android:name="io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer"
288
+ android:authorities="${applicationId}.pinvault-networking" tools:node="remove" />
289
+ ```
290
+
291
+ With the provider removed, `PinVaultNetworking.install(this)` in
292
+ `MainApplication.onCreate` (before `loadReactNative`) is the explicit
293
+ alternative; a hook installed later than that misses the clients RN already built.
294
+ - **iOS:** the plugin's `RNPinVaultURLRequestHandler` (an `RCTURLRequestHandler`)
295
+ answers for `https` with `handlerPriority` 10; RCTNetworking picks the
296
+ highest-priority handler, so RN's `RCTHTTPRequestHandler` (priority 0) only
297
+ sees plain `http`. Codegen registers it (`codegenConfig.ios.
298
+ modulesConformingToProtocol` in this package's `package.json`; `pod install`
299
+ picks it up). Requests run on `PinVault.shared.session()` with the library's
300
+ bounded read (`ios.reactNativeMaxResponseBytes`, default 50 MiB, at most
301
+ 256 MiB); the library hands out whole answers, so RN gets the response, the
302
+ body in chunks and the completion after the body has been read (progress
303
+ events arrive at the end); cancelling a request cancels it. `start` checks
304
+ which handler RCTNetworking picks for an `https` request.
305
+ 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`.
312
+ Keep App Transport Security on (no `NSAllowsArbitraryLoads`).
313
+
314
+ ## Enrollment and vault tokens
315
+
316
+ Tokens cross the bridge once, as an argument, and the plugin keeps them on the
317
+ native side only. The enrollment token is held for the duration of the call
318
+ (`enrollForResult(token)`). Vault access tokens of `TOKEN` / `TOKEN_MTLS` files
319
+ are set with `setVaultToken(key, token)` or passed to `fetchFile(key, { token })`
320
+ and kept **in native memory only**: the native `accessToken { … }` provider
321
+ reads them on every download; the plugin never writes them to disk or the
322
+ Keychain, never hands them back to JS, and they are gone with the process.
323
+ What the plugin cannot control is the JS side: the string you pass is a JS
324
+ value until the garbage collector drops it, and if it went through React
325
+ state, a form field or a store, it lives there too. Pass it straight from where
326
+ it came from, clear the field, and never persist or log it. For an attestation
327
+ `PinVault-Token`, prefer `tokenHosts` + `PinVault.fetch` (the native client adds
328
+ it) over `fetchAttestationToken`, which hands the token to JS. With
329
+ `proofOfPossession` the native client also adds a `PinVault-Proof` signed by the
330
+ device key on every such request, and a backend that requires it refuses the
331
+ token alone — a token in JS is then of no use without the native client. Forget vault
332
+ tokens on a revocation:
333
+
334
+ ```ts
335
+ PinVault.addConnectionListener((e) => {
336
+ if (e.type === 'clientCertRenewal' && e.status === 'REENROLL_REQUIRED') PinVault.clearVaultTokens();
337
+ });
338
+ ```
339
+
340
+ Every string the bridge hands back (reasons, messages, `lastError`) has the
341
+ tokens it holds replaced by `***`.
342
+
343
+ ## Vault content
344
+
345
+ - `fetchFile` results never carry the content (`{ type: 'updated', key, version }`);
346
+ read it with `loadFile(key, 'utf8' | 'base64')`.
347
+ - Files behind the screen lock (`USER_AUTH`) never open with `loadFile`:
348
+ `unlockFile(key, { title, description, negativeButtonText })` shows the
349
+ native prompt (Android `BiometricPrompt`, which needs a `FragmentActivity` —
350
+ `ReactActivity` is one; iOS `LAContext`) and only then returns the content.
351
+ - Once the content is in JS, it is a JS string: keep it only as long as you
352
+ need it, do not put it into state that is persisted, and never log it.
353
+
354
+ ## environmentGuard
355
+
356
+ ```ts
357
+ environmentGuard: async (operation) => !(await myRaspSaysCompromised()),
358
+ environmentGuardTimeoutMs: 5000,
359
+ ```
360
+
361
+ Asked before `INIT`, `ENROLL`, `FETCH_FILE`, `UNLOCK_FILE` and `LOAD_FILE`. On iOS
362
+ the bridge asks before it calls the library (`start`: `INIT` and `ENROLL`;
363
+ enrollment calls: `ENROLL`; `fetchFile` / `syncAllFiles`: `FETCH_FILE`;
364
+ `unlockFile`: `UNLOCK_FILE`; `loadFile`: `LOAD_FILE`), so the wait for JS holds no thread; an operation the library
365
+ starts on its own (a background refresh) still waits on its own thread,
366
+ bounded by the timeout. **Fail closed:**
367
+ a timeout (native deadline 100–30 000 ms, default 5000), a thrown error, a
368
+ rejected promise or anything but `true` refuses the operation. The guard runs
369
+ in JavaScript, and code that hooks the JS runtime can answer for it: treat it
370
+ as one more signal. The decisive check is server-side attestation
371
+ (`attestation: true` on a Config API) with a strict policy.
372
+
373
+ ## Security notes (OWASP MASVS)
374
+
375
+ - **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
378
+ hook is detected.
379
+ - **Trust anchors** — release builds take the relaxations only from the native
380
+ security file; with the file, JS cannot change pins, keys or scopes.
381
+ - **STORAGE** — the plugin stores nothing in JS (no AsyncStorage); tokens live
382
+ in native memory only; vault content leaves native code only through
383
+ `loadFile` / `unlockFile`.
384
+ - **CRYPTO / AUTH** — no crypto in JS; screen-lock prompts are native.
385
+ - **PLATFORM** — every bridge input is validated natively (types, sizes, allowed
386
+ keys); events and errors carry no token, password or file content; no WebView.
387
+ - **CODE** — TypeScript strict; the plugin logs only in development builds
388
+ (`__DEV__`); R8 rules ship in `android/consumer-rules.pro`.
389
+ - **RESILIENCE** — `requireHardwareBackedKeys`, `requireUnlockedDevice` and
390
+ `expectedSignerSha256` are config keys; turn them on in release builds.
391
+
392
+ ## Privacy: what leaves the device
393
+
394
+ The same as the native libraries, to your own PinVault server only: the device
395
+ id (`ANDROID_ID` / `identifierForVendor`), manufacturer and model, the
396
+ `deviceAlias` you set, and — with `attestation` — the attestation report (app
397
+ package / bundle id, version, signer digests, installer, OS version and patch
398
+ level, key security level, and the integrity signals: root/jailbreak, emulator,
399
+ debugger, hooking framework, app integrity, cloner, installer, adb, software
400
+ key; see [`ATTESTATION.md`](https://github.com/umutcansu/PinVault/blob/main/ATTESTATION.md) §3). Vault downloads report key,
401
+ version, status, device model, device id and alias. Connection events stay on
402
+ the device unless your listener sends them somewhere.
403
+
404
+ ## Tests
405
+
406
+ ```bash
407
+ npm test # Jest: the TS layer with the native module mocked
408
+ npx tsc --noEmit
409
+ # Android JVM tests (config parsing, refusals, result mapping, RN networking hooks, fetch):
410
+ cd ../sample-client-rn/android && ./gradlew :umutcansu_react-native-pinvault:testDebugUnitTest
411
+ # iOS XCTest for the Swift core (on the Mac, against this repository's Swift package):
412
+ cd ios/Tests && swift test
413
+ ```
414
+
415
+ ## Development in this repository
416
+
417
+ The sample app [`sample-client-rn/`](https://github.com/umutcansu/PinVault/blob/main/sample-client-rn) builds the plugin from
418
+ source. Android: `pinvault.localPath` in its `gradle.properties` publishes the
419
+ repository's `:pinvault` (unsigned) into `android/build/pinvault-maven` at every
420
+ build and resolves `io.github.umutcansu:pinvault` only from there — a composite
421
+ build is not possible because PinVault builds with AGP 8.7 and React Native 0.87
422
+ 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.
424
+
425
+ ## Known limits
426
+
427
+ - Android 9 **emulator** images: their software keymaster (`keymaster@3.0`)
428
+ crashes while attesting a key made with `requireUnlockedDevice` (SIGSEGV in
429
+ `build_auth_list`); the keystore daemon then keeps a dead connection and every
430
+ Keystore operation fails until it is restarted. Turn `requireUnlockedDevice`
431
+ 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
437
+ React Native version that renames them reads as "could not be read", which
438
+ counts as not pinned.
@@ -0,0 +1,43 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ # PinVault for iOS is a Swift package (Package.swift at the repository root,
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);
8
+ # - development: PINVAULT_IOS_PACKAGE_PATH = a PinVault checkout (the sample's
9
+ # Podfile sets it to this repository), the counterpart of pinvault.localPath.
10
+ pinvault_url = "https://github.com/umutcansu/PinVault.git"
11
+ local_package = ENV["PINVAULT_IOS_PACKAGE_PATH"].to_s.strip
12
+
13
+ Pod::Spec.new do |s|
14
+ s.name = "RNPinVault"
15
+ s.version = package["version"]
16
+ s.summary = package["description"]
17
+ s.homepage = package["homepage"]
18
+ s.license = package["license"]
19
+ s.authors = package["author"]
20
+
21
+ s.platforms = { :ios => "16.0" }
22
+ s.source = { :git => pinvault_url, :tag => "v#{s.version}" }
23
+ s.source_files = "ios/*.{h,mm,swift}", "ios/Core/*.swift"
24
+ s.private_header_files = "ios/*.h"
25
+ s.swift_version = "5.9"
26
+
27
+ if local_package.empty?
28
+ spm_dependency(s,
29
+ url: pinvault_url,
30
+ requirement: { kind: "exactVersion", version: s.version.to_s },
31
+ products: ["PinVault"]
32
+ )
33
+ else
34
+ path = File.expand_path(local_package)
35
+ unless File.exist?(File.join(path, "Package.swift"))
36
+ raise "PINVAULT_IOS_PACKAGE_PATH=#{local_package}: no Package.swift there (a PinVault checkout is expected)"
37
+ end
38
+ Pod::UI.puts "[RNPinVault] PinVault Swift package from #{path}"
39
+ spm_dependency(s, url: path, requirement: {}, products: ["PinVault"])
40
+ end
41
+
42
+ install_modules_dependencies(s)
43
+ end
@@ -0,0 +1,68 @@
1
+ // @umutcansu/react-native-pinvault — Android TurboModule over
2
+ // io.github.umutcansu:pinvault (Maven Central).
3
+ //
4
+ // Development against an unpublished PinVault: the app's settings.gradle can
5
+ // substitute the Maven artifact with the repository's :pinvault project
6
+ // (sample-client-rn/android/settings.gradle, Gradle property pinvault.localPath).
7
+ buildscript {
8
+ ext.PinVaultRN = [
9
+ kotlinVersion: "2.2.0",
10
+ minSdkVersion: 24,
11
+ compileSdkVersion: 36,
12
+ pinvaultVersion: "2.4.0",
13
+ ]
14
+ ext.pinvaultRnExt = { prop ->
15
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : PinVaultRN[prop]
16
+ }
17
+ repositories {
18
+ google()
19
+ mavenCentral()
20
+ }
21
+ dependencies {
22
+ classpath "com.android.tools.build:gradle:8.7.2"
23
+ // noinspection DifferentKotlinGradleVersion
24
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${pinvaultRnExt('kotlinVersion')}"
25
+ }
26
+ }
27
+
28
+ apply plugin: "com.android.library"
29
+ if (project.extensions.findByName("kotlin") == null) {
30
+ apply plugin: "kotlin-android"
31
+ }
32
+ apply plugin: "com.facebook.react"
33
+
34
+ def pinvaultVersion = project.findProperty("pinvault.version") ?: pinvaultRnExt("pinvaultVersion")
35
+
36
+ android {
37
+ namespace "io.github.umutcansu.pinvault.reactnative"
38
+ compileSdkVersion pinvaultRnExt("compileSdkVersion")
39
+
40
+ defaultConfig {
41
+ minSdkVersion pinvaultRnExt("minSdkVersion")
42
+ consumerProguardFiles "consumer-rules.pro"
43
+ }
44
+
45
+ compileOptions {
46
+ sourceCompatibility JavaVersion.VERSION_17
47
+ targetCompatibility JavaVersion.VERSION_17
48
+ }
49
+
50
+ testOptions {
51
+ unitTests.returnDefaultValues = true
52
+ }
53
+ }
54
+
55
+ dependencies {
56
+ implementation "com.facebook.react:react-android"
57
+ implementation "io.github.umutcansu:pinvault:${pinvaultVersion}"
58
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
59
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
60
+ implementation "androidx.fragment:fragment-ktx:1.8.5"
61
+
62
+ testImplementation "junit:junit:4.13.2"
63
+ // The real org.json (android.jar only has stubs in JVM tests).
64
+ testImplementation "org.json:json:20240303"
65
+ testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"
66
+ testImplementation "com.squareup.okhttp3:okhttp-tls:4.12.0"
67
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1"
68
+ }