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:
@@ -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