@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,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
+ }