From ba150060cfa7511b9784aaba30af6969cac89e4b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:22:32 +0100 Subject: [PATCH] feat: use TLS client certificates on iOS #3 --- app.config.js | 2 + plugins/ios-app-sources.js | 34 ++ plugins/ios-client-cert/BulwarkClientCert.m | 13 + .../ios-client-cert/BulwarkClientCert.swift | 350 ++++++++++++++++++ plugins/ios-client-cert/index.js | 18 + src/lib/__tests__/client-cert-ios.test.ts | 67 ++++ src/lib/client-cert.ts | 28 +- 7 files changed, 500 insertions(+), 12 deletions(-) create mode 100644 plugins/ios-app-sources.js create mode 100644 plugins/ios-client-cert/BulwarkClientCert.m create mode 100644 plugins/ios-client-cert/BulwarkClientCert.swift create mode 100644 plugins/ios-client-cert/index.js create mode 100644 src/lib/__tests__/client-cert-ios.test.ts diff --git a/app.config.js b/app.config.js index aad9b209..0bc1c3e7 100644 --- a/app.config.js +++ b/app.config.js @@ -106,6 +106,8 @@ module.exports = { }, ], ['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }], + // TLS client certificates (#3): the iOS side of src/lib/client-cert.ts. + './plugins/ios-client-cert', // Bundles the Involved sound calendar reminders (src/lib/calendar- // notifications.ts) and new mail (plugins/ios-push) play on iOS. // Android has it in res/raw. diff --git a/plugins/ios-app-sources.js b/plugins/ios-app-sources.js new file mode 100644 index 00000000..98b7cf48 --- /dev/null +++ b/plugins/ios-app-sources.js @@ -0,0 +1,34 @@ +// Compiles sources that live next to a config plugin into the iOS app target: +// copied into ios// at prebuild (ios/ is generated, never edited by +// hand) and added to the app's Sources build phase. + +const fs = require('node:fs'); +const path = require('node:path'); +const { IOSConfig, withDangerousMod, withXcodeProject } = require('@expo/config-plugins'); + +function withAppSources(config, { dir, files }) { + config = withDangerousMod(config, [ + 'ios', + async (cfg) => { + const appDir = path.join(cfg.modRequest.platformProjectRoot, cfg.modRequest.projectName); + for (const file of files) { + fs.copyFileSync(path.join(dir, file), path.join(appDir, file)); + } + return cfg; + }, + ]); + config = withXcodeProject(config, (cfg) => { + const projectName = cfg.modRequest.projectName; + for (const file of files) { + IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ + filepath: `${projectName}/${file}`, + groupName: projectName, + project: cfg.modResults, + }); + } + return cfg; + }); + return config; +} + +module.exports = { withAppSources }; diff --git a/plugins/ios-client-cert/BulwarkClientCert.m b/plugins/ios-client-cert/BulwarkClientCert.m new file mode 100644 index 00000000..202647f9 --- /dev/null +++ b/plugins/ios-client-cert/BulwarkClientCert.m @@ -0,0 +1,13 @@ +// Exposes BulwarkClientCert.swift to React Native as NativeModules.BulwarkClientCert, +// with the methods of Android's BulwarkClientCertModule.kt. + +#import + +@interface RCT_EXTERN_MODULE(BulwarkClientCert, NSObject) + +RCT_EXTERN_METHOD(getAlias:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(clearAlias:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(pickAlias:(NSString *)host resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(fetchSecure:(NSDictionary *)request resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/plugins/ios-client-cert/BulwarkClientCert.swift b/plugins/ios-client-cert/BulwarkClientCert.swift new file mode 100644 index 00000000..ce05e101 --- /dev/null +++ b/plugins/ios-client-cert/BulwarkClientCert.swift @@ -0,0 +1,350 @@ +import Foundation +import React +import Security +import UIKit +import UniformTypeIdentifiers + +/// TLS client certificates on iOS (#3): the counterpart of Android's +/// BulwarkClientCertModule.kt, with the same methods and results, so +/// src/lib/client-cert.ts drives both. +/// +/// pickAlias(host) the user picks a .p12/.pfx file in Files and types its +/// password; the identity goes into the app's keychain. +/// Resolves with the certificate's name, or null when the +/// user cancels. +/// getAlias() that name, or null. +/// clearAlias() forgets the certificate and removes it from the keychain. +/// fetchSecure(r) one request over URLSession that answers the server's +/// client-certificate challenge with the identity. +/// +/// iOS has no store of client certificates that apps can pick from, like +/// Android's KeyChain: identities installed with a configuration profile are +/// only reachable by Apple's own apps. So the user brings the file. +/// +/// React Native's own fetch can't answer the challenge: its URLSession +/// delegate (RCTHTTPRequestHandler) takes the default handling, and the +/// configuration hook RN offers can't add a delegate. Hence fetchSecure, as +/// on Android, which src/lib/client-cert.ts routes every request through once +/// a certificate is set. +@objc(BulwarkClientCert) +final class BulwarkClientCert: NSObject, UIDocumentPickerDelegate { + @objc static func requiresMainQueueSetup() -> Bool { false } + + private var pending: (resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock, host: String?)? + + // MARK: alias management + + @objc(getAlias:rejecter:) + func getAlias(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { + resolve(ClientIdentityStore.alias) + } + + @objc(clearAlias:rejecter:) + func clearAlias(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { + ClientIdentityStore.clear() + SecureSession.reset() + resolve(nil) + } + + @objc(pickAlias:resolver:rejecter:) + func pickAlias( + _ host: String?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + DispatchQueue.main.async { + guard let presenter = RCTPresentedViewController() else { + reject("no_activity", "No screen to show the file picker on", nil) + return + } + if let previous = self.pending { + previous.resolve(nil) + } + self.pending = (resolve, reject, host) + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.pkcs12], asCopy: true) + picker.delegate = self + picker.allowsMultipleSelection = false + presenter.present(picker, animated: true) + } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + settle { resolve, _ in resolve(nil) } + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first, let data = try? Data(contentsOf: url) else { + settle { _, reject in reject("pick_failed", "The certificate file could not be read", nil) } + return + } + try? FileManager.default.removeItem(at: url) + // The picker may still be on its way out; an alert presented over it + // would not show. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + self.askPassword(for: data, wrongPassword: false) + } + } + + /// Asks for the file's password; again after a wrong one. + private func askPassword(for data: Data, wrongPassword: Bool) { + guard let presenter = RCTPresentedViewController() else { + settle { _, reject in reject("no_activity", "No screen to ask for the password on", nil) } + return + } + let host = pending?.host + let message = wrongPassword + ? "That password is wrong. Try again." + : (host.map { "The certificate for \($0) is protected by a password." } ?? "The certificate is protected by a password.") + let alert = UIAlertController(title: "Certificate password", message: message, preferredStyle: .alert) + alert.addTextField { field in + field.isSecureTextEntry = true + field.textContentType = .password + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + self.settle { resolve, _ in resolve(nil) } + }) + alert.addAction(UIAlertAction(title: "Import", style: .default) { [weak alert] _ in + let password = alert?.textFields?.first?.text ?? "" + switch ClientIdentityStore.importPKCS12(data, password: password) { + case .success(let alias): + SecureSession.reset() + self.settle { resolve, _ in resolve(alias) } + case .wrongPassword: + self.askPassword(for: data, wrongPassword: true) + case .failure(let message): + self.settle { _, reject in reject("pick_failed", message, nil) } + } + }) + presenter.present(alert, animated: true) + } + + private func settle(_ body: (RCTPromiseResolveBlock, RCTPromiseRejectBlock) -> Void) { + guard let current = pending else { return } + pending = nil + body(current.resolve, current.reject) + } + + // MARK: secure fetch + + /// request: {url, method, headers: {k: v}, bodyBase64: string|null, timeoutMs: number} + /// Resolves with {status, statusText, headers, bodyBase64}. Redirects come + /// back as they are: src/lib/client-cert.ts decides which to follow. + @objc(fetchSecure:resolver:rejecter:) + func fetchSecure( + _ request: NSDictionary, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard let urlString = request["url"] as? String, !urlString.isEmpty, let url = URL(string: urlString) else { + reject("bad_args", "url is required", nil) + return + } + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = ((request["method"] as? String) ?? "GET").uppercased() + urlRequest.timeoutInterval = ((request["timeoutMs"] as? NSNumber)?.doubleValue ?? 30_000) / 1000 + urlRequest.cachePolicy = .reloadIgnoringLocalCacheData + if let headers = request["headers"] as? [String: Any] { + for (name, value) in headers { + // URLSession sets the length from the body itself. + if name.caseInsensitiveCompare("Content-Length") == .orderedSame { continue } + if let value = value as? String { urlRequest.setValue(value, forHTTPHeaderField: name) } + } + } + if let bodyBase64 = request["bodyBase64"] as? String { + guard let body = Data(base64Encoded: bodyBase64) else { + reject("bad_args", "bodyBase64 is not base64", nil) + return + } + urlRequest.httpBody = body + } + SecureSession.shared.dataTask(with: urlRequest) { data, response, error in + if let error { + reject("fetch_failed", error.localizedDescription, error) + return + } + guard let http = response as? HTTPURLResponse else { + reject("fetch_failed", "No HTTP response", nil) + return + } + var headers: [String: String] = [:] + for (name, value) in http.allHeaderFields { + if let name = name as? String { headers[name] = "\(value)" } + } + resolve([ + "status": http.statusCode, + "statusText": HTTPURLResponse.localizedString(forStatusCode: http.statusCode), + "headers": headers, + "bodyBase64": (data ?? Data()).base64EncodedString(), + ]) + }.resume() + } +} + +/// The URLSession fetchSecure uses. Replaced when the certificate changes, so +/// no connection keeps a TLS session made with the old one. +private enum SecureSession { + private static let lock = NSLock() + private static var session: URLSession? + + static var shared: URLSession { + lock.lock() + defer { lock.unlock() } + if let session { return session } + let config = URLSessionConfiguration.ephemeral + config.urlCache = nil + let created = URLSession(configuration: config, delegate: SecureSessionDelegate(), delegateQueue: nil) + session = created + return created + } + + static func reset() { + lock.lock() + let old = session + session = nil + lock.unlock() + old?.finishTasksAndInvalidate() + } +} + +private final class SecureSessionDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate, + let credential = ClientIdentityStore.credential() else { + // Server trust and everything else: the system's own checks. + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, credential) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + // Hand the 3xx back; JS follows only same-host hops (mayFollowRedirect). + completionHandler(nil) + } +} + +enum ImportResult { + case success(String) + case wrongPassword + case failure(String) +} + +/// The picked identity: kept in the app's keychain, found again by its +/// persistent reference. The name and the intermediate certificates (public) +/// sit in UserDefaults. +enum ClientIdentityStore { + private static let aliasKey = "bulwark.clientCert.alias" + private static let refKey = "bulwark.clientCert.ref" + private static let chainKey = "bulwark.clientCert.chain" + + static var alias: String? { + UserDefaults.standard.string(forKey: aliasKey) + } + + static func importPKCS12(_ data: Data, password: String) -> ImportResult { + var items: CFArray? + let options = [kSecImportExportPassphrase as String: password] as CFDictionary + let status = SecPKCS12Import(data as CFData, options, &items) + if status == errSecAuthFailed { return .wrongPassword } + guard status == errSecSuccess, + let first = (items as? [[String: Any]])?.first, + let identityValue = first[kSecImportItemIdentity as String] else { + return .failure("The file holds no certificate with a private key (\(status))") + } + let identity = identityValue as! SecIdentity + var leaf: SecCertificate? + guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { + return .failure("The certificate could not be read") + } + let leafData = SecCertificateCopyData(leaf) as Data + let name = (SecCertificateCopySubjectSummary(leaf) as String?) ?? "Client certificate" + let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []) + .map { SecCertificateCopyData($0) as Data } + .filter { $0 != leafData } + + clear() + let add: [String: Any] = [ + kSecValueRef as String: identity, + kSecAttrLabel as String: name, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecReturnPersistentRef as String: true, + ] + var added: CFTypeRef? + let addStatus = SecItemAdd(add as CFDictionary, &added) + let ref: Data? + if addStatus == errSecSuccess { + ref = (added as? Data) ?? persistentRef(ofIdentityWith: leafData) + } else if addStatus == errSecDuplicateItem { + // Already in the keychain (picked before, or left by an earlier install). + ref = persistentRef(ofIdentityWith: leafData) + } else { + return .failure("The certificate could not be stored (\(addStatus))") + } + guard let ref else { return .failure("The stored certificate could not be found") } + let defaults = UserDefaults.standard + defaults.set(ref, forKey: refKey) + defaults.set(name, forKey: aliasKey) + defaults.set(chain, forKey: chainKey) + return .success(name) + } + + static func clear() { + let defaults = UserDefaults.standard + if let ref = defaults.data(forKey: refKey) { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecValuePersistentRef as String: ref, + ] + SecItemDelete(query as CFDictionary) + } + defaults.removeObject(forKey: refKey) + defaults.removeObject(forKey: aliasKey) + defaults.removeObject(forKey: chainKey) + } + + static func credential() -> URLCredential? { + let defaults = UserDefaults.standard + guard let ref = defaults.data(forKey: refKey) else { return nil } + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecValuePersistentRef as String: ref, + kSecReturnRef as String: true, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil } + let identity = item as! SecIdentity + let chain = (defaults.array(forKey: chainKey) as? [Data] ?? []) + .compactMap { SecCertificateCreateWithData(nil, $0 as CFData) } + return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession) + } + + private static func persistentRef(ofIdentityWith certificateData: Data) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnRef as String: true, + kSecReturnPersistentRef as String: true, + ] + var items: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &items) == errSecSuccess, + let list = items as? [[String: Any]] else { return nil } + for entry in list { + guard let value = entry[kSecValueRef as String], let ref = entry[kSecValuePersistentRef as String] as? Data else { continue } + var certificate: SecCertificate? + guard SecIdentityCopyCertificate(value as! SecIdentity, &certificate) == errSecSuccess, let certificate else { continue } + if SecCertificateCopyData(certificate) as Data == certificateData { return ref } + } + return nil + } +} diff --git a/plugins/ios-client-cert/index.js b/plugins/ios-client-cert/index.js new file mode 100644 index 00000000..2396c7e8 --- /dev/null +++ b/plugins/ios-client-cert/index.js @@ -0,0 +1,18 @@ +// Config plugin: TLS client certificates on iOS (#3). +// +// Compiles the BulwarkClientCert native module (BulwarkClientCert.swift, and +// BulwarkClientCert.m that registers it with React Native) into the app. It +// has the methods of Android's BulwarkClientCertModule.kt, so +// src/lib/client-cert.ts works the same on both. + +const { createRunOncePlugin } = require('@expo/config-plugins'); +const { withAppSources } = require('../ios-app-sources'); + +const FILES = ['BulwarkClientCert.swift', 'BulwarkClientCert.m']; + +function withIosClientCert(config) { + return withAppSources(config, { dir: __dirname, files: FILES }); +} + +module.exports = createRunOncePlugin(withIosClientCert, 'bulwark-ios-client-cert', '1.0.0'); +module.exports.FILES = FILES; diff --git a/src/lib/__tests__/client-cert-ios.test.ts b/src/lib/__tests__/client-cert-ios.test.ts new file mode 100644 index 00000000..d2ac76d4 --- /dev/null +++ b/src/lib/__tests__/client-cert-ios.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { clientCertModuleOf } from '../client-cert'; + +// TLS client certificates on iOS (#3). The app used the native module on +// Android only; iOS now has one with the same methods (plugins/ios-client-cert), +// so client-cert.ts takes the module wherever react-native offers it. + +const ROOT = path.resolve(__dirname, '../../..'); +const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +describe('client-cert.ts on iOS', () => { + it('uses the BulwarkClientCert module whatever the platform', () => { + const module = { getAlias: vi.fn(), pickAlias: vi.fn(), clearAlias: vi.fn(), fetchSecure: vi.fn() }; + expect(clientCertModuleOf({ NativeModules: { BulwarkClientCert: module } })).toBe(module); + expect(clientCertModuleOf({ NativeModules: {} })).toBeNull(); + expect(read('src/lib/client-cert.ts')).not.toMatch(/Platform\.OS !== 'android'/); + }); +}); + +describe('the iOS BulwarkClientCert module', () => { + const bridge = read('plugins/ios-client-cert/BulwarkClientCert.m'); + const swift = read('plugins/ios-client-cert/BulwarkClientCert.swift'); + const kotlin = read('android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt'); + + const selectors = [...bridge.matchAll(/RCT_EXTERN_METHOD\(([\s\S]*?)\)\r?\n/g)].map((m) => + // "pickAlias:(NSString *)host resolver:(...)resolve rejecter:(...)reject" -> "pickAlias:resolver:rejecter:" + m[1].replace(/\([^)]*\)\w+/g, '').replace(/\s+/g, ''), + ); + + it('registers under the Android module\'s name with the same methods', () => { + expect(bridge).toContain('@interface RCT_EXTERN_MODULE(BulwarkClientCert, NSObject)'); + expect(kotlin).toContain('const val MODULE_NAME = "BulwarkClientCert"'); + const jsNames = selectors.map((s) => s.split(':')[0]).sort(); + const androidNames = [...kotlin.matchAll(/@ReactMethod\s+fun (\w+)\(/g)].map((m) => m[1]).sort(); + expect(jsNames).toEqual(androidNames); + expect(jsNames).toEqual(['clearAlias', 'fetchSecure', 'getAlias', 'pickAlias']); + }); + + it('implements every exported selector in Swift', () => { + expect(swift).toContain('@objc(BulwarkClientCert)'); + for (const selector of selectors) expect(swift).toContain(`@objc(${selector})`); + }); + + it('answers client-certificate challenges and leaves server trust to the system', () => { + expect(swift).toMatch(/authenticationMethod == NSURLAuthenticationMethodClientCertificate/); + expect(swift).toContain('completionHandler(.useCredential, credential)'); + expect(swift).toContain('completionHandler(.performDefaultHandling, nil)'); + }); + + it('hands redirects back to JS, which follows only same-host hops', () => { + const redirect = swift.slice(swift.indexOf('willPerformHTTPRedirection')); + expect(redirect.slice(0, 400)).toContain('completionHandler(nil)'); + }); + + it('resolves fetchSecure with the shape secureFetch rebuilds a Response from', () => { + for (const key of ['"status"', '"statusText"', '"headers"', '"bodyBase64"']) expect(swift).toContain(key); + }); + + it('is compiled into the app by its config plugin', () => { + const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-client-cert/index.js')) as { FILES: string[] }; + expect(plugin.FILES).toEqual(['BulwarkClientCert.swift', 'BulwarkClientCert.m']); + expect(read('app.config.js')).toContain("'./plugins/ios-client-cert'"); + }); +}); diff --git a/src/lib/client-cert.ts b/src/lib/client-cert.ts index d945acc5..556161be 100644 --- a/src/lib/client-cert.ts +++ b/src/lib/client-cert.ts @@ -2,8 +2,11 @@ // 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. +// Android (BulwarkClientCertModule.kt) picks a certificate from the system's +// KeyChain; iOS (plugins/ios-client-cert) imports a .p12 file into the app's +// keychain. Both have the same methods. Where the module is missing (web, +// tests), 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; @@ -29,17 +32,18 @@ type Native = { let nativeProbed = false; let nativeModule: Native | null = null; +/** The client-certificate module react-native offers, on any platform that has one. */ +export function clientCertModuleOf(rn: { NativeModules: Record }): Native | null { + return (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? 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; + const rn = require('react-native') as { NativeModules: Record }; + nativeModule = clientCertModuleOf(rn); } catch { nativeModule = null; } @@ -63,7 +67,7 @@ export async function getClientCertAlias(): Promise { export async function pickClientCertAlias(host: string | null): Promise { const native = getNative(); - if (!native) throw new Error('Client certificates require Android'); + if (!native) throw new Error('Client certificates are not available on this device'); const alias = await native.pickAlias(host); cachedAlias = alias; return alias; @@ -144,9 +148,9 @@ function buildResponse(raw: { /** * 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. + * the user has selected a client certificate. When no cert is set (or there + * is no native module), it just calls the platform `fetch` and the caller + * pays no bridge overhead. */ export interface SecureFetchInit extends RequestInit { /** Native-path deadline (ms); blob transfers pass a longer one. */