@umutcansu/react-native-pinvault 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0 — 2026-10-09
4
+
5
+ ### Breaking
6
+
7
+ - **Release builds need the native security file** when the config has Config
8
+ APIs or static pins (Android manifest meta-data
9
+ `io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE` / iOS Info.plist
10
+ `PinVaultAllowNoNativeSecurityFile` = YES opts out).
11
+ - **`requirePinnedReactNativeNetworking` is on by default in release builds.**
12
+ - **`expiredConfigGrace` is at most 7 days in release builds.**
13
+ - **Vault file keys** must match `[A-Za-z0-9._-]{1,64}` (not only dots).
14
+
15
+ ### Security
16
+
17
+ - The native security file may also fix a block's `url`, `tokenHosts`,
18
+ `clientCertHosts` and `attestation`, carry a `require` section (protections
19
+ JS cannot switch off) and `vaultFiles` (signature key, encryption, lock).
20
+ - `start` answers `nativeSecurityApplied`.
21
+ - `proofOfPossession` on a Config API (JS or the native security file, where
22
+ it cannot be switched off): the native clients add a `PinVault-Proof` signed
23
+ by the device key to every request that carries the token (ATTESTATION.md §5.1).
24
+ - Android: React Native's long-lived clients (WebSocket, images) run the
25
+ current start's network interceptors (the per-request pin check) and their
26
+ pooled connections are closed on every start and reset; a hook installed only
27
+ by `start()` is reported as not pinned; a hook replaced after start refuses
28
+ https while `requirePinnedReactNativeNetworking` is on.
29
+ - The JS guard of a start the native side refuses is put back.
30
+ - `LOAD_FILE` is a guarded operation (`loadFile`).
31
+
3
32
  ## 2.3.2 — 2026-10-08 — first release
4
33
 
5
34
  First published version (2.3.1 was never published). Needs PinVault 2.3.2 on both platforms.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # PinVault for React Native
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@umutcansu/react-native-pinvault)](https://www.npmjs.com/package/@umutcansu/react-native-pinvault)
4
+
3
5
  `@umutcansu/react-native-pinvault` — dynamic certificate pinning with a signed,
4
6
  remotely updated pin config, mTLS enrollment with a hardware identity key,
5
7
  signed and encrypted vault files, and device attestation, for React Native
@@ -11,7 +13,7 @@ It is a thin bridge over the two native libraries — Android
11
13
  Secure Enclave), signature checks and vault decryption all stay native: no
12
14
  crypto runs in JavaScript, and JavaScript never sees TLS. Names are the native
13
15
  ones (`InitResult`, `ClientCertEnrollmentResult`, `VaultFileResult`, …), so the
14
- [library documentation](https://github.com/umutcansu/PinVault/blob/main/README.md) applies as it is.
16
+ [library documentation](https://github.com/umutcansu/PinVault/blob/main/GUIDE.md) applies as it is.
15
17
 
16
18
  - React Native **0.87+**, New Architecture only (TurboModule, codegen), Hermes.
17
19
  - Android minSdk 24; iOS 16+.
@@ -19,11 +21,18 @@ ones (`InitResult`, `ClientCertEnrollmentResult`, `VaultFileResult`, …), so th
19
21
 
20
22
  ## Install
21
23
 
24
+ From npm ([`@umutcansu/react-native-pinvault`](https://www.npmjs.com/package/@umutcansu/react-native-pinvault)):
25
+
22
26
  ```bash
23
27
  npm install @umutcansu/react-native-pinvault
24
28
  cd ios && pod install
25
29
  ```
26
30
 
31
+ The native libraries come with it, at the package's own version: npm 2.3.2
32
+ uses `io.github.umutcansu:pinvault:2.3.2` from Maven Central and the Swift
33
+ package at tag `v2.3.2`. The New Architecture must stay on (React Native
34
+ 0.87's default).
35
+
27
36
  **iOS.** The pod depends on the PinVault Swift package through React Native's
28
37
  `spm_dependency` (git URL `https://github.com/umutcansu/PinVault.git`, exact
29
38
  version = this package's version, product `PinVault`). Info.plist, as for the
@@ -93,7 +102,7 @@ names), enum values are the Kotlin constant names (`'TOKEN_MTLS'`,
93
102
  ```ts
94
103
  {
95
104
  configApis: [{ id, url, bootstrapPins, signaturePublicKeys, requiredSignatures, recoveryPublicKeys,
96
- serverScope, clientCaPins, clientCertHosts, renewalUrl, attestation, tokenHosts, … }],
105
+ serverScope, clientCaPins, clientCertHosts, renewalUrl, attestation, tokenHosts, proofOfPossession, … }],
97
106
  vaultFiles: [{ key, endpoint, configApi, accessPolicy, encryption, userAuth, storage,
98
107
  maxOfflineAge: { amount: 7, unit: 'DAYS' }, … }],
99
108
  requireCaTrust, requireUnlockedDevice, requireHardwareBackedKeys, expectedSignerSha256,
@@ -167,9 +176,25 @@ signature. Ship them natively as well, in `pinvault_security.json`:
167
176
  "signaturePublicKeys": ["…", "…", "…"], "requiredSignatures": 2,
168
177
  "recoveryPublicKeys": ["…"], "requiredRecoverySignatures": 1,
169
178
  "serverScope": "default-tls", "clientCaPins": ["…"],
179
+ "url": "https://config.example.com:8081/", "attestation": true,
180
+ "tokenHosts": ["api.example.com"], "proofOfPossession": true, "clientCertHosts": ["api.example.com:443"],
181
+ "enrollmentUrl": "https://enroll.example.com/", "renewalUrl": "https://renew.example.com/",
170
182
  "allowUnsigned": false, "allowUnpinnedConfigApi": false, "allowServerGeneratedKey": false
171
183
  }],
172
- "staticPins": { "pins": [ … ], "version": 1 }
184
+ "staticPins": { "pins": [ … ], "version": 1 },
185
+ "require": {
186
+ "requireUnlockedDevice": true, "requireHardwareBackedKeys": true,
187
+ "managedTrustRoots": true, "wipeVaultFilesOnRevocation": true,
188
+ "requireCaTrust": ["api.example.com"],
189
+ "expectedSignerSha256": ["…"],
190
+ "expectedBundleIds": ["com.example.app"], "expectedTeamIds": ["ABCDE12345"],
191
+ "expiredConfigGraceSeconds": 0, "vaultFileMaxOfflineAgeSeconds": 604800,
192
+ "userAuthStrength": "BIOMETRIC_CURRENT_SET"
193
+ },
194
+ "vaultFiles": [
195
+ { "key": "statement", "signaturePublicKey": "…", "encryption": "USER_AUTH", "userAuth": "REQUIRED",
196
+ "maxOfflineAgeSeconds": 86400 }
197
+ ]
173
198
  }
174
199
  ```
175
200
 
@@ -183,11 +208,32 @@ parsed as strictly; a broken file rejects `start` with `E_INVALID_CONFIG`
183
208
  applies) or repeat it (lists in any order); a different value rejects `start`
184
209
  with `E_INVALID_CONFIG` naming the field;
185
210
  - `allowUnsigned`, `allowUnpinnedConfigApi` and `allowServerGeneratedKey` from
186
- JS are refused unless the file allows them for that block.
211
+ JS are refused unless the file allows them for that block;
212
+ - a block's `url`, `enrollmentUrl`, `renewalUrl`, `tokenHosts` and
213
+ `clientCertHosts` are fixed where the file gives them (where the block talks to, and who gets its token and identity),
214
+ and `attestation: true` / `proofOfPossession: true` there cannot be turned off from JS;
215
+ - `require` only tightens: a protection it turns on stays on whatever JS says,
216
+ its `requireCaTrust` hosts are added to JS's, the expected signer / bundle /
217
+ team ids and iOS `userAuthStrength` are fixed, and `expiredConfigGraceSeconds`
218
+ / `vaultFileMaxOfflineAgeSeconds` are the most JS may ask for (the value when
219
+ JS asks for none)
220
+ (Android reads `expectedSignerSha256`, iOS the bundle and team ids; one file
221
+ can serve both);
222
+ - `vaultFiles` fixes those files' signature key, encryption and screen lock, and
223
+ `maxOfflineAgeSeconds` is the longest offline lifetime JS may give them;
224
+ with the section present, a JS vault file it does not name is refused.
225
+
226
+ `start` answers `nativeSecurityApplied: true` when the file was applied.
187
227
 
188
228
  Without the file, **release builds** (Android: the app is not
189
- `android:debuggable`; iOS: compiled without `DEBUG`) refuse those three
190
- relaxations from JS; debug builds take them as before. The sample app writes
229
+ `android:debuggable`; iOS: compiled without `DEBUG`) refuse to start a config
230
+ with Config APIs or static pins: its trust anchors would come from JS alone.
231
+ An app that accepts that says so natively — Android:
232
+ `<meta-data android:name="io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE" android:value="true"/>`
233
+ in the manifest's `<application>`; iOS: `PinVaultAllowNoNativeSecurityFile` =
234
+ YES in Info.plist — and the three relaxations stay refused from JS then. Debug
235
+ builds take everything as before. Whatever the file says, a release build
236
+ takes an `expiredConfigGrace` of at most 7 days. The sample app writes
191
237
  the file for its release builds from `sample-host.properties`
192
238
  (`scripts/gen-host-config.js --native-out`).
193
239
 
@@ -214,9 +260,11 @@ signer check of attestation (`expectedSignerSha256`, `expectedTeamIds`) reports.
214
260
  - Hosts without a pin entry are refused like everywhere else in PinVault.
215
261
  - No `https` → `http` redirects; no disk HTTP cache and no cookie jar (Android:
216
262
  `android.keepReactNativeHttpCache` / `keepReactNativeCookies` keep RN's own).
217
- - `requirePinnedReactNativeNetworking: true` makes `start` fail with
218
- `E_NETWORKING_NOT_PINNED` when RN's networking does not go through PinVault
219
- (below); without it a warning is logged.
263
+ - `requirePinnedReactNativeNetworking` (on by default in release builds)
264
+ makes `start` fail with `E_NETWORKING_NOT_PINNED` when RN's networking does
265
+ not go through PinVault (below); on Android a hook replaced after start
266
+ then refuses RN's https too (checked every few seconds). With it off a
267
+ warning is logged.
220
268
  - **Android** (two hooks of RN 0.87, read from its sources), installed by the
221
269
  plugin's content provider before `Application.onCreate`, so every client RN
222
270
  builds is covered:
@@ -277,7 +325,10 @@ value until the garbage collector drops it, and if it went through React
277
325
  state, a form field or a store, it lives there too. Pass it straight from where
278
326
  it came from, clear the field, and never persist or log it. For an attestation
279
327
  `PinVault-Token`, prefer `tokenHosts` + `PinVault.fetch` (the native client adds
280
- it) over `fetchAttestationToken`, which hands the token to JS. Forget vault
328
+ it) over `fetchAttestationToken`, which hands the token to JS. With
329
+ `proofOfPossession` the native client also adds a `PinVault-Proof` signed by the
330
+ device key on every such request, and a backend that requires it refuses the
331
+ token alone — a token in JS is then of no use without the native client. Forget vault
281
332
  tokens on a revocation:
282
333
 
283
334
  ```ts
@@ -307,10 +358,10 @@ environmentGuard: async (operation) => !(await myRaspSaysCompromised()),
307
358
  environmentGuardTimeoutMs: 5000,
308
359
  ```
309
360
 
310
- Asked before `INIT`, `ENROLL`, `FETCH_FILE` and `UNLOCK_FILE`. On iOS the bridge
311
- asks before it calls the library (`start`: `INIT` and `ENROLL`; enrollment
312
- calls: `ENROLL`; `fetchFile` / `syncAllFiles`: `FETCH_FILE`; `unlockFile`:
313
- `UNLOCK_FILE`), so the wait for JS holds no thread; an operation the library
361
+ Asked before `INIT`, `ENROLL`, `FETCH_FILE`, `UNLOCK_FILE` and `LOAD_FILE`. On iOS
362
+ the bridge asks before it calls the library (`start`: `INIT` and `ENROLL`;
363
+ enrollment calls: `ENROLL`; `fetchFile` / `syncAllFiles`: `FETCH_FILE`;
364
+ `unlockFile`: `UNLOCK_FILE`; `loadFile`: `LOAD_FILE`), so the wait for JS holds no thread; an operation the library
314
365
  starts on its own (a background refresh) still waits on its own thread,
315
366
  bounded by the timeout. **Fail closed:**
316
367
  a timeout (native deadline 100–30 000 ms, default 5000), a thrown error, a
@@ -4,7 +4,7 @@ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
4
 
5
5
  # PinVault for iOS is a Swift package (Package.swift at the repository root,
6
6
  # product "PinVault"). React Native's spm_dependency adds it to the Pods project:
7
- # - consumers: the git URL at the tag of this package's version (v2.3.2);
7
+ # - consumers: the git URL at the tag of this package's version (v2.4.0);
8
8
  # - development: PINVAULT_IOS_PACKAGE_PATH = a PinVault checkout (the sample's
9
9
  # Podfile sets it to this repository), the counterpart of pinvault.localPath.
10
10
  pinvault_url = "https://github.com/umutcansu/PinVault.git"
@@ -9,7 +9,7 @@ buildscript {
9
9
  kotlinVersion: "2.2.0",
10
10
  minSdkVersion: 24,
11
11
  compileSdkVersion: 36,
12
- pinvaultVersion: "2.3.2",
12
+ pinvaultVersion: "2.4.0",
13
13
  ]
14
14
  ext.pinvaultRnExt = { prop ->
15
15
  rootProject.ext.has(prop) ? rootProject.ext.get(prop) : PinVaultRN[prop]
@@ -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,8 @@ 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
+ val requirePinned = root.bool("requirePinnedReactNativeNetworking") ?: (release && pinGlobal)
113
156
  if (requirePinned && !pinGlobal) {
114
157
  throw BridgeInputException("config.requirePinnedReactNativeNetworking: contradicts android.pinGlobalNetworking: false")
115
158
  }
@@ -206,11 +249,34 @@ internal object ConfigParser {
206
249
  val enrollmentUrl = httpsUrl(b, "enrollmentUrl")
207
250
  val threshold = b.double("clientCertRenewalThreshold", 0.0, 1.0)
208
251
  val disableRenewal = b.bool("disableClientCertRenewal") == true
209
- val attestation = b.bool("attestation") == true
252
+ val jsAttestation = b.bool("attestation")
210
253
  val attestationInterval = b.obj("attestationInterval")?.let(::duration)
211
- val tokenHosts = b.stringList("tokenHosts", 64, 255)
254
+ val jsTokenHosts = b.stringList("tokenHosts", 64, 255)
255
+ val jsProof = b.bool("proofOfPossession")
212
256
  b.finish()
213
257
 
258
+ // Where the block talks to and who gets its token and identity: fixed where the file says.
259
+ val nativeBlock = native?.blocks?.get(id)
260
+ fun fixed(key: String): Nothing =
261
+ throw BridgeInputException("${b.path}.$key: differs from the app's native security file (${native?.source}); the native value is fixed")
262
+ val blockUrl = nativeBlock?.url?.also { if (it != url) fixed("url") } ?: url
263
+ val fixedEnrollmentUrl = nativeBlock?.enrollmentUrl?.also { if (enrollmentUrl != null && it != enrollmentUrl) fixed("enrollmentUrl") } ?: enrollmentUrl
264
+ val fixedRenewalUrl = nativeBlock?.renewalUrl?.also { if (renewalUrl != null && it != renewalUrl) fixed("renewalUrl") } ?: renewalUrl
265
+ if (nativeBlock?.attestation == true && jsAttestation == false) {
266
+ throw BridgeInputException("${b.path}.attestation: the app's native security file turns it on; JS cannot turn it off")
267
+ }
268
+ val attestation = jsAttestation == true || nativeBlock?.attestation == true
269
+ if (nativeBlock?.proofOfPossession == true && jsProof == false) {
270
+ throw BridgeInputException("${b.path}.proofOfPossession: the app's native security file turns it on; JS cannot turn it off")
271
+ }
272
+ val proof = jsProof == true || nativeBlock?.proofOfPossession == true
273
+ val tokenHosts = nativeBlock?.tokenHosts?.also { n ->
274
+ if (jsTokenHosts != null && !NativeSecurity.sameKeys(jsTokenHosts.map(String::lowercase), n.map(String::lowercase))) fixed("tokenHosts")
275
+ } ?: jsTokenHosts
276
+ val certHosts = nativeBlock?.clientCertHosts?.also { n ->
277
+ if (clientCertHosts != null && !NativeSecurity.sameKeys(clientCertHosts.map(String::lowercase), n.map(String::lowercase))) fixed("clientCertHosts")
278
+ } ?: clientCertHosts
279
+
214
280
  // The trust anchors and relaxations: the native security file decides, JS only repeats.
215
281
  val sec = SecurityPolicy.apply(
216
282
  b.path, id,
@@ -221,7 +287,7 @@ internal object ConfigParser {
221
287
  native, release,
222
288
  )
223
289
 
224
- builder.configApi(id, url) {
290
+ builder.configApi(id, blockUrl) {
225
291
  sec.bootstrapPins?.let { this.bootstrapPins(it) }
226
292
  configEndpoint?.let { this.configEndpoint(it) }
227
293
  healthEndpoint?.let { this.healthEndpoint(it) }
@@ -236,36 +302,66 @@ internal object ConfigParser {
236
302
  if (sec.allowServerKey) this.allowServerGeneratedKey()
237
303
  sec.clientCaPins?.let { this.clientCaPins(it) }
238
304
  maxLifetime?.let { this.maxClientCertLifetimeDays(it) }
239
- clientCertHosts?.let { this.clientCertHosts(it) }
305
+ certHosts?.let { this.clientCertHosts(it) }
240
306
  enrollmentEndpoint?.let { this.enrollmentEndpoint(it) }
241
307
  clientCertEndpoint?.let { this.clientCertEndpoint(it) }
242
308
  vaultReportEndpoint?.let { this.vaultReportEndpoint(it) }
243
309
  clientCertLabel?.let { this.clientCertLabel(it) }
244
310
  wantPinsFor?.let { this.wantPinsFor(*it.toTypedArray()) }
245
- renewalUrl?.let { this.renewalUrl(it) }
246
- enrollmentUrl?.let { this.enrollmentUrl(it) }
311
+ fixedRenewalUrl?.let { this.renewalUrl(it) }
312
+ fixedEnrollmentUrl?.let { this.enrollmentUrl(it) }
247
313
  threshold?.let { this.clientCertRenewalThreshold(it) }
248
314
  if (disableRenewal) this.disableClientCertRenewal()
249
315
  if (attestation) this.attestation()
250
316
  attestationInterval?.let { this.attestationInterval(it.first, it.second) }
251
317
  tokenHosts?.let { this.tokenHosts(it) }
318
+ if (proof) this.proofOfPossession()
252
319
  }
253
320
  }
254
321
 
255
- private fun addVaultFile(builder: PinVaultConfig.Builder, f: Fields, tokens: VaultTokenStore) {
322
+ private fun addVaultFile(builder: PinVaultConfig.Builder, f: Fields, tokens: VaultTokenStore, native: NativeSecurity?) {
256
323
  val key = f.requireString("key", 128)
324
+ // The same rule as the library's (the key names the stored copy's file).
325
+ if (!VAULT_KEY.matches(key) || key.all { it == '.' }) {
326
+ throw BridgeInputException("${f.path}.key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
327
+ }
257
328
  val endpoint = f.requireString("endpoint", 512)
258
- val signatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
329
+ val jsSignatureKey = f.string("signaturePublicKey", KEY_LENGTH, multiline = true)
259
330
  val updateWithPins = f.bool("updateWithPins")
260
331
  val storage = f.enum("storage", StorageStrategy.values())
261
332
  val configApi = f.string("configApi", 128)
262
333
  val policy = f.enum("accessPolicy", VaultFileAccessPolicy.values())
263
- val encryption = f.enum("encryption", VaultFileEncryption.values())
264
- val userAuth = f.enum("userAuth", UserAuth.values())
334
+ val jsEncryption = f.enum("encryption", VaultFileEncryption.values())
335
+ val jsUserAuth = f.enum("userAuth", UserAuth.values())
265
336
  val maxOfflineAge = f.obj("maxOfflineAge")?.let(::duration)
266
337
  val wipeWhenStale = f.bool("wipeWhenStale") == true
267
338
  f.finish()
268
339
 
340
+ // A file the native security file names keeps its signature key, encryption and lock.
341
+ val declared = native?.vaultFiles?.let { files ->
342
+ files[key] ?: throw BridgeInputException("${f.path}: vault file '$key' is not declared in the app's native security file (${native.source})")
343
+ }
344
+ fun fixed(field: String): Nothing =
345
+ throw BridgeInputException("${f.path}.$field: differs from the app's native security file (${native?.source}); the native value is fixed")
346
+ val signatureKey = declared?.signaturePublicKey?.also { n ->
347
+ if (jsSignatureKey != null && jsSignatureKey.trim() != n.trim()) fixed("signaturePublicKey")
348
+ } ?: jsSignatureKey
349
+ val encryption = declared?.encryption?.also { if (jsEncryption != null && jsEncryption != it) fixed("encryption") } ?: jsEncryption
350
+ val userAuth = declared?.userAuth?.also { if (jsUserAuth != null && jsUserAuth != it) fixed("userAuth") } ?: jsUserAuth
351
+ // The tighter of the file's own cap and the config-wide one: a per-file value
352
+ // overrides the config's, so it must not escape the config-wide cap either.
353
+ val cap = listOfNotNull(declared?.maxOfflineAgeMs, native?.require?.vaultFileMaxOfflineAgeMs).minOrNull()
354
+ cap?.let { max ->
355
+ maxOfflineAge?.let { (amount, unit) ->
356
+ val ms = unit.toMillis(amount)
357
+ // 0 = no limit: longer than any.
358
+ if (ms == 0L || ms > max) {
359
+ throw BridgeInputException("${f.path}.maxOfflineAge: longer than the app's native security file allows (${max / 1000} s)")
360
+ }
361
+ }
362
+ }
363
+ val offlineAge = maxOfflineAge ?: declared?.maxOfflineAgeMs?.let { it to TimeUnit.MILLISECONDS }
364
+
269
365
  builder.vaultFile(key) {
270
366
  this.endpoint(endpoint)
271
367
  signatureKey?.let { this.signaturePublicKey(it) }
@@ -279,7 +375,7 @@ internal object ConfigParser {
279
375
  }
280
376
  encryption?.let { this.encryption(it) }
281
377
  userAuth?.let { this.userAuth(it) }
282
- maxOfflineAge?.let { this.maxOfflineAge(it.first, it.second) }
378
+ offlineAge?.let { this.maxOfflineAge(it.first, it.second) }
283
379
  if (wipeWhenStale) this.wipeWhenStale()
284
380
  }
285
381
  }
@@ -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,18 @@ 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, "managedTrustRoots": true,
35
+ * "wipeVaultFilesOnRevocation": true, "requireCaTrust": ["api.example.com"],
36
+ * "expectedSignerSha256": ["…"], "expiredConfigGraceSeconds": 0, "vaultFileMaxOfflineAgeSeconds": 604800
37
+ * },
38
+ * "vaultFiles": [{ "key": "statement", "signaturePublicKey": "…", "encryption": "USER_AUTH", "userAuth": "REQUIRED", "maxOfflineAgeSeconds": 86400 }]
28
39
  * }
29
40
  * ```
30
41
  *
@@ -35,18 +46,57 @@ import java.io.IOException
35
46
  * - a field the file declares is the value: JS may leave it out or repeat it,
36
47
  * a different value is refused (`E_INVALID_CONFIG`);
37
48
  * - `allowUnsigned`, `allowUnpinnedConfigApi`, `allowServerGeneratedKey` from
38
- * JS are refused unless the file allows them for that block.
49
+ * JS are refused unless the file allows them for that block;
50
+ * - `url`, `tokenHosts` and `clientCertHosts` of a block are fixed where the
51
+ * file gives them, and `attestation: true` / `proofOfPossession: true` there
52
+ * cannot be switched off;
53
+ * - `require` only tightens: a protection it turns on stays on whatever JS
54
+ * says, `requireCaTrust` hosts are added to JS's, `expectedSignerSha256` is
55
+ * fixed, and `expiredConfigGraceSeconds` is the most JS may ask for;
56
+ * - `vaultFiles` fixes those files' signature key, encryption and screen lock;
57
+ * with the section present, a JS vault file it does not name is refused.
39
58
  *
40
- * Without the file, release builds (the app is not debuggable) refuse those
41
- * three relaxations from JS; debug builds take them as before.
59
+ * Without the file, release builds (the app is not debuggable) refuse to start
60
+ * a config with Config APIs or static pins: the trust anchors would come from
61
+ * JS alone. An app that accepts that says so natively, in its manifest
62
+ * (`<meta-data android:name="io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE" android:value="true"/>`);
63
+ * the three relaxations stay refused from JS then. Debug builds take
64
+ * everything as before. Whatever the file says, a release build caps
65
+ * `expiredConfigGrace` at [MAX_RELEASE_GRACE_MS].
42
66
  */
43
67
  internal class NativeSecurity(
44
68
  /** Where the file came from, for messages. */
45
69
  val source: String,
46
70
  val blocks: Map<String, Block>,
47
71
  val staticPins: CertificateConfig?,
72
+ val require: Require = Require(),
73
+ /** Null when the file has no `vaultFiles` section. */
74
+ val vaultFiles: Map<String, VaultFilePolicy>? = null,
48
75
  ) {
49
76
 
77
+ /** The `require` section: protections JS cannot switch off. */
78
+ class Require(
79
+ val requireUnlockedDevice: Boolean = false,
80
+ val requireHardwareBackedKeys: Boolean = false,
81
+ val managedTrustRoots: Boolean = false,
82
+ val wipeVaultFilesOnRevocation: Boolean = false,
83
+ val requireCaTrust: List<String>? = null,
84
+ val expectedSignerSha256: List<String>? = null,
85
+ /** The most `expiredConfigGrace` JS may set; null = only the release cap. */
86
+ val expiredConfigGraceMs: Long? = null,
87
+ /** The most `vaultFileMaxOfflineAge` JS may set (and the value when JS sets none); null = no rule. */
88
+ val vaultFileMaxOfflineAgeMs: Long? = null,
89
+ )
90
+
91
+ /** One `vaultFiles` entry: what JS cannot change about that file. */
92
+ class VaultFilePolicy(
93
+ val signaturePublicKey: String?,
94
+ val encryption: VaultFileEncryption?,
95
+ val userAuth: UserAuth?,
96
+ /** The most `maxOfflineAge` JS may give this file (and the value when JS gives none). */
97
+ val maxOfflineAgeMs: Long? = null,
98
+ )
99
+
50
100
  class Block(
51
101
  val bootstrapPins: List<HostPin>?,
52
102
  val signaturePublicKeys: List<String>?,
@@ -58,10 +108,25 @@ internal class NativeSecurity(
58
108
  val allowUnsigned: Boolean,
59
109
  val allowUnpinnedConfigApi: Boolean,
60
110
  val allowServerGeneratedKey: Boolean,
111
+ val url: String? = null,
112
+ val attestation: Boolean = false,
113
+ val tokenHosts: List<String>? = null,
114
+ val proofOfPossession: Boolean = false,
115
+ val clientCertHosts: List<String>? = null,
116
+ val enrollmentUrl: String? = null,
117
+ val renewalUrl: String? = null,
61
118
  )
62
119
 
63
120
  companion object {
64
121
  const val ASSET_NAME = "pinvault_security.json"
122
+ const val NO_FILE_META_DATA = "io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE"
123
+ /** The longest `expiredConfigGrace` a release build takes: an expired pin set must not live on for months. */
124
+ const val MAX_RELEASE_GRACE_MS = 7L * 24 * 3600 * 1000
125
+ private const val MAX_OFFLINE_AGE_S = 10L * 365 * 24 * 3600
126
+
127
+ private fun httpsOnly(b: Fields, key: String): String? = b.string(key, 2048)?.also {
128
+ if (!it.startsWith("https://")) throw BridgeInputException("${b.path}.$key: must be an https:// URL")
129
+ }
65
130
  private const val MAX_FILE_CHARS = 256 * 1024
66
131
 
67
132
  /** The file from the app's assets; null when the app ships none. A broken file throws [BridgeInputException]. */
@@ -77,6 +142,15 @@ internal class NativeSecurity(
77
142
  return parse(text, "assets/$ASSET_NAME")
78
143
  }
79
144
 
145
+ /** True when the app's manifest says it starts without a native security file in release. */
146
+ fun noFileAllowed(context: Context): Boolean = try {
147
+ @Suppress("DEPRECATION")
148
+ context.packageManager.getApplicationInfo(context.packageName, android.content.pm.PackageManager.GET_META_DATA)
149
+ .metaData?.getBoolean(NO_FILE_META_DATA, false) == true
150
+ } catch (_: Exception) {
151
+ false
152
+ }
153
+
80
154
  /** True for a release build of the app (not `android:debuggable`). */
81
155
  fun isReleaseBuild(context: Context): Boolean =
82
156
  (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
@@ -103,15 +177,56 @@ internal class NativeSecurity(
103
177
  allowUnsigned = b.bool("allowUnsigned") == true,
104
178
  allowUnpinnedConfigApi = b.bool("allowUnpinnedConfigApi") == true,
105
179
  allowServerGeneratedKey = b.bool("allowServerGeneratedKey") == true,
180
+ url = httpsOnly(b, "url"),
181
+ attestation = b.bool("attestation") == true,
182
+ tokenHosts = b.stringList("tokenHosts", 64, 255),
183
+ proofOfPossession = b.bool("proofOfPossession") == true,
184
+ clientCertHosts = b.stringList("clientCertHosts", 64, 2048),
185
+ enrollmentUrl = httpsOnly(b, "enrollmentUrl"),
186
+ renewalUrl = httpsOnly(b, "renewalUrl"),
106
187
  )
107
188
  b.finish()
108
189
  }
109
190
  val staticPins = root.obj("staticPins")?.let(ConfigParser::staticPins)
191
+ val require = root.obj("require")?.let { r ->
192
+ Require(
193
+ requireUnlockedDevice = r.bool("requireUnlockedDevice") == true,
194
+ requireHardwareBackedKeys = r.bool("requireHardwareBackedKeys") == true,
195
+ managedTrustRoots = r.bool("managedTrustRoots") == true,
196
+ wipeVaultFilesOnRevocation = r.bool("wipeVaultFilesOnRevocation") == true,
197
+ requireCaTrust = r.stringList("requireCaTrust", 64, 255),
198
+ expectedSignerSha256 = r.stringList("expectedSignerSha256", 16, 128),
199
+ expiredConfigGraceMs = r.long("expiredConfigGraceSeconds", 0, MAX_RELEASE_GRACE_MS / 1000)?.let { it * 1000 },
200
+ vaultFileMaxOfflineAgeMs = r.long("vaultFileMaxOfflineAgeSeconds", 1, MAX_OFFLINE_AGE_S)?.let { it * 1000 },
201
+ ).also {
202
+ // iOS-only (screen-lock strength): read for the shape, used by the iOS side.
203
+ r.string("userAuthStrength", 32)
204
+ // iOS-only (bundle and team ids): read for the shape, used by the iOS side.
205
+ r.stringList("expectedBundleIds", 16, 255)
206
+ r.stringList("expectedTeamIds", 16, 32)
207
+ r.finish()
208
+ }
209
+ } ?: Require()
210
+ val vaultFiles = root.objList("vaultFiles", ConfigParser.MAX_VAULT_FILES)?.let { list ->
211
+ val map = LinkedHashMap<String, VaultFilePolicy>()
212
+ list.forEach { f ->
213
+ val key = f.requireString("key", 128)
214
+ if (key in map) throw BridgeInputException("${f.path}.key: '$key' is declared twice")
215
+ map[key] = VaultFilePolicy(
216
+ signaturePublicKey = f.string("signaturePublicKey", ConfigParser.KEY_LENGTH, multiline = true),
217
+ encryption = f.enum("encryption", VaultFileEncryption.values()),
218
+ userAuth = f.enum("userAuth", UserAuth.values()),
219
+ maxOfflineAgeMs = f.long("maxOfflineAgeSeconds", 1, MAX_OFFLINE_AGE_S)?.let { it * 1000 },
220
+ )
221
+ f.finish()
222
+ }
223
+ map
224
+ }
110
225
  root.finish()
111
226
  if (blocks.isEmpty() && staticPins == null) {
112
227
  throw BridgeInputException("$source: declares neither configApis nor staticPins")
113
228
  }
114
- return NativeSecurity(source, blocks, staticPins)
229
+ return NativeSecurity(source, blocks, staticPins, require, vaultFiles)
115
230
  } catch (e: BridgeInputException) {
116
231
  val message = e.message ?: ""
117
232
  throw BridgeInputException(if (message.startsWith("native security file")) message else "native security file $message")