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 APIClient
|
||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -225,12 +226,19 @@ final class SessionThumbnailPipeline {
|
|||||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生产装配:共享一个 ephemeral URLSession(preview 字节可能含屏上密钥,
|
/// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存
|
||||||
/// 内存缓存 only——同 T-iOS-19 对 RO GET 的裁定)。
|
/// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份,
|
||||||
|
/// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。
|
||||||
|
/// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复:
|
||||||
|
/// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。
|
||||||
static func live() -> SessionThumbnailPipeline {
|
static func live() -> SessionThumbnailPipeline {
|
||||||
SessionThumbnailPipeline(
|
let identityStore = KeychainClientIdentityStore()
|
||||||
|
let transport = URLSessionHTTPTransport(identityProvider: {
|
||||||
|
identityStore.loadedIdentityOrNil()
|
||||||
|
})
|
||||||
|
return SessionThumbnailPipeline(
|
||||||
loader: { request in
|
loader: { request in
|
||||||
try await APIClient(endpoint: request.endpoint, http: liveTransport)
|
try await APIClient(endpoint: request.endpoint, http: transport)
|
||||||
.preview(id: request.sessionId)
|
.preview(id: request.sessionId)
|
||||||
},
|
},
|
||||||
renderer: { data, cols, rows in
|
renderer: { data, cols, rows in
|
||||||
@@ -239,8 +247,6 @@ final class SessionThumbnailPipeline {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let liveTransport = URLSessionHTTPTransport()
|
|
||||||
|
|
||||||
/// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder`
|
/// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder`
|
||||||
/// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。
|
/// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。
|
||||||
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
|
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 }
|
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||||||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||||||
var onAddHost: () -> Void = {}
|
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
|
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||||||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||||||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||||||
@@ -213,6 +219,12 @@ struct SessionListScreen: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
onDeviceCert()
|
||||||
|
} label: {
|
||||||
|
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("sessions.deviceCert")
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||||||
@@ -342,6 +354,7 @@ private enum ScreenCopy {
|
|||||||
static let newSession = "新建会话"
|
static let newSession = "新建会话"
|
||||||
static let kill = "结束"
|
static let kill = "结束"
|
||||||
static let addHost = "配对新主机"
|
static let addHost = "配对新主机"
|
||||||
|
static let deviceCert = "设备证书"
|
||||||
static let hostMenuFallback = "主机"
|
static let hostMenuFallback = "主机"
|
||||||
static let notPairedTitle = "还没有配对的主机"
|
static let notPairedTitle = "还没有配对的主机"
|
||||||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import APIClient
|
import APIClient
|
||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import HostRegistry
|
import HostRegistry
|
||||||
import Observation
|
import Observation
|
||||||
@@ -116,10 +117,21 @@ final class PairingViewModel {
|
|||||||
|
|
||||||
@ObservationIgnored private let store: any HostStore
|
@ObservationIgnored private let store: any HostStore
|
||||||
@ObservationIgnored private let probe: Probe
|
@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.store = store
|
||||||
self.probe = probe
|
self.probe = probe
|
||||||
|
self.isDeviceCertInstalled = isDeviceCertInstalled
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
||||||
@@ -205,10 +217,20 @@ final class PairingViewModel {
|
|||||||
|
|
||||||
private func runProbe(for pending: PendingHost) async {
|
private func runProbe(for pending: PendingHost) async {
|
||||||
needsPublicRiskAcknowledgement = false
|
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)
|
phase = .probing(pending)
|
||||||
switch await probe(pending.endpoint) {
|
switch await probe(pending.endpoint) {
|
||||||
case .failure(let error):
|
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):
|
case .success(let endpoint):
|
||||||
await storePairedHost(endpoint: endpoint, pending: pending)
|
await storePairedHost(endpoint: endpoint, pending: pending)
|
||||||
}
|
}
|
||||||
@@ -239,6 +261,19 @@ final class PairingViewModel {
|
|||||||
|
|
||||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
// 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 {
|
static func display(for error: PairingError) -> FailureDisplay {
|
||||||
switch error {
|
switch error {
|
||||||
case .localNetworkDenied:
|
case .localNetworkDenied:
|
||||||
@@ -270,6 +305,14 @@ final class PairingViewModel {
|
|||||||
/// block regardless of scheme (§5.4 table: https is included in the
|
/// block regardless of scheme (§5.4 table: https is included in the
|
||||||
/// public-host confirm warning); otherwise https clears every notice.
|
/// public-host confirm warning); otherwise https clears every notice.
|
||||||
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
|
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 ?? "")
|
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
|
||||||
if hostClass == .publicHost {
|
if hostClass == .publicHost {
|
||||||
return .publicHostBlocking
|
return .publicHostBlocking
|
||||||
@@ -353,8 +396,17 @@ final class PairingViewModel {
|
|||||||
return octets
|
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)
|
// MARK: - Named constants (no magic values, plan §4)
|
||||||
|
|
||||||
|
private static let tunnelZoneSuffix = ".terminal.yaojia.wang"
|
||||||
private static let schemeSeparator = "://"
|
private static let schemeSeparator = "://"
|
||||||
private static let defaultManualScheme = "http://"
|
private static let defaultManualScheme = "http://"
|
||||||
private static let httpsScheme = "https"
|
private static let httpsScheme = "https"
|
||||||
@@ -385,6 +437,13 @@ enum PairingCopy {
|
|||||||
"TLS 连接失败:证书无效或不受信任。"
|
"TLS 连接失败:证书无效或不受信任。"
|
||||||
static let timeout =
|
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 {
|
static func hostUnreachable(_ underlying: String) -> String {
|
||||||
"无法连接主机:\(underlying)"
|
"无法连接主机:\(underlying)"
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ struct AdaptiveRootView: View {
|
|||||||
) {
|
) {
|
||||||
projectsSheet
|
projectsSheet
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
|
||||||
|
deviceCertSheet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout branch (the SOLE size-class consumer)
|
// 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 每次进入都重新拉取。
|
/// prefs 每次进入都重新拉取。
|
||||||
private(set) var projectsViewModel: ProjectsViewModel?
|
private(set) var projectsViewModel: ProjectsViewModel?
|
||||||
var isProjectsPresented = false
|
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
|
let sessionList: SessionListViewModel
|
||||||
@ObservationIgnored let environment: AppEnvironment
|
@ObservationIgnored let environment: AppEnvironment
|
||||||
@@ -108,6 +113,13 @@ final class AppCoordinator {
|
|||||||
projectsViewModel = nil
|
projectsViewModel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device certificate (C-iOS-3)
|
||||||
|
|
||||||
|
/// Toolbar host-menu 入口:呈现设备证书导入/轮换 sheet。
|
||||||
|
func presentDeviceCert() {
|
||||||
|
isDeviceCertPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||||
func openProject(_ request: ProjectOpenRequest) {
|
func openProject(_ request: ProjectOpenRequest) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import APIClient
|
import APIClient
|
||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import HostRegistry
|
import HostRegistry
|
||||||
import SessionCore
|
import SessionCore
|
||||||
@@ -37,8 +38,20 @@ struct AppEnvironment: Sendable {
|
|||||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||||
|
|
||||||
static func production() -> AppEnvironment {
|
static func production() -> AppEnvironment {
|
||||||
let http = URLSessionHTTPTransport()
|
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
||||||
let termTransport = URLSessionTermTransport()
|
// 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(
|
return AppEnvironment(
|
||||||
hostStore: KeychainHostStore(),
|
hostStore: KeychainHostStore(),
|
||||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ struct StackRootView: View {
|
|||||||
SessionListScreen(
|
SessionListScreen(
|
||||||
viewModel: coordinator.sessionList,
|
viewModel: coordinator.sessionList,
|
||||||
onOpen: { coordinator.open($0) },
|
onOpen: { coordinator.open($0) },
|
||||||
onAddHost: { coordinator.presentAddHost() }
|
onAddHost: { coordinator.presentAddHost() },
|
||||||
|
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||||
)
|
)
|
||||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||||
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
||||||
@@ -178,4 +179,5 @@ struct ProjectsToolbarItem: ToolbarContent {
|
|||||||
enum RootCopy {
|
enum RootCopy {
|
||||||
static let continueLast = "继续上次会话"
|
static let continueLast = "继续上次会话"
|
||||||
static let projects = "项目"
|
static let projects = "项目"
|
||||||
|
static let done = "完成"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ struct SplitRootView: View {
|
|||||||
// 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。
|
// 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。
|
||||||
coordinator.selectSidebarItem(sidebarItem(for: request))
|
coordinator.selectSidebarItem(sidebarItem(for: request))
|
||||||
},
|
},
|
||||||
onAddHost: { coordinator.presentAddHost() }
|
onAddHost: { coordinator.presentAddHost() },
|
||||||
|
onDeviceCert: { coordinator.presentDeviceCert() }
|
||||||
)
|
)
|
||||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||||
.animation(
|
.animation(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClientTLS
|
||||||
import Foundation
|
import Foundation
|
||||||
import WireProtocol
|
import WireProtocol
|
||||||
|
|
||||||
@@ -9,14 +10,39 @@ import WireProtocol
|
|||||||
/// bypass that single audited point (review CRITICAL).
|
/// bypass that single audited point (review CRITICAL).
|
||||||
struct URLSessionHTTPTransport: HTTPTransport {
|
struct URLSessionHTTPTransport: HTTPTransport {
|
||||||
private let session: URLSession
|
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
|
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||||
/// include `/live-sessions/:id/preview` — raw terminal ring-buffer bytes
|
/// provider, so behaviour is identical to capturing the identity directly.
|
||||||
/// that may contain printed secrets — and `.shared`'s default URLCache
|
init(identity: ClientIdentity? = nil) {
|
||||||
/// writes responses to disk. Ephemeral keeps them memory-only, matching
|
self.init(identityProvider: { identity })
|
||||||
/// the WS transport and the privacy-shade posture.
|
}
|
||||||
init(session: URLSession = URLSession(configuration: .ephemeral)) {
|
|
||||||
self.session = session
|
/// 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) {
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
@@ -29,3 +55,40 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
|||||||
return (data, httpResponse)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
22
ios/Packages/ClientTLS/Package.swift
Normal file
22
ios/Packages/ClientTLS/Package.swift
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
// C-iOS-1 · Device client-certificate (mutual-TLS) leaf package.
|
||||||
|
//
|
||||||
|
// Standalone, no local-package dependencies — imports ONLY Security/Foundation.
|
||||||
|
// It is a downward leaf: SessionCore and the App target may depend on it, it
|
||||||
|
// depends on nothing in this workspace. This keeps the pure, unit-testable
|
||||||
|
// mTLS logic (identity wrapper, PKCS#12 import, challenge responder) isolated
|
||||||
|
// from the WS/HTTP transport plumbing that consumes it.
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ClientTLS",
|
||||||
|
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||||
|
products: [
|
||||||
|
.library(name: "ClientTLS", targets: ["ClientTLS"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "ClientTLS"),
|
||||||
|
.testTarget(name: "ClientTLSTests", dependencies: ["ClientTLS"]),
|
||||||
|
],
|
||||||
|
swiftLanguageModes: [.v6]
|
||||||
|
)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// C-iOS-3 · Display summary of a device certificate, shown on the install /
|
||||||
|
/// rotation screen so the user can confirm *which* cert is active and *when*
|
||||||
|
/// it expires before relying on it.
|
||||||
|
public struct ClientCertificateSummary: Equatable, Sendable {
|
||||||
|
/// Subject common name — the device/leaf CN (e.g. `t1-iphone`).
|
||||||
|
public let subjectCommonName: String?
|
||||||
|
/// Issuer common name — the device-CA CN (e.g. `webterm-device-ca`).
|
||||||
|
public let issuerCommonName: String?
|
||||||
|
/// Not-after date; `nil` if it could not be parsed.
|
||||||
|
public let notAfter: Date?
|
||||||
|
|
||||||
|
public init(subjectCommonName: String?, issuerCommonName: String?, notAfter: Date?) {
|
||||||
|
self.subjectCommonName = subjectCommonName
|
||||||
|
self.issuerCommonName = issuerCommonName
|
||||||
|
self.notAfter = notAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expired relative to `now` (defaults to the current instant). Unknown
|
||||||
|
/// expiry is treated as NOT expired (fail-open for display only — the TLS
|
||||||
|
/// stack, not this label, is the real gate).
|
||||||
|
public func isExpired(asOf now: Date = Date()) -> Bool {
|
||||||
|
guard let notAfter else { return false }
|
||||||
|
return notAfter < now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the display fields off a `SecCertificate`.
|
||||||
|
///
|
||||||
|
/// Implemented as a minimal X.509 DER walk over `SecCertificateCopyData` rather
|
||||||
|
/// than `SecCertificateCopyValues`, because that API and its `kSecOID*` /
|
||||||
|
/// `kSecPropertyKey*` constants are **macOS-only** — unavailable on iOS, which
|
||||||
|
/// is the real deployment target. The DER walk is identical on both platforms
|
||||||
|
/// and unit-testable against the fixture. Any parse miss degrades to `nil`
|
||||||
|
/// fields (the summary is display-only; the TLS stack is the gate).
|
||||||
|
enum CertificateInspector {
|
||||||
|
static func summary(of certificate: SecCertificate) -> ClientCertificateSummary {
|
||||||
|
let der = [UInt8](SecCertificateCopyData(certificate) as Data)
|
||||||
|
let fields = X509Fields.parse(der: der)
|
||||||
|
return ClientCertificateSummary(
|
||||||
|
subjectCommonName: fields?.subjectCommonName,
|
||||||
|
issuerCommonName: fields?.issuerCommonName,
|
||||||
|
notAfter: fields?.notAfter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Minimal ASN.1 DER reader
|
||||||
|
|
||||||
|
/// One tag-length-value element, as byte ranges into the source buffer.
|
||||||
|
private struct DERElement {
|
||||||
|
let tag: UInt8
|
||||||
|
let valueStart: Int
|
||||||
|
let valueEnd: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum DER {
|
||||||
|
/// DER tags used here.
|
||||||
|
static let sequence: UInt8 = 0x30
|
||||||
|
static let set: UInt8 = 0x31
|
||||||
|
static let oid: UInt8 = 0x06
|
||||||
|
static let contextTag0: UInt8 = 0xA0 // [0] EXPLICIT (X.509 version)
|
||||||
|
static let utcTime: UInt8 = 0x17
|
||||||
|
static let generalizedTime: UInt8 = 0x18
|
||||||
|
|
||||||
|
/// Read a single TLV at `start`. Returns the element and the index just
|
||||||
|
/// past it, or `nil` on any malformed length/overrun (defensive: the cert
|
||||||
|
/// bytes come from the system but are still parsed as untrusted input).
|
||||||
|
static func read(_ bytes: [UInt8], at start: Int) -> DERElement? {
|
||||||
|
guard start >= 0, start + 1 < bytes.count else { return nil }
|
||||||
|
let tag = bytes[start]
|
||||||
|
var index = start + 1
|
||||||
|
let firstLengthByte = bytes[index]
|
||||||
|
index += 1
|
||||||
|
var length = 0
|
||||||
|
if firstLengthByte & 0x80 == 0 {
|
||||||
|
length = Int(firstLengthByte)
|
||||||
|
} else {
|
||||||
|
let byteCount = Int(firstLengthByte & 0x7F)
|
||||||
|
guard byteCount > 0, byteCount <= 4, index + byteCount <= bytes.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _ in 0..<byteCount {
|
||||||
|
length = (length << 8) | Int(bytes[index])
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let valueEnd = index + length
|
||||||
|
guard length >= 0, valueEnd <= bytes.count else { return nil }
|
||||||
|
return DERElement(tag: tag, valueStart: index, valueEnd: valueEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a constructed value `[start, end)` into its immediate children.
|
||||||
|
static func children(_ bytes: [UInt8], from start: Int, to end: Int) -> [DERElement] {
|
||||||
|
var elements: [DERElement] = []
|
||||||
|
var index = start
|
||||||
|
while index < end, let element = read(bytes, at: index) {
|
||||||
|
elements.append(element)
|
||||||
|
index = element.valueEnd
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - X.509 field extraction
|
||||||
|
|
||||||
|
private struct X509Fields {
|
||||||
|
let subjectCommonName: String?
|
||||||
|
let issuerCommonName: String?
|
||||||
|
let notAfter: Date?
|
||||||
|
|
||||||
|
/// CommonName attribute OID `2.5.4.3` → DER content bytes `55 04 03`.
|
||||||
|
private static let commonNameOID: [UInt8] = [0x55, 0x04, 0x03]
|
||||||
|
|
||||||
|
/// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
|
||||||
|
/// tbsCertificate ::= SEQUENCE { [0] version?, serialNumber, signature,
|
||||||
|
/// issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
|
||||||
|
static func parse(der bytes: [UInt8]) -> X509Fields? {
|
||||||
|
guard let certificate = DER.read(bytes, at: 0), certificate.tag == DER.sequence else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let certChildren = DER.children(
|
||||||
|
bytes, from: certificate.valueStart, to: certificate.valueEnd
|
||||||
|
)
|
||||||
|
guard let tbs = certChildren.first, tbs.tag == DER.sequence else { return nil }
|
||||||
|
|
||||||
|
var tbsChildren = DER.children(bytes, from: tbs.valueStart, to: tbs.valueEnd)
|
||||||
|
// Optional EXPLICIT [0] version — drop it so the fixed fields align.
|
||||||
|
if let first = tbsChildren.first, first.tag == DER.contextTag0 {
|
||||||
|
tbsChildren.removeFirst()
|
||||||
|
}
|
||||||
|
// Fixed order after (optional) version: serialNumber, signature,
|
||||||
|
// issuer, validity, subject, ...
|
||||||
|
guard tbsChildren.count >= 5 else { return nil }
|
||||||
|
let issuer = tbsChildren[2]
|
||||||
|
let validity = tbsChildren[3]
|
||||||
|
let subject = tbsChildren[4]
|
||||||
|
|
||||||
|
return X509Fields(
|
||||||
|
subjectCommonName: commonName(bytes, in: subject),
|
||||||
|
issuerCommonName: commonName(bytes, in: issuer),
|
||||||
|
notAfter: notAfterDate(bytes, in: validity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name ::= SEQUENCE OF RDN(SET) OF AttributeTypeAndValue(SEQ{OID, value}).
|
||||||
|
/// Returns the first CN value found.
|
||||||
|
private static func commonName(_ bytes: [UInt8], in name: DERElement) -> String? {
|
||||||
|
for rdn in DER.children(bytes, from: name.valueStart, to: name.valueEnd)
|
||||||
|
where rdn.tag == DER.set {
|
||||||
|
for atv in DER.children(bytes, from: rdn.valueStart, to: rdn.valueEnd)
|
||||||
|
where atv.tag == DER.sequence {
|
||||||
|
let parts = DER.children(bytes, from: atv.valueStart, to: atv.valueEnd)
|
||||||
|
guard parts.count >= 2, parts[0].tag == DER.oid else { continue }
|
||||||
|
let oid = Array(bytes[parts[0].valueStart..<parts[0].valueEnd])
|
||||||
|
if oid == commonNameOID {
|
||||||
|
let value = Array(bytes[parts[1].valueStart..<parts[1].valueEnd])
|
||||||
|
// PrintableString / UTF8String both decode as UTF-8.
|
||||||
|
return String(bytes: value, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validity ::= SEQUENCE { notBefore Time, notAfter Time } — the 2nd child.
|
||||||
|
private static func notAfterDate(_ bytes: [UInt8], in validity: DERElement) -> Date? {
|
||||||
|
let times = DER.children(bytes, from: validity.valueStart, to: validity.valueEnd)
|
||||||
|
guard times.count >= 2 else { return nil }
|
||||||
|
let notAfter = times[1]
|
||||||
|
let value = Array(bytes[notAfter.valueStart..<notAfter.valueEnd])
|
||||||
|
guard let text = String(bytes: value, encoding: .ascii) else { return nil }
|
||||||
|
return parseTime(text, tag: notAfter.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseTime(_ text: String, tag: UInt8) -> Date? {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||||
|
// UTCTime: YYMMDDHHMMSSZ (used for years < 2050); GeneralizedTime:
|
||||||
|
// YYYYMMDDHHMMSSZ. The 2-digit-year window covers the relevant range
|
||||||
|
// (device certs live in the 2020s-2040s).
|
||||||
|
formatter.dateFormat = tag == DER.utcTime ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||||
|
return formatter.date(from: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// C-iOS-1 · An imported device client identity: the `SecIdentity` (leaf
|
||||||
|
/// certificate + its private key) plus the issuer chain that accompanies it in
|
||||||
|
/// the TLS handshake.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable`: `SecIdentity` / `SecCertificate` are CoreFoundation
|
||||||
|
/// handles that are immutable once imported and thread-safe to read; this value
|
||||||
|
/// only ever holds finished imports and never mutates them. Marking it lets the
|
||||||
|
/// identity flow into the `@unchecked Sendable` WS connection and the URLSession
|
||||||
|
/// delegates that answer client-certificate challenges.
|
||||||
|
public struct ClientIdentity: @unchecked Sendable {
|
||||||
|
/// The leaf certificate + private key used to authenticate to the server.
|
||||||
|
public let secIdentity: SecIdentity
|
||||||
|
/// Issuer certificates to present alongside the leaf (the CA chain, leaf
|
||||||
|
/// excluded). May be empty when the trust anchor is already pinned server
|
||||||
|
/// side (nginx `ssl_client_certificate` = the device-CA) — the leaf alone
|
||||||
|
/// then verifies at `ssl_verify_depth 1`.
|
||||||
|
public let issuerCertificates: [SecCertificate]
|
||||||
|
|
||||||
|
public init(secIdentity: SecIdentity, issuerCertificates: [SecCertificate] = []) {
|
||||||
|
self.secIdentity = secIdentity
|
||||||
|
self.issuerCertificates = issuerCertificates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `URLCredential` handed back to a `ClientCertificate` auth challenge.
|
||||||
|
///
|
||||||
|
/// `.forSession` (not `.permanent`) per plan §C-iOS-1: the identity already
|
||||||
|
/// lives in the app's keychain item — persisting the credential in the
|
||||||
|
/// shared URL credential store would be a second, unmanaged copy.
|
||||||
|
public func urlCredential(
|
||||||
|
persistence: URLCredential.Persistence = .forSession
|
||||||
|
) -> URLCredential {
|
||||||
|
URLCredential(
|
||||||
|
identity: secIdentity,
|
||||||
|
certificates: issuerCertificates.isEmpty ? nil : issuerCertificates,
|
||||||
|
persistence: persistence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The leaf `SecCertificate` backing this identity (for display / summary).
|
||||||
|
public func leafCertificate() -> SecCertificate? {
|
||||||
|
var certificate: SecCertificate?
|
||||||
|
let status = SecIdentityCopyCertificate(secIdentity, &certificate)
|
||||||
|
return status == errSecSuccess ? certificate : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable summary of the leaf certificate (subject CN, issuer CN,
|
||||||
|
/// expiry) for the install/rotation UI. `nil` only if the leaf can't be
|
||||||
|
/// read (should never happen for a valid import).
|
||||||
|
public func summary() -> ClientCertificateSummary? {
|
||||||
|
leafCertificate().map(CertificateInspector.summary(of:))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// C-iOS-2 · Reusable `NSObject` URLSession delegate that answers
|
||||||
|
/// **session-level** auth challenges by delegating to `MutualTLSChallengeResponder`.
|
||||||
|
///
|
||||||
|
/// This is the seam for the HTTP transport (`session.data(for:)` surfaces
|
||||||
|
/// challenges through `urlSession(_:didReceive:completionHandler:)`). The WS
|
||||||
|
/// transport can't reuse it — its connection object is already the session's
|
||||||
|
/// delegate and needs the **task-level** callback — so that one implements the
|
||||||
|
/// task-level method itself against the same responder.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
||||||
|
/// arbitrary queues; every stored field is an immutable `let` over thread-safe
|
||||||
|
/// values (`ClientIdentity` is `@unchecked Sendable`, the responder is stateless).
|
||||||
|
public final class ClientTLSSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||||
|
private let identity: ClientIdentity?
|
||||||
|
private let responder: MutualTLSChallengeResponder
|
||||||
|
|
||||||
|
public init(
|
||||||
|
identity: ClientIdentity?,
|
||||||
|
responder: MutualTLSChallengeResponder = MutualTLSChallengeResponder()
|
||||||
|
) {
|
||||||
|
self.identity = identity
|
||||||
|
self.responder = responder
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
|
) {
|
||||||
|
let resolution = responder.resolve(challenge, identity: identity)
|
||||||
|
completionHandler(resolution.disposition, resolution.credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
|
||||||
|
/// **and** its passphrase are stored together (the passphrase is required to
|
||||||
|
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
|
||||||
|
public protocol ClientIdentityStore: Sendable {
|
||||||
|
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
|
||||||
|
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
|
||||||
|
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
|
||||||
|
func save(p12Data: Data, passphrase: String) throws
|
||||||
|
/// Re-import and return the stored identity; `nil` if none is installed.
|
||||||
|
func loadIdentity() throws -> ClientIdentity?
|
||||||
|
/// Display summary of the stored certificate; `nil` if none is installed.
|
||||||
|
func loadSummary() throws -> ClientCertificateSummary?
|
||||||
|
/// Delete the stored identity (rotation / removal). Idempotent.
|
||||||
|
func remove() throws
|
||||||
|
/// Cheap existence check for gating (does NOT re-import).
|
||||||
|
func hasInstalledIdentity() -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
|
||||||
|
/// A Keychain `SecItem*` call failed with this `OSStatus`.
|
||||||
|
case keychain(OSStatus)
|
||||||
|
/// The stored blob was present but could not be decoded.
|
||||||
|
case corruptStoredBlob
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ClientIdentityStore {
|
||||||
|
/// Convenience for composition roots: load the identity, logging and
|
||||||
|
/// swallowing errors into `nil`. A missing cert is the normal pre-install
|
||||||
|
/// state; a genuine fault must not crash launch, but is logged (never
|
||||||
|
/// silently dropped).
|
||||||
|
func loadedIdentityOrNil() -> ClientIdentity? {
|
||||||
|
do {
|
||||||
|
return try loadIdentity()
|
||||||
|
} catch {
|
||||||
|
ClientTLSLog.identity.error(
|
||||||
|
"loadIdentity failed: \(String(describing: error), privacy: .public)"
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored payload — `.p12` bytes plus the passphrase needed to re-import.
|
||||||
|
private struct StoredP12Blob: Codable {
|
||||||
|
let p12: Data
|
||||||
|
let passphrase: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
|
||||||
|
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
|
||||||
|
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
|
||||||
|
/// unlock post-boot, never migrates off this device).
|
||||||
|
public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||||
|
public static let defaultService = "com.yaojia.webterm.clienttls"
|
||||||
|
public static let defaultAccount = "device-identity"
|
||||||
|
|
||||||
|
private let service: String
|
||||||
|
private let account: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
service: String = defaultService, account: String = defaultAccount
|
||||||
|
) {
|
||||||
|
self.service = service
|
||||||
|
self.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(p12Data: Data, passphrase: String) throws {
|
||||||
|
// Validate BEFORE persisting — a wrong passphrase / corrupt file must
|
||||||
|
// surface to the install UI and leave any prior identity untouched.
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||||
|
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
|
||||||
|
try writeItem(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadIdentity() throws -> ClientIdentity? {
|
||||||
|
guard let blob = try readBlob() else { return nil }
|
||||||
|
return try PKCS12Importer.importIdentity(
|
||||||
|
data: blob.p12, passphrase: blob.passphrase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||||
|
try loadIdentity()?.summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove() throws {
|
||||||
|
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw ClientIdentityStoreError.keychain(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hasInstalledIdentity() -> Bool {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = false
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain plumbing
|
||||||
|
|
||||||
|
private func baseQuery() -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeItem(_ data: Data) throws {
|
||||||
|
// Delete-then-add keeps the item's protection class deterministic
|
||||||
|
// (SecItemUpdate can't change kSecAttrAccessible in place).
|
||||||
|
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||||
|
throw ClientIdentityStoreError.keychain(deleteStatus)
|
||||||
|
}
|
||||||
|
var attributes = baseQuery()
|
||||||
|
attributes[kSecValueData as String] = data
|
||||||
|
attributes[kSecAttrAccessible as String] =
|
||||||
|
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||||
|
guard addStatus == errSecSuccess else {
|
||||||
|
throw ClientIdentityStoreError.keychain(addStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readBlob() throws -> StoredP12Blob? {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecItemNotFound { return nil }
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else {
|
||||||
|
throw ClientIdentityStoreError.keychain(status)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw ClientIdentityStoreError.corruptStoredBlob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encode(_ blob: StoredP12Blob) throws -> Data {
|
||||||
|
do {
|
||||||
|
return try JSONEncoder().encode(blob)
|
||||||
|
} catch {
|
||||||
|
throw ClientIdentityStoreError.corruptStoredBlob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory store for previews and unit tests: same import/summary code path as
|
||||||
|
/// the keychain store (so the roundtrip is exercised) without any keychain
|
||||||
|
/// entitlement. `@unchecked Sendable` — mutable blob guarded by a lock.
|
||||||
|
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var blob: StoredP12BlobBox?
|
||||||
|
|
||||||
|
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
|
||||||
|
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
|
||||||
|
private struct StoredP12BlobBox {
|
||||||
|
let p12: Data
|
||||||
|
let passphrase: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func save(p12Data: Data, passphrase: String) throws {
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
|
||||||
|
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadIdentity() throws -> ClientIdentity? {
|
||||||
|
guard let stored = lock.withLock({ blob }) else { return nil }
|
||||||
|
return try PKCS12Importer.importIdentity(
|
||||||
|
data: stored.p12, passphrase: stored.passphrase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadSummary() throws -> ClientCertificateSummary? {
|
||||||
|
try loadIdentity()?.summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove() throws {
|
||||||
|
lock.withLock { blob = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hasInstalledIdentity() -> Bool {
|
||||||
|
lock.withLock { blob != nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ClientTLSLog {
|
||||||
|
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
|
||||||
|
///
|
||||||
|
/// It maps a URLSession auth challenge (+ the optionally-installed device
|
||||||
|
/// identity) to a disposition and credential. Deliberately free of any
|
||||||
|
/// URLSession / socket state so the full truth table is unit-testable without a
|
||||||
|
/// live connection — the transports (WS task delegate, HTTP session delegate)
|
||||||
|
/// are thin adapters that call `resolve` and forward its result to the
|
||||||
|
/// completion handler.
|
||||||
|
///
|
||||||
|
/// Truth table (plan §C-iOS-1):
|
||||||
|
/// a. `ClientCertificate` + identity present → `.useCredential` with the
|
||||||
|
/// identity's `URLCredential`.
|
||||||
|
/// b. `ClientCertificate` + no identity → `.cancelAuthenticationChallenge`
|
||||||
|
/// (a clean, classifiable failure — never a silent no-cert continue).
|
||||||
|
/// c. `ServerTrust` → `.performDefaultHandling`
|
||||||
|
/// (the LE wildcard is validated by the system trust store).
|
||||||
|
/// d. anything else → `.performDefaultHandling`.
|
||||||
|
public struct MutualTLSChallengeResponder: Sendable {
|
||||||
|
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
|
||||||
|
/// which isn't) — it is produced and consumed synchronously on the delegate
|
||||||
|
/// queue, never sent across isolation domains.
|
||||||
|
public struct Resolution {
|
||||||
|
public let disposition: URLSession.AuthChallengeDisposition
|
||||||
|
public let credential: URLCredential?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
disposition: URLSession.AuthChallengeDisposition,
|
||||||
|
credential: URLCredential? = nil
|
||||||
|
) {
|
||||||
|
self.disposition = disposition
|
||||||
|
self.credential = credential
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
|
||||||
|
/// authentication method.
|
||||||
|
public func resolve(
|
||||||
|
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
|
||||||
|
) -> Resolution {
|
||||||
|
resolve(
|
||||||
|
authenticationMethod: challenge.protectionSpace.authenticationMethod,
|
||||||
|
identity: identity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Method-keyed core (challenge-free) — the directly-tested pure function.
|
||||||
|
public func resolve(
|
||||||
|
authenticationMethod: String, identity: ClientIdentity?
|
||||||
|
) -> Resolution {
|
||||||
|
switch authenticationMethod {
|
||||||
|
case NSURLAuthenticationMethodClientCertificate:
|
||||||
|
guard let identity else {
|
||||||
|
// (b) No installed identity: cancel so the failure surfaces as a
|
||||||
|
// classifiable client-cert error rather than a bare TLS reset.
|
||||||
|
return Resolution(disposition: .cancelAuthenticationChallenge)
|
||||||
|
}
|
||||||
|
// (a) Present the device certificate for this session.
|
||||||
|
return Resolution(
|
||||||
|
disposition: .useCredential, credential: identity.urlCredential()
|
||||||
|
)
|
||||||
|
case NSURLAuthenticationMethodServerTrust:
|
||||||
|
// (c) System-trusted LE wildcard — default evaluation.
|
||||||
|
return Resolution(disposition: .performDefaultHandling)
|
||||||
|
default:
|
||||||
|
// (d) Basic/Digest/NTLM/etc. — not used by this app; default.
|
||||||
|
return Resolution(disposition: .performDefaultHandling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Failure modes of a PKCS#12 import, each mapped to actionable install-UX copy
|
||||||
|
/// (C-iOS-3). The three plan-named `OSStatus` mappings are honored:
|
||||||
|
/// `errSecAuthFailed → .wrongPassphrase`, `errSecDecode → .corruptFile`, and any
|
||||||
|
/// other non-success status (unsupported/unknown format — the plan's "errSecPkg"
|
||||||
|
/// bucket) → `.unsupported`.
|
||||||
|
public enum PKCS12ImportError: Error, Equatable, Sendable {
|
||||||
|
/// Wrong passphrase (`errSecAuthFailed`).
|
||||||
|
case wrongPassphrase
|
||||||
|
/// Not a decodable PKCS#12 blob (`errSecDecode`) — truncated / not a `.p12`.
|
||||||
|
case corruptFile
|
||||||
|
/// Decoded, but the format/algorithms aren't importable on this OS
|
||||||
|
/// (any other non-success `OSStatus`). Retains the raw status for logs.
|
||||||
|
case unsupported(OSStatus)
|
||||||
|
/// Import succeeded but carried no `SecIdentity` (e.g. a certs-only `.p12`).
|
||||||
|
case noIdentity
|
||||||
|
|
||||||
|
public static func == (lhs: PKCS12ImportError, rhs: PKCS12ImportError) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.wrongPassphrase, .wrongPassphrase),
|
||||||
|
(.corruptFile, .corruptFile),
|
||||||
|
(.noIdentity, .noIdentity):
|
||||||
|
return true
|
||||||
|
case let (.unsupported(a), .unsupported(b)):
|
||||||
|
return a == b
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports a `.p12` into a `ClientIdentity` via `SecPKCS12Import`.
|
||||||
|
///
|
||||||
|
/// Memory-only: `SecPKCS12Import` returns the identity/chain as CoreFoundation
|
||||||
|
/// handles without writing to the keychain, so callers control persistence
|
||||||
|
/// (see `KeychainClientIdentityStore`).
|
||||||
|
public enum PKCS12Importer {
|
||||||
|
public static func importIdentity(
|
||||||
|
data: Data, passphrase: String
|
||||||
|
) throws -> ClientIdentity {
|
||||||
|
let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
|
||||||
|
var rawItems: CFArray?
|
||||||
|
let status = SecPKCS12Import(data as CFData, options, &rawItems)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
break
|
||||||
|
case errSecAuthFailed:
|
||||||
|
throw PKCS12ImportError.wrongPassphrase
|
||||||
|
case errSecDecode:
|
||||||
|
throw PKCS12ImportError.corruptFile
|
||||||
|
default:
|
||||||
|
// Covers unsupported formats/algorithms and any other failure —
|
||||||
|
// the plan's `errSecPkg → .unsupported` mapping (that symbol does
|
||||||
|
// not exist in the SDK; the status is preserved for diagnostics).
|
||||||
|
throw PKCS12ImportError.unsupported(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let items = rawItems as? [[String: Any]], let first = items.first,
|
||||||
|
let identityValue = first[kSecImportItemIdentity as String]
|
||||||
|
else {
|
||||||
|
throw PKCS12ImportError.noIdentity
|
||||||
|
}
|
||||||
|
// Force-cast is safe: `kSecImportItemIdentity` is always a SecIdentity.
|
||||||
|
let secIdentity = identityValue as! SecIdentity
|
||||||
|
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate]) ?? []
|
||||||
|
return ClientIdentity(
|
||||||
|
secIdentity: secIdentity,
|
||||||
|
issuerCertificates: issuerChain(from: chain, identity: secIdentity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chain from `SecPKCS12Import` includes the leaf at index 0; the
|
||||||
|
/// `URLCredential` must carry only the *issuer* certs (Apple: do not repeat
|
||||||
|
/// the identity's own cert). Drops whichever chain entry DER-matches the
|
||||||
|
/// identity's leaf.
|
||||||
|
private static func issuerChain(
|
||||||
|
from chain: [SecCertificate], identity: SecIdentity
|
||||||
|
) -> [SecCertificate] {
|
||||||
|
var leaf: SecCertificate?
|
||||||
|
SecIdentityCopyCertificate(identity, &leaf)
|
||||||
|
guard let leafData = leaf.map({ SecCertificateCopyData($0) as Data }) else {
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
return chain.filter { SecCertificateCopyData($0) as Data != leafData }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// C-iOS-1 · Store roundtrip via the InMemory store (same import/summary code
|
||||||
|
// path as the keychain store, minus the entitlement — the keychain variant is
|
||||||
|
// covered by on-device/simulator tests, mirroring KeychainHostStoreLiveTests).
|
||||||
|
|
||||||
|
@Test("save → loadIdentity roundtrips and hasInstalledIdentity flips")
|
||||||
|
func storeRoundtrip() throws {
|
||||||
|
// Arrange
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
#expect(try store.loadIdentity() == nil)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(store.hasInstalledIdentity() == true)
|
||||||
|
let identity = try #require(try store.loadIdentity())
|
||||||
|
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||||
|
let summary = try #require(try store.loadSummary())
|
||||||
|
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("save validates the passphrase and leaves prior state untouched on failure")
|
||||||
|
func storeSaveValidatesBeforePersisting() {
|
||||||
|
// Arrange
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
|
||||||
|
// Act / Assert — a wrong passphrase must surface, not persist.
|
||||||
|
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||||
|
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
|
||||||
|
}
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("remove clears the stored identity")
|
||||||
|
func storeRemove() throws {
|
||||||
|
// Arrange
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
#expect(store.hasInstalledIdentity() == true)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.remove()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
#expect(try store.loadIdentity() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("loadedIdentityOrNil returns nil (not a throw) when nothing is installed")
|
||||||
|
func loadedIdentityOrNilEmpty() {
|
||||||
|
let store = InMemoryClientIdentityStore()
|
||||||
|
#expect(store.loadedIdentityOrNil() == nil)
|
||||||
|
}
|
||||||
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal file
33
ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Embedded test fixture: a real PKCS#12 generated at authoring time with
|
||||||
|
/// OpenSSL (EC P-256 self-signed device-CA → EC P-256 leaf, `EKU=clientAuth`,
|
||||||
|
/// exported `-legacy` for iOS/Android import parity — mirrors
|
||||||
|
/// `deploy/scripts/gen-device-ca.sh` / `issue-device-cert.sh`).
|
||||||
|
///
|
||||||
|
/// Embedded as base64 rather than an SPM resource so the MUST-PASS package test
|
||||||
|
/// runs headless with no `Bundle.module` resource wiring. Regenerate with:
|
||||||
|
/// openssl ecparam -name prime256v1 -genkey -noout -out ca.key.pem
|
||||||
|
/// openssl req -x509 -new -key ca.key.pem -sha256 -days 3650 \
|
||||||
|
/// -subj "/CN=webterm-device-ca-fixture" \
|
||||||
|
/// -addext "basicConstraints=critical,CA:TRUE" \
|
||||||
|
/// -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.cert.pem
|
||||||
|
/// openssl ecparam -name prime256v1 -genkey -noout -out leaf.key.pem
|
||||||
|
/// openssl req -new -key leaf.key.pem -subj "/CN=device-fixture" -out leaf.csr.pem
|
||||||
|
/// openssl x509 -req -in leaf.csr.pem -CA ca.cert.pem -CAkey ca.key.pem \
|
||||||
|
/// -CAcreateserial -days 825 -sha256 -extfile leaf.ext -out leaf.cert.pem
|
||||||
|
/// openssl pkcs12 -export -legacy -inkey leaf.key.pem -in leaf.cert.pem \
|
||||||
|
/// -certfile ca.cert.pem -passout pass:clienttls-test-pass \
|
||||||
|
/// -name device-fixture -out device.p12 ; base64 device.p12
|
||||||
|
enum ClientTLSFixtures {
|
||||||
|
static let passphrase = "clienttls-test-pass"
|
||||||
|
static let leafCommonName = "device-fixture"
|
||||||
|
static let issuerCommonName = "webterm-device-ca-fixture"
|
||||||
|
|
||||||
|
static var deviceP12Data: Data {
|
||||||
|
Data(base64Encoded: deviceP12Base64)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next line_length
|
||||||
|
static let deviceP12Base64 = "MIIF8wIBAzCCBbkGCSqGSIb3DQEHAaCCBaoEggWmMIIFojCCBGcGCSqGSIb3DQEHBqCCBFgwggRUAgEAMIIETQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQIKBUqQZQxk0wCAggAgIIEIBIkpttDOWO6edQjmr9V3ASfsuc0m+Wdl8//Yrt5FPgz5HOhYZKhOUXhBLWyGXPnTx8fBrC1Yk4YmXwQisHT/4PsfvwIAgF9PBFo3UNwsQLATWHrplUF1rTO9uB5Luju306Ox9QYB+VP64BOFxdixtvhjZF8LemfJ6bV/9DWmXOk5UoGdd0c5/6WeYUTfr1aFG/TJo+GOHa5CD5fW5Mr4SejYKNcvvT2Fki5kCKAQRbhJb8W7+RzcmYoF6ECzCNeSnDNenpZm2xowpdGP/6d3U7MbrXj4bmAtQYljAnA391atw/bm2T9d8Q6CPx5QD+WqMNdKN6tYV9OvDB6XB+VPimVMd0GCNYaIKazHj0ohNYqvEYDjSaYgbBvLHJyJRqnumyjwMz4o26NoTGm1m/U28pUGUJL3njYhuyxKilat85oXT3YO4x16bCeF7fmuGVkakeZjrxvxAKap8rD5yPaDd533Va9+xg1byIj7h01Q5tiOXO3sqkEYB1XI9/n78Et5eZItMYEH53v4uE9NRWZmetA7TQ5uRon4EK1ajKIJaQi/6lGdCAzLf5tysZ7A8vfJPJeBRFI2a9QYQzcyL8sZWEfAhJszRo59g6LWZn87DXL6EFEP/UvV0tDy6kPz/OzUmDGMrWNbeCX/5zI/xaFaf+2OpiXkJYwMUybi9JkTmCfNeOLdpPjHI0lJ47iG2cmiiMNYWJ5hXKqmdxigQt5W/5WShjD4iYh6PcjzdD6IBD5UT8d3fTKWa4cvJs2bnILjElXOWo+s8o5NGAL359+QbCQfiWYha1P4SdSNhX98SXy0K6Dla0+Ny+miEVIf5N0jXvNlsJnnjjGgK+9gPGlU9mEbpF+4EqV2XgGp+Tg8ZGO489kK2S37gJTKMUhU+haMhAF0eoj5FSMN30LcXnHTjMjsps4hpeGeUu8gb0dm96+vvmAAibt9wnCULgMFKIEpP10IY5l4D8puLTi+smvf0MMvvrGRC3+aAq+jfemQEmJZgfhlhtr1O5DdUoTvcWazmNdNScwoJ68M/D/45pplBUglECub/Ib34HKNGmEMX5+tbDEHxe589A2Jwxvq1meycCnj3IoL/lXSprB7jWt3ZInURTJItaS5GSw9D4vLboe4OISAHZftdgGJea2rXJYPZk6N5L0EbLfftEh+zzOTb3Zr3MsIiCdxpWLshaDfiijRgVYsAd0rsx0LG+8+Icb33KvN3MK/ol8PjavM+T7F2qUIiKyaA5eZ9B5CAkm7YGvrE1TBYHeEpsgvaGnRyl3PcPYSCoOQ5ckZ67briJlf6OpZM+5P6u/MP/BWhQa8Ksox2SO4aZEtDzceWAHI4ccJSbD8h/jpoLZbSm2Z+CqNUmqr1atZnZgpgArKdcnxErljkCOfkp+k6nRkhg5RkbOCjCCATMGCSqGSIb3DQEHAaCCASQEggEgMIIBHDCCARgGCyqGSIb3DQEMCgECoIG0MIGxMBwGCiqGSIb3DQEMAQMwDgQIybmfLdxQ2ZUCAggABIGQQm1iiwDNnPOMO1rBboRmVPjVzV8OLeEVLNz2qhOhSR7nEBMSw289dZzdgbY/PZh2GrO0duJSHIzjBj7W9ShHVB6zdl0kZx1JRh5aJie3eEZ76l9zVOZadp2T0wCd7HD7c4rOYJFjuTwPVB4/GWzlA3r2HVGiyuQR8N8Z855M97/KrUCByR6HG1Nl8e1+EYyrMVIwIwYJKoZIhvcNAQkVMRYEFBZc8x6cgn9EB/NImL993PHArfe6MCsGCSqGSIb3DQEJFDEeHhwAZABlAHYAaQBjAGUALQBmAGkAeAB0AHUAcgBlMDEwITAJBgUrDgMCGgUABBQGw7bWopAP93FLjXc4LIiReHWd5AQISUwQzLqUrPECAggA"
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// C-iOS-1 · The responder truth table: 3 challenge types × identity present /
|
||||||
|
// absent. Exercised through the pure method-keyed core AND through a real
|
||||||
|
// `URLAuthenticationChallenge` (proving the method is extracted correctly),
|
||||||
|
// with no live socket.
|
||||||
|
|
||||||
|
private let responder = MutualTLSChallengeResponder()
|
||||||
|
|
||||||
|
private func makeIdentity() throws -> ClientIdentity {
|
||||||
|
try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ClientCertificate
|
||||||
|
|
||||||
|
@Test("ClientCertificate + identity → .useCredential with a credential")
|
||||||
|
func clientCertWithIdentityUsesCredential() throws {
|
||||||
|
// Arrange
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: identity
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(resolution.disposition == .useCredential)
|
||||||
|
#expect(resolution.credential != nil)
|
||||||
|
#expect(resolution.credential?.identity != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ClientCertificate + no identity → .cancelAuthenticationChallenge, no credential")
|
||||||
|
func clientCertWithoutIdentityCancels() {
|
||||||
|
// Act
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(resolution.disposition == .cancelAuthenticationChallenge)
|
||||||
|
#expect(resolution.credential == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ServerTrust
|
||||||
|
|
||||||
|
@Test("ServerTrust → .performDefaultHandling regardless of identity")
|
||||||
|
func serverTrustDefaultHandling() throws {
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
for id in [identity, nil] as [ClientIdentity?] {
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodServerTrust, identity: id
|
||||||
|
)
|
||||||
|
#expect(resolution.disposition == .performDefaultHandling)
|
||||||
|
#expect(resolution.credential == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Other method (e.g. Basic)
|
||||||
|
|
||||||
|
@Test("an unrelated method → .performDefaultHandling regardless of identity")
|
||||||
|
func otherMethodDefaultHandling() throws {
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
for id in [identity, nil] as [ClientIdentity?] {
|
||||||
|
let resolution = responder.resolve(
|
||||||
|
authenticationMethod: NSURLAuthenticationMethodHTTPBasic, identity: id
|
||||||
|
)
|
||||||
|
#expect(resolution.disposition == .performDefaultHandling)
|
||||||
|
#expect(resolution.credential == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full URLAuthenticationChallenge extraction
|
||||||
|
|
||||||
|
@Test("resolve(challenge:) extracts the method from the protection space")
|
||||||
|
func resolveFromRealChallenge() throws {
|
||||||
|
// Arrange — a real challenge for the ClientCertificate method.
|
||||||
|
let identity = try makeIdentity()
|
||||||
|
let challenge = makeChallenge(method: NSURLAuthenticationMethodClientCertificate)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let withIdentity = responder.resolve(challenge, identity: identity)
|
||||||
|
let withoutIdentity = responder.resolve(challenge, identity: nil)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(withIdentity.disposition == .useCredential)
|
||||||
|
#expect(withIdentity.credential != nil)
|
||||||
|
#expect(withoutIdentity.disposition == .cancelAuthenticationChallenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Minimal sender so `URLAuthenticationChallenge` can be constructed in-process
|
||||||
|
/// (its designated init requires a non-optional sender). The responder never
|
||||||
|
/// calls back into the sender — it only reads `protectionSpace`.
|
||||||
|
private final class NoopChallengeSender: NSObject, URLAuthenticationChallengeSender {
|
||||||
|
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||||
|
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||||
|
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
|
||||||
|
let space = URLProtectionSpace(
|
||||||
|
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
|
||||||
|
realm: nil, authenticationMethod: method
|
||||||
|
)
|
||||||
|
return URLAuthenticationChallenge(
|
||||||
|
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
|
||||||
|
failureResponse: nil, error: nil, sender: NoopChallengeSender()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// C-iOS-1 · PKCS#12 import against a real OpenSSL-generated fixture `.p12`,
|
||||||
|
// including the wrong-passphrase and corrupt-file error mappings.
|
||||||
|
|
||||||
|
@Test("import with the correct passphrase yields an identity whose leaf CN matches")
|
||||||
|
func importSucceedsAndExposesLeaf() throws {
|
||||||
|
// Arrange
|
||||||
|
let data = ClientTLSFixtures.deviceP12Data
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let identity = try PKCS12Importer.importIdentity(
|
||||||
|
data: data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
let summary = try #require(identity.summary())
|
||||||
|
#expect(summary.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||||
|
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||||
|
// 825-day leaf minted at authoring time → not yet expired.
|
||||||
|
#expect(summary.isExpired() == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("import drops the leaf from the issuer chain (credential must not repeat it)")
|
||||||
|
func importIssuerChainExcludesLeaf() throws {
|
||||||
|
// Arrange / Act
|
||||||
|
let identity = try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — fixture chain is [leaf, CA]; issuerCertificates keeps only the CA.
|
||||||
|
let leaf = try #require(identity.leafCertificate())
|
||||||
|
let leafData = SecCertificateCopyData(leaf) as Data
|
||||||
|
#expect(identity.issuerCertificates.count == 1)
|
||||||
|
for issuer in identity.issuerCertificates {
|
||||||
|
#expect((SecCertificateCopyData(issuer) as Data) != leafData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("wrong passphrase maps errSecAuthFailed → .wrongPassphrase")
|
||||||
|
func importWrongPassphrase() {
|
||||||
|
// Arrange / Act / Assert
|
||||||
|
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||||
|
_ = try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: "not-the-passphrase"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("garbage bytes map errSecDecode → .corruptFile")
|
||||||
|
func importCorruptFile() {
|
||||||
|
// Arrange
|
||||||
|
let garbage = Data([0x00, 0x01, 0x02, 0x03, 0x99, 0xAB, 0xCD, 0xEF])
|
||||||
|
|
||||||
|
// Act / Assert
|
||||||
|
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: garbage, passphrase: "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty data is treated as a corrupt file, not a crash")
|
||||||
|
func importEmptyData() {
|
||||||
|
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||||
|
_ = try PKCS12Importer.importIdentity(data: Data(), passphrase: "x")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 6.0
|
||||||
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
||||||
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks.
|
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 tasks.
|
||||||
// Dependency direction is strictly downward: SessionCore → WireProtocol only.
|
// Dependency direction is strictly downward: SessionCore → WireProtocol, plus
|
||||||
|
// ClientTLS (C-iOS-2) for the device client-cert mTLS responder the WS transport
|
||||||
|
// answers connection-level challenges with. Both are downward leaves.
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
@@ -12,12 +14,16 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../WireProtocol"),
|
.package(path: "../WireProtocol"),
|
||||||
|
.package(path: "../ClientTLS"),
|
||||||
.package(path: "../TestSupport"),
|
.package(path: "../TestSupport"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "SessionCore",
|
name: "SessionCore",
|
||||||
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
|
dependencies: [
|
||||||
|
.product(name: "WireProtocol", package: "WireProtocol"),
|
||||||
|
.product(name: "ClientTLS", package: "ClientTLS"),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SessionCoreTests",
|
name: "SessionCoreTests",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClientTLS
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import WireProtocol
|
import WireProtocol
|
||||||
@@ -76,16 +77,45 @@ protocol ConnectionPinger: Sendable {
|
|||||||
public struct URLSessionTermTransport: TermTransport {
|
public struct URLSessionTermTransport: TermTransport {
|
||||||
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
||||||
private let maxMessageBytes: Int
|
private let maxMessageBytes: Int
|
||||||
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the optional device client
|
||||||
|
/// identity LAZILY, once per `connect`, so a certificate imported mid-run
|
||||||
|
/// takes effect on the NEXT connection without an app relaunch (a snapshot
|
||||||
|
/// captured at composition would stay stale). When the resolved identity is
|
||||||
|
/// present, the connection answers a `ClientCertificate` challenge (mTLS to a
|
||||||
|
/// tunneled host) with its credential; when `nil`, the challenge is cancelled
|
||||||
|
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
|
||||||
|
/// result is inert there.
|
||||||
|
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||||
|
|
||||||
public init() {
|
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||||
self.init(maxMessageBytes: Tunables.maxWSMessageBytes)
|
/// provider, so behaviour is identical to capturing the identity directly.
|
||||||
|
public init(identity: ClientIdentity? = nil) {
|
||||||
|
self.init(identityProvider: { identity })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
|
||||||
|
/// provider is re-consulted on every `connect`, so a nil→installed
|
||||||
|
/// transition is picked up without relaunch.
|
||||||
|
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||||
|
self.init(
|
||||||
|
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal TEST seam: a shrunken cap makes the oversize→EMSGSIZE path
|
/// Internal TEST seam: a shrunken cap makes the oversize→EMSGSIZE path
|
||||||
/// deterministic without 16 MiB fixtures. Production code paths always go
|
/// deterministic without 16 MiB fixtures. Production code paths always go
|
||||||
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`.
|
/// through `init()` / `init(identityProvider:)` and the frozen
|
||||||
init(maxMessageBytes: Int) {
|
/// `Tunables.maxWSMessageBytes`.
|
||||||
|
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
|
||||||
|
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
maxMessageBytes: Int,
|
||||||
|
identityProvider: @escaping @Sendable () -> ClientIdentity?
|
||||||
|
) {
|
||||||
self.maxMessageBytes = maxMessageBytes
|
self.maxMessageBytes = maxMessageBytes
|
||||||
|
self.identityProvider = identityProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||||
@@ -93,9 +123,13 @@ public struct URLSessionTermTransport: TermTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Internal: concrete-typed connect (tests assert task configuration;
|
/// Internal: concrete-typed connect (tests assert task configuration;
|
||||||
/// `connectPingable` builds on it).
|
/// `connectPingable` builds on it). The identity is resolved HERE, per
|
||||||
|
/// connect, from `identityProvider` (no-relaunch pickup).
|
||||||
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
||||||
try await WSConnection.open(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
try await WSConnection.open(
|
||||||
|
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
|
||||||
|
identity: identityProvider()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +169,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
|||||||
private let frames: AsyncThrowingStream<String, any Error>
|
private let frames: AsyncThrowingStream<String, any Error>
|
||||||
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
|
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
|
||||||
|
|
||||||
|
/// C-iOS-2 mTLS. Set once in `configure` BEFORE `task.resume()`, so the
|
||||||
|
/// connection-level challenge (which can only arrive after resume) always
|
||||||
|
/// reads a stable value without locking — same set-once discipline as
|
||||||
|
/// `task`/`session`.
|
||||||
|
private var identity: ClientIdentity?
|
||||||
|
private let challengeResponder = MutualTLSChallengeResponder()
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
|
||||||
super.init()
|
super.init()
|
||||||
@@ -143,9 +184,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
|||||||
/// Connect: build session+task, resume, await the delegate-driven
|
/// Connect: build session+task, resume, await the delegate-driven
|
||||||
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
||||||
/// then start the re-arming receive loop.
|
/// then start the re-arming receive loop.
|
||||||
static func open(endpoint: HostEndpoint, maxMessageBytes: Int) async throws -> WSConnection {
|
static func open(
|
||||||
|
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
|
||||||
|
) async throws -> WSConnection {
|
||||||
let connection = WSConnection()
|
let connection = WSConnection()
|
||||||
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
|
connection.configure(
|
||||||
|
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
|
||||||
|
)
|
||||||
try await connection.performHandshake()
|
try await connection.performHandshake()
|
||||||
connection.startReceiveLoop()
|
connection.startReceiveLoop()
|
||||||
return connection
|
return connection
|
||||||
@@ -163,7 +208,12 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: Setup
|
// MARK: Setup
|
||||||
|
|
||||||
private func configure(endpoint: HostEndpoint, maxMessageBytes: Int) {
|
private func configure(
|
||||||
|
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
|
||||||
|
) {
|
||||||
|
// Set BEFORE building the task/resume: the mTLS challenge can only fire
|
||||||
|
// after `task.resume()`, so this write happens-before any read of it.
|
||||||
|
self.identity = identity
|
||||||
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
||||||
var request = URLRequest(url: endpoint.wsURL)
|
var request = URLRequest(url: endpoint.wsURL)
|
||||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||||
@@ -311,6 +361,22 @@ extension WSConnection: URLSessionWebSocketDelegate {
|
|||||||
takeHandshakeContinuation()?.resume()
|
takeHandshakeContinuation()?.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// C-iOS-2 · Connection-level auth challenge (server trust + client cert)
|
||||||
|
/// arrives task-level for a WS task. Delegate the decision to the pure
|
||||||
|
/// `MutualTLSChallengeResponder`: present the device identity for an mTLS
|
||||||
|
/// tunnel host, default-handle the LE server trust, cancel cleanly if a
|
||||||
|
/// client cert is demanded but none is installed. `identity` is set-once
|
||||||
|
/// before `resume()` (see `configure`), so this read needs no lock.
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
|
) {
|
||||||
|
let resolution = challengeResponder.resolve(challenge, identity: identity)
|
||||||
|
completionHandler(resolution.disposition, resolution.credential)
|
||||||
|
}
|
||||||
|
|
||||||
/// Server close frame processed → CLEAN close: the stream FINISHES
|
/// Server close frame processed → CLEAN close: the stream FINISHES
|
||||||
/// (distinguishable from the transport-error THROW path).
|
/// (distinguishable from the transport-error THROW path).
|
||||||
func urlSession(
|
func urlSession(
|
||||||
|
|||||||
@@ -243,6 +243,29 @@ struct URLSessionTermTransportTests {
|
|||||||
await connection.close()
|
await connection.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("identity provider is resolved PER CONNECT (no-relaunch pickup, not captured once)")
|
||||||
|
func identityProviderResolvedPerConnect() async throws {
|
||||||
|
// A cert imported mid-run must take effect on the NEXT connection. The
|
||||||
|
// transport therefore re-consults its provider on every `connect` rather
|
||||||
|
// than capturing a snapshot — proven here by a provider whose invocation
|
||||||
|
// count grows once per connect (nil identity: ScriptedWSServer is plain
|
||||||
|
// ws, so the connection succeeds regardless).
|
||||||
|
let (server, endpoint) = try await Self.startServer()
|
||||||
|
defer { server.stop() }
|
||||||
|
let calls = CallCounter()
|
||||||
|
let transport = URLSessionTermTransport(identityProvider: {
|
||||||
|
calls.increment()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
let first = try await transport.connect(to: endpoint)
|
||||||
|
await first.close()
|
||||||
|
let second = try await transport.connect(to: endpoint)
|
||||||
|
await second.close()
|
||||||
|
|
||||||
|
#expect(calls.value == 2)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("connect to a dead port throws (handshake failure path, no hang)")
|
@Test("connect to a dead port throws (handshake failure path, no hang)")
|
||||||
func connectToDeadPortThrows() async throws {
|
func connectToDeadPortThrows() async throws {
|
||||||
let server = ScriptedWSServer()
|
let server = ScriptedWSServer()
|
||||||
@@ -254,5 +277,14 @@ struct URLSessionTermTransportTests {
|
|||||||
_ = try await URLSessionTermTransport().connect(to: endpoint)
|
_ = try await URLSessionTermTransport().connect(to: endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thread-safe invocation counter for the `@Sendable` identity provider
|
||||||
|
/// (URLSession may consult it off the test's isolation domain).
|
||||||
|
private final class CallCounter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var count = 0
|
||||||
|
func increment() { lock.withLock { count += 1 } }
|
||||||
|
var value: Int { lock.withLock { count } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ packages:
|
|||||||
path: Packages/HostRegistry
|
path: Packages/HostRegistry
|
||||||
APIClient:
|
APIClient:
|
||||||
path: Packages/APIClient
|
path: Packages/APIClient
|
||||||
|
ClientTLS:
|
||||||
|
path: Packages/ClientTLS # C-iOS · device client-cert (mTLS) leaf package
|
||||||
TestSupport:
|
TestSupport:
|
||||||
path: Packages/TestSupport # test doubles — WebTermTests only, never the app target
|
path: Packages/TestSupport # test doubles — WebTermTests only, never the app target
|
||||||
# SwiftTerm is the ONLY third-party dependency, attached to the App target
|
# SwiftTerm is the ONLY third-party dependency, attached to the App target
|
||||||
@@ -50,6 +52,7 @@ targets:
|
|||||||
- package: SessionCore
|
- package: SessionCore
|
||||||
- package: HostRegistry
|
- package: HostRegistry
|
||||||
- package: APIClient
|
- package: APIClient
|
||||||
|
- package: ClientTLS
|
||||||
- package: SwiftTerm
|
- package: SwiftTerm
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
@@ -119,6 +122,7 @@ targets:
|
|||||||
- package: SessionCore
|
- package: SessionCore
|
||||||
- package: HostRegistry
|
- package: HostRegistry
|
||||||
- package: APIClient
|
- package: APIClient
|
||||||
|
- package: ClientTLS
|
||||||
- package: TestSupport
|
- package: TestSupport
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
|||||||
Reference in New Issue
Block a user