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>
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
apps/TerminalX/iOS/KeychainKnownHosts.swift
Normal file
54
apps/TerminalX/iOS/KeychainKnownHosts.swift
Normal file
@@ -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": "<base64>", "type": <int> } }`,
|
||||
/// 落 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)
|
||||
}
|
||||
}
|
||||
@@ -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") ?? "")
|
||||
}
|
||||
|
||||
|
||||
@@ -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. 代码地图与数据流
|
||||
|
||||
52
packages/TXCore/Sources/TXCore/SSH/HostKey.swift
Normal file
52
packages/TXCore/Sources/TXCore/SSH/HostKey.swift
Normal file
@@ -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)
|
||||
}
|
||||
55
packages/TXCore/Sources/TXCore/SSH/SSHWire.swift
Normal file
55
packages/TXCore/Sources/TXCore/SSH/SSHWire.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
60
packages/TXCore/Tests/TXCoreTests/SSHWireTests.swift
Normal file
60
packages/TXCore/Tests/TXCoreTests/SSHWireTests.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user