Files
terminalX/apps/TerminalX/iOS/ContentView.swift
kid 1a257fae7f feat: M4a host key 校验(TOFU pin,关闭 MITM 敞口)
- TXCore/SSH: SSHWire(string/mpint 编码)、ECDSAConv(DER→SSH 签名/P256 blob)、
  HostKey(opensshFingerprint/evaluate/HostTriple/KnownHostsStore),+10 单测(含真实指纹向量、高位 DER)
- TXTransport: SSHConfig.hostKeyVerifier 闭包 + SSHError.hostKeyMismatch;
  SSHSession 握手后 libssh2_session_hostkey 取 blob 交 verifier,拒绝则断开抛错
- app: 文件后端 KnownHostsStore(未签名 app 无 keychain 权限 SecItem -34018,host key 是
  公钥非机密,沙盒文件对 TOFU 足够)+ TOFU verifier + firstUse 横幅 + mismatch alert(信任/取消)
- 验证(192.168.9.199):首次信任 pin+接受连上;假 pin→不符→断开+告警,本次指纹与 ssh-keygen 逐字符一致

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-24 18:06:39 +08:00

255 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import GhosttyTerminal
import TXCore
struct ContentView: View {
@StateObject private var model = SSHTerminalModel()
@Environment(\.scenePhase) private var scenePhase
var body: some View {
// M1 -txTsnetKey -txHost tsnet +
// -txHostmodel tsnet fd
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
TsnetProbeView(authKey: tsnetKey)
} else {
Group {
if model.showsTerminal {
TerminalScreen(model: model)
} else {
ConnectionForm(model: model)
}
}
.onAppear { model.autoConnectIfConfigured() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background: model.enterBackground()
case .active: model.enterForeground()
default: break
}
}
.alert("⚠️ Host Key 已变化", isPresented: Binding(
get: { model.hostKeyMismatch != nil },
set: { if !$0 { model.dismissHostKeyMismatch() } }
), presenting: model.hostKeyMismatch) { _ in
Button("信任新密钥", role: .destructive) { model.trustNewHostKey() }
Button("取消", role: .cancel) { model.dismissHostKeyMismatch() }
} message: { info in
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
}
}
}
}
/// M0 SSH tsnet
struct ConnectionForm: View {
@ObservedObject var model: SSHTerminalModel
@State private var host = ""
@State private var port = "22"
@State private var username = ""
@State private var password = ""
var body: some View {
NavigationStack {
Form {
Section("SSH 直连 (M0)") {
TextField("主机 / IP", text: $host)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField("端口", text: $port)
.keyboardType(.numberPad)
TextField("用户名", text: $username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("密码", text: $password)
}
Section {
Button {
model.connect(host: host, port: Int(port) ?? 22,
username: username, password: password)
} label: {
Text("连接").frame(maxWidth: .infinity)
}
.disabled(host.isEmpty || username.isEmpty)
}
Section("状态") {
Text(model.phaseText)
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary)
if !model.banner.isEmpty {
Text(model.banner).font(.caption).foregroundStyle(.secondary)
}
}
}
.navigationTitle("terminalX")
}
}
}
struct TerminalScreen: View {
@ObservedObject var model: SSHTerminalModel
var body: some View {
if let tmux = model.tmuxController {
TmuxTabbedView(controller: tmux) // tmux -CC tab
} else {
RawTerminalView(model: model) //
}
}
}
/// tmux
struct RawTerminalView: View {
@ObservedObject var model: SSHTerminalModel
@FocusState private var focused: Bool
var body: some View {
TerminalSurfaceView(context: model.state)
.terminalFocusOnAppear($focused)
.overlay(alignment: .top) {
if model.phaseText != "已连接", !model.banner.isEmpty {
Text(model.banner)
.font(.caption)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(.thinMaterial, in: Capsule())
.padding(.top, 6)
}
}
.task {
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
model.markSurfaceReady()
}
}
}
/// tmux control-modewindow tab + window
struct TmuxTabbedView: View {
@ObservedObject var controller: TmuxController
var body: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(controller.windows) { win in
TmuxTabChip(window: win,
isActive: win.id == controller.activeWindowID) {
controller.selectWindow(win.id)
}
}
Button { controller.newWindow() } label: {
Image(systemName: "plus").padding(.horizontal, 6)
}
}
.padding(.horizontal, 8).padding(.vertical, 6)
}
.background(.thinMaterial)
Divider()
if let win = controller.activeWindow {
TmuxWindowView(window: win, controller: controller)
.id(win.id.raw)
} else {
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
}
}
}
}
struct TmuxTabChip: View {
@ObservedObject var window: TmuxWindow
let isActive: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
Text(window.title)
.font(.caption).lineLimit(1)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
/// window tmux pane pane surface
struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow
let controller: TmuxController
@Environment(\.displayScale) private var displayScale
var body: some View {
GeometryReader { geo in
Group {
if let layout = window.visibleLayout {
let root = layout.root.rect
let cw = geo.size.width / CGFloat(max(1, root.width))
let ch = geo.size.height / CGFloat(max(1, root.height))
ZStack(alignment: .topLeading) {
ForEach(leaves(layout.root), id: \.pane.raw) { item in
if let surface = window.panes[item.pane] {
TmuxPaneView(
surface: surface,
isActive: window.activePane == item.pane,
onTap: { controller.selectPane(item.pane, in: window.id) },
onReady: { controller.markPaneReady(item.pane) }
)
.frame(width: CGFloat(item.rect.width) * cw,
height: CGFloat(item.rect.height) * ch)
.position(x: (CGFloat(item.rect.x) + CGFloat(item.rect.width) / 2) * cw,
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
}
}
}
} else {
Color.clear
}
}
// iPadOS refresh-client -Ccontroller debounce
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
.onChange(of: geo.size) { _, s in
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
}
}
}
/// pane + rectidentity paneID surface view
private func leaves(_ node: TmuxLayout.Node) -> [(pane: TmuxPaneID, rect: TmuxLayout.Rect)] {
switch node {
case .leaf(let p, let r): return [(p, r)]
case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves)
}
}
}
/// pane + +
struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface
let isActive: Bool
let onTap: () -> Void
let onReady: () -> Void
@FocusState private var focused: Bool
var body: some View {
TerminalSurfaceView(context: surface.state)
.terminalFocusOnAppear($focused)
.overlay(
Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35),
lineWidth: isActive ? 2 : 0.5)
)
// pane tap-catcherTerminalSurfaceView pane
.overlay {
if !isActive {
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
}
}
.onChange(of: isActive) { _, active in focused = active }
.task {
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
}
onReady()
}
}
}