feat(ios): device client-cert mTLS — ClientTLS package, transport wiring, install UX (C-iOS)
- ios/Packages/ClientTLS: SecIdentity wrapper, PKCS12 importer (typed errors), keychain store
(AfterFirstUnlockThisDeviceOnly), pure MutualTLSChallengeResponder truth table, cross-platform
X.509 DER summary. 14/14 tests.
- Both transports (SessionCore URLSessionTermTransport, App URLSessionHTTPTransport) + SessionThumbnail
take a lazy @Sendable ()->ClientIdentity? provider: WS resolves per-connect, HTTP per client-cert
challenge, so a freshly-imported cert applies without an app relaunch. AppEnvironment injects
{ store.loadedIdentityOrNil() }.
- ClientCertScreen (.fileImporter([.pkcs12]) + passphrase -> import -> keychain), reachable via a
设备证书 entry in SessionListScreen.hostMenu. PairingViewModel gates tunnel-host probes on cert
presence and re-maps mTLS-reject to a clientCertRejected message.
Verified: ClientTLS 14/14, SessionCore 93/93, xcodegen + xcodebuild BUILD SUCCEEDED.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import APIClient
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
@@ -116,10 +117,21 @@ final class PairingViewModel {
|
||||
|
||||
@ObservationIgnored private let store: any HostStore
|
||||
@ObservationIgnored private let probe: Probe
|
||||
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
|
||||
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||
@ObservationIgnored private let isDeviceCertInstalled: @Sendable () -> Bool
|
||||
|
||||
init(store: any HostStore, probe: @escaping Probe) {
|
||||
init(
|
||||
store: any HostStore,
|
||||
probe: @escaping Probe,
|
||||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||
}
|
||||
) {
|
||||
self.store = store
|
||||
self.probe = probe
|
||||
self.isDeviceCertInstalled = isDeviceCertInstalled
|
||||
}
|
||||
|
||||
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
||||
@@ -205,10 +217,20 @@ final class PairingViewModel {
|
||||
|
||||
private func runProbe(for pending: PendingHost) async {
|
||||
needsPublicRiskAcknowledgement = false
|
||||
// C-iOS-3 · Tunnel hosts are mTLS-only: refuse to probe (which would
|
||||
// fail at the TLS handshake) until a device certificate is installed.
|
||||
// This is the single choke point both confirmConnect and retry funnel
|
||||
// through, so the gate can't be bypassed via retry().
|
||||
if Self.isTunnelHost(pending.endpoint), !isDeviceCertInstalled() {
|
||||
phase = .failed(pending, FailureDisplay(
|
||||
message: PairingCopy.deviceCertRequired, action: .retry
|
||||
))
|
||||
return
|
||||
}
|
||||
phase = .probing(pending)
|
||||
switch await probe(pending.endpoint) {
|
||||
case .failure(let error):
|
||||
phase = .failed(pending, Self.display(for: error))
|
||||
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||
case .success(let endpoint):
|
||||
await storePairedHost(endpoint: endpoint, pending: pending)
|
||||
}
|
||||
@@ -239,6 +261,19 @@ final class PairingViewModel {
|
||||
|
||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||
|
||||
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||
/// client cert at the TLS layer; URLSession surfaces that as
|
||||
/// secureConnectionFailed / connection-reset → `PairingError.classify` maps
|
||||
/// it to `.tlsFailure` ("server cert invalid"), which is the WRONG diagnosis
|
||||
/// for an mTLS tunnel host. Re-map that one case to the device-cert copy;
|
||||
/// everything else falls through to the per-case mapping.
|
||||
static func display(for error: PairingError, endpoint: HostEndpoint) -> FailureDisplay {
|
||||
if case .tlsFailure = error, isTunnelHost(endpoint) {
|
||||
return FailureDisplay(message: PairingCopy.clientCertRejected, action: .retry)
|
||||
}
|
||||
return display(for: error)
|
||||
}
|
||||
|
||||
static func display(for error: PairingError) -> FailureDisplay {
|
||||
switch error {
|
||||
case .localNetworkDenied:
|
||||
@@ -270,6 +305,14 @@ final class PairingViewModel {
|
||||
/// block regardless of scheme (§5.4 table: https is included in the
|
||||
/// public-host confirm warning); otherwise https clears every notice.
|
||||
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
|
||||
// C-iOS-3 · A *.terminal.yaojia.wang tunnel host is gated by the device
|
||||
// client certificate (mTLS), so the "anyone who can reach the port gets
|
||||
// a shell" blocking warning is FALSE here and would only deter the
|
||||
// intended flow. The real gate is the cert-install check in runProbe.
|
||||
// Genuinely public NON-tunnel hosts still hit .publicHostBlocking below.
|
||||
if isTunnelHost(endpoint) {
|
||||
return .none
|
||||
}
|
||||
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
|
||||
if hostClass == .publicHost {
|
||||
return .publicHostBlocking
|
||||
@@ -353,8 +396,17 @@ final class PairingViewModel {
|
||||
return octets
|
||||
}
|
||||
|
||||
/// C-iOS-3 · A native mTLS reverse-tunnel host (`<name>.terminal.yaojia.wang`).
|
||||
/// These reach a loopback base app through the VPS and are protected ONLY by
|
||||
/// the device client certificate — hence the softened warning + the
|
||||
/// cert-install gate + the client-cert-rejected re-classification.
|
||||
static func isTunnelHost(_ endpoint: HostEndpoint) -> Bool {
|
||||
(endpoint.baseURL.host ?? "").lowercased().hasSuffix(tunnelZoneSuffix)
|
||||
}
|
||||
|
||||
// MARK: - Named constants (no magic values, plan §4)
|
||||
|
||||
private static let tunnelZoneSuffix = ".terminal.yaojia.wang"
|
||||
private static let schemeSeparator = "://"
|
||||
private static let defaultManualScheme = "http://"
|
||||
private static let httpsScheme = "https"
|
||||
@@ -385,6 +437,13 @@ enum PairingCopy {
|
||||
"TLS 连接失败:证书无效或不受信任。"
|
||||
static let timeout =
|
||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
|
||||
static let deviceCertRequired =
|
||||
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
|
||||
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
|
||||
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
|
||||
static let clientCertRejected =
|
||||
"本设备证书无效或已吊销,请重新导入。"
|
||||
|
||||
static func hostUnreachable(_ underlying: String) -> String {
|
||||
"无法连接主机:\(underlying)"
|
||||
|
||||
Reference in New Issue
Block a user