@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.
@@ -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
- * Two hooks, both verified against React Native 0.87's sources:
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 two hooks this object installed; compared by identity in [status]. */
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 both hooks (once per process). The plugin's content provider
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 two networking hooks are still the ones [install] set. */
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 && clientBuilderIsPinVault == 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 both fields' names.
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
- /** Clients RN builds once and keeps: TLS goes through the pinned factory of the current start. */
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
- /** Refuses `https` while PinVault has not started. */
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 && template == null) {
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, 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,11 @@ 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
+ // 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.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) }
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.string("configEndpoint", maxLength: 512)
156
- let healthEndpoint = try b.string("healthEndpoint", maxLength: 512)
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.string("enrollmentEndpoint", maxLength: 512)
173
- let clientCertEndpoint = try b.string("clientCertEndpoint", maxLength: 512)
174
- let vaultReportEndpoint = try b.string("vaultReportEndpoint", maxLength: 512)
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 attestation = try b.bool("attestation") == true
273
+ let jsAttestation = try b.bool("attestation")
182
274
  let attestationInterval = try b.object("attestationInterval").map(duration)
183
- let tokenHosts = try b.stringList("tokenHosts", maxItems: 64, maxLength: 255)
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: url) { api in
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 clientCertHosts { api.clientCertHosts(clientCertHosts) }
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 renewalUrl { api.renewalUrl(renewalUrl) }
220
- if let enrollmentUrl { api.enrollmentUrl(enrollmentUrl) }
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
- let signatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
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 encryption = try f.enumValue("encryption", VaultFileEncryption.self)
238
- let userAuth = try f.enumValue("userAuth", UserAuth.self)
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) = maxOfflineAge { file.maxOfflineAge(amount, unit) }
442
+ if let (amount, unit) = offlineAge { file.maxOfflineAge(amount, unit) }
259
443
  if wipeWhenStale { file.wipeWhenStale() }
260
444
  }
261
445
  }