@umutcansu/react-native-pinvault 2.3.2 → 2.4.1
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 +69 -0
- package/README.md +116 -36
- package/RNPinVault.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/consumer-rules.pro +3 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +150 -29
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +134 -5
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +12 -3
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +116 -16
- package/ios/Core/ConfigParser.swift +216 -32
- package/ios/Core/NativeSecurity.swift +127 -6
- package/ios/PinVaultBridge.swift +24 -7
- package/ios/PinVaultReactNetworking.swift +16 -0
- package/ios/RNPinVault.mm +6 -0
- package/ios/RNPinVaultWebSocketPinning.h +21 -0
- package/ios/RNPinVaultWebSocketPinning.mm +170 -0
- package/lib/module/index.js +21 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +14 -1
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +15 -2
- package/src/types.ts +14 -3
package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt
CHANGED
|
@@ -10,6 +10,7 @@ import com.facebook.react.modules.network.CustomClientBuilder
|
|
|
10
10
|
import com.facebook.react.modules.network.NetworkingModule
|
|
11
11
|
import com.facebook.react.modules.network.OkHttpClientFactory
|
|
12
12
|
import com.facebook.react.modules.network.OkHttpClientProvider
|
|
13
|
+
import com.facebook.react.modules.websocket.WebSocketModule
|
|
13
14
|
import io.github.umutcansu.pinvault.PinVault
|
|
14
15
|
import okhttp3.Cache
|
|
15
16
|
import okhttp3.CookieJar
|
|
@@ -35,7 +36,7 @@ import javax.net.ssl.X509TrustManager
|
|
|
35
36
|
* Startup pattern, without the dependency). [install] stays as an explicit
|
|
36
37
|
* alternative for apps that remove the provider.
|
|
37
38
|
*
|
|
38
|
-
*
|
|
39
|
+
* Three hooks, verified against React Native 0.81 and 0.87's sources:
|
|
39
40
|
*
|
|
40
41
|
* - `NetworkingModule.setCustomClientBuilder`: RN calls it for **every**
|
|
41
42
|
* fetch / XHR request on `client.newBuilder()`. Each request gets the
|
|
@@ -50,6 +51,11 @@ import javax.net.ssl.X509TrustManager
|
|
|
50
51
|
* singleton, Fresco's image client) get a forwarding socket factory: every
|
|
51
52
|
* TLS handshake uses the pinned factory of the current start. No disk
|
|
52
53
|
* cache, no `https` → `http` redirects.
|
|
54
|
+
* - `WebSocketModule.setCustomClientBuilder`: RN calls it for every
|
|
55
|
+
* `WebSocket`. Up to 0.81 the module builds each socket's client from a
|
|
56
|
+
* bare `OkHttpClient.Builder()`, outside `OkHttpClientProvider`; from 0.87
|
|
57
|
+
* it derives it from the provider's client. Either way the socket gets the
|
|
58
|
+
* same forwarding factory, gate and network interceptors.
|
|
53
59
|
*
|
|
54
60
|
* Before `start()` — and after a start that failed — every `https` request
|
|
55
61
|
* through RN's networking fails (`IOException: PinVault has not started`),
|
|
@@ -59,7 +65,7 @@ import javax.net.ssl.X509TrustManager
|
|
|
59
65
|
* without a pin entry are refused by PinVault's trust manager like
|
|
60
66
|
* everywhere else.
|
|
61
67
|
*
|
|
62
|
-
* Another library that calls `setOkHttpClientFactory` or
|
|
68
|
+
* Another library that calls `setOkHttpClientFactory` or either
|
|
63
69
|
* `setCustomClientBuilder` after PinVault replaces these hooks: [status]
|
|
64
70
|
* tells, `start()` and every plugin `fetch` check it and log a warning, and
|
|
65
71
|
* `requirePinnedReactNativeNetworking: true` makes `start()` fail.
|
|
@@ -71,39 +77,50 @@ object PinVaultNetworking {
|
|
|
71
77
|
@Volatile private var installed = false
|
|
72
78
|
@Volatile private var app: Context? = null
|
|
73
79
|
|
|
74
|
-
/** The
|
|
80
|
+
/** The hooks this object installed; compared by identity in [status]. */
|
|
75
81
|
private val factory = OkHttpClientFactory {
|
|
76
|
-
pinning.configureLongLived(OkHttpClientProvider.createClientBuilder()).build()
|
|
82
|
+
pinning.track(pinning.configureLongLived(OkHttpClientProvider.createClientBuilder()).build())
|
|
77
83
|
}
|
|
78
84
|
private val clientBuilder = CustomClientBuilder { builder -> pinning.configurePerRequest(builder) }
|
|
85
|
+
private val webSocketBuilder = CustomClientBuilder { builder -> pinning.configureLongLived(builder) }
|
|
79
86
|
|
|
80
87
|
internal val pinning = NetworkPinning(
|
|
81
88
|
applier = { builder -> PinVault.applyTo(builder) },
|
|
82
89
|
httpCache = { app?.let { ReactHttpCache.get(it) } },
|
|
90
|
+
hooksPinned = { status().pinned },
|
|
83
91
|
)
|
|
84
92
|
|
|
93
|
+
/** True when [install] ran only from `start()`: React Native may have built clients before it. */
|
|
94
|
+
@Volatile private var installedLate = false
|
|
95
|
+
|
|
85
96
|
/**
|
|
86
|
-
* Installs
|
|
97
|
+
* Installs the hooks (once per process). The plugin's content provider
|
|
87
98
|
* calls it before `Application.onCreate`; call it yourself in
|
|
88
99
|
* `MainApplication.onCreate`, before `loadReactNative`, only when the app
|
|
89
100
|
* removed that provider.
|
|
90
101
|
*/
|
|
91
102
|
@JvmStatic
|
|
92
103
|
@Synchronized
|
|
93
|
-
fun install(context: Context)
|
|
104
|
+
fun install(context: Context) = install(context, late = false)
|
|
105
|
+
|
|
106
|
+
/** [late]: called from `start()`, after React Native may already have built its clients. */
|
|
107
|
+
@Synchronized
|
|
108
|
+
internal fun install(context: Context, late: Boolean) {
|
|
94
109
|
if (installed) return
|
|
95
110
|
app = context.applicationContext
|
|
96
111
|
OkHttpClientProvider.setOkHttpClientFactory(factory)
|
|
97
112
|
NetworkingModule.setCustomClientBuilder(clientBuilder)
|
|
113
|
+
WebSocketModule.setCustomClientBuilder(webSocketBuilder)
|
|
98
114
|
installed = true
|
|
115
|
+
installedLate = late
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
@JvmStatic
|
|
102
119
|
val isInstalled: Boolean get() = installed
|
|
103
120
|
|
|
104
|
-
/** Whether React Native's
|
|
121
|
+
/** Whether React Native's networking hooks are still the ones [install] set. */
|
|
105
122
|
@JvmStatic
|
|
106
|
-
fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder)
|
|
123
|
+
fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder, webSocketBuilder, installedLate)
|
|
107
124
|
|
|
108
125
|
private val warned = java.util.concurrent.atomic.AtomicReference<HookStatus?>(null)
|
|
109
126
|
|
|
@@ -126,30 +143,41 @@ data class HookStatus(
|
|
|
126
143
|
val factoryIsPinVault: Boolean?,
|
|
127
144
|
/** `NetworkingModule`'s custom client builder is PinVault's. */
|
|
128
145
|
val clientBuilderIsPinVault: Boolean?,
|
|
146
|
+
/** `WebSocketModule`'s custom client builder is PinVault's. */
|
|
147
|
+
val webSocketBuilderIsPinVault: Boolean?,
|
|
148
|
+
/** Installed only by `start()`: clients React Native built before (Fresco's, among them) keep the system's trust. */
|
|
149
|
+
val installedLate: Boolean = false,
|
|
129
150
|
) {
|
|
130
|
-
val pinned: Boolean get() = installed && factoryIsPinVault == true &&
|
|
151
|
+
val pinned: Boolean get() = installed && !installedLate && factoryIsPinVault == true &&
|
|
152
|
+
clientBuilderIsPinVault == true && webSocketBuilderIsPinVault == true
|
|
131
153
|
|
|
132
154
|
fun describe(): String = when {
|
|
133
155
|
pinned -> "React Native's networking is pinned by PinVault"
|
|
156
|
+
installed && installedLate -> "React Native's networking hooks were installed only by start() (the plugin's content provider " +
|
|
157
|
+
"was removed and PinVaultNetworking.install was not called before React Native started): clients it built " +
|
|
158
|
+
"before, such as the image client, are NOT pinned"
|
|
134
159
|
!installed -> "React Native's networking hooks are not installed (the plugin's content provider was removed " +
|
|
135
160
|
"and PinVaultNetworking.install was not called, or android.pinGlobalNetworking is false): " +
|
|
136
161
|
"RN fetch / XHR / WebSocket / images are NOT pinned"
|
|
137
162
|
else -> buildList {
|
|
138
163
|
if (factoryIsPinVault != true) add("OkHttpClientProvider's client factory " + if (factoryIsPinVault == null) "could not be read" else "was replaced by another library")
|
|
139
164
|
if (clientBuilderIsPinVault != true) add("NetworkingModule's custom client builder " + if (clientBuilderIsPinVault == null) "could not be read" else "was replaced by another library")
|
|
165
|
+
if (webSocketBuilderIsPinVault != true) add("WebSocketModule's custom client builder " + if (webSocketBuilderIsPinVault == null) "could not be read" else "was replaced by another library")
|
|
140
166
|
}.joinToString("; ") + ": React Native's own networking may NOT be pinned"
|
|
141
167
|
}
|
|
142
168
|
|
|
143
169
|
internal companion object {
|
|
144
|
-
fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any): HookStatus = HookStatus(
|
|
170
|
+
fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any, ourWebSocketBuilder: Any, installedLate: Boolean = false): HookStatus = HookStatus(
|
|
145
171
|
installed,
|
|
146
172
|
isOurs(OkHttpClientProvider::class.java, "factory", ourFactory),
|
|
147
173
|
isOurs(NetworkingModule::class.java, "customClientBuilder", ourBuilder),
|
|
174
|
+
isOurs(WebSocketModule::class.java, "customClientBuilder", ourWebSocketBuilder),
|
|
175
|
+
installedLate,
|
|
148
176
|
)
|
|
149
177
|
|
|
150
178
|
/**
|
|
151
179
|
* Whether RN's private static field [name] holds [ours]; null when it
|
|
152
|
-
* cannot be read. The plugin's consumer R8 rules keep
|
|
180
|
+
* cannot be read. The plugin's consumer R8 rules keep the fields' names.
|
|
153
181
|
*/
|
|
154
182
|
private fun isOurs(owner: Class<*>, name: String, ours: Any): Boolean? = try {
|
|
155
183
|
owner.getDeclaredField(name).apply { isAccessible = true }.get(null) === ours
|
|
@@ -199,8 +227,30 @@ internal object ReactHttpCache {
|
|
|
199
227
|
internal class NetworkPinning(
|
|
200
228
|
private val applier: (OkHttpClient.Builder) -> Unit,
|
|
201
229
|
private val httpCache: () -> Cache? = { null },
|
|
230
|
+
/** Whether React Native's hooks are still PinVault's (``PinVaultNetworking.status``). */
|
|
231
|
+
private val hooksPinned: () -> Boolean = { true },
|
|
202
232
|
) {
|
|
203
233
|
|
|
234
|
+
/** The long-lived clients React Native built through the factory: their pools are emptied on every change. */
|
|
235
|
+
private val longLived = java.util.Collections.synchronizedList(mutableListOf<java.lang.ref.WeakReference<OkHttpClient>>())
|
|
236
|
+
|
|
237
|
+
/** Last answer of [hooksPinned] and when it was read (reflection: not on every request). */
|
|
238
|
+
@Volatile private var hooksCheckedAt = 0L
|
|
239
|
+
@Volatile private var hooksWerePinned = true
|
|
240
|
+
|
|
241
|
+
fun track(client: OkHttpClient): OkHttpClient {
|
|
242
|
+
longLived.add(java.lang.ref.WeakReference(client))
|
|
243
|
+
return client
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Closes the idle pooled connections of every long-lived client: the next request makes a fresh, pinned handshake. */
|
|
247
|
+
private fun evictLongLived() {
|
|
248
|
+
synchronized(longLived) {
|
|
249
|
+
longLived.removeAll { it.get() == null }
|
|
250
|
+
longLived.forEach { it.get()?.connectionPool?.evictAll() }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
204
254
|
/** A client `PinVault.applyTo` configured after the last `start()`; null = not started. */
|
|
205
255
|
@Volatile private var template: OkHttpClient? = null
|
|
206
256
|
@Volatile var options: NetworkingOptions = NetworkingOptions()
|
|
@@ -212,11 +262,13 @@ internal class NetworkPinning(
|
|
|
212
262
|
fun activate(options: NetworkingOptions = this.options) {
|
|
213
263
|
this.options = options
|
|
214
264
|
template = OkHttpClient.Builder().also(applier).build()
|
|
265
|
+
evictLongLived()
|
|
215
266
|
}
|
|
216
267
|
|
|
217
268
|
@Synchronized
|
|
218
269
|
fun deactivate() {
|
|
219
270
|
template = null
|
|
271
|
+
evictLongLived()
|
|
220
272
|
}
|
|
221
273
|
|
|
222
274
|
/** NetworkingModule's per-request builder (fetch / XHR). */
|
|
@@ -242,23 +294,71 @@ internal class NetworkPinning(
|
|
|
242
294
|
pinned.networkInterceptors.forEach { if (it !in builder.networkInterceptors()) builder.addNetworkInterceptor(it) }
|
|
243
295
|
}
|
|
244
296
|
|
|
245
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* Clients RN builds once and keeps, and each WebSocket's client: TLS goes
|
|
299
|
+
* through the pinned factory of the current start. Idempotent: from RN
|
|
300
|
+
* 0.87 a WebSocket's builder derives from a client already configured here.
|
|
301
|
+
*/
|
|
246
302
|
fun configureLongLived(builder: OkHttpClient.Builder): OkHttpClient.Builder {
|
|
247
303
|
val forwarding = ForwardingSocketFactory { template?.sslSocketFactory }
|
|
248
304
|
builder.sslSocketFactory(forwarding, ForwardingTrustManager { template?.x509TrustManager })
|
|
249
|
-
builder.addInterceptor(httpsGate)
|
|
305
|
+
if (httpsGate !in builder.interceptors()) builder.addInterceptor(httpsGate)
|
|
306
|
+
// The current start's network interceptors (the per-request pin check of a pooled
|
|
307
|
+
// or coalesced connection, among them) run for these clients too.
|
|
308
|
+
if (forwardingNetwork !in builder.networkInterceptors()) builder.addNetworkInterceptor(forwardingNetwork)
|
|
250
309
|
builder.cache(null)
|
|
251
310
|
builder.followSslRedirects(false)
|
|
252
311
|
return builder
|
|
253
312
|
}
|
|
254
313
|
|
|
255
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Refuses `https` while PinVault has not started, and — with
|
|
316
|
+
* `requirePinnedReactNativeNetworking` — once another library has replaced
|
|
317
|
+
* React Native's hooks after start (checked at most every few seconds).
|
|
318
|
+
*/
|
|
256
319
|
private val httpsGate = Interceptor { chain ->
|
|
257
|
-
if (chain.request().isHttps
|
|
258
|
-
throw IOException("PinVault has not started: https requests are refused until start() returns")
|
|
320
|
+
if (chain.request().isHttps) {
|
|
321
|
+
if (template == null) throw IOException("PinVault has not started: https requests are refused until start() returns")
|
|
322
|
+
if (options.requirePinned && !hooksStillPinned()) {
|
|
323
|
+
throw IOException("requirePinnedReactNativeNetworking: React Native's networking hooks are no longer PinVault's")
|
|
324
|
+
}
|
|
259
325
|
}
|
|
260
326
|
chain.proceed(chain.request())
|
|
261
327
|
}
|
|
328
|
+
|
|
329
|
+
private fun hooksStillPinned(): Boolean {
|
|
330
|
+
val now = android.os.SystemClock.elapsedRealtime()
|
|
331
|
+
if (now - hooksCheckedAt > HOOK_CHECK_MS) {
|
|
332
|
+
hooksWerePinned = hooksPinned()
|
|
333
|
+
hooksCheckedAt = now
|
|
334
|
+
}
|
|
335
|
+
return hooksWerePinned
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Runs the current start's network interceptors in order, then the request. */
|
|
339
|
+
private val forwardingNetwork = Interceptor { chain ->
|
|
340
|
+
val interceptors = template?.networkInterceptors ?: emptyList()
|
|
341
|
+
ForwardingChain(chain, interceptors, 0).proceed(chain.request())
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
companion object {
|
|
345
|
+
const val HOOK_CHECK_MS = 5_000L
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A chain that hands the request to `interceptors[index]`, …, and finally to the real chain. */
|
|
350
|
+
internal class ForwardingChain(
|
|
351
|
+
private val base: Interceptor.Chain,
|
|
352
|
+
private val interceptors: List<Interceptor>,
|
|
353
|
+
private val index: Int,
|
|
354
|
+
) : Interceptor.Chain by base {
|
|
355
|
+
override fun proceed(request: okhttp3.Request): okhttp3.Response =
|
|
356
|
+
if (index >= interceptors.size) base.proceed(request)
|
|
357
|
+
else interceptors[index].intercept(ForwardingChain(base, interceptors, index + 1).withRequest(request))
|
|
358
|
+
|
|
359
|
+
private var current: okhttp3.Request = base.request()
|
|
360
|
+
private fun withRequest(request: okhttp3.Request) = also { current = request }
|
|
361
|
+
override fun request(): okhttp3.Request = current
|
|
262
362
|
}
|
|
263
363
|
|
|
264
364
|
/** Hands every socket to the pinned factory of the current start; none before `start()`. */
|
|
@@ -30,23 +30,38 @@ public enum ConfigParser {
|
|
|
30
30
|
|
|
31
31
|
/// - Parameters:
|
|
32
32
|
/// - native: the app's native security file (``NativeSecurity``); nil = none.
|
|
33
|
-
/// - release: a release build: without a native file,
|
|
33
|
+
/// - release: a release build: without a native file, a config with Config APIs or static
|
|
34
|
+
/// pins is refused (unless `noFileAllowed`) and the relaxations (`allowUnsigned`, …) are
|
|
35
|
+
/// refused from JS; `expiredConfigGrace` is capped.
|
|
36
|
+
/// - noFileAllowed: the app's Info.plist accepts a release start without the file (``NativeSecurity/noFileInfoKey``).
|
|
34
37
|
public static func parse(
|
|
35
38
|
_ json: String,
|
|
36
39
|
tokens: VaultTokenStore,
|
|
37
40
|
guardFactory: (Int64) -> any EnvironmentGuard,
|
|
38
41
|
listener: PinVaultConnectionListener?,
|
|
39
42
|
native: NativeSecurity? = nil,
|
|
40
|
-
release: Bool = false
|
|
43
|
+
release: Bool = false,
|
|
44
|
+
noFileAllowed: Bool = false,
|
|
45
|
+
reactNetworkingEnabled: Bool = true
|
|
41
46
|
) throws -> ParsedConfig {
|
|
42
47
|
let root = try StrictJSON.parseObject(json, path: "config")
|
|
43
48
|
let builder = PinVaultConfig.Builder()
|
|
49
|
+
let require = native?.require ?? NativeSecurity.Require()
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
let configApis = try root.objectList("configApis", maxItems: maxConfigApis)
|
|
52
|
+
let vaultFiles = try root.objectList("vaultFiles", maxItems: maxVaultFiles)
|
|
53
|
+
let jsStaticPins = try root.object("staticPins").map(staticPins)
|
|
54
|
+
if release && native == nil && !noFileAllowed && (!(configApis ?? []).isEmpty || jsStaticPins != nil) {
|
|
55
|
+
throw BridgeInputError(
|
|
56
|
+
"config: a release build takes its trust anchors only from the app's native security file " +
|
|
57
|
+
"(\(NativeSecurity.resourceName).json, README \"Native security file\"); none is shipped. " +
|
|
58
|
+
"An app that accepts JS-only anchors says so in its Info.plist: \(NativeSecurity.noFileInfoKey) = YES"
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
for api in configApis ?? [] {
|
|
46
62
|
try addConfigApi(builder, api, native: native, release: release)
|
|
47
63
|
}
|
|
48
|
-
for file in
|
|
49
|
-
let jsStaticPins = try root.object("staticPins").map(staticPins)
|
|
64
|
+
for file in vaultFiles ?? [] { try addVaultFile(builder, file, tokens, native: native) }
|
|
50
65
|
if let pins = try SecurityPolicy.staticPins(path: "config.staticPins", js: jsStaticPins, native: native) {
|
|
51
66
|
builder.staticPins(pins)
|
|
52
67
|
}
|
|
@@ -55,13 +70,38 @@ public enum ConfigParser {
|
|
|
55
70
|
if let v = try root.int64("updateIntervalHours", min: 1, max: 24 * 30) { builder.updateIntervalHours(v) }
|
|
56
71
|
if let v = try root.int64("updateIntervalMinutes", min: 15, max: 60 * 24 * 30) { builder.updateIntervalMinutes(v) }
|
|
57
72
|
if let v = try root.string("deviceAlias", maxLength: 128) { builder.deviceAlias(v) }
|
|
58
|
-
if let d = try root.object("expiredConfigGrace") {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
if let d = try root.object("expiredConfigGrace") {
|
|
74
|
+
let (a, u) = try duration(d)
|
|
75
|
+
let ms = millis(a, u)
|
|
76
|
+
if release && ms > NativeSecurity.maxReleaseGraceMs { throw BridgeInputError("\(d.path): a release build takes at most 7 days") }
|
|
77
|
+
if let max = require.expiredConfigGraceMs, ms > max {
|
|
78
|
+
throw BridgeInputError("\(d.path): longer than the app's native security file allows (\(max / 1000) s)")
|
|
79
|
+
}
|
|
80
|
+
builder.expiredConfigGrace(a, u)
|
|
81
|
+
} else if let max = require.expiredConfigGraceMs {
|
|
82
|
+
builder.expiredConfigGrace(max, .milliseconds)
|
|
83
|
+
}
|
|
84
|
+
// `require` hosts are added to JS's: JS can name more, never fewer.
|
|
85
|
+
let caTrust = (try root.stringList("requireCaTrust", maxItems: 64, maxLength: 255) ?? []) + (require.requireCaTrust ?? [])
|
|
86
|
+
if !caTrust.isEmpty {
|
|
87
|
+
var seen = Set<String>()
|
|
88
|
+
builder.requireCaTrust(caTrust.filter { seen.insert($0).inserted })
|
|
89
|
+
}
|
|
90
|
+
if try root.bool("wipeVaultFilesOnRevocation") == true || require.wipeVaultFilesOnRevocation { builder.wipeVaultFilesOnRevocation() }
|
|
91
|
+
if let d = try root.object("vaultFileMaxOfflineAge") {
|
|
92
|
+
let (a, u) = try duration(d)
|
|
93
|
+
if let max = require.vaultFileMaxOfflineAgeMs {
|
|
94
|
+
let ms = millis(a, u)
|
|
95
|
+
// 0 = no limit: longer than any.
|
|
96
|
+
if ms == 0 || ms > max { throw BridgeInputError("\(d.path): longer than the app's native security file allows (\(max / 1000) s)") }
|
|
97
|
+
}
|
|
98
|
+
builder.vaultFileMaxOfflineAge(a, u)
|
|
99
|
+
} else if let max = require.vaultFileMaxOfflineAgeMs {
|
|
100
|
+
builder.vaultFileMaxOfflineAge(max, .milliseconds)
|
|
101
|
+
}
|
|
102
|
+
if try root.bool("requireUnlockedDevice") == true || require.requireUnlockedDevice { builder.requireUnlockedDevice() }
|
|
103
|
+
if try root.bool("requireHardwareBackedKeys") == true || require.requireHardwareBackedKeys { builder.requireHardwareBackedKeys() }
|
|
104
|
+
if try root.bool("managedTrustRoots") == true || require.managedTrustRoots { builder.managedTrustRoots() }
|
|
65
105
|
if let v = try root.stringList("expectedSignerSha256", maxItems: 16, maxLength: 128) { builder.expectedSignerSha256(v) }
|
|
66
106
|
|
|
67
107
|
var guardTimeout: Int64?
|
|
@@ -73,7 +113,11 @@ public enum ConfigParser {
|
|
|
73
113
|
builder.environmentGuard(guardFactory(timeout))
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
|
|
116
|
+
// On by default in a release build — unless the app opted out of pinning RN's
|
|
117
|
+
// networking natively (Info.plist PinVaultPinReactNativeNetworking = NO).
|
|
118
|
+
// The native security file can pin this on (RN-3): JS may turn it on, never off.
|
|
119
|
+
let requirePinned = (try root.bool("requirePinnedReactNativeNetworking") ?? (release && reactNetworkingEnabled))
|
|
120
|
+
|| require.requirePinnedReactNativeNetworking
|
|
77
121
|
// Android-only settings: the Android side reads them; here only their shape is checked.
|
|
78
122
|
_ = try root.object("android")
|
|
79
123
|
var maxRN = ReactNetworking.defaultMaxResponseBytes
|
|
@@ -82,12 +126,28 @@ public enum ConfigParser {
|
|
|
82
126
|
if let resolve = try ios.stringMap("resolve", maxItems: 64, maxKeyLength: 255, maxValueLength: 255) {
|
|
83
127
|
for (host, address) in resolve.sorted(by: { $0.key < $1.key }) { builder.resolve(host: host, to: address) }
|
|
84
128
|
}
|
|
85
|
-
if let v = try ios.
|
|
86
|
-
|
|
87
|
-
|
|
129
|
+
if let v = try fixedList("config.ios.expectedBundleIds", require.expectedBundleIds,
|
|
130
|
+
try ios.stringList("expectedBundleIds", maxItems: 16, maxLength: 255), native) {
|
|
131
|
+
builder.expectedBundleId(v)
|
|
132
|
+
}
|
|
133
|
+
if let v = try fixedList("config.ios.expectedTeamIds", require.expectedTeamIds,
|
|
134
|
+
try ios.stringList("expectedTeamIds", maxItems: 16, maxLength: 32), native) {
|
|
135
|
+
builder.expectedTeamId(v)
|
|
136
|
+
}
|
|
137
|
+
let jsStrength = try ios.enumValue("userAuthStrength", UserAuthStrength.self)
|
|
138
|
+
if let fixed = require.userAuthStrength, let jsStrength, jsStrength != fixed {
|
|
139
|
+
throw BridgeInputError("config.ios.userAuthStrength: differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
|
|
140
|
+
}
|
|
141
|
+
if let v = require.userAuthStrength ?? jsStrength { builder.userAuthStrength(v) }
|
|
88
142
|
try ios.finish()
|
|
89
143
|
}
|
|
90
144
|
|
|
145
|
+
else {
|
|
146
|
+
if let v = require.expectedBundleIds { builder.expectedBundleId(v) }
|
|
147
|
+
if let v = require.expectedTeamIds { builder.expectedTeamId(v) }
|
|
148
|
+
if let v = require.userAuthStrength { builder.userAuthStrength(v) }
|
|
149
|
+
}
|
|
150
|
+
|
|
91
151
|
try root.finish()
|
|
92
152
|
if let listener { builder.onConnectionEvent(listener) }
|
|
93
153
|
var parsed = ParsedConfig(config: try builder.build(), guardTimeoutMs: guardTimeout)
|
|
@@ -97,6 +157,26 @@ public enum ConfigParser {
|
|
|
97
157
|
return parsed
|
|
98
158
|
}
|
|
99
159
|
|
|
160
|
+
/// A JS list against the native file's: fixed where the file gives it.
|
|
161
|
+
static func fixedList(_ path: String, _ native: [String]?, _ js: [String]?, _ file: NativeSecurity?) throws -> [String]? {
|
|
162
|
+
guard let native else { return js }
|
|
163
|
+
if let js, !NativeSecurity.sameKeys(js, native) {
|
|
164
|
+
throw BridgeInputError("\(path): differs from the app's native security file (\(file?.source ?? "")); the native value is fixed")
|
|
165
|
+
}
|
|
166
|
+
return native
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
static func millis(_ amount: Int64, _ unit: TimeUnit) -> Int64 {
|
|
170
|
+
switch unit {
|
|
171
|
+
case .milliseconds: return amount
|
|
172
|
+
case .seconds: return amount.multipliedReportingOverflow(by: 1000).overflow ? .max : amount * 1000
|
|
173
|
+
case .minutes: return amount.multipliedReportingOverflow(by: 60_000).overflow ? .max : amount * 60_000
|
|
174
|
+
case .hours: return amount.multipliedReportingOverflow(by: 3_600_000).overflow ? .max : amount * 3_600_000
|
|
175
|
+
case .days: return amount.multipliedReportingOverflow(by: 86_400_000).overflow ? .max : amount * 86_400_000
|
|
176
|
+
default: return .max
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
100
180
|
static func duration(_ d: Fields) throws -> (Int64, TimeUnit) {
|
|
101
181
|
guard let amount = try d.int64("amount", min: 0, max: 10 * 365 * 24 * 3600 * 1000) else {
|
|
102
182
|
throw BridgeInputError("\(d.path).amount: required")
|
|
@@ -116,6 +196,18 @@ public enum ConfigParser {
|
|
|
116
196
|
return url
|
|
117
197
|
}
|
|
118
198
|
|
|
199
|
+
/// An endpoint path resolved relative to the block's base URL. An absolute
|
|
200
|
+
/// value — a scheme (`://`) or a root (`/`, `\`) — would silently retarget the
|
|
201
|
+
/// request past the pinned host (`URL(string:relativeTo:)` honours it), so it
|
|
202
|
+
/// is refused (RN-2). Returns nil when the optional field is absent.
|
|
203
|
+
static func relEndpoint(_ f: Fields, _ key: String, maxLength: Int) throws -> String? {
|
|
204
|
+
guard let v = try f.string(key, maxLength: maxLength) else { return nil }
|
|
205
|
+
if v.contains("://") || v.hasPrefix("/") || v.hasPrefix("\\") {
|
|
206
|
+
throw BridgeInputError("\(f.path).\(key): must be a relative path, not an absolute URL")
|
|
207
|
+
}
|
|
208
|
+
return v
|
|
209
|
+
}
|
|
210
|
+
|
|
119
211
|
public static func hostPin(_ p: Fields) throws -> HostPin {
|
|
120
212
|
let hostname = try p.requireString("hostname", maxLength: 255)
|
|
121
213
|
guard let pins = try p.stringList("sha256", maxItems: maxPinsPerHost, maxLength: 128) else {
|
|
@@ -152,8 +244,8 @@ public enum ConfigParser {
|
|
|
152
244
|
let id = try b.requireString("id", maxLength: 128)
|
|
153
245
|
guard let url = try httpsUrl(b, "url") else { throw BridgeInputError("\(b.path).url: required") }
|
|
154
246
|
let bootstrapPins = try b.objectList("bootstrapPins", maxItems: maxPins)?.map(hostPin)
|
|
155
|
-
let configEndpoint = try b
|
|
156
|
-
let healthEndpoint = try b
|
|
247
|
+
let configEndpoint = try relEndpoint(b, "configEndpoint", maxLength: 512)
|
|
248
|
+
let healthEndpoint = try relEndpoint(b, "healthEndpoint", maxLength: 512)
|
|
157
249
|
let oneKey = try b.string("signaturePublicKey", maxLength: keyLength, multiline: true)
|
|
158
250
|
let keys = try b.stringList("signaturePublicKeys", maxItems: 16, maxLength: keyLength, multiline: true)
|
|
159
251
|
if oneKey != nil && keys != nil {
|
|
@@ -169,20 +261,67 @@ public enum ConfigParser {
|
|
|
169
261
|
let clientCaPins = try b.stringList("clientCaPins", maxItems: 16, maxLength: 128)
|
|
170
262
|
let maxLifetime = try b.int("maxClientCertLifetimeDays", min: 1, max: 3650)
|
|
171
263
|
let clientCertHosts = try b.stringList("clientCertHosts", maxItems: 64, maxLength: 2048)
|
|
172
|
-
let enrollmentEndpoint = try b
|
|
173
|
-
let clientCertEndpoint = try b
|
|
174
|
-
let vaultReportEndpoint = try b
|
|
264
|
+
let enrollmentEndpoint = try relEndpoint(b, "enrollmentEndpoint", maxLength: 512)
|
|
265
|
+
let clientCertEndpoint = try relEndpoint(b, "clientCertEndpoint", maxLength: 512)
|
|
266
|
+
let vaultReportEndpoint = try relEndpoint(b, "vaultReportEndpoint", maxLength: 512)
|
|
175
267
|
let clientCertLabel = try b.string("clientCertLabel", maxLength: 128)
|
|
176
268
|
let wantPinsFor = try b.stringList("wantPinsFor", maxItems: 256, maxLength: 255)
|
|
177
269
|
let renewalUrl = try httpsUrl(b, "renewalUrl")
|
|
178
270
|
let enrollmentUrl = try httpsUrl(b, "enrollmentUrl")
|
|
179
271
|
let threshold = try b.double("clientCertRenewalThreshold", min: 0, max: 1)
|
|
180
272
|
let disableRenewal = try b.bool("disableClientCertRenewal") == true
|
|
181
|
-
let
|
|
273
|
+
let jsAttestation = try b.bool("attestation")
|
|
182
274
|
let attestationInterval = try b.object("attestationInterval").map(duration)
|
|
183
|
-
let
|
|
275
|
+
let jsTokenHosts = try b.stringList("tokenHosts", maxItems: 64, maxLength: 255)
|
|
276
|
+
let jsProof = try b.bool("proofOfPossession")
|
|
184
277
|
try b.finish()
|
|
185
278
|
|
|
279
|
+
// Where the block talks to and who gets its token and identity: fixed where the file says.
|
|
280
|
+
let nativeBlock = native?.blocks[id]
|
|
281
|
+
func fixed(_ key: String) -> BridgeInputError {
|
|
282
|
+
BridgeInputError("\(b.path).\(key): differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
|
|
283
|
+
}
|
|
284
|
+
var blockUrl = url
|
|
285
|
+
if let n = nativeBlock?.url {
|
|
286
|
+
if n != url { throw fixed("url") }
|
|
287
|
+
blockUrl = n
|
|
288
|
+
}
|
|
289
|
+
if nativeBlock?.attestation == true && jsAttestation == false {
|
|
290
|
+
throw BridgeInputError("\(b.path).attestation: the app's native security file turns it on; JS cannot turn it off")
|
|
291
|
+
}
|
|
292
|
+
let attestation = jsAttestation == true || nativeBlock?.attestation == true
|
|
293
|
+
if nativeBlock?.proofOfPossession == true && jsProof == false {
|
|
294
|
+
throw BridgeInputError("\(b.path).proofOfPossession: the app's native security file turns it on; JS cannot turn it off")
|
|
295
|
+
}
|
|
296
|
+
let proof = jsProof == true || nativeBlock?.proofOfPossession == true
|
|
297
|
+
let lower: ([String]) -> [String] = { $0.map { $0.lowercased() } }
|
|
298
|
+
var tokenHosts = jsTokenHosts
|
|
299
|
+
if let n = nativeBlock?.tokenHosts {
|
|
300
|
+
if let j = jsTokenHosts, !NativeSecurity.sameKeys(lower(j), lower(n)) { throw fixed("tokenHosts") }
|
|
301
|
+
tokenHosts = n
|
|
302
|
+
}
|
|
303
|
+
var fixedEnrollmentUrl = enrollmentUrl
|
|
304
|
+
if let n = nativeBlock?.enrollmentUrl {
|
|
305
|
+
if let j = enrollmentUrl, j != n { throw fixed("enrollmentUrl") }
|
|
306
|
+
fixedEnrollmentUrl = n
|
|
307
|
+
}
|
|
308
|
+
var fixedRenewalUrl = renewalUrl
|
|
309
|
+
if let n = nativeBlock?.renewalUrl {
|
|
310
|
+
if let j = renewalUrl, j != n { throw fixed("renewalUrl") }
|
|
311
|
+
fixedRenewalUrl = n
|
|
312
|
+
}
|
|
313
|
+
// Who gets the device's identity is the file's call in a release build: JS cannot
|
|
314
|
+
// add a host the file does not name (tokenHosts only narrows the library's default).
|
|
315
|
+
if release, let nativeBlock, nativeBlock.clientCertHosts == nil, clientCertHosts != nil {
|
|
316
|
+
throw BridgeInputError("\(b.path).clientCertHosts: a release build takes it only from the app's native security file "
|
|
317
|
+
+ "(\(native?.source ?? "")); declare clientCertHosts for Config API '\(id)' there")
|
|
318
|
+
}
|
|
319
|
+
var certHosts = clientCertHosts
|
|
320
|
+
if let n = nativeBlock?.clientCertHosts {
|
|
321
|
+
if let j = clientCertHosts, !NativeSecurity.sameKeys(lower(j), lower(n)) { throw fixed("clientCertHosts") }
|
|
322
|
+
certHosts = n
|
|
323
|
+
}
|
|
324
|
+
|
|
186
325
|
// The trust anchors and relaxations: the native security file decides, JS only repeats.
|
|
187
326
|
let sec = try SecurityPolicy.apply(
|
|
188
327
|
path: b.path, id: id,
|
|
@@ -195,7 +334,7 @@ public enum ConfigParser {
|
|
|
195
334
|
native: native, release: release
|
|
196
335
|
)
|
|
197
336
|
|
|
198
|
-
builder.configApi(id, url:
|
|
337
|
+
builder.configApi(id, url: blockUrl) { api in
|
|
199
338
|
if let v = sec.bootstrapPins { api.bootstrapPins(v) }
|
|
200
339
|
if let configEndpoint { api.configEndpoint(configEndpoint) }
|
|
201
340
|
if let healthEndpoint { api.healthEndpoint(healthEndpoint) }
|
|
@@ -210,35 +349,80 @@ public enum ConfigParser {
|
|
|
210
349
|
if sec.allowServerKey { api.allowServerGeneratedKey() }
|
|
211
350
|
if let v = sec.clientCaPins { api.clientCaPins(v) }
|
|
212
351
|
if let maxLifetime { api.maxClientCertLifetimeDays(maxLifetime) }
|
|
213
|
-
if let
|
|
352
|
+
if let certHosts { api.clientCertHosts(certHosts) }
|
|
214
353
|
if let enrollmentEndpoint { api.enrollmentEndpoint(enrollmentEndpoint) }
|
|
215
354
|
if let clientCertEndpoint { api.clientCertEndpoint(clientCertEndpoint) }
|
|
216
355
|
if let vaultReportEndpoint { api.vaultReportEndpoint(vaultReportEndpoint) }
|
|
217
356
|
if let clientCertLabel { api.clientCertLabel(clientCertLabel) }
|
|
218
357
|
if let wantPinsFor { api.wantPinsFor(wantPinsFor) }
|
|
219
|
-
if let
|
|
220
|
-
if let
|
|
358
|
+
if let fixedRenewalUrl { api.renewalUrl(fixedRenewalUrl) }
|
|
359
|
+
if let fixedEnrollmentUrl { api.enrollmentUrl(fixedEnrollmentUrl) }
|
|
221
360
|
if let threshold { api.clientCertRenewalThreshold(threshold) }
|
|
222
361
|
if disableRenewal { api.disableClientCertRenewal() }
|
|
223
362
|
if attestation { api.attestation() }
|
|
224
363
|
if let (amount, unit) = attestationInterval { api.attestationInterval(amount, unit) }
|
|
225
364
|
if let tokenHosts { api.tokenHosts(tokenHosts) }
|
|
365
|
+
if proof { api.proofOfPossession() }
|
|
226
366
|
}
|
|
227
367
|
}
|
|
228
368
|
|
|
229
|
-
static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore) throws {
|
|
369
|
+
static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore, native: NativeSecurity?) throws {
|
|
230
370
|
let key = try f.requireString("key", maxLength: 128)
|
|
231
371
|
let endpoint = try f.requireString("endpoint", maxLength: 512)
|
|
232
|
-
|
|
372
|
+
if endpoint.contains("://") || endpoint.hasPrefix("/") || endpoint.hasPrefix("\\") {
|
|
373
|
+
throw BridgeInputError("\(f.path).endpoint: must be a relative path, not an absolute URL")
|
|
374
|
+
}
|
|
375
|
+
let jsSignatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
|
|
233
376
|
let updateWithPins = try f.bool("updateWithPins")
|
|
234
377
|
let storage = try f.enumValue("storage", StorageStrategy.self)
|
|
235
378
|
let configApi = try f.string("configApi", maxLength: 128)
|
|
236
379
|
let policy = try f.enumValue("accessPolicy", VaultFileAccessPolicy.self)
|
|
237
|
-
let
|
|
238
|
-
let
|
|
380
|
+
let jsEncryption = try f.enumValue("encryption", VaultFileEncryption.self)
|
|
381
|
+
let jsUserAuth = try f.enumValue("userAuth", UserAuth.self)
|
|
239
382
|
let maxOfflineAge = try f.object("maxOfflineAge").map(duration)
|
|
240
383
|
let wipeWhenStale = try f.bool("wipeWhenStale") == true
|
|
241
384
|
try f.finish()
|
|
385
|
+
var offlineAge = maxOfflineAge
|
|
386
|
+
|
|
387
|
+
// A file the native security file names keeps its signature key, encryption and lock.
|
|
388
|
+
var signatureKey = jsSignatureKey
|
|
389
|
+
var encryption = jsEncryption
|
|
390
|
+
var userAuth = jsUserAuth
|
|
391
|
+
if let files = native?.vaultFiles {
|
|
392
|
+
guard let declared = files[key] else {
|
|
393
|
+
throw BridgeInputError("\(f.path): vault file '\(key)' is not declared in the app's native security file (\(native?.source ?? ""))")
|
|
394
|
+
}
|
|
395
|
+
func fixed(_ field: String) -> BridgeInputError {
|
|
396
|
+
BridgeInputError("\(f.path).\(field): differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
|
|
397
|
+
}
|
|
398
|
+
if let n = declared.signaturePublicKey {
|
|
399
|
+
if let j = jsSignatureKey, j.trimmingCharacters(in: .whitespacesAndNewlines) != n.trimmingCharacters(in: .whitespacesAndNewlines) {
|
|
400
|
+
throw fixed("signaturePublicKey")
|
|
401
|
+
}
|
|
402
|
+
signatureKey = n
|
|
403
|
+
}
|
|
404
|
+
if let n = declared.encryption {
|
|
405
|
+
if let j = jsEncryption, j != n { throw fixed("encryption") }
|
|
406
|
+
encryption = n
|
|
407
|
+
}
|
|
408
|
+
if let n = declared.userAuth {
|
|
409
|
+
if let j = jsUserAuth, j != n { throw fixed("userAuth") }
|
|
410
|
+
userAuth = n
|
|
411
|
+
}
|
|
412
|
+
if let max = declared.maxOfflineAgeMs, maxOfflineAge == nil {
|
|
413
|
+
offlineAge = (max, .milliseconds)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// The tighter of the file's own cap and the config-wide one: a per-file value
|
|
417
|
+
// overrides the config's, so it must not escape the config-wide cap either.
|
|
418
|
+
let cap = [native?.vaultFiles?[key]?.maxOfflineAgeMs, native?.require.vaultFileMaxOfflineAgeMs].compactMap { $0 }.min()
|
|
419
|
+
if let cap, let (a, u) = maxOfflineAge {
|
|
420
|
+
let ms = millis(a, u)
|
|
421
|
+
// 0 = no limit: longer than any.
|
|
422
|
+
if ms == 0 || ms > cap {
|
|
423
|
+
throw BridgeInputError("\(f.path).maxOfflineAge: longer than the app's native security file allows (\(cap / 1000) s)")
|
|
424
|
+
}
|
|
425
|
+
}
|
|
242
426
|
|
|
243
427
|
builder.vaultFile(key) { file in
|
|
244
428
|
file.endpoint(endpoint)
|
|
@@ -255,7 +439,7 @@ public enum ConfigParser {
|
|
|
255
439
|
}
|
|
256
440
|
if let encryption { file.encryption(encryption) }
|
|
257
441
|
if let userAuth { file.userAuth(userAuth) }
|
|
258
|
-
if let (amount, unit) =
|
|
442
|
+
if let (amount, unit) = offlineAge { file.maxOfflineAge(amount, unit) }
|
|
259
443
|
if wipeWhenStale { file.wipeWhenStale() }
|
|
260
444
|
}
|
|
261
445
|
}
|