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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +386 -2
  4. package/RNPinVault.podspec +43 -0
  5. package/android/build.gradle +68 -0
  6. package/android/consumer-rules.pro +25 -0
  7. package/android/src/main/AndroidManifest.xml +16 -0
  8. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +300 -0
  9. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
  10. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +239 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -0
  13. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
  14. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
  15. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
  16. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
  17. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
  18. package/ios/Core/ConfigParser.swift +276 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +197 -0
  21. package/ios/Core/PinnedFetch.swift +119 -0
  22. package/ios/Core/ReactNetworking.swift +172 -0
  23. package/ios/Core/ResultMapper.swift +163 -0
  24. package/ios/Core/StrictJSON.swift +214 -0
  25. package/ios/Core/VaultTokenStore.swift +66 -0
  26. package/ios/PinVaultBridge.swift +412 -0
  27. package/ios/PinVaultReactNetworking.swift +47 -0
  28. package/ios/RNPinVault.h +7 -0
  29. package/ios/RNPinVault.mm +293 -0
  30. package/ios/RNPinVaultURLRequestHandler.mm +92 -0
  31. package/lib/module/NativePinVault.js +13 -0
  32. package/lib/module/NativePinVault.js.map +1 -0
  33. package/lib/module/index.js +312 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/types.js +2 -0
  37. package/lib/module/types.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/NativePinVault.d.ts +50 -0
  40. package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +150 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/lib/typescript/src/types.d.ts +346 -0
  44. package/lib/typescript/src/types.d.ts.map +1 -0
  45. package/package.json +128 -4
  46. package/src/NativePinVault.ts +72 -0
  47. package/src/index.ts +385 -0
  48. package/src/types.ts +351 -0
@@ -0,0 +1,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
+ }