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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +386 -2
  4. package/RNPinVault.podspec +43 -0
  5. package/android/build.gradle +68 -0
  6. package/android/consumer-rules.pro +25 -0
  7. package/android/src/main/AndroidManifest.xml +16 -0
  8. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +300 -0
  9. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
  10. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +239 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -0
  13. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
  14. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
  15. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
  16. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
  17. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
  18. package/ios/Core/ConfigParser.swift +276 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +197 -0
  21. package/ios/Core/PinnedFetch.swift +119 -0
  22. package/ios/Core/ReactNetworking.swift +172 -0
  23. package/ios/Core/ResultMapper.swift +163 -0
  24. package/ios/Core/StrictJSON.swift +214 -0
  25. package/ios/Core/VaultTokenStore.swift +66 -0
  26. package/ios/PinVaultBridge.swift +412 -0
  27. package/ios/PinVaultReactNetworking.swift +47 -0
  28. package/ios/RNPinVault.h +7 -0
  29. package/ios/RNPinVault.mm +293 -0
  30. package/ios/RNPinVaultURLRequestHandler.mm +92 -0
  31. package/lib/module/NativePinVault.js +13 -0
  32. package/lib/module/NativePinVault.js.map +1 -0
  33. package/lib/module/index.js +312 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/types.js +2 -0
  37. package/lib/module/types.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/NativePinVault.d.ts +50 -0
  40. package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +150 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/lib/typescript/src/types.d.ts +346 -0
  44. package/lib/typescript/src/types.d.ts.map +1 -0
  45. package/package.json +128 -4
  46. package/src/NativePinVault.ts +72 -0
  47. package/src/index.ts +385 -0
  48. package/src/types.ts +351 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,197 @@
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" }],
11
+ // "staticPins": { "pins": [...], "version": 1 } }
12
+ //
13
+ // When the file is there: every JS Config API (and JS `staticPins`) must be
14
+ // declared in it; a declared field is the value (JS may omit or repeat it, a
15
+ // different value is refused); the three relaxations need the file's consent.
16
+ // Without it, release builds refuse the three relaxations from JS.
17
+ import Foundation
18
+ import PinVault
19
+
20
+ public struct NativeSecurity {
21
+ public static let resourceName = "pinvault_security"
22
+ static let maxFileChars = 256 * 1024
23
+
24
+ public struct Block {
25
+ let bootstrapPins: [HostPin]?
26
+ let signaturePublicKeys: [String]?
27
+ let requiredSignatures: Int?
28
+ let recoveryPublicKeys: [String]?
29
+ let requiredRecoverySignatures: Int?
30
+ let serverScope: String?
31
+ let clientCaPins: [String]?
32
+ let allowUnsigned: Bool
33
+ let allowUnpinnedConfigApi: Bool
34
+ let allowServerGeneratedKey: Bool
35
+ }
36
+
37
+ /// Where the file came from, for messages.
38
+ public let source: String
39
+ let blocks: [String: Block]
40
+ let staticPins: CertificateConfig?
41
+
42
+ /// The file from `bundle`; nil when the app ships none. A broken file throws `BridgeInputError`.
43
+ public static func load(from bundle: Bundle = .main) throws -> NativeSecurity? {
44
+ guard let url = bundle.url(forResource: resourceName, withExtension: "json") else { return nil }
45
+ let source = "\(resourceName).json (app bundle)"
46
+ guard let data = try? Data(contentsOf: url) else {
47
+ throw BridgeInputError("native security file \(source): cannot be read")
48
+ }
49
+ guard let text = String(data: data, encoding: .utf8) else {
50
+ throw BridgeInputError("native security file \(source): not UTF-8")
51
+ }
52
+ return try parse(text, source: source)
53
+ }
54
+
55
+ public static func parse(_ text: String, source: String) throws -> NativeSecurity {
56
+ do {
57
+ let root = try StrictJSON.parseObject(text, path: source, maxChars: maxFileChars)
58
+ var blocks: [String: Block] = [:]
59
+ for b in try root.objectList("configApis", maxItems: ConfigParser.maxConfigApis) ?? [] {
60
+ let id = try b.requireString("id", maxLength: 128)
61
+ if blocks[id] != nil { throw BridgeInputError("\(b.path).id: '\(id)' is declared twice") }
62
+ blocks[id] = Block(
63
+ bootstrapPins: try b.objectList("bootstrapPins", maxItems: ConfigParser.maxPins)?.map(ConfigParser.hostPin),
64
+ signaturePublicKeys: try b.stringList("signaturePublicKeys", maxItems: 16, maxLength: ConfigParser.keyLength, multiline: true),
65
+ requiredSignatures: try b.int("requiredSignatures", min: 1, max: 16),
66
+ recoveryPublicKeys: try b.stringList("recoveryPublicKeys", maxItems: 16, maxLength: ConfigParser.keyLength, multiline: true),
67
+ requiredRecoverySignatures: try b.int("requiredRecoverySignatures", min: 1, max: 16),
68
+ serverScope: try b.string("serverScope", maxLength: 128),
69
+ clientCaPins: try b.stringList("clientCaPins", maxItems: 16, maxLength: 128),
70
+ allowUnsigned: try b.bool("allowUnsigned") == true,
71
+ allowUnpinnedConfigApi: try b.bool("allowUnpinnedConfigApi") == true,
72
+ allowServerGeneratedKey: try b.bool("allowServerGeneratedKey") == true
73
+ )
74
+ try b.finish()
75
+ }
76
+ let staticPins = try root.object("staticPins").map(ConfigParser.staticPins)
77
+ try root.finish()
78
+ if blocks.isEmpty && staticPins == nil {
79
+ throw BridgeInputError("\(source): declares neither configApis nor staticPins")
80
+ }
81
+ return NativeSecurity(source: source, blocks: blocks, staticPins: staticPins)
82
+ } catch let e as BridgeInputError {
83
+ throw e.message.hasPrefix("native security file") ? e : BridgeInputError("native security file \(e.message)")
84
+ } catch {
85
+ throw BridgeInputError("native security file \(source): \((error as? PinVaultError)?.message ?? "\(error)")")
86
+ }
87
+ }
88
+
89
+ // MARK: comparison (the order of lists does not matter)
90
+
91
+ static func sameKeys(_ a: [String], _ b: [String]) -> Bool {
92
+ Set(a.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }) == Set(b.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })
93
+ }
94
+
95
+ private static func pinKey(_ p: HostPin) -> String {
96
+ [p.hostname.lowercased(), p.sha256.sorted().joined(separator: ","), "\(p.version)", "\(p.forceUpdate)", "\(p.mtls)",
97
+ p.clientCertVersion.map { "\($0)" } ?? "nil"].joined(separator: "|")
98
+ }
99
+
100
+ static func samePins(_ a: [HostPin], _ b: [HostPin]) -> Bool {
101
+ a.map(pinKey).sorted() == b.map(pinKey).sorted()
102
+ }
103
+
104
+ static func sameStaticPins(_ a: CertificateConfig, _ b: CertificateConfig) -> Bool {
105
+ a.version == b.version && a.forceUpdate == b.forceUpdate && samePins(a.pins, b.pins)
106
+ }
107
+ }
108
+
109
+ /// What JS asked for in one `configApis[i]` entry, before the native policy.
110
+ struct SecurityFields {
111
+ var bootstrapPins: [HostPin]?
112
+ var oneKey: String?
113
+ var keys: [String]?
114
+ var requiredSignatures: Int?
115
+ var recoveryKeys: [String]?
116
+ var requiredRecovery: Int?
117
+ var serverScope: String?
118
+ var clientCaPins: [String]?
119
+ var allowUnsigned: Bool
120
+ var allowUnpinned: Bool
121
+ var allowServerKey: Bool
122
+ }
123
+
124
+ enum SecurityPolicy {
125
+ static let relaxationsHint =
126
+ "a release build takes it only from the app's native security file (\(NativeSecurity.resourceName).json, README \"Native security file\")"
127
+
128
+ static func apply(path: String, id: String, js: SecurityFields, native: NativeSecurity?, release: Bool) throws -> SecurityFields {
129
+ guard let native else {
130
+ if release {
131
+ if js.allowUnsigned { throw BridgeInputError("\(path).allowUnsigned: refused from JS; \(relaxationsHint)") }
132
+ if js.allowUnpinned { throw BridgeInputError("\(path).allowUnpinnedConfigApi: refused from JS; \(relaxationsHint)") }
133
+ if js.allowServerKey { throw BridgeInputError("\(path).allowServerGeneratedKey: refused from JS; \(relaxationsHint)") }
134
+ }
135
+ return js
136
+ }
137
+ guard let block = native.blocks[id] else {
138
+ throw BridgeInputError("\(path): Config API '\(id)' is not declared in the app's native security file (\(native.source))")
139
+ }
140
+ func fixed(_ key: String) -> BridgeInputError {
141
+ BridgeInputError("\(path).\(key): differs from the app's native security file (\(native.source)); the native value is fixed")
142
+ }
143
+ func relaxation(_ key: String, _ asked: Bool, _ allowed: Bool) throws {
144
+ if asked && !allowed {
145
+ throw BridgeInputError("\(path).\(key): refused; the app's native security file (\(native.source)) does not allow it")
146
+ }
147
+ }
148
+ try relaxation("allowUnsigned", js.allowUnsigned, block.allowUnsigned)
149
+ try relaxation("allowUnpinnedConfigApi", js.allowUnpinned, block.allowUnpinnedConfigApi)
150
+ try relaxation("allowServerGeneratedKey", js.allowServerKey, block.allowServerGeneratedKey)
151
+
152
+ var out = js
153
+ if let n = block.bootstrapPins {
154
+ if let j = js.bootstrapPins, !NativeSecurity.samePins(j, n) { throw fixed("bootstrapPins") }
155
+ out.bootstrapPins = n
156
+ }
157
+ if let n = block.signaturePublicKeys {
158
+ if let j = js.oneKey.map({ [$0] }) ?? js.keys {
159
+ if !NativeSecurity.sameKeys(j, n) { throw fixed(js.oneKey != nil ? "signaturePublicKey" : "signaturePublicKeys") }
160
+ } else {
161
+ out.keys = n
162
+ }
163
+ }
164
+ func one<T: Equatable>(_ key: String, _ n: T?, _ j: T?) throws -> T? {
165
+ guard let n else { return j }
166
+ if let j, j != n { throw fixed(key) }
167
+ return n
168
+ }
169
+ func list(_ key: String, _ n: [String]?, _ j: [String]?) throws -> [String]? {
170
+ guard let n else { return j }
171
+ if let j, !NativeSecurity.sameKeys(j, n) { throw fixed(key) }
172
+ return n
173
+ }
174
+ out.requiredSignatures = try one("requiredSignatures", block.requiredSignatures, js.requiredSignatures)
175
+ out.recoveryKeys = try list("recoveryPublicKeys", block.recoveryPublicKeys, js.recoveryKeys)
176
+ out.requiredRecovery = try one("requiredRecoverySignatures", block.requiredRecoverySignatures, js.requiredRecovery)
177
+ out.serverScope = try one("serverScope", block.serverScope, js.serverScope)
178
+ out.clientCaPins = try list("clientCaPins", block.clientCaPins, js.clientCaPins)
179
+ // The file is the source of truth for the relaxations too.
180
+ out.allowUnsigned = block.allowUnsigned
181
+ out.allowUnpinned = block.allowUnpinnedConfigApi
182
+ out.allowServerKey = block.allowServerGeneratedKey
183
+ return out
184
+ }
185
+
186
+ static func staticPins(path: String, js: CertificateConfig?, native: NativeSecurity?) throws -> CertificateConfig? {
187
+ guard let native else { return js }
188
+ guard let n = native.staticPins else {
189
+ if js != nil { throw BridgeInputError("\(path): not declared in the app's native security file (\(native.source))") }
190
+ return nil
191
+ }
192
+ if let js, !NativeSecurity.sameStaticPins(js, n) {
193
+ throw BridgeInputError("\(path): differs from the app's native security file (\(native.source)); the native value is fixed")
194
+ }
195
+ return n
196
+ }
197
+ }
@@ -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
+ }