@umutcansu/react-native-pinvault 0.0.0-stage → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +437 -2
- package/RNPinVault.podspec +43 -0
- package/android/build.gradle +68 -0
- package/android/consumer-rules.pro +25 -0
- package/android/src/main/AndroidManifest.xml +16 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +396 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +354 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
- package/ios/Core/ConfigParser.swift +437 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +303 -0
- package/ios/Core/PinnedFetch.swift +119 -0
- package/ios/Core/ReactNetworking.swift +172 -0
- package/ios/Core/ResultMapper.swift +163 -0
- package/ios/Core/StrictJSON.swift +214 -0
- package/ios/Core/VaultTokenStore.swift +66 -0
- package/ios/PinVaultBridge.swift +424 -0
- package/ios/PinVaultReactNetworking.swift +47 -0
- package/ios/RNPinVault.h +7 -0
- package/ios/RNPinVault.mm +293 -0
- package/ios/RNPinVaultURLRequestHandler.mm +92 -0
- package/lib/module/NativePinVault.js +13 -0
- package/lib/module/NativePinVault.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePinVault.d.ts +50 -0
- package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +150 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +359 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +128 -4
- package/src/NativePinVault.ts +72 -0
- package/src/index.ts +398 -0
- package/src/types.ts +362 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import android.content.ContentProvider
|
|
4
|
+
import android.content.ContentValues
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.database.Cursor
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import com.facebook.react.modules.network.CustomClientBuilder
|
|
10
|
+
import com.facebook.react.modules.network.NetworkingModule
|
|
11
|
+
import com.facebook.react.modules.network.OkHttpClientFactory
|
|
12
|
+
import com.facebook.react.modules.network.OkHttpClientProvider
|
|
13
|
+
import io.github.umutcansu.pinvault.PinVault
|
|
14
|
+
import okhttp3.Cache
|
|
15
|
+
import okhttp3.CookieJar
|
|
16
|
+
import okhttp3.Interceptor
|
|
17
|
+
import okhttp3.OkHttpClient
|
|
18
|
+
import java.io.File
|
|
19
|
+
import java.io.IOException
|
|
20
|
+
import java.net.InetAddress
|
|
21
|
+
import java.net.Socket
|
|
22
|
+
import java.security.cert.X509Certificate
|
|
23
|
+
import javax.net.ssl.SSLHandshakeException
|
|
24
|
+
import javax.net.ssl.SSLSocketFactory
|
|
25
|
+
import javax.net.ssl.X509TrustManager
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pins React Native's own networking (global `fetch` / `XMLHttpRequest`,
|
|
29
|
+
* `WebSocket`, and `<Image>` through Fresco) with `PinVault.applyTo(builder)` —
|
|
30
|
+
* Android only.
|
|
31
|
+
*
|
|
32
|
+
* Installed **automatically before the app's code runs**: the plugin's manifest
|
|
33
|
+
* declares [PinVaultNetworkingInitializer], a `ContentProvider`, and Android
|
|
34
|
+
* creates content providers before `Application.onCreate` (the AndroidX
|
|
35
|
+
* Startup pattern, without the dependency). [install] stays as an explicit
|
|
36
|
+
* alternative for apps that remove the provider.
|
|
37
|
+
*
|
|
38
|
+
* Two hooks, both verified against React Native 0.87's sources:
|
|
39
|
+
*
|
|
40
|
+
* - `NetworkingModule.setCustomClientBuilder`: RN calls it for **every**
|
|
41
|
+
* fetch / XHR request on `client.newBuilder()`. Each request gets the
|
|
42
|
+
* pinned socket factory and PinVault's interceptors (pin check of pooled
|
|
43
|
+
* connections, `PinVault-Token`, pin-mismatch recovery) of one client that
|
|
44
|
+
* `PinVault.applyTo` configured after `start()` — one per start, so pooled
|
|
45
|
+
* connections are reused — no disk HTTP cache and no cookie jar (unless
|
|
46
|
+
* `android.keepReactNativeHttpCache` / `keepReactNativeCookies`), and no
|
|
47
|
+
* `https` → `http` redirects.
|
|
48
|
+
* - `OkHttpClientProvider.setOkHttpClientFactory`: the clients RN builds once
|
|
49
|
+
* and keeps (the networking base client, the WebSocket / dev-support
|
|
50
|
+
* singleton, Fresco's image client) get a forwarding socket factory: every
|
|
51
|
+
* TLS handshake uses the pinned factory of the current start. No disk
|
|
52
|
+
* cache, no `https` → `http` redirects.
|
|
53
|
+
*
|
|
54
|
+
* Before `start()` — and after a start that failed — every `https` request
|
|
55
|
+
* through RN's networking fails (`IOException: PinVault has not started`),
|
|
56
|
+
* the same fail-closed rule as the library's own `getClient()` / `applyTo`,
|
|
57
|
+
* which refuse before `init`. Plain `http` is not TLS and is left to the
|
|
58
|
+
* app's network security config (Metro in debug builds uses it). Hosts
|
|
59
|
+
* without a pin entry are refused by PinVault's trust manager like
|
|
60
|
+
* everywhere else.
|
|
61
|
+
*
|
|
62
|
+
* Another library that calls `setOkHttpClientFactory` or
|
|
63
|
+
* `setCustomClientBuilder` after PinVault replaces these hooks: [status]
|
|
64
|
+
* tells, `start()` and every plugin `fetch` check it and log a warning, and
|
|
65
|
+
* `requirePinnedReactNativeNetworking: true` makes `start()` fail.
|
|
66
|
+
*/
|
|
67
|
+
object PinVaultNetworking {
|
|
68
|
+
|
|
69
|
+
private const val TAG = "PinVault"
|
|
70
|
+
|
|
71
|
+
@Volatile private var installed = false
|
|
72
|
+
@Volatile private var app: Context? = null
|
|
73
|
+
|
|
74
|
+
/** The two hooks this object installed; compared by identity in [status]. */
|
|
75
|
+
private val factory = OkHttpClientFactory {
|
|
76
|
+
pinning.track(pinning.configureLongLived(OkHttpClientProvider.createClientBuilder()).build())
|
|
77
|
+
}
|
|
78
|
+
private val clientBuilder = CustomClientBuilder { builder -> pinning.configurePerRequest(builder) }
|
|
79
|
+
|
|
80
|
+
internal val pinning = NetworkPinning(
|
|
81
|
+
applier = { builder -> PinVault.applyTo(builder) },
|
|
82
|
+
httpCache = { app?.let { ReactHttpCache.get(it) } },
|
|
83
|
+
hooksPinned = { status().pinned },
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
/** True when [install] ran only from `start()`: React Native may have built clients before it. */
|
|
87
|
+
@Volatile private var installedLate = false
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Installs both hooks (once per process). The plugin's content provider
|
|
91
|
+
* calls it before `Application.onCreate`; call it yourself in
|
|
92
|
+
* `MainApplication.onCreate`, before `loadReactNative`, only when the app
|
|
93
|
+
* removed that provider.
|
|
94
|
+
*/
|
|
95
|
+
@JvmStatic
|
|
96
|
+
@Synchronized
|
|
97
|
+
fun install(context: Context) = install(context, late = false)
|
|
98
|
+
|
|
99
|
+
/** [late]: called from `start()`, after React Native may already have built its clients. */
|
|
100
|
+
@Synchronized
|
|
101
|
+
internal fun install(context: Context, late: Boolean) {
|
|
102
|
+
if (installed) return
|
|
103
|
+
app = context.applicationContext
|
|
104
|
+
OkHttpClientProvider.setOkHttpClientFactory(factory)
|
|
105
|
+
NetworkingModule.setCustomClientBuilder(clientBuilder)
|
|
106
|
+
installed = true
|
|
107
|
+
installedLate = late
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@JvmStatic
|
|
111
|
+
val isInstalled: Boolean get() = installed
|
|
112
|
+
|
|
113
|
+
/** Whether React Native's two networking hooks are still the ones [install] set. */
|
|
114
|
+
@JvmStatic
|
|
115
|
+
fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder, installedLate)
|
|
116
|
+
|
|
117
|
+
private val warned = java.util.concurrent.atomic.AtomicReference<HookStatus?>(null)
|
|
118
|
+
|
|
119
|
+
/** Logs a warning when the hooks are not PinVault's, once per distinct state. */
|
|
120
|
+
internal fun warnIfNotPinned(where: String): HookStatus {
|
|
121
|
+
val s = status()
|
|
122
|
+
if (!s.pinned && warned.getAndSet(s) != s) Log.w(TAG, "$where: ${s.describe()}")
|
|
123
|
+
if (s.pinned) warned.set(null)
|
|
124
|
+
return s
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What React Native's networking hooks are now. `null` = could not be read
|
|
130
|
+
* (a React Native version whose internals moved): treated as not pinned.
|
|
131
|
+
*/
|
|
132
|
+
data class HookStatus(
|
|
133
|
+
val installed: Boolean,
|
|
134
|
+
/** `OkHttpClientProvider`'s factory is PinVault's. */
|
|
135
|
+
val factoryIsPinVault: Boolean?,
|
|
136
|
+
/** `NetworkingModule`'s custom client builder is PinVault's. */
|
|
137
|
+
val clientBuilderIsPinVault: Boolean?,
|
|
138
|
+
/** Installed only by `start()`: clients React Native built before (Fresco's, among them) keep the system's trust. */
|
|
139
|
+
val installedLate: Boolean = false,
|
|
140
|
+
) {
|
|
141
|
+
val pinned: Boolean get() = installed && !installedLate && factoryIsPinVault == true && clientBuilderIsPinVault == true
|
|
142
|
+
|
|
143
|
+
fun describe(): String = when {
|
|
144
|
+
pinned -> "React Native's networking is pinned by PinVault"
|
|
145
|
+
installed && installedLate -> "React Native's networking hooks were installed only by start() (the plugin's content provider " +
|
|
146
|
+
"was removed and PinVaultNetworking.install was not called before React Native started): clients it built " +
|
|
147
|
+
"before, such as the image client, are NOT pinned"
|
|
148
|
+
!installed -> "React Native's networking hooks are not installed (the plugin's content provider was removed " +
|
|
149
|
+
"and PinVaultNetworking.install was not called, or android.pinGlobalNetworking is false): " +
|
|
150
|
+
"RN fetch / XHR / WebSocket / images are NOT pinned"
|
|
151
|
+
else -> buildList {
|
|
152
|
+
if (factoryIsPinVault != true) add("OkHttpClientProvider's client factory " + if (factoryIsPinVault == null) "could not be read" else "was replaced by another library")
|
|
153
|
+
if (clientBuilderIsPinVault != true) add("NetworkingModule's custom client builder " + if (clientBuilderIsPinVault == null) "could not be read" else "was replaced by another library")
|
|
154
|
+
}.joinToString("; ") + ": React Native's own networking may NOT be pinned"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
internal companion object {
|
|
158
|
+
fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any, installedLate: Boolean = false): HookStatus = HookStatus(
|
|
159
|
+
installed,
|
|
160
|
+
isOurs(OkHttpClientProvider::class.java, "factory", ourFactory),
|
|
161
|
+
isOurs(NetworkingModule::class.java, "customClientBuilder", ourBuilder),
|
|
162
|
+
installedLate,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether RN's private static field [name] holds [ours]; null when it
|
|
167
|
+
* cannot be read. The plugin's consumer R8 rules keep both fields' names.
|
|
168
|
+
*/
|
|
169
|
+
private fun isOurs(owner: Class<*>, name: String, ours: Any): Boolean? = try {
|
|
170
|
+
owner.getDeclaredField(name).apply { isAccessible = true }.get(null) === ours
|
|
171
|
+
} catch (_: ReflectiveOperationException) {
|
|
172
|
+
null
|
|
173
|
+
} catch (_: RuntimeException) {
|
|
174
|
+
null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Installs [PinVaultNetworking] when the app process starts, before
|
|
181
|
+
* `Application.onCreate`. Opt out in the app's manifest:
|
|
182
|
+
*
|
|
183
|
+
* ```xml
|
|
184
|
+
* <provider android:name="io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer"
|
|
185
|
+
* android:authorities="${applicationId}.pinvault-networking" tools:node="remove" />
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
class PinVaultNetworkingInitializer : ContentProvider() {
|
|
189
|
+
override fun onCreate(): Boolean {
|
|
190
|
+
context?.let { PinVaultNetworking.install(it) }
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
|
195
|
+
override fun getType(uri: Uri): String? = null
|
|
196
|
+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
|
197
|
+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
198
|
+
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** RN's own 10 MiB disk cache (`OkHttpClientProvider.createClientBuilder(context)`), only with `keepReactNativeHttpCache`. */
|
|
202
|
+
internal object ReactHttpCache {
|
|
203
|
+
@Volatile private var cache: Cache? = null
|
|
204
|
+
|
|
205
|
+
fun get(context: Context): Cache = cache ?: synchronized(this) {
|
|
206
|
+
cache ?: Cache(File(context.cacheDir, "http-cache"), 10L * 1024 * 1024).also { cache = it }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The mechanics of [PinVaultNetworking], with the `applyTo` call injected so
|
|
212
|
+
* that JVM tests can run it without a device.
|
|
213
|
+
*/
|
|
214
|
+
internal class NetworkPinning(
|
|
215
|
+
private val applier: (OkHttpClient.Builder) -> Unit,
|
|
216
|
+
private val httpCache: () -> Cache? = { null },
|
|
217
|
+
/** Whether React Native's hooks are still PinVault's (``PinVaultNetworking.status``). */
|
|
218
|
+
private val hooksPinned: () -> Boolean = { true },
|
|
219
|
+
) {
|
|
220
|
+
|
|
221
|
+
/** The long-lived clients React Native built through the factory: their pools are emptied on every change. */
|
|
222
|
+
private val longLived = java.util.Collections.synchronizedList(mutableListOf<java.lang.ref.WeakReference<OkHttpClient>>())
|
|
223
|
+
|
|
224
|
+
/** Last answer of [hooksPinned] and when it was read (reflection: not on every request). */
|
|
225
|
+
@Volatile private var hooksCheckedAt = 0L
|
|
226
|
+
@Volatile private var hooksWerePinned = true
|
|
227
|
+
|
|
228
|
+
fun track(client: OkHttpClient): OkHttpClient {
|
|
229
|
+
longLived.add(java.lang.ref.WeakReference(client))
|
|
230
|
+
return client
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Closes the idle pooled connections of every long-lived client: the next request makes a fresh, pinned handshake. */
|
|
234
|
+
private fun evictLongLived() {
|
|
235
|
+
synchronized(longLived) {
|
|
236
|
+
longLived.removeAll { it.get() == null }
|
|
237
|
+
longLived.forEach { it.get()?.connectionPool?.evictAll() }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A client `PinVault.applyTo` configured after the last `start()`; null = not started. */
|
|
242
|
+
@Volatile private var template: OkHttpClient? = null
|
|
243
|
+
@Volatile var options: NetworkingOptions = NetworkingOptions()
|
|
244
|
+
|
|
245
|
+
val isActive: Boolean get() = template != null
|
|
246
|
+
|
|
247
|
+
/** After `start()`: one pinned template per start (its socket factory keys the connection pool). */
|
|
248
|
+
@Synchronized
|
|
249
|
+
fun activate(options: NetworkingOptions = this.options) {
|
|
250
|
+
this.options = options
|
|
251
|
+
template = OkHttpClient.Builder().also(applier).build()
|
|
252
|
+
evictLongLived()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@Synchronized
|
|
256
|
+
fun deactivate() {
|
|
257
|
+
template = null
|
|
258
|
+
evictLongLived()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** NetworkingModule's per-request builder (fetch / XHR). */
|
|
262
|
+
fun configurePerRequest(builder: OkHttpClient.Builder) {
|
|
263
|
+
// Never from https into plain http; not even before start (the gate below refuses https anyway).
|
|
264
|
+
builder.followSslRedirects(false)
|
|
265
|
+
val o = options
|
|
266
|
+
builder.cache(if (o.keepHttpCache) httpCache() else null)
|
|
267
|
+
// RN puts its persistent cookie jar (Android's CookieManager) on its base client;
|
|
268
|
+
// NO_COOKIES = nothing is sent or stored. RN itself sets NO_COOKIES for withCredentials: false.
|
|
269
|
+
if (!o.keepCookies) builder.cookieJar(CookieJar.NO_COOKIES)
|
|
270
|
+
val pinned = template
|
|
271
|
+
if (pinned == null) {
|
|
272
|
+
if (httpsGate !in builder.interceptors()) builder.addInterceptor(httpsGate)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
builder.sslSocketFactory(pinned.sslSocketFactory, requireNotNull(pinned.x509TrustManager))
|
|
276
|
+
builder.hostnameVerifier(pinned.hostnameVerifier)
|
|
277
|
+
builder.certificatePinner(pinned.certificatePinner)
|
|
278
|
+
// A builder derived from a client this method already configured would
|
|
279
|
+
// carry the interceptors twice; RN derives every request from its base client.
|
|
280
|
+
pinned.interceptors.forEach { if (it !in builder.interceptors()) builder.addInterceptor(it) }
|
|
281
|
+
pinned.networkInterceptors.forEach { if (it !in builder.networkInterceptors()) builder.addNetworkInterceptor(it) }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Clients RN builds once and keeps: TLS goes through the pinned factory of the current start. */
|
|
285
|
+
fun configureLongLived(builder: OkHttpClient.Builder): OkHttpClient.Builder {
|
|
286
|
+
val forwarding = ForwardingSocketFactory { template?.sslSocketFactory }
|
|
287
|
+
builder.sslSocketFactory(forwarding, ForwardingTrustManager { template?.x509TrustManager })
|
|
288
|
+
builder.addInterceptor(httpsGate)
|
|
289
|
+
// The current start's network interceptors (the per-request pin check of a pooled
|
|
290
|
+
// or coalesced connection, among them) run for these clients too.
|
|
291
|
+
builder.addNetworkInterceptor(forwardingNetwork)
|
|
292
|
+
builder.cache(null)
|
|
293
|
+
builder.followSslRedirects(false)
|
|
294
|
+
return builder
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Refuses `https` while PinVault has not started, and — with
|
|
299
|
+
* `requirePinnedReactNativeNetworking` — once another library has replaced
|
|
300
|
+
* React Native's hooks after start (checked at most every few seconds).
|
|
301
|
+
*/
|
|
302
|
+
private val httpsGate = Interceptor { chain ->
|
|
303
|
+
if (chain.request().isHttps) {
|
|
304
|
+
if (template == null) throw IOException("PinVault has not started: https requests are refused until start() returns")
|
|
305
|
+
if (options.requirePinned && !hooksStillPinned()) {
|
|
306
|
+
throw IOException("requirePinnedReactNativeNetworking: React Native's networking hooks are no longer PinVault's")
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
chain.proceed(chain.request())
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private fun hooksStillPinned(): Boolean {
|
|
313
|
+
val now = android.os.SystemClock.elapsedRealtime()
|
|
314
|
+
if (now - hooksCheckedAt > HOOK_CHECK_MS) {
|
|
315
|
+
hooksWerePinned = hooksPinned()
|
|
316
|
+
hooksCheckedAt = now
|
|
317
|
+
}
|
|
318
|
+
return hooksWerePinned
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Runs the current start's network interceptors in order, then the request. */
|
|
322
|
+
private val forwardingNetwork = Interceptor { chain ->
|
|
323
|
+
val interceptors = template?.networkInterceptors ?: emptyList()
|
|
324
|
+
ForwardingChain(chain, interceptors, 0).proceed(chain.request())
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
companion object {
|
|
328
|
+
const val HOOK_CHECK_MS = 5_000L
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A chain that hands the request to `interceptors[index]`, …, and finally to the real chain. */
|
|
333
|
+
internal class ForwardingChain(
|
|
334
|
+
private val base: Interceptor.Chain,
|
|
335
|
+
private val interceptors: List<Interceptor>,
|
|
336
|
+
private val index: Int,
|
|
337
|
+
) : Interceptor.Chain by base {
|
|
338
|
+
override fun proceed(request: okhttp3.Request): okhttp3.Response =
|
|
339
|
+
if (index >= interceptors.size) base.proceed(request)
|
|
340
|
+
else interceptors[index].intercept(ForwardingChain(base, interceptors, index + 1).withRequest(request))
|
|
341
|
+
|
|
342
|
+
private var current: okhttp3.Request = base.request()
|
|
343
|
+
private fun withRequest(request: okhttp3.Request) = also { current = request }
|
|
344
|
+
override fun request(): okhttp3.Request = current
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Hands every socket to the pinned factory of the current start; none before `start()`. */
|
|
348
|
+
internal class ForwardingSocketFactory(private val current: () -> SSLSocketFactory?) : SSLSocketFactory() {
|
|
349
|
+
|
|
350
|
+
private fun delegate(): SSLSocketFactory =
|
|
351
|
+
current() ?: throw SSLHandshakeException("PinVault has not started: TLS is refused until start() returns")
|
|
352
|
+
|
|
353
|
+
override fun getDefaultCipherSuites(): Array<String> = current()?.defaultCipherSuites ?: emptyArray()
|
|
354
|
+
override fun getSupportedCipherSuites(): Array<String> = current()?.supportedCipherSuites ?: emptyArray()
|
|
355
|
+
override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket =
|
|
356
|
+
delegate().createSocket(s, host, port, autoClose)
|
|
357
|
+
override fun createSocket(host: String?, port: Int): Socket = delegate().createSocket(host, port)
|
|
358
|
+
override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket =
|
|
359
|
+
delegate().createSocket(host, port, localHost, localPort)
|
|
360
|
+
override fun createSocket(host: InetAddress?, port: Int): Socket = delegate().createSocket(host, port)
|
|
361
|
+
override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket =
|
|
362
|
+
delegate().createSocket(address, port, localAddress, localPort)
|
|
363
|
+
override fun createSocket(): Socket = delegate().createSocket()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* OkHttp uses this trust manager only to clean certificate chains; the
|
|
368
|
+
* handshake itself is checked inside the pinned socket factory.
|
|
369
|
+
*/
|
|
370
|
+
internal class ForwardingTrustManager(private val current: () -> X509TrustManager?) : X509TrustManager {
|
|
371
|
+
private fun delegate(): X509TrustManager =
|
|
372
|
+
current() ?: throw java.security.cert.CertificateException("PinVault has not started")
|
|
373
|
+
|
|
374
|
+
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
|
|
375
|
+
delegate().checkClientTrusted(chain, authType)
|
|
376
|
+
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) =
|
|
377
|
+
delegate().checkServerTrusted(chain, authType)
|
|
378
|
+
override fun getAcceptedIssuers(): Array<X509Certificate> = current()?.acceptedIssuers ?: emptyArray()
|
|
379
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
class PinVaultPackage : BaseReactPackage() {
|
|
10
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
|
11
|
+
if (name == PinVaultModule.NAME) PinVaultModule(reactContext) else null
|
|
12
|
+
|
|
13
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
14
|
+
mapOf(
|
|
15
|
+
PinVaultModule.NAME to ReactModuleInfo(
|
|
16
|
+
name = PinVaultModule.NAME,
|
|
17
|
+
className = PinVaultModule.NAME,
|
|
18
|
+
canOverrideExistingModule = false,
|
|
19
|
+
needsEagerInit = false,
|
|
20
|
+
isCxxModule = false,
|
|
21
|
+
isTurboModule = true,
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import io.github.umutcansu.pinvault.model.HttpConnectionSettings
|
|
4
|
+
import okhttp3.Headers
|
|
5
|
+
import okhttp3.HttpUrl
|
|
6
|
+
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
7
|
+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
8
|
+
import okhttp3.OkHttpClient
|
|
9
|
+
import okhttp3.Request
|
|
10
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
11
|
+
import okio.Buffer
|
|
12
|
+
import okio.ByteString.Companion.decodeBase64
|
|
13
|
+
import java.io.IOException
|
|
14
|
+
import java.util.concurrent.TimeUnit
|
|
15
|
+
|
|
16
|
+
/** A `fetch(url, init)` from JS, checked. */
|
|
17
|
+
internal class FetchRequest(
|
|
18
|
+
val url: HttpUrl,
|
|
19
|
+
val method: String,
|
|
20
|
+
val headers: Headers,
|
|
21
|
+
val body: ByteArray?,
|
|
22
|
+
val responseEncoding: String,
|
|
23
|
+
val timeoutMs: Long?,
|
|
24
|
+
val maxResponseBytes: Long,
|
|
25
|
+
val settings: HttpConnectionSettings?,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/** The response was larger than the request allowed. */
|
|
29
|
+
internal class ResponseTooLargeException(limit: Long) : IOException("Response larger than $limit bytes")
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The plugin's own `fetch`: parsed strictly, sent through the pinned client
|
|
33
|
+
* the library hands out (`PinVault.getClient()` or `getClient(settings)`),
|
|
34
|
+
* HTTPS only, bounded in both directions. A redirect from `https` into plain
|
|
35
|
+
* `http` is not followed (the 3xx comes back), as on iOS.
|
|
36
|
+
*/
|
|
37
|
+
internal object PinnedFetch {
|
|
38
|
+
|
|
39
|
+
const val MAX_REQUEST_BYTES = 10L * 1024 * 1024
|
|
40
|
+
const val DEFAULT_MAX_RESPONSE_BYTES = 10L * 1024 * 1024
|
|
41
|
+
const val MAX_RESPONSE_BYTES = 50L * 1024 * 1024
|
|
42
|
+
private val METHODS = setOf("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
|
43
|
+
private val TOKEN = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}")
|
|
44
|
+
|
|
45
|
+
fun parse(json: String): FetchRequest {
|
|
46
|
+
// Base64 inflates by 4/3; leave room for the other fields.
|
|
47
|
+
val f = StrictJson.parseObject(json, "request", maxChars = (MAX_REQUEST_BYTES * 4 / 3 + 64 * 1024).toInt())
|
|
48
|
+
val urlText = f.requireString("url", 8192)
|
|
49
|
+
val url = urlText.toHttpUrlOrNull() ?: throw BridgeInputException("request.url: not a valid URL")
|
|
50
|
+
if (!url.isHttps) throw BridgeInputException("request.url: only https:// URLs go through the pinned client")
|
|
51
|
+
val method = (f.string("method", 16) ?: "GET").uppercase()
|
|
52
|
+
if (method !in METHODS) throw BridgeInputException("request.method: '$method' is not supported")
|
|
53
|
+
val headers = Headers.Builder()
|
|
54
|
+
f.stringMap("headers", 128, 256, 8192)?.forEach { (name, value) ->
|
|
55
|
+
if (!TOKEN.matches(name)) throw BridgeInputException("request.headers: '${name.take(40)}' is not a header name")
|
|
56
|
+
if (value.any { it == '\r' || it == '\n' || it.code == 0 }) {
|
|
57
|
+
throw BridgeInputException("request.headers.$name: line breaks are not allowed")
|
|
58
|
+
}
|
|
59
|
+
headers.add(name, value)
|
|
60
|
+
}
|
|
61
|
+
val bodyText = f.text("body", (MAX_REQUEST_BYTES * 4 / 3 + 4).toInt())
|
|
62
|
+
val bodyEncoding = ResultMapper.checkEncoding(f.string("bodyEncoding", 16) ?: ResultMapper.UTF8)
|
|
63
|
+
val body = bodyText?.let {
|
|
64
|
+
if (bodyEncoding == ResultMapper.BASE64) {
|
|
65
|
+
it.decodeBase64()?.toByteArray() ?: throw BridgeInputException("request.body: not valid Base64")
|
|
66
|
+
} else {
|
|
67
|
+
it.toByteArray(Charsets.UTF_8)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (body != null && body.size > MAX_REQUEST_BYTES) throw BridgeInputException("request.body: larger than $MAX_REQUEST_BYTES bytes")
|
|
71
|
+
if (body != null && (method == "GET" || method == "HEAD")) throw BridgeInputException("request.body: $method has no body")
|
|
72
|
+
val responseEncoding = ResultMapper.checkEncoding(f.string("responseEncoding", 16) ?: ResultMapper.UTF8)
|
|
73
|
+
val timeoutMs = f.long("timeoutMs", 1, 10 * 60 * 1000L)
|
|
74
|
+
val maxResponse = f.long("maxResponseBytes", 1, MAX_RESPONSE_BYTES) ?: DEFAULT_MAX_RESPONSE_BYTES
|
|
75
|
+
val settings = f.obj("settings")?.let { s ->
|
|
76
|
+
val d = HttpConnectionSettings()
|
|
77
|
+
val parsed = HttpConnectionSettings(
|
|
78
|
+
connectTimeout = s.long("connectTimeout", 0, 600) ?: d.connectTimeout,
|
|
79
|
+
readTimeout = s.long("readTimeout", 0, 600) ?: d.readTimeout,
|
|
80
|
+
writeTimeout = s.long("writeTimeout", 0, 600) ?: d.writeTimeout,
|
|
81
|
+
callTimeout = s.long("callTimeout", 0, 600) ?: d.callTimeout,
|
|
82
|
+
)
|
|
83
|
+
s.finish()
|
|
84
|
+
parsed
|
|
85
|
+
}
|
|
86
|
+
f.finish()
|
|
87
|
+
return FetchRequest(url, method, headers.build(), body, responseEncoding, timeoutMs, maxResponse, settings)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Runs [request] on [client] (a pinned client from the library); blocking. */
|
|
91
|
+
fun execute(client: OkHttpClient, request: FetchRequest): Map<String, Any?> {
|
|
92
|
+
// newBuilder shares the pool, the pinned socket factory and the
|
|
93
|
+
// library's interceptors; it only stops https → http redirects.
|
|
94
|
+
val callClient = client.newBuilder().followSslRedirects(false).build()
|
|
95
|
+
val contentType = request.headers["Content-Type"]?.toMediaTypeOrNull()
|
|
96
|
+
val body = when {
|
|
97
|
+
request.body != null -> request.body.toRequestBody(contentType)
|
|
98
|
+
request.method in setOf("POST", "PUT", "PATCH") -> ByteArray(0).toRequestBody(contentType)
|
|
99
|
+
else -> null
|
|
100
|
+
}
|
|
101
|
+
val okRequest = Request.Builder().url(request.url).headers(request.headers).method(request.method, body).build()
|
|
102
|
+
val call = callClient.newCall(okRequest)
|
|
103
|
+
request.timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
|
|
104
|
+
call.execute().use { response ->
|
|
105
|
+
val responseBody = response.body
|
|
106
|
+
val declared = responseBody?.contentLength() ?: -1L
|
|
107
|
+
if (declared > request.maxResponseBytes) throw ResponseTooLargeException(request.maxResponseBytes)
|
|
108
|
+
val buffer = Buffer()
|
|
109
|
+
if (responseBody != null) {
|
|
110
|
+
val source = responseBody.source()
|
|
111
|
+
while (true) {
|
|
112
|
+
val read = source.read(buffer, 64 * 1024L)
|
|
113
|
+
if (read == -1L) break
|
|
114
|
+
if (buffer.size > request.maxResponseBytes) throw ResponseTooLargeException(request.maxResponseBytes)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
val bytes = buffer.readByteArray()
|
|
118
|
+
val headers = LinkedHashMap<String, String>()
|
|
119
|
+
response.headers.names().forEach { name ->
|
|
120
|
+
headers[name.lowercase()] = response.headers.values(name).joinToString(", ")
|
|
121
|
+
}
|
|
122
|
+
return mapOf(
|
|
123
|
+
"status" to response.code,
|
|
124
|
+
"url" to response.request.url.toString(),
|
|
125
|
+
"headers" to headers,
|
|
126
|
+
"body" to ResultMapper.encode(bytes, request.responseEncoding),
|
|
127
|
+
"bodyEncoding" to request.responseEncoding,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|