@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.
@@ -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
- if (parsed.pinGlobalNetworking) PinVaultNetworking.install(app)
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
- promise.resolve(toBridge(mapper.init(result)))
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 (key.isEmpty() || key.length > 128) throw BridgeInputException("key: must be 1 to 128 characters")
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}$")
@@ -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
- /** Refuses `https` while PinVault has not started. */
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 && template == null) {
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, the relaxations (`allowUnsigned`, …) are refused from JS.
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
- for api in try root.objectList("configApis", maxItems: maxConfigApis) ?? [] {
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 try root.objectList("vaultFiles", maxItems: maxVaultFiles) ?? [] { try addVaultFile(builder, file, tokens) }
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") { let (a, u) = try duration(d); builder.expiredConfigGrace(a, u) }
59
- if let v = try root.stringList("requireCaTrust", maxItems: 64, maxLength: 255) { builder.requireCaTrust(v) }
60
- if try root.bool("wipeVaultFilesOnRevocation") == true { builder.wipeVaultFilesOnRevocation() }
61
- if let d = try root.object("vaultFileMaxOfflineAge") { let (a, u) = try duration(d); builder.vaultFileMaxOfflineAge(a, u) }
62
- if try root.bool("requireUnlockedDevice") == true { builder.requireUnlockedDevice() }
63
- if try root.bool("requireHardwareBackedKeys") == true { builder.requireHardwareBackedKeys() }
64
- if try root.bool("managedTrustRoots") == true { builder.managedTrustRoots() }
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
- let requirePinned = try root.bool("requirePinnedReactNativeNetworking") == true
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.stringList("expectedBundleIds", maxItems: 16, maxLength: 255) { builder.expectedBundleId(v) }
86
- if let v = try ios.stringList("expectedTeamIds", maxItems: 16, maxLength: 32) { builder.expectedTeamId(v) }
87
- if let v = try ios.enumValue("userAuthStrength", UserAuthStrength.self) { builder.userAuthStrength(v) }
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 attestation = try b.bool("attestation") == true
259
+ let jsAttestation = try b.bool("attestation")
182
260
  let attestationInterval = try b.object("attestationInterval").map(duration)
183
- let tokenHosts = try b.stringList("tokenHosts", maxItems: 64, maxLength: 255)
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: url) { api in
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 clientCertHosts { api.clientCertHosts(clientCertHosts) }
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 renewalUrl { api.renewalUrl(renewalUrl) }
220
- if let enrollmentUrl { api.enrollmentUrl(enrollmentUrl) }
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 signatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
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 encryption = try f.enumValue("encryption", VaultFileEncryption.self)
238
- let userAuth = try f.enumValue("userAuth", UserAuth.self)
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) = maxOfflineAge { file.maxOfflineAge(amount, unit) }
419
+ if let (amount, unit) = offlineAge { file.maxOfflineAge(amount, unit) }
259
420
  if wipeWhenStale { file.wipeWhenStale() }
260
421
  }
261
422
  }