@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.
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +386 -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 +300 -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 +239 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -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 +276 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +197 -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 +412 -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 +312 -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 +346 -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 +385 -0
- 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
|
+
}
|