@umutcansu/react-native-pinvault 0.0.0-stage → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -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 +396 -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 +354 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -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 +437 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +303 -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 +424 -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 +331 -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 +359 -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 +398 -0
  48. package/src/types.ts +362 -0
@@ -0,0 +1,437 @@
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, a config with Config APIs or static
34
+ /// pins is refused (unless `noFileAllowed`) and the relaxations (`allowUnsigned`, …) are
35
+ /// refused from JS; `expiredConfigGrace` is capped.
36
+ /// - noFileAllowed: the app's Info.plist accepts a release start without the file (``NativeSecurity/noFileInfoKey``).
37
+ public static func parse(
38
+ _ json: String,
39
+ tokens: VaultTokenStore,
40
+ guardFactory: (Int64) -> any EnvironmentGuard,
41
+ listener: PinVaultConnectionListener?,
42
+ native: NativeSecurity? = nil,
43
+ release: Bool = false,
44
+ noFileAllowed: Bool = false,
45
+ reactNetworkingEnabled: Bool = true
46
+ ) throws -> ParsedConfig {
47
+ let root = try StrictJSON.parseObject(json, path: "config")
48
+ let builder = PinVaultConfig.Builder()
49
+ let require = native?.require ?? NativeSecurity.Require()
50
+
51
+ let configApis = try root.objectList("configApis", maxItems: maxConfigApis)
52
+ let vaultFiles = try root.objectList("vaultFiles", maxItems: maxVaultFiles)
53
+ let jsStaticPins = try root.object("staticPins").map(staticPins)
54
+ if release && native == nil && !noFileAllowed && (!(configApis ?? []).isEmpty || jsStaticPins != nil) {
55
+ throw BridgeInputError(
56
+ "config: a release build takes its trust anchors only from the app's native security file " +
57
+ "(\(NativeSecurity.resourceName).json, README \"Native security file\"); none is shipped. " +
58
+ "An app that accepts JS-only anchors says so in its Info.plist: \(NativeSecurity.noFileInfoKey) = YES"
59
+ )
60
+ }
61
+ for api in configApis ?? [] {
62
+ try addConfigApi(builder, api, native: native, release: release)
63
+ }
64
+ for file in vaultFiles ?? [] { try addVaultFile(builder, file, tokens, native: native) }
65
+ if let pins = try SecurityPolicy.staticPins(path: "config.staticPins", js: jsStaticPins, native: native) {
66
+ builder.staticPins(pins)
67
+ }
68
+
69
+ if let v = try root.int("maxRetryCount", min: 0, max: 10) { builder.maxRetryCount(v) }
70
+ if let v = try root.int64("updateIntervalHours", min: 1, max: 24 * 30) { builder.updateIntervalHours(v) }
71
+ if let v = try root.int64("updateIntervalMinutes", min: 15, max: 60 * 24 * 30) { builder.updateIntervalMinutes(v) }
72
+ if let v = try root.string("deviceAlias", maxLength: 128) { builder.deviceAlias(v) }
73
+ if let d = try root.object("expiredConfigGrace") {
74
+ let (a, u) = try duration(d)
75
+ let ms = millis(a, u)
76
+ if release && ms > NativeSecurity.maxReleaseGraceMs { throw BridgeInputError("\(d.path): a release build takes at most 7 days") }
77
+ if let max = require.expiredConfigGraceMs, ms > max {
78
+ throw BridgeInputError("\(d.path): longer than the app's native security file allows (\(max / 1000) s)")
79
+ }
80
+ builder.expiredConfigGrace(a, u)
81
+ } else if let max = require.expiredConfigGraceMs {
82
+ builder.expiredConfigGrace(max, .milliseconds)
83
+ }
84
+ // `require` hosts are added to JS's: JS can name more, never fewer.
85
+ let caTrust = (try root.stringList("requireCaTrust", maxItems: 64, maxLength: 255) ?? []) + (require.requireCaTrust ?? [])
86
+ if !caTrust.isEmpty {
87
+ var seen = Set<String>()
88
+ builder.requireCaTrust(caTrust.filter { seen.insert($0).inserted })
89
+ }
90
+ if try root.bool("wipeVaultFilesOnRevocation") == true || require.wipeVaultFilesOnRevocation { builder.wipeVaultFilesOnRevocation() }
91
+ if let d = try root.object("vaultFileMaxOfflineAge") {
92
+ let (a, u) = try duration(d)
93
+ if let max = require.vaultFileMaxOfflineAgeMs {
94
+ let ms = millis(a, u)
95
+ // 0 = no limit: longer than any.
96
+ if ms == 0 || ms > max { throw BridgeInputError("\(d.path): longer than the app's native security file allows (\(max / 1000) s)") }
97
+ }
98
+ builder.vaultFileMaxOfflineAge(a, u)
99
+ } else if let max = require.vaultFileMaxOfflineAgeMs {
100
+ builder.vaultFileMaxOfflineAge(max, .milliseconds)
101
+ }
102
+ if try root.bool("requireUnlockedDevice") == true || require.requireUnlockedDevice { builder.requireUnlockedDevice() }
103
+ if try root.bool("requireHardwareBackedKeys") == true || require.requireHardwareBackedKeys { builder.requireHardwareBackedKeys() }
104
+ if try root.bool("managedTrustRoots") == true || require.managedTrustRoots { builder.managedTrustRoots() }
105
+ if let v = try root.stringList("expectedSignerSha256", maxItems: 16, maxLength: 128) { builder.expectedSignerSha256(v) }
106
+
107
+ var guardTimeout: Int64?
108
+ if let g = try root.object("environmentGuard") {
109
+ let timeout = try g.int64("timeoutMs", min: JSEnvironmentGuard.minTimeoutMs, max: JSEnvironmentGuard.maxTimeoutMs)
110
+ ?? JSEnvironmentGuard.defaultTimeoutMs
111
+ try g.finish()
112
+ guardTimeout = timeout
113
+ builder.environmentGuard(guardFactory(timeout))
114
+ }
115
+
116
+ // On by default in a release build — unless the app opted out of pinning RN's
117
+ // networking natively (Info.plist PinVaultPinReactNativeNetworking = NO).
118
+ let requirePinned = try root.bool("requirePinnedReactNativeNetworking") ?? (release && reactNetworkingEnabled)
119
+ // Android-only settings: the Android side reads them; here only their shape is checked.
120
+ _ = try root.object("android")
121
+ var maxRN = ReactNetworking.defaultMaxResponseBytes
122
+ if let ios = try root.object("ios") {
123
+ if let v = try ios.int64("reactNativeMaxResponseBytes", min: 1, max: ReactNetworking.maxResponseBytesLimit) { maxRN = v }
124
+ if let resolve = try ios.stringMap("resolve", maxItems: 64, maxKeyLength: 255, maxValueLength: 255) {
125
+ for (host, address) in resolve.sorted(by: { $0.key < $1.key }) { builder.resolve(host: host, to: address) }
126
+ }
127
+ if let v = try fixedList("config.ios.expectedBundleIds", require.expectedBundleIds,
128
+ try ios.stringList("expectedBundleIds", maxItems: 16, maxLength: 255), native) {
129
+ builder.expectedBundleId(v)
130
+ }
131
+ if let v = try fixedList("config.ios.expectedTeamIds", require.expectedTeamIds,
132
+ try ios.stringList("expectedTeamIds", maxItems: 16, maxLength: 32), native) {
133
+ builder.expectedTeamId(v)
134
+ }
135
+ let jsStrength = try ios.enumValue("userAuthStrength", UserAuthStrength.self)
136
+ if let fixed = require.userAuthStrength, let jsStrength, jsStrength != fixed {
137
+ throw BridgeInputError("config.ios.userAuthStrength: differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
138
+ }
139
+ if let v = require.userAuthStrength ?? jsStrength { builder.userAuthStrength(v) }
140
+ try ios.finish()
141
+ }
142
+
143
+ else {
144
+ if let v = require.expectedBundleIds { builder.expectedBundleId(v) }
145
+ if let v = require.expectedTeamIds { builder.expectedTeamId(v) }
146
+ if let v = require.userAuthStrength { builder.userAuthStrength(v) }
147
+ }
148
+
149
+ try root.finish()
150
+ if let listener { builder.onConnectionEvent(listener) }
151
+ var parsed = ParsedConfig(config: try builder.build(), guardTimeoutMs: guardTimeout)
152
+ parsed.requirePinnedReactNativeNetworking = requirePinned
153
+ parsed.reactNativeMaxResponseBytes = maxRN
154
+ parsed.nativeSecurityApplied = native != nil
155
+ return parsed
156
+ }
157
+
158
+ /// A JS list against the native file's: fixed where the file gives it.
159
+ static func fixedList(_ path: String, _ native: [String]?, _ js: [String]?, _ file: NativeSecurity?) throws -> [String]? {
160
+ guard let native else { return js }
161
+ if let js, !NativeSecurity.sameKeys(js, native) {
162
+ throw BridgeInputError("\(path): differs from the app's native security file (\(file?.source ?? "")); the native value is fixed")
163
+ }
164
+ return native
165
+ }
166
+
167
+ static func millis(_ amount: Int64, _ unit: TimeUnit) -> Int64 {
168
+ switch unit {
169
+ case .milliseconds: return amount
170
+ case .seconds: return amount.multipliedReportingOverflow(by: 1000).overflow ? .max : amount * 1000
171
+ case .minutes: return amount.multipliedReportingOverflow(by: 60_000).overflow ? .max : amount * 60_000
172
+ case .hours: return amount.multipliedReportingOverflow(by: 3_600_000).overflow ? .max : amount * 3_600_000
173
+ case .days: return amount.multipliedReportingOverflow(by: 86_400_000).overflow ? .max : amount * 86_400_000
174
+ default: return .max
175
+ }
176
+ }
177
+
178
+ static func duration(_ d: Fields) throws -> (Int64, TimeUnit) {
179
+ guard let amount = try d.int64("amount", min: 0, max: 10 * 365 * 24 * 3600 * 1000) else {
180
+ throw BridgeInputError("\(d.path).amount: required")
181
+ }
182
+ let raw = try d.requireString("unit", maxLength: 16)
183
+ let allowed: [TimeUnit] = [.milliseconds, .seconds, .minutes, .hours, .days]
184
+ guard let unit = TimeUnit(rawValue: raw), allowed.contains(unit) else {
185
+ throw BridgeInputError("\(d.path).unit: '\(raw)' is not one of MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS")
186
+ }
187
+ try d.finish()
188
+ return (amount, unit)
189
+ }
190
+
191
+ static func httpsUrl(_ f: Fields, _ key: String) throws -> String? {
192
+ guard let url = try f.string(key, maxLength: 2048) else { return nil }
193
+ guard url.hasPrefix("https://") else { throw BridgeInputError("\(f.path).\(key): must be an https:// URL") }
194
+ return url
195
+ }
196
+
197
+ public static func hostPin(_ p: Fields) throws -> HostPin {
198
+ let hostname = try p.requireString("hostname", maxLength: 255)
199
+ guard let pins = try p.stringList("sha256", maxItems: maxPinsPerHost, maxLength: 128) else {
200
+ throw BridgeInputError("\(p.path).sha256: required")
201
+ }
202
+ let pin = HostPin(
203
+ hostname: hostname,
204
+ sha256: pins,
205
+ version: try p.int("version", min: 0, max: Int(Int32.max)) ?? 0,
206
+ forceUpdate: try p.bool("forceUpdate") ?? false,
207
+ mtls: try p.bool("mtls") ?? false,
208
+ clientCertVersion: try p.int("clientCertVersion", min: 0, max: Int(Int32.max))
209
+ )
210
+ try p.finish()
211
+ // The Kotlin constructor's rule (≥ 2 pins), applied here so the refusal names the path.
212
+ do { try pin.validate() } catch { throw BridgeInputError("\(p.path): \((error as? PinVaultError)?.message ?? "\(error)")") }
213
+ return pin
214
+ }
215
+
216
+ public static func staticPins(_ s: Fields) throws -> CertificateConfig {
217
+ guard let pins = try s.objectList("pins", maxItems: maxPins)?.map(hostPin) else {
218
+ throw BridgeInputError("\(s.path).pins: required")
219
+ }
220
+ let config = CertificateConfig(
221
+ version: try s.int("version", min: 0, max: Int(Int32.max)) ?? 0,
222
+ pins: pins,
223
+ forceUpdate: try s.bool("forceUpdate") ?? false
224
+ )
225
+ try s.finish()
226
+ return config
227
+ }
228
+
229
+ static func addConfigApi(_ builder: PinVaultConfig.Builder, _ b: Fields, native: NativeSecurity?, release: Bool) throws {
230
+ let id = try b.requireString("id", maxLength: 128)
231
+ guard let url = try httpsUrl(b, "url") else { throw BridgeInputError("\(b.path).url: required") }
232
+ let bootstrapPins = try b.objectList("bootstrapPins", maxItems: maxPins)?.map(hostPin)
233
+ let configEndpoint = try b.string("configEndpoint", maxLength: 512)
234
+ let healthEndpoint = try b.string("healthEndpoint", maxLength: 512)
235
+ let oneKey = try b.string("signaturePublicKey", maxLength: keyLength, multiline: true)
236
+ let keys = try b.stringList("signaturePublicKeys", maxItems: 16, maxLength: keyLength, multiline: true)
237
+ if oneKey != nil && keys != nil {
238
+ throw BridgeInputError("\(b.path): give signaturePublicKey or signaturePublicKeys, not both")
239
+ }
240
+ let requiredSignatures = try b.int("requiredSignatures", min: 1, max: 16)
241
+ let recoveryKeys = try b.stringList("recoveryPublicKeys", maxItems: 16, maxLength: keyLength, multiline: true)
242
+ let requiredRecovery = try b.int("requiredRecoverySignatures", min: 1, max: 16)
243
+ let allowUnsigned = try b.bool("allowUnsigned") == true
244
+ let serverScope = try b.string("serverScope", maxLength: 128)
245
+ let allowUnpinned = try b.bool("allowUnpinnedConfigApi") == true
246
+ let allowServerKey = try b.bool("allowServerGeneratedKey") == true
247
+ let clientCaPins = try b.stringList("clientCaPins", maxItems: 16, maxLength: 128)
248
+ let maxLifetime = try b.int("maxClientCertLifetimeDays", min: 1, max: 3650)
249
+ let clientCertHosts = try b.stringList("clientCertHosts", maxItems: 64, maxLength: 2048)
250
+ let enrollmentEndpoint = try b.string("enrollmentEndpoint", maxLength: 512)
251
+ let clientCertEndpoint = try b.string("clientCertEndpoint", maxLength: 512)
252
+ let vaultReportEndpoint = try b.string("vaultReportEndpoint", maxLength: 512)
253
+ let clientCertLabel = try b.string("clientCertLabel", maxLength: 128)
254
+ let wantPinsFor = try b.stringList("wantPinsFor", maxItems: 256, maxLength: 255)
255
+ let renewalUrl = try httpsUrl(b, "renewalUrl")
256
+ let enrollmentUrl = try httpsUrl(b, "enrollmentUrl")
257
+ let threshold = try b.double("clientCertRenewalThreshold", min: 0, max: 1)
258
+ let disableRenewal = try b.bool("disableClientCertRenewal") == true
259
+ let jsAttestation = try b.bool("attestation")
260
+ let attestationInterval = try b.object("attestationInterval").map(duration)
261
+ let jsTokenHosts = try b.stringList("tokenHosts", maxItems: 64, maxLength: 255)
262
+ let jsProof = try b.bool("proofOfPossession")
263
+ try b.finish()
264
+
265
+ // Where the block talks to and who gets its token and identity: fixed where the file says.
266
+ let nativeBlock = native?.blocks[id]
267
+ func fixed(_ key: String) -> BridgeInputError {
268
+ BridgeInputError("\(b.path).\(key): differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
269
+ }
270
+ var blockUrl = url
271
+ if let n = nativeBlock?.url {
272
+ if n != url { throw fixed("url") }
273
+ blockUrl = n
274
+ }
275
+ if nativeBlock?.attestation == true && jsAttestation == false {
276
+ throw BridgeInputError("\(b.path).attestation: the app's native security file turns it on; JS cannot turn it off")
277
+ }
278
+ let attestation = jsAttestation == true || nativeBlock?.attestation == true
279
+ if nativeBlock?.proofOfPossession == true && jsProof == false {
280
+ throw BridgeInputError("\(b.path).proofOfPossession: the app's native security file turns it on; JS cannot turn it off")
281
+ }
282
+ let proof = jsProof == true || nativeBlock?.proofOfPossession == true
283
+ let lower: ([String]) -> [String] = { $0.map { $0.lowercased() } }
284
+ var tokenHosts = jsTokenHosts
285
+ if let n = nativeBlock?.tokenHosts {
286
+ if let j = jsTokenHosts, !NativeSecurity.sameKeys(lower(j), lower(n)) { throw fixed("tokenHosts") }
287
+ tokenHosts = n
288
+ }
289
+ var fixedEnrollmentUrl = enrollmentUrl
290
+ if let n = nativeBlock?.enrollmentUrl {
291
+ if let j = enrollmentUrl, j != n { throw fixed("enrollmentUrl") }
292
+ fixedEnrollmentUrl = n
293
+ }
294
+ var fixedRenewalUrl = renewalUrl
295
+ if let n = nativeBlock?.renewalUrl {
296
+ if let j = renewalUrl, j != n { throw fixed("renewalUrl") }
297
+ fixedRenewalUrl = n
298
+ }
299
+ var certHosts = clientCertHosts
300
+ if let n = nativeBlock?.clientCertHosts {
301
+ if let j = clientCertHosts, !NativeSecurity.sameKeys(lower(j), lower(n)) { throw fixed("clientCertHosts") }
302
+ certHosts = n
303
+ }
304
+
305
+ // The trust anchors and relaxations: the native security file decides, JS only repeats.
306
+ let sec = try SecurityPolicy.apply(
307
+ path: b.path, id: id,
308
+ js: SecurityFields(
309
+ bootstrapPins: bootstrapPins, oneKey: oneKey, keys: keys, requiredSignatures: requiredSignatures,
310
+ recoveryKeys: recoveryKeys, requiredRecovery: requiredRecovery, serverScope: serverScope,
311
+ clientCaPins: clientCaPins, allowUnsigned: allowUnsigned, allowUnpinned: allowUnpinned,
312
+ allowServerKey: allowServerKey
313
+ ),
314
+ native: native, release: release
315
+ )
316
+
317
+ builder.configApi(id, url: blockUrl) { api in
318
+ if let v = sec.bootstrapPins { api.bootstrapPins(v) }
319
+ if let configEndpoint { api.configEndpoint(configEndpoint) }
320
+ if let healthEndpoint { api.healthEndpoint(healthEndpoint) }
321
+ if let v = sec.oneKey { api.signaturePublicKey(v) }
322
+ if let v = sec.keys { api.signaturePublicKeys(v) }
323
+ if let v = sec.requiredSignatures { api.requiredSignatures(v) }
324
+ if let v = sec.recoveryKeys { api.recoveryPublicKeys(v) }
325
+ if let v = sec.requiredRecovery { api.requiredRecoverySignatures(v) }
326
+ if sec.allowUnsigned { api.allowUnsigned() }
327
+ if let v = sec.serverScope { api.serverScope(v) }
328
+ if sec.allowUnpinned { api.allowUnpinnedConfigApi() }
329
+ if sec.allowServerKey { api.allowServerGeneratedKey() }
330
+ if let v = sec.clientCaPins { api.clientCaPins(v) }
331
+ if let maxLifetime { api.maxClientCertLifetimeDays(maxLifetime) }
332
+ if let certHosts { api.clientCertHosts(certHosts) }
333
+ if let enrollmentEndpoint { api.enrollmentEndpoint(enrollmentEndpoint) }
334
+ if let clientCertEndpoint { api.clientCertEndpoint(clientCertEndpoint) }
335
+ if let vaultReportEndpoint { api.vaultReportEndpoint(vaultReportEndpoint) }
336
+ if let clientCertLabel { api.clientCertLabel(clientCertLabel) }
337
+ if let wantPinsFor { api.wantPinsFor(wantPinsFor) }
338
+ if let fixedRenewalUrl { api.renewalUrl(fixedRenewalUrl) }
339
+ if let fixedEnrollmentUrl { api.enrollmentUrl(fixedEnrollmentUrl) }
340
+ if let threshold { api.clientCertRenewalThreshold(threshold) }
341
+ if disableRenewal { api.disableClientCertRenewal() }
342
+ if attestation { api.attestation() }
343
+ if let (amount, unit) = attestationInterval { api.attestationInterval(amount, unit) }
344
+ if let tokenHosts { api.tokenHosts(tokenHosts) }
345
+ if proof { api.proofOfPossession() }
346
+ }
347
+ }
348
+
349
+ static func addVaultFile(_ builder: PinVaultConfig.Builder, _ f: Fields, _ tokens: VaultTokenStore, native: NativeSecurity?) throws {
350
+ let key = try f.requireString("key", maxLength: 128)
351
+ let endpoint = try f.requireString("endpoint", maxLength: 512)
352
+ let jsSignatureKey = try f.string("signaturePublicKey", maxLength: keyLength, multiline: true)
353
+ let updateWithPins = try f.bool("updateWithPins")
354
+ let storage = try f.enumValue("storage", StorageStrategy.self)
355
+ let configApi = try f.string("configApi", maxLength: 128)
356
+ let policy = try f.enumValue("accessPolicy", VaultFileAccessPolicy.self)
357
+ let jsEncryption = try f.enumValue("encryption", VaultFileEncryption.self)
358
+ let jsUserAuth = try f.enumValue("userAuth", UserAuth.self)
359
+ let maxOfflineAge = try f.object("maxOfflineAge").map(duration)
360
+ let wipeWhenStale = try f.bool("wipeWhenStale") == true
361
+ try f.finish()
362
+ var offlineAge = maxOfflineAge
363
+
364
+ // A file the native security file names keeps its signature key, encryption and lock.
365
+ var signatureKey = jsSignatureKey
366
+ var encryption = jsEncryption
367
+ var userAuth = jsUserAuth
368
+ if let files = native?.vaultFiles {
369
+ guard let declared = files[key] else {
370
+ throw BridgeInputError("\(f.path): vault file '\(key)' is not declared in the app's native security file (\(native?.source ?? ""))")
371
+ }
372
+ func fixed(_ field: String) -> BridgeInputError {
373
+ BridgeInputError("\(f.path).\(field): differs from the app's native security file (\(native?.source ?? "")); the native value is fixed")
374
+ }
375
+ if let n = declared.signaturePublicKey {
376
+ if let j = jsSignatureKey, j.trimmingCharacters(in: .whitespacesAndNewlines) != n.trimmingCharacters(in: .whitespacesAndNewlines) {
377
+ throw fixed("signaturePublicKey")
378
+ }
379
+ signatureKey = n
380
+ }
381
+ if let n = declared.encryption {
382
+ if let j = jsEncryption, j != n { throw fixed("encryption") }
383
+ encryption = n
384
+ }
385
+ if let n = declared.userAuth {
386
+ if let j = jsUserAuth, j != n { throw fixed("userAuth") }
387
+ userAuth = n
388
+ }
389
+ if let max = declared.maxOfflineAgeMs, maxOfflineAge == nil {
390
+ offlineAge = (max, .milliseconds)
391
+ }
392
+ }
393
+ // The tighter of the file's own cap and the config-wide one: a per-file value
394
+ // overrides the config's, so it must not escape the config-wide cap either.
395
+ let cap = [native?.vaultFiles?[key]?.maxOfflineAgeMs, native?.require.vaultFileMaxOfflineAgeMs].compactMap { $0 }.min()
396
+ if let cap, let (a, u) = maxOfflineAge {
397
+ let ms = millis(a, u)
398
+ // 0 = no limit: longer than any.
399
+ if ms == 0 || ms > cap {
400
+ throw BridgeInputError("\(f.path).maxOfflineAge: longer than the app's native security file allows (\(cap / 1000) s)")
401
+ }
402
+ }
403
+
404
+ builder.vaultFile(key) { file in
405
+ file.endpoint(endpoint)
406
+ if let signatureKey { file.signaturePublicKey(signatureKey) }
407
+ if let updateWithPins { file.updateWithPins(updateWithPins) }
408
+ if let storage { file.storage(storage) }
409
+ if let configApi { file.configApi(configApi) }
410
+ if let policy {
411
+ file.accessPolicy(policy)
412
+ if policy == .token || policy == .tokenMtls {
413
+ // Read on every download; the token never leaves native memory.
414
+ file.accessToken { tokens.get(key) }
415
+ }
416
+ }
417
+ if let encryption { file.encryption(encryption) }
418
+ if let userAuth { file.userAuth(userAuth) }
419
+ if let (amount, unit) = offlineAge { file.maxOfflineAge(amount, unit) }
420
+ if wipeWhenStale { file.wipeWhenStale() }
421
+ }
422
+ }
423
+
424
+ /// `unlockFile` prompt texts + the content encoding.
425
+ public static func unlockPrompt(_ json: String) throws -> (VaultFileUnlockPrompt, String) {
426
+ let p = try StrictJSON.parseObject(json, path: "prompt", maxChars: 16 * 1024)
427
+ let prompt = VaultFileUnlockPrompt(
428
+ title: try p.requireString("title", maxLength: 256),
429
+ subtitle: try p.string("subtitle", maxLength: 256),
430
+ description: try p.string("description", maxLength: 1024),
431
+ negativeButtonText: try p.string("negativeButtonText", maxLength: 64) ?? "Cancel"
432
+ )
433
+ let encoding = try ResultMapper.checkEncoding(try p.string("encoding", maxLength: 16) ?? ResultMapper.utf8)
434
+ try p.finish()
435
+ return (prompt, encoding)
436
+ }
437
+ }
@@ -0,0 +1,129 @@
1
+ // `environmentGuard` answered by a JS callback. Fail closed: no answer in time,
2
+ // an unknown request id — every one of them is a refusal.
3
+ //
4
+ // PinVault's `EnvironmentGuard.allows(_:)` is synchronous and runs on the
5
+ // calling task, i.e. on a thread of Swift's shared cooperative pool. Waiting
6
+ // there for JS would hold that thread for up to the timeout (30 s at most). So
7
+ // the bridge asks JS *before* it calls the library (`withVerdicts`): the wait
8
+ // is a suspended continuation, no thread is held, and the library's
9
+ // synchronous check answers at once from that verdict. Only an operation the
10
+ // library starts on its own (a background refresh picking up a pending
11
+ // enrollment, a periodic vault sync) finds no verdict and waits on its own
12
+ // thread, bounded by the timeout.
13
+ import Foundation
14
+ import PinVault
15
+
16
+ public final class JSEnvironmentGuard: EnvironmentGuard, @unchecked Sendable {
17
+ public static let defaultTimeoutMs: Int64 = 5000
18
+ public static let minTimeoutMs: Int64 = 100
19
+ public static let maxTimeoutMs: Int64 = 30_000
20
+
21
+ /// One question to JS; answered exactly once (by JS, or `false` at the deadline).
22
+ private final class Pending: @unchecked Sendable {
23
+ private let lock = NSLock()
24
+ private var done = false
25
+ private let deliver: (Bool) -> Void
26
+
27
+ init(_ deliver: @escaping (Bool) -> Void) { self.deliver = deliver }
28
+
29
+ func finish(_ allowed: Bool) {
30
+ lock.lock()
31
+ let first = !done
32
+ done = true
33
+ lock.unlock()
34
+ if first { deliver(allowed) }
35
+ }
36
+ }
37
+
38
+ private let timeoutMs: Int64
39
+ private let ask: (_ requestId: String, _ operation: String) -> Void
40
+ private let lock = NSLock()
41
+ private var pending: [String: Pending] = [:]
42
+ /// Verdicts asked before a bridge call, per operation, while that call runs.
43
+ private var verdicts: [GuardedOperation: [(UUID, Bool)]] = [:]
44
+
45
+ public init(timeoutMs: Int64, ask: @escaping (_ requestId: String, _ operation: String) -> Void) {
46
+ self.timeoutMs = timeoutMs
47
+ self.ask = ask
48
+ }
49
+
50
+ private func send(_ operation: GuardedOperation, _ deliver: @escaping (Bool) -> Void) {
51
+ let id = UUID().uuidString
52
+ let request = Pending { [weak self] allowed in
53
+ self?.lock.lock()
54
+ self?.pending.removeValue(forKey: id)
55
+ self?.lock.unlock()
56
+ deliver(allowed)
57
+ }
58
+ lock.lock(); pending[id] = request; lock.unlock()
59
+ DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(Int(timeoutMs))) { request.finish(false) }
60
+ ask(id, operation.rawValue)
61
+ }
62
+
63
+ /// JS's verdict on `operation`, waited for without holding a thread.
64
+ public func verdict(_ operation: GuardedOperation) async -> Bool {
65
+ await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
66
+ send(operation) { continuation.resume(returning: $0) }
67
+ }
68
+ }
69
+
70
+ /// Runs `body` (a library call) with JS's verdicts on `operations` asked
71
+ /// beforehand: the library's synchronous ``allows(_:)`` reads them.
72
+ public func withVerdicts<T>(_ operations: [GuardedOperation], _ body: () async throws -> T) async rethrows -> T {
73
+ var asked: [(GuardedOperation, Bool)] = []
74
+ for operation in operations { asked.append((operation, await verdict(operation))) }
75
+ let token = UUID()
76
+ hold(asked, token)
77
+ defer { release(asked.map(\.0), token) }
78
+ return try await body()
79
+ }
80
+
81
+ private func hold(_ asked: [(GuardedOperation, Bool)], _ token: UUID) {
82
+ lock.lock(); defer { lock.unlock() }
83
+ for (operation, allowed) in asked { verdicts[operation, default: []].append((token, allowed)) }
84
+ }
85
+
86
+ private func release(_ operations: [GuardedOperation], _ token: UUID) {
87
+ lock.lock(); defer { lock.unlock() }
88
+ for operation in operations { verdicts[operation]?.removeAll { $0.0 == token } }
89
+ }
90
+
91
+ public func allows(_ operation: GuardedOperation) throws -> Bool {
92
+ lock.lock()
93
+ let held = verdicts[operation] ?? []
94
+ lock.unlock()
95
+ // Asked before the bridge call that runs now: no waiting. A refusal wins.
96
+ if !held.isEmpty { return held.allSatisfy { $0.1 } }
97
+ // The library asks on its own: wait here, bounded by the timeout.
98
+ let semaphore = DispatchSemaphore(value: 0)
99
+ let result = Locked(false)
100
+ send(operation) { allowed in
101
+ result.set(allowed)
102
+ semaphore.signal()
103
+ }
104
+ guard semaphore.wait(timeout: .now() + .milliseconds(Int(timeoutMs) + 50)) == .success else { return false }
105
+ return result.get()
106
+ }
107
+
108
+ /// JS's verdict; ignored when the request is unknown or already timed out.
109
+ public func answer(_ requestId: String, allowed: Bool) {
110
+ lock.lock()
111
+ let request = pending[requestId]
112
+ lock.unlock()
113
+ request?.finish(allowed)
114
+ }
115
+
116
+ public var pendingCount: Int {
117
+ lock.lock(); defer { lock.unlock() }
118
+ return pending.count
119
+ }
120
+
121
+ /// A value behind a lock (the answer crosses threads).
122
+ private final class Locked<V>: @unchecked Sendable {
123
+ private let lock = NSLock()
124
+ private var value: V
125
+ init(_ value: V) { self.value = value }
126
+ func set(_ v: V) { lock.lock(); value = v; lock.unlock() }
127
+ func get() -> V { lock.lock(); defer { lock.unlock() }; return value }
128
+ }
129
+ }