@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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +437 -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 +396 -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 +354 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -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 +437 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +303 -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 +424 -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 +331 -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 +359 -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 +398 -0
- 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
|
+
}
|