diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt new file mode 100644 index 00000000..48b28bb9 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt @@ -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(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?, + issuers: Array?, + socket: Socket?, + ): String = alias + + override fun chooseEngineClientAlias( + keyType: Array?, + issuers: Array?, + engine: javax.net.ssl.SSLEngine?, + ): String = alias + + override fun getCertificateChain(alias: String?): Array? { + 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?): 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" + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt index 4f6dba03..1ab5516a 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt @@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager class BulwarkFcmPackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): List = - listOf(BulwarkFcmModule(reactContext)) + listOf( + BulwarkFcmModule(reactContext), + BulwarkClientCertModule(reactContext), + ) override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList() diff --git a/src/api/blob.ts b/src/api/blob.ts index 4bda205d..ceff0d45 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -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', diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 4befe5c2..bf000a03 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -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 { 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 { 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'); diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index 319582a0..4d6e028e 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -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(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 ( + {certSupported && ( + + + + + + {certAlias ? `Active: ${certAlias}` : 'Not set'} + + + Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here. + + + + + + {certAlias && ( + + )} + + + )} + @@ -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' }, }); } diff --git a/src/lib/client-cert.ts b/src/lib/client-cert.ts new file mode 100644 index 00000000..4d770bb3 --- /dev/null +++ b/src/lib/client-cert.ts @@ -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; + pickAlias(host: string | null): Promise; + clearAlias(): Promise; + fetchSecure(request: { + url: string; + method: string; + headers: Record; + bodyBase64: string | null; + timeoutMs: number; + }): Promise<{ + status: number; + statusText: string; + headers: Record; + 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; + }; + 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 { + 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 { + 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 { + 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 { + 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 { + const out: Record = {}; + 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) }; +} + +function buildResponse(raw: { + status: number; + statusText: string; + headers: Record; + 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 { + 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}`); + } +} diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 24ad533d..4ca50164 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -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 { + 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 { 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 { 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'); }