import CryptoKit import Foundation public nonisolated enum SSHImportContentKind: Equatable, Sendable { case privateKey case userCertificate case knownHosts case configuration case unknown } public nonisolated enum SSHImportParser { private static let privateKeyHeaders = [ "-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----", "-----BEGIN PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----BEGIN DSA PRIVATE KEY-----", ] /// Classifies OpenSSH text by syntax rather than by the path that contains it. /// Private material wins before the line-oriented formats so a PEM body can /// never be mistaken for a configuration file. public static func contentKind(_ text: String) -> SSHImportContentKind { if privateKeyHeaders.contains(where: { text.hasPrefix($0) }) { return .privateKey } let meaningful = text.components(separatedBy: .newlines).compactMap { raw -> [String]? in let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { return nil } return (try? tokens(raw)).flatMap { $0.isEmpty ? nil : $0 } } guard !meaningful.isEmpty else { return .unknown } if meaningful.contains(where: { fields in fields.first?.contains("-cert-v01@openssh.com") == true && fields.count >= 2 }) { return .userCertificate } if meaningful.contains(where: resemblesKnownHostRecord) { return .knownHosts } if meaningful.contains(where: { fields in fields.first.map { configurationKeywords.contains($0.lowercased()) } == true }) { return .configuration } return .unknown } private static func resemblesKnownHostRecord(_ fields: [String]) -> Bool { let algorithmIndex = fields.first?.hasPrefix("@") == true ? 2 : 1 guard fields.indices.contains(algorithmIndex), fields.indices.contains(algorithmIndex + 1) else { return false } let algorithm = fields[algorithmIndex] return algorithm.hasPrefix("ssh-") || algorithm.hasPrefix("ecdsa-") || algorithm.hasPrefix("sk-") } /// Includes the directives Termind evaluates plus common OpenSSH options. /// Seeing any of these is enough to treat the file as configuration; the /// parser still reports unsupported directives as warnings. private static let configurationKeywords: Set = [ "addkeystoagent", "addressfamily", "batchmode", "bindaddress", "bindinterface", "canonicaldomains", "canonicalizefallbacklocal", "canonicalizehostname", "canonicalizemaxdots", "canonicalizepermittedcnames", "casignaturealgorithms", "certificatefile", "checkhostip", "ciphers", "clearallforwardings", "compression", "connectionattempts", "connecttimeout", "controlmaster", "controlpath", "controlpersist", "dynamicforward", "enableescapecommandline", "enablesshkeysign", "escapechar", "exitonforwardfailure", "fingerprinthash", "forkafterauthentication", "forwardagent", "forwardx11", "forwardx11timeout", "forwardx11trusted", "gatewayports", "globalknownhostsfile", "gssapiauthentication", "gssapidelegatecredentials", "hashknownhosts", "host", "hostbasedauthentication", "hostkeyalgorithms", "hostname", "identitiesonly", "identityagent", "identityfile", "ignoreunknown", "include", "ipqos", "kbdinteractiveauthentication", "kexalgorithms", "knownhostscommand", "localcommand", "localforward", "loglevel", "logverbose", "macs", "match", "nohostauthenticationforlocalhost", "numberofpasswordprompts", "obscurekeystroketiming", "passwordauthentication", "permitlocalcommand", "permitremoteopen", "pkcs11provider", "port", "preferredauthentications", "proxycommand", "proxyjump", "proxyusefdpass", "pubkeyacceptedalgorithms", "pubkeyauthentication", "remoteforward", "rekeylimit", "requesttty", "requiredrsasize", "sendenv", "serveralivecountmax", "serveraliveinterval", "sessiontype", "setenv", "stdinnull", "streamlocalbindmask", "streamlocalbindunlink", "stricthostkeychecking", "syslogfacility", "tcpkeepalive", "tunnel", "tunneldevice", "updatehostkeys", "user", "userknownhostsfile", "verifyhostkeydns", "visualhostkey", "xauthlocation", ] /// Tokenizes OpenSSH's quoted arguments without invoking a shell. public static func tokens(_ line: String) throws -> [String] { var result: [String] = [] var token = "" var quote: Character? var escaping = false for character in line { if escaping { token.append(character) escaping = false continue } if character == "\\" { escaping = true continue } if let current = quote { if character == current { quote = nil } else { token.append(character) } } else if character == "\"" || character == "'" { quote = character } else if character == "#", token.isEmpty { break } else if character.isWhitespace || (character == "=" && (result.isEmpty || (result.count == 1 && token.isEmpty))) { if !token.isEmpty { result.append(token) token = "" } } else { token.append(character) } } guard quote == nil, !escaping else { throw CocoaError(.fileReadCorruptFile) } if !token.isEmpty { result.append(token) } return result } public static func glob(_ pattern: String, matches value: String, path: Bool = false) -> Bool { let expression = "^" + NSRegularExpression.escapedPattern(for: pattern) .replacingOccurrences(of: "\\*", with: path ? "[^/]*" : ".*").replacingOccurrences( of: "\\?", with: path ? "[^/]" : ".") + "$" return value.range(of: expression, options: [.regularExpression, .caseInsensitive]) != nil } public static func matches(_ patterns: [String], alias: String) -> Bool { var positive = false for pattern in patterns { if pattern.hasPrefix("!") { if glob(String(pattern.dropFirst()), matches: alias) { return false } } else if glob(pattern, matches: alias) { positive = true } } return positive } public static func concrete(_ alias: String) -> Bool { !alias.isEmpty && !alias.contains(where: { "*?!".contains($0) }) } public static func endpoint(_ value: String) -> (String, Int)? { if value.hasPrefix("[") { guard let close = value.firstIndex(of: "]"), value[value.index(after: close)...].hasPrefix(":"), let port = Int(value[value.index(close, offsetBy: 2)...]), (1...65_535).contains(port) else { return nil } return (String(value[value.index(after: value.startIndex).. [SSHImportItem] { var result: [SSHImportItem] = [] for (offset, raw) in text.components(separatedBy: .newlines).enumerated() { let fields = raw.split(whereSeparator: \.isWhitespace).map(String.init) guard let first = fields.first, !first.hasPrefix("#") else { continue } let location = "\(source):\(offset + 1)" do { guard !first.hasPrefix("@") else { throw ImportParseError.unsupported } guard fields.count >= 3, let algorithm = SSHKeyAlgorithm(rawValue: fields[1]), let blob = Data(base64Encoded: fields[2]), validPublicKey(blob, algorithm: algorithm) else { throw ImportParseError.invalid } let fingerprint = "SHA256:" + Data(SHA256.hash(data: blob)).base64EncodedString() .replacingOccurrences(of: "=", with: "") let addresses = first.split(separator: ",", omittingEmptySubsequences: false).map(String.init) // Negation belongs to the whole pattern list, not to one split entry. guard !addresses.contains(where: { $0.contains(where: { "*!?".contains($0) }) }) else { throw ImportParseError.unsupported } for address in addresses { let endpoint = address.hasPrefix("|") ? (address, 22) : endpoint(address) guard let (hostname, port) = endpoint else { throw ImportParseError.invalid } let entry = KnownHostEntry( hostname: hostname, port: port, algorithm: algorithm, fingerprint: fingerprint, addedAt: Date() ) guard entry.isValid else { throw ImportParseError.invalid } result.append( SSHImportItem( category: .knownHosts, name: entry.displayAddress, source: location, knownHost: entry )) } } catch { result.append( SSHImportItem( category: .knownHosts, name: first, source: location, selected: false, issue: error.localizedDescription, unavailable: true )) } } return result } /// Validate the wire key structure as well as its outer algorithm label. public static func validPublicKey(_ blob: Data, algorithm: SSHKeyAlgorithm) -> Bool { var cursor = 0 func field() -> Data? { guard cursor + 4 <= blob.count else { return nil } let length = blob[cursor..