@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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +437 -2
- package/RNPinVault.podspec +43 -0
- package/android/build.gradle +68 -0
- package/android/consumer-rules.pro +25 -0
- package/android/src/main/AndroidManifest.xml +16 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +396 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +354 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
- package/ios/Core/ConfigParser.swift +437 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +303 -0
- package/ios/Core/PinnedFetch.swift +119 -0
- package/ios/Core/ReactNetworking.swift +172 -0
- package/ios/Core/ResultMapper.swift +163 -0
- package/ios/Core/StrictJSON.swift +214 -0
- package/ios/Core/VaultTokenStore.swift +66 -0
- package/ios/PinVaultBridge.swift +424 -0
- package/ios/PinVaultReactNetworking.swift +47 -0
- package/ios/RNPinVault.h +7 -0
- package/ios/RNPinVault.mm +293 -0
- package/ios/RNPinVaultURLRequestHandler.mm +92 -0
- package/lib/module/NativePinVault.js +13 -0
- package/lib/module/NativePinVault.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePinVault.d.ts +50 -0
- package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +150 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +359 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +128 -4
- package/src/NativePinVault.ts +72 -0
- package/src/index.ts +398 -0
- 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
|
+
}
|