@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,296 @@
|
|
|
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.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
|
+
)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Installs both hooks (once per process). The plugin's content provider
|
|
87
|
+
* calls it before `Application.onCreate`; call it yourself in
|
|
88
|
+
* `MainApplication.onCreate`, before `loadReactNative`, only when the app
|
|
89
|
+
* removed that provider.
|
|
90
|
+
*/
|
|
91
|
+
@JvmStatic
|
|
92
|
+
@Synchronized
|
|
93
|
+
fun install(context: Context) {
|
|
94
|
+
if (installed) return
|
|
95
|
+
app = context.applicationContext
|
|
96
|
+
OkHttpClientProvider.setOkHttpClientFactory(factory)
|
|
97
|
+
NetworkingModule.setCustomClientBuilder(clientBuilder)
|
|
98
|
+
installed = true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@JvmStatic
|
|
102
|
+
val isInstalled: Boolean get() = installed
|
|
103
|
+
|
|
104
|
+
/** Whether React Native's two networking hooks are still the ones [install] set. */
|
|
105
|
+
@JvmStatic
|
|
106
|
+
fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder)
|
|
107
|
+
|
|
108
|
+
private val warned = java.util.concurrent.atomic.AtomicReference<HookStatus?>(null)
|
|
109
|
+
|
|
110
|
+
/** Logs a warning when the hooks are not PinVault's, once per distinct state. */
|
|
111
|
+
internal fun warnIfNotPinned(where: String): HookStatus {
|
|
112
|
+
val s = status()
|
|
113
|
+
if (!s.pinned && warned.getAndSet(s) != s) Log.w(TAG, "$where: ${s.describe()}")
|
|
114
|
+
if (s.pinned) warned.set(null)
|
|
115
|
+
return s
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What React Native's networking hooks are now. `null` = could not be read
|
|
121
|
+
* (a React Native version whose internals moved): treated as not pinned.
|
|
122
|
+
*/
|
|
123
|
+
data class HookStatus(
|
|
124
|
+
val installed: Boolean,
|
|
125
|
+
/** `OkHttpClientProvider`'s factory is PinVault's. */
|
|
126
|
+
val factoryIsPinVault: Boolean?,
|
|
127
|
+
/** `NetworkingModule`'s custom client builder is PinVault's. */
|
|
128
|
+
val clientBuilderIsPinVault: Boolean?,
|
|
129
|
+
) {
|
|
130
|
+
val pinned: Boolean get() = installed && factoryIsPinVault == true && clientBuilderIsPinVault == true
|
|
131
|
+
|
|
132
|
+
fun describe(): String = when {
|
|
133
|
+
pinned -> "React Native's networking is pinned by PinVault"
|
|
134
|
+
!installed -> "React Native's networking hooks are not installed (the plugin's content provider was removed " +
|
|
135
|
+
"and PinVaultNetworking.install was not called, or android.pinGlobalNetworking is false): " +
|
|
136
|
+
"RN fetch / XHR / WebSocket / images are NOT pinned"
|
|
137
|
+
else -> buildList {
|
|
138
|
+
if (factoryIsPinVault != true) add("OkHttpClientProvider's client factory " + if (factoryIsPinVault == null) "could not be read" else "was replaced by another library")
|
|
139
|
+
if (clientBuilderIsPinVault != true) add("NetworkingModule's custom client builder " + if (clientBuilderIsPinVault == null) "could not be read" else "was replaced by another library")
|
|
140
|
+
}.joinToString("; ") + ": React Native's own networking may NOT be pinned"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
internal companion object {
|
|
144
|
+
fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any): HookStatus = HookStatus(
|
|
145
|
+
installed,
|
|
146
|
+
isOurs(OkHttpClientProvider::class.java, "factory", ourFactory),
|
|
147
|
+
isOurs(NetworkingModule::class.java, "customClientBuilder", ourBuilder),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether RN's private static field [name] holds [ours]; null when it
|
|
152
|
+
* cannot be read. The plugin's consumer R8 rules keep both fields' names.
|
|
153
|
+
*/
|
|
154
|
+
private fun isOurs(owner: Class<*>, name: String, ours: Any): Boolean? = try {
|
|
155
|
+
owner.getDeclaredField(name).apply { isAccessible = true }.get(null) === ours
|
|
156
|
+
} catch (_: ReflectiveOperationException) {
|
|
157
|
+
null
|
|
158
|
+
} catch (_: RuntimeException) {
|
|
159
|
+
null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Installs [PinVaultNetworking] when the app process starts, before
|
|
166
|
+
* `Application.onCreate`. Opt out in the app's manifest:
|
|
167
|
+
*
|
|
168
|
+
* ```xml
|
|
169
|
+
* <provider android:name="io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer"
|
|
170
|
+
* android:authorities="${applicationId}.pinvault-networking" tools:node="remove" />
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
class PinVaultNetworkingInitializer : ContentProvider() {
|
|
174
|
+
override fun onCreate(): Boolean {
|
|
175
|
+
context?.let { PinVaultNetworking.install(it) }
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
|
180
|
+
override fun getType(uri: Uri): String? = null
|
|
181
|
+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
|
182
|
+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
183
|
+
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** RN's own 10 MiB disk cache (`OkHttpClientProvider.createClientBuilder(context)`), only with `keepReactNativeHttpCache`. */
|
|
187
|
+
internal object ReactHttpCache {
|
|
188
|
+
@Volatile private var cache: Cache? = null
|
|
189
|
+
|
|
190
|
+
fun get(context: Context): Cache = cache ?: synchronized(this) {
|
|
191
|
+
cache ?: Cache(File(context.cacheDir, "http-cache"), 10L * 1024 * 1024).also { cache = it }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The mechanics of [PinVaultNetworking], with the `applyTo` call injected so
|
|
197
|
+
* that JVM tests can run it without a device.
|
|
198
|
+
*/
|
|
199
|
+
internal class NetworkPinning(
|
|
200
|
+
private val applier: (OkHttpClient.Builder) -> Unit,
|
|
201
|
+
private val httpCache: () -> Cache? = { null },
|
|
202
|
+
) {
|
|
203
|
+
|
|
204
|
+
/** A client `PinVault.applyTo` configured after the last `start()`; null = not started. */
|
|
205
|
+
@Volatile private var template: OkHttpClient? = null
|
|
206
|
+
@Volatile var options: NetworkingOptions = NetworkingOptions()
|
|
207
|
+
|
|
208
|
+
val isActive: Boolean get() = template != null
|
|
209
|
+
|
|
210
|
+
/** After `start()`: one pinned template per start (its socket factory keys the connection pool). */
|
|
211
|
+
@Synchronized
|
|
212
|
+
fun activate(options: NetworkingOptions = this.options) {
|
|
213
|
+
this.options = options
|
|
214
|
+
template = OkHttpClient.Builder().also(applier).build()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@Synchronized
|
|
218
|
+
fun deactivate() {
|
|
219
|
+
template = null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** NetworkingModule's per-request builder (fetch / XHR). */
|
|
223
|
+
fun configurePerRequest(builder: OkHttpClient.Builder) {
|
|
224
|
+
// Never from https into plain http; not even before start (the gate below refuses https anyway).
|
|
225
|
+
builder.followSslRedirects(false)
|
|
226
|
+
val o = options
|
|
227
|
+
builder.cache(if (o.keepHttpCache) httpCache() else null)
|
|
228
|
+
// RN puts its persistent cookie jar (Android's CookieManager) on its base client;
|
|
229
|
+
// NO_COOKIES = nothing is sent or stored. RN itself sets NO_COOKIES for withCredentials: false.
|
|
230
|
+
if (!o.keepCookies) builder.cookieJar(CookieJar.NO_COOKIES)
|
|
231
|
+
val pinned = template
|
|
232
|
+
if (pinned == null) {
|
|
233
|
+
if (httpsGate !in builder.interceptors()) builder.addInterceptor(httpsGate)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
builder.sslSocketFactory(pinned.sslSocketFactory, requireNotNull(pinned.x509TrustManager))
|
|
237
|
+
builder.hostnameVerifier(pinned.hostnameVerifier)
|
|
238
|
+
builder.certificatePinner(pinned.certificatePinner)
|
|
239
|
+
// A builder derived from a client this method already configured would
|
|
240
|
+
// carry the interceptors twice; RN derives every request from its base client.
|
|
241
|
+
pinned.interceptors.forEach { if (it !in builder.interceptors()) builder.addInterceptor(it) }
|
|
242
|
+
pinned.networkInterceptors.forEach { if (it !in builder.networkInterceptors()) builder.addNetworkInterceptor(it) }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Clients RN builds once and keeps: TLS goes through the pinned factory of the current start. */
|
|
246
|
+
fun configureLongLived(builder: OkHttpClient.Builder): OkHttpClient.Builder {
|
|
247
|
+
val forwarding = ForwardingSocketFactory { template?.sslSocketFactory }
|
|
248
|
+
builder.sslSocketFactory(forwarding, ForwardingTrustManager { template?.x509TrustManager })
|
|
249
|
+
builder.addInterceptor(httpsGate)
|
|
250
|
+
builder.cache(null)
|
|
251
|
+
builder.followSslRedirects(false)
|
|
252
|
+
return builder
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Refuses `https` while PinVault has not started. */
|
|
256
|
+
private val httpsGate = Interceptor { chain ->
|
|
257
|
+
if (chain.request().isHttps && template == null) {
|
|
258
|
+
throw IOException("PinVault has not started: https requests are refused until start() returns")
|
|
259
|
+
}
|
|
260
|
+
chain.proceed(chain.request())
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Hands every socket to the pinned factory of the current start; none before `start()`. */
|
|
265
|
+
internal class ForwardingSocketFactory(private val current: () -> SSLSocketFactory?) : SSLSocketFactory() {
|
|
266
|
+
|
|
267
|
+
private fun delegate(): SSLSocketFactory =
|
|
268
|
+
current() ?: throw SSLHandshakeException("PinVault has not started: TLS is refused until start() returns")
|
|
269
|
+
|
|
270
|
+
override fun getDefaultCipherSuites(): Array<String> = current()?.defaultCipherSuites ?: emptyArray()
|
|
271
|
+
override fun getSupportedCipherSuites(): Array<String> = current()?.supportedCipherSuites ?: emptyArray()
|
|
272
|
+
override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket =
|
|
273
|
+
delegate().createSocket(s, host, port, autoClose)
|
|
274
|
+
override fun createSocket(host: String?, port: Int): Socket = delegate().createSocket(host, port)
|
|
275
|
+
override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket =
|
|
276
|
+
delegate().createSocket(host, port, localHost, localPort)
|
|
277
|
+
override fun createSocket(host: InetAddress?, port: Int): Socket = delegate().createSocket(host, port)
|
|
278
|
+
override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket =
|
|
279
|
+
delegate().createSocket(address, port, localAddress, localPort)
|
|
280
|
+
override fun createSocket(): Socket = delegate().createSocket()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* OkHttp uses this trust manager only to clean certificate chains; the
|
|
285
|
+
* handshake itself is checked inside the pinned socket factory.
|
|
286
|
+
*/
|
|
287
|
+
internal class ForwardingTrustManager(private val current: () -> X509TrustManager?) : X509TrustManager {
|
|
288
|
+
private fun delegate(): X509TrustManager =
|
|
289
|
+
current() ?: throw java.security.cert.CertificateException("PinVault has not started")
|
|
290
|
+
|
|
291
|
+
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
|
|
292
|
+
delegate().checkClientTrusted(chain, authType)
|
|
293
|
+
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) =
|
|
294
|
+
delegate().checkServerTrusted(chain, authType)
|
|
295
|
+
override fun getAcceptedIssuers(): Array<X509Certificate> = current()?.acceptedIssuers ?: emptyArray()
|
|
296
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import io.github.umutcansu.pinvault.api.PinVaultConnectionEvent
|
|
4
|
+
import io.github.umutcansu.pinvault.model.AttestationStatus
|
|
5
|
+
import io.github.umutcansu.pinvault.model.AttestationTokenResult
|
|
6
|
+
import io.github.umutcansu.pinvault.model.ClientCertEnrollmentResult
|
|
7
|
+
import io.github.umutcansu.pinvault.model.InitResult
|
|
8
|
+
import io.github.umutcansu.pinvault.model.SigningStatus
|
|
9
|
+
import io.github.umutcansu.pinvault.model.UpdateResult
|
|
10
|
+
import io.github.umutcansu.pinvault.model.VaultFileResult
|
|
11
|
+
import io.github.umutcansu.pinvault.model.VaultFileUnlockResult
|
|
12
|
+
import okio.ByteString.Companion.toByteString
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Native results → plain maps for JS. `type` is the sealed subclass in
|
|
16
|
+
* lowerCamel (`Ready` → `ready`), every other key is the Kotlin property name.
|
|
17
|
+
* Every free-text field goes through [VaultTokenStore.redact]; vault file
|
|
18
|
+
* content appears only in `unlocked` (after the native prompt) and in
|
|
19
|
+
* `loadFile`, never in a download result or an event.
|
|
20
|
+
*/
|
|
21
|
+
internal class ResultMapper(private val tokens: VaultTokenStore) {
|
|
22
|
+
|
|
23
|
+
private fun text(s: String?): String? = tokens.redact(s)
|
|
24
|
+
|
|
25
|
+
fun exception(e: Throwable?): Map<String, Any?>? = e?.let {
|
|
26
|
+
mapOf("name" to it.javaClass.simpleName.ifEmpty { "Exception" }, "message" to text(it.message))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fun init(r: InitResult): Map<String, Any?> = when (r) {
|
|
30
|
+
is InitResult.Ready -> mapOf("type" to "ready", "version" to r.version)
|
|
31
|
+
is InitResult.Failed -> mapOf("type" to "failed", "reason" to text(r.reason), "exception" to exception(r.exception))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fun update(r: UpdateResult): Map<String, Any?> = when (r) {
|
|
35
|
+
is UpdateResult.Updated -> mapOf("type" to "updated", "newVersion" to r.newVersion)
|
|
36
|
+
UpdateResult.AlreadyCurrent -> mapOf("type" to "alreadyCurrent")
|
|
37
|
+
is UpdateResult.Failed -> mapOf("type" to "failed", "reason" to text(r.reason), "exception" to exception(r.exception))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fun enrollment(r: ClientCertEnrollmentResult): Map<String, Any?> = when (r) {
|
|
41
|
+
is ClientCertEnrollmentResult.Enrolled -> mapOf(
|
|
42
|
+
"type" to "enrolled",
|
|
43
|
+
"alreadyEnrolled" to r.alreadyEnrolled,
|
|
44
|
+
"keySecurityLevel" to r.keySecurityLevel?.name,
|
|
45
|
+
)
|
|
46
|
+
is ClientCertEnrollmentResult.Refused -> mapOf(
|
|
47
|
+
"type" to "refused",
|
|
48
|
+
"reason" to r.reason.name,
|
|
49
|
+
"httpStatus" to r.httpStatus,
|
|
50
|
+
"serverError" to text(r.serverError),
|
|
51
|
+
"message" to text(r.message),
|
|
52
|
+
)
|
|
53
|
+
is ClientCertEnrollmentResult.Pending -> mapOf(
|
|
54
|
+
"type" to "pending",
|
|
55
|
+
"requestId" to r.requestId,
|
|
56
|
+
"clientId" to r.clientId,
|
|
57
|
+
"message" to text(r.message),
|
|
58
|
+
"retryAfterSeconds" to r.retryAfterSeconds,
|
|
59
|
+
"verificationCode" to r.verificationCode,
|
|
60
|
+
)
|
|
61
|
+
is ClientCertEnrollmentResult.Failed -> mapOf(
|
|
62
|
+
"type" to "failed",
|
|
63
|
+
"message" to text(r.message),
|
|
64
|
+
"cause" to exception(r.cause),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fun vaultFile(r: VaultFileResult): Map<String, Any?> = when (r) {
|
|
69
|
+
// No bytes: the content is read with loadFile / unlockFile, on purpose.
|
|
70
|
+
is VaultFileResult.Updated -> mapOf("type" to "updated", "key" to r.key, "version" to r.version)
|
|
71
|
+
is VaultFileResult.AlreadyCurrent -> mapOf("type" to "alreadyCurrent", "key" to r.key, "version" to r.version)
|
|
72
|
+
is VaultFileResult.Failed -> mapOf(
|
|
73
|
+
"type" to "failed",
|
|
74
|
+
"key" to r.key,
|
|
75
|
+
"reason" to text(r.reason),
|
|
76
|
+
"code" to r.code,
|
|
77
|
+
"exception" to exception(r.exception),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun unlock(r: VaultFileUnlockResult, encoding: String): Map<String, Any?> = when (r) {
|
|
82
|
+
is VaultFileUnlockResult.Unlocked -> mapOf(
|
|
83
|
+
"type" to "unlocked",
|
|
84
|
+
"key" to r.key,
|
|
85
|
+
"version" to r.version,
|
|
86
|
+
"content" to encode(r.bytes, encoding),
|
|
87
|
+
"encoding" to encoding,
|
|
88
|
+
)
|
|
89
|
+
is VaultFileUnlockResult.NotFound -> mapOf("type" to "notFound", "key" to r.key)
|
|
90
|
+
is VaultFileUnlockResult.Cancelled -> mapOf("type" to "cancelled", "key" to r.key)
|
|
91
|
+
is VaultFileUnlockResult.Invalidated -> mapOf("type" to "invalidated", "key" to r.key)
|
|
92
|
+
is VaultFileUnlockResult.Stale -> mapOf("type" to "stale", "key" to r.key)
|
|
93
|
+
is VaultFileUnlockResult.Failed -> mapOf(
|
|
94
|
+
"type" to "failed",
|
|
95
|
+
"key" to r.key,
|
|
96
|
+
"reason" to text(r.reason),
|
|
97
|
+
"exception" to exception(r.exception),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fun attestation(s: AttestationStatus): Map<String, Any?> = mapOf(
|
|
102
|
+
"configApiId" to s.configApiId,
|
|
103
|
+
"result" to s.result.name,
|
|
104
|
+
"arc" to s.arc,
|
|
105
|
+
"rejectionReasons" to s.rejectionReasons,
|
|
106
|
+
"warnings" to s.warnings,
|
|
107
|
+
"tokenExpiresAt" to s.tokenExpiresAt,
|
|
108
|
+
"lastAttestedAt" to s.lastAttestedAt,
|
|
109
|
+
"nextAttestAt" to s.nextAttestAt,
|
|
110
|
+
"clockSkewMs" to s.clockSkewMs,
|
|
111
|
+
"lastError" to text(s.lastError),
|
|
112
|
+
"policyVersion" to s.policyVersion,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
fun attestationToken(r: AttestationTokenResult): Map<String, Any?> = when (r) {
|
|
116
|
+
is AttestationTokenResult.Token -> mapOf("type" to "token", "value" to r.value, "expiresAt" to r.expiresAt)
|
|
117
|
+
is AttestationTokenResult.Rejected -> mapOf("type" to "rejected", "status" to attestation(r.status))
|
|
118
|
+
is AttestationTokenResult.Failed -> mapOf("type" to "failed", "message" to text(r.message))
|
|
119
|
+
AttestationTokenResult.Unsupported -> mapOf("type" to "unsupported")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fun signing(s: SigningStatus): Map<String, Any?> = mapOf(
|
|
123
|
+
"configApiId" to s.configApiId,
|
|
124
|
+
"trustedKeyIds" to s.trustedKeyIds,
|
|
125
|
+
"requiredSignatures" to s.requiredSignatures,
|
|
126
|
+
"keySetVersion" to s.keySetVersion,
|
|
127
|
+
"recoveryKeyIds" to s.recoveryKeyIds,
|
|
128
|
+
"lastConfigSignedBy" to s.lastConfigSignedBy,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
/** Connection telemetry. The library puts no token in events; free text is redacted anyway. */
|
|
132
|
+
fun event(e: PinVaultConnectionEvent): Map<String, Any?> = when (e) {
|
|
133
|
+
is PinVaultConnectionEvent.Connection -> mapOf(
|
|
134
|
+
"type" to "connection",
|
|
135
|
+
"hostname" to e.hostname,
|
|
136
|
+
"success" to e.success,
|
|
137
|
+
"pinVersion" to e.pinVersion,
|
|
138
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
139
|
+
"deviceModel" to e.deviceModel,
|
|
140
|
+
"actualPin" to e.actualPin,
|
|
141
|
+
"expectedPins" to e.expectedPins,
|
|
142
|
+
)
|
|
143
|
+
is PinVaultConnectionEvent.ConfigUpdate -> mapOf(
|
|
144
|
+
"type" to "configUpdate",
|
|
145
|
+
"status" to e.status.name,
|
|
146
|
+
"newVersion" to e.newVersion,
|
|
147
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
148
|
+
"deviceModel" to e.deviceModel,
|
|
149
|
+
"failureReason" to text(e.failureReason),
|
|
150
|
+
)
|
|
151
|
+
is PinVaultConnectionEvent.ClientCertRenewal -> mapOf(
|
|
152
|
+
"type" to "clientCertRenewal",
|
|
153
|
+
"status" to e.status.name,
|
|
154
|
+
"notAfterEpochMs" to e.notAfterEpochMs,
|
|
155
|
+
"via" to e.via?.name,
|
|
156
|
+
"configApiId" to e.configApiId,
|
|
157
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
158
|
+
"deviceModel" to e.deviceModel,
|
|
159
|
+
"failureReason" to text(e.failureReason),
|
|
160
|
+
)
|
|
161
|
+
is PinVaultConnectionEvent.Attestation -> mapOf(
|
|
162
|
+
"type" to "attestation",
|
|
163
|
+
"configApiId" to e.configApiId,
|
|
164
|
+
"status" to e.status.name,
|
|
165
|
+
"arc" to e.arc,
|
|
166
|
+
"rejectionReasons" to e.rejectionReasons,
|
|
167
|
+
"warnings" to e.warnings,
|
|
168
|
+
"tokenExpiresAt" to e.tokenExpiresAt,
|
|
169
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
170
|
+
"deviceModel" to e.deviceModel,
|
|
171
|
+
"failureReason" to text(e.failureReason),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
companion object {
|
|
176
|
+
const val UTF8 = "utf8"
|
|
177
|
+
const val BASE64 = "base64"
|
|
178
|
+
|
|
179
|
+
fun checkEncoding(encoding: String): String {
|
|
180
|
+
if (encoding != UTF8 && encoding != BASE64) throw BridgeInputException("encoding: must be 'utf8' or 'base64'")
|
|
181
|
+
return encoding
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fun encode(bytes: ByteArray, encoding: String): String =
|
|
185
|
+
// Okio, not android.util.Base64: it also runs in JVM unit tests (and java.util.Base64 needs API 26).
|
|
186
|
+
if (encoding == BASE64) bytes.toByteString().base64() else bytes.toString(Charsets.UTF_8)
|
|
187
|
+
}
|
|
188
|
+
}
|