@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,303 @@
|
|
|
1
|
+
// The app's native security file (the Swift twin of NativeSecurity.kt): the
|
|
2
|
+
// trust anchors and relaxations of `start(config)` declared outside the JS
|
|
3
|
+
// bundle, which an OTA update (CodePush, Expo Updates) or an edited bundle can
|
|
4
|
+
// change. The file is a resource of the app bundle (`pinvault_security.json`),
|
|
5
|
+
// which the code signature seals. Same JSON shape as a JS config subset:
|
|
6
|
+
//
|
|
7
|
+
// { "configApis": [{ "id", "bootstrapPins", "signaturePublicKeys", "requiredSignatures",
|
|
8
|
+
// "recoveryPublicKeys", "requiredRecoverySignatures", "serverScope",
|
|
9
|
+
// "clientCaPins", "allowUnsigned", "allowUnpinnedConfigApi",
|
|
10
|
+
// "allowServerGeneratedKey", "url", "attestation", "tokenHosts", "proofOfPossession",
|
|
11
|
+
// "clientCertHosts",
|
|
12
|
+
// "enrollmentUrl", "renewalUrl" }],
|
|
13
|
+
// "staticPins": { "pins": [...], "version": 1 },
|
|
14
|
+
// "require": { "requireUnlockedDevice", "requireHardwareBackedKeys", "managedTrustRoots",
|
|
15
|
+
// "wipeVaultFilesOnRevocation", "requireCaTrust", "expectedBundleIds",
|
|
16
|
+
// "expectedTeamIds", "userAuthStrength", "expiredConfigGraceSeconds",
|
|
17
|
+
// "vaultFileMaxOfflineAgeSeconds", "expectedSignerSha256" (Android's, read for the shape) },
|
|
18
|
+
// "vaultFiles": [{ "key", "signaturePublicKey", "encryption", "userAuth", "maxOfflineAgeSeconds" }] }
|
|
19
|
+
//
|
|
20
|
+
// When the file is there: every JS Config API (and JS `staticPins`) must be
|
|
21
|
+
// declared in it; a declared field is the value (JS may omit or repeat it, a
|
|
22
|
+
// different value is refused); the three relaxations need the file's consent;
|
|
23
|
+
// `attestation: true` and `proofOfPossession: true` cannot be switched off; `require` only tightens (its
|
|
24
|
+
// protections stay on, its `requireCaTrust` hosts are added to JS's, its
|
|
25
|
+
// expected ids are fixed, its grace is the most JS may ask for); `vaultFiles`
|
|
26
|
+
// fixes those files' signature key, encryption and lock, and a JS vault file
|
|
27
|
+
// it does not name is refused.
|
|
28
|
+
//
|
|
29
|
+
// Without it, a release build refuses a config with Config APIs or static
|
|
30
|
+
// pins — the trust anchors would come from JS alone — unless the app's
|
|
31
|
+
// Info.plist says `PinVaultAllowNoNativeSecurityFile` = YES; the three
|
|
32
|
+
// relaxations stay refused from JS then. A release build caps
|
|
33
|
+
// `expiredConfigGrace` at 7 days whatever the file says.
|
|
34
|
+
import Foundation
|
|
35
|
+
import PinVault
|
|
36
|
+
|
|
37
|
+
public struct NativeSecurity {
|
|
38
|
+
public static let resourceName = "pinvault_security"
|
|
39
|
+
static let maxFileChars = 256 * 1024
|
|
40
|
+
|
|
41
|
+
public struct Block {
|
|
42
|
+
let bootstrapPins: [HostPin]?
|
|
43
|
+
let signaturePublicKeys: [String]?
|
|
44
|
+
let requiredSignatures: Int?
|
|
45
|
+
let recoveryPublicKeys: [String]?
|
|
46
|
+
let requiredRecoverySignatures: Int?
|
|
47
|
+
let serverScope: String?
|
|
48
|
+
let clientCaPins: [String]?
|
|
49
|
+
let allowUnsigned: Bool
|
|
50
|
+
let allowUnpinnedConfigApi: Bool
|
|
51
|
+
let allowServerGeneratedKey: Bool
|
|
52
|
+
var url: String? = nil
|
|
53
|
+
var attestation = false
|
|
54
|
+
var tokenHosts: [String]? = nil
|
|
55
|
+
var proofOfPossession = false
|
|
56
|
+
var clientCertHosts: [String]? = nil
|
|
57
|
+
var enrollmentUrl: String? = nil
|
|
58
|
+
var renewalUrl: String? = nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// The `require` section: protections JS cannot switch off.
|
|
62
|
+
public struct Require {
|
|
63
|
+
var requireUnlockedDevice = false
|
|
64
|
+
var requireHardwareBackedKeys = false
|
|
65
|
+
var managedTrustRoots = false
|
|
66
|
+
var wipeVaultFilesOnRevocation = false
|
|
67
|
+
var requireCaTrust: [String]? = nil
|
|
68
|
+
var expectedBundleIds: [String]? = nil
|
|
69
|
+
var expectedTeamIds: [String]? = nil
|
|
70
|
+
/// The most `expiredConfigGrace` JS may set; nil = only the release cap.
|
|
71
|
+
var expiredConfigGraceMs: Int64? = nil
|
|
72
|
+
/// The most `vaultFileMaxOfflineAge` JS may set (and the value when JS sets none).
|
|
73
|
+
var vaultFileMaxOfflineAgeMs: Int64? = nil
|
|
74
|
+
/// `ios.userAuthStrength`, fixed.
|
|
75
|
+
var userAuthStrength: UserAuthStrength? = nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// One `vaultFiles` entry: what JS cannot change about that file.
|
|
79
|
+
struct VaultFilePolicy {
|
|
80
|
+
let signaturePublicKey: String?
|
|
81
|
+
let encryption: VaultFileEncryption?
|
|
82
|
+
let userAuth: UserAuth?
|
|
83
|
+
/// The most `maxOfflineAge` JS may give this file (and the value when JS gives none).
|
|
84
|
+
var maxOfflineAgeMs: Int64? = nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// The Info.plist key with which an app accepts a release start without the file.
|
|
88
|
+
public static let noFileInfoKey = "PinVaultAllowNoNativeSecurityFile"
|
|
89
|
+
/// The longest `expiredConfigGrace` a release build takes.
|
|
90
|
+
public static let maxReleaseGraceMs: Int64 = 7 * 24 * 3600 * 1000
|
|
91
|
+
static let maxOfflineAgeS: Int64 = 10 * 365 * 24 * 3600
|
|
92
|
+
|
|
93
|
+
/// Where the file came from, for messages.
|
|
94
|
+
public let source: String
|
|
95
|
+
let blocks: [String: Block]
|
|
96
|
+
let staticPins: CertificateConfig?
|
|
97
|
+
var require = Require()
|
|
98
|
+
/// Nil when the file has no `vaultFiles` section.
|
|
99
|
+
var vaultFiles: [String: VaultFilePolicy]? = nil
|
|
100
|
+
|
|
101
|
+
/// True when the app's Info.plist accepts a release start without the file.
|
|
102
|
+
public static func noFileAllowed(_ bundle: Bundle = .main) -> Bool {
|
|
103
|
+
(bundle.object(forInfoDictionaryKey: noFileInfoKey) as? Bool) == true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// The file from `bundle`; nil when the app ships none. A broken file throws `BridgeInputError`.
|
|
107
|
+
public static func load(from bundle: Bundle = .main) throws -> NativeSecurity? {
|
|
108
|
+
guard let url = bundle.url(forResource: resourceName, withExtension: "json") else { return nil }
|
|
109
|
+
let source = "\(resourceName).json (app bundle)"
|
|
110
|
+
guard let data = try? Data(contentsOf: url) else {
|
|
111
|
+
throw BridgeInputError("native security file \(source): cannot be read")
|
|
112
|
+
}
|
|
113
|
+
guard let text = String(data: data, encoding: .utf8) else {
|
|
114
|
+
throw BridgeInputError("native security file \(source): not UTF-8")
|
|
115
|
+
}
|
|
116
|
+
return try parse(text, source: source)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public static func parse(_ text: String, source: String) throws -> NativeSecurity {
|
|
120
|
+
do {
|
|
121
|
+
let root = try StrictJSON.parseObject(text, path: source, maxChars: maxFileChars)
|
|
122
|
+
var blocks: [String: Block] = [:]
|
|
123
|
+
for b in try root.objectList("configApis", maxItems: ConfigParser.maxConfigApis) ?? [] {
|
|
124
|
+
let id = try b.requireString("id", maxLength: 128)
|
|
125
|
+
if blocks[id] != nil { throw BridgeInputError("\(b.path).id: '\(id)' is declared twice") }
|
|
126
|
+
blocks[id] = Block(
|
|
127
|
+
bootstrapPins: try b.objectList("bootstrapPins", maxItems: ConfigParser.maxPins)?.map(ConfigParser.hostPin),
|
|
128
|
+
signaturePublicKeys: try b.stringList("signaturePublicKeys", maxItems: 16, maxLength: ConfigParser.keyLength, multiline: true),
|
|
129
|
+
requiredSignatures: try b.int("requiredSignatures", min: 1, max: 16),
|
|
130
|
+
recoveryPublicKeys: try b.stringList("recoveryPublicKeys", maxItems: 16, maxLength: ConfigParser.keyLength, multiline: true),
|
|
131
|
+
requiredRecoverySignatures: try b.int("requiredRecoverySignatures", min: 1, max: 16),
|
|
132
|
+
serverScope: try b.string("serverScope", maxLength: 128),
|
|
133
|
+
clientCaPins: try b.stringList("clientCaPins", maxItems: 16, maxLength: 128),
|
|
134
|
+
allowUnsigned: try b.bool("allowUnsigned") == true,
|
|
135
|
+
allowUnpinnedConfigApi: try b.bool("allowUnpinnedConfigApi") == true,
|
|
136
|
+
allowServerGeneratedKey: try b.bool("allowServerGeneratedKey") == true,
|
|
137
|
+
url: try ConfigParser.httpsUrl(b, "url"),
|
|
138
|
+
attestation: try b.bool("attestation") == true,
|
|
139
|
+
tokenHosts: try b.stringList("tokenHosts", maxItems: 64, maxLength: 255),
|
|
140
|
+
proofOfPossession: try b.bool("proofOfPossession") == true,
|
|
141
|
+
clientCertHosts: try b.stringList("clientCertHosts", maxItems: 64, maxLength: 2048),
|
|
142
|
+
enrollmentUrl: try ConfigParser.httpsUrl(b, "enrollmentUrl"),
|
|
143
|
+
renewalUrl: try ConfigParser.httpsUrl(b, "renewalUrl")
|
|
144
|
+
)
|
|
145
|
+
try b.finish()
|
|
146
|
+
}
|
|
147
|
+
let staticPins = try root.object("staticPins").map(ConfigParser.staticPins)
|
|
148
|
+
var require = Require()
|
|
149
|
+
if let r = try root.object("require") {
|
|
150
|
+
require.requireUnlockedDevice = try r.bool("requireUnlockedDevice") == true
|
|
151
|
+
require.requireHardwareBackedKeys = try r.bool("requireHardwareBackedKeys") == true
|
|
152
|
+
require.managedTrustRoots = try r.bool("managedTrustRoots") == true
|
|
153
|
+
require.wipeVaultFilesOnRevocation = try r.bool("wipeVaultFilesOnRevocation") == true
|
|
154
|
+
require.requireCaTrust = try r.stringList("requireCaTrust", maxItems: 64, maxLength: 255)
|
|
155
|
+
require.expectedBundleIds = try r.stringList("expectedBundleIds", maxItems: 16, maxLength: 255)
|
|
156
|
+
require.expectedTeamIds = try r.stringList("expectedTeamIds", maxItems: 16, maxLength: 32)
|
|
157
|
+
require.expiredConfigGraceMs = try r.int64("expiredConfigGraceSeconds", min: 0, max: maxReleaseGraceMs / 1000).map { $0 * 1000 }
|
|
158
|
+
require.vaultFileMaxOfflineAgeMs = try r.int64("vaultFileMaxOfflineAgeSeconds", min: 1, max: maxOfflineAgeS).map { $0 * 1000 }
|
|
159
|
+
require.userAuthStrength = try r.enumValue("userAuthStrength", UserAuthStrength.self)
|
|
160
|
+
// Android-only (signer digests): read for the shape, used by the Android side.
|
|
161
|
+
_ = try r.stringList("expectedSignerSha256", maxItems: 16, maxLength: 128)
|
|
162
|
+
try r.finish()
|
|
163
|
+
}
|
|
164
|
+
var vaultFiles: [String: VaultFilePolicy]?
|
|
165
|
+
if let list = try root.objectList("vaultFiles", maxItems: ConfigParser.maxVaultFiles) {
|
|
166
|
+
var map: [String: VaultFilePolicy] = [:]
|
|
167
|
+
for f in list {
|
|
168
|
+
let key = try f.requireString("key", maxLength: 128)
|
|
169
|
+
if map[key] != nil { throw BridgeInputError("\(f.path).key: '\(key)' is declared twice") }
|
|
170
|
+
map[key] = VaultFilePolicy(
|
|
171
|
+
signaturePublicKey: try f.string("signaturePublicKey", maxLength: ConfigParser.keyLength, multiline: true),
|
|
172
|
+
encryption: try f.enumValue("encryption", VaultFileEncryption.self),
|
|
173
|
+
userAuth: try f.enumValue("userAuth", UserAuth.self),
|
|
174
|
+
maxOfflineAgeMs: try f.int64("maxOfflineAgeSeconds", min: 1, max: maxOfflineAgeS).map { $0 * 1000 }
|
|
175
|
+
)
|
|
176
|
+
try f.finish()
|
|
177
|
+
}
|
|
178
|
+
vaultFiles = map
|
|
179
|
+
}
|
|
180
|
+
try root.finish()
|
|
181
|
+
if blocks.isEmpty && staticPins == nil {
|
|
182
|
+
throw BridgeInputError("\(source): declares neither configApis nor staticPins")
|
|
183
|
+
}
|
|
184
|
+
var native = NativeSecurity(source: source, blocks: blocks, staticPins: staticPins)
|
|
185
|
+
native.require = require
|
|
186
|
+
native.vaultFiles = vaultFiles
|
|
187
|
+
return native
|
|
188
|
+
} catch let e as BridgeInputError {
|
|
189
|
+
throw e.message.hasPrefix("native security file") ? e : BridgeInputError("native security file \(e.message)")
|
|
190
|
+
} catch {
|
|
191
|
+
throw BridgeInputError("native security file \(source): \((error as? PinVaultError)?.message ?? "\(error)")")
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// MARK: comparison (the order of lists does not matter)
|
|
196
|
+
|
|
197
|
+
static func sameKeys(_ a: [String], _ b: [String]) -> Bool {
|
|
198
|
+
Set(a.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }) == Set(b.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private static func pinKey(_ p: HostPin) -> String {
|
|
202
|
+
[p.hostname.lowercased(), p.sha256.sorted().joined(separator: ","), "\(p.version)", "\(p.forceUpdate)", "\(p.mtls)",
|
|
203
|
+
p.clientCertVersion.map { "\($0)" } ?? "nil"].joined(separator: "|")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
static func samePins(_ a: [HostPin], _ b: [HostPin]) -> Bool {
|
|
207
|
+
a.map(pinKey).sorted() == b.map(pinKey).sorted()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
static func sameStaticPins(_ a: CertificateConfig, _ b: CertificateConfig) -> Bool {
|
|
211
|
+
a.version == b.version && a.forceUpdate == b.forceUpdate && samePins(a.pins, b.pins)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// What JS asked for in one `configApis[i]` entry, before the native policy.
|
|
216
|
+
struct SecurityFields {
|
|
217
|
+
var bootstrapPins: [HostPin]?
|
|
218
|
+
var oneKey: String?
|
|
219
|
+
var keys: [String]?
|
|
220
|
+
var requiredSignatures: Int?
|
|
221
|
+
var recoveryKeys: [String]?
|
|
222
|
+
var requiredRecovery: Int?
|
|
223
|
+
var serverScope: String?
|
|
224
|
+
var clientCaPins: [String]?
|
|
225
|
+
var allowUnsigned: Bool
|
|
226
|
+
var allowUnpinned: Bool
|
|
227
|
+
var allowServerKey: Bool
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
enum SecurityPolicy {
|
|
231
|
+
static let relaxationsHint =
|
|
232
|
+
"a release build takes it only from the app's native security file (\(NativeSecurity.resourceName).json, README \"Native security file\")"
|
|
233
|
+
|
|
234
|
+
static func apply(path: String, id: String, js: SecurityFields, native: NativeSecurity?, release: Bool) throws -> SecurityFields {
|
|
235
|
+
guard let native else {
|
|
236
|
+
if release {
|
|
237
|
+
if js.allowUnsigned { throw BridgeInputError("\(path).allowUnsigned: refused from JS; \(relaxationsHint)") }
|
|
238
|
+
if js.allowUnpinned { throw BridgeInputError("\(path).allowUnpinnedConfigApi: refused from JS; \(relaxationsHint)") }
|
|
239
|
+
if js.allowServerKey { throw BridgeInputError("\(path).allowServerGeneratedKey: refused from JS; \(relaxationsHint)") }
|
|
240
|
+
}
|
|
241
|
+
return js
|
|
242
|
+
}
|
|
243
|
+
guard let block = native.blocks[id] else {
|
|
244
|
+
throw BridgeInputError("\(path): Config API '\(id)' is not declared in the app's native security file (\(native.source))")
|
|
245
|
+
}
|
|
246
|
+
func fixed(_ key: String) -> BridgeInputError {
|
|
247
|
+
BridgeInputError("\(path).\(key): differs from the app's native security file (\(native.source)); the native value is fixed")
|
|
248
|
+
}
|
|
249
|
+
func relaxation(_ key: String, _ asked: Bool, _ allowed: Bool) throws {
|
|
250
|
+
if asked && !allowed {
|
|
251
|
+
throw BridgeInputError("\(path).\(key): refused; the app's native security file (\(native.source)) does not allow it")
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try relaxation("allowUnsigned", js.allowUnsigned, block.allowUnsigned)
|
|
255
|
+
try relaxation("allowUnpinnedConfigApi", js.allowUnpinned, block.allowUnpinnedConfigApi)
|
|
256
|
+
try relaxation("allowServerGeneratedKey", js.allowServerKey, block.allowServerGeneratedKey)
|
|
257
|
+
|
|
258
|
+
var out = js
|
|
259
|
+
if let n = block.bootstrapPins {
|
|
260
|
+
if let j = js.bootstrapPins, !NativeSecurity.samePins(j, n) { throw fixed("bootstrapPins") }
|
|
261
|
+
out.bootstrapPins = n
|
|
262
|
+
}
|
|
263
|
+
if let n = block.signaturePublicKeys {
|
|
264
|
+
if let j = js.oneKey.map({ [$0] }) ?? js.keys {
|
|
265
|
+
if !NativeSecurity.sameKeys(j, n) { throw fixed(js.oneKey != nil ? "signaturePublicKey" : "signaturePublicKeys") }
|
|
266
|
+
} else {
|
|
267
|
+
out.keys = n
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
func one<T: Equatable>(_ key: String, _ n: T?, _ j: T?) throws -> T? {
|
|
271
|
+
guard let n else { return j }
|
|
272
|
+
if let j, j != n { throw fixed(key) }
|
|
273
|
+
return n
|
|
274
|
+
}
|
|
275
|
+
func list(_ key: String, _ n: [String]?, _ j: [String]?) throws -> [String]? {
|
|
276
|
+
guard let n else { return j }
|
|
277
|
+
if let j, !NativeSecurity.sameKeys(j, n) { throw fixed(key) }
|
|
278
|
+
return n
|
|
279
|
+
}
|
|
280
|
+
out.requiredSignatures = try one("requiredSignatures", block.requiredSignatures, js.requiredSignatures)
|
|
281
|
+
out.recoveryKeys = try list("recoveryPublicKeys", block.recoveryPublicKeys, js.recoveryKeys)
|
|
282
|
+
out.requiredRecovery = try one("requiredRecoverySignatures", block.requiredRecoverySignatures, js.requiredRecovery)
|
|
283
|
+
out.serverScope = try one("serverScope", block.serverScope, js.serverScope)
|
|
284
|
+
out.clientCaPins = try list("clientCaPins", block.clientCaPins, js.clientCaPins)
|
|
285
|
+
// The file is the source of truth for the relaxations too.
|
|
286
|
+
out.allowUnsigned = block.allowUnsigned
|
|
287
|
+
out.allowUnpinned = block.allowUnpinnedConfigApi
|
|
288
|
+
out.allowServerKey = block.allowServerGeneratedKey
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
static func staticPins(path: String, js: CertificateConfig?, native: NativeSecurity?) throws -> CertificateConfig? {
|
|
293
|
+
guard let native else { return js }
|
|
294
|
+
guard let n = native.staticPins else {
|
|
295
|
+
if js != nil { throw BridgeInputError("\(path): not declared in the app's native security file (\(native.source))") }
|
|
296
|
+
return nil
|
|
297
|
+
}
|
|
298
|
+
if let js, !NativeSecurity.sameStaticPins(js, n) {
|
|
299
|
+
throw BridgeInputError("\(path): differs from the app's native security file (\(native.source)); the native value is fixed")
|
|
300
|
+
}
|
|
301
|
+
return n
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The plugin's own `fetch` (the Swift twin of PinnedFetch.kt): parsed strictly,
|
|
2
|
+
// sent through the pinned session the library hands out
|
|
3
|
+
// (`PinVault.shared.session()` or `session(settings:)`), HTTPS only, bounded.
|
|
4
|
+
// PinVault's sessions never follow a redirect from https into plain http.
|
|
5
|
+
import Foundation
|
|
6
|
+
import PinVault
|
|
7
|
+
|
|
8
|
+
public struct FetchRequest {
|
|
9
|
+
public let url: URL
|
|
10
|
+
public let method: String
|
|
11
|
+
public let headers: [(String, String)]
|
|
12
|
+
public let body: Data?
|
|
13
|
+
public let responseEncoding: String
|
|
14
|
+
public let timeoutMs: Int64?
|
|
15
|
+
public let maxResponseBytes: Int64
|
|
16
|
+
public let settings: HttpConnectionSettings?
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public struct ResponseTooLargeError: Error, CustomStringConvertible {
|
|
20
|
+
public let limit: Int64
|
|
21
|
+
public var description: String { "Response larger than \(limit) bytes" }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public enum PinnedFetch {
|
|
25
|
+
public static let maxRequestBytes: Int64 = 10 * 1024 * 1024
|
|
26
|
+
public static let defaultMaxResponseBytes: Int64 = 10 * 1024 * 1024
|
|
27
|
+
public static let maxResponseBytesLimit: Int64 = 50 * 1024 * 1024
|
|
28
|
+
static let methods: Set<String> = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
29
|
+
static let tokenChars = CharacterSet(charactersIn: "!#$%&'*+.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-")
|
|
30
|
+
|
|
31
|
+
public static func parse(_ json: String) throws -> FetchRequest {
|
|
32
|
+
let f = try StrictJSON.parseObject(json, path: "request", maxChars: Int(maxRequestBytes * 4 / 3) + 64 * 1024)
|
|
33
|
+
let urlText = try f.requireString("url", maxLength: 8192)
|
|
34
|
+
guard let url = URL(string: urlText), url.host != nil else { throw BridgeInputError("request.url: not a valid URL") }
|
|
35
|
+
guard url.scheme?.lowercased() == "https" else {
|
|
36
|
+
throw BridgeInputError("request.url: only https:// URLs go through the pinned client")
|
|
37
|
+
}
|
|
38
|
+
let method = (try f.string("method", maxLength: 16) ?? "GET").uppercased()
|
|
39
|
+
guard methods.contains(method) else { throw BridgeInputError("request.method: '\(method)' is not supported") }
|
|
40
|
+
var headers: [(String, String)] = []
|
|
41
|
+
for (name, value) in (try f.stringMap("headers", maxItems: 128, maxKeyLength: 256, maxValueLength: 8192) ?? [:]).sorted(by: { $0.key < $1.key }) {
|
|
42
|
+
guard name.unicodeScalars.allSatisfy({ tokenChars.contains($0) }) else {
|
|
43
|
+
throw BridgeInputError("request.headers: '\(name.prefix(40))' is not a header name")
|
|
44
|
+
}
|
|
45
|
+
if value.unicodeScalars.contains(where: { $0 == "\r" || $0 == "\n" || $0.value == 0 }) {
|
|
46
|
+
throw BridgeInputError("request.headers.\(name): line breaks are not allowed")
|
|
47
|
+
}
|
|
48
|
+
headers.append((name, value))
|
|
49
|
+
}
|
|
50
|
+
let bodyText = try f.text("body", maxLength: Int(maxRequestBytes * 4 / 3) + 4)
|
|
51
|
+
let bodyEncoding = try ResultMapper.checkEncoding(try f.string("bodyEncoding", maxLength: 16) ?? ResultMapper.utf8)
|
|
52
|
+
var body: Data?
|
|
53
|
+
if let bodyText {
|
|
54
|
+
if bodyEncoding == ResultMapper.base64 {
|
|
55
|
+
guard let d = Data(base64Encoded: bodyText) else { throw BridgeInputError("request.body: not valid Base64") }
|
|
56
|
+
body = d
|
|
57
|
+
} else {
|
|
58
|
+
body = Data(bodyText.utf8)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if let body, Int64(body.count) > maxRequestBytes { throw BridgeInputError("request.body: larger than \(maxRequestBytes) bytes") }
|
|
62
|
+
if body != nil && (method == "GET" || method == "HEAD") { throw BridgeInputError("request.body: \(method) has no body") }
|
|
63
|
+
let responseEncoding = try ResultMapper.checkEncoding(try f.string("responseEncoding", maxLength: 16) ?? ResultMapper.utf8)
|
|
64
|
+
let timeoutMs = try f.int64("timeoutMs", min: 1, max: 10 * 60 * 1000)
|
|
65
|
+
let maxResponse = try f.int64("maxResponseBytes", min: 1, max: maxResponseBytesLimit) ?? defaultMaxResponseBytes
|
|
66
|
+
var settings: HttpConnectionSettings?
|
|
67
|
+
if let s = try f.object("settings") {
|
|
68
|
+
let d = HttpConnectionSettings()
|
|
69
|
+
settings = HttpConnectionSettings(
|
|
70
|
+
connectTimeout: try s.int64("connectTimeout", min: 0, max: 600) ?? d.connectTimeout,
|
|
71
|
+
readTimeout: try s.int64("readTimeout", min: 0, max: 600) ?? d.readTimeout,
|
|
72
|
+
writeTimeout: try s.int64("writeTimeout", min: 0, max: 600) ?? d.writeTimeout,
|
|
73
|
+
callTimeout: try s.int64("callTimeout", min: 0, max: 600) ?? d.callTimeout
|
|
74
|
+
)
|
|
75
|
+
try s.finish()
|
|
76
|
+
}
|
|
77
|
+
try f.finish()
|
|
78
|
+
return FetchRequest(
|
|
79
|
+
url: url, method: method, headers: headers, body: body, responseEncoding: responseEncoding,
|
|
80
|
+
timeoutMs: timeoutMs, maxResponseBytes: maxResponse, settings: settings
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public static func urlRequest(_ r: FetchRequest) -> URLRequest {
|
|
85
|
+
var request = URLRequest(url: r.url)
|
|
86
|
+
request.httpMethod = r.method
|
|
87
|
+
for (name, value) in r.headers { request.addValue(value, forHTTPHeaderField: name) }
|
|
88
|
+
request.httpBody = r.body
|
|
89
|
+
if let t = r.timeoutMs { request.timeoutInterval = Double(t) / 1000 }
|
|
90
|
+
return request
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Runs `request` on `session` (a pinned session from the library). The
|
|
94
|
+
/// library stops reading once the body passes `maxResponseBytes` (a
|
|
95
|
+
/// declared `Content-Length` over it is refused before the body is read).
|
|
96
|
+
public static func execute(_ session: PinnedSession, _ r: FetchRequest) async throws -> [String: Any] {
|
|
97
|
+
let (data, response) = try await session.data(for: urlRequest(r), maxResponseBytes: r.maxResponseBytes)
|
|
98
|
+
guard let http = response as? HTTPURLResponse else { throw BridgeInputError("not an HTTP response") }
|
|
99
|
+
return try map(http, data, r)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public static func map(_ http: HTTPURLResponse, _ data: Data, _ r: FetchRequest) throws -> [String: Any] {
|
|
103
|
+
// The transport enforced the bound while reading; this is a second look.
|
|
104
|
+
if Int64(data.count) > r.maxResponseBytes { throw ResponseTooLargeError(limit: r.maxResponseBytes) }
|
|
105
|
+
var headers: [String: String] = [:]
|
|
106
|
+
for (k, v) in http.allHeaderFields {
|
|
107
|
+
guard let name = (k as? String)?.lowercased() else { continue }
|
|
108
|
+
let value = "\(v)"
|
|
109
|
+
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
|
|
110
|
+
}
|
|
111
|
+
return [
|
|
112
|
+
"status": http.statusCode,
|
|
113
|
+
"url": http.url?.absoluteString ?? r.url.absoluteString,
|
|
114
|
+
"headers": headers,
|
|
115
|
+
"body": ResultMapper.encode(data, r.responseEncoding),
|
|
116
|
+
"bodyEncoding": r.responseEncoding,
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// React Native's own https traffic (fetch, XMLHttpRequest, <Image>) through
|
|
2
|
+
// PinVault's pinned session — the engine behind RNPinVaultURLRequestHandler,
|
|
3
|
+
// the RCTURLRequestHandler that RCTNetworking prefers over its
|
|
4
|
+
// RCTHTTPRequestHandler for https. Foundation only, so it is tested on the Mac.
|
|
5
|
+
//
|
|
6
|
+
// The rules are the Android hook's:
|
|
7
|
+
// - before `start()` (and after a start that failed: the library's session
|
|
8
|
+
// then refuses every request) every https request fails — fail closed;
|
|
9
|
+
// - pinning, `PinVault-Token`, pin-mismatch recovery: the library's session;
|
|
10
|
+
// a redirect from https into plain http is not followed (the library's
|
|
11
|
+
// sessions never follow one; the 3xx is the answer);
|
|
12
|
+
// - no cookies and no cache (the library's sessions are ephemeral, without a
|
|
13
|
+
// cookie store or URL cache);
|
|
14
|
+
// - the answer is bounded (`maxResponseBytes`, default 50 MiB): the library
|
|
15
|
+
// stops reading past it.
|
|
16
|
+
//
|
|
17
|
+
// The library hands out whole answers (`PinnedSession.data(for:)`), so RN
|
|
18
|
+
// gets the response, then the body in chunks, then the completion — after the
|
|
19
|
+
// body has been read; progress events arrive at the end.
|
|
20
|
+
import Foundation
|
|
21
|
+
|
|
22
|
+
public enum ReactNetworking {
|
|
23
|
+
public static let defaultMaxResponseBytes: Int64 = 50 * 1024 * 1024
|
|
24
|
+
public static let maxResponseBytesLimit: Int64 = 256 * 1024 * 1024
|
|
25
|
+
/// Body pieces handed to RN's delegate.
|
|
26
|
+
public static let chunkSize = 64 * 1024
|
|
27
|
+
public static let errorDomain = "PinVault"
|
|
28
|
+
|
|
29
|
+
/// What a request needs: `send` = the pinned session's bounded `data(for:maxResponseBytes:)`,
|
|
30
|
+
/// nil before `start()`.
|
|
31
|
+
public typealias Send = @Sendable (URLRequest, Int64) async throws -> (Data, URLResponse)
|
|
32
|
+
|
|
33
|
+
/// RN's `RCTURLRequestDelegate`, as blocks (Core does not link React).
|
|
34
|
+
public struct Callbacks: @unchecked Sendable {
|
|
35
|
+
public let didSendData: (Int64) -> Void
|
|
36
|
+
public let didReceiveResponse: (URLResponse) -> Void
|
|
37
|
+
public let didReceiveData: (Data) -> Void
|
|
38
|
+
public let didComplete: ((any Error)?) -> Void
|
|
39
|
+
|
|
40
|
+
public init(
|
|
41
|
+
didSendData: @escaping (Int64) -> Void,
|
|
42
|
+
didReceiveResponse: @escaping (URLResponse) -> Void,
|
|
43
|
+
didReceiveData: @escaping (Data) -> Void,
|
|
44
|
+
didComplete: @escaping ((any Error)?) -> Void
|
|
45
|
+
) {
|
|
46
|
+
self.didSendData = didSendData
|
|
47
|
+
self.didReceiveResponse = didReceiveResponse
|
|
48
|
+
self.didReceiveData = didReceiveData
|
|
49
|
+
self.didComplete = didComplete
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Only https: plain http stays with RN's own handler (ATS refuses it unless the app allows it).
|
|
54
|
+
public static func handles(_ request: URLRequest) -> Bool {
|
|
55
|
+
request.url?.scheme?.lowercased() == "https"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The error RN hands to JS (`fetch` rejects with "Network request failed").
|
|
59
|
+
public static func error(_ name: String, _ message: String) -> NSError {
|
|
60
|
+
NSError(domain: errorDomain, code: 1, userInfo: [
|
|
61
|
+
NSLocalizedDescriptionKey: "\(name): \(message)",
|
|
62
|
+
"exceptionName": name,
|
|
63
|
+
])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// ``prepare(_:send:maxResponseBytes:describe:callbacks:)`` + `resume()`.
|
|
67
|
+
@discardableResult
|
|
68
|
+
public static func start(
|
|
69
|
+
_ request: URLRequest,
|
|
70
|
+
send: Send?,
|
|
71
|
+
maxResponseBytes: Int64,
|
|
72
|
+
describe: @escaping @Sendable (any Error) -> (String, String),
|
|
73
|
+
callbacks: Callbacks
|
|
74
|
+
) -> ReactNetworkingTask {
|
|
75
|
+
let task = prepare(request, send: send, maxResponseBytes: maxResponseBytes, describe: describe, callbacks: callbacks)
|
|
76
|
+
task.resume()
|
|
77
|
+
return task
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// The request, not started yet: the task is the request token RN keeps,
|
|
81
|
+
/// and nothing reaches the delegate before `resume()` (RN's rule: the
|
|
82
|
+
/// token first, then the callbacks).
|
|
83
|
+
/// - Parameter describe: an error → (exception name, message) for JS.
|
|
84
|
+
public static func prepare(
|
|
85
|
+
_ request: URLRequest,
|
|
86
|
+
send: Send?,
|
|
87
|
+
maxResponseBytes: Int64,
|
|
88
|
+
describe: @escaping @Sendable (any Error) -> (String, String),
|
|
89
|
+
callbacks: Callbacks
|
|
90
|
+
) -> ReactNetworkingTask {
|
|
91
|
+
let task = ReactNetworkingTask(callbacks)
|
|
92
|
+
let sent = Int64(request.httpBody?.count ?? 0)
|
|
93
|
+
task.body = {
|
|
94
|
+
guard let send else {
|
|
95
|
+
throw ReactNetworking.error(
|
|
96
|
+
"IllegalStateException", "PinVault has not started: https requests are refused until start() returns"
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
do {
|
|
100
|
+
let (data, response) = try await send(request, maxResponseBytes)
|
|
101
|
+
return (data, response, sent)
|
|
102
|
+
} catch {
|
|
103
|
+
let (name, message) = describe(error)
|
|
104
|
+
throw ReactNetworking.error(name, message)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return task
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// One React Native request: its token, and its cancellation.
|
|
112
|
+
@objc(RNPinVaultReactNetworkingTask)
|
|
113
|
+
public final class ReactNetworkingTask: NSObject, @unchecked Sendable {
|
|
114
|
+
private let callbacks: ReactNetworking.Callbacks
|
|
115
|
+
private let lock = NSLock()
|
|
116
|
+
private var work: _Concurrency.Task<Void, Never>?
|
|
117
|
+
private var cancelled = false
|
|
118
|
+
/// Set by `prepare`, run by `resume`.
|
|
119
|
+
var body: (@Sendable () async throws -> (Data, URLResponse, Int64))?
|
|
120
|
+
|
|
121
|
+
init(_ callbacks: ReactNetworking.Callbacks) {
|
|
122
|
+
self.callbacks = callbacks
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public var isCancelled: Bool {
|
|
126
|
+
lock.lock(); defer { lock.unlock() }
|
|
127
|
+
return cancelled
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// RN cancelled (`cancelRequest:`): the request stops, the delegate hears nothing more.
|
|
131
|
+
@objc public func cancel() {
|
|
132
|
+
lock.lock()
|
|
133
|
+
cancelled = true
|
|
134
|
+
let w = work
|
|
135
|
+
lock.unlock()
|
|
136
|
+
w?.cancel()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Sends the request (once).
|
|
140
|
+
@objc public func resume() {
|
|
141
|
+
lock.lock()
|
|
142
|
+
let body = self.body
|
|
143
|
+
self.body = nil
|
|
144
|
+
lock.unlock()
|
|
145
|
+
guard let body else { return }
|
|
146
|
+
let callbacks = self.callbacks
|
|
147
|
+
let w = _Concurrency.Task.detached { [self] in
|
|
148
|
+
do {
|
|
149
|
+
let (data, response, sent) = try await body()
|
|
150
|
+
if isCancelled { return }
|
|
151
|
+
if sent > 0 { callbacks.didSendData(sent) }
|
|
152
|
+
callbacks.didReceiveResponse(response)
|
|
153
|
+
var offset = 0
|
|
154
|
+
while offset < data.count {
|
|
155
|
+
if isCancelled { return }
|
|
156
|
+
let end = min(offset + ReactNetworking.chunkSize, data.count)
|
|
157
|
+
callbacks.didReceiveData(data.subdata(in: offset..<end))
|
|
158
|
+
offset = end
|
|
159
|
+
}
|
|
160
|
+
callbacks.didComplete(nil)
|
|
161
|
+
} catch {
|
|
162
|
+
if isCancelled { return }
|
|
163
|
+
callbacks.didComplete(error)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
lock.lock()
|
|
167
|
+
work = w
|
|
168
|
+
let alreadyCancelled = cancelled
|
|
169
|
+
lock.unlock()
|
|
170
|
+
if alreadyCancelled { w.cancel() }
|
|
171
|
+
}
|
|
172
|
+
}
|