@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +386 -2
  4. package/RNPinVault.podspec +43 -0
  5. package/android/build.gradle +68 -0
  6. package/android/consumer-rules.pro +25 -0
  7. package/android/src/main/AndroidManifest.xml +16 -0
  8. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +300 -0
  9. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
  10. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +239 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -0
  13. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
  14. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
  15. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
  16. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
  17. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
  18. package/ios/Core/ConfigParser.swift +276 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +197 -0
  21. package/ios/Core/PinnedFetch.swift +119 -0
  22. package/ios/Core/ReactNetworking.swift +172 -0
  23. package/ios/Core/ResultMapper.swift +163 -0
  24. package/ios/Core/StrictJSON.swift +214 -0
  25. package/ios/Core/VaultTokenStore.swift +66 -0
  26. package/ios/PinVaultBridge.swift +412 -0
  27. package/ios/PinVaultReactNetworking.swift +47 -0
  28. package/ios/RNPinVault.h +7 -0
  29. package/ios/RNPinVault.mm +293 -0
  30. package/ios/RNPinVaultURLRequestHandler.mm +92 -0
  31. package/lib/module/NativePinVault.js +13 -0
  32. package/lib/module/NativePinVault.js.map +1 -0
  33. package/lib/module/index.js +312 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/types.js +2 -0
  37. package/lib/module/types.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/NativePinVault.d.ts +50 -0
  40. package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +150 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/lib/typescript/src/types.d.ts +346 -0
  44. package/lib/typescript/src/types.d.ts.map +1 -0
  45. package/package.json +128 -4
  46. package/src/NativePinVault.ts +72 -0
  47. package/src/index.ts +385 -0
  48. 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
+ }