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 os
|
||||
import SwiftUI
|
||||
@@ -225,12 +226,19 @@ final class SessionThumbnailPipeline {
|
||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||
}
|
||||
|
||||
/// 生产装配:共享一个 ephemeral URLSession(preview 字节可能含屏上密钥,
|
||||
/// 内存缓存 only——同 T-iOS-19 对 RO GET 的裁定)。
|
||||
/// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存
|
||||
/// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份,
|
||||
/// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。
|
||||
/// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复:
|
||||
/// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。
|
||||
static func live() -> SessionThumbnailPipeline {
|
||||
SessionThumbnailPipeline(
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
let transport = URLSessionHTTPTransport(identityProvider: {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
})
|
||||
return SessionThumbnailPipeline(
|
||||
loader: { request in
|
||||
try await APIClient(endpoint: request.endpoint, http: liveTransport)
|
||||
try await APIClient(endpoint: request.endpoint, http: transport)
|
||||
.preview(id: request.sessionId)
|
||||
},
|
||||
renderer: { data, cols, rows in
|
||||
@@ -239,8 +247,6 @@ final class SessionThumbnailPipeline {
|
||||
)
|
||||
}
|
||||
|
||||
private static let liveTransport = URLSessionHTTPTransport()
|
||||
|
||||
/// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder`
|
||||
/// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。
|
||||
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
|
||||
|
||||
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal file
243
ios/App/WebTerm/Screens/ClientCertScreen.swift
Normal file
@@ -0,0 +1,243 @@
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// C-iOS-3 · Device-certificate install / rotation screen.
|
||||
///
|
||||
/// Flow: `.fileImporter([.pkcs12])` → secure passphrase → import (validates via
|
||||
/// `SecPKCS12Import`) → persist in the keychain → show issuer CN + expiry, with
|
||||
/// replace/rotate + remove. This is the in-app, app-sandboxed install path (a
|
||||
/// `.p12` delivered via AirDrop/Files), NOT a configuration profile.
|
||||
///
|
||||
/// INTEGRATION NOTE: presenting this screen needs a one-line nav/Settings entry
|
||||
/// ("Device certificate") in the app chrome (RootView / PairingScreen), which
|
||||
/// live outside this task's ownership. The screen is fully self-contained so
|
||||
/// that wiring is a single `NavigationLink { ClientCertScreen() }`.
|
||||
struct ClientCertScreen: View {
|
||||
@State private var model: ClientCertViewModel
|
||||
|
||||
init(model: ClientCertViewModel = ClientCertViewModel()) {
|
||||
_model = State(initialValue: model)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
installedSection
|
||||
importSection
|
||||
if model.summary != nil {
|
||||
removeSection
|
||||
}
|
||||
}
|
||||
.navigationTitle(ClientCertCopy.title)
|
||||
.fileImporter(
|
||||
isPresented: $model.isPickingFile,
|
||||
allowedContentTypes: [.pkcs12],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
model.handleFileSelection(result)
|
||||
}
|
||||
.task { model.refresh() }
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
@ViewBuilder private var installedSection: some View {
|
||||
Section(ClientCertCopy.installedHeader) {
|
||||
if let summary = model.summary {
|
||||
LabeledContent(ClientCertCopy.subject, value: summary.subjectCommonName ?? "—")
|
||||
LabeledContent(ClientCertCopy.issuer, value: summary.issuerCommonName ?? "—")
|
||||
LabeledContent(ClientCertCopy.expiry, value: model.expiryText(summary))
|
||||
if summary.isExpired() {
|
||||
Label(ClientCertCopy.expiredWarning, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} else {
|
||||
Text(ClientCertCopy.noneInstalled).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var importSection: some View {
|
||||
Section {
|
||||
if let name = model.pendingFileName {
|
||||
LabeledContent(ClientCertCopy.selectedFile, value: name)
|
||||
SecureField(ClientCertCopy.passphrase, text: $model.passphrase)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
Button(ClientCertCopy.importAction) { model.importPending() }
|
||||
.disabled(!model.canImport)
|
||||
Button(ClientCertCopy.cancelAction, role: .cancel) { model.clearPending() }
|
||||
} else {
|
||||
Button {
|
||||
model.beginPicking()
|
||||
} label: {
|
||||
Label(
|
||||
model.summary == nil
|
||||
? ClientCertCopy.chooseFile
|
||||
: ClientCertCopy.replaceFile,
|
||||
systemImage: "doc.badge.plus"
|
||||
)
|
||||
}
|
||||
}
|
||||
if let error = model.errorMessage {
|
||||
Text(error).foregroundStyle(.red).font(.footnote)
|
||||
}
|
||||
} header: {
|
||||
Text(model.summary == nil ? ClientCertCopy.importHeader : ClientCertCopy.rotateHeader)
|
||||
} footer: {
|
||||
Text(ClientCertCopy.importFooter)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var removeSection: some View {
|
||||
Section {
|
||||
Button(ClientCertCopy.removeAction, role: .destructive) { model.remove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State machine for the install screen. Errors are mapped to actionable copy
|
||||
/// and never swallowed; a failed import leaves any prior identity intact.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ClientCertViewModel {
|
||||
private(set) var summary: ClientCertificateSummary?
|
||||
var isPickingFile = false
|
||||
var passphrase = ""
|
||||
private(set) var pendingData: Data?
|
||||
private(set) var pendingFileName: String?
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let store: any ClientIdentityStore
|
||||
|
||||
init(store: any ClientIdentityStore = KeychainClientIdentityStore()) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var canImport: Bool { pendingData != nil && !passphrase.isEmpty }
|
||||
|
||||
func refresh() {
|
||||
do {
|
||||
summary = try store.loadSummary()
|
||||
} catch {
|
||||
summary = nil
|
||||
errorMessage = ClientCertCopy.loadFailed
|
||||
}
|
||||
}
|
||||
|
||||
func beginPicking() {
|
||||
errorMessage = nil
|
||||
isPickingFile = true
|
||||
}
|
||||
|
||||
func handleFileSelection(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .failure:
|
||||
errorMessage = ClientCertCopy.fileReadFailed
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
readFile(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func readFile(at url: URL) {
|
||||
let didAccess = url.startAccessingSecurityScopedResource()
|
||||
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
|
||||
do {
|
||||
pendingData = try Data(contentsOf: url)
|
||||
pendingFileName = url.lastPathComponent
|
||||
passphrase = ""
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
pendingData = nil
|
||||
pendingFileName = nil
|
||||
errorMessage = ClientCertCopy.fileReadFailed
|
||||
}
|
||||
}
|
||||
|
||||
func importPending() {
|
||||
guard let data = pendingData else { return }
|
||||
do {
|
||||
try store.save(p12Data: data, passphrase: passphrase)
|
||||
clearPending()
|
||||
refresh()
|
||||
} catch {
|
||||
errorMessage = Self.copy(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
func clearPending() {
|
||||
pendingData = nil
|
||||
pendingFileName = nil
|
||||
passphrase = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func remove() {
|
||||
do {
|
||||
try store.remove()
|
||||
summary = nil
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = ClientCertCopy.removeFailed
|
||||
}
|
||||
}
|
||||
|
||||
func expiryText(_ summary: ClientCertificateSummary) -> String {
|
||||
guard let notAfter = summary.notAfter else { return "—" }
|
||||
return notAfter.formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
|
||||
/// Map an import/storage error to install-UX copy (never swallowed).
|
||||
private static func copy(for error: any Error) -> String {
|
||||
switch error {
|
||||
case PKCS12ImportError.wrongPassphrase:
|
||||
return ClientCertCopy.errWrongPassphrase
|
||||
case PKCS12ImportError.corruptFile:
|
||||
return ClientCertCopy.errCorruptFile
|
||||
case PKCS12ImportError.unsupported:
|
||||
return ClientCertCopy.errUnsupported
|
||||
case PKCS12ImportError.noIdentity:
|
||||
return ClientCertCopy.errNoIdentity
|
||||
case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob:
|
||||
return ClientCertCopy.errKeychain
|
||||
default:
|
||||
return ClientCertCopy.errUnknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing copy (Chinese, matching the rest of the app).
|
||||
enum ClientCertCopy {
|
||||
static let title = "设备证书"
|
||||
static let installedHeader = "已安装证书"
|
||||
static let subject = "设备 (CN)"
|
||||
static let issuer = "签发 CA"
|
||||
static let expiry = "有效期至"
|
||||
static let expiredWarning = "证书已过期,请重新导入。"
|
||||
static let noneInstalled = "尚未安装设备证书。"
|
||||
|
||||
static let importHeader = "导入证书"
|
||||
static let rotateHeader = "替换证书"
|
||||
static let chooseFile = "选择 .p12 文件"
|
||||
static let replaceFile = "选择新的 .p12 文件"
|
||||
static let selectedFile = "已选文件"
|
||||
static let passphrase = "证书密码"
|
||||
static let importAction = "导入"
|
||||
static let cancelAction = "取消"
|
||||
static let removeAction = "移除证书"
|
||||
static let importFooter =
|
||||
"证书用于连接你自己的隧道主机 (mTLS)。通过 AirDrop / 文件 把 .p12 传到本机后在此导入;证书与密码只保存在本设备钥匙串。"
|
||||
|
||||
static let errWrongPassphrase = "密码错误,请重试。"
|
||||
static let errCorruptFile = "文件无法识别,请确认是有效的 .p12 证书。"
|
||||
static let errUnsupported = "证书格式不受支持(请用 openssl pkcs12 -export -legacy 生成)。"
|
||||
static let errNoIdentity = "该 .p12 不含身份(缺少私钥),无法用于客户端认证。"
|
||||
static let errKeychain = "保存到钥匙串失败,请重试。"
|
||||
static let errUnknown = "导入失败,请重试。"
|
||||
static let loadFailed = "读取已安装证书失败。"
|
||||
static let fileReadFailed = "无法读取所选文件。"
|
||||
static let removeFailed = "移除证书失败,请重试。"
|
||||
}
|
||||
@@ -23,6 +23,12 @@ struct SessionListScreen: View {
|
||||
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||||
var onAddHost: () -> Void = {}
|
||||
/// C-iOS-3 (HIGH reachability fix) · "设备证书" entry — presents
|
||||
/// `ClientCertScreen` (import / rotate the mTLS device cert). The device
|
||||
/// cert is a global concern, but the toolbar host-menu is the app's only
|
||||
/// settings-like surface and is present in every empty state, so this is the
|
||||
/// nav idiom that makes the orphaned screen reachable.
|
||||
var onDeviceCert: () -> Void = {}
|
||||
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||||
@@ -213,6 +219,12 @@ struct SessionListScreen: View {
|
||||
} label: {
|
||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
onDeviceCert()
|
||||
} label: {
|
||||
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.deviceCert")
|
||||
} label: {
|
||||
Label(
|
||||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||||
@@ -342,6 +354,7 @@ private enum ScreenCopy {
|
||||
static let newSession = "新建会话"
|
||||
static let kill = "结束"
|
||||
static let addHost = "配对新主机"
|
||||
static let deviceCert = "设备证书"
|
||||
static let hostMenuFallback = "主机"
|
||||
static let notPairedTitle = "还没有配对的主机"
|
||||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -51,6 +51,9 @@ struct AdaptiveRootView: View {
|
||||
) {
|
||||
projectsSheet
|
||||
}
|
||||
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
|
||||
deviceCertSheet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout branch (the SOLE size-class consumer)
|
||||
@@ -107,4 +110,20 @@ struct AdaptiveRootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device certificate sheet (C-iOS-3, HIGH reachability fix)
|
||||
|
||||
/// 自带 NavigationStack(`ClientCertScreen` 用 `.navigationTitle`)+ 右上「完成」
|
||||
/// 关闭按钮,保证导入 .p12 后能明确返回。`ClientCertScreen` 自持
|
||||
/// `ClientCertViewModel`(keychain store),此处零依赖注入。
|
||||
@ViewBuilder private var deviceCertSheet: some View {
|
||||
NavigationStack {
|
||||
ClientCertScreen()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(RootCopy.done) { coordinator.isDeviceCertPresented = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ final class AppCoordinator {
|
||||
/// prefs 每次进入都重新拉取。
|
||||
private(set) var projectsViewModel: ProjectsViewModel?
|
||||
var isProjectsPresented = false
|
||||
/// C-iOS-3 (HIGH reachability fix) · "设备证书" sheet (import / rotate the
|
||||
/// mTLS device cert via `ClientCertScreen`). No VM state — the screen owns
|
||||
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
|
||||
/// refresh because the transports resolve the identity lazily per connection.
|
||||
var isDeviceCertPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
@@ -108,6 +113,13 @@ final class AppCoordinator {
|
||||
projectsViewModel = nil
|
||||
}
|
||||
|
||||
// MARK: - Device certificate (C-iOS-3)
|
||||
|
||||
/// Toolbar host-menu 入口:呈现设备证书导入/轮换 sheet。
|
||||
func presentDeviceCert() {
|
||||
isDeviceCertPresented = true
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||
func openProject(_ request: ProjectOpenRequest) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import APIClient
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
@@ -37,8 +38,20 @@ struct AppEnvironment: Sendable {
|
||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
let http = URLSessionHTTPTransport()
|
||||
let termTransport = URLSessionTermTransport()
|
||||
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
||||
// identity LAZILY from the keychain on each connect/challenge, injected
|
||||
// into BOTH transports + the probe as a provider. This way a certificate
|
||||
// imported from the "设备证书" screen takes effect on the NEXT connection
|
||||
// without relaunching — a snapshot captured here would stay stale. A
|
||||
// missing cert is the normal pre-install state (→ nil); genuine faults
|
||||
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
|
||||
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
let identityProvider: @Sendable () -> ClientIdentity? = {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
}
|
||||
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
|
||||
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
|
||||
return AppEnvironment(
|
||||
hostStore: KeychainHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||
|
||||
@@ -74,7 +74,8 @@ struct StackRootView: View {
|
||||
SessionListScreen(
|
||||
viewModel: coordinator.sessionList,
|
||||
onOpen: { coordinator.open($0) },
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
onAddHost: { coordinator.presentAddHost() },
|
||||
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
||||
@@ -178,4 +179,5 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
static let done = "完成"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ struct SplitRootView: View {
|
||||
// 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。
|
||||
coordinator.selectSidebarItem(sidebarItem(for: request))
|
||||
},
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
onAddHost: { coordinator.presentAddHost() },
|
||||
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
.animation(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
@@ -9,14 +10,39 @@ import WireProtocol
|
||||
/// bypass that single audited point (review CRITICAL).
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
||||
/// until invalidated, but this ephemeral session is never explicitly
|
||||
/// invalidated, so holding it here documents the ownership and keeps the
|
||||
/// transport a self-contained value.
|
||||
private let tlsDelegate: LazyClientTLSSessionDelegate
|
||||
|
||||
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
|
||||
/// include `/live-sessions/:id/preview` — raw terminal ring-buffer bytes
|
||||
/// that may contain printed secrets — and `.shared`'s default URLCache
|
||||
/// writes responses to disk. Ephemeral keeps them memory-only, matching
|
||||
/// the WS transport and the privacy-shade posture.
|
||||
init(session: URLSession = URLSession(configuration: .ephemeral)) {
|
||||
self.session = session
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · The session's mTLS delegate resolves
|
||||
/// the device identity LAZILY, per client-certificate challenge, from
|
||||
/// `identityProvider`. A `ClientCertificate` challenge from a tunneled host
|
||||
/// is thus answered with whatever cert is installed AT CHALLENGE TIME — so a
|
||||
/// certificate imported mid-run is presented on the NEXT TLS handshake
|
||||
/// without an app relaunch (a snapshot captured here would stay stale). The
|
||||
/// session itself stays a single long-lived value (no per-request churn).
|
||||
/// Local http/https hosts never issue that challenge, so the provider is
|
||||
/// never even consulted for them (and a `nil` result is inert regardless).
|
||||
///
|
||||
/// EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies include
|
||||
/// `/live-sessions/:id/preview` — raw terminal ring-buffer bytes that may
|
||||
/// contain printed secrets — and `.shared`'s default URLCache writes
|
||||
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
||||
/// transport and the privacy-shade posture.
|
||||
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
||||
self.tlsDelegate = delegate
|
||||
self.session = URLSession(
|
||||
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
||||
)
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
@@ -29,3 +55,40 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
return (data, httpResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
||||
/// the device identity FRESH per client-certificate challenge — the App-layer
|
||||
/// twin of ClientTLS's fixed-identity `ClientTLSSessionDelegate` (which captures
|
||||
/// the identity once). The provider is only consulted for a `ClientCertificate`
|
||||
/// challenge, so a `ServerTrust` challenge never triggers a keychain read; the
|
||||
/// pure decision itself is delegated to the shared `MutualTLSChallengeResponder`
|
||||
/// (single truth table, unit-tested in ClientTLS).
|
||||
///
|
||||
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
||||
/// arbitrary queues; every stored field is an immutable `let` over a `@Sendable`
|
||||
/// value (the provider is `@Sendable`, the responder is stateless).
|
||||
private final class LazyClientTLSSessionDelegate:
|
||||
NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||
private let responder = MutualTLSChallengeResponder()
|
||||
|
||||
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
self.identityProvider = identityProvider
|
||||
super.init()
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
// Only a client-certificate challenge needs the identity; resolving it
|
||||
// for a server-trust challenge would do a needless keychain read/import
|
||||
// on every HTTPS handshake.
|
||||
let isClientCert = challenge.protectionSpace.authenticationMethod
|
||||
== NSURLAuthenticationMethodClientCertificate
|
||||
let identity = isClientCert ? identityProvider() : nil
|
||||
let resolution = responder.resolve(challenge, identity: identity)
|
||||
completionHandler(resolution.disposition, resolution.credential)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user