@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.
@@ -55,11 +55,16 @@ internal object ConfigParser {
55
55
  const val MAX_PINS = 256
56
56
  const val MAX_PINS_PER_HOST = 16
57
57
  const val KEY_LENGTH = 4096
58
+ private val VAULT_KEY = Regex("^[A-Za-z0-9._-]{1,64}$")
58
59
 
59
60
  /**
60
61
  * @param native the app's native security file ([NativeSecurity]); null = none.
61
- * @param release a release build of the app: without a native file, the
62
- * relaxations (`allowUnsigned`, …) are refused from JS.
62
+ * @param release a release build of the app: without a native file, a
63
+ * config with Config APIs or static pins is refused (unless
64
+ * [noFileAllowed]) and the relaxations (`allowUnsigned`, …) are refused
65
+ * from JS; `expiredConfigGrace` is capped.
66
+ * @param noFileAllowed the app's manifest accepts a release start without
67
+ * a native security file ([NativeSecurity.NO_FILE_META_DATA]).
63
68
  */
64
69
  fun parse(
65
70
  json: String,
@@ -68,27 +73,64 @@ internal object ConfigParser {
68
73
  listener: PinVaultConnectionListener?,
69
74
  native: NativeSecurity? = null,
70
75
  release: Boolean = false,
76
+ noFileAllowed: Boolean = false,
71
77
  ): ParsedConfig {
72
78
  val root = StrictJson.parseObject(json, "config")
73
79
  val builder = PinVaultConfig.Builder()
80
+ val nativeRequire = native?.require ?: NativeSecurity.Require()
74
81
 
75
- root.objList("configApis", MAX_CONFIG_APIS)?.forEach { addConfigApi(builder, it, native, release) }
76
- root.objList("vaultFiles", MAX_VAULT_FILES)?.forEach { addVaultFile(builder, it, tokens) }
82
+ val configApis = root.objList("configApis", MAX_CONFIG_APIS)
83
+ val vaultFiles = root.objList("vaultFiles", MAX_VAULT_FILES)
77
84
  val staticPins = root.obj("staticPins")?.let(::staticPins)
85
+ if (release && native == null && !noFileAllowed && (!configApis.isNullOrEmpty() || staticPins != null)) {
86
+ throw BridgeInputException(
87
+ "config: a release build takes its trust anchors only from the app's native security file " +
88
+ "(assets/${NativeSecurity.ASSET_NAME}, README \"Native security file\"); none is shipped. " +
89
+ "An app that accepts JS-only anchors says so in its manifest: meta-data ${NativeSecurity.NO_FILE_META_DATA}=true"
90
+ )
91
+ }
92
+ configApis?.forEach { addConfigApi(builder, it, native, release) }
93
+ vaultFiles?.forEach { addVaultFile(builder, it, tokens, native) }
78
94
  SecurityPolicy.staticPins("config.staticPins", staticPins, native)?.let { builder.staticPins(it) }
79
95
 
80
96
  root.int("maxRetryCount", 0, 10)?.let { builder.maxRetryCount(it) }
81
97
  root.long("updateIntervalHours", 1, 24L * 30)?.let { builder.updateIntervalHours(it) }
82
98
  root.long("updateIntervalMinutes", 15, 60L * 24 * 30)?.let { builder.updateIntervalMinutes(it) }
83
99
  root.string("deviceAlias", 128)?.let { builder.deviceAlias(it) }
84
- root.obj("expiredConfigGrace")?.let { d -> duration(d).let { builder.expiredConfigGrace(it.first, it.second) } }
85
- root.stringList("requireCaTrust", 64, 255)?.let { builder.requireCaTrust(*it.toTypedArray()) }
86
- if (root.bool("wipeVaultFilesOnRevocation") == true) builder.wipeVaultFilesOnRevocation()
87
- root.obj("vaultFileMaxOfflineAge")?.let { d -> duration(d).let { builder.vaultFileMaxOfflineAge(it.first, it.second) } }
88
- val requireUnlocked = root.bool("requireUnlockedDevice") == true
89
- if (root.bool("requireHardwareBackedKeys") == true) builder.requireHardwareBackedKeys()
90
- if (root.bool("managedTrustRoots") == true) builder.managedTrustRoots()
91
- root.stringList("expectedSignerSha256", 16, 128)?.let { builder.expectedSignerSha256(it) }
100
+ root.obj("expiredConfigGrace")?.let { d ->
101
+ val (amount, unit) = duration(d)
102
+ val ms = unit.toMillis(amount)
103
+ if (release && ms > NativeSecurity.MAX_RELEASE_GRACE_MS) {
104
+ throw BridgeInputException("${d.path}: a release build takes at most 7 days")
105
+ }
106
+ nativeRequire.expiredConfigGraceMs?.let { max ->
107
+ if (ms > max) throw BridgeInputException("${d.path}: longer than the app's native security file allows (${max / 1000} s)")
108
+ }
109
+ builder.expiredConfigGrace(amount, unit)
110
+ } ?: nativeRequire.expiredConfigGraceMs?.let { builder.expiredConfigGrace(it, TimeUnit.MILLISECONDS) }
111
+ // `require` hosts are added to JS's: JS can name more, never fewer.
112
+ val caTrust = (root.stringList("requireCaTrust", 64, 255) ?: emptyList()) + (nativeRequire.requireCaTrust ?: emptyList())
113
+ if (caTrust.isNotEmpty()) builder.requireCaTrust(*caTrust.distinct().toTypedArray())
114
+ if (root.bool("wipeVaultFilesOnRevocation") == true || nativeRequire.wipeVaultFilesOnRevocation) builder.wipeVaultFilesOnRevocation()
115
+ root.obj("vaultFileMaxOfflineAge")?.let { d ->
116
+ val (amount, unit) = duration(d)
117
+ nativeRequire.vaultFileMaxOfflineAgeMs?.let { max ->
118
+ val ms = unit.toMillis(amount)
119
+ // 0 = no limit: longer than any.
120
+ if (ms == 0L || ms > max) throw BridgeInputException("${d.path}: longer than the app's native security file allows (${max / 1000} s)")
121
+ }
122
+ builder.vaultFileMaxOfflineAge(amount, unit)
123
+ } ?: nativeRequire.vaultFileMaxOfflineAgeMs?.let { builder.vaultFileMaxOfflineAge(it, TimeUnit.MILLISECONDS) }
124
+ val requireUnlocked = root.bool("requireUnlockedDevice") == true || nativeRequire.requireUnlockedDevice
125
+ if (root.bool("requireHardwareBackedKeys") == true || nativeRequire.requireHardwareBackedKeys) builder.requireHardwareBackedKeys()
126
+ if (root.bool("managedTrustRoots") == true || nativeRequire.managedTrustRoots) builder.managedTrustRoots()
127
+ val signers = root.stringList("expectedSignerSha256", 16, 128)
128
+ nativeRequire.expectedSignerSha256?.let { fixed ->
129
+ if (signers != null && !NativeSecurity.sameKeys(signers.map(String::lowercase), fixed.map(String::lowercase))) {
130
+ throw BridgeInputException("config.expectedSignerSha256: differs from the app's native security file (${native?.source}); the native value is fixed")
131
+ }
132
+ }
133
+ (nativeRequire.expectedSignerSha256 ?: signers)?.let { builder.expectedSignerSha256(it) }
92
134
 
93
135
  var guardTimeout: Long? = null
94
136
  root.obj("environmentGuard")?.let { g ->
@@ -109,7 +151,10 @@ internal object ConfigParser {
109
151
  keepCookies = a.bool("keepReactNativeCookies") == true
110
152
  a.finish()
111
153
  }
112
- val requirePinned = root.bool("requirePinnedReactNativeNetworking") == true
154
+ // On by default in a release build: RN's https must go through PinVault there.
155
+ // The native security file can pin this on (RN-3): JS may turn it on, never off.
156
+ val requirePinned = (root.bool("requirePinnedReactNativeNetworking") ?: (release && pinGlobal)) ||
157
+ nativeRequire.requirePinnedReactNativeNetworking
113
158
  if (requirePinned && !pinGlobal) {
114
159
  throw BridgeInputException("config.requirePinnedReactNativeNetworking: contradicts android.pinGlobalNetworking: false")
115
160
  }
@@ -149,6 +194,20 @@ internal object ConfigParser {
149
194
  return url
150
195
  }
151
196
 
197
+ /**
198
+ * An endpoint path, resolved relative to the block's base URL. An absolute
199
+ * value — a scheme (`://`) or a root (`/`, `\`) — would silently retarget the
200
+ * request past the pinned host (`@Url`/`URL(string:relativeTo:)` honour it),
201
+ * so it is refused (RN-2). Returns null when the optional field is absent.
202
+ */
203
+ private fun relEndpoint(f: Fields, key: String, max: Int): String? {
204
+ val v = f.string(key, max) ?: return null
205
+ if (v.contains("://") || v.startsWith("/") || v.startsWith("\\")) {
206
+ throw BridgeInputException("${f.path}.$key: must be a relative path, not an absolute URL")
207
+ }
208
+ return v
209
+ }
210
+
152
211
  fun hostPin(p: Fields): HostPin {
153
212
  val hostname = p.requireString("hostname", 255)
154
213
  val pins = p.stringList("sha256", MAX_PINS_PER_HOST, 128) ?: throw BridgeInputException("${p.path}.sha256: required")
@@ -180,8 +239,8 @@ internal object ConfigParser {
180
239
  val url = httpsUrl(b, "url") ?: throw BridgeInputException("${b.path}.url: required")
181
240
  // Read everything before the builder runs, so a refusal names the JSON path.
182
241
  val bootstrapPins = b.objList("bootstrapPins", MAX_PINS)?.map(::hostPin)
183
- val configEndpoint = b.string("configEndpoint", 512)
184
- val healthEndpoint = b.string("healthEndpoint", 512)
242
+ val configEndpoint = relEndpoint(b, "configEndpoint", 512)
243
+ val healthEndpoint = relEndpoint(b, "healthEndpoint", 512)
185
244
  val oneKey = b.string("signaturePublicKey", KEY_LENGTH, multiline = true)
186
245
  val keys = b.stringList("signaturePublicKeys", 16, KEY_LENGTH, multiline = true)
187
246
  if (oneKey != null && keys != null) {
@@ -197,20 +256,49 @@ internal object ConfigParser {
197
256
  val clientCaPins = b.stringList("clientCaPins", 16, 128)
198
257
  val maxLifetime = b.int("maxClientCertLifetimeDays", 1, 3650)
199
258
  val clientCertHosts = b.stringList("clientCertHosts", 64, 2048)
200
- val enrollmentEndpoint = b.string("enrollmentEndpoint", 512)
201
- val clientCertEndpoint = b.string("clientCertEndpoint", 512)
202
- val vaultReportEndpoint = b.string("vaultReportEndpoint", 512)
259
+ val enrollmentEndpoint = relEndpoint(b, "enrollmentEndpoint", 512)
260
+ val clientCertEndpoint = relEndpoint(b, "clientCertEndpoint", 512)
261
+ val vaultReportEndpoint = relEndpoint(b, "vaultReportEndpoint", 512)
203
262
  val clientCertLabel = b.string("clientCertLabel", 128)
204
263
  val wantPinsFor = b.stringList("wantPinsFor", 256, 255)
205
264
  val renewalUrl = httpsUrl(b, "renewalUrl")
206
265
  val enrollmentUrl = httpsUrl(b, "enrollmentUrl")
207
266
  val threshold = b.double("clientCertRenewalThreshold", 0.0, 1.0)
208
267
  val disableRenewal = b.bool("disableClientCertRenewal") == true
209
- val attestation = b.bool("attestation") == true
268
+ val jsAttestation = b.bool("attestation")
210
269
  val attestationInterval = b.obj("attestationInterval")?.let(::duration)
211
- val tokenHosts = b.stringList("tokenHosts", 64, 255)
270
+ val jsTokenHosts = b.stringList("tokenHosts", 64, 255)
271
+ val jsProof = b.bool("proofOfPossession")
212
272
  b.finish()
213
273
 
274
+ // Where the block talks to and who gets its token and identity: fixed where the file says.
275
+ val nativeBlock = native?.blocks?.get(id)
276
+ fun fixed(key: String): Nothing =
277
+ throw BridgeInputException("${b.path}.$key: differs from the app's native security file (${native?.source}); the native value is fixed")
278
+ val blockUrl = nativeBlock?.url?.also { if (it != url) fixed("url") } ?: url
279
+ val fixedEnrollmentUrl = nativeBlock?.enrollmentUrl?.also { if (enrollmentUrl != null && it != enrollmentUrl) fixed("enrollmentUrl") } ?: enrollmentUrl
280
+ val fixedRenewalUrl = nativeBlock?.renewalUrl?.also { if (renewalUrl != null && it != renewalUrl) fixed("renewalUrl") } ?: renewalUrl
281
+ if (nativeBlock?.attestation == true && jsAttestation == false) {
282
+ throw BridgeInputException("${b.path}.attestation: the app's native security file turns it on; JS cannot turn it off")
283
+ }
284
+ val attestation = jsAttestation == true || nativeBlock?.attestation == true
285
+ if (nativeBlock?.proofOfPossession == true && jsProof == false) {
286
+ throw BridgeInputException("${b.path}.proofOfPossession: the app's native security file turns it on; JS cannot turn it off")
287
+ }
288
+ val proof = jsProof == true || nativeBlock?.proofOfPossession == true
289
+ val tokenHosts = nativeBlock?.tokenHosts?.also { n ->
290
+ if (jsTokenHosts != null && !NativeSecurity.sameKeys(jsTokenHosts.map(String::lowercase), n.map(String::lowercase))) fixed("tokenHosts")
291
+ } ?: jsTokenHosts
292
+ // Who gets the device's identity is the file's call in a release build: JS cannot
293
+ // add a host the file does not name (tokenHosts only narrows the library's default).
294
+ if (release && nativeBlock != null && nativeBlock.clientCertHosts == null && clientCertHosts != null) {
295
+ throw BridgeInputException("${b.path}.clientCertHosts: a release build takes it only from the app's native security file " +
296
+ "(${native?.source}); declare clientCertHosts for Config API '$id' there")
297
+ }
298
+ val certHosts = nativeBlock?.clientCertHosts?.also { n ->
299
+ if (clientCertHosts != null && !NativeSecurity.sameKeys(clientCertHosts.map(String::lowercase), n.map(String::lowercase))) fixed("clientCertHosts")
300
+ } ?: clientCertHosts
301
+
214
302
  // The trust anchors and relaxations: the native security file decides, JS only repeats.
215
303
  val sec = SecurityPolicy.apply(
216
304
  b.path, id,
@@ -221,7 +309,7 @@ internal object ConfigParser {
221
309
  native, release,
222
310
  )
223
311
 
224
- builder.configApi(id, url) {
312
+ builder.configApi(id, blockUrl) {
225
313
  sec.bootstrapPins?.let { this.bootstrapPins(it) }
226
314
  configEndpoint?.let { this.configEndpoint(it) }
227
315
  healthEndpoint?.let { this.healthEndpoint(it) }
@@ -236,36 +324,69 @@ internal object ConfigParser {
236
324
  if (sec.allowServerKey) this.allowServerGeneratedKey()
237
325
  sec.clientCaPins?.let { this.clientCaPins(it) }
238
326
  maxLifetime?.let { this.maxClientCertLifetimeDays(it) }
239
- clientCertHosts?.let { this.clientCertHosts(it) }
327
+ certHosts?.let { this.clientCertHosts(it) }
240
328
  enrollmentEndpoint?.let { this.enrollmentEndpoint(it) }
241
329
  clientCertEndpoint?.let { this.clientCertEndpoint(it) }
242
330
  vaultReportEndpoint?.let { this.vaultReportEndpoint(it) }
243
331
  clientCertLabel?.let { this.clientCertLabel(it) }
244
332
  wantPinsFor?.let { this.wantPinsFor(*it.toTypedArray()) }
245
- renewalUrl?.let { this.renewalUrl(it) }
246
- enrollmentUrl?.let { this.enrollmentUrl(it) }
333
+ fixedRenewalUrl?.let { this.renewalUrl(it) }
334
+ fixedEnrollmentUrl?.let { this.enrollmentUrl(it) }
247
335
  threshold?.let { this.clientCertRenewalThreshold(it) }
248
336
  if (disableRenewal) this.disableClientCertRenewal()
249
337
  if (attestation) this.attestation()
250
338
  attestationInterval?.let { this.attestationInterval(it.first, it.second) }
251
339
  tokenHosts?.let { this.tokenHosts(it) }
340
+ if (proof) this.proofOfPossession()
252
341
  }
253
342
  }
254
343
 
255
- private fun addVaultFile(builder: PinVaultConfig.Builder, f: Fields, tokens: VaultTokenStore) {
344
+ private fun addVaultFile(builder: PinVaultConfig.Builder, f: Fields, tokens: VaultTokenStore, native: NativeSecurity?) {
256
345
  val key = f.requireString("key", 128)
346
+ // The same rule as the library's (the key names the stored copy's file).
347
+ if (!VAULT_KEY.matches(key) || key.all { it == '.' }) {
348
+ throw BridgeInputException("${f.path}.key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
349
+ }
257
350
  val endpoint = f.requireString("endpoint", 512)
258
- val signatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
351
+ if (endpoint.contains("://") || endpoint.startsWith("/") || endpoint.startsWith("\\")) {
352
+ throw BridgeInputException("${f.path}.endpoint: must be a relative path, not an absolute URL")
353
+ }
354
+ val jsSignatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
259
355
  val updateWithPins = f.bool("updateWithPins")
260
356
  val storage = f.enum("storage", StorageStrategy.values())
261
357
  val configApi = f.string("configApi", 128)
262
358
  val policy = f.enum("accessPolicy", VaultFileAccessPolicy.values())
263
- val encryption = f.enum("encryption", VaultFileEncryption.values())
264
- val userAuth = f.enum("userAuth", UserAuth.values())
359
+ val jsEncryption = f.enum("encryption", VaultFileEncryption.values())
360
+ val jsUserAuth = f.enum("userAuth", UserAuth.values())
265
361
  val maxOfflineAge = f.obj("maxOfflineAge")?.let(::duration)
266
362
  val wipeWhenStale = f.bool("wipeWhenStale") == true
267
363
  f.finish()
268
364
 
365
+ // A file the native security file names keeps its signature key, encryption and lock.
366
+ val declared = native?.vaultFiles?.let { files ->
367
+ files[key] ?: throw BridgeInputException("${f.path}: vault file '$key' is not declared in the app's native security file (${native.source})")
368
+ }
369
+ fun fixed(field: String): Nothing =
370
+ throw BridgeInputException("${f.path}.$field: differs from the app's native security file (${native?.source}); the native value is fixed")
371
+ val signatureKey = declared?.signaturePublicKey?.also { n ->
372
+ if (jsSignatureKey != null && jsSignatureKey.trim() != n.trim()) fixed("signaturePublicKey")
373
+ } ?: jsSignatureKey
374
+ val encryption = declared?.encryption?.also { if (jsEncryption != null && jsEncryption != it) fixed("encryption") } ?: jsEncryption
375
+ val userAuth = declared?.userAuth?.also { if (jsUserAuth != null && jsUserAuth != it) fixed("userAuth") } ?: jsUserAuth
376
+ // The tighter of the file's own cap and the config-wide one: a per-file value
377
+ // overrides the config's, so it must not escape the config-wide cap either.
378
+ val cap = listOfNotNull(declared?.maxOfflineAgeMs, native?.require?.vaultFileMaxOfflineAgeMs).minOrNull()
379
+ cap?.let { max ->
380
+ maxOfflineAge?.let { (amount, unit) ->
381
+ val ms = unit.toMillis(amount)
382
+ // 0 = no limit: longer than any.
383
+ if (ms == 0L || ms > max) {
384
+ throw BridgeInputException("${f.path}.maxOfflineAge: longer than the app's native security file allows (${max / 1000} s)")
385
+ }
386
+ }
387
+ }
388
+ val offlineAge = maxOfflineAge ?: declared?.maxOfflineAgeMs?.let { it to TimeUnit.MILLISECONDS }
389
+
269
390
  builder.vaultFile(key) {
270
391
  this.endpoint(endpoint)
271
392
  signatureKey?.let { this.signaturePublicKey(it) }
@@ -279,7 +400,7 @@ internal object ConfigParser {
279
400
  }
280
401
  encryption?.let { this.encryption(it) }
281
402
  userAuth?.let { this.userAuth(it) }
282
- maxOfflineAge?.let { this.maxOfflineAge(it.first, it.second) }
403
+ offlineAge?.let { this.maxOfflineAge(it.first, it.second) }
283
404
  if (wipeWhenStale) this.wipeWhenStale()
284
405
  }
285
406
  }
@@ -4,6 +4,8 @@ import android.content.Context
4
4
  import android.content.pm.ApplicationInfo
5
5
  import io.github.umutcansu.pinvault.model.CertificateConfig
6
6
  import io.github.umutcansu.pinvault.model.HostPin
7
+ import io.github.umutcansu.pinvault.model.UserAuth
8
+ import io.github.umutcansu.pinvault.model.VaultFileEncryption
7
9
  import java.io.FileNotFoundException
8
10
  import java.io.IOException
9
11
 
@@ -22,9 +24,19 @@ import java.io.IOException
22
24
  * "signaturePublicKeys": ["…", "…"], "requiredSignatures": 2,
23
25
  * "recoveryPublicKeys": ["…"], "requiredRecoverySignatures": 1,
24
26
  * "serverScope": "default-tls", "clientCaPins": ["…"],
27
+ * "url": "https://config.example.com/", "attestation": true,
28
+ * "tokenHosts": ["api.example.com"], "proofOfPossession": true, "clientCertHosts": ["api.example.com:443"],
29
+ * "enrollmentUrl": "https://enroll.example.com/", "renewalUrl": "https://renew.example.com/",
25
30
  * "allowUnsigned": false, "allowUnpinnedConfigApi": false, "allowServerGeneratedKey": false
26
31
  * }],
27
- * "staticPins": { "pins": [ … ], "version": 1 }
32
+ * "staticPins": { "pins": [ … ], "version": 1 },
33
+ * "require": {
34
+ * "requireUnlockedDevice": true, "requireHardwareBackedKeys": true,
35
+ * "requirePinnedReactNativeNetworking": true, "managedTrustRoots": true,
36
+ * "wipeVaultFilesOnRevocation": true, "requireCaTrust": ["api.example.com"],
37
+ * "expectedSignerSha256": ["…"], "expiredConfigGraceSeconds": 0, "vaultFileMaxOfflineAgeSeconds": 604800
38
+ * },
39
+ * "vaultFiles": [{ "key": "statement", "signaturePublicKey": "…", "encryption": "USER_AUTH", "userAuth": "REQUIRED", "maxOfflineAgeSeconds": 86400 }]
28
40
  * }
29
41
  * ```
30
42
  *
@@ -35,18 +47,59 @@ import java.io.IOException
35
47
  * - a field the file declares is the value: JS may leave it out or repeat it,
36
48
  * a different value is refused (`E_INVALID_CONFIG`);
37
49
  * - `allowUnsigned`, `allowUnpinnedConfigApi`, `allowServerGeneratedKey` from
38
- * JS are refused unless the file allows them for that block.
50
+ * JS are refused unless the file allows them for that block;
51
+ * - `url`, `tokenHosts` and `clientCertHosts` of a block are fixed where the
52
+ * file gives them, and `attestation: true` / `proofOfPossession: true` there
53
+ * cannot be switched off;
54
+ * - `require` only tightens: a protection it turns on stays on whatever JS
55
+ * says, `requireCaTrust` hosts are added to JS's, `expectedSignerSha256` is
56
+ * fixed, and `expiredConfigGraceSeconds` is the most JS may ask for;
57
+ * - `vaultFiles` fixes those files' signature key, encryption and screen lock;
58
+ * with the section present, a JS vault file it does not name is refused.
39
59
  *
40
- * Without the file, release builds (the app is not debuggable) refuse those
41
- * three relaxations from JS; debug builds take them as before.
60
+ * Without the file, release builds (the app is not debuggable) refuse to start
61
+ * a config with Config APIs or static pins: the trust anchors would come from
62
+ * JS alone. An app that accepts that says so natively, in its manifest
63
+ * (`<meta-data android:name="io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE" android:value="true"/>`);
64
+ * the three relaxations stay refused from JS then. Debug builds take
65
+ * everything as before. Whatever the file says, a release build caps
66
+ * `expiredConfigGrace` at [MAX_RELEASE_GRACE_MS].
42
67
  */
43
68
  internal class NativeSecurity(
44
69
  /** Where the file came from, for messages. */
45
70
  val source: String,
46
71
  val blocks: Map<String, Block>,
47
72
  val staticPins: CertificateConfig?,
73
+ val require: Require = Require(),
74
+ /** Null when the file has no `vaultFiles` section. */
75
+ val vaultFiles: Map<String, VaultFilePolicy>? = null,
48
76
  ) {
49
77
 
78
+ /** The `require` section: protections JS cannot switch off. */
79
+ class Require(
80
+ val requireUnlockedDevice: Boolean = false,
81
+ val requireHardwareBackedKeys: Boolean = false,
82
+ /** Pins `requirePinnedReactNativeNetworking`: JS cannot set it false (RN-3). */
83
+ val requirePinnedReactNativeNetworking: Boolean = false,
84
+ val managedTrustRoots: Boolean = false,
85
+ val wipeVaultFilesOnRevocation: Boolean = false,
86
+ val requireCaTrust: List<String>? = null,
87
+ val expectedSignerSha256: List<String>? = null,
88
+ /** The most `expiredConfigGrace` JS may set; null = only the release cap. */
89
+ val expiredConfigGraceMs: Long? = null,
90
+ /** The most `vaultFileMaxOfflineAge` JS may set (and the value when JS sets none); null = no rule. */
91
+ val vaultFileMaxOfflineAgeMs: Long? = null,
92
+ )
93
+
94
+ /** One `vaultFiles` entry: what JS cannot change about that file. */
95
+ class VaultFilePolicy(
96
+ val signaturePublicKey: String?,
97
+ val encryption: VaultFileEncryption?,
98
+ val userAuth: UserAuth?,
99
+ /** The most `maxOfflineAge` JS may give this file (and the value when JS gives none). */
100
+ val maxOfflineAgeMs: Long? = null,
101
+ )
102
+
50
103
  class Block(
51
104
  val bootstrapPins: List<HostPin>?,
52
105
  val signaturePublicKeys: List<String>?,
@@ -58,10 +111,25 @@ internal class NativeSecurity(
58
111
  val allowUnsigned: Boolean,
59
112
  val allowUnpinnedConfigApi: Boolean,
60
113
  val allowServerGeneratedKey: Boolean,
114
+ val url: String? = null,
115
+ val attestation: Boolean = false,
116
+ val tokenHosts: List<String>? = null,
117
+ val proofOfPossession: Boolean = false,
118
+ val clientCertHosts: List<String>? = null,
119
+ val enrollmentUrl: String? = null,
120
+ val renewalUrl: String? = null,
61
121
  )
62
122
 
63
123
  companion object {
64
124
  const val ASSET_NAME = "pinvault_security.json"
125
+ const val NO_FILE_META_DATA = "io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE"
126
+ /** The longest `expiredConfigGrace` a release build takes: an expired pin set must not live on for months. */
127
+ const val MAX_RELEASE_GRACE_MS = 7L * 24 * 3600 * 1000
128
+ private const val MAX_OFFLINE_AGE_S = 10L * 365 * 24 * 3600
129
+
130
+ private fun httpsOnly(b: Fields, key: String): String? = b.string(key, 2048)?.also {
131
+ if (!it.startsWith("https://")) throw BridgeInputException("${b.path}.$key: must be an https:// URL")
132
+ }
65
133
  private const val MAX_FILE_CHARS = 256 * 1024
66
134
 
67
135
  /** The file from the app's assets; null when the app ships none. A broken file throws [BridgeInputException]. */
@@ -77,6 +145,15 @@ internal class NativeSecurity(
77
145
  return parse(text, "assets/$ASSET_NAME")
78
146
  }
79
147
 
148
+ /** True when the app's manifest says it starts without a native security file in release. */
149
+ fun noFileAllowed(context: Context): Boolean = try {
150
+ @Suppress("DEPRECATION")
151
+ context.packageManager.getApplicationInfo(context.packageName, android.content.pm.PackageManager.GET_META_DATA)
152
+ .metaData?.getBoolean(NO_FILE_META_DATA, false) == true
153
+ } catch (_: Exception) {
154
+ false
155
+ }
156
+
80
157
  /** True for a release build of the app (not `android:debuggable`). */
81
158
  fun isReleaseBuild(context: Context): Boolean =
82
159
  (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
@@ -103,15 +180,57 @@ internal class NativeSecurity(
103
180
  allowUnsigned = b.bool("allowUnsigned") == true,
104
181
  allowUnpinnedConfigApi = b.bool("allowUnpinnedConfigApi") == true,
105
182
  allowServerGeneratedKey = b.bool("allowServerGeneratedKey") == true,
183
+ url = httpsOnly(b, "url"),
184
+ attestation = b.bool("attestation") == true,
185
+ tokenHosts = b.stringList("tokenHosts", 64, 255),
186
+ proofOfPossession = b.bool("proofOfPossession") == true,
187
+ clientCertHosts = b.stringList("clientCertHosts", 64, 2048),
188
+ enrollmentUrl = httpsOnly(b, "enrollmentUrl"),
189
+ renewalUrl = httpsOnly(b, "renewalUrl"),
106
190
  )
107
191
  b.finish()
108
192
  }
109
193
  val staticPins = root.obj("staticPins")?.let(ConfigParser::staticPins)
194
+ val require = root.obj("require")?.let { r ->
195
+ Require(
196
+ requireUnlockedDevice = r.bool("requireUnlockedDevice") == true,
197
+ requireHardwareBackedKeys = r.bool("requireHardwareBackedKeys") == true,
198
+ requirePinnedReactNativeNetworking = r.bool("requirePinnedReactNativeNetworking") == true,
199
+ managedTrustRoots = r.bool("managedTrustRoots") == true,
200
+ wipeVaultFilesOnRevocation = r.bool("wipeVaultFilesOnRevocation") == true,
201
+ requireCaTrust = r.stringList("requireCaTrust", 64, 255),
202
+ expectedSignerSha256 = r.stringList("expectedSignerSha256", 16, 128),
203
+ expiredConfigGraceMs = r.long("expiredConfigGraceSeconds", 0, MAX_RELEASE_GRACE_MS / 1000)?.let { it * 1000 },
204
+ vaultFileMaxOfflineAgeMs = r.long("vaultFileMaxOfflineAgeSeconds", 1, MAX_OFFLINE_AGE_S)?.let { it * 1000 },
205
+ ).also {
206
+ // iOS-only (screen-lock strength): read for the shape, used by the iOS side.
207
+ r.string("userAuthStrength", 32)
208
+ // iOS-only (bundle and team ids): read for the shape, used by the iOS side.
209
+ r.stringList("expectedBundleIds", 16, 255)
210
+ r.stringList("expectedTeamIds", 16, 32)
211
+ r.finish()
212
+ }
213
+ } ?: Require()
214
+ val vaultFiles = root.objList("vaultFiles", ConfigParser.MAX_VAULT_FILES)?.let { list ->
215
+ val map = LinkedHashMap<String, VaultFilePolicy>()
216
+ list.forEach { f ->
217
+ val key = f.requireString("key", 128)
218
+ if (key in map) throw BridgeInputException("${f.path}.key: '$key' is declared twice")
219
+ map[key] = VaultFilePolicy(
220
+ signaturePublicKey = f.string("signaturePublicKey", ConfigParser.KEY_LENGTH, multiline = true),
221
+ encryption = f.enum("encryption", VaultFileEncryption.values()),
222
+ userAuth = f.enum("userAuth", UserAuth.values()),
223
+ maxOfflineAgeMs = f.long("maxOfflineAgeSeconds", 1, MAX_OFFLINE_AGE_S)?.let { it * 1000 },
224
+ )
225
+ f.finish()
226
+ }
227
+ map
228
+ }
110
229
  root.finish()
111
230
  if (blocks.isEmpty() && staticPins == null) {
112
231
  throw BridgeInputException("$source: declares neither configApis nor staticPins")
113
232
  }
114
- return NativeSecurity(source, blocks, staticPins)
233
+ return NativeSecurity(source, blocks, staticPins, require, vaultFiles)
115
234
  } catch (e: BridgeInputException) {
116
235
  val message = e.message ?: ""
117
236
  throw BridgeInputException(if (message.startsWith("native security file")) message else "native security file $message")
@@ -180,6 +299,16 @@ internal object SecurityPolicy {
180
299
  relaxation("allowUnpinnedConfigApi", js.allowUnpinned, block.allowUnpinnedConfigApi)
181
300
  relaxation("allowServerGeneratedKey", js.allowServerKey, block.allowServerGeneratedKey)
182
301
 
302
+ // A release build's trust anchors come from the file, so a declared block
303
+ // must carry them: a bare `{"id": …}` would hand pins and keys to the bundle.
304
+ if (release) {
305
+ fun anchor(key: String, relaxation: String): Nothing = throw BridgeInputException(
306
+ "$path.$key: a release build takes it only from the app's native security file (${native.source}); " +
307
+ "declare $key for Config API '$id' there (or $relaxation)")
308
+ if (block.bootstrapPins == null && !block.allowUnpinnedConfigApi) anchor("bootstrapPins", "allowUnpinnedConfigApi")
309
+ if (block.signaturePublicKeys == null && !block.allowUnsigned) anchor("signaturePublicKeys", "allowUnsigned")
310
+ }
311
+
183
312
  val pins = block.bootstrapPins?.also { n ->
184
313
  if (js.bootstrapPins != null && !NativeSecurity.samePins(js.bootstrapPins, n)) fixed("bootstrapPins")
185
314
  } ?: js.bootstrapPins
@@ -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}$")