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)
}
}