@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
@@ -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>
@@ -0,0 +1,396 @@
1
+ package io.github.umutcansu.pinvault.reactnative
2
+
3
+ import io.github.umutcansu.pinvault.api.PinVaultConnectionListener
4
+ import io.github.umutcansu.pinvault.model.CertificateConfig
5
+ import io.github.umutcansu.pinvault.model.EnvironmentGuard
6
+ import io.github.umutcansu.pinvault.model.HostPin
7
+ import io.github.umutcansu.pinvault.model.PinVaultConfig
8
+ import io.github.umutcansu.pinvault.model.StorageStrategy
9
+ import io.github.umutcansu.pinvault.model.UserAuth
10
+ import io.github.umutcansu.pinvault.model.VaultFileAccessPolicy
11
+ import io.github.umutcansu.pinvault.model.VaultFileConfig
12
+ import io.github.umutcansu.pinvault.model.VaultFileEncryption
13
+ import io.github.umutcansu.pinvault.model.VaultFileUnlockPrompt
14
+ import java.util.concurrent.TimeUnit
15
+
16
+ /** What `start(config)` needs besides the library's own config. */
17
+ internal class ParsedConfig(
18
+ val config: PinVaultConfig,
19
+ /** Non-null when JS registered an `environmentGuard`. */
20
+ val guardTimeoutMs: Long?,
21
+ /** `android.pinGlobalNetworking` (default true). */
22
+ val pinGlobalNetworking: Boolean,
23
+ /** RN's networking options of `android` and `requirePinnedReactNativeNetworking`. */
24
+ val networking: NetworkingOptions = NetworkingOptions(),
25
+ /** True when a native security file was applied. */
26
+ val nativeSecurityApplied: Boolean = false,
27
+ )
28
+
29
+ /** How React Native's own networking is pinned (see [PinVaultNetworking]). */
30
+ internal data class NetworkingOptions(
31
+ /** `requirePinnedReactNativeNetworking`: start fails when RN's hooks are not PinVault's. */
32
+ val requirePinned: Boolean = false,
33
+ /** `android.keepReactNativeHttpCache`: RN's 10 MiB disk HTTP cache for fetch / XHR (default off). */
34
+ val keepHttpCache: Boolean = false,
35
+ /** `android.keepReactNativeCookies`: RN's persistent cookie jar for fetch / XHR (default off). */
36
+ val keepCookies: Boolean = false,
37
+ )
38
+
39
+ /**
40
+ * JSON config from JS → [PinVaultConfig] through the library's own builders,
41
+ * so every builder rule (`require`) still applies. The JSON keys are the
42
+ * builder method names. Unknown keys, wrong types and out-of-range sizes are
43
+ * refused with [BridgeInputException]; builder refusals surface as their
44
+ * `IllegalArgumentException` (both are `E_INVALID_CONFIG` for JS).
45
+ *
46
+ * Not exposed on purpose: `clientKeystore(bytes, password)` (a P12 and its
47
+ * password would cross the JS bridge — enroll instead), custom
48
+ * `CertificateConfigApi` / storage providers / integrity providers (native
49
+ * objects), `reportToPinVaultBackend`.
50
+ */
51
+ internal object ConfigParser {
52
+
53
+ const val MAX_CONFIG_APIS = 16
54
+ const val MAX_VAULT_FILES = 64
55
+ const val MAX_PINS = 256
56
+ const val MAX_PINS_PER_HOST = 16
57
+ const val KEY_LENGTH = 4096
58
+ private val VAULT_KEY = Regex("^[A-Za-z0-9._-]{1,64}$")
59
+
60
+ /**
61
+ * @param native the app's native security file ([NativeSecurity]); null = none.
62
+ * @param release a release build of the app: without a native file, a
63
+ * config with Config APIs or static pins is refused (unless
64
+ * [noFileAllowed]) and the relaxations (`allowUnsigned`, …) are refused
65
+ * from JS; `expiredConfigGrace` is capped.
66
+ * @param noFileAllowed the app's manifest accepts a release start without
67
+ * a native security file ([NativeSecurity.NO_FILE_META_DATA]).
68
+ */
69
+ fun parse(
70
+ json: String,
71
+ tokens: VaultTokenStore,
72
+ guardFactory: (timeoutMs: Long) -> EnvironmentGuard,
73
+ listener: PinVaultConnectionListener?,
74
+ native: NativeSecurity? = null,
75
+ release: Boolean = false,
76
+ noFileAllowed: Boolean = false,
77
+ ): ParsedConfig {
78
+ val root = StrictJson.parseObject(json, "config")
79
+ val builder = PinVaultConfig.Builder()
80
+ val nativeRequire = native?.require ?: NativeSecurity.Require()
81
+
82
+ val configApis = root.objList("configApis", MAX_CONFIG_APIS)
83
+ val vaultFiles = root.objList("vaultFiles", MAX_VAULT_FILES)
84
+ val staticPins = root.obj("staticPins")?.let(::staticPins)
85
+ if (release && native == null && !noFileAllowed && (!configApis.isNullOrEmpty() || staticPins != null)) {
86
+ throw BridgeInputException(
87
+ "config: a release build takes its trust anchors only from the app's native security file " +
88
+ "(assets/${NativeSecurity.ASSET_NAME}, README \"Native security file\"); none is shipped. " +
89
+ "An app that accepts JS-only anchors says so in its manifest: meta-data ${NativeSecurity.NO_FILE_META_DATA}=true"
90
+ )
91
+ }
92
+ configApis?.forEach { addConfigApi(builder, it, native, release) }
93
+ vaultFiles?.forEach { addVaultFile(builder, it, tokens, native) }
94
+ SecurityPolicy.staticPins("config.staticPins", staticPins, native)?.let { builder.staticPins(it) }
95
+
96
+ root.int("maxRetryCount", 0, 10)?.let { builder.maxRetryCount(it) }
97
+ root.long("updateIntervalHours", 1, 24L * 30)?.let { builder.updateIntervalHours(it) }
98
+ root.long("updateIntervalMinutes", 15, 60L * 24 * 30)?.let { builder.updateIntervalMinutes(it) }
99
+ root.string("deviceAlias", 128)?.let { builder.deviceAlias(it) }
100
+ root.obj("expiredConfigGrace")?.let { d ->
101
+ val (amount, unit) = duration(d)
102
+ val ms = unit.toMillis(amount)
103
+ if (release && ms > NativeSecurity.MAX_RELEASE_GRACE_MS) {
104
+ throw BridgeInputException("${d.path}: a release build takes at most 7 days")
105
+ }
106
+ nativeRequire.expiredConfigGraceMs?.let { max ->
107
+ if (ms > max) throw BridgeInputException("${d.path}: longer than the app's native security file allows (${max / 1000} s)")
108
+ }
109
+ builder.expiredConfigGrace(amount, unit)
110
+ } ?: nativeRequire.expiredConfigGraceMs?.let { builder.expiredConfigGrace(it, TimeUnit.MILLISECONDS) }
111
+ // `require` hosts are added to JS's: JS can name more, never fewer.
112
+ val caTrust = (root.stringList("requireCaTrust", 64, 255) ?: emptyList()) + (nativeRequire.requireCaTrust ?: emptyList())
113
+ if (caTrust.isNotEmpty()) builder.requireCaTrust(*caTrust.distinct().toTypedArray())
114
+ if (root.bool("wipeVaultFilesOnRevocation") == true || nativeRequire.wipeVaultFilesOnRevocation) builder.wipeVaultFilesOnRevocation()
115
+ root.obj("vaultFileMaxOfflineAge")?.let { d ->
116
+ val (amount, unit) = duration(d)
117
+ nativeRequire.vaultFileMaxOfflineAgeMs?.let { max ->
118
+ val ms = unit.toMillis(amount)
119
+ // 0 = no limit: longer than any.
120
+ if (ms == 0L || ms > max) throw BridgeInputException("${d.path}: longer than the app's native security file allows (${max / 1000} s)")
121
+ }
122
+ builder.vaultFileMaxOfflineAge(amount, unit)
123
+ } ?: nativeRequire.vaultFileMaxOfflineAgeMs?.let { builder.vaultFileMaxOfflineAge(it, TimeUnit.MILLISECONDS) }
124
+ val requireUnlocked = root.bool("requireUnlockedDevice") == true || nativeRequire.requireUnlockedDevice
125
+ if (root.bool("requireHardwareBackedKeys") == true || nativeRequire.requireHardwareBackedKeys) builder.requireHardwareBackedKeys()
126
+ if (root.bool("managedTrustRoots") == true || nativeRequire.managedTrustRoots) builder.managedTrustRoots()
127
+ val signers = root.stringList("expectedSignerSha256", 16, 128)
128
+ nativeRequire.expectedSignerSha256?.let { fixed ->
129
+ if (signers != null && !NativeSecurity.sameKeys(signers.map(String::lowercase), fixed.map(String::lowercase))) {
130
+ throw BridgeInputException("config.expectedSignerSha256: differs from the app's native security file (${native?.source}); the native value is fixed")
131
+ }
132
+ }
133
+ (nativeRequire.expectedSignerSha256 ?: signers)?.let { builder.expectedSignerSha256(it) }
134
+
135
+ var guardTimeout: Long? = null
136
+ root.obj("environmentGuard")?.let { g ->
137
+ guardTimeout = g.long("timeoutMs", JsEnvironmentGuard.MIN_TIMEOUT_MS, JsEnvironmentGuard.MAX_TIMEOUT_MS)
138
+ ?: JsEnvironmentGuard.DEFAULT_TIMEOUT_MS
139
+ g.finish()
140
+ builder.environmentGuard(guardFactory(guardTimeout!!))
141
+ }
142
+
143
+ var allowFallback = false
144
+ var pinGlobal = true
145
+ var keepCache = false
146
+ var keepCookies = false
147
+ root.obj("android")?.let { a ->
148
+ allowFallback = a.bool("requireUnlockedDeviceAllowFallback") == true
149
+ pinGlobal = a.bool("pinGlobalNetworking") ?: true
150
+ keepCache = a.bool("keepReactNativeHttpCache") == true
151
+ keepCookies = a.bool("keepReactNativeCookies") == true
152
+ a.finish()
153
+ }
154
+ // On by default in a release build: RN's https must go through PinVault there.
155
+ val requirePinned = root.bool("requirePinnedReactNativeNetworking") ?: (release && pinGlobal)
156
+ if (requirePinned && !pinGlobal) {
157
+ throw BridgeInputException("config.requirePinnedReactNativeNetworking: contradicts android.pinGlobalNetworking: false")
158
+ }
159
+ if (allowFallback && !requireUnlocked) {
160
+ throw BridgeInputException("config.android.requireUnlockedDeviceAllowFallback: needs requireUnlockedDevice: true")
161
+ }
162
+ if (requireUnlocked) builder.requireUnlockedDevice(allowFallback)
163
+ // iOS-only settings: the iOS side reads them; here only their shape is checked.
164
+ root.obj("ios")
165
+
166
+ root.finish()
167
+ listener?.let { builder.onConnectionEvent(it) }
168
+ return ParsedConfig(
169
+ builder.build(), guardTimeout, pinGlobal,
170
+ NetworkingOptions(requirePinned, keepCache, keepCookies),
171
+ nativeSecurityApplied = native != null,
172
+ )
173
+ }
174
+
175
+ private fun duration(d: Fields): Pair<Long, TimeUnit> {
176
+ val amount = d.long("amount", 0, 10L * 365 * 24 * 3600 * 1000) ?: throw BridgeInputException("${d.path}.amount: required")
177
+ val unit = when (val u = d.requireString("unit", 16)) {
178
+ "MILLISECONDS" -> TimeUnit.MILLISECONDS
179
+ "SECONDS" -> TimeUnit.SECONDS
180
+ "MINUTES" -> TimeUnit.MINUTES
181
+ "HOURS" -> TimeUnit.HOURS
182
+ "DAYS" -> TimeUnit.DAYS
183
+ else -> throw BridgeInputException("${d.path}.unit: '$u' is not one of MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS")
184
+ }
185
+ d.finish()
186
+ return amount to unit
187
+ }
188
+
189
+ private fun httpsUrl(f: Fields, key: String): String? {
190
+ val url = f.string(key, 2048) ?: return null
191
+ if (!url.startsWith("https://")) throw BridgeInputException("${f.path}.$key: must be an https:// URL")
192
+ return url
193
+ }
194
+
195
+ fun hostPin(p: Fields): HostPin {
196
+ val hostname = p.requireString("hostname", 255)
197
+ val pins = p.stringList("sha256", MAX_PINS_PER_HOST, 128) ?: throw BridgeInputException("${p.path}.sha256: required")
198
+ val pin = HostPin(
199
+ hostname = hostname,
200
+ sha256 = pins,
201
+ version = p.int("version", 0, Int.MAX_VALUE) ?: 0,
202
+ forceUpdate = p.bool("forceUpdate") ?: false,
203
+ mtls = p.bool("mtls") ?: false,
204
+ clientCertVersion = p.int("clientCertVersion", 0, Int.MAX_VALUE),
205
+ )
206
+ p.finish()
207
+ return pin
208
+ }
209
+
210
+ fun staticPins(s: Fields): CertificateConfig {
211
+ val pins = s.objList("pins", MAX_PINS)?.map(::hostPin) ?: throw BridgeInputException("${s.path}.pins: required")
212
+ val config = CertificateConfig(
213
+ version = s.int("version", 0, Int.MAX_VALUE) ?: 0,
214
+ pins = pins,
215
+ forceUpdate = s.bool("forceUpdate") ?: false,
216
+ )
217
+ s.finish()
218
+ return config
219
+ }
220
+
221
+ private fun addConfigApi(builder: PinVaultConfig.Builder, b: Fields, native: NativeSecurity?, release: Boolean) {
222
+ val id = b.requireString("id", 128)
223
+ val url = httpsUrl(b, "url") ?: throw BridgeInputException("${b.path}.url: required")
224
+ // Read everything before the builder runs, so a refusal names the JSON path.
225
+ val bootstrapPins = b.objList("bootstrapPins", MAX_PINS)?.map(::hostPin)
226
+ val configEndpoint = b.string("configEndpoint", 512)
227
+ val healthEndpoint = b.string("healthEndpoint", 512)
228
+ val oneKey = b.string("signaturePublicKey", KEY_LENGTH, multiline = true)
229
+ val keys = b.stringList("signaturePublicKeys", 16, KEY_LENGTH, multiline = true)
230
+ if (oneKey != null && keys != null) {
231
+ throw BridgeInputException("${b.path}: give signaturePublicKey or signaturePublicKeys, not both")
232
+ }
233
+ val requiredSignatures = b.int("requiredSignatures", 1, 16)
234
+ val recoveryKeys = b.stringList("recoveryPublicKeys", 16, KEY_LENGTH, multiline = true)
235
+ val requiredRecovery = b.int("requiredRecoverySignatures", 1, 16)
236
+ val allowUnsigned = b.bool("allowUnsigned") == true
237
+ val serverScope = b.string("serverScope", 128)
238
+ val allowUnpinned = b.bool("allowUnpinnedConfigApi") == true
239
+ val allowServerKey = b.bool("allowServerGeneratedKey") == true
240
+ val clientCaPins = b.stringList("clientCaPins", 16, 128)
241
+ val maxLifetime = b.int("maxClientCertLifetimeDays", 1, 3650)
242
+ val clientCertHosts = b.stringList("clientCertHosts", 64, 2048)
243
+ val enrollmentEndpoint = b.string("enrollmentEndpoint", 512)
244
+ val clientCertEndpoint = b.string("clientCertEndpoint", 512)
245
+ val vaultReportEndpoint = b.string("vaultReportEndpoint", 512)
246
+ val clientCertLabel = b.string("clientCertLabel", 128)
247
+ val wantPinsFor = b.stringList("wantPinsFor", 256, 255)
248
+ val renewalUrl = httpsUrl(b, "renewalUrl")
249
+ val enrollmentUrl = httpsUrl(b, "enrollmentUrl")
250
+ val threshold = b.double("clientCertRenewalThreshold", 0.0, 1.0)
251
+ val disableRenewal = b.bool("disableClientCertRenewal") == true
252
+ val jsAttestation = b.bool("attestation")
253
+ val attestationInterval = b.obj("attestationInterval")?.let(::duration)
254
+ val jsTokenHosts = b.stringList("tokenHosts", 64, 255)
255
+ val jsProof = b.bool("proofOfPossession")
256
+ b.finish()
257
+
258
+ // Where the block talks to and who gets its token and identity: fixed where the file says.
259
+ val nativeBlock = native?.blocks?.get(id)
260
+ fun fixed(key: String): Nothing =
261
+ throw BridgeInputException("${b.path}.$key: differs from the app's native security file (${native?.source}); the native value is fixed")
262
+ val blockUrl = nativeBlock?.url?.also { if (it != url) fixed("url") } ?: url
263
+ val fixedEnrollmentUrl = nativeBlock?.enrollmentUrl?.also { if (enrollmentUrl != null && it != enrollmentUrl) fixed("enrollmentUrl") } ?: enrollmentUrl
264
+ val fixedRenewalUrl = nativeBlock?.renewalUrl?.also { if (renewalUrl != null && it != renewalUrl) fixed("renewalUrl") } ?: renewalUrl
265
+ if (nativeBlock?.attestation == true && jsAttestation == false) {
266
+ throw BridgeInputException("${b.path}.attestation: the app's native security file turns it on; JS cannot turn it off")
267
+ }
268
+ val attestation = jsAttestation == true || nativeBlock?.attestation == true
269
+ if (nativeBlock?.proofOfPossession == true && jsProof == false) {
270
+ throw BridgeInputException("${b.path}.proofOfPossession: the app's native security file turns it on; JS cannot turn it off")
271
+ }
272
+ val proof = jsProof == true || nativeBlock?.proofOfPossession == true
273
+ val tokenHosts = nativeBlock?.tokenHosts?.also { n ->
274
+ if (jsTokenHosts != null && !NativeSecurity.sameKeys(jsTokenHosts.map(String::lowercase), n.map(String::lowercase))) fixed("tokenHosts")
275
+ } ?: jsTokenHosts
276
+ val certHosts = nativeBlock?.clientCertHosts?.also { n ->
277
+ if (clientCertHosts != null && !NativeSecurity.sameKeys(clientCertHosts.map(String::lowercase), n.map(String::lowercase))) fixed("clientCertHosts")
278
+ } ?: clientCertHosts
279
+
280
+ // The trust anchors and relaxations: the native security file decides, JS only repeats.
281
+ val sec = SecurityPolicy.apply(
282
+ b.path, id,
283
+ SecurityFields(
284
+ bootstrapPins, oneKey, keys, requiredSignatures, recoveryKeys, requiredRecovery,
285
+ serverScope, clientCaPins, allowUnsigned, allowUnpinned, allowServerKey,
286
+ ),
287
+ native, release,
288
+ )
289
+
290
+ builder.configApi(id, blockUrl) {
291
+ sec.bootstrapPins?.let { this.bootstrapPins(it) }
292
+ configEndpoint?.let { this.configEndpoint(it) }
293
+ healthEndpoint?.let { this.healthEndpoint(it) }
294
+ sec.oneKey?.let { this.signaturePublicKey(it) }
295
+ sec.keys?.let { this.signaturePublicKeys(it) }
296
+ sec.requiredSignatures?.let { this.requiredSignatures(it) }
297
+ sec.recoveryKeys?.let { this.recoveryPublicKeys(it) }
298
+ sec.requiredRecovery?.let { this.requiredRecoverySignatures(it) }
299
+ if (sec.allowUnsigned) this.allowUnsigned()
300
+ sec.serverScope?.let { this.serverScope(it) }
301
+ if (sec.allowUnpinned) this.allowUnpinnedConfigApi()
302
+ if (sec.allowServerKey) this.allowServerGeneratedKey()
303
+ sec.clientCaPins?.let { this.clientCaPins(it) }
304
+ maxLifetime?.let { this.maxClientCertLifetimeDays(it) }
305
+ certHosts?.let { this.clientCertHosts(it) }
306
+ enrollmentEndpoint?.let { this.enrollmentEndpoint(it) }
307
+ clientCertEndpoint?.let { this.clientCertEndpoint(it) }
308
+ vaultReportEndpoint?.let { this.vaultReportEndpoint(it) }
309
+ clientCertLabel?.let { this.clientCertLabel(it) }
310
+ wantPinsFor?.let { this.wantPinsFor(*it.toTypedArray()) }
311
+ fixedRenewalUrl?.let { this.renewalUrl(it) }
312
+ fixedEnrollmentUrl?.let { this.enrollmentUrl(it) }
313
+ threshold?.let { this.clientCertRenewalThreshold(it) }
314
+ if (disableRenewal) this.disableClientCertRenewal()
315
+ if (attestation) this.attestation()
316
+ attestationInterval?.let { this.attestationInterval(it.first, it.second) }
317
+ tokenHosts?.let { this.tokenHosts(it) }
318
+ if (proof) this.proofOfPossession()
319
+ }
320
+ }
321
+
322
+ private fun addVaultFile(builder: PinVaultConfig.Builder, f: Fields, tokens: VaultTokenStore, native: NativeSecurity?) {
323
+ val key = f.requireString("key", 128)
324
+ // The same rule as the library's (the key names the stored copy's file).
325
+ if (!VAULT_KEY.matches(key) || key.all { it == '.' }) {
326
+ throw BridgeInputException("${f.path}.key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
327
+ }
328
+ val endpoint = f.requireString("endpoint", 512)
329
+ val jsSignatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
330
+ val updateWithPins = f.bool("updateWithPins")
331
+ val storage = f.enum("storage", StorageStrategy.values())
332
+ val configApi = f.string("configApi", 128)
333
+ val policy = f.enum("accessPolicy", VaultFileAccessPolicy.values())
334
+ val jsEncryption = f.enum("encryption", VaultFileEncryption.values())
335
+ val jsUserAuth = f.enum("userAuth", UserAuth.values())
336
+ val maxOfflineAge = f.obj("maxOfflineAge")?.let(::duration)
337
+ val wipeWhenStale = f.bool("wipeWhenStale") == true
338
+ f.finish()
339
+
340
+ // A file the native security file names keeps its signature key, encryption and lock.
341
+ val declared = native?.vaultFiles?.let { files ->
342
+ files[key] ?: throw BridgeInputException("${f.path}: vault file '$key' is not declared in the app's native security file (${native.source})")
343
+ }
344
+ fun fixed(field: String): Nothing =
345
+ throw BridgeInputException("${f.path}.$field: differs from the app's native security file (${native?.source}); the native value is fixed")
346
+ val signatureKey = declared?.signaturePublicKey?.also { n ->
347
+ if (jsSignatureKey != null && jsSignatureKey.trim() != n.trim()) fixed("signaturePublicKey")
348
+ } ?: jsSignatureKey
349
+ val encryption = declared?.encryption?.also { if (jsEncryption != null && jsEncryption != it) fixed("encryption") } ?: jsEncryption
350
+ val userAuth = declared?.userAuth?.also { if (jsUserAuth != null && jsUserAuth != it) fixed("userAuth") } ?: jsUserAuth
351
+ // The tighter of the file's own cap and the config-wide one: a per-file value
352
+ // overrides the config's, so it must not escape the config-wide cap either.
353
+ val cap = listOfNotNull(declared?.maxOfflineAgeMs, native?.require?.vaultFileMaxOfflineAgeMs).minOrNull()
354
+ cap?.let { max ->
355
+ maxOfflineAge?.let { (amount, unit) ->
356
+ val ms = unit.toMillis(amount)
357
+ // 0 = no limit: longer than any.
358
+ if (ms == 0L || ms > max) {
359
+ throw BridgeInputException("${f.path}.maxOfflineAge: longer than the app's native security file allows (${max / 1000} s)")
360
+ }
361
+ }
362
+ }
363
+ val offlineAge = maxOfflineAge ?: declared?.maxOfflineAgeMs?.let { it to TimeUnit.MILLISECONDS }
364
+
365
+ builder.vaultFile(key) {
366
+ this.endpoint(endpoint)
367
+ signatureKey?.let { this.signaturePublicKey(it) }
368
+ updateWithPins?.let { this.updateWithPins(it) }
369
+ storage?.let { this.storage(it) }
370
+ configApi?.let { this.configApi(it) }
371
+ policy?.let { this.accessPolicy(it) }
372
+ if (policy == VaultFileAccessPolicy.TOKEN || policy == VaultFileAccessPolicy.TOKEN_MTLS) {
373
+ // Read on every download; the token never leaves native memory.
374
+ this.accessToken { tokens.get(key) }
375
+ }
376
+ encryption?.let { this.encryption(it) }
377
+ userAuth?.let { this.userAuth(it) }
378
+ offlineAge?.let { this.maxOfflineAge(it.first, it.second) }
379
+ if (wipeWhenStale) this.wipeWhenStale()
380
+ }
381
+ }
382
+
383
+ /** `unlockFile` prompt texts + the content encoding. */
384
+ fun unlockPrompt(json: String): Pair<VaultFileUnlockPrompt, String> {
385
+ val p = StrictJson.parseObject(json, "prompt", maxChars = 16 * 1024)
386
+ val prompt = VaultFileUnlockPrompt(
387
+ title = p.requireString("title", 256),
388
+ subtitle = p.string("subtitle", 256),
389
+ description = p.string("description", 1024),
390
+ negativeButtonText = p.string("negativeButtonText", 64) ?: "Cancel",
391
+ )
392
+ val encoding = ResultMapper.checkEncoding(p.string("encoding", 16) ?: ResultMapper.UTF8)
393
+ p.finish()
394
+ return prompt to encoding
395
+ }
396
+ }
@@ -0,0 +1,53 @@
1
+ package io.github.umutcansu.pinvault.reactnative
2
+
3
+ import io.github.umutcansu.pinvault.model.EnvironmentGuard
4
+ import io.github.umutcansu.pinvault.model.GuardedOperation
5
+ import java.util.UUID
6
+ import java.util.concurrent.CompletableFuture
7
+ import java.util.concurrent.ConcurrentHashMap
8
+ import java.util.concurrent.TimeUnit
9
+
10
+ /**
11
+ * `environmentGuard` answered by a JS callback. The library asks on a
12
+ * background thread; this sends `{requestId, operation}` to JS and waits for
13
+ * [answer] up to [timeoutMs]. Fail closed: no answer in time, an unknown
14
+ * request id, an interrupt — every one of them is a refusal.
15
+ *
16
+ * The answer comes on the native-modules thread, the question is asked on the
17
+ * library's worker thread, so waiting here never blocks the thread that
18
+ * delivers the answer.
19
+ */
20
+ internal class JsEnvironmentGuard(
21
+ private val timeoutMs: Long,
22
+ private val ask: (requestId: String, operation: String) -> Unit,
23
+ ) : EnvironmentGuard {
24
+
25
+ private val pending = ConcurrentHashMap<String, CompletableFuture<Boolean>>()
26
+
27
+ override fun allows(operation: GuardedOperation): Boolean {
28
+ val id = UUID.randomUUID().toString()
29
+ val answer = CompletableFuture<Boolean>()
30
+ pending[id] = answer
31
+ return try {
32
+ ask(id, operation.name)
33
+ answer.get(timeoutMs, TimeUnit.MILLISECONDS) == true
34
+ } catch (_: Exception) {
35
+ false
36
+ } finally {
37
+ pending.remove(id)
38
+ }
39
+ }
40
+
41
+ /** JS's verdict; ignored when the request is unknown or already timed out. */
42
+ fun answer(requestId: String, allowed: Boolean) {
43
+ pending[requestId]?.complete(allowed)
44
+ }
45
+
46
+ fun pendingCount(): Int = pending.size
47
+
48
+ companion object {
49
+ const val DEFAULT_TIMEOUT_MS = 5000L
50
+ const val MIN_TIMEOUT_MS = 100L
51
+ const val MAX_TIMEOUT_MS = 30_000L
52
+ }
53
+ }