import Foundation import TXCore /// 已信任 host key 的展示条目(设置 → Known Hosts)。 struct KnownHostEntry: Identifiable { let id: String // accountKey "egress|host|port" let egress: String let host: String let port: Int let fingerprint: String } /// 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": "", "type": } }`, /// 落 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() } /// 已信任 host key 列表(供设置里查看/删除)。 func entries() -> [KnownHostEntry] { lock.lock(); defer { lock.unlock() } return cache.compactMap { key, entry in guard let b64 = entry["blob"] as? String, let blob = Data(base64Encoded: b64) else { return nil } let parts = key.split(separator: "|", maxSplits: 2).map(String.init) return KnownHostEntry( id: key, egress: parts.count > 0 ? parts[0] : "", host: parts.count > 1 ? parts[1] : "", port: parts.count > 2 ? (Int(parts[2]) ?? 0) : 0, fingerprint: HostKey.opensshFingerprint(blob)) }.sorted { $0.host < $1.host } } func removeKey(_ id: String) { lock.lock(); defer { lock.unlock() } cache[id] = nil persist() } private func persist() { guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return } try? data.write(to: url, options: .atomic) } }