@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.
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +386 -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 +300 -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 +239 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -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 +276 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +197 -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 +412 -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 +312 -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 +346 -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 +385 -0
- package/src/types.ts +351 -0
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param native the app's native security file ([NativeSecurity]); null = none.
|
|
61
|
+
* @param release a release build of the app: without a native file, the
|
|
62
|
+
* relaxations (`allowUnsigned`, …) are refused from JS.
|
|
63
|
+
*/
|
|
64
|
+
fun parse(
|
|
65
|
+
json: String,
|
|
66
|
+
tokens: VaultTokenStore,
|
|
67
|
+
guardFactory: (timeoutMs: Long) -> EnvironmentGuard,
|
|
68
|
+
listener: PinVaultConnectionListener?,
|
|
69
|
+
native: NativeSecurity? = null,
|
|
70
|
+
release: Boolean = false,
|
|
71
|
+
): ParsedConfig {
|
|
72
|
+
val root = StrictJson.parseObject(json, "config")
|
|
73
|
+
val builder = PinVaultConfig.Builder()
|
|
74
|
+
|
|
75
|
+
root.objList("configApis", MAX_CONFIG_APIS)?.forEach { addConfigApi(builder, it, native, release) }
|
|
76
|
+
root.objList("vaultFiles", MAX_VAULT_FILES)?.forEach { addVaultFile(builder, it, tokens) }
|
|
77
|
+
val staticPins = root.obj("staticPins")?.let(::staticPins)
|
|
78
|
+
SecurityPolicy.staticPins("config.staticPins", staticPins, native)?.let { builder.staticPins(it) }
|
|
79
|
+
|
|
80
|
+
root.int("maxRetryCount", 0, 10)?.let { builder.maxRetryCount(it) }
|
|
81
|
+
root.long("updateIntervalHours", 1, 24L * 30)?.let { builder.updateIntervalHours(it) }
|
|
82
|
+
root.long("updateIntervalMinutes", 15, 60L * 24 * 30)?.let { builder.updateIntervalMinutes(it) }
|
|
83
|
+
root.string("deviceAlias", 128)?.let { builder.deviceAlias(it) }
|
|
84
|
+
root.obj("expiredConfigGrace")?.let { d -> duration(d).let { builder.expiredConfigGrace(it.first, it.second) } }
|
|
85
|
+
root.stringList("requireCaTrust", 64, 255)?.let { builder.requireCaTrust(*it.toTypedArray()) }
|
|
86
|
+
if (root.bool("wipeVaultFilesOnRevocation") == true) builder.wipeVaultFilesOnRevocation()
|
|
87
|
+
root.obj("vaultFileMaxOfflineAge")?.let { d -> duration(d).let { builder.vaultFileMaxOfflineAge(it.first, it.second) } }
|
|
88
|
+
val requireUnlocked = root.bool("requireUnlockedDevice") == true
|
|
89
|
+
if (root.bool("requireHardwareBackedKeys") == true) builder.requireHardwareBackedKeys()
|
|
90
|
+
if (root.bool("managedTrustRoots") == true) builder.managedTrustRoots()
|
|
91
|
+
root.stringList("expectedSignerSha256", 16, 128)?.let { builder.expectedSignerSha256(it) }
|
|
92
|
+
|
|
93
|
+
var guardTimeout: Long? = null
|
|
94
|
+
root.obj("environmentGuard")?.let { g ->
|
|
95
|
+
guardTimeout = g.long("timeoutMs", JsEnvironmentGuard.MIN_TIMEOUT_MS, JsEnvironmentGuard.MAX_TIMEOUT_MS)
|
|
96
|
+
?: JsEnvironmentGuard.DEFAULT_TIMEOUT_MS
|
|
97
|
+
g.finish()
|
|
98
|
+
builder.environmentGuard(guardFactory(guardTimeout!!))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var allowFallback = false
|
|
102
|
+
var pinGlobal = true
|
|
103
|
+
var keepCache = false
|
|
104
|
+
var keepCookies = false
|
|
105
|
+
root.obj("android")?.let { a ->
|
|
106
|
+
allowFallback = a.bool("requireUnlockedDeviceAllowFallback") == true
|
|
107
|
+
pinGlobal = a.bool("pinGlobalNetworking") ?: true
|
|
108
|
+
keepCache = a.bool("keepReactNativeHttpCache") == true
|
|
109
|
+
keepCookies = a.bool("keepReactNativeCookies") == true
|
|
110
|
+
a.finish()
|
|
111
|
+
}
|
|
112
|
+
val requirePinned = root.bool("requirePinnedReactNativeNetworking") == true
|
|
113
|
+
if (requirePinned && !pinGlobal) {
|
|
114
|
+
throw BridgeInputException("config.requirePinnedReactNativeNetworking: contradicts android.pinGlobalNetworking: false")
|
|
115
|
+
}
|
|
116
|
+
if (allowFallback && !requireUnlocked) {
|
|
117
|
+
throw BridgeInputException("config.android.requireUnlockedDeviceAllowFallback: needs requireUnlockedDevice: true")
|
|
118
|
+
}
|
|
119
|
+
if (requireUnlocked) builder.requireUnlockedDevice(allowFallback)
|
|
120
|
+
// iOS-only settings: the iOS side reads them; here only their shape is checked.
|
|
121
|
+
root.obj("ios")
|
|
122
|
+
|
|
123
|
+
root.finish()
|
|
124
|
+
listener?.let { builder.onConnectionEvent(it) }
|
|
125
|
+
return ParsedConfig(
|
|
126
|
+
builder.build(), guardTimeout, pinGlobal,
|
|
127
|
+
NetworkingOptions(requirePinned, keepCache, keepCookies),
|
|
128
|
+
nativeSecurityApplied = native != null,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private fun duration(d: Fields): Pair<Long, TimeUnit> {
|
|
133
|
+
val amount = d.long("amount", 0, 10L * 365 * 24 * 3600 * 1000) ?: throw BridgeInputException("${d.path}.amount: required")
|
|
134
|
+
val unit = when (val u = d.requireString("unit", 16)) {
|
|
135
|
+
"MILLISECONDS" -> TimeUnit.MILLISECONDS
|
|
136
|
+
"SECONDS" -> TimeUnit.SECONDS
|
|
137
|
+
"MINUTES" -> TimeUnit.MINUTES
|
|
138
|
+
"HOURS" -> TimeUnit.HOURS
|
|
139
|
+
"DAYS" -> TimeUnit.DAYS
|
|
140
|
+
else -> throw BridgeInputException("${d.path}.unit: '$u' is not one of MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS")
|
|
141
|
+
}
|
|
142
|
+
d.finish()
|
|
143
|
+
return amount to unit
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fun httpsUrl(f: Fields, key: String): String? {
|
|
147
|
+
val url = f.string(key, 2048) ?: return null
|
|
148
|
+
if (!url.startsWith("https://")) throw BridgeInputException("${f.path}.$key: must be an https:// URL")
|
|
149
|
+
return url
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fun hostPin(p: Fields): HostPin {
|
|
153
|
+
val hostname = p.requireString("hostname", 255)
|
|
154
|
+
val pins = p.stringList("sha256", MAX_PINS_PER_HOST, 128) ?: throw BridgeInputException("${p.path}.sha256: required")
|
|
155
|
+
val pin = HostPin(
|
|
156
|
+
hostname = hostname,
|
|
157
|
+
sha256 = pins,
|
|
158
|
+
version = p.int("version", 0, Int.MAX_VALUE) ?: 0,
|
|
159
|
+
forceUpdate = p.bool("forceUpdate") ?: false,
|
|
160
|
+
mtls = p.bool("mtls") ?: false,
|
|
161
|
+
clientCertVersion = p.int("clientCertVersion", 0, Int.MAX_VALUE),
|
|
162
|
+
)
|
|
163
|
+
p.finish()
|
|
164
|
+
return pin
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fun staticPins(s: Fields): CertificateConfig {
|
|
168
|
+
val pins = s.objList("pins", MAX_PINS)?.map(::hostPin) ?: throw BridgeInputException("${s.path}.pins: required")
|
|
169
|
+
val config = CertificateConfig(
|
|
170
|
+
version = s.int("version", 0, Int.MAX_VALUE) ?: 0,
|
|
171
|
+
pins = pins,
|
|
172
|
+
forceUpdate = s.bool("forceUpdate") ?: false,
|
|
173
|
+
)
|
|
174
|
+
s.finish()
|
|
175
|
+
return config
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private fun addConfigApi(builder: PinVaultConfig.Builder, b: Fields, native: NativeSecurity?, release: Boolean) {
|
|
179
|
+
val id = b.requireString("id", 128)
|
|
180
|
+
val url = httpsUrl(b, "url") ?: throw BridgeInputException("${b.path}.url: required")
|
|
181
|
+
// Read everything before the builder runs, so a refusal names the JSON path.
|
|
182
|
+
val bootstrapPins = b.objList("bootstrapPins", MAX_PINS)?.map(::hostPin)
|
|
183
|
+
val configEndpoint = b.string("configEndpoint", 512)
|
|
184
|
+
val healthEndpoint = b.string("healthEndpoint", 512)
|
|
185
|
+
val oneKey = b.string("signaturePublicKey", KEY_LENGTH, multiline = true)
|
|
186
|
+
val keys = b.stringList("signaturePublicKeys", 16, KEY_LENGTH, multiline = true)
|
|
187
|
+
if (oneKey != null && keys != null) {
|
|
188
|
+
throw BridgeInputException("${b.path}: give signaturePublicKey or signaturePublicKeys, not both")
|
|
189
|
+
}
|
|
190
|
+
val requiredSignatures = b.int("requiredSignatures", 1, 16)
|
|
191
|
+
val recoveryKeys = b.stringList("recoveryPublicKeys", 16, KEY_LENGTH, multiline = true)
|
|
192
|
+
val requiredRecovery = b.int("requiredRecoverySignatures", 1, 16)
|
|
193
|
+
val allowUnsigned = b.bool("allowUnsigned") == true
|
|
194
|
+
val serverScope = b.string("serverScope", 128)
|
|
195
|
+
val allowUnpinned = b.bool("allowUnpinnedConfigApi") == true
|
|
196
|
+
val allowServerKey = b.bool("allowServerGeneratedKey") == true
|
|
197
|
+
val clientCaPins = b.stringList("clientCaPins", 16, 128)
|
|
198
|
+
val maxLifetime = b.int("maxClientCertLifetimeDays", 1, 3650)
|
|
199
|
+
val clientCertHosts = b.stringList("clientCertHosts", 64, 2048)
|
|
200
|
+
val enrollmentEndpoint = b.string("enrollmentEndpoint", 512)
|
|
201
|
+
val clientCertEndpoint = b.string("clientCertEndpoint", 512)
|
|
202
|
+
val vaultReportEndpoint = b.string("vaultReportEndpoint", 512)
|
|
203
|
+
val clientCertLabel = b.string("clientCertLabel", 128)
|
|
204
|
+
val wantPinsFor = b.stringList("wantPinsFor", 256, 255)
|
|
205
|
+
val renewalUrl = httpsUrl(b, "renewalUrl")
|
|
206
|
+
val enrollmentUrl = httpsUrl(b, "enrollmentUrl")
|
|
207
|
+
val threshold = b.double("clientCertRenewalThreshold", 0.0, 1.0)
|
|
208
|
+
val disableRenewal = b.bool("disableClientCertRenewal") == true
|
|
209
|
+
val attestation = b.bool("attestation") == true
|
|
210
|
+
val attestationInterval = b.obj("attestationInterval")?.let(::duration)
|
|
211
|
+
val tokenHosts = b.stringList("tokenHosts", 64, 255)
|
|
212
|
+
b.finish()
|
|
213
|
+
|
|
214
|
+
// The trust anchors and relaxations: the native security file decides, JS only repeats.
|
|
215
|
+
val sec = SecurityPolicy.apply(
|
|
216
|
+
b.path, id,
|
|
217
|
+
SecurityFields(
|
|
218
|
+
bootstrapPins, oneKey, keys, requiredSignatures, recoveryKeys, requiredRecovery,
|
|
219
|
+
serverScope, clientCaPins, allowUnsigned, allowUnpinned, allowServerKey,
|
|
220
|
+
),
|
|
221
|
+
native, release,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
builder.configApi(id, url) {
|
|
225
|
+
sec.bootstrapPins?.let { this.bootstrapPins(it) }
|
|
226
|
+
configEndpoint?.let { this.configEndpoint(it) }
|
|
227
|
+
healthEndpoint?.let { this.healthEndpoint(it) }
|
|
228
|
+
sec.oneKey?.let { this.signaturePublicKey(it) }
|
|
229
|
+
sec.keys?.let { this.signaturePublicKeys(it) }
|
|
230
|
+
sec.requiredSignatures?.let { this.requiredSignatures(it) }
|
|
231
|
+
sec.recoveryKeys?.let { this.recoveryPublicKeys(it) }
|
|
232
|
+
sec.requiredRecovery?.let { this.requiredRecoverySignatures(it) }
|
|
233
|
+
if (sec.allowUnsigned) this.allowUnsigned()
|
|
234
|
+
sec.serverScope?.let { this.serverScope(it) }
|
|
235
|
+
if (sec.allowUnpinned) this.allowUnpinnedConfigApi()
|
|
236
|
+
if (sec.allowServerKey) this.allowServerGeneratedKey()
|
|
237
|
+
sec.clientCaPins?.let { this.clientCaPins(it) }
|
|
238
|
+
maxLifetime?.let { this.maxClientCertLifetimeDays(it) }
|
|
239
|
+
clientCertHosts?.let { this.clientCertHosts(it) }
|
|
240
|
+
enrollmentEndpoint?.let { this.enrollmentEndpoint(it) }
|
|
241
|
+
clientCertEndpoint?.let { this.clientCertEndpoint(it) }
|
|
242
|
+
vaultReportEndpoint?.let { this.vaultReportEndpoint(it) }
|
|
243
|
+
clientCertLabel?.let { this.clientCertLabel(it) }
|
|
244
|
+
wantPinsFor?.let { this.wantPinsFor(*it.toTypedArray()) }
|
|
245
|
+
renewalUrl?.let { this.renewalUrl(it) }
|
|
246
|
+
enrollmentUrl?.let { this.enrollmentUrl(it) }
|
|
247
|
+
threshold?.let { this.clientCertRenewalThreshold(it) }
|
|
248
|
+
if (disableRenewal) this.disableClientCertRenewal()
|
|
249
|
+
if (attestation) this.attestation()
|
|
250
|
+
attestationInterval?.let { this.attestationInterval(it.first, it.second) }
|
|
251
|
+
tokenHosts?.let { this.tokenHosts(it) }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private fun addVaultFile(builder: PinVaultConfig.Builder, f: Fields, tokens: VaultTokenStore) {
|
|
256
|
+
val key = f.requireString("key", 128)
|
|
257
|
+
val endpoint = f.requireString("endpoint", 512)
|
|
258
|
+
val signatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
|
|
259
|
+
val updateWithPins = f.bool("updateWithPins")
|
|
260
|
+
val storage = f.enum("storage", StorageStrategy.values())
|
|
261
|
+
val configApi = f.string("configApi", 128)
|
|
262
|
+
val policy = f.enum("accessPolicy", VaultFileAccessPolicy.values())
|
|
263
|
+
val encryption = f.enum("encryption", VaultFileEncryption.values())
|
|
264
|
+
val userAuth = f.enum("userAuth", UserAuth.values())
|
|
265
|
+
val maxOfflineAge = f.obj("maxOfflineAge")?.let(::duration)
|
|
266
|
+
val wipeWhenStale = f.bool("wipeWhenStale") == true
|
|
267
|
+
f.finish()
|
|
268
|
+
|
|
269
|
+
builder.vaultFile(key) {
|
|
270
|
+
this.endpoint(endpoint)
|
|
271
|
+
signatureKey?.let { this.signaturePublicKey(it) }
|
|
272
|
+
updateWithPins?.let { this.updateWithPins(it) }
|
|
273
|
+
storage?.let { this.storage(it) }
|
|
274
|
+
configApi?.let { this.configApi(it) }
|
|
275
|
+
policy?.let { this.accessPolicy(it) }
|
|
276
|
+
if (policy == VaultFileAccessPolicy.TOKEN || policy == VaultFileAccessPolicy.TOKEN_MTLS) {
|
|
277
|
+
// Read on every download; the token never leaves native memory.
|
|
278
|
+
this.accessToken { tokens.get(key) }
|
|
279
|
+
}
|
|
280
|
+
encryption?.let { this.encryption(it) }
|
|
281
|
+
userAuth?.let { this.userAuth(it) }
|
|
282
|
+
maxOfflineAge?.let { this.maxOfflineAge(it.first, it.second) }
|
|
283
|
+
if (wipeWhenStale) this.wipeWhenStale()
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `unlockFile` prompt texts + the content encoding. */
|
|
288
|
+
fun unlockPrompt(json: String): Pair<VaultFileUnlockPrompt, String> {
|
|
289
|
+
val p = StrictJson.parseObject(json, "prompt", maxChars = 16 * 1024)
|
|
290
|
+
val prompt = VaultFileUnlockPrompt(
|
|
291
|
+
title = p.requireString("title", 256),
|
|
292
|
+
subtitle = p.string("subtitle", 256),
|
|
293
|
+
description = p.string("description", 1024),
|
|
294
|
+
negativeButtonText = p.string("negativeButtonText", 64) ?: "Cancel",
|
|
295
|
+
)
|
|
296
|
+
val encoding = ResultMapper.checkEncoding(p.string("encoding", 16) ?: ResultMapper.UTF8)
|
|
297
|
+
p.finish()
|
|
298
|
+
return prompt to encoding
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.ApplicationInfo
|
|
5
|
+
import io.github.umutcansu.pinvault.model.CertificateConfig
|
|
6
|
+
import io.github.umutcansu.pinvault.model.HostPin
|
|
7
|
+
import java.io.FileNotFoundException
|
|
8
|
+
import java.io.IOException
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The app's native security file: the trust anchors and relaxations of
|
|
12
|
+
* `start(config)` declared outside the JS bundle, which an OTA update
|
|
13
|
+
* (CodePush, Expo Updates) or an edited bundle can change. The file ships in
|
|
14
|
+
* the APK (`src/main/assets/pinvault_security.json`, or a build type's own
|
|
15
|
+
* `src/release/assets/`), which the APK signature seals.
|
|
16
|
+
*
|
|
17
|
+
* ```json
|
|
18
|
+
* {
|
|
19
|
+
* "configApis": [{
|
|
20
|
+
* "id": "default-tls",
|
|
21
|
+
* "bootstrapPins": [{ "hostname": "api.example.com", "sha256": ["…", "…"] }],
|
|
22
|
+
* "signaturePublicKeys": ["…", "…"], "requiredSignatures": 2,
|
|
23
|
+
* "recoveryPublicKeys": ["…"], "requiredRecoverySignatures": 1,
|
|
24
|
+
* "serverScope": "default-tls", "clientCaPins": ["…"],
|
|
25
|
+
* "allowUnsigned": false, "allowUnpinnedConfigApi": false, "allowServerGeneratedKey": false
|
|
26
|
+
* }],
|
|
27
|
+
* "staticPins": { "pins": [ … ], "version": 1 }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* When the file is there ([apply]):
|
|
32
|
+
* - every Config API of the JS config must be declared in it (an OTA update
|
|
33
|
+
* cannot add a block with keys of its own), and JS `staticPins` must be
|
|
34
|
+
* declared too;
|
|
35
|
+
* - a field the file declares is the value: JS may leave it out or repeat it,
|
|
36
|
+
* a different value is refused (`E_INVALID_CONFIG`);
|
|
37
|
+
* - `allowUnsigned`, `allowUnpinnedConfigApi`, `allowServerGeneratedKey` from
|
|
38
|
+
* JS are refused unless the file allows them for that block.
|
|
39
|
+
*
|
|
40
|
+
* Without the file, release builds (the app is not debuggable) refuse those
|
|
41
|
+
* three relaxations from JS; debug builds take them as before.
|
|
42
|
+
*/
|
|
43
|
+
internal class NativeSecurity(
|
|
44
|
+
/** Where the file came from, for messages. */
|
|
45
|
+
val source: String,
|
|
46
|
+
val blocks: Map<String, Block>,
|
|
47
|
+
val staticPins: CertificateConfig?,
|
|
48
|
+
) {
|
|
49
|
+
|
|
50
|
+
class Block(
|
|
51
|
+
val bootstrapPins: List<HostPin>?,
|
|
52
|
+
val signaturePublicKeys: List<String>?,
|
|
53
|
+
val requiredSignatures: Int?,
|
|
54
|
+
val recoveryPublicKeys: List<String>?,
|
|
55
|
+
val requiredRecoverySignatures: Int?,
|
|
56
|
+
val serverScope: String?,
|
|
57
|
+
val clientCaPins: List<String>?,
|
|
58
|
+
val allowUnsigned: Boolean,
|
|
59
|
+
val allowUnpinnedConfigApi: Boolean,
|
|
60
|
+
val allowServerGeneratedKey: Boolean,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
companion object {
|
|
64
|
+
const val ASSET_NAME = "pinvault_security.json"
|
|
65
|
+
private const val MAX_FILE_CHARS = 256 * 1024
|
|
66
|
+
|
|
67
|
+
/** The file from the app's assets; null when the app ships none. A broken file throws [BridgeInputException]. */
|
|
68
|
+
fun load(context: Context): NativeSecurity? {
|
|
69
|
+
val text = try {
|
|
70
|
+
context.assets.open(ASSET_NAME).bufferedReader(Charsets.UTF_8).use { it.readText() }
|
|
71
|
+
} catch (_: FileNotFoundException) {
|
|
72
|
+
return null
|
|
73
|
+
} catch (e: IOException) {
|
|
74
|
+
// There, but unreadable: fail closed.
|
|
75
|
+
throw BridgeInputException("native security file assets/$ASSET_NAME: cannot be read (${e.javaClass.simpleName})")
|
|
76
|
+
}
|
|
77
|
+
return parse(text, "assets/$ASSET_NAME")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True for a release build of the app (not `android:debuggable`). */
|
|
81
|
+
fun isReleaseBuild(context: Context): Boolean =
|
|
82
|
+
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
|
|
83
|
+
|
|
84
|
+
fun parse(text: String, source: String): NativeSecurity {
|
|
85
|
+
val root = try {
|
|
86
|
+
StrictJson.parseObject(text, source, maxChars = MAX_FILE_CHARS)
|
|
87
|
+
} catch (e: BridgeInputException) {
|
|
88
|
+
throw BridgeInputException("native security file ${e.message}")
|
|
89
|
+
}
|
|
90
|
+
val blocks = LinkedHashMap<String, Block>()
|
|
91
|
+
try {
|
|
92
|
+
root.objList("configApis", ConfigParser.MAX_CONFIG_APIS)?.forEach { b ->
|
|
93
|
+
val id = b.requireString("id", 128)
|
|
94
|
+
if (id in blocks) throw BridgeInputException("${b.path}.id: '$id' is declared twice")
|
|
95
|
+
blocks[id] = Block(
|
|
96
|
+
bootstrapPins = b.objList("bootstrapPins", ConfigParser.MAX_PINS)?.map(ConfigParser::hostPin),
|
|
97
|
+
signaturePublicKeys = b.stringList("signaturePublicKeys", 16, ConfigParser.KEY_LENGTH, multiline = true),
|
|
98
|
+
requiredSignatures = b.int("requiredSignatures", 1, 16),
|
|
99
|
+
recoveryPublicKeys = b.stringList("recoveryPublicKeys", 16, ConfigParser.KEY_LENGTH, multiline = true),
|
|
100
|
+
requiredRecoverySignatures = b.int("requiredRecoverySignatures", 1, 16),
|
|
101
|
+
serverScope = b.string("serverScope", 128),
|
|
102
|
+
clientCaPins = b.stringList("clientCaPins", 16, 128),
|
|
103
|
+
allowUnsigned = b.bool("allowUnsigned") == true,
|
|
104
|
+
allowUnpinnedConfigApi = b.bool("allowUnpinnedConfigApi") == true,
|
|
105
|
+
allowServerGeneratedKey = b.bool("allowServerGeneratedKey") == true,
|
|
106
|
+
)
|
|
107
|
+
b.finish()
|
|
108
|
+
}
|
|
109
|
+
val staticPins = root.obj("staticPins")?.let(ConfigParser::staticPins)
|
|
110
|
+
root.finish()
|
|
111
|
+
if (blocks.isEmpty() && staticPins == null) {
|
|
112
|
+
throw BridgeInputException("$source: declares neither configApis nor staticPins")
|
|
113
|
+
}
|
|
114
|
+
return NativeSecurity(source, blocks, staticPins)
|
|
115
|
+
} catch (e: BridgeInputException) {
|
|
116
|
+
val message = e.message ?: ""
|
|
117
|
+
throw BridgeInputException(if (message.startsWith("native security file")) message else "native security file $message")
|
|
118
|
+
} catch (e: IllegalArgumentException) {
|
|
119
|
+
// A builder / constructor rule (two pins per host, …).
|
|
120
|
+
throw BridgeInputException("native security file $source: ${e.message}")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Comparison (order of lists does not matter) ─────────────────────
|
|
125
|
+
|
|
126
|
+
fun sameKeys(a: List<String>, b: List<String>): Boolean = a.map(String::trim).toSet() == b.map(String::trim).toSet()
|
|
127
|
+
|
|
128
|
+
private fun pinKey(p: HostPin) =
|
|
129
|
+
listOf(p.hostname.lowercase(), p.sha256.sorted().joinToString(","), p.version, p.forceUpdate, p.mtls, p.clientCertVersion)
|
|
130
|
+
.joinToString("|")
|
|
131
|
+
|
|
132
|
+
fun samePins(a: List<HostPin>, b: List<HostPin>): Boolean = a.map(::pinKey).sorted() == b.map(::pinKey).sorted()
|
|
133
|
+
|
|
134
|
+
fun sameStaticPins(a: CertificateConfig, b: CertificateConfig): Boolean =
|
|
135
|
+
a.version == b.version && a.forceUpdate == b.forceUpdate && samePins(a.pins, b.pins)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What JS asked for in one `configApis[i]` entry, before the native policy is applied. */
|
|
140
|
+
internal class SecurityFields(
|
|
141
|
+
val bootstrapPins: List<HostPin>?,
|
|
142
|
+
/** `signaturePublicKey` (one) or `signaturePublicKeys`; never both. */
|
|
143
|
+
val oneKey: String?,
|
|
144
|
+
val keys: List<String>?,
|
|
145
|
+
val requiredSignatures: Int?,
|
|
146
|
+
val recoveryKeys: List<String>?,
|
|
147
|
+
val requiredRecovery: Int?,
|
|
148
|
+
val serverScope: String?,
|
|
149
|
+
val clientCaPins: List<String>?,
|
|
150
|
+
val allowUnsigned: Boolean,
|
|
151
|
+
val allowUnpinned: Boolean,
|
|
152
|
+
val allowServerKey: Boolean,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
/** The native policy applied to one block: the values that go to the builder, or a refusal. */
|
|
156
|
+
internal object SecurityPolicy {
|
|
157
|
+
|
|
158
|
+
private const val RELAXATIONS_HINT =
|
|
159
|
+
"a release build takes it only from the app's native security file (assets/${NativeSecurity.ASSET_NAME}, README \"Native security file\")"
|
|
160
|
+
|
|
161
|
+
fun apply(path: String, id: String, js: SecurityFields, native: NativeSecurity?, release: Boolean): SecurityFields {
|
|
162
|
+
if (native == null) {
|
|
163
|
+
if (release) {
|
|
164
|
+
if (js.allowUnsigned) throw BridgeInputException("$path.allowUnsigned: refused from JS; $RELAXATIONS_HINT")
|
|
165
|
+
if (js.allowUnpinned) throw BridgeInputException("$path.allowUnpinnedConfigApi: refused from JS; $RELAXATIONS_HINT")
|
|
166
|
+
if (js.allowServerKey) throw BridgeInputException("$path.allowServerGeneratedKey: refused from JS; $RELAXATIONS_HINT")
|
|
167
|
+
}
|
|
168
|
+
return js
|
|
169
|
+
}
|
|
170
|
+
val block = native.blocks[id]
|
|
171
|
+
?: throw BridgeInputException("$path: Config API '$id' is not declared in the app's native security file (${native.source})")
|
|
172
|
+
|
|
173
|
+
fun fixed(key: String): Nothing =
|
|
174
|
+
throw BridgeInputException("$path.$key: differs from the app's native security file (${native.source}); the native value is fixed")
|
|
175
|
+
|
|
176
|
+
fun relaxation(key: String, js: Boolean, allowed: Boolean) {
|
|
177
|
+
if (js && !allowed) throw BridgeInputException("$path.$key: refused; the app's native security file (${native.source}) does not allow it")
|
|
178
|
+
}
|
|
179
|
+
relaxation("allowUnsigned", js.allowUnsigned, block.allowUnsigned)
|
|
180
|
+
relaxation("allowUnpinnedConfigApi", js.allowUnpinned, block.allowUnpinnedConfigApi)
|
|
181
|
+
relaxation("allowServerGeneratedKey", js.allowServerKey, block.allowServerGeneratedKey)
|
|
182
|
+
|
|
183
|
+
val pins = block.bootstrapPins?.also { n ->
|
|
184
|
+
if (js.bootstrapPins != null && !NativeSecurity.samePins(js.bootstrapPins, n)) fixed("bootstrapPins")
|
|
185
|
+
} ?: js.bootstrapPins
|
|
186
|
+
|
|
187
|
+
val oneKey = js.oneKey
|
|
188
|
+
var keys = js.keys
|
|
189
|
+
block.signaturePublicKeys?.let { n ->
|
|
190
|
+
val jsKeys = js.oneKey?.let { listOf(it) } ?: js.keys
|
|
191
|
+
if (jsKeys == null) {
|
|
192
|
+
keys = n
|
|
193
|
+
} else if (!NativeSecurity.sameKeys(jsKeys, n)) {
|
|
194
|
+
fixed(if (js.oneKey != null) "signaturePublicKey" else "signaturePublicKeys")
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fun <T> one(key: String, nativeValue: T?, jsValue: T?): T? {
|
|
199
|
+
if (nativeValue == null) return jsValue
|
|
200
|
+
if (jsValue != null && jsValue != nativeValue) fixed(key)
|
|
201
|
+
return nativeValue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fun list(key: String, nativeValue: List<String>?, jsValue: List<String>?): List<String>? {
|
|
205
|
+
if (nativeValue == null) return jsValue
|
|
206
|
+
if (jsValue != null && !NativeSecurity.sameKeys(jsValue, nativeValue)) fixed(key)
|
|
207
|
+
return nativeValue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return SecurityFields(
|
|
211
|
+
bootstrapPins = pins,
|
|
212
|
+
oneKey = oneKey,
|
|
213
|
+
keys = keys,
|
|
214
|
+
requiredSignatures = one("requiredSignatures", block.requiredSignatures, js.requiredSignatures),
|
|
215
|
+
recoveryKeys = list("recoveryPublicKeys", block.recoveryPublicKeys, js.recoveryKeys),
|
|
216
|
+
requiredRecovery = one("requiredRecoverySignatures", block.requiredRecoverySignatures, js.requiredRecovery),
|
|
217
|
+
serverScope = one("serverScope", block.serverScope, js.serverScope),
|
|
218
|
+
clientCaPins = list("clientCaPins", block.clientCaPins, js.clientCaPins),
|
|
219
|
+
// The file is the source of truth for the relaxations too.
|
|
220
|
+
allowUnsigned = block.allowUnsigned,
|
|
221
|
+
allowUnpinned = block.allowUnpinnedConfigApi,
|
|
222
|
+
allowServerKey = block.allowServerGeneratedKey,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** JS `staticPins` against the file: declared there or refused; the file's apply when JS has none. */
|
|
227
|
+
fun staticPins(path: String, js: CertificateConfig?, native: NativeSecurity?): CertificateConfig? {
|
|
228
|
+
if (native == null) return js
|
|
229
|
+
val n = native.staticPins
|
|
230
|
+
if (n == null) {
|
|
231
|
+
if (js != null) throw BridgeInputException("$path: not declared in the app's native security file (${native.source})")
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
if (js != null && !NativeSecurity.sameStaticPins(js, n)) {
|
|
235
|
+
throw BridgeInputException("$path: differs from the app's native security file (${native.source}); the native value is fixed")
|
|
236
|
+
}
|
|
237
|
+
return n
|
|
238
|
+
}
|
|
239
|
+
}
|