package com.anonymous.bulwarkmobile import android.content.Context import android.content.SharedPreferences import android.security.KeyChain import android.security.KeyChainAliasCallback import android.util.Base64 import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap import java.io.ByteArrayOutputStream import java.net.HttpURLConnection import java.net.Socket import java.net.URL import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLEngine import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509ExtendedKeyManager import javax.net.ssl.X509TrustManager import kotlin.concurrent.thread /** * Bridges Android's KeyChain client-certificate machinery into JS so the * mobile app can authenticate to a reverse proxy that asks for mTLS. * * Two surfaces: * 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user * pick a cert installed in Android's system credential store. The * selected alias is persisted in SharedPreferences so the choice * survives app restarts. * 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that * injects the picked cert into the TLS handshake. JS-side `fetch` * can't present a client cert because RN's bundled OkHttp client * doesn't expose its KeyManager hook to userland; this method is * the workaround. */ class BulwarkClientCertModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private val prefs: SharedPreferences = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null) @Volatile private var cachedFactory: SSLSocketFactory? = null @Volatile private var cachedFactoryForAlias: String? = null override fun getName(): String = MODULE_NAME // ── alias management ──────────────────────────────────────── @ReactMethod fun getAlias(promise: Promise) { promise.resolve(cachedAlias) } @ReactMethod fun clearAlias(promise: Promise) { prefs.edit().remove(PREF_ALIAS).apply() cachedAlias = null invalidateFactory() promise.resolve(null) } /** * Show the system "Choose a certificate" picker. Resolves with the * selected alias, or `null` when the user dismisses the picker. */ @ReactMethod fun pickAlias(host: String?, promise: Promise) { // ReactContextBaseJavaModule no longer exposes a Kotlin synthetic // `currentActivity` property in RN 0.80+ (compiles as an unresolved // reference). The explicit Java-style getter on the application // context is the supported call site. val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity() if (activity == null) { promise.reject("no_activity", "No current activity to host the cert picker") return } val callback = KeyChainAliasCallback { alias -> // Callback runs off the main thread; persist + resolve directly. if (alias.isNullOrBlank()) { promise.resolve(null) return@KeyChainAliasCallback } prefs.edit().putString(PREF_ALIAS, alias).apply() cachedAlias = alias invalidateFactory() promise.resolve(alias) } try { KeyChain.choosePrivateKeyAlias( activity, callback, /* keyTypes */ arrayOf("RSA", "EC"), /* issuers */ null, /* host */ host, /* port */ -1, /* alias */ cachedAlias, ) } catch (e: Exception) { promise.reject("pick_failed", e.message, e) } } // ── secure fetch ──────────────────────────────────────────── /** * Perform an HTTP request that presents the persisted client cert (if * any) during the TLS handshake. * * @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number} * * Resolves with `{status, statusText, headers, bodyBase64}` so JS can * reconstruct a standard `Response`-like object. */ @ReactMethod fun fetchSecure(request: ReadableMap, promise: Promise) { val url = request.getString("url") if (url.isNullOrBlank()) { promise.reject("bad_args", "url is required") return } val method = (request.getString("method") ?: "GET").uppercase() val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000 val headersMap = request.getMap("headers") val bodyBase64 = request.getString("bodyBase64") val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) } thread(name = "BulwarkClientCert.fetch") { try { val conn = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = timeoutMs readTimeout = timeoutMs requestMethod = method // Redirects are resolved in JS (lib/client-cert.ts) so a POST // stays a POST and credentials never follow a cross-host hop. instanceFollowRedirects = false if (bodyBytes != null) { doOutput = true setFixedLengthStreamingMode(bodyBytes.size) } } if (conn is HttpsURLConnection && cachedAlias != null) { conn.sslSocketFactory = buildOrReuseSocketFactory() } headersMap?.let { h -> val it = h.keySetIterator() while (it.hasNextKey()) { val k = it.nextKey() val v = h.getString(k) if (v != null) conn.setRequestProperty(k, v) } } conn.connect() if (bodyBytes != null) { conn.outputStream.use { out -> out.write(bodyBytes) } } val status = conn.responseCode val statusText = conn.responseMessage ?: "" val responseStream = try { conn.inputStream } catch (_: Exception) { conn.errorStream } val responseBytes = if (responseStream != null) { val buf = ByteArrayOutputStream() responseStream.copyTo(buf) responseStream.close() buf.toByteArray() } else { ByteArray(0) } val out = Arguments.createMap() out.putInt("status", status) out.putString("statusText", statusText) val headers = Arguments.createMap() // headerFields is a `Map>`; the null // key is the HTTP status line, which we don't need to copy. for ((name, values) in conn.headerFields) { if (name == null || values == null) continue headers.putString(name, values.joinToString(", ")) } out.putMap("headers", headers) out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP)) conn.disconnect() promise.resolve(out) } catch (e: Exception) { promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e) } } } // ── internals ─────────────────────────────────────────────── private fun invalidateFactory() { cachedFactory = null cachedFactoryForAlias = null } private fun buildOrReuseSocketFactory(): SSLSocketFactory { val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected") val existing = cachedFactory if (existing != null && cachedFactoryForAlias == alias) return existing val ctx = reactApplicationContext val km = KeyChainKeyManager(ctx, alias) val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) tmf.init(null as java.security.KeyStore?) val trustManagers = tmf.trustManagers val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager? ?: throw IllegalStateException("No X509TrustManager available") val sslContext = SSLContext.getInstance("TLS") sslContext.init(arrayOf(km), arrayOf(tm), null) val factory = sslContext.socketFactory cachedFactory = factory cachedFactoryForAlias = alias return factory } /** * X509ExtendedKeyManager that always returns the same KeyChain alias. * KeyChain.getPrivateKey / getCertificateChain are blocking; we call * them lazily during the TLS handshake, which is fine since the * fetch already runs off-main-thread. */ private class KeyChainKeyManager( private val context: Context, private val alias: String, ) : X509ExtendedKeyManager() { override fun chooseClientAlias( keyTypes: Array?, issuers: Array?, socket: Socket?, ): String = alias override fun chooseEngineClientAlias( keyType: Array?, issuers: Array?, engine: SSLEngine?, ): String = alias override fun getCertificateChain(alias: String?): Array? { return try { KeyChain.getCertificateChain(context, this.alias) } catch (_: Exception) { null } } override fun getPrivateKey(alias: String?): PrivateKey? { return try { KeyChain.getPrivateKey(context, this.alias) } catch (_: Exception) { null } } override fun getClientAliases(keyType: String?, issuers: Array?): Array = arrayOf(alias) override fun getServerAliases(keyType: String?, issuers: Array?): Array = emptyArray() override fun chooseServerAlias( keyType: String?, issuers: Array?, socket: Socket?, ): String? = null } companion object { const val MODULE_NAME = "BulwarkClientCert" private const val PREFS_NAME = "bulwark_client_cert" private const val PREF_ALIAS = "alias" } }