mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: use TLS client certificates on iOS #3
This commit is contained in:
@@ -106,6 +106,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
['./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-
|
// Bundles the Involved sound calendar reminders (src/lib/calendar-
|
||||||
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
|
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
|
||||||
// Android has it in res/raw.
|
// Android has it in res/raw.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Compiles sources that live next to a config plugin into the iOS app target:
|
||||||
|
// copied into ios/<project>/ 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 };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Exposes BulwarkClientCert.swift to React Native as NativeModules.BulwarkClientCert,
|
||||||
|
// with the methods of Android's BulwarkClientCertModule.kt.
|
||||||
|
|
||||||
|
#import <React/RCTBridgeModule.h>
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-12
@@ -2,8 +2,11 @@
|
|||||||
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
|
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
|
||||||
// closely enough that callers can swap them out.
|
// closely enough that callers can swap them out.
|
||||||
//
|
//
|
||||||
// On non-Android platforms (or when the user has not picked a cert) we fall
|
// Android (BulwarkClientCertModule.kt) picks a certificate from the system's
|
||||||
// straight back to the global `fetch` so we don't pay any bridge overhead.
|
// 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 = {
|
type Native = {
|
||||||
getAlias(): Promise<string | null>;
|
getAlias(): Promise<string | null>;
|
||||||
@@ -29,17 +32,18 @@ type Native = {
|
|||||||
let nativeProbed = false;
|
let nativeProbed = false;
|
||||||
let nativeModule: Native | null = null;
|
let nativeModule: Native | null = null;
|
||||||
|
|
||||||
|
/** The client-certificate module react-native offers, on any platform that has one. */
|
||||||
|
export function clientCertModuleOf(rn: { NativeModules: Record<string, unknown> }): Native | null {
|
||||||
|
return (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function getNative(): Native | null {
|
function getNative(): Native | null {
|
||||||
if (nativeProbed) return nativeModule;
|
if (nativeProbed) return nativeModule;
|
||||||
nativeProbed = true;
|
nativeProbed = true;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const rn = require('react-native') as {
|
const rn = require('react-native') as { NativeModules: Record<string, unknown> };
|
||||||
Platform: { OS: string };
|
nativeModule = clientCertModuleOf(rn);
|
||||||
NativeModules: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
if (rn.Platform.OS !== 'android') return (nativeModule = null);
|
|
||||||
nativeModule = (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
|
|
||||||
} catch {
|
} catch {
|
||||||
nativeModule = null;
|
nativeModule = null;
|
||||||
}
|
}
|
||||||
@@ -63,7 +67,7 @@ export async function getClientCertAlias(): Promise<string | null> {
|
|||||||
|
|
||||||
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
|
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
|
||||||
const native = getNative();
|
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);
|
const alias = await native.pickAlias(host);
|
||||||
cachedAlias = alias;
|
cachedAlias = alias;
|
||||||
return alias;
|
return alias;
|
||||||
@@ -144,9 +148,9 @@ function buildResponse(raw: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement for `fetch` that routes through the native client when
|
* 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
|
* the user has selected a client certificate. When no cert is set (or there
|
||||||
* on iOS), it just calls the platform `fetch` and the caller pays no
|
* is no native module), it just calls the platform `fetch` and the caller
|
||||||
* bridge overhead.
|
* pays no bridge overhead.
|
||||||
*/
|
*/
|
||||||
export interface SecureFetchInit extends RequestInit {
|
export interface SecureFetchInit extends RequestInit {
|
||||||
/** Native-path deadline (ms); blob transfers pass a longer one. */
|
/** Native-path deadline (ms); blob transfers pass a longer one. */
|
||||||
|
|||||||
Reference in New Issue
Block a user