mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: implement client certificate support with secure fetch and alias management
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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 okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.net.Socket
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLContext
|
||||
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 OkHttp that injects the picked
|
||||
* cert into the TLS handshake. JS-side `fetch` cannot present client
|
||||
* certs because RN's bundled OkHttpClient 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 cachedClient: OkHttpClient? = null
|
||||
@Volatile private var cachedClientForAlias: 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
|
||||
invalidateClient()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system "Choose a certificate" picker. Resolves with the
|
||||
* selected alias, or `null` when the user dismisses the picker.
|
||||
*
|
||||
* @param host optional host hint shown by the picker
|
||||
*/
|
||||
@ReactMethod
|
||||
fun pickAlias(host: String?, promise: Promise) {
|
||||
val activity = currentActivity
|
||||
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. We persist + resolve directly.
|
||||
if (alias.isNullOrBlank()) {
|
||||
promise.resolve(null)
|
||||
return@KeyChainAliasCallback
|
||||
}
|
||||
prefs.edit().putString(PREF_ALIAS, alias).apply()
|
||||
cachedAlias = alias
|
||||
invalidateClient()
|
||||
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"
|
||||
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs").toLong() else 30_000L
|
||||
val headersMap = request.getMap("headers")
|
||||
val bodyBase64 = request.getString("bodyBase64")
|
||||
val contentType = headersMap
|
||||
?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
var ct: String? = null
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
if (k.equals("Content-Type", ignoreCase = true)) {
|
||||
ct = h.getString(k)
|
||||
break
|
||||
}
|
||||
}
|
||||
ct
|
||||
}
|
||||
|
||||
// Run on a worker thread so we don't block the JS thread on socket IO.
|
||||
thread(name = "BulwarkClientCert.fetch") {
|
||||
try {
|
||||
val client = buildOrReuseClient(timeoutMs)
|
||||
val builder = Request.Builder().url(url)
|
||||
headersMap?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
val v = h.getString(k)
|
||||
if (v != null) builder.addHeader(k, v)
|
||||
}
|
||||
}
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
val body = when {
|
||||
bodyBytes != null -> bodyBytes.toRequestBody(contentType?.toMediaTypeOrNull())
|
||||
methodRequiresBody(method) -> ByteArray(0).toRequestBody(contentType?.toMediaTypeOrNull())
|
||||
else -> null
|
||||
}
|
||||
builder.method(method.uppercase(), body)
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
val out = Arguments.createMap()
|
||||
out.putInt("status", response.code)
|
||||
out.putString("statusText", response.message)
|
||||
val headers = Arguments.createMap()
|
||||
response.headers.forEach { (name, value) ->
|
||||
// OkHttp can return multiple values per header; we
|
||||
// join with ", " which is the standard wire format
|
||||
// for repeated header values.
|
||||
if (headers.hasKey(name)) {
|
||||
val existing = headers.getString(name)
|
||||
headers.putString(name, "$existing, $value")
|
||||
} else {
|
||||
headers.putString(name, value)
|
||||
}
|
||||
}
|
||||
out.putMap("headers", headers)
|
||||
val responseBytes = response.body?.bytes() ?: ByteArray(0)
|
||||
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
|
||||
promise.resolve(out)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────
|
||||
|
||||
private fun invalidateClient() {
|
||||
cachedClient = null
|
||||
cachedClientForAlias = null
|
||||
}
|
||||
|
||||
private fun buildOrReuseClient(timeoutMs: Long): OkHttpClient {
|
||||
val alias = cachedAlias
|
||||
val existing = cachedClient
|
||||
if (existing != null && cachedClientForAlias == alias) return existing
|
||||
|
||||
val builder = OkHttpClient.Builder()
|
||||
.callTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (alias != null) {
|
||||
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<TrustManager>(tm), null)
|
||||
builder.sslSocketFactory(sslContext.socketFactory, tm)
|
||||
}
|
||||
|
||||
val client = builder.build()
|
||||
cachedClient = client
|
||||
cachedClientForAlias = alias
|
||||
return client
|
||||
}
|
||||
|
||||
private fun methodRequiresBody(method: String): Boolean = when (method.uppercase()) {
|
||||
"POST", "PUT", "PATCH", "DELETE" -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* X509ExtendedKeyManager that always returns the same KeyChain alias.
|
||||
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
|
||||
* them lazily on the OkHttp dispatcher thread, 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<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String = alias
|
||||
|
||||
override fun chooseEngineClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
engine: javax.net.ssl.SSLEngine?,
|
||||
): String = alias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, this.alias)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, this.alias)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
emptyArray()
|
||||
|
||||
override fun chooseServerAlias(
|
||||
keyType: String?,
|
||||
issuers: Array<out Principal>?,
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(BulwarkFcmModule(reactContext))
|
||||
listOf(
|
||||
BulwarkFcmModule(reactContext),
|
||||
BulwarkClientCertModule(reactContext),
|
||||
)
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { File } from 'expo-file-system';
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
|
||||
export async function uploadBlob(
|
||||
uri: string,
|
||||
@@ -20,7 +21,7 @@ export async function uploadBlob(
|
||||
// empty/garbage upload.
|
||||
const bytes = await new File(uri).bytes();
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from './types';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
@@ -234,7 +235,7 @@ export class JMAPClient {
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
const url = `${baseUrl}/.well-known/jmap`;
|
||||
const response = await fetch(url, {
|
||||
const response = await secureFetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
@@ -279,7 +280,7 @@ export class JMAPClient {
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const response = await fetch(this.session.apiUrl, {
|
||||
const response = await secureFetch(this.session.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -334,7 +335,7 @@ export class JMAPClient {
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await fetch(url, {
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: this.authHeader },
|
||||
});
|
||||
if (response.status === 401) throw new AuthenticationError('Session expired');
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import {
|
||||
clearClientCertAlias,
|
||||
getClientCertAlias,
|
||||
isClientCertSupported,
|
||||
pickClientCertAlias,
|
||||
} from '../../lib/client-cert';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
@@ -30,8 +37,96 @@ export function AccountSecuritySettings() {
|
||||
const [twoFactor, setTwoFactor] = useState(false);
|
||||
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
|
||||
|
||||
const certSupported = isClientCertSupported();
|
||||
const [certAlias, setCertAlias] = useState<string | null>(null);
|
||||
const [certBusy, setCertBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certSupported) return;
|
||||
void getClientCertAlias().then(setCertAlias);
|
||||
}, [certSupported]);
|
||||
|
||||
const onPickCert = async () => {
|
||||
if (certBusy) return;
|
||||
setCertBusy(true);
|
||||
try {
|
||||
// Hint the picker with the JMAP server host so it can pre-filter to certs
|
||||
// issued for it when the OEM picker honours that.
|
||||
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
|
||||
const alias = await pickClientCertAlias(host);
|
||||
setCertAlias(alias);
|
||||
} catch (err) {
|
||||
Alert.alert('Pick failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearCert = async () => {
|
||||
if (certBusy) return;
|
||||
Alert.alert(
|
||||
'Stop using client certificate?',
|
||||
'New requests will no longer present a certificate. The certificate stays installed in Android.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Clear',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setCertBusy(true);
|
||||
try {
|
||||
await clearClientCertAlias();
|
||||
setCertAlias(null);
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{certSupported && (
|
||||
<SettingsSection
|
||||
title="TLS client certificate"
|
||||
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
|
||||
>
|
||||
<View style={styles.certRow}>
|
||||
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certStatus}>
|
||||
{certAlias ? `Active: ${certAlias}` : 'Not set'}
|
||||
</Text>
|
||||
<Text style={styles.certHint}>
|
||||
Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.certActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onPickCert(); }}
|
||||
>
|
||||
{certAlias ? 'Choose another' : 'Pick certificate'}
|
||||
</Button>
|
||||
{certAlias && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onClearCert(); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Password" description="Change the password for this account.">
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.pwField}>
|
||||
@@ -196,5 +291,14 @@ function makeStyles(c: ThemePalette) {
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
certRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
certStatus: { ...typography.bodyMedium, color: c.text },
|
||||
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
|
||||
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Bridge to the native BulwarkClientCert module. Wraps the alias pick / get /
|
||||
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
|
||||
// closely enough that callers can swap them out.
|
||||
//
|
||||
// On non-Android platforms (or when the user has not picked a cert) we fall
|
||||
// straight back to the global `fetch` so we don't pay any bridge overhead.
|
||||
|
||||
type Native = {
|
||||
getAlias(): Promise<string | null>;
|
||||
pickAlias(host: string | null): Promise<string | null>;
|
||||
clearAlias(): Promise<void>;
|
||||
fetchSecure(request: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string | null;
|
||||
timeoutMs: number;
|
||||
}): Promise<{
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Lazy-loaded so unit tests (which run in plain Node) don't have to parse the
|
||||
// `react-native` entry point. We resolve the module on first access; the
|
||||
// result is cached by the require cache.
|
||||
let nativeProbed = false;
|
||||
let nativeModule: Native | null = null;
|
||||
|
||||
function getNative(): Native | null {
|
||||
if (nativeProbed) return nativeModule;
|
||||
nativeProbed = true;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const rn = require('react-native') as {
|
||||
Platform: { OS: string };
|
||||
NativeModules: Record<string, unknown>;
|
||||
};
|
||||
if (rn.Platform.OS !== 'android') return (nativeModule = null);
|
||||
nativeModule = (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
|
||||
} catch {
|
||||
nativeModule = null;
|
||||
}
|
||||
return nativeModule;
|
||||
}
|
||||
|
||||
// Cached so the hot path can short-circuit without a bridge round-trip.
|
||||
let cachedAlias: string | null | undefined;
|
||||
|
||||
export function isClientCertSupported(): boolean {
|
||||
return getNative() != null;
|
||||
}
|
||||
|
||||
export async function getClientCertAlias(): Promise<string | null> {
|
||||
const native = getNative();
|
||||
if (!native) return null;
|
||||
if (cachedAlias !== undefined) return cachedAlias;
|
||||
cachedAlias = await native.getAlias();
|
||||
return cachedAlias;
|
||||
}
|
||||
|
||||
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
|
||||
const native = getNative();
|
||||
if (!native) throw new Error('Client certificates require Android');
|
||||
const alias = await native.pickAlias(host);
|
||||
cachedAlias = alias;
|
||||
return alias;
|
||||
}
|
||||
|
||||
export async function clearClientCertAlias(): Promise<void> {
|
||||
const native = getNative();
|
||||
if (!native) return;
|
||||
await native.clearAlias();
|
||||
cachedAlias = null;
|
||||
}
|
||||
|
||||
// ── secureFetch ───────────────────────────────────────────────
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
|
||||
}
|
||||
return globalThis.btoa ? globalThis.btoa(bin) : btoa(bin);
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function bodyToBase64(body: BodyInit | null | undefined): Promise<string | null> {
|
||||
if (body == null) return null;
|
||||
if (typeof body === 'string') {
|
||||
// UTF-8 encode then base64.
|
||||
const encoder = new TextEncoder();
|
||||
return bytesToBase64(encoder.encode(body));
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return bytesToBase64(new Uint8Array(body));
|
||||
}
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
const view = body as ArrayBufferView;
|
||||
return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
||||
}
|
||||
// Blob / FormData / URLSearchParams / ReadableStream are not used in the
|
||||
// current code paths. If they appear, the caller should pre-serialize.
|
||||
throw new Error(`secureFetch: unsupported body type ${(body as object).constructor?.name}`);
|
||||
}
|
||||
|
||||
function flattenHeaders(input: HeadersInit | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!input) return out;
|
||||
if (Array.isArray(input)) {
|
||||
for (const [k, v] of input) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
if (typeof Headers !== 'undefined' && input instanceof Headers) {
|
||||
input.forEach((v, k) => { out[k] = v; });
|
||||
return out;
|
||||
}
|
||||
return { ...(input as Record<string, string>) };
|
||||
}
|
||||
|
||||
function buildResponse(raw: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
}): Response {
|
||||
const bytes = raw.bodyBase64 ? base64ToBytes(raw.bodyBase64) : new Uint8Array(0);
|
||||
const blob = new Blob([bytes as unknown as BlobPart]);
|
||||
return new Response(blob, {
|
||||
status: raw.status,
|
||||
statusText: raw.statusText,
|
||||
headers: raw.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `fetch` that routes through the native client when
|
||||
* the user has selected a client certificate. When no cert is set (or we're
|
||||
* on iOS), it just calls the platform `fetch` and the caller pays no
|
||||
* bridge overhead.
|
||||
*/
|
||||
export async function secureFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const native = getNative();
|
||||
if (!native) return fetch(url, init);
|
||||
const alias = await getClientCertAlias();
|
||||
if (!alias) return fetch(url, init);
|
||||
|
||||
const method = init?.method ?? 'GET';
|
||||
const headers = flattenHeaders(init?.headers);
|
||||
const bodyBase64 = await bodyToBase64(init?.body);
|
||||
// Force Content-Length to come from the body bytes - some servers (Stalwart
|
||||
// behind nginx in particular) reject chunked uploads when an explicit
|
||||
// length isn't provided alongside the bytes.
|
||||
if (bodyBase64 != null) {
|
||||
const decoded = base64ToBytes(bodyBase64);
|
||||
headers['Content-Length'] = String(decoded.byteLength);
|
||||
}
|
||||
try {
|
||||
const raw = await native.fetchSecure({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
bodyBase64,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
return buildResponse(raw);
|
||||
} catch (err) {
|
||||
// Surface as a TypeError to mimic fetch's network-error contract; keep
|
||||
// the original message for diagnostics.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new TypeError(`secureFetch failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
+40
-14
@@ -1,7 +1,8 @@
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { getDownloadUrl } from '../api/blob';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
|
||||
@@ -21,6 +22,40 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
|
||||
// itself with its bundled SharingFileProvider before launching the share
|
||||
// intent, so we must NOT pre-translate to `file.contentUri`.
|
||||
|
||||
// Routes the download via the client-cert-aware native module when the user
|
||||
// has picked a cert, and via expo-file-system's native streaming downloader
|
||||
// otherwise. The streaming path scales to large attachments without buffering
|
||||
// in JS, so we keep using it as the default.
|
||||
async function downloadInto(
|
||||
url: string,
|
||||
dest: File,
|
||||
parent: Directory,
|
||||
): Promise<File> {
|
||||
const alias = await getClientCertAlias();
|
||||
if (!alias) {
|
||||
// The static returns a separately-typed `FileSystemFile`; we already
|
||||
// have a fully-typed `File` referencing the same uri, so we ignore the
|
||||
// return value and re-use our `dest` reference for downstream code.
|
||||
await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
return dest;
|
||||
}
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: authHeader() },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
}
|
||||
if (!parent.exists) parent.create({ intermediates: true, idempotent: true });
|
||||
if (dest.exists) dest.delete();
|
||||
const buffer = await response.arrayBuffer();
|
||||
dest.create();
|
||||
dest.write(new Uint8Array(buffer));
|
||||
return dest;
|
||||
}
|
||||
|
||||
export async function shareAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
@@ -30,10 +65,7 @@ export async function shareAttachment(
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
@@ -57,10 +89,7 @@ export async function downloadAttachment(
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.document, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.document);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
@@ -72,7 +101,7 @@ export async function downloadAttachment(
|
||||
|
||||
export async function fetchRawEmail(blobId: string): Promise<string> {
|
||||
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
|
||||
const r = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
|
||||
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
||||
return r.text();
|
||||
}
|
||||
@@ -85,10 +114,7 @@ function safeFilename(subject: string | undefined): string {
|
||||
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
|
||||
const dest = new File(Paths.cache, safeFilename(subject));
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user