import Foundation /// Host-Key-Prüfung OHNE interaktiven Yes/No-Prompt (der kommt ohne echtes /// Terminal/Ctty nicht an — ssh bricht dann mit „Host key verification failed." ab). /// Ablauf: ssh-keyscan → Fingerprint → Popup (akzeptieren/ablehnen) → known_hosts. /// Nutzt bewusst ~/.ssh/known_hosts gemeinsam mit Terminal.app. enum HostKeyStatus: Equatable { case known case unknown(HostKeyInfo) case changed(HostKeyInfo) case unreachable(String) } struct HostKeyInfo: Equatable { var host: String var port: Int var keyType: String var fingerprintSHA256: String var fingerprintMD5: String var allKeyLines: [String] } enum HostKeyService { static var knownHostsPath = NSHomeDirectory() + "/.ssh/known_hosts" static var scanTimeoutSeconds = "5" static func canonicalHost(_ host: String, port: Int) -> String { port == 22 ? host : "[\(host)]:\(port)" } @discardableResult static func run(_ exe: String, _ args: [String]) -> (Int32, String) { let p = Process() p.executableURL = URL(fileURLWithPath: exe) p.arguments = args let out = Pipe() p.standardOutput = out p.standardError = Pipe() do { try p.run() } catch { return (-1, "") } p.waitUntilExit() let data = out.fileHandleForReading.readDataToEndOfFile() return (p.terminationStatus, String(data: data, encoding: .utf8) ?? "") } /// Rohe Key-Zeilen vom Server (ohne #-Kommentare). static func keyscan(host: String, port: Int) -> [String] { let (code, out) = run("/usr/bin/ssh-keyscan", ["-T", scanTimeoutSeconds, "-p", "\(port)", host]) guard code == 0 else { return [] } return out.split(separator: "\n").map(String.init).filter { line in let t = line.trimmingCharacters(in: .whitespaces) return !t.isEmpty && !t.hasPrefix("#") && t.contains(" ") } } static func knownHostsContent() -> String { (try? String(contentsOfFile: knownHostsPath, encoding: .utf8)) ?? "" } static func status(host: String, port: Int) -> HostKeyStatus { let h = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !h.isEmpty else { return .unreachable("Kein Host.") } let lines = keyscan(host: h, port: port) guard !lines.isEmpty else { return .unreachable("Host-Key nicht abfragbar (ssh-keyscan ohne Antwort).") } guard let info = info(host: h, port: port, lines: lines) else { return .unreachable("Fingerprint nicht berechenbar.") } let (fcode, fout) = run("/usr/bin/ssh-keygen", ["-F", canonicalHost(h, port: port), "-f", knownHostsPath]) let hasEntry = fcode == 0 && fout.contains("found:") if hasEntry { let knownKeys = lines.compactMap { $0.split(separator: " ").last.map(String.init) } if knownKeys.contains(where: { knownHostsContent().contains($0) }) { return .known } return .changed(info) } return .unknown(info) } /// Bevorzugter Key (ed25519 > ecdsa > Rest) + Fingerprints via ssh-keygen. static func info(host: String, port: Int, lines: [String]) -> HostKeyInfo? { func rank(_ l: String) -> Int { if l.contains("ssh-ed25519") { return 0 } if l.contains("ecdsa-") { return 1 } return 2 } guard let line = lines.sorted(by: { rank($0) < rank($1) }).first else { return nil } let parts = line.split(separator: " ") guard parts.count >= 3 else { return nil } let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("rmm-fp-\(UUID().uuidString)") do { try (line + "\n").write(to: tmp, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: tmp) } let (_, sha) = run("/usr/bin/ssh-keygen", ["-l", "-f", tmp.path]) let (_, md5) = run("/usr/bin/ssh-keygen", ["-l", "-E", "md5", "-f", tmp.path]) return HostKeyInfo(host: host, port: port, keyType: String(parts[1]), fingerprintSHA256: parseFP(sha), fingerprintMD5: parseFP(md5), allKeyLines: lines) } catch { return nil } } /// Aus `256 SHA256:XYZ host (ED25519)` wird `SHA256:XYZ`. static func parseFP(_ out: String) -> String { let parts = out.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ") guard parts.count >= 2 else { return out.trimmingCharacters(in: .whitespacesAndNewlines) } return String(parts[1]) } /// Fehlende Key-Zeilen an known_hosts anhängen (0600, ~/.ssh 0700). static func accept(_ info: HostKeyInfo) -> Bool { let dir = (knownHostsPath as NSString).deletingLastPathComponent do { try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) let existing = knownHostsContent() let missing = info.allKeyLines.filter { line in let key = line.split(separator: " ").last.map(String.init) ?? line return !existing.contains(key) } guard !missing.isEmpty else { return true } if !FileManager.default.fileExists(atPath: knownHostsPath) { FileManager.default.createFile(atPath: knownHostsPath, contents: nil, attributes: [.posixPermissions: 0o600]) } let fh = try FileHandle(forWritingTo: URL(fileURLWithPath: knownHostsPath)) defer { try? fh.close() } try fh.seekToEnd() fh.write(((missing.joined(separator: "\n")) + "\n").data(using: .utf8)!) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: knownHostsPath) return true } catch { return false } } /// Alle Einträge für Host entfernen (ssh-keygen -R). static func revoke(host: String, port: Int) -> Bool { let (code, _) = run("/usr/bin/ssh-keygen", ["-R", canonicalHost(host, port: port), "-f", knownHostsPath]) return code == 0 } }