@umutcansu/react-native-pinvault 0.0.0-stage → 2.3.2
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 +55 -0
- package/LICENSE +21 -0
- package/README.md +386 -2
- package/RNPinVault.podspec +43 -0
- package/android/build.gradle +68 -0
- package/android/consumer-rules.pro +25 -0
- package/android/src/main/AndroidManifest.xml +16 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +300 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +239 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
- package/ios/Core/ConfigParser.swift +276 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +197 -0
- package/ios/Core/PinnedFetch.swift +119 -0
- package/ios/Core/ReactNetworking.swift +172 -0
- package/ios/Core/ResultMapper.swift +163 -0
- package/ios/Core/StrictJSON.swift +214 -0
- package/ios/Core/VaultTokenStore.swift +66 -0
- package/ios/PinVaultBridge.swift +412 -0
- package/ios/PinVaultReactNetworking.swift +47 -0
- package/ios/RNPinVault.h +7 -0
- package/ios/RNPinVault.mm +293 -0
- package/ios/RNPinVaultURLRequestHandler.mm +92 -0
- package/lib/module/NativePinVault.js +13 -0
- package/lib/module/NativePinVault.js.map +1 -0
- package/lib/module/index.js +312 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePinVault.d.ts +50 -0
- package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +150 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +346 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +128 -4
- package/src/NativePinVault.ts +72 -0
- package/src/index.ts +385 -0
- package/src/types.ts +351 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONException
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import org.json.JSONTokener
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A refusal of bridge input: unknown key, wrong type, value out of range,
|
|
10
|
+
* oversized input. Its message names the path (`config.configApis[0].url`).
|
|
11
|
+
*/
|
|
12
|
+
class BridgeInputException(message: String) : IllegalArgumentException(message)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strict JSON for bridge input. JSON from JS → plain Kotlin values (Map, List,
|
|
16
|
+
* String, Boolean, Number, null); then [Fields] reads an object key by key and
|
|
17
|
+
* refuses every key it did not read ([Fields.finish]). Nothing is silently
|
|
18
|
+
* ignored and nothing is coerced (a "1" is not a number, a 1 is not a boolean).
|
|
19
|
+
*/
|
|
20
|
+
internal object StrictJson {
|
|
21
|
+
|
|
22
|
+
const val MAX_INPUT_CHARS = 256 * 1024
|
|
23
|
+
const val MAX_DEPTH = 8
|
|
24
|
+
|
|
25
|
+
fun parseObject(json: String, path: String, maxChars: Int = MAX_INPUT_CHARS): Fields {
|
|
26
|
+
if (json.length > maxChars) throw BridgeInputException("$path: larger than $maxChars characters")
|
|
27
|
+
// Before the parser: org.json's tokener recurses once per level, and a
|
|
28
|
+
// StackOverflowError is no Exception the bridge would catch.
|
|
29
|
+
if (nestingDepth(json) > MAX_DEPTH + 1) throw BridgeInputException("$path: nested too deeply")
|
|
30
|
+
val value = try {
|
|
31
|
+
JSONTokener(json).nextValue()
|
|
32
|
+
} catch (e: JSONException) {
|
|
33
|
+
throw BridgeInputException("$path: not valid JSON")
|
|
34
|
+
} catch (e: StackOverflowError) {
|
|
35
|
+
throw BridgeInputException("$path: nested too deeply")
|
|
36
|
+
}
|
|
37
|
+
if (value !is JSONObject) throw BridgeInputException("$path: must be an object")
|
|
38
|
+
@Suppress("UNCHECKED_CAST")
|
|
39
|
+
return Fields(path, toKotlin(value, path, 0) as Map<String, Any?>)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The deepest `[` / `{` nesting of [json], strings skipped; stops counting
|
|
44
|
+
* past the limit. Malformed text is left to the parser.
|
|
45
|
+
*/
|
|
46
|
+
fun nestingDepth(json: String, stopAt: Int = MAX_DEPTH + 2): Int {
|
|
47
|
+
var depth = 0
|
|
48
|
+
var deepest = 0
|
|
49
|
+
var inString = false
|
|
50
|
+
var escaped = false
|
|
51
|
+
for (c in json) {
|
|
52
|
+
if (inString) {
|
|
53
|
+
when {
|
|
54
|
+
escaped -> escaped = false
|
|
55
|
+
c == '\\' -> escaped = true
|
|
56
|
+
c == '"' -> inString = false
|
|
57
|
+
}
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
when (c) {
|
|
61
|
+
'"' -> inString = true
|
|
62
|
+
'[', '{' -> {
|
|
63
|
+
depth++
|
|
64
|
+
if (depth > deepest) {
|
|
65
|
+
deepest = depth
|
|
66
|
+
if (deepest >= stopAt) return deepest
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
']', '}' -> if (depth > 0) depth--
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return deepest
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fun toKotlin(value: Any?, path: String, depth: Int): Any? {
|
|
76
|
+
if (depth > MAX_DEPTH) throw BridgeInputException("$path: nested too deeply")
|
|
77
|
+
return when (value) {
|
|
78
|
+
null, JSONObject.NULL -> null
|
|
79
|
+
is JSONObject -> {
|
|
80
|
+
val out = LinkedHashMap<String, Any?>()
|
|
81
|
+
val keys = value.keys()
|
|
82
|
+
while (keys.hasNext()) {
|
|
83
|
+
val k = keys.next()
|
|
84
|
+
out[k] = toKotlin(value.opt(k), "$path.$k", depth + 1)
|
|
85
|
+
}
|
|
86
|
+
out
|
|
87
|
+
}
|
|
88
|
+
is JSONArray -> (0 until value.length()).map { toKotlin(value.opt(it), "$path[$it]", depth + 1) }
|
|
89
|
+
is String, is Boolean -> value
|
|
90
|
+
is Number -> {
|
|
91
|
+
val d = value.toDouble()
|
|
92
|
+
if (d.isNaN() || d.isInfinite()) throw BridgeInputException("$path: not a finite number")
|
|
93
|
+
value
|
|
94
|
+
}
|
|
95
|
+
else -> throw BridgeInputException("$path: unsupported value")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One JSON object; every accessor marks its key as known. JSON `null` counts as absent. */
|
|
101
|
+
internal class Fields(val path: String, private val map: Map<String, Any?>) {
|
|
102
|
+
|
|
103
|
+
private val known = mutableSetOf<String>()
|
|
104
|
+
|
|
105
|
+
private fun raw(key: String): Any? {
|
|
106
|
+
known += key
|
|
107
|
+
return map[key]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fun has(key: String): Boolean {
|
|
111
|
+
known += key
|
|
112
|
+
return map[key] != null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Refuses every key no accessor asked for. */
|
|
116
|
+
fun finish() {
|
|
117
|
+
val unknown = map.keys.filter { it !in known }
|
|
118
|
+
if (unknown.isNotEmpty()) {
|
|
119
|
+
throw BridgeInputException(
|
|
120
|
+
"$path: unknown key${if (unknown.size > 1) "s" else ""} ${unknown.joinToString { "'$it'" }}"
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fun string(key: String, maxLength: Int = 2048, allowEmpty: Boolean = false, multiline: Boolean = false): String? {
|
|
126
|
+
val v = raw(key) ?: return null
|
|
127
|
+
if (v !is String) throw BridgeInputException("$path.$key: must be a string")
|
|
128
|
+
if (v.length > maxLength) throw BridgeInputException("$path.$key: longer than $maxLength characters")
|
|
129
|
+
if (!allowEmpty && v.isEmpty()) throw BridgeInputException("$path.$key: must not be empty")
|
|
130
|
+
if (v.any { badChar(it, multiline) }) throw BridgeInputException("$path.$key: control characters are not allowed")
|
|
131
|
+
return v
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Free text (a request body): any characters, bounded length. */
|
|
135
|
+
fun text(key: String, maxLength: Int): String? {
|
|
136
|
+
val v = raw(key) ?: return null
|
|
137
|
+
if (v !is String) throw BridgeInputException("$path.$key: must be a string")
|
|
138
|
+
if (v.length > maxLength) throw BridgeInputException("$path.$key: longer than $maxLength characters")
|
|
139
|
+
return v
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private fun badChar(c: Char, multiline: Boolean): Boolean =
|
|
143
|
+
(c.code < 0x20 || c.code == 0x7f) && !(multiline && (c == '\n' || c == '\r'))
|
|
144
|
+
|
|
145
|
+
fun requireString(key: String, maxLength: Int = 2048): String =
|
|
146
|
+
string(key, maxLength) ?: throw BridgeInputException("$path.$key: required")
|
|
147
|
+
|
|
148
|
+
fun bool(key: String): Boolean? {
|
|
149
|
+
val v = raw(key) ?: return null
|
|
150
|
+
if (v !is Boolean) throw BridgeInputException("$path.$key: must be a boolean")
|
|
151
|
+
return v
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fun long(key: String, min: Long, max: Long): Long? {
|
|
155
|
+
val v = raw(key) ?: return null
|
|
156
|
+
if (v !is Number) throw BridgeInputException("$path.$key: must be a number")
|
|
157
|
+
val d = v.toDouble()
|
|
158
|
+
if (d != Math.floor(d)) throw BridgeInputException("$path.$key: must be a whole number")
|
|
159
|
+
if (d < min || d > max) throw BridgeInputException("$path.$key: must be between $min and $max")
|
|
160
|
+
return d.toLong()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fun int(key: String, min: Int, max: Int): Int? = long(key, min.toLong(), max.toLong())?.toInt()
|
|
164
|
+
|
|
165
|
+
fun double(key: String, min: Double, max: Double): Double? {
|
|
166
|
+
val v = raw(key) ?: return null
|
|
167
|
+
if (v !is Number) throw BridgeInputException("$path.$key: must be a number")
|
|
168
|
+
val d = v.toDouble()
|
|
169
|
+
if (d < min || d > max) throw BridgeInputException("$path.$key: must be between $min and $max")
|
|
170
|
+
return d
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fun stringList(key: String, maxItems: Int = 64, maxLength: Int = 2048, multiline: Boolean = false): List<String>? {
|
|
174
|
+
val v = raw(key) ?: return null
|
|
175
|
+
if (v !is List<*>) throw BridgeInputException("$path.$key: must be a list of strings")
|
|
176
|
+
if (v.size > maxItems) throw BridgeInputException("$path.$key: more than $maxItems items")
|
|
177
|
+
return v.mapIndexed { i, item ->
|
|
178
|
+
if (item !is String) throw BridgeInputException("$path.$key[$i]: must be a string")
|
|
179
|
+
if (item.isEmpty() || item.length > maxLength) {
|
|
180
|
+
throw BridgeInputException("$path.$key[$i]: must be 1 to $maxLength characters")
|
|
181
|
+
}
|
|
182
|
+
if (item.any { badChar(it, multiline) }) throw BridgeInputException("$path.$key[$i]: control characters are not allowed")
|
|
183
|
+
item
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fun obj(key: String): Fields? {
|
|
188
|
+
val v = raw(key) ?: return null
|
|
189
|
+
if (v !is Map<*, *>) throw BridgeInputException("$path.$key: must be an object")
|
|
190
|
+
@Suppress("UNCHECKED_CAST")
|
|
191
|
+
return Fields("$path.$key", v as Map<String, Any?>)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fun objList(key: String, maxItems: Int): List<Fields>? {
|
|
195
|
+
val v = raw(key) ?: return null
|
|
196
|
+
if (v !is List<*>) throw BridgeInputException("$path.$key: must be a list of objects")
|
|
197
|
+
if (v.size > maxItems) throw BridgeInputException("$path.$key: more than $maxItems items")
|
|
198
|
+
return v.mapIndexed { i, item ->
|
|
199
|
+
if (item !is Map<*, *>) throw BridgeInputException("$path.$key[$i]: must be an object")
|
|
200
|
+
@Suppress("UNCHECKED_CAST")
|
|
201
|
+
Fields("$path.$key[$i]", item as Map<String, Any?>)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A string → string map (`ios.resolve`, request headers). */
|
|
206
|
+
fun stringMap(key: String, maxItems: Int, maxKeyLength: Int, maxValueLength: Int): Map<String, String>? {
|
|
207
|
+
val v = raw(key) ?: return null
|
|
208
|
+
if (v !is Map<*, *>) throw BridgeInputException("$path.$key: must be an object")
|
|
209
|
+
if (v.size > maxItems) throw BridgeInputException("$path.$key: more than $maxItems entries")
|
|
210
|
+
return v.entries.associate { (k, value) ->
|
|
211
|
+
val name = k as String
|
|
212
|
+
if (name.isEmpty() || name.length > maxKeyLength) throw BridgeInputException("$path.$key: key '${name.take(40)}' has a bad length")
|
|
213
|
+
if (value !is String) throw BridgeInputException("$path.$key.$name: must be a string")
|
|
214
|
+
if (value.length > maxValueLength) throw BridgeInputException("$path.$key.$name: longer than $maxValueLength characters")
|
|
215
|
+
name to value
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `enum` by its Kotlin constant name. */
|
|
220
|
+
fun <E : Enum<E>> enum(key: String, values: Array<E>): E? {
|
|
221
|
+
val v = string(key, 64) ?: return null
|
|
222
|
+
return values.firstOrNull { it.name == v }
|
|
223
|
+
?: throw BridgeInputException("$path.$key: '$v' is not one of ${values.joinToString { it.name }}")
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vault access tokens and the enrollment token of a running call, in memory
|
|
7
|
+
* only: never written to disk, gone with the process. The vault files'
|
|
8
|
+
* `accessToken { … }` providers read from here on every download. Every
|
|
9
|
+
* string that goes back to JS is passed through [redact], so a token that a
|
|
10
|
+
* native message happens to echo never reaches JS.
|
|
11
|
+
*/
|
|
12
|
+
internal class VaultTokenStore {
|
|
13
|
+
|
|
14
|
+
private val tokens = ConcurrentHashMap<String, String>()
|
|
15
|
+
private val transient = ConcurrentHashMap.newKeySet<String>()
|
|
16
|
+
|
|
17
|
+
fun put(key: String, token: String?) {
|
|
18
|
+
if (token.isNullOrEmpty()) tokens.remove(key) else tokens[key] = token
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Empty when there is none: the server refuses that as an invalid token. */
|
|
22
|
+
fun get(key: String): String = tokens[key] ?: ""
|
|
23
|
+
|
|
24
|
+
fun clear(): Int {
|
|
25
|
+
val n = tokens.size
|
|
26
|
+
tokens.clear()
|
|
27
|
+
return n
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A secret that is not stored but must still be redacted while a call runs (enrollment token). */
|
|
31
|
+
fun <T> withTransientSecret(secret: String, block: () -> T): T {
|
|
32
|
+
if (secret.length >= MIN_REDACT_LENGTH) transient += secret
|
|
33
|
+
try {
|
|
34
|
+
return block()
|
|
35
|
+
} finally {
|
|
36
|
+
transient -= secret
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
suspend fun <T> withTransientSecretSuspend(secret: String, block: suspend () -> T): T {
|
|
41
|
+
if (secret.length >= MIN_REDACT_LENGTH) transient += secret
|
|
42
|
+
try {
|
|
43
|
+
return block()
|
|
44
|
+
} finally {
|
|
45
|
+
transient -= secret
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fun redact(text: String?): String? {
|
|
50
|
+
if (text == null) return null
|
|
51
|
+
var out: String = text
|
|
52
|
+
for (secret in tokens.values + transient) {
|
|
53
|
+
if (secret.length >= MIN_REDACT_LENGTH) out = out.replace(secret, "***")
|
|
54
|
+
}
|
|
55
|
+
return if (out.length > MAX_MESSAGE) out.take(MAX_MESSAGE) + "…" else out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
companion object {
|
|
59
|
+
/** Shorter values would redact ordinary words. */
|
|
60
|
+
const val MIN_REDACT_LENGTH = 6
|
|
61
|
+
const val MAX_MESSAGE = 1000
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// JSON config from JS → PinVaultConfig through the library's own builders (the
|
|
2
|
+
// Swift twin of ConfigParser.kt; same keys, same limits). Unknown keys, wrong
|
|
3
|
+
// types and out-of-range sizes are refused with BridgeInputError; builder
|
|
4
|
+
// refusals surface from `build()` as PinVaultError.invalidConfiguration.
|
|
5
|
+
//
|
|
6
|
+
// Not exposed on purpose: `clientKeystore(_:password:)` (a P12 and its password
|
|
7
|
+
// would cross the JS bridge — enroll instead), custom CertificateConfigApi /
|
|
8
|
+
// storage / integrity providers (native objects), `reportToPinVaultBackend`.
|
|
9
|
+
import Foundation
|
|
10
|
+
import PinVault
|
|
11
|
+
|
|
12
|
+
public struct ParsedConfig {
|
|
13
|
+
public let config: PinVaultConfig
|
|
14
|
+
/// Non-nil when JS registered an `environmentGuard`.
|
|
15
|
+
public let guardTimeoutMs: Int64?
|
|
16
|
+
/// `requirePinnedReactNativeNetworking`: start fails when RN's https does not go through PinVault.
|
|
17
|
+
public var requirePinnedReactNativeNetworking = false
|
|
18
|
+
/// `ios.reactNativeMaxResponseBytes`: the bound of a React Native https answer (default 50 MiB).
|
|
19
|
+
public var reactNativeMaxResponseBytes = ReactNetworking.defaultMaxResponseBytes
|
|
20
|
+
/// True when a native security file was applied.
|
|
21
|
+
public var nativeSecurityApplied = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public enum ConfigParser {
|
|
25
|
+
public static let maxConfigApis = 16
|
|
26
|
+
public static let maxVaultFiles = 64
|
|
27
|
+
public static let maxPins = 256
|
|
28
|
+
public static let maxPinsPerHost = 16
|
|
29
|
+
static let keyLength = 4096
|
|
30
|
+
|
|
31
|
+
/// - Parameters:
|
|
32
|
+
/// - native: the app's native security file (``NativeSecurity``); nil = none.
|
|
33
|
+
/// - release: a release build: without a native file, the relaxations (`allowUnsigned`, …) are refused from JS.
|
|
34
|
+
public static func parse(
|
|
35
|
+
_ json: String,
|
|
36
|
+
tokens: VaultTokenStore,
|
|
37
|
+
guardFactory: (Int64) -> any EnvironmentGuard,
|
|
38
|
+
listener: PinVaultConnectionListener?,
|
|
39
|
+
native: NativeSecurity? = nil,
|
|
40
|
+
release: Bool = false
|
|
41
|
+
) throws -> ParsedConfig {
|
|
42
|
+
let root = try StrictJSON.parseObject(json, path: "config")
|
|
43
|
+
let builder = PinVaultConfig.Builder()
|
|
44
|
+
|
|
45
|
+
for api in try root.objectList("configApis", maxItems: maxConfigApis) ?? [] {
|
|
46
|
+
try addConfigApi(builder, api, native: native, release: release)
|
|
47
|
+
}
|
|
48
|
+
for file in try root.objectList("vaultFiles", maxItems: maxVaultFiles) ?? [] { try addVaultFile(builder, file, tokens) }
|
|
49
|
+
let jsStaticPins = try root.object("staticPins").map(staticPins)
|
|
50
|
+
if let pins = try SecurityPolicy.staticPins(path: "config.staticPins", js: jsStaticPins, native: native) {
|
|
51
|
+
builder.staticPins(pins)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if let v = try root.int("maxRetryCount", min: 0, max: 10) { builder.maxRetryCount(v) }
|
|
55
|
+
if let v = try root.int64("updateIntervalHours", min: 1, max: 24 * 30) { builder.updateIntervalHours(v) }
|
|
56
|
+
if let v = try root.int64("updateIntervalMinutes", min: 15, max: 60 * 24 * 30) { builder.updateIntervalMinutes(v) }
|
|
57
|
+
if let v = try root.string("deviceAlias", maxLength: 128) { builder.deviceAlias(v) }
|
|
58
|
+
if let d = try root.object("expiredConfigGrace") { let (a, u) = try duration(d); builder.expiredConfigGrace(a, u) }
|
|
59
|
+
if let v = try root.stringList("requireCaTrust", maxItems: 64, maxLength: 255) { builder.requireCaTrust(v) }
|
|
60
|
+
if try root.bool("wipeVaultFilesOnRevocation") == true { builder.wipeVaultFilesOnRevocation() }
|
|
61
|
+
if let d = try root.object("vaultFileMaxOfflineAge") { let (a, u) = try duration(d); builder.vaultFileMaxOfflineAge(a, u) }
|
|
62
|
+
if try root.bool("requireUnlockedDevice") == true { builder.requireUnlockedDevice() }
|
|
63
|
+
if try root.bool("requireHardwareBackedKeys") == true { builder.requireHardwareBackedKeys() }
|
|
64
|
+
if try root.bool("managedTrustRoots") == true { builder.managedTrustRoots() }
|
|
65
|
+
if let v = try root.stringList("expectedSignerSha256", maxItems: 16, maxLength: 128) { builder.expectedSignerSha256(v) }
|
|
66
|
+
|
|
67
|
+
var guardTimeout: Int64?
|
|
68
|
+
if let g = try root.object("environmentGuard") {
|
|
69
|
+
let timeout = try g.int64("timeoutMs", min: JSEnvironmentGuard.minTimeoutMs, max: JSEnvironmentGuard.maxTimeoutMs)
|
|
70
|
+
?? JSEnvironmentGuard.defaultTimeoutMs
|
|
71
|
+
try g.finish()
|
|
72
|
+
guardTimeout = timeout
|
|
73
|
+
builder.environmentGuard(guardFactory(timeout))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let requirePinned = try root.bool("requirePinnedReactNativeNetworking") == true
|
|
77
|
+
// Android-only settings: the Android side reads them; here only their shape is checked.
|
|
78
|
+
_ = try root.object("android")
|
|
79
|
+
var maxRN = ReactNetworking.defaultMaxResponseBytes
|
|
80
|
+
if let ios = try root.object("ios") {
|
|
81
|
+
if let v = try ios.int64("reactNativeMaxResponseBytes", min: 1, max: ReactNetworking.maxResponseBytesLimit) { maxRN = v }
|
|
82
|
+
if let resolve = try ios.stringMap("resolve", maxItems: 64, maxKeyLength: 255, maxValueLength: 255) {
|
|
83
|
+
for (host, address) in resolve.sorted(by: { $0.key < $1.key }) { builder.resolve(host: host, to: address) }
|
|
84
|
+
}
|
|
85
|
+
if let v = try ios.stringList("expectedBundleIds", maxItems: 16, maxLength: 255) { builder.expectedBundleId(v) }
|
|
86
|
+
if let v = try ios.stringList("expectedTeamIds", maxItems: 16, maxLength: 32) { builder.expectedTeamId(v) }
|
|
87
|
+
if let v = try ios.enumValue("userAuthStrength", UserAuthStrength.self) { builder.userAuthStrength(v) }
|
|
88
|
+
try ios.finish()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try root.finish()
|
|
92
|
+
if let listener { builder.onConnectionEvent(listener) }
|
|
93
|
+
var parsed = ParsedConfig(config: try builder.build(), guardTimeoutMs: guardTimeout)
|
|
94
|
+
parsed.requirePinnedReactNativeNetworking = requirePinned
|
|
95
|
+
parsed.reactNativeMaxResponseBytes = maxRN
|
|
96
|
+
parsed.nativeSecurityApplied = native != nil
|
|
97
|
+
return parsed
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static func duration(_ d: Fields) throws -> (Int64, TimeUnit) {
|
|
101
|
+
guard let amount = try d.int64("amount", min: 0, max: 10 * 365 * 24 * 3600 * 1000) else {
|
|
102
|
+
throw BridgeInputError("\(d.path).amount: required")
|
|
103
|
+
}
|
|
104
|
+
let raw = try d.requireString("unit", maxLength: 16)
|
|
105
|
+
let allowed: [TimeUnit] = [.milliseconds, .seconds, .minutes, .hours, .days]
|
|
106
|
+
guard let unit = TimeUnit(rawValue: raw), allowed.contains(unit) else {
|
|
107
|
+
throw BridgeInputError("\(d.path).unit: '\(raw)' is not one of MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS")
|
|
108
|
+
}
|
|
109
|
+
try d.finish()
|
|
110
|
+
return (amount, unit)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static func httpsUrl(_ f: Fields, _ key: String) throws -> String? {
|
|
114
|
+
guard let url = try f.string(key, maxLength: 2048) else { return nil }
|
|
115
|
+
guard url.hasPrefix("https://") else { throw BridgeInputError("\(f.path).\(key): must be an https:// URL") }
|
|
116
|
+
return url
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public static func hostPin(_ p: Fields) throws -> HostPin {
|
|
120
|
+
let hostname = try p.requireString("hostname", maxLength: 255)
|
|
121
|
+
guard let pins = try p.stringList("sha256", maxItems: maxPinsPerHost, maxLength: 128) else {
|
|
122
|
+
throw BridgeInputError("\(p.path).sha256: required")
|
|
123
|
+
}
|
|
124
|
+
let pin = HostPin(
|
|
125
|
+
hostname: hostname,
|
|
126
|
+
sha256: pins,
|
|
127
|
+
version: try p.int("version", min: 0, max: Int(Int32.max)) ?? 0,
|
|
128
|
+
forceUpdate: try p.bool("forceUpdate") ?? false,
|
|
129
|
+
mtls: try p.bool("mtls") ?? false,
|
|
130
|
+
clientCertVersion: try p.int("clientCertVersion", min: 0, max: Int(Int32.max))
|
|
131
|
+
)
|
|
132
|
+
try p.finish()
|
|
133
|
+
// The Kotlin constructor's rule (≥ 2 pins), applied here so the refusal names the path.
|
|
134
|
+
do { try pin.validate() } catch { throw BridgeInputError("\(p.path): \((error as? PinVaultError)?.message ?? "\(error)")") }
|
|
135
|
+
return pin
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public static func staticPins(_ s: Fields) throws -> CertificateConfig {
|
|
139
|
+
guard let pins = try s.objectList("pins", maxItems: maxPins)?.map(hostPin) else {
|
|
140
|
+
throw BridgeInputError("\(s.path).pins: required")
|
|
141
|
+
}
|
|
142
|
+
let config = CertificateConfig(
|
|
143
|
+
version: try s.int("version", min: 0, max: Int(Int32.max)) ?? 0,
|
|
144
|
+
pins: pins,
|
|
145
|
+
forceUpdate: try s.bool("forceUpdate") ?? false
|
|
146
|
+
)
|
|
147
|
+
try s.finish()
|
|
148
|
+
return config
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
static func addConfigApi(_ builder: PinVaultConfig.Builder, _ b: Fields, native: NativeSecurity?, release: Bool) throws {
|
|
152
|
+
let id = try b.requireString("id", maxLength: 128)
|
|
153
|
+
guard let url = try httpsUrl(b, "url") else { throw BridgeInputError("\(b.path).url: required") }
|
|
154
|
+
let bootstrapPins = try b.objectList("bootstrapPins", maxItems: maxPins)?.map(hostPin)
|
|
155
|
+
let configEndpoint = try b.string("configEndpoint", maxLength: 512)
|
|
156
|
+
let healthEndpoint = try b.string("healthEndpoint", maxLength: 512)
|
|
157
|
+
let oneKey = try b.string("signaturePublicKey", maxLength: keyLength, multiline: true)
|
|
158
|
+
let keys = try b.stringList("signaturePublicKeys", maxItems: 16, maxLength: keyLength, multiline: true)
|
|
159
|
+
if oneKey != nil && keys != nil {
|
|
160
|
+
throw BridgeInputError("\(b.path): give signaturePublicKey or signaturePublicKeys, not both")
|
|
161
|
+
}
|
|
162
|
+
let requiredSignatures = try b.int("requiredSignatures", min: 1, max: 16)
|
|
163
|
+
let recoveryKeys = try b.stringList("recoveryPublicKeys", maxItems: 16, maxLength: keyLength, multiline: true)
|
|
164
|
+
let requiredRecovery = try b.int("requiredRecoverySignatures", min: 1, max: 16)
|
|
165
|
+
let allowUnsigned = try b.bool("allowUnsigned") == true
|
|
166
|
+
let serverScope = try b.string("serverScope", maxLength: 128)
|
|
167
|
+
let allowUnpinned = try b.bool("allowUnpinnedConfigApi") == true
|
|
168
|
+
let allowServerKey = try b.bool("allowServerGeneratedKey") == true
|
|
169
|
+
let clientCaPins = try b.stringList("clientCaPins", maxItems: 16, maxLength: 128)
|
|
170
|
+
let maxLifetime = try b.int("maxClientCertLifetimeDays", min: 1, max: 3650)
|
|
171
|
+
let clientCertHosts = try b.stringList("clientCertHosts", maxItems: 64, maxLength: 2048)
|
|
172
|
+
let enrollmentEndpoint = try b.string("enrollmentEndpoint", maxLength: 512)
|
|
173
|
+
let clientCertEndpoint = try b.string("clientCertEndpoint", maxLength: 512)
|
|
174
|
+
let vaultReportEndpoint = try b.string("vaultReportEndpoint", maxLength: 512)
|
|
175
|
+
let clientCertLabel = try b.string("clientCertLabel", maxLength: 128)
|
|
176
|
+
let wantPinsFor = try b.stringList("wantPinsFor", maxItems: 256, maxLength: 255)
|
|
177
|
+
let renewalUrl = try httpsUrl(b, "renewalUrl")
|
|
178
|
+
let enrollmentUrl = try httpsUrl(b, "enrollmentUrl")
|
|
179
|
+
let threshold = try b.double("clientCertRenewalThreshold", min: 0, max: 1)
|
|
180
|
+
let disableRenewal = try b.bool("disableClientCertRenewal") == true
|
|
181
|
+
let attestation = try b.bool("attestation") == true
|
|
182
|
+
let attestationInterval = try b.object("attestationInterval").map(duration)
|
|
183
|
+
let tokenHosts = try b.stringList("tokenHosts", maxItems: 64, maxLength: 255)
|
|
184
|
+
try b.finish()
|
|
185
|
+
|
|
186
|
+
// The trust anchors and relaxations: the native security file decides, JS only repeats.
|
|
187
|
+
let sec = try SecurityPolicy.apply(
|
|
188
|
+
path: b.path, id: id,
|
|
189
|
+
js: SecurityFields(
|
|
190
|
+
bootstrapPins: bootstrapPins, oneKey: oneKey, keys: keys, requiredSignatures: requiredSignatures,
|
|
191
|
+
recoveryKeys: recoveryKeys, requiredRecovery: requiredRecovery, serverScope: serverScope,
|
|
192
|
+
clientCaPins: clientCaPins, allowUnsigned: allowUnsigned, allowUnpinned: allowUnpinned,
|
|
193
|
+
allowServerKey: allowServerKey
|
|
194
|
+
),
|
|
195
|
+
native: native, release: release
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
builder.configApi(id, url: url) { api in
|
|
199
|
+
if let v = sec.bootstrapPins { api.bootstrapPins(v) }
|
|
200
|
+
if let configEndpoint { api.configEndpoint(configEndpoint) }
|
|
201
|
+
if let healthEndpoint { api.healthEndpoint(healthEndpoint) }
|
|
202
|
+
if let v = sec.oneKey { api.signaturePublicKey(v) }
|
|
203
|
+
if let v = sec.keys { api.signaturePublicKeys(v) }
|
|
204
|
+
if let v = sec.requiredSignatures { api.requiredSignatures(v) }
|
|
205
|
+
if let v = sec.recoveryKeys { api.recoveryPublicKeys(v) }
|
|
206
|
+
if let v = sec.requiredRecovery { api.requiredRecoverySignatures(v) }
|
|
207
|
+
if sec.allowUnsigned { api.allowUnsigned() }
|
|
208
|
+
if let v = sec.serverScope { api.serverScope(v) }
|
|
209
|
+
if sec.allowUnpinned { api.allowUnpinnedConfigApi() }
|
|
210
|
+
if sec.allowServerKey { api.allowServerGeneratedKey() }
|
|
211
|
+
if let v = sec.clientCaPins { api.clientCaPins(v) }
|
|
212
|
+
if let maxLifetime { api.maxClientCertLifetimeDays(maxLifetime) }
|
|
213
|
+
if let clientCertHosts { api.clientCertHosts(clientCertHosts) }
|
|
214
|
+
if let enrollmentEndpoint { api.enrollmentEndpoint(enrollmentEndpoint) }
|
|
215
|
+
if let clientCertEndpoint { api.clientCertEndpoint(clientCertEndpoint) }
|
|
216
|
+
if let vaultReportEndpoint { api.vaultReportEndpoint(vaultReportEndpoint) }
|
|
217
|
+
if let clientCertLabel { api.clientCertLabel(clientCertLabel) }
|
|
218
|
+
if let wantPinsFor { api.wantPinsFor(wantPinsFor) }
|
|
219
|
+
if let renewalUrl { api.renewalUrl(renewalUrl) }
|
|
220
|
+
if let enrollmentUrl { api.enrollmentUrl(enrollmentUrl) }
|
|
221
|
+
if let threshold { api.clientCertRenewalThreshold(threshold) }
|
|
222
|
+
if disableRenewal { api.disableClientCertRenewal() }
|
|
223
|
+
if attestation { api.attestation() }
|
|
224
|
+
if let (amount, unit) = attestationInterval { api.attestationInterval(amount, unit) }
|
|
225
|
+
if let tokenHosts { api.tokenHosts(tokenHosts) }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore) throws {
|
|
230
|
+
let key = try f.requireString("key", maxLength: 128)
|
|
231
|
+
let endpoint = try f.requireString("endpoint", maxLength: 512)
|
|
232
|
+
let signatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
|
|
233
|
+
let updateWithPins = try f.bool("updateWithPins")
|
|
234
|
+
let storage = try f.enumValue("storage", StorageStrategy.self)
|
|
235
|
+
let configApi = try f.string("configApi", maxLength: 128)
|
|
236
|
+
let policy = try f.enumValue("accessPolicy", VaultFileAccessPolicy.self)
|
|
237
|
+
let encryption = try f.enumValue("encryption", VaultFileEncryption.self)
|
|
238
|
+
let userAuth = try f.enumValue("userAuth", UserAuth.self)
|
|
239
|
+
let maxOfflineAge = try f.object("maxOfflineAge").map(duration)
|
|
240
|
+
let wipeWhenStale = try f.bool("wipeWhenStale") == true
|
|
241
|
+
try f.finish()
|
|
242
|
+
|
|
243
|
+
builder.vaultFile(key) { file in
|
|
244
|
+
file.endpoint(endpoint)
|
|
245
|
+
if let signatureKey { file.signaturePublicKey(signatureKey) }
|
|
246
|
+
if let updateWithPins { file.updateWithPins(updateWithPins) }
|
|
247
|
+
if let storage { file.storage(storage) }
|
|
248
|
+
if let configApi { file.configApi(configApi) }
|
|
249
|
+
if let policy {
|
|
250
|
+
file.accessPolicy(policy)
|
|
251
|
+
if policy == .token || policy == .tokenMtls {
|
|
252
|
+
// Read on every download; the token never leaves native memory.
|
|
253
|
+
file.accessToken { tokens.get(key) }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if let encryption { file.encryption(encryption) }
|
|
257
|
+
if let userAuth { file.userAuth(userAuth) }
|
|
258
|
+
if let (amount, unit) = maxOfflineAge { file.maxOfflineAge(amount, unit) }
|
|
259
|
+
if wipeWhenStale { file.wipeWhenStale() }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// `unlockFile` prompt texts + the content encoding.
|
|
264
|
+
public static func unlockPrompt(_ json: String) throws -> (VaultFileUnlockPrompt, String) {
|
|
265
|
+
let p = try StrictJSON.parseObject(json, path: "prompt", maxChars: 16 * 1024)
|
|
266
|
+
let prompt = VaultFileUnlockPrompt(
|
|
267
|
+
title: try p.requireString("title", maxLength: 256),
|
|
268
|
+
subtitle: try p.string("subtitle", maxLength: 256),
|
|
269
|
+
description: try p.string("description", maxLength: 1024),
|
|
270
|
+
negativeButtonText: try p.string("negativeButtonText", maxLength: 64) ?? "Cancel"
|
|
271
|
+
)
|
|
272
|
+
let encoding = try ResultMapper.checkEncoding(try p.string("encoding", maxLength: 16) ?? ResultMapper.utf8)
|
|
273
|
+
try p.finish()
|
|
274
|
+
return (prompt, encoding)
|
|
275
|
+
}
|
|
276
|
+
}
|