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:
kid
2026-07-24 18:06:39 +08:00
parent 7abe61a71f
commit 1a257fae7f
9 changed files with 321 additions and 5 deletions

View File

@@ -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?
/// M4host 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 falseSSHSession
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 connectingverifier 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") ?? "")
}