@umutcansu/react-native-pinvault 2.3.2 → 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 +29 -0
- package/README.md +65 -14
- package/RNPinVault.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +120 -24
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +120 -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 +91 -8
- package/ios/Core/ConfigParser.swift +188 -27
- package/ios/Core/NativeSecurity.swift +112 -6
- package/ios/PinVaultBridge.swift +16 -4
- 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 +1 -1
- package/src/index.ts +15 -2
- package/src/types.ts +14 -3
|
@@ -103,6 +103,7 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
103
103
|
listener = connectionListener,
|
|
104
104
|
native = NativeSecurity.load(app),
|
|
105
105
|
release = NativeSecurity.isReleaseBuild(app),
|
|
106
|
+
noFileAllowed = NativeSecurity.noFileAllowed(app),
|
|
106
107
|
)
|
|
107
108
|
} catch (e: IllegalArgumentException) {
|
|
108
109
|
// The guard of the running config stays: a refused config changes nothing.
|
|
@@ -119,7 +120,9 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
119
120
|
)
|
|
120
121
|
return@launch
|
|
121
122
|
}
|
|
122
|
-
|
|
123
|
+
// Installed here only when the plugin's provider was removed and the app did not
|
|
124
|
+
// install it before React Native started: marked late (HookStatus.installedLate).
|
|
125
|
+
if (parsed.pinGlobalNetworking && !PinVaultNetworking.isInstalled) PinVaultNetworking.install(app, late = true)
|
|
123
126
|
val hooks = PinVaultNetworking.warnIfNotPinned("PinVault.start")
|
|
124
127
|
if (parsed.networking.requirePinned && !hooks.pinned) {
|
|
125
128
|
promise.reject(E_NETWORKING_NOT_PINNED, "requirePinnedReactNativeNetworking: ${hooks.describe()}")
|
|
@@ -142,7 +145,8 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
142
145
|
// Setup failed (InitResult.Failed): RN's https stays refused.
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
|
-
|
|
148
|
+
// Says whether the anchors came from the native security file (README "Native security file").
|
|
149
|
+
promise.resolve(toBridge(mapper.init(result) + ("nativeSecurityApplied" to parsed.nativeSecurityApplied)))
|
|
146
150
|
} catch (e: Exception) {
|
|
147
151
|
promise.reject(E_NATIVE, tokens.redact("${e.javaClass.simpleName}: ${e.message}"), exceptionInfo(e))
|
|
148
152
|
}
|
|
@@ -337,8 +341,11 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
337
341
|
guard?.answer(requestId, allowed)
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
/** A vault file key: the library's rule (it names the stored copy's file). */
|
|
340
345
|
private fun checkKey(key: String) {
|
|
341
|
-
if (
|
|
346
|
+
if (!VAULT_KEY.matches(key) || key.all { it == '.' }) {
|
|
347
|
+
throw BridgeInputException("key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
|
|
348
|
+
}
|
|
342
349
|
}
|
|
343
350
|
|
|
344
351
|
private fun rejectNow(promise: Promise, code: String, message: String): Nothing {
|
|
@@ -408,3 +415,5 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
408
415
|
}
|
|
409
416
|
}
|
|
410
417
|
}
|
|
418
|
+
|
|
419
|
+
private val VAULT_KEY = Regex("^[A-Za-z0-9._-]{1,64}$")
|
package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt
CHANGED
|
@@ -73,15 +73,19 @@ object PinVaultNetworking {
|
|
|
73
73
|
|
|
74
74
|
/** The two hooks this object installed; compared by identity in [status]. */
|
|
75
75
|
private val factory = OkHttpClientFactory {
|
|
76
|
-
pinning.configureLongLived(OkHttpClientProvider.createClientBuilder()).build()
|
|
76
|
+
pinning.track(pinning.configureLongLived(OkHttpClientProvider.createClientBuilder()).build())
|
|
77
77
|
}
|
|
78
78
|
private val clientBuilder = CustomClientBuilder { builder -> pinning.configurePerRequest(builder) }
|
|
79
79
|
|
|
80
80
|
internal val pinning = NetworkPinning(
|
|
81
81
|
applier = { builder -> PinVault.applyTo(builder) },
|
|
82
82
|
httpCache = { app?.let { ReactHttpCache.get(it) } },
|
|
83
|
+
hooksPinned = { status().pinned },
|
|
83
84
|
)
|
|
84
85
|
|
|
86
|
+
/** True when [install] ran only from `start()`: React Native may have built clients before it. */
|
|
87
|
+
@Volatile private var installedLate = false
|
|
88
|
+
|
|
85
89
|
/**
|
|
86
90
|
* Installs both hooks (once per process). The plugin's content provider
|
|
87
91
|
* calls it before `Application.onCreate`; call it yourself in
|
|
@@ -90,12 +94,17 @@ object PinVaultNetworking {
|
|
|
90
94
|
*/
|
|
91
95
|
@JvmStatic
|
|
92
96
|
@Synchronized
|
|
93
|
-
fun install(context: Context)
|
|
97
|
+
fun install(context: Context) = install(context, late = false)
|
|
98
|
+
|
|
99
|
+
/** [late]: called from `start()`, after React Native may already have built its clients. */
|
|
100
|
+
@Synchronized
|
|
101
|
+
internal fun install(context: Context, late: Boolean) {
|
|
94
102
|
if (installed) return
|
|
95
103
|
app = context.applicationContext
|
|
96
104
|
OkHttpClientProvider.setOkHttpClientFactory(factory)
|
|
97
105
|
NetworkingModule.setCustomClientBuilder(clientBuilder)
|
|
98
106
|
installed = true
|
|
107
|
+
installedLate = late
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
@JvmStatic
|
|
@@ -103,7 +112,7 @@ object PinVaultNetworking {
|
|
|
103
112
|
|
|
104
113
|
/** Whether React Native's two networking hooks are still the ones [install] set. */
|
|
105
114
|
@JvmStatic
|
|
106
|
-
fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder)
|
|
115
|
+
fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder, installedLate)
|
|
107
116
|
|
|
108
117
|
private val warned = java.util.concurrent.atomic.AtomicReference<HookStatus?>(null)
|
|
109
118
|
|
|
@@ -126,11 +135,16 @@ data class HookStatus(
|
|
|
126
135
|
val factoryIsPinVault: Boolean?,
|
|
127
136
|
/** `NetworkingModule`'s custom client builder is PinVault's. */
|
|
128
137
|
val clientBuilderIsPinVault: Boolean?,
|
|
138
|
+
/** Installed only by `start()`: clients React Native built before (Fresco's, among them) keep the system's trust. */
|
|
139
|
+
val installedLate: Boolean = false,
|
|
129
140
|
) {
|
|
130
|
-
val pinned: Boolean get() = installed && factoryIsPinVault == true && clientBuilderIsPinVault == true
|
|
141
|
+
val pinned: Boolean get() = installed && !installedLate && factoryIsPinVault == true && clientBuilderIsPinVault == true
|
|
131
142
|
|
|
132
143
|
fun describe(): String = when {
|
|
133
144
|
pinned -> "React Native's networking is pinned by PinVault"
|
|
145
|
+
installed && installedLate -> "React Native's networking hooks were installed only by start() (the plugin's content provider " +
|
|
146
|
+
"was removed and PinVaultNetworking.install was not called before React Native started): clients it built " +
|
|
147
|
+
"before, such as the image client, are NOT pinned"
|
|
134
148
|
!installed -> "React Native's networking hooks are not installed (the plugin's content provider was removed " +
|
|
135
149
|
"and PinVaultNetworking.install was not called, or android.pinGlobalNetworking is false): " +
|
|
136
150
|
"RN fetch / XHR / WebSocket / images are NOT pinned"
|
|
@@ -141,10 +155,11 @@ data class HookStatus(
|
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
internal companion object {
|
|
144
|
-
fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any): HookStatus = HookStatus(
|
|
158
|
+
fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any, installedLate: Boolean = false): HookStatus = HookStatus(
|
|
145
159
|
installed,
|
|
146
160
|
isOurs(OkHttpClientProvider::class.java, "factory", ourFactory),
|
|
147
161
|
isOurs(NetworkingModule::class.java, "customClientBuilder", ourBuilder),
|
|
162
|
+
installedLate,
|
|
148
163
|
)
|
|
149
164
|
|
|
150
165
|
/**
|
|
@@ -199,8 +214,30 @@ internal object ReactHttpCache {
|
|
|
199
214
|
internal class NetworkPinning(
|
|
200
215
|
private val applier: (OkHttpClient.Builder) -> Unit,
|
|
201
216
|
private val httpCache: () -> Cache? = { null },
|
|
217
|
+
/** Whether React Native's hooks are still PinVault's (``PinVaultNetworking.status``). */
|
|
218
|
+
private val hooksPinned: () -> Boolean = { true },
|
|
202
219
|
) {
|
|
203
220
|
|
|
221
|
+
/** The long-lived clients React Native built through the factory: their pools are emptied on every change. */
|
|
222
|
+
private val longLived = java.util.Collections.synchronizedList(mutableListOf<java.lang.ref.WeakReference<OkHttpClient>>())
|
|
223
|
+
|
|
224
|
+
/** Last answer of [hooksPinned] and when it was read (reflection: not on every request). */
|
|
225
|
+
@Volatile private var hooksCheckedAt = 0L
|
|
226
|
+
@Volatile private var hooksWerePinned = true
|
|
227
|
+
|
|
228
|
+
fun track(client: OkHttpClient): OkHttpClient {
|
|
229
|
+
longLived.add(java.lang.ref.WeakReference(client))
|
|
230
|
+
return client
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Closes the idle pooled connections of every long-lived client: the next request makes a fresh, pinned handshake. */
|
|
234
|
+
private fun evictLongLived() {
|
|
235
|
+
synchronized(longLived) {
|
|
236
|
+
longLived.removeAll { it.get() == null }
|
|
237
|
+
longLived.forEach { it.get()?.connectionPool?.evictAll() }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
204
241
|
/** A client `PinVault.applyTo` configured after the last `start()`; null = not started. */
|
|
205
242
|
@Volatile private var template: OkHttpClient? = null
|
|
206
243
|
@Volatile var options: NetworkingOptions = NetworkingOptions()
|
|
@@ -212,11 +249,13 @@ internal class NetworkPinning(
|
|
|
212
249
|
fun activate(options: NetworkingOptions = this.options) {
|
|
213
250
|
this.options = options
|
|
214
251
|
template = OkHttpClient.Builder().also(applier).build()
|
|
252
|
+
evictLongLived()
|
|
215
253
|
}
|
|
216
254
|
|
|
217
255
|
@Synchronized
|
|
218
256
|
fun deactivate() {
|
|
219
257
|
template = null
|
|
258
|
+
evictLongLived()
|
|
220
259
|
}
|
|
221
260
|
|
|
222
261
|
/** NetworkingModule's per-request builder (fetch / XHR). */
|
|
@@ -247,18 +286,62 @@ internal class NetworkPinning(
|
|
|
247
286
|
val forwarding = ForwardingSocketFactory { template?.sslSocketFactory }
|
|
248
287
|
builder.sslSocketFactory(forwarding, ForwardingTrustManager { template?.x509TrustManager })
|
|
249
288
|
builder.addInterceptor(httpsGate)
|
|
289
|
+
// The current start's network interceptors (the per-request pin check of a pooled
|
|
290
|
+
// or coalesced connection, among them) run for these clients too.
|
|
291
|
+
builder.addNetworkInterceptor(forwardingNetwork)
|
|
250
292
|
builder.cache(null)
|
|
251
293
|
builder.followSslRedirects(false)
|
|
252
294
|
return builder
|
|
253
295
|
}
|
|
254
296
|
|
|
255
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* Refuses `https` while PinVault has not started, and — with
|
|
299
|
+
* `requirePinnedReactNativeNetworking` — once another library has replaced
|
|
300
|
+
* React Native's hooks after start (checked at most every few seconds).
|
|
301
|
+
*/
|
|
256
302
|
private val httpsGate = Interceptor { chain ->
|
|
257
|
-
if (chain.request().isHttps
|
|
258
|
-
throw IOException("PinVault has not started: https requests are refused until start() returns")
|
|
303
|
+
if (chain.request().isHttps) {
|
|
304
|
+
if (template == null) throw IOException("PinVault has not started: https requests are refused until start() returns")
|
|
305
|
+
if (options.requirePinned && !hooksStillPinned()) {
|
|
306
|
+
throw IOException("requirePinnedReactNativeNetworking: React Native's networking hooks are no longer PinVault's")
|
|
307
|
+
}
|
|
259
308
|
}
|
|
260
309
|
chain.proceed(chain.request())
|
|
261
310
|
}
|
|
311
|
+
|
|
312
|
+
private fun hooksStillPinned(): Boolean {
|
|
313
|
+
val now = android.os.SystemClock.elapsedRealtime()
|
|
314
|
+
if (now - hooksCheckedAt > HOOK_CHECK_MS) {
|
|
315
|
+
hooksWerePinned = hooksPinned()
|
|
316
|
+
hooksCheckedAt = now
|
|
317
|
+
}
|
|
318
|
+
return hooksWerePinned
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Runs the current start's network interceptors in order, then the request. */
|
|
322
|
+
private val forwardingNetwork = Interceptor { chain ->
|
|
323
|
+
val interceptors = template?.networkInterceptors ?: emptyList()
|
|
324
|
+
ForwardingChain(chain, interceptors, 0).proceed(chain.request())
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
companion object {
|
|
328
|
+
const val HOOK_CHECK_MS = 5_000L
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A chain that hands the request to `interceptors[index]`, …, and finally to the real chain. */
|
|
333
|
+
internal class ForwardingChain(
|
|
334
|
+
private val base: Interceptor.Chain,
|
|
335
|
+
private val interceptors: List<Interceptor>,
|
|
336
|
+
private val index: Int,
|
|
337
|
+
) : Interceptor.Chain by base {
|
|
338
|
+
override fun proceed(request: okhttp3.Request): okhttp3.Response =
|
|
339
|
+
if (index >= interceptors.size) base.proceed(request)
|
|
340
|
+
else interceptors[index].intercept(ForwardingChain(base, interceptors, index + 1).withRequest(request))
|
|
341
|
+
|
|
342
|
+
private var current: okhttp3.Request = base.request()
|
|
343
|
+
private fun withRequest(request: okhttp3.Request) = also { current = request }
|
|
344
|
+
override fun request(): okhttp3.Request = current
|
|
262
345
|
}
|
|
263
346
|
|
|
264
347
|
/** 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,9 @@ 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
|
+
let requirePinned = try root.bool("requirePinnedReactNativeNetworking") ?? (release && reactNetworkingEnabled)
|
|
77
119
|
// Android-only settings: the Android side reads them; here only their shape is checked.
|
|
78
120
|
_ = try root.object("android")
|
|
79
121
|
var maxRN = ReactNetworking.defaultMaxResponseBytes
|
|
@@ -82,12 +124,28 @@ public enum ConfigParser {
|
|
|
82
124
|
if let resolve = try ios.stringMap("resolve", maxItems: 64, maxKeyLength: 255, maxValueLength: 255) {
|
|
83
125
|
for (host, address) in resolve.sorted(by: { $0.key < $1.key }) { builder.resolve(host: host, to: address) }
|
|
84
126
|
}
|
|
85
|
-
if let v = try ios.
|
|
86
|
-
|
|
87
|
-
|
|
127
|
+
if let v = try fixedList("config.ios.expectedBundleIds", require.expectedBundleIds,
|
|
128
|
+
try ios.stringList("expectedBundleIds", maxItems: 16, maxLength: 255), native) {
|
|
129
|
+
builder.expectedBundleId(v)
|
|
130
|
+
}
|
|
131
|
+
if let v = try fixedList("config.ios.expectedTeamIds", require.expectedTeamIds,
|
|
132
|
+
try ios.stringList("expectedTeamIds", maxItems: 16, maxLength: 32), native) {
|
|
133
|
+
builder.expectedTeamId(v)
|
|
134
|
+
}
|
|
135
|
+
let jsStrength = try ios.enumValue("userAuthStrength", UserAuthStrength.self)
|
|
136
|
+
if let fixed = require.userAuthStrength, let jsStrength, jsStrength != fixed {
|
|
137
|
+
throw BridgeInputError("config.ios.userAuthStrength: differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
|
|
138
|
+
}
|
|
139
|
+
if let v = require.userAuthStrength ?? jsStrength { builder.userAuthStrength(v) }
|
|
88
140
|
try ios.finish()
|
|
89
141
|
}
|
|
90
142
|
|
|
143
|
+
else {
|
|
144
|
+
if let v = require.expectedBundleIds { builder.expectedBundleId(v) }
|
|
145
|
+
if let v = require.expectedTeamIds { builder.expectedTeamId(v) }
|
|
146
|
+
if let v = require.userAuthStrength { builder.userAuthStrength(v) }
|
|
147
|
+
}
|
|
148
|
+
|
|
91
149
|
try root.finish()
|
|
92
150
|
if let listener { builder.onConnectionEvent(listener) }
|
|
93
151
|
var parsed = ParsedConfig(config: try builder.build(), guardTimeoutMs: guardTimeout)
|
|
@@ -97,6 +155,26 @@ public enum ConfigParser {
|
|
|
97
155
|
return parsed
|
|
98
156
|
}
|
|
99
157
|
|
|
158
|
+
/// A JS list against the native file's: fixed where the file gives it.
|
|
159
|
+
static func fixedList(_ path: String, _ native: [String]?, _ js: [String]?, _ file: NativeSecurity?) throws -> [String]? {
|
|
160
|
+
guard let native else { return js }
|
|
161
|
+
if let js, !NativeSecurity.sameKeys(js, native) {
|
|
162
|
+
throw BridgeInputError("\(path): differs from the app's native security file (\(file?.source ?? "")); the native value is fixed")
|
|
163
|
+
}
|
|
164
|
+
return native
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
static func millis(_ amount: Int64, _ unit: TimeUnit) -> Int64 {
|
|
168
|
+
switch unit {
|
|
169
|
+
case .milliseconds: return amount
|
|
170
|
+
case .seconds: return amount.multipliedReportingOverflow(by: 1000).overflow ? .max : amount * 1000
|
|
171
|
+
case .minutes: return amount.multipliedReportingOverflow(by: 60_000).overflow ? .max : amount * 60_000
|
|
172
|
+
case .hours: return amount.multipliedReportingOverflow(by: 3_600_000).overflow ? .max : amount * 3_600_000
|
|
173
|
+
case .days: return amount.multipliedReportingOverflow(by: 86_400_000).overflow ? .max : amount * 86_400_000
|
|
174
|
+
default: return .max
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
100
178
|
static func duration(_ d: Fields) throws -> (Int64, TimeUnit) {
|
|
101
179
|
guard let amount = try d.int64("amount", min: 0, max: 10 * 365 * 24 * 3600 * 1000) else {
|
|
102
180
|
throw BridgeInputError("\(d.path).amount: required")
|
|
@@ -178,11 +256,52 @@ public enum ConfigParser {
|
|
|
178
256
|
let enrollmentUrl = try httpsUrl(b, "enrollmentUrl")
|
|
179
257
|
let threshold = try b.double("clientCertRenewalThreshold", min: 0, max: 1)
|
|
180
258
|
let disableRenewal = try b.bool("disableClientCertRenewal") == true
|
|
181
|
-
let
|
|
259
|
+
let jsAttestation = try b.bool("attestation")
|
|
182
260
|
let attestationInterval = try b.object("attestationInterval").map(duration)
|
|
183
|
-
let
|
|
261
|
+
let jsTokenHosts = try b.stringList("tokenHosts", maxItems: 64, maxLength: 255)
|
|
262
|
+
let jsProof = try b.bool("proofOfPossession")
|
|
184
263
|
try b.finish()
|
|
185
264
|
|
|
265
|
+
// Where the block talks to and who gets its token and identity: fixed where the file says.
|
|
266
|
+
let nativeBlock = native?.blocks[id]
|
|
267
|
+
func fixed(_ key: String) -> BridgeInputError {
|
|
268
|
+
BridgeInputError("\(b.path).\(key): differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
|
|
269
|
+
}
|
|
270
|
+
var blockUrl = url
|
|
271
|
+
if let n = nativeBlock?.url {
|
|
272
|
+
if n != url { throw fixed("url") }
|
|
273
|
+
blockUrl = n
|
|
274
|
+
}
|
|
275
|
+
if nativeBlock?.attestation == true && jsAttestation == false {
|
|
276
|
+
throw BridgeInputError("\(b.path).attestation: the app's native security file turns it on; JS cannot turn it off")
|
|
277
|
+
}
|
|
278
|
+
let attestation = jsAttestation == true || nativeBlock?.attestation == true
|
|
279
|
+
if nativeBlock?.proofOfPossession == true && jsProof == false {
|
|
280
|
+
throw BridgeInputError("\(b.path).proofOfPossession: the app's native security file turns it on; JS cannot turn it off")
|
|
281
|
+
}
|
|
282
|
+
let proof = jsProof == true || nativeBlock?.proofOfPossession == true
|
|
283
|
+
let lower: ([String]) -> [String] = { $0.map { $0.lowercased() } }
|
|
284
|
+
var tokenHosts = jsTokenHosts
|
|
285
|
+
if let n = nativeBlock?.tokenHosts {
|
|
286
|
+
if let j = jsTokenHosts, !NativeSecurity.sameKeys(lower(j), lower(n)) { throw fixed("tokenHosts") }
|
|
287
|
+
tokenHosts = n
|
|
288
|
+
}
|
|
289
|
+
var fixedEnrollmentUrl = enrollmentUrl
|
|
290
|
+
if let n = nativeBlock?.enrollmentUrl {
|
|
291
|
+
if let j = enrollmentUrl, j != n { throw fixed("enrollmentUrl") }
|
|
292
|
+
fixedEnrollmentUrl = n
|
|
293
|
+
}
|
|
294
|
+
var fixedRenewalUrl = renewalUrl
|
|
295
|
+
if let n = nativeBlock?.renewalUrl {
|
|
296
|
+
if let j = renewalUrl, j != n { throw fixed("renewalUrl") }
|
|
297
|
+
fixedRenewalUrl = n
|
|
298
|
+
}
|
|
299
|
+
var certHosts = clientCertHosts
|
|
300
|
+
if let n = nativeBlock?.clientCertHosts {
|
|
301
|
+
if let j = clientCertHosts, !NativeSecurity.sameKeys(lower(j), lower(n)) { throw fixed("clientCertHosts") }
|
|
302
|
+
certHosts = n
|
|
303
|
+
}
|
|
304
|
+
|
|
186
305
|
// The trust anchors and relaxations: the native security file decides, JS only repeats.
|
|
187
306
|
let sec = try SecurityPolicy.apply(
|
|
188
307
|
path: b.path, id: id,
|
|
@@ -195,7 +314,7 @@ public enum ConfigParser {
|
|
|
195
314
|
native: native, release: release
|
|
196
315
|
)
|
|
197
316
|
|
|
198
|
-
builder.configApi(id, url:
|
|
317
|
+
builder.configApi(id, url: blockUrl) { api in
|
|
199
318
|
if let v = sec.bootstrapPins { api.bootstrapPins(v) }
|
|
200
319
|
if let configEndpoint { api.configEndpoint(configEndpoint) }
|
|
201
320
|
if let healthEndpoint { api.healthEndpoint(healthEndpoint) }
|
|
@@ -210,35 +329,77 @@ public enum ConfigParser {
|
|
|
210
329
|
if sec.allowServerKey { api.allowServerGeneratedKey() }
|
|
211
330
|
if let v = sec.clientCaPins { api.clientCaPins(v) }
|
|
212
331
|
if let maxLifetime { api.maxClientCertLifetimeDays(maxLifetime) }
|
|
213
|
-
if let
|
|
332
|
+
if let certHosts { api.clientCertHosts(certHosts) }
|
|
214
333
|
if let enrollmentEndpoint { api.enrollmentEndpoint(enrollmentEndpoint) }
|
|
215
334
|
if let clientCertEndpoint { api.clientCertEndpoint(clientCertEndpoint) }
|
|
216
335
|
if let vaultReportEndpoint { api.vaultReportEndpoint(vaultReportEndpoint) }
|
|
217
336
|
if let clientCertLabel { api.clientCertLabel(clientCertLabel) }
|
|
218
337
|
if let wantPinsFor { api.wantPinsFor(wantPinsFor) }
|
|
219
|
-
if let
|
|
220
|
-
if let
|
|
338
|
+
if let fixedRenewalUrl { api.renewalUrl(fixedRenewalUrl) }
|
|
339
|
+
if let fixedEnrollmentUrl { api.enrollmentUrl(fixedEnrollmentUrl) }
|
|
221
340
|
if let threshold { api.clientCertRenewalThreshold(threshold) }
|
|
222
341
|
if disableRenewal { api.disableClientCertRenewal() }
|
|
223
342
|
if attestation { api.attestation() }
|
|
224
343
|
if let (amount, unit) = attestationInterval { api.attestationInterval(amount, unit) }
|
|
225
344
|
if let tokenHosts { api.tokenHosts(tokenHosts) }
|
|
345
|
+
if proof { api.proofOfPossession() }
|
|
226
346
|
}
|
|
227
347
|
}
|
|
228
348
|
|
|
229
|
-
static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore) throws {
|
|
349
|
+
static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore, native: NativeSecurity?) throws {
|
|
230
350
|
let key = try f.requireString("key", maxLength: 128)
|
|
231
351
|
let endpoint = try f.requireString("endpoint", maxLength: 512)
|
|
232
|
-
let
|
|
352
|
+
let jsSignatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
|
|
233
353
|
let updateWithPins = try f.bool("updateWithPins")
|
|
234
354
|
let storage = try f.enumValue("storage", StorageStrategy.self)
|
|
235
355
|
let configApi = try f.string("configApi", maxLength: 128)
|
|
236
356
|
let policy = try f.enumValue("accessPolicy", VaultFileAccessPolicy.self)
|
|
237
|
-
let
|
|
238
|
-
let
|
|
357
|
+
let jsEncryption = try f.enumValue("encryption", VaultFileEncryption.self)
|
|
358
|
+
let jsUserAuth = try f.enumValue("userAuth", UserAuth.self)
|
|
239
359
|
let maxOfflineAge = try f.object("maxOfflineAge").map(duration)
|
|
240
360
|
let wipeWhenStale = try f.bool("wipeWhenStale") == true
|
|
241
361
|
try f.finish()
|
|
362
|
+
var offlineAge = maxOfflineAge
|
|
363
|
+
|
|
364
|
+
// A file the native security file names keeps its signature key, encryption and lock.
|
|
365
|
+
var signatureKey = jsSignatureKey
|
|
366
|
+
var encryption = jsEncryption
|
|
367
|
+
var userAuth = jsUserAuth
|
|
368
|
+
if let files = native?.vaultFiles {
|
|
369
|
+
guard let declared = files[key] else {
|
|
370
|
+
throw BridgeInputError("\(f.path): vault file '\(key)' is not declared in the app's native security file (\(native?.source ?? ""))")
|
|
371
|
+
}
|
|
372
|
+
func fixed(_ field: String) -> BridgeInputError {
|
|
373
|
+
BridgeInputError("\(f.path).\(field): differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
|
|
374
|
+
}
|
|
375
|
+
if let n = declared.signaturePublicKey {
|
|
376
|
+
if let j = jsSignatureKey, j.trimmingCharacters(in: .whitespacesAndNewlines) != n.trimmingCharacters(in: .whitespacesAndNewlines) {
|
|
377
|
+
throw fixed("signaturePublicKey")
|
|
378
|
+
}
|
|
379
|
+
signatureKey = n
|
|
380
|
+
}
|
|
381
|
+
if let n = declared.encryption {
|
|
382
|
+
if let j = jsEncryption, j != n { throw fixed("encryption") }
|
|
383
|
+
encryption = n
|
|
384
|
+
}
|
|
385
|
+
if let n = declared.userAuth {
|
|
386
|
+
if let j = jsUserAuth, j != n { throw fixed("userAuth") }
|
|
387
|
+
userAuth = n
|
|
388
|
+
}
|
|
389
|
+
if let max = declared.maxOfflineAgeMs, maxOfflineAge == nil {
|
|
390
|
+
offlineAge = (max, .milliseconds)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// The tighter of the file's own cap and the config-wide one: a per-file value
|
|
394
|
+
// overrides the config's, so it must not escape the config-wide cap either.
|
|
395
|
+
let cap = [native?.vaultFiles?[key]?.maxOfflineAgeMs, native?.require.vaultFileMaxOfflineAgeMs].compactMap { $0 }.min()
|
|
396
|
+
if let cap, let (a, u) = maxOfflineAge {
|
|
397
|
+
let ms = millis(a, u)
|
|
398
|
+
// 0 = no limit: longer than any.
|
|
399
|
+
if ms == 0 || ms > cap {
|
|
400
|
+
throw BridgeInputError("\(f.path).maxOfflineAge: longer than the app's native security file allows (\(cap / 1000) s)")
|
|
401
|
+
}
|
|
402
|
+
}
|
|
242
403
|
|
|
243
404
|
builder.vaultFile(key) { file in
|
|
244
405
|
file.endpoint(endpoint)
|
|
@@ -255,7 +416,7 @@ public enum ConfigParser {
|
|
|
255
416
|
}
|
|
256
417
|
if let encryption { file.encryption(encryption) }
|
|
257
418
|
if let userAuth { file.userAuth(userAuth) }
|
|
258
|
-
if let (amount, unit) =
|
|
419
|
+
if let (amount, unit) = offlineAge { file.maxOfflineAge(amount, unit) }
|
|
259
420
|
if wipeWhenStale { file.wipeWhenStale() }
|
|
260
421
|
}
|
|
261
422
|
}
|