- 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.
458 lines
19 KiB
Swift
458 lines
19 KiB
Swift
import APIClient
|
||
import ClientTLS
|
||
import Foundation
|
||
import HostRegistry
|
||
import Observation
|
||
import WireProtocol
|
||
|
||
/// T-iOS-12 · Pairing state machine (plan §7 / §5.4).
|
||
///
|
||
/// Flow: scan / manual URL → **confirm gate** → two-step probe →
|
||
/// `Host{id,name}` into the `HostStore` + navigate signal.
|
||
///
|
||
/// Security invariants (plan §5 / task RED list):
|
||
/// - Scan payloads are UNTRUSTED external input: parsed exclusively through
|
||
/// `HostEndpoint` (single-point derivation — no hand-assembly), non-http(s)
|
||
/// rejected with copy, and **zero network happens before the user confirms**
|
||
/// (probe ① already GETs the target; probe ② spawns a PTY on it).
|
||
/// - The §5.4 warning tiers render ON the confirm page; the public-host tier
|
||
/// is BLOCKING — `confirmConnect` refuses to probe until the user has set
|
||
/// `hasAcknowledgedPublicRisk` explicitly.
|
||
/// - Every `PairingError` maps to inline copy + a recovery action
|
||
/// (`localNetworkDenied` → Settings deep-link; `originRejected` surfaces the
|
||
/// probe's hint VERBATIM; `atsBlocked` uses the §3.4 wording).
|
||
///
|
||
/// Documented decisions:
|
||
/// - **Manual entry reuses the same confirm state as scanning** (task left it
|
||
/// free): one code path, and the §5.4 warning tiers apply uniformly to
|
||
/// typed URLs too. Convenience: input without `://` gets an `http://`
|
||
/// prefix before the `HostEndpoint` parse (scan payloads get NO such help).
|
||
/// - **Host classification is (re)implemented here**: APIClient's
|
||
/// `PairingError.isPrivateOrLocalHost` is internal AND too coarse for the
|
||
/// tiers (it collapses loopback/Tailscale/RFC1918 into one bucket).
|
||
/// Duplication noted for the T-iOS-38 dedup pass.
|
||
/// - `.local` (mDNS) hosts over http are shown the plaintext-LAN notice: they
|
||
/// resolve to LAN addresses, so the §5.4 "ws:// on an untrusted LAN" row
|
||
/// applies to them the same way.
|
||
///
|
||
/// §3.4 contract ruling (2026-07-04): the injected probe returns the validated
|
||
/// `HostEndpoint`; `Host{id: UUID(), name:}` is constructed HERE (id/name are
|
||
/// not the probe's to know). Production wiring (T-iOS-15) passes
|
||
/// `runPairingProbe` with the real transports.
|
||
@MainActor
|
||
@Observable
|
||
final class PairingViewModel {
|
||
/// The probe, injected as a closure so tests can both fake results AND
|
||
/// assert non-invocation before the user confirms (task RED list).
|
||
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
|
||
|
||
// MARK: - UI state model
|
||
|
||
/// §5.4 warning tiers, decided from scheme + host class (see `warning(for:)`).
|
||
enum SecurityWarning: Equatable, Sendable {
|
||
/// https anywhere private-class, or ws→loopback: nothing to warn about.
|
||
case none
|
||
/// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no
|
||
/// plaintext warning, an optional positive badge instead.
|
||
case tailscaleEncrypted
|
||
/// ws→RFC1918 / link-local / `.local`: NON-blocking notice — keystrokes
|
||
/// and output are sniffable on the same LAN; prefer `tailscale serve`.
|
||
case plaintextLAN
|
||
/// Public host (http AND https alike, §5.4 table): strongest BLOCKING
|
||
/// warning — anyone who can reach the port gets a shell.
|
||
case publicHostBlocking
|
||
|
||
var isBlocking: Bool { self == .publicHostBlocking }
|
||
}
|
||
|
||
/// The parsed-but-not-yet-probed target shown on the confirm page.
|
||
struct PendingHost: Equatable, Sendable {
|
||
let endpoint: HostEndpoint
|
||
let warning: SecurityWarning
|
||
|
||
/// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation
|
||
/// (browser-Origin serialization) — NEVER hand-assembled.
|
||
var displayAddress: String { endpoint.originHeader }
|
||
}
|
||
|
||
/// What the failure UI offers besides the message.
|
||
enum RecoveryAction: Equatable, Sendable {
|
||
case retry
|
||
/// iOS Local Network permission was denied → deep-link to the app's
|
||
/// Settings pane (its 本地网络 toggle lives there).
|
||
case openLocalNetworkSettings
|
||
}
|
||
|
||
struct FailureDisplay: Equatable, Sendable {
|
||
let message: String
|
||
let action: RecoveryAction
|
||
}
|
||
|
||
enum Phase: Equatable {
|
||
case idle
|
||
case confirming(PendingHost)
|
||
case probing(PendingHost)
|
||
case failed(PendingHost, FailureDisplay)
|
||
case paired(HostRegistry.Host)
|
||
}
|
||
|
||
// MARK: - Observable state
|
||
|
||
private(set) var phase: Phase = .idle
|
||
/// Inline rejection copy for invalid scan/manual input (idle-state error).
|
||
private(set) var inputRejection: String?
|
||
/// Editable name shown on the confirm page; defaults to the endpoint host
|
||
/// and falls back to it when the user clears the field.
|
||
var hostName = ""
|
||
/// Explicit user acknowledgement for the blocking public-host warning.
|
||
var hasAcknowledgedPublicRisk = false
|
||
/// Set when confirm was attempted on a blocking warning WITHOUT the
|
||
/// acknowledgement — the UI highlights the ack control.
|
||
private(set) var needsPublicRiskAcknowledgement = false
|
||
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
|
||
/// observes it to move on to the session list).
|
||
private(set) var pairedHost: HostRegistry.Host?
|
||
|
||
// MARK: - Dependencies (not observed)
|
||
|
||
@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,
|
||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||
KeychainClientIdentityStore().hasInstalledIdentity()
|
||
}
|
||
) {
|
||
self.store = store
|
||
self.probe = probe
|
||
self.isDeviceCertInstalled = isDeviceCertInstalled
|
||
}
|
||
|
||
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
||
|
||
/// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the
|
||
/// payload must already be a full http(s) URL — no scheme inference for
|
||
/// untrusted external input.
|
||
func handleScannedCode(_ payload: String) {
|
||
guard canAcceptNewTarget else { return }
|
||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else {
|
||
inputRejection = PairingCopy.scanRejected
|
||
return
|
||
}
|
||
enterConfirming(endpoint)
|
||
}
|
||
|
||
/// Manually typed URL. The user knows what they typed, but it still goes
|
||
/// through the SAME confirm state (uniform warning tiers — documented
|
||
/// decision). Convenience: no `://` → `http://` prefix before parsing.
|
||
func submitManualURL(_ text: String) {
|
||
guard canAcceptNewTarget else { return }
|
||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else {
|
||
inputRejection = PairingCopy.manualRejected
|
||
return
|
||
}
|
||
let candidate = trimmed.contains(Self.schemeSeparator)
|
||
? trimmed
|
||
: Self.defaultManualScheme + trimmed
|
||
guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else {
|
||
inputRejection = PairingCopy.manualRejected
|
||
return
|
||
}
|
||
enterConfirming(endpoint)
|
||
}
|
||
|
||
/// Back out of confirm/failed to a clean entry state. Never probes.
|
||
func cancel() {
|
||
phase = .idle
|
||
inputRejection = nil
|
||
hasAcknowledgedPublicRisk = false
|
||
needsPublicRiskAcknowledgement = false
|
||
}
|
||
|
||
// MARK: - Confirm → probe → store
|
||
|
||
/// The ONLY way network starts. No-op unless confirming; a blocking
|
||
/// warning without explicit acknowledgement refuses and flags the UI.
|
||
func confirmConnect() async {
|
||
guard case .confirming(let pending) = phase else { return }
|
||
if pending.warning.isBlocking && !hasAcknowledgedPublicRisk {
|
||
needsPublicRiskAcknowledgement = true
|
||
return
|
||
}
|
||
await runProbe(for: pending)
|
||
}
|
||
|
||
/// Re-run the full probe against the same endpoint after a failure.
|
||
func retry() async {
|
||
guard case .failed(let pending, _) = phase else { return }
|
||
await runProbe(for: pending)
|
||
}
|
||
|
||
private var canAcceptNewTarget: Bool {
|
||
switch phase {
|
||
case .idle, .confirming, .failed:
|
||
return true
|
||
case .probing, .paired:
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func enterConfirming(_ endpoint: HostEndpoint) {
|
||
inputRejection = nil
|
||
hasAcknowledgedPublicRisk = false
|
||
needsPublicRiskAcknowledgement = false
|
||
hostName = endpoint.baseURL.host ?? ""
|
||
phase = .confirming(PendingHost(
|
||
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||
))
|
||
}
|
||
|
||
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, endpoint: pending.endpoint))
|
||
case .success(let endpoint):
|
||
await storePairedHost(endpoint: endpoint, pending: pending)
|
||
}
|
||
}
|
||
|
||
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
|
||
/// endpoint. A store failure is surfaced explicitly (never swallowed);
|
||
/// retry re-runs the whole confirm flow.
|
||
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
||
let host = HostRegistry.Host(
|
||
id: UUID(),
|
||
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
||
endpoint: endpoint
|
||
)
|
||
do {
|
||
_ = try await store.upsert(host)
|
||
} catch {
|
||
phase = .failed(pending, FailureDisplay(
|
||
message: PairingCopy.storeFailed, action: .retry
|
||
))
|
||
return
|
||
}
|
||
pairedHost = host
|
||
phase = .paired(host)
|
||
}
|
||
|
||
// 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:
|
||
return FailureDisplay(
|
||
message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings
|
||
)
|
||
case .hostUnreachable(let underlying):
|
||
return FailureDisplay(
|
||
message: PairingCopy.hostUnreachable(underlying), action: .retry
|
||
)
|
||
case .httpOkButNotWebTerminal:
|
||
return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry)
|
||
case .originRejected(let hint):
|
||
// The probe already derived the complete actionable copy from
|
||
// endpoint.originHeader — surface it VERBATIM, never re-derive.
|
||
return FailureDisplay(message: hint, action: .retry)
|
||
case .atsBlocked(let host):
|
||
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||
case .tlsFailure:
|
||
return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry)
|
||
case .timeout:
|
||
return FailureDisplay(message: PairingCopy.timeout, action: .retry)
|
||
}
|
||
}
|
||
|
||
// MARK: - §5.4 warning tiers
|
||
|
||
/// Decide the confirm-page warning from scheme + host class. Public hosts
|
||
/// 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
|
||
}
|
||
if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme {
|
||
return .none
|
||
}
|
||
switch hostClass {
|
||
case .loopback:
|
||
return .none
|
||
case .tailscale:
|
||
return .tailscaleEncrypted
|
||
case .privateLAN:
|
||
return .plaintextLAN
|
||
case .publicHost:
|
||
return .publicHostBlocking // unreachable; keeps the switch total
|
||
}
|
||
}
|
||
|
||
/// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's
|
||
/// internal `isPrivateOrLocalHost` (finer-grained here) — T-iOS-38 dedup.
|
||
enum HostClass: Equatable, Sendable {
|
||
case loopback
|
||
case tailscale
|
||
case privateLAN
|
||
case publicHost
|
||
}
|
||
|
||
static func classifyHost(_ rawHost: String) -> HostClass {
|
||
let host = rawHost.lowercased()
|
||
.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets
|
||
if host == Self.localhostName {
|
||
return .loopback
|
||
}
|
||
if host.hasSuffix(Self.tailscaleMagicDNSSuffix) {
|
||
return .tailscale
|
||
}
|
||
if host.hasSuffix(Self.mdnsSuffix) {
|
||
return .privateLAN
|
||
}
|
||
if let octets = ipv4Octets(host) {
|
||
return classifyIPv4(octets)
|
||
}
|
||
if host.contains(":") {
|
||
return classifyIPv6(host)
|
||
}
|
||
return .publicHost
|
||
}
|
||
|
||
private static func classifyIPv4(_ octets: [Int]) -> HostClass {
|
||
switch (octets[0], octets[1]) {
|
||
case (127, _):
|
||
return .loopback
|
||
case (10, _), (192, 168), (169, 254):
|
||
return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16
|
||
case (172, 16...31):
|
||
return .privateLAN // RFC1918 172.16/12
|
||
case (100, 64...127):
|
||
return .tailscale // CGNAT 100.64/10
|
||
default:
|
||
return .publicHost
|
||
}
|
||
}
|
||
|
||
private static func classifyIPv6(_ host: String) -> HostClass {
|
||
if host == Self.ipv6Loopback {
|
||
return .loopback
|
||
}
|
||
let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix)
|
||
let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7
|
||
return (isLinkLocal || isULA) ? .privateLAN : .publicHost
|
||
}
|
||
|
||
private static func ipv4Octets(_ host: String) -> [Int]? {
|
||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||
guard parts.count == Self.ipv4OctetCount else { return nil }
|
||
let octets = parts.compactMap { Int($0) }
|
||
guard octets.count == Self.ipv4OctetCount,
|
||
octets.allSatisfy({ Self.ipv4OctetRange.contains($0) })
|
||
else { return nil }
|
||
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"
|
||
private static let localhostName = "localhost"
|
||
private static let tailscaleMagicDNSSuffix = ".ts.net"
|
||
private static let mdnsSuffix = ".local"
|
||
private static let ipv6Loopback = "::1"
|
||
private static let ipv6LinkLocalPrefix = "fe80"
|
||
private static let ipv4OctetCount = 4
|
||
private static let ipv4OctetRange = 0...255
|
||
}
|
||
|
||
/// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2
|
||
/// Local-Network guidance including the iOS 18 restart caveat).
|
||
enum PairingCopy {
|
||
static let scanRejected =
|
||
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
|
||
static let manualRejected =
|
||
"无法解析这个地址。请输入完整 URL,例如 http://192.168.1.5:3000"
|
||
static let storeFailed =
|
||
"主机已通过验证,但保存到本机失败,请重试。"
|
||
static let localNetworkDenied =
|
||
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
|
||
+ "(iOS 18 存在需要重启手机才生效的已知问题)。"
|
||
static let notWebTerminal =
|
||
"对方在响应 HTTP,但不是 web-terminal——端口对吗?"
|
||
static let tlsFailure =
|
||
"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)"
|
||
}
|
||
|
||
/// §3.4 wording for the ATS cleartext block.
|
||
static func atsBlocked(host: String) -> String {
|
||
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
|
||
+ "请改用 https / tailscale serve,或反馈该网段。"
|
||
}
|
||
}
|