From 1a257fae7f70aab55b04cfbefb509c7ac665f049 Mon Sep 17 00:00:00 2001 From: kid Date: Fri, 24 Jul 2026 18:06:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M4a=20host=20key=20=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=88TOFU=20pin=EF=BC=8C=E5=85=B3=E9=97=AD=20MITM=20?= =?UTF-8?q?=E6=95=9E=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/TerminalX/iOS/ContentView.swift | 9 +++ apps/TerminalX/iOS/KeychainKnownHosts.swift | 54 ++++++++++++++++ apps/TerminalX/iOS/SSHTerminalModel.swift | 64 ++++++++++++++++++- docs/HANDOFF.md | 2 +- .../TXCore/Sources/TXCore/SSH/HostKey.swift | 52 +++++++++++++++ .../TXCore/Sources/TXCore/SSH/SSHWire.swift | 55 ++++++++++++++++ .../Tests/TXCoreTests/SSHWireTests.swift | 60 +++++++++++++++++ .../Sources/TXTransport/SSHSession.swift | 20 +++++- .../Sources/TXTransport/Transport.swift | 10 ++- 9 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 apps/TerminalX/iOS/KeychainKnownHosts.swift create mode 100644 packages/TXCore/Sources/TXCore/SSH/HostKey.swift create mode 100644 packages/TXCore/Sources/TXCore/SSH/SSHWire.swift create mode 100644 packages/TXCore/Tests/TXCoreTests/SSHWireTests.swift diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index c9916db..22ea4a6 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -28,6 +28,15 @@ struct ContentView: View { 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)") + } } } } diff --git a/apps/TerminalX/iOS/KeychainKnownHosts.swift b/apps/TerminalX/iOS/KeychainKnownHosts.swift new file mode 100644 index 0000000..baf9c92 --- /dev/null +++ b/apps/TerminalX/iOS/KeychainKnownHosts.swift @@ -0,0 +1,54 @@ +import Foundation +import TXCore + +/// known_hosts pin 存储(文件后端)。 +/// +/// 为何用文件而非 Keychain:本 app 当前未签名(CODE_SIGNING_ALLOWED=NO),无 keychain-access-group +/// 权限 → SecItem 全返 errSecMissingEntitlement(-34018)。而 host key pin 是**公钥完整性数据、非机密**, +/// 存于 app 沙盒文件对 TOFU 足够(威胁模型是 MITM 换 key,沙盒文件完整性对非越狱设备与 Keychain 相当)。 +/// 生产签名构建可换 Keychain 加固(另留 KnownHostsStore 协议便于替换)。 +/// +/// 格式:JSON `{ "egress|host|port": { "blob": "", "type": } }`, +/// 落 Application Support/known-hosts.json。 +final class KeychainKnownHostsStore: KnownHostsStore, @unchecked Sendable { + private let lock = NSLock() + private let url: URL + private var cache: [String: [String: Any]] + + init() { + let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0] + try? FileManager.default.createDirectory(atPath: base, withIntermediateDirectories: true) + url = URL(fileURLWithPath: base).appendingPathComponent("known-hosts.json") + if let data = try? Data(contentsOf: url), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] { + cache = obj + } else { + cache = [:] + } + } + + func lookup(_ triple: HostTriple) -> HostKeyRecord? { + lock.lock(); defer { lock.unlock() } + guard let entry = cache[triple.accountKey], + let b64 = entry["blob"] as? String, let blob = Data(base64Encoded: b64), + let type = entry["type"] as? Int else { return nil } + return HostKeyRecord(blob: blob, keyType: Int32(type)) + } + + func pin(_ triple: HostTriple, record: HostKeyRecord) { + lock.lock(); defer { lock.unlock() } + cache[triple.accountKey] = ["blob": record.blob.base64EncodedString(), "type": Int(record.keyType)] + persist() + } + + func remove(_ triple: HostTriple) { + lock.lock(); defer { lock.unlock() } + cache[triple.accountKey] = nil + persist() + } + + private func persist() { + guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return } + try? data.write(to: url, options: .atomic) + } +} diff --git a/apps/TerminalX/iOS/SSHTerminalModel.swift b/apps/TerminalX/iOS/SSHTerminalModel.swift index 5fd7642..0ff9790 100644 --- a/apps/TerminalX/iOS/SSHTerminalModel.swift +++ b/apps/TerminalX/iOS/SSHTerminalModel.swift @@ -140,6 +140,16 @@ final class GridBox: @unchecked Sendable { } } +/// M4 host key 不符详情(供 SwiftUI alert 并排显示旧/新指纹 + 用户抉择)。 +struct HostKeyMismatchInfo: Identifiable { + let id = UUID() + let egress: String, host: String, port: Int + let keyType: Int32 + let presentedBlob: Data + let presentedFp: String + let storedFp: String +} + /// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端, /// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。 @MainActor @@ -155,6 +165,10 @@ final class SSHTerminalModel: ObservableObject { @Published var phaseText: String = "空闲" /// 非 nil 时进入 tmux control-mode(原生 tab)。 @Published var tmuxController: TmuxController? + /// M4:host key 与 pin 不符 → 非 nil 触发 SwiftUI alert(信任新密钥 / 取消)。 + @Published var hostKeyMismatch: HostKeyMismatchInfo? + + private let knownHosts = KeychainKnownHostsStore() private var machine = SessionMachine() private var config: SSHConfig? @@ -227,11 +241,50 @@ final class SSHTerminalModel: ObservableObject { // MARK: - 公开入口 func connect(host: String, port: Int, username: String, password: String) { + let egress = tsnetAuthKey != nil ? "tsnet" : "direct" config = SSHConfig(host: host, port: port, username: username, - authentication: .password(password)) + authentication: .password(password), + hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port)) run(machine.reduce(.connectRequested)) } + /// M4:构造 host key 校验器(TOFU)。在 ssh 串行队列同步调用;firstUse 自动 pin + 横幅, + /// mismatch 冒泡到主线程触发 alert 并返回 false(SSHSession 会断开重试)。 + private func makeHostKeyVerifier(egress: String, host: String, port: Int) -> @Sendable (Data, Int32) -> Bool { + let store = knownHosts + let triple = HostTriple(egress: egress, host: host, port: port) + return { [weak self] blob, keyType in + switch HostKey.evaluate(stored: store.lookup(triple)?.blob, presented: blob) { + case .firstUse: + store.pin(triple, record: HostKeyRecord(blob: blob, keyType: keyType)) + let fp = HostKey.opensshFingerprint(blob) + Task { @MainActor in self?.banner = "已记住 host key \(fp)" } + return true + case .trusted: + return true + case .mismatch(let stored): + let info = HostKeyMismatchInfo( + egress: egress, host: host, port: port, keyType: keyType, + presentedBlob: blob, presentedFp: HostKey.opensshFingerprint(blob), + storedFp: HostKey.opensshFingerprint(stored)) + Task { @MainActor in self?.hostKeyMismatch = info } + return false + } + } + } + + /// 用户确认信任新 host key → 覆盖 pin 并重连。 + func trustNewHostKey() { + guard let m = hostKeyMismatch else { return } + knownHosts.pin(HostTriple(egress: m.egress, host: m.host, port: m.port), + record: HostKeyRecord(blob: m.presentedBlob, keyType: m.keyType)) + hostKeyMismatch = nil + run(machine.reduce(.retryRequested)) // failed → connecting(verifier 此时将判定 trusted) + } + + /// 用户取消 → 保持断开。 + func dismissHostKeyMismatch() { hostKeyMismatch = nil } + func close() { run(machine.reduce(.closeRequested)) } func enterBackground() { if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP @@ -270,7 +323,14 @@ final class SSHTerminalModel: ObservableObject { if let cmd = d.string(forKey: "txMoshServerCmd"), !cmd.isEmpty { moshServerCmd = cmd } autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand } - connect(host: host, port: port == 0 ? 22 : port, + let realPort = port == 0 ? 22 : port + // 无头验证:播种一个假 pin → 真实 host key 将 mismatch,触发 alert 路径。 + if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) { + let egress = tsnetAuthKey != nil ? "tsnet" : "direct" + knownHosts.pin(HostTriple(egress: egress, host: host, port: realPort), + record: HostKeyRecord(blob: fake, keyType: 3)) + } + connect(host: host, port: realPort, username: user, password: d.string(forKey: "txPass") ?? "") } diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 111f74b..361cf21 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -42,7 +42,7 @@ | **R5** Go+gvisor+C+++Swift 同进程 | ✅ retire | 全程真机无崩溃 | | **M2** mosh | ✅ 完成(LAN + tsnet 均验证) | libmoshios(C++)+protobuf-lite→`MoshCore.xcframework`;UDP relay over tsnet;**LAN 直连 + mosh-over-tsnet 均端到端验证通过**(R1 证伪) | | **M3** 生命周期深化 | ✅ mosh 快恢复(真机深挂起待补) | scenePhase FSM + **mosh 挂起→恢复<3s 已验证**(模拟器:15/30s 冻结→前台,wake pulse→1.3s 收到服务器包,"已恢复");冷启动重连(SSP 序号+key 持久化)划入 M4/M5 | -| **M4** 安全 | ◻️ 未做 | **当前 SSH 接受任意 host key**(见 SSHSession TODO);known_hosts 固定 + Secure Enclave 密钥 待做 | +| **M4** 安全 | ◐ host key 校验完成 / SE 认证待做 | **host key TOFU pin 已做并验证**(首次信任+不符告警,指纹对齐 ssh-keygen,MITM 敞口关闭;pin 存沙盒文件因未签名 app 无 keychain 权限);Secure Enclave publickey 认证(M4b) 待做 | | **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 | ## 5. 代码地图与数据流 diff --git a/packages/TXCore/Sources/TXCore/SSH/HostKey.swift b/packages/TXCore/Sources/TXCore/SSH/HostKey.swift new file mode 100644 index 0000000..1bb348e --- /dev/null +++ b/packages/TXCore/Sources/TXCore/SSH/HostKey.swift @@ -0,0 +1,52 @@ +import Foundation +import CryptoKit + +/// host key 三元组身份:egress(direct / tsnet 节点名)+ host + port。 +/// 同一台机器经不同 egress(如 192.168.x 直连 vs 100.x tailnet)host 本就不同,egress 维度兜底 hostname 复用。 +public struct HostTriple: Hashable, Sendable { + public let egress: String + public let host: String + public let port: Int + public init(egress: String, host: String, port: Int) { + self.egress = egress + self.host = host.lowercased() + self.port = port + } + /// Keychain account 规范化键。 + public var accountKey: String { "\(egress)|\(host)|\(port)" } +} + +/// TOFU 评估结果。 +public enum HostKeyEvaluation: Equatable, Sendable { + case firstUse // 无记录 → 首次信任并 pin + case trusted // 与 pin 一致 + case mismatch(stored: Data) // 与 pin 不符 → 高度可疑(MITM/换机/配置漂移) +} + +public enum HostKey { + /// 纯函数:拿已存 blob 与本次呈现 blob 比对。 + public static func evaluate(stored: Data?, presented: Data) -> HostKeyEvaluation { + guard let stored else { return .firstUse } + return stored == presented ? .trusted : .mismatch(stored: stored) + } + + /// OpenSSH 风格指纹:"SHA256:" + base64(sha256(blob))(去尾部 '=' padding,与 ssh-keygen -lf 逐字符一致)。 + public static func opensshFingerprint(_ blob: Data) -> String { + let digest = SHA256.hash(data: blob) + let b64 = Data(digest).base64EncodedString().replacingOccurrences(of: "=", with: "") + return "SHA256:" + b64 + } +} + +/// known_hosts 记录(跨平台层存取)。 +public struct HostKeyRecord: Equatable, Sendable { + public let blob: Data + public let keyType: Int32 // LIBSSH2_HOSTKEY_TYPE_* + public init(blob: Data, keyType: Int32) { self.blob = blob; self.keyType = keyType } +} + +/// pin 存储抽象(平台层用 Keychain 实现;单测用内存实现)。 +public protocol KnownHostsStore: Sendable { + func lookup(_ triple: HostTriple) -> HostKeyRecord? + func pin(_ triple: HostTriple, record: HostKeyRecord) +} diff --git a/packages/TXCore/Sources/TXCore/SSH/SSHWire.swift b/packages/TXCore/Sources/TXCore/SSH/SSHWire.swift new file mode 100644 index 0000000..cc8af5a --- /dev/null +++ b/packages/TXCore/Sources/TXCore/SSH/SSHWire.swift @@ -0,0 +1,55 @@ +import Foundation + +/// SSH 线格式基础编码(RFC 4251)。纯逻辑、可单测。 +public enum SSHWire { + /// `string`:4 字节大端长度前缀 + 原始字节。 + public static func string(_ bytes: [UInt8]) -> [UInt8] { + let n = UInt32(bytes.count) + return [UInt8(truncatingIfNeeded: n >> 24), UInt8(truncatingIfNeeded: n >> 16), + UInt8(truncatingIfNeeded: n >> 8), UInt8(truncatingIfNeeded: n)] + bytes + } + public static func string(_ s: String) -> [UInt8] { string([UInt8](s.utf8)) } + + /// `mpint`:大端二补码整数(本函数只处理非负)。去前导 0;最高位为 1 时前补 0x00;再套 string。 + public static func mpint(_ magnitude: [UInt8]) -> [UInt8] { + var b = magnitude + while b.first == 0 { b.removeFirst() } // 去前导零 + if b.isEmpty { return [0, 0, 0, 0] } // 值为 0 → 空串 + if b[0] & 0x80 != 0 { b.insert(0, at: 0) } // 高位为 1 → 前补 0x00(否则被当负数) + return string(b) + } +} + +/// ECDSA(P-256) 的 SSH 线格式转换(供 Secure Enclave publickey 认证)。 +public enum ECDSAConv { + /// 把 `SecKeyCreateSignature` 的 DER `SEQUENCE{INTEGER r, INTEGER s}` 转成 SSH 的 `mpint(r) || mpint(s)`。 + /// 注意:r/s 最高位为 1 时 DER 已含前导 0x00,mpint 重新按规则编码即可。失败返回 nil。 + public static func derToSSHSignature(_ der: [UInt8]) -> [UInt8]? { + var i = 0 + func readLen() -> Int? { + guard i < der.count else { return nil } + var l = Int(der[i]); i += 1 + if l & 0x80 != 0 { + let n = l & 0x7f + guard n > 0, i + n <= der.count else { return nil } + l = 0 + for _ in 0 ..< n { l = (l << 8) | Int(der[i]); i += 1 } + } + return l + } + guard i < der.count, der[i] == 0x30 else { return nil }; i += 1 // SEQUENCE + guard readLen() != nil else { return nil } + func readInt() -> [UInt8]? { + guard i < der.count, der[i] == 0x02 else { return nil }; i += 1 // INTEGER + guard let l = readLen(), i + l <= der.count else { return nil } + let v = Array(der[i ..< i + l]); i += l; return v + } + guard let r = readInt(), let s = readInt() else { return nil } + return SSHWire.mpint(r) + SSHWire.mpint(s) + } + + /// 由 X9.63 公钥字节(`0x04 || X || Y`,65B)拼 SSH `ecdsa-sha2-nistp256` 公钥 blob。 + public static func p256PublicKeyBlob(x963: [UInt8]) -> [UInt8] { + SSHWire.string("ecdsa-sha2-nistp256") + SSHWire.string("nistp256") + SSHWire.string(x963) + } +} diff --git a/packages/TXCore/Tests/TXCoreTests/SSHWireTests.swift b/packages/TXCore/Tests/TXCoreTests/SSHWireTests.swift new file mode 100644 index 0000000..c166c3e --- /dev/null +++ b/packages/TXCore/Tests/TXCoreTests/SSHWireTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import TXCore + +final class SSHWireTests: XCTestCase { + func testStringEncoding() { + XCTAssertEqual(SSHWire.string([0xAA, 0xBB]), [0, 0, 0, 2, 0xAA, 0xBB]) + XCTAssertEqual(SSHWire.string(""), [0, 0, 0, 0]) + XCTAssertEqual(SSHWire.string("nistp256"), [0, 0, 0, 8] + Array("nistp256".utf8)) + } + + func testMpint() { + // 普通正数(高位为 0):原样。 + XCTAssertEqual(SSHWire.mpint([0x01, 0x02]), [0, 0, 0, 2, 0x01, 0x02]) + // 高位为 1:前补 0x00(否则被当负数)。 + XCTAssertEqual(SSHWire.mpint([0xFF]), [0, 0, 0, 2, 0x00, 0xFF]) + // 前导零去除。 + XCTAssertEqual(SSHWire.mpint([0x00, 0x00, 0x05]), [0, 0, 0, 1, 0x05]) + // 值为 0 → 空串。 + XCTAssertEqual(SSHWire.mpint([0x00, 0x00]), [0, 0, 0, 0]) + } + + func testDerToSSHSignatureHighBit() { + // 构造 DER SEQUENCE{ INTEGER r(高位1,含前导00), INTEGER s(高位0) }。 + let r = [UInt8(0x00), 0xFF] + [UInt8](repeating: 0x11, count: 31) // 33B DER 内容 + let s = [UInt8(0x01)] + [UInt8](repeating: 0x22, count: 31) // 32B DER 内容 + var der: [UInt8] = [0x30, UInt8(2 + r.count + 2 + s.count)] + der += [0x02, UInt8(r.count)] + r + der += [0x02, UInt8(s.count)] + s + + let sig = ECDSAConv.derToSSHSignature(der) + XCTAssertNotNil(sig) + // 应等于独立计算的 mpint(r)+mpint(s)。 + let expected = SSHWire.mpint(r) + SSHWire.mpint(s) + XCTAssertEqual(sig, expected) + // r 高位为 1 → mpint 33 字节,长度前缀 0x21。 + XCTAssertEqual(Array(sig!.prefix(4)), [0, 0, 0, 0x21]) + } + + func testP256Blob() { + let q = [UInt8(0x04)] + [UInt8](repeating: 0xAB, count: 64) // X9.63 65B + let blob = ECDSAConv.p256PublicKeyBlob(x963: q) + // 开头必须是 string("ecdsa-sha2-nistp256")。 + XCTAssertEqual(Array(blob.prefix(4)), [0, 0, 0, 0x13]) + XCTAssertEqual(Array(blob[4 ..< 4 + 19]), Array("ecdsa-sha2-nistp256".utf8)) + } + + /// 真实向量:192.168.9.199 的 ecdsa host key,指纹须与 `ssh-keygen -lf` 一致。 + func testOpenSSHFingerprintRealVector() { + let b64 = "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCkvFPRkGE8RGQ+scBa3JZMWnZnGUn7BWfsz8rfEtKjIojjqxxJLdZvQE9rUlaNNUFi8eGb9lfr5rO4yFS0dnsU=" + let blob = Data(base64Encoded: b64)! + XCTAssertEqual(HostKey.opensshFingerprint(blob), "SHA256:dcpjdomtDzOZJuv/NHr1vYtm0mP5EbsEML0kClhgqxg") + } + + func testEvaluate() { + let a = Data([1, 2, 3]); let b = Data([1, 2, 3]); let c = Data([9]) + XCTAssertEqual(HostKey.evaluate(stored: nil, presented: a), .firstUse) + XCTAssertEqual(HostKey.evaluate(stored: b, presented: a), .trusted) + XCTAssertEqual(HostKey.evaluate(stored: c, presented: a), .mismatch(stored: c)) + } +} diff --git a/packages/TXTransport/Sources/TXTransport/SSHSession.swift b/packages/TXTransport/Sources/TXTransport/SSHSession.swift index 3d23005..2418fd5 100644 --- a/packages/TXTransport/Sources/TXTransport/SSHSession.swift +++ b/packages/TXTransport/Sources/TXTransport/SSHSession.swift @@ -1,6 +1,7 @@ import Foundation import Darwin import CSSH +import TXCore /// 基于 libssh2 的 SSH 传输。M0:系统 socket 直连(不过 tsnet),密码 / 私钥认证, /// 打开 PTY + shell,非阻塞事件循环把 PTY 输出投递给 `onBytes`。 @@ -115,7 +116,24 @@ public final class SSHSession: Transport, @unchecked Sendable { libssh2_session_set_blocking(s, 1) let rc = libssh2_session_handshake(s, sock) guard rc == 0 else { throw SSHError.handshake(Int(rc)) } - // TODO(M4/TXSecurity): 校验 host key 指纹(known_hosts 三元组)。M0 暂接受任意。 + try verifyHostKey(s) + } + + /// M4:TOFU host key 校验。取服务器 host key blob 交给 verifier;拒绝则断开并抛 hostKeyMismatch。 + private func verifyHostKey(_ s: OpaquePointer) throws { + guard let verifier = config.hostKeyVerifier else { return } // 未配置校验器 → 历史行为 + var len = 0 + var type: Int32 = 0 + guard let raw = libssh2_session_hostkey(s, &len, &type), len > 0 else { + throw SSHError.hostKeyMismatch(fingerprint: "(无法获取 host key)") + } + let blob = Data(bytes: raw, count: len) + if verifier(blob, type) { return } // 接受(trusted / firstUse 已 pin) + let fp = HostKey.opensshFingerprint(blob) + "hostkey".withCString { r in "".withCString { l in + _ = libssh2_session_disconnect_ex(s, 11, r, l) // BY_APPLICATION + } } + throw SSHError.hostKeyMismatch(fingerprint: fp) } // MARK: - Auth diff --git a/packages/TXTransport/Sources/TXTransport/Transport.swift b/packages/TXTransport/Sources/TXTransport/Transport.swift index cc9e60b..eee4b4d 100644 --- a/packages/TXTransport/Sources/TXTransport/Transport.swift +++ b/packages/TXTransport/Sources/TXTransport/Transport.swift @@ -31,6 +31,10 @@ public struct SSHConfig: Sendable { public var terminalType: String public var initialCols: UInt16 public var initialRows: UInt16 + /// M4 host key 校验:握手后以 (blob, keyType) 同步回调(在 ssh 串行队列)。返回 true=接受 + /// (trusted 或 firstUse 已 pin),false=拒绝(mismatch)→ SSHSession 断开并抛 hostKeyMismatch。 + /// nil 时不校验(历史行为)。校验/pin 的存储与 UI 由 app 层实现。 + public var hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? public enum Authentication: Sendable { case password(String) @@ -44,7 +48,8 @@ public struct SSHConfig: Sendable { authentication: Authentication, terminalType: String = "xterm-256color", initialCols: UInt16 = 80, - initialRows: UInt16 = 24 + initialRows: UInt16 = 24, + hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil ) { self.host = host self.port = port @@ -53,6 +58,7 @@ public struct SSHConfig: Sendable { self.terminalType = terminalType self.initialCols = initialCols self.initialRows = initialRows + self.hostKeyVerifier = hostKeyVerifier } } @@ -61,4 +67,6 @@ public enum SSHError: Error, Sendable, Equatable { case handshake(Int) case authentication(Int) case channel(String) + /// host key 与已 pin 记录不符(潜在 MITM)。携带本次呈现的 OpenSSH 指纹。 + case hostKeyMismatch(fingerprint: String) }