@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.
- package/CHANGELOG.md +69 -0
- package/README.md +116 -36
- package/RNPinVault.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/consumer-rules.pro +3 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +150 -29
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +134 -5
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +12 -3
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +116 -16
- package/ios/Core/ConfigParser.swift +216 -32
- package/ios/Core/NativeSecurity.swift +127 -6
- package/ios/PinVaultBridge.swift +24 -7
- package/ios/PinVaultReactNetworking.swift +16 -0
- package/ios/RNPinVault.mm +6 -0
- package/ios/RNPinVaultWebSocketPinning.h +21 -0
- package/ios/RNPinVaultWebSocketPinning.mm +170 -0
- package/lib/module/index.js +21 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +14 -1
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +15 -2
- package/src/types.ts +14 -3
|
@@ -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,
|
|
62
|
-
*
|
|
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)
|
|
76
|
-
root.objList("vaultFiles", MAX_VAULT_FILES)
|
|
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 ->
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
184
|
-
val healthEndpoint = b
|
|
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
|
|
201
|
-
val clientCertEndpoint = b
|
|
202
|
-
val vaultReportEndpoint = b
|
|
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
|
|
268
|
+
val jsAttestation = b.bool("attestation")
|
|
210
269
|
val attestationInterval = b.obj("attestationInterval")?.let(::duration)
|
|
211
|
-
val
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
264
|
-
val
|
|
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
|
-
|
|
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
|
|
41
|
-
*
|
|
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
|
-
|
|
123
|
+
// Installed here only when the plugin's provider was removed and the app did not
|
|
124
|
+
// install it before React Native started: marked late (HookStatus.installedLate).
|
|
125
|
+
if (parsed.pinGlobalNetworking && !PinVaultNetworking.isInstalled) PinVaultNetworking.install(app, late = true)
|
|
123
126
|
val hooks = PinVaultNetworking.warnIfNotPinned("PinVault.start")
|
|
124
127
|
if (parsed.networking.requirePinned && !hooks.pinned) {
|
|
125
128
|
promise.reject(E_NETWORKING_NOT_PINNED, "requirePinnedReactNativeNetworking: ${hooks.describe()}")
|
|
@@ -142,7 +145,8 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
142
145
|
// Setup failed (InitResult.Failed): RN's https stays refused.
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
|
-
|
|
148
|
+
// Says whether the anchors came from the native security file (README "Native security file").
|
|
149
|
+
promise.resolve(toBridge(mapper.init(result) + ("nativeSecurityApplied" to parsed.nativeSecurityApplied)))
|
|
146
150
|
} catch (e: Exception) {
|
|
147
151
|
promise.reject(E_NATIVE, tokens.redact("${e.javaClass.simpleName}: ${e.message}"), exceptionInfo(e))
|
|
148
152
|
}
|
|
@@ -337,8 +341,11 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
337
341
|
guard?.answer(requestId, allowed)
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
/** A vault file key: the library's rule (it names the stored copy's file). */
|
|
340
345
|
private fun checkKey(key: String) {
|
|
341
|
-
if (
|
|
346
|
+
if (!VAULT_KEY.matches(key) || key.all { it == '.' }) {
|
|
347
|
+
throw BridgeInputException("key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
|
|
348
|
+
}
|
|
342
349
|
}
|
|
343
350
|
|
|
344
351
|
private fun rejectNow(promise: Promise, code: String, message: String): Nothing {
|
|
@@ -408,3 +415,5 @@ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec
|
|
|
408
415
|
}
|
|
409
416
|
}
|
|
410
417
|
}
|
|
418
|
+
|
|
419
|
+
private val VAULT_KEY = Regex("^[A-Za-z0-9._-]{1,64}$")
|