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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +386 -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 +300 -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 +239 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -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 +276 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +197 -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 +412 -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 +312 -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 +346 -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 +385 -0
  48. package/src/types.ts +351 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## 2.3.2 — 2026-10-08 — first release
4
+
5
+ First published version (2.3.1 was never published). Needs PinVault 2.3.2 on both platforms.
6
+
7
+ ### MASVS audit fixes
8
+
9
+ - **Native security file** (`pinvault_security.json`: Android assets, iOS app
10
+ bundle): bootstrap pins, signing / recovery keys, `requiredSignatures`,
11
+ `serverScope`, `clientCaPins` and the three relaxations declared outside the
12
+ JS bundle. With it, JS may only repeat them (a different value, an undeclared
13
+ Config API or undeclared `staticPins` → `E_INVALID_CONFIG`); without it,
14
+ release builds refuse `allowUnsigned`, `allowUnpinnedConfigApi` and
15
+ `allowServerGeneratedKey` from JS.
16
+ - **Android networking hook installed automatically** by a content provider
17
+ before `Application.onCreate` (`PinVaultNetworking.install` stays as the
18
+ alternative). `start` and every `fetch` detect a replaced OkHttp factory /
19
+ custom client builder (`PinVaultNetworking.status()`) and warn;
20
+ `requirePinnedReactNativeNetworking: true` fails `start` with
21
+ `E_NETWORKING_NOT_PINNED`. RN's fetch / XHR get no disk cache and no cookie
22
+ jar by default (`android.keepReactNativeHttpCache` / `keepReactNativeCookies`),
23
+ no `https` → `http` redirects; Fresco's image client is covered too.
24
+ `android.pinGlobalNetworking: false` is refused while the hooks are installed
25
+ (opt out natively). Consumer R8 rules for the release build of a React Native
26
+ 0.87 app (Error Prone annotations, WorkManager's Room database).
27
+ - **iOS: React Native's own fetch / XHR / `<Image>` pinned** by
28
+ `RNPinVaultURLRequestHandler` (an `RCTURLRequestHandler`, priority over
29
+ `RCTHTTPRequestHandler` for https) on `PinVault.shared.session()`: fail closed
30
+ before `start`, bounded answers (`ios.reactNativeMaxResponseBytes`), no
31
+ `https` → `http` redirects, cancellation. Opt out with the Info.plist key
32
+ `PinVaultPinReactNativeNetworking` = NO. WebSocket stays unpinned on iOS
33
+ (README).
34
+ - iOS `fetch` enforces `maxResponseBytes` while reading (needs the library's new
35
+ `PinnedSession.data(for:maxResponseBytes:)`).
36
+ - Deep nesting refused before the JSON parsers recurse (JS, Kotlin, Swift);
37
+ Android's `StackOverflowError` can no longer escape.
38
+ - A refused second `start` keeps the running `environmentGuard`; the iOS guard
39
+ asks JS before the library call, so no thread waits for JS.
40
+ - README: token handling described as it is (JS values until garbage
41
+ collection; native memory only on the plugin's side).
42
+
43
+ ### Initial bridge
44
+
45
+ First release of the React Native plugin, aligned with PinVault 2.3.1
46
+ (Android `io.github.umutcansu:pinvault:2.3.1`, iOS Swift package `v2.3.1`).
47
+
48
+ - TurboModule (New Architecture, React Native 0.87+): `start`, pinned `fetch`,
49
+ enrollment, vault files, attestation and connection events, with the native
50
+ names and result shapes.
51
+ - Strict native config parsing (unknown keys, wrong types and oversized input are
52
+ refused), tokens in native memory only, redacted messages.
53
+ - `environmentGuard` as an async JS callback with a native deadline (fail closed).
54
+ - Android: React Native's own `fetch` / `XMLHttpRequest` / `WebSocket` pinned with
55
+ `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,387 @@
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
+ `@umutcansu/react-native-pinvault` — dynamic certificate pinning with a signed,
4
+ remotely updated pin config, mTLS enrollment with a hardware identity key,
5
+ signed and encrypted vault files, and device attestation, for React Native
6
+ apps on Android and iOS.
7
+
8
+ It is a thin bridge over the two native libraries — Android
9
+ [`io.github.umutcansu:pinvault`](https://github.com/umutcansu/PinVault/blob/main/pinvault) and the iOS Swift package
10
+ [`PinVault`](https://github.com/umutcansu/PinVault/blob/main/pinvault-ios/README.md). Pinning, keys (Android Keystore /
11
+ Secure Enclave), signature checks and vault decryption all stay native: no
12
+ crypto runs in JavaScript, and JavaScript never sees TLS. Names are the native
13
+ ones (`InitResult`, `ClientCertEnrollmentResult`, `VaultFileResult`, …), so the
14
+ [library documentation](https://github.com/umutcansu/PinVault/blob/main/README.md) applies as it is.
15
+
16
+ - React Native **0.87+**, New Architecture only (TurboModule, codegen), Hermes.
17
+ - Android minSdk 24; iOS 16+.
18
+ - No runtime dependencies: `react` and `react-native` are peers.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @umutcansu/react-native-pinvault
24
+ cd ios && pod install
25
+ ```
26
+
27
+ **iOS.** The pod depends on the PinVault Swift package through React Native's
28
+ `spm_dependency` (git URL `https://github.com/umutcansu/PinVault.git`, exact
29
+ version = this package's version, product `PinVault`). Info.plist, as for the
30
+ native library:
31
+
32
+ | Key | Why |
33
+ |---|---|
34
+ | `NSFaceIDUsageDescription` | vault files behind the screen lock (`userAuth`) |
35
+ | `BGTaskSchedulerPermittedIdentifiers` = `io.github.umutcansu.pinvault.refresh`, `UIBackgroundModes` = `fetch` | `schedulePeriodicUpdates` |
36
+
37
+ Register the background task in `AppDelegate` before launch ends (the app target
38
+ does not link the Swift package itself, the pod exposes the call):
39
+
40
+ ```swift
41
+ import RNPinVault
42
+ // application(_:didFinishLaunchingWithOptions:)
43
+ _ = PinVaultBridge.registerBackgroundTask()
44
+ ```
45
+
46
+ **Android.** The module depends on `io.github.umutcansu:pinvault` (same version
47
+ as this package; override with the Gradle property `pinvault.version`). React
48
+ Native's own networking is pinned with no code of yours: the plugin's manifest
49
+ declares a content provider (`PinVaultNetworkingInitializer`) that installs the
50
+ hooks before `Application.onCreate` runs (see [Networking](#networking)).
51
+
52
+ **Release builds.** Put the trust anchors into the app itself, not only into
53
+ the JS bundle: [Native security file](#native-security-file).
54
+
55
+ ## Quick start
56
+
57
+ ```ts
58
+ import PinVault from '@umutcansu/react-native-pinvault';
59
+
60
+ const result = await PinVault.start({
61
+ configApis: [{
62
+ id: 'default-tls',
63
+ url: 'https://api.example.com:8081/',
64
+ bootstrapPins: [{ hostname: 'api.example.com', sha256: [primaryPin, backupPin] }],
65
+ signaturePublicKeys: [signingKey, backupSigningKey],
66
+ requiredSignatures: 2,
67
+ serverScope: 'default-tls',
68
+ }],
69
+ requireCaTrust: ['api.example.com'],
70
+ });
71
+ if (result.type !== 'ready') {
72
+ // Fail closed: until start() returns ready, every pinned request is refused.
73
+ return;
74
+ }
75
+
76
+ const res = await PinVault.fetch('https://api.example.com/v1/me', { headers: { Accept: 'application/json' } });
77
+ console.log(res.status, JSON.parse(res.body));
78
+ ```
79
+
80
+ `start` fails closed exactly like the native `init` / `start(config:)`. Calling
81
+ `start` again applies the new config (the bridge calls `reset()` first, as the
82
+ native samples do when they restart).
83
+
84
+ ## Configuration
85
+
86
+ `start(config)` takes the builders as JSON: the keys are the builder method
87
+ names of `PinVaultConfig.Builder`, `ConfigApiBlock.Builder` and
88
+ `VaultFileConfig.Builder`, durations are `{ amount, unit }` (`TimeUnit`
89
+ names), enum values are the Kotlin constant names (`'TOKEN_MTLS'`,
90
+ `'USER_AUTH'`, …). The full shape is the `PinVaultConfig` type in
91
+ [`src/types.ts`](src/types.ts).
92
+
93
+ ```ts
94
+ {
95
+ configApis: [{ id, url, bootstrapPins, signaturePublicKeys, requiredSignatures, recoveryPublicKeys,
96
+ serverScope, clientCaPins, clientCertHosts, renewalUrl, attestation, tokenHosts, … }],
97
+ vaultFiles: [{ key, endpoint, configApi, accessPolicy, encryption, userAuth, storage,
98
+ maxOfflineAge: { amount: 7, unit: 'DAYS' }, … }],
99
+ requireCaTrust, requireUnlockedDevice, requireHardwareBackedKeys, expectedSignerSha256,
100
+ wipeVaultFilesOnRevocation, vaultFileMaxOfflineAge, updateIntervalMinutes, deviceAlias, …
101
+ environmentGuard: async (operation) => boolean, // JS, see below
102
+ requirePinnedReactNativeNetworking, // see Networking
103
+ android: { requireUnlockedDeviceAllowFallback, pinGlobalNetworking,
104
+ keepReactNativeHttpCache, keepReactNativeCookies },
105
+ ios: { resolve: { 'mock-tls.sample': '10.0.0.12' }, expectedBundleIds, expectedTeamIds, userAuthStrength,
106
+ reactNativeMaxResponseBytes },
107
+ }
108
+ ```
109
+
110
+ **The native parsers are strict.** An unknown key at any level, a wrong type
111
+ (`"3"` for a number, `1` for a boolean), a value out of range, a non-`https`
112
+ Config API URL, an oversized input (256 KiB config, bounded lists and strings)
113
+ rejects `start` with `PinVaultError` code `E_INVALID_CONFIG` and a message that
114
+ names the path (`config.configApis[0]: unknown key 'bootstrapPinz'`). The
115
+ library's own builder rules still apply on top (two pins per host, a signing
116
+ key for a signed Config API, `userAuth` for `USER_AUTH` files, …). Nothing is
117
+ silently ignored; the other platform's section (`ios` on Android, `android` on
118
+ iOS) is shape-checked and left to that platform.
119
+
120
+ Not exposed, on purpose: `clientKeystore(bytes, password)` (a P12 and its
121
+ password would cross the JS bridge — enroll instead), custom
122
+ `CertificateConfigApi`, storage, integrity-token and verdict providers (native
123
+ objects; iOS adds its App Attest verdict provider by itself), and
124
+ `reportToPinVaultBackend`.
125
+
126
+ ## API
127
+
128
+ All functions return promises; `PinVault.x` and the named export `x` are the same.
129
+
130
+ | Area | Functions |
131
+ |---|---|
132
+ | Start / config | `start`, `updateNow`, `currentVersion`, `hostPinVersions`, `pinsForHost`, `signingStatus`, `isForceUpdate`, `reset`, `schedulePeriodicUpdates`, `cancelPeriodicUpdates`, `enableDebugLogging` (debug builds only) |
133
+ | HTTP | `fetch(url, { method, headers, body, bodyEncoding, responseEncoding, timeoutMs, maxResponseBytes, settings })` → `{ status, url, headers, body, bodyEncoding, ok }` |
134
+ | Enrollment | `deviceId`, `enrollForResult(token)`, `autoEnrollForResult`, `checkPendingEnrollment`, `isEnrolled`, `isEnrollmentPending`, `enrollmentVerificationCode`, `enrolledClientCN`, `enrolledClientNotAfter`, `unenroll(label, { wipeVaultFiles })`, `identityKeySecurityLevel` |
135
+ | Vault | `setVaultToken(key, token)`, `clearVaultTokens`, `fetchFile(key, { token })`, `loadFile(key, encoding)`, `fileStatus`, `unlockFile(key, prompt)`, `isFileLocked`, `hasFile`, `fileVersion`, `clearFile`, `syncAllFiles` |
136
+ | Attestation | `attestNow`, `fetchAttestationToken(host)`, `attestationStatus`, `attestationHeaderName` |
137
+ | Events | `addConnectionListener(event => …)` → `{ remove() }` |
138
+
139
+ Results are plain objects with a `type` (the sealed subclass in lowerCamel):
140
+ `{ type: 'ready', version }`, `{ type: 'failed', reason, exception: { name, message } }`,
141
+ `{ type: 'refused', reason: 'INVALID_TOKEN', httpStatus, … }`, `{ type: 'updated', key, version }`, …
142
+ A refused operation is a result, not an exception — the same as natively.
143
+ Rejected promises are `PinVaultError` with `code` (`E_INVALID_CONFIG`,
144
+ `E_INVALID_ARGUMENT`, `E_NOT_STARTED`, `E_FETCH`, `E_NO_ACTIVITY`, `E_NATIVE`,
145
+ `E_NETWORKING_NOT_PINNED`)
146
+ and `exception` (the native class name and message, e.g.
147
+ `SSLPeerUnverifiedException` for a pin mismatch).
148
+
149
+ ## Native security file
150
+
151
+ The JS bundle is not a safe home for trust anchors: an OTA update (CodePush,
152
+ Expo Updates) or an edited bundle changes it without touching the app's
153
+ signature. Ship them natively as well, in `pinvault_security.json`:
154
+
155
+ - **Android:** `android/app/src/main/assets/pinvault_security.json` (or a build
156
+ type's own `src/release/assets/`). Assets, not `res/raw`: resource shrinking
157
+ cannot drop them and no `R` reference is needed. The APK signature seals it.
158
+ - **iOS:** a resource of the app bundle named `pinvault_security.json` (add it to
159
+ the app target, or copy it into the bundle in a build phase that runs before
160
+ signing). The code signature seals it.
161
+
162
+ ```json
163
+ {
164
+ "configApis": [{
165
+ "id": "default-tls",
166
+ "bootstrapPins": [{ "hostname": "api.example.com", "sha256": ["…", "…"] }],
167
+ "signaturePublicKeys": ["…", "…", "…"], "requiredSignatures": 2,
168
+ "recoveryPublicKeys": ["…"], "requiredRecoverySignatures": 1,
169
+ "serverScope": "default-tls", "clientCaPins": ["…"],
170
+ "allowUnsigned": false, "allowUnpinnedConfigApi": false, "allowServerGeneratedKey": false
171
+ }],
172
+ "staticPins": { "pins": [ … ], "version": 1 }
173
+ }
174
+ ```
175
+
176
+ The keys are the JS config's (a subset of `configApis[]` plus `staticPins`),
177
+ parsed as strictly; a broken file rejects `start` with `E_INVALID_CONFIG`
178
+ (fail closed). When the file is there:
179
+
180
+ - every Config API of the JS config must be declared in it (a bundle cannot add
181
+ a block with keys of its own), and JS `staticPins` must be declared too;
182
+ - a field the file declares is the value: JS may leave it out (the native value
183
+ applies) or repeat it (lists in any order); a different value rejects `start`
184
+ with `E_INVALID_CONFIG` naming the field;
185
+ - `allowUnsigned`, `allowUnpinnedConfigApi` and `allowServerGeneratedKey` from
186
+ JS are refused unless the file allows them for that block.
187
+
188
+ Without the file, **release builds** (Android: the app is not
189
+ `android:debuggable`; iOS: compiled without `DEBUG`) refuse those three
190
+ relaxations from JS; debug builds take them as before. The sample app writes
191
+ the file for its release builds from `sample-host.properties`
192
+ (`scripts/gen-host-config.js --native-out`).
193
+
194
+ A repackaged app can change the file too — but only by re-signing, which the
195
+ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
196
+
197
+ ## Networking
198
+
199
+ - **`PinVault.fetch`** goes through the native pinned client on both platforms
200
+ (`PinVault.getClient()` / `PinVault.shared.session()`; `settings` →
201
+ `getClient(settings)` / `session(settings:)`, pinning without pin-mismatch
202
+ recovery): pin check, attestation token, pin-mismatch recovery. HTTPS only;
203
+ a redirect from `https` into plain `http` is not followed (the 3xx comes
204
+ back) on either platform. Request bodies ≤ 10 MiB, responses ≤ 10 MiB by
205
+ default (`maxResponseBytes`, at most 50 MiB) — enforced while the body is
206
+ read on both platforms (a declared `Content-Length` over it is refused first).
207
+ - **React Native's own `fetch`, `XMLHttpRequest` and `<Image>` are pinned on
208
+ both platforms** (WebSocket on Android only). The rules are the same on both:
209
+ - **Before `start()`** — and after a start that failed — every `https`
210
+ request through RN's networking fails (`PinVault has not started`): fail
211
+ closed, as the native `getClient()` / `session()` before `init`. Plain
212
+ `http` is not TLS and stays with RN (Metro in debug builds; ATS / the
213
+ network security config decide).
214
+ - Hosts without a pin entry are refused like everywhere else in PinVault.
215
+ - No `https` → `http` redirects; no disk HTTP cache and no cookie jar (Android:
216
+ `android.keepReactNativeHttpCache` / `keepReactNativeCookies` keep RN's own).
217
+ - `requirePinnedReactNativeNetworking: true` makes `start` fail with
218
+ `E_NETWORKING_NOT_PINNED` when RN's networking does not go through PinVault
219
+ (below); without it a warning is logged.
220
+ - **Android** (two hooks of RN 0.87, read from its sources), installed by the
221
+ plugin's content provider before `Application.onCreate`, so every client RN
222
+ builds is covered:
223
+ `NetworkingModule.setCustomClientBuilder` is called for every fetch / XHR
224
+ request; each one gets the socket factory and interceptors of one client that
225
+ `PinVault.applyTo(builder)` configured after `start()` (one per start, so
226
+ pooled connections are reused). `OkHttpClientProvider.setOkHttpClientFactory`
227
+ covers the clients RN builds once — the networking base client, the
228
+ WebSocket / dev-support singleton and Fresco's image client: their TLS goes
229
+ through a forwarding socket factory to the current pinned one.
230
+ Another library that calls either setter after PinVault (a network inspector,
231
+ a crash reporter, another pinning package) replaces the hook: `start()` and
232
+ every `PinVault.fetch` check both (`PinVaultNetworking.status()`) and log a
233
+ warning; `requirePinnedReactNativeNetworking` turns it into a failed start.
234
+ Opt out natively by removing the provider in the app's manifest —
235
+ `android.pinGlobalNetworking: false` alone is refused while the hooks are in
236
+ place, so a JS bundle cannot unpin RN's networking:
237
+
238
+ ```xml
239
+ <provider android:name="io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer"
240
+ android:authorities="${applicationId}.pinvault-networking" tools:node="remove" />
241
+ ```
242
+
243
+ With the provider removed, `PinVaultNetworking.install(this)` in
244
+ `MainApplication.onCreate` (before `loadReactNative`) is the explicit
245
+ alternative; a hook installed later than that misses the clients RN already built.
246
+ - **iOS:** the plugin's `RNPinVaultURLRequestHandler` (an `RCTURLRequestHandler`)
247
+ answers for `https` with `handlerPriority` 10; RCTNetworking picks the
248
+ highest-priority handler, so RN's `RCTHTTPRequestHandler` (priority 0) only
249
+ sees plain `http`. Codegen registers it (`codegenConfig.ios.
250
+ modulesConformingToProtocol` in this package's `package.json`; `pod install`
251
+ picks it up). Requests run on `PinVault.shared.session()` with the library's
252
+ bounded read (`ios.reactNativeMaxResponseBytes`, default 50 MiB, at most
253
+ 256 MiB); the library hands out whole answers, so RN gets the response, the
254
+ body in chunks and the completion after the body has been read (progress
255
+ events arrive at the end); cancelling a request cancels it. `start` checks
256
+ which handler RCTNetworking picks for an `https` request.
257
+ Opt out natively with the Info.plist key `PinVaultPinReactNativeNetworking`
258
+ = NO. **WebSocket is not pinned on iOS:** RN 0.87's `RCTWebSocketModule`
259
+ uses SocketRocket on CFStream, not URLSession, so it cannot take PinVault's
260
+ session or delegate; its hook (`RCTSetCustomSRWebSocketProvider` with an
261
+ `SRSecurityPolicy` whose `evaluateServerTrust:forDomain:` decides) would need
262
+ a public trust-evaluation API from the PinVault library, which it does not
263
+ have. Keep secrets off `wss://` on iOS, or send them with `PinVault.fetch`.
264
+ Keep App Transport Security on (no `NSAllowsArbitraryLoads`).
265
+
266
+ ## Enrollment and vault tokens
267
+
268
+ Tokens cross the bridge once, as an argument, and the plugin keeps them on the
269
+ native side only. The enrollment token is held for the duration of the call
270
+ (`enrollForResult(token)`). Vault access tokens of `TOKEN` / `TOKEN_MTLS` files
271
+ are set with `setVaultToken(key, token)` or passed to `fetchFile(key, { token })`
272
+ and kept **in native memory only**: the native `accessToken { … }` provider
273
+ reads them on every download; the plugin never writes them to disk or the
274
+ Keychain, never hands them back to JS, and they are gone with the process.
275
+ What the plugin cannot control is the JS side: the string you pass is a JS
276
+ value until the garbage collector drops it, and if it went through React
277
+ state, a form field or a store, it lives there too. Pass it straight from where
278
+ it came from, clear the field, and never persist or log it. For an attestation
279
+ `PinVault-Token`, prefer `tokenHosts` + `PinVault.fetch` (the native client adds
280
+ it) over `fetchAttestationToken`, which hands the token to JS. Forget vault
281
+ tokens on a revocation:
282
+
283
+ ```ts
284
+ PinVault.addConnectionListener((e) => {
285
+ if (e.type === 'clientCertRenewal' && e.status === 'REENROLL_REQUIRED') PinVault.clearVaultTokens();
286
+ });
287
+ ```
288
+
289
+ Every string the bridge hands back (reasons, messages, `lastError`) has the
290
+ tokens it holds replaced by `***`.
291
+
292
+ ## Vault content
293
+
294
+ - `fetchFile` results never carry the content (`{ type: 'updated', key, version }`);
295
+ read it with `loadFile(key, 'utf8' | 'base64')`.
296
+ - Files behind the screen lock (`USER_AUTH`) never open with `loadFile`:
297
+ `unlockFile(key, { title, description, negativeButtonText })` shows the
298
+ native prompt (Android `BiometricPrompt`, which needs a `FragmentActivity` —
299
+ `ReactActivity` is one; iOS `LAContext`) and only then returns the content.
300
+ - Once the content is in JS, it is a JS string: keep it only as long as you
301
+ need it, do not put it into state that is persisted, and never log it.
302
+
303
+ ## environmentGuard
304
+
305
+ ```ts
306
+ environmentGuard: async (operation) => !(await myRaspSaysCompromised()),
307
+ environmentGuardTimeoutMs: 5000,
308
+ ```
309
+
310
+ Asked before `INIT`, `ENROLL`, `FETCH_FILE` and `UNLOCK_FILE`. On iOS the bridge
311
+ asks before it calls the library (`start`: `INIT` and `ENROLL`; enrollment
312
+ calls: `ENROLL`; `fetchFile` / `syncAllFiles`: `FETCH_FILE`; `unlockFile`:
313
+ `UNLOCK_FILE`), so the wait for JS holds no thread; an operation the library
314
+ starts on its own (a background refresh) still waits on its own thread,
315
+ bounded by the timeout. **Fail closed:**
316
+ a timeout (native deadline 100–30 000 ms, default 5000), a thrown error, a
317
+ rejected promise or anything but `true` refuses the operation. The guard runs
318
+ in JavaScript, and code that hooks the JS runtime can answer for it: treat it
319
+ as one more signal. The decisive check is server-side attestation
320
+ (`attestation: true` on a Config API) with a strict policy.
321
+
322
+ ## Security notes (OWASP MASVS)
323
+
324
+ - **NETWORK** — pinned native path only; no unpinned fallback; `https` → `http`
325
+ redirects are not followed; RN's own fetch / XHR / images are pinned on both
326
+ platforms (WebSocket on Android), fail closed before `start`, and a replaced
327
+ hook is detected.
328
+ - **Trust anchors** — release builds take the relaxations only from the native
329
+ security file; with the file, JS cannot change pins, keys or scopes.
330
+ - **STORAGE** — the plugin stores nothing in JS (no AsyncStorage); tokens live
331
+ in native memory only; vault content leaves native code only through
332
+ `loadFile` / `unlockFile`.
333
+ - **CRYPTO / AUTH** — no crypto in JS; screen-lock prompts are native.
334
+ - **PLATFORM** — every bridge input is validated natively (types, sizes, allowed
335
+ keys); events and errors carry no token, password or file content; no WebView.
336
+ - **CODE** — TypeScript strict; the plugin logs only in development builds
337
+ (`__DEV__`); R8 rules ship in `android/consumer-rules.pro`.
338
+ - **RESILIENCE** — `requireHardwareBackedKeys`, `requireUnlockedDevice` and
339
+ `expectedSignerSha256` are config keys; turn them on in release builds.
340
+
341
+ ## Privacy: what leaves the device
342
+
343
+ The same as the native libraries, to your own PinVault server only: the device
344
+ id (`ANDROID_ID` / `identifierForVendor`), manufacturer and model, the
345
+ `deviceAlias` you set, and — with `attestation` — the attestation report (app
346
+ package / bundle id, version, signer digests, installer, OS version and patch
347
+ level, key security level, and the integrity signals: root/jailbreak, emulator,
348
+ debugger, hooking framework, app integrity, cloner, installer, adb, software
349
+ key; see [`ATTESTATION.md`](https://github.com/umutcansu/PinVault/blob/main/ATTESTATION.md) §3). Vault downloads report key,
350
+ version, status, device model, device id and alias. Connection events stay on
351
+ the device unless your listener sends them somewhere.
352
+
353
+ ## Tests
354
+
355
+ ```bash
356
+ npm test # Jest: the TS layer with the native module mocked
357
+ npx tsc --noEmit
358
+ # Android JVM tests (config parsing, refusals, result mapping, RN networking hooks, fetch):
359
+ cd ../sample-client-rn/android && ./gradlew :umutcansu_react-native-pinvault:testDebugUnitTest
360
+ # iOS XCTest for the Swift core (on the Mac, against this repository's Swift package):
361
+ cd ios/Tests && swift test
362
+ ```
363
+
364
+ ## Development in this repository
365
+
366
+ The sample app [`sample-client-rn/`](https://github.com/umutcansu/PinVault/blob/main/sample-client-rn) builds the plugin from
367
+ source. Android: `pinvault.localPath` in its `gradle.properties` publishes the
368
+ repository's `:pinvault` (unsigned) into `android/build/pinvault-maven` at every
369
+ build and resolves `io.github.umutcansu:pinvault` only from there — a composite
370
+ build is not possible because PinVault builds with AGP 8.7 and React Native 0.87
371
+ with AGP 9. iOS: the Podfile sets `PINVAULT_IOS_PACKAGE_PATH` to the repository,
372
+ and the podspec hands `spm_dependency` that local path instead of the git tag.
373
+
374
+ ## Known limits
375
+
376
+ - Android 9 **emulator** images: their software keymaster (`keymaster@3.0`)
377
+ crashes while attesting a key made with `requireUnlockedDevice` (SIGSEGV in
378
+ `build_auth_list`); the keystore daemon then keeps a dead connection and every
379
+ Keystore operation fails until it is restarted. Turn `requireUnlockedDevice`
380
+ on for release builds / real devices only (the sample does).
381
+ - iOS: RN's WebSocket is not pinned, and RN's https answers are delivered after
382
+ the whole (bounded) body is read (above).
383
+ - Android: the networking hooks are detected by reading two private fields of
384
+ React Native 0.87 (`OkHttpClientProvider.factory`,
385
+ `NetworkingModule.customClientBuilder`; the consumer R8 rules keep them). A
386
+ React Native version that renames them reads as "could not be read", which
387
+ 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.3.2);
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.3.2",
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
+ }
@@ -0,0 +1,25 @@
1
+ # @umutcansu/react-native-pinvault — R8 rules for apps.
2
+ # The TurboModule is looked up by the generated code (no reflection); the
3
+ # PinVault library ships its own consumer rules. Keep the module and package
4
+ # names readable in crash reports only.
5
+ -keep class io.github.umutcansu.pinvault.reactnative.PinVaultPackage { *; }
6
+ -keep class io.github.umutcansu.pinvault.reactnative.PinVaultModule { *; }
7
+ -keep class io.github.umutcansu.pinvault.reactnative.PinVaultNetworking { public *; }
8
+ -keep class io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer { <init>(); }
9
+ # PinVaultNetworking.status() reads React Native's two networking hooks by name
10
+ # to detect another library replacing them.
11
+ -keepclassmembers class com.facebook.react.modules.network.OkHttpClientProvider {
12
+ private static com.facebook.react.modules.network.OkHttpClientFactory factory;
13
+ }
14
+ -keepclassmembers class com.facebook.react.modules.network.NetworkingModule {
15
+ private static com.facebook.react.modules.network.CustomClientBuilder customClientBuilder;
16
+ }
17
+ # Tink (under the PinVault library's encrypted storage) references Error Prone's
18
+ # compile-time annotations, which no React Native app has on its classpath; R8
19
+ # stops a release build on the missing classes otherwise.
20
+ -dontwarn com.google.errorprone.annotations.**
21
+ # PinVault's periodic updates use WorkManager, which opens its Room database by
22
+ # reflection (WorkDatabase_Impl's no-argument constructor). In a React Native
23
+ # 0.87 release build (AGP 9, R8 full mode) R8 dropped that constructor and the
24
+ # app died at launch in androidx.startup's InitializationProvider.
25
+ -keep class * extends androidx.room.RoomDatabase { <init>(); }
@@ -0,0 +1,16 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <!-- PinVault itself declares what it needs (INTERNET, biometrics). -->
3
+ <application>
4
+ <!--
5
+ Pins React Native's own networking before the app's code runs: Android
6
+ creates content providers before Application.onCreate, so the hooks are
7
+ in place before React Native builds its HTTP clients (README, "Networking").
8
+ Opt out in the app's manifest with tools:node="remove" on this element.
9
+ -->
10
+ <provider
11
+ android:name="io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer"
12
+ android:authorities="${applicationId}.pinvault-networking"
13
+ android:exported="false"
14
+ android:initOrder="1000" />
15
+ </application>
16
+ </manifest>