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:
@@ -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") ?? "")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user