Files
Termind/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift
T

238 lines
11 KiB
Swift

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<String> = [
"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)..<close]), port)
}
guard !value.isEmpty else { return nil }
return (value, 22)
}
public static func knownHosts(_ text: String, source: String) -> [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..<cursor + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
cursor += 4
guard Int(length) <= blob.count - cursor else { return nil }
defer { cursor += Int(length) }
return blob.subdata(in: cursor..<cursor + Int(length))
}
guard let name = field(), String(data: name, encoding: .utf8) == algorithm.rawValue else { return false }
switch algorithm {
case .ed25519:
guard field()?.count == 32 else { return false }
case .rsa:
guard let exponent = field(), !exponent.isEmpty, let modulus = field(), !modulus.isEmpty else {
return false
}
case .ecdsa256, .ecdsa384, .ecdsa521:
let curve = algorithm.rawValue.replacingOccurrences(of: "ecdsa-sha2-", with: "")
guard let name = field(), String(data: name, encoding: .utf8) == curve,
let point = field(), point.first == 4,
point.count == (algorithm == .ecdsa256 ? 65 : algorithm == .ecdsa384 ? 97 : 133)
else { return false }
}
return cursor == blob.count
}
}
public nonisolated enum ImportParseError: LocalizedError {
case invalid, unsupported
public var errorDescription: String? {
switch self {
case .invalid: String(localized: "Invalid SSH record.")
case .unsupported: String(localized: "This SSH record is not supported.")
}
}
}