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

@@ -0,0 +1,52 @@
import Foundation
import CryptoKit
/// host key egressdirect / tsnet + host + port
/// egress 192.168.x vs 100.x tailnethost 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)
}

View 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 0x00mpint 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)
}
}

View 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(100), 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))
}
}

View File

@@ -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)
}
/// M4TOFU 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

View File

@@ -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 pinfalse=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)
}