diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 964fde03..8f0ec840 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -2656,6 +2656,23 @@ } } }, + "An imported private key changed or became unavailable. Scan again." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入的私钥已更改或不可用,请重新扫描。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯入的私鑰已變更或無法使用,請重新掃描。" + } + } + } + }, "Annual" : { "localizations" : { "zh-Hans" : { @@ -23480,6 +23497,23 @@ } } }, + "The selected file does not contain Known Hosts records." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所选文件不包含Known Hosts记录。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "所選檔案不包含Known Hosts記錄。" + } + } + } + }, "The selected host has no login username." : { "extractionState" : "stale", "localizations" : { @@ -26964,4 +26998,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift index cf1173d7..f84eec46 100644 --- a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift @@ -8,6 +8,10 @@ public final class SSHImportModel { public var items: [SSHImportItem] = [] public var expanded = Set(SSHImportCategory.allCases) public var storage: KeyStorage = .iCloud + public var keyColor: TagColor = .none + public var hostGroupID: HostGroup.ID? + public var hostColor: TagColor = .none + public var hostTags: [String] = [] public private(set) var isWorking = false public private(set) var error: String? public private(set) var completed = false @@ -169,10 +173,12 @@ public final class SSHImportModel { items.filter { (category == nil || $0.category == category) && selectable($0) } } public func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) { - for index in items.indices - where selectable(items[index]) && (category == nil || items[index].category == category) { - items[index].selected = selected + var updated = items + for index in updated.indices + where selectable(updated[index]) && (category == nil || updated[index].category == category) { + updated[index].selected = selected } + items = updated } public func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] { @@ -280,14 +286,26 @@ public final class SSHImportModel { public func unlock(_ id: UUID, passphrase: String) async { guard let index = items.firstIndex(where: { $0.id == id }) else { return } isWorking = true + error = nil defer { isWorking = false } do { - items[index].key = try await reader.unlock(id: id, name: items[index].name, passphrase: passphrase) - items[index].requiresPassphrase = false - items[index].issue = "" + let previous = items[index] + var replacements = try await reader.unlock(id: id, name: previous.name, passphrase: passphrase) + guard !replacements.isEmpty, replacements.allSatisfy({ $0.category == .keys && $0.key != nil }) else { + throw CocoaError(.coderInvalidValue) + } + for replacementIndex in replacements.indices { + replacements[replacementIndex].selected = previous.selected + replacements[replacementIndex].acceptedWarnings = previous.acceptedWarnings + } + items.replaceSubrange(index...index, with: replacements) refreshDuplicates() } catch { - items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.") + if let sourceError = error as? SSHImportSourceError { + self.error = sourceError.localizedDescription + } else { + items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.") + } } } @@ -325,6 +343,7 @@ public final class SSHImportModel { batch.keys = selectedItems(.keys).compactMap { item in guard var key = item.key else { return nil } key.storage = storage + key.colorTag = keyColor return key } batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost) @@ -365,7 +384,8 @@ public final class SSHImportModel { } let host = Host( id: item.id, name: item.name, host: imported.hostname, port: imported.port, - identityID: identity.id, jumpHostID: jumpID) + identityID: identity.id, jumpHostID: jumpID, groupID: hostGroupID, + tags: hostTags, colorTag: hostColor) built[item.id] = host return host } diff --git a/Frameworks/TermindApplication/Repositories/SSHImportReading.swift b/Frameworks/TermindApplication/Repositories/SSHImportReading.swift index 70eb7bcd..e900d5f9 100644 --- a/Frameworks/TermindApplication/Repositories/SSHImportReading.swift +++ b/Frameworks/TermindApplication/Repositories/SSHImportReading.swift @@ -3,7 +3,7 @@ import TermindDomain public nonisolated protocol SSHImportReading: Sendable { func scan(root: URL, knownHostsOnly: Bool, grants: [URL]) async throws -> [SSHImportItem] - func unlock(id: UUID, name: String, passphrase: String) async throws -> SSHKeyRef + func unlock(id: UUID, name: String, passphrase: String) async throws -> [SSHImportItem] func credentials(for ids: Set) async throws -> [UUID: StoredSSHKeyCredential] func discard() async } diff --git a/Frameworks/TermindDomain/SSHImport/SSHConfigParser.swift b/Frameworks/TermindDomain/SSHImport/SSHConfigParser.swift index 29627573..cdc2b960 100644 --- a/Frameworks/TermindDomain/SSHImport/SSHConfigParser.swift +++ b/Frameworks/TermindDomain/SSHImport/SSHConfigParser.swift @@ -7,6 +7,7 @@ public nonisolated struct SSHConfigParser { public let home: URL public let username: String public let files: [String: String] + public let entryPaths: [String] public var externalPaths: Set = [] public var diagnostics: [SSHImportItem] = [] @@ -32,45 +33,62 @@ public nonisolated struct SSHConfigParser { } public mutating func parse() -> [SSHImportItem] { - let config = root.appendingPathComponent("config").path - guard files[config] != nil else { return [] } - var aliases: [String] = [] - discover(config, stack: [], aliases: &aliases) var result: [SSHImportItem] = [] - for alias in aliases { - var state = Evaluation() - state.warnings = diagnostics.map { $0.source + ": " + $0.issue } - evaluate(config, alias: alias, stack: [], state: &state) - var host = SSHImportedHost( - alias: alias, hostname: state.values["hostname"] ?? alias, username: state.values["user"] ?? username) - host.hostname = host.hostname.replacingOccurrences(of: "%h", with: alias).replacingOccurrences( - of: "%n", with: alias) - var issue = "" - if let value = state.values["port"] { - if let port = Int(value), (1...65_535).contains(port) { - host.port = port - } else { - issue = String(localized: "Invalid SSH port.") + for config in rootEntryPaths() { + var aliases: [String] = [] + discover(config, stack: [], aliases: &aliases) + for alias in aliases { + var state = Evaluation() + state.warnings = diagnostics.map { $0.source + ": " + $0.issue } + evaluate(config, alias: alias, stack: [], state: &state) + var host = SSHImportedHost( + alias: alias, hostname: state.values["hostname"] ?? alias, + username: state.values["user"] ?? username) + host.hostname = host.hostname.replacingOccurrences(of: "%h", with: alias).replacingOccurrences( + of: "%n", with: alias) + var issue = "" + if let value = state.values["port"] { + if let port = Int(value), (1...65_535).contains(port) { + host.port = port + } else { + issue = String(localized: "Invalid SSH port.") + } } + let identities = + state.identities.isEmpty + ? ["~/.ssh/id_rsa", "~/.ssh/id_ecdsa", "~/.ssh/id_ed25519"] : state.identities + host.usesDefaultIdentityFiles = state.identities.isEmpty + host.identityPaths = identities.filter { $0 != "none" }.map { expandedPath($0, host: host) } + host.certificatePaths = state.certificates.filter { $0 != "none" }.map { expandedPath($0, host: host) } + if let jump = state.values["proxyjump"], jump != "none" { + host.jumps = jump.split(separator: ",").map(String.init) + } + let source = state.source.isEmpty ? config : state.source + result.append( + SSHImportItem( + category: .hosts, name: alias, source: source, + issue: issue, warnings: state.warnings, host: host)) } - let identities = - state.identities.isEmpty - ? ["~/.ssh/id_rsa", "~/.ssh/id_ecdsa", "~/.ssh/id_ed25519"] : state.identities - host.usesDefaultIdentityFiles = state.identities.isEmpty - host.identityPaths = identities.filter { $0 != "none" }.map { expandedPath($0, host: host) } - host.certificatePaths = state.certificates.filter { $0 != "none" }.map { expandedPath($0, host: host) } - if let jump = state.values["proxyjump"], jump != "none" { - host.jumps = jump.split(separator: ",").map(String.init) - } - let source = state.source.isEmpty ? config : state.source - result.append( - SSHImportItem( - category: .hosts, name: alias, source: source, - issue: issue, warnings: state.warnings, host: host)) } return result + diagnostics } + /// A recursively discovered Include fragment is evaluated through its + /// parent, not a second time as an independent configuration root. + private mutating func rootEntryPaths() -> [String] { + let candidates = entryPaths.filter { files[$0] != nil } + guard !candidates.isEmpty else { return [] } + let candidateSet = Set(candidates) + var included: Set = [] + for path in candidates { + for line in lines(path) where line.keyword == "include" { + included.formUnion(includes(line).filter { candidateSet.contains($0) }) + } + } + let roots = candidates.filter { !included.contains($0) } + return (roots.isEmpty ? candidates : roots).sorted() + } + private struct Evaluation { public var active = true public var inMatch = false @@ -189,13 +207,15 @@ public nonisolated struct SSHConfigParser { } } public init( - root: URL, home: URL, username: String, files: [String: String], externalPaths: Set = [], + root: URL, home: URL, username: String, files: [String: String], entryPaths: [String]? = nil, + externalPaths: Set = [], diagnostics: [SSHImportItem] = [] ) { self.root = root self.home = home self.username = username self.files = files + self.entryPaths = entryPaths ?? [root.appendingPathComponent("config").path] self.externalPaths = externalPaths self.diagnostics = diagnostics } diff --git a/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift b/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift index cd32652f..806945c1 100644 --- a/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift +++ b/Frameworks/TermindDomain/SSHImport/SSHImportItem.swift @@ -12,7 +12,8 @@ public nonisolated enum SSHImportCategory: String, CaseIterable, Identifiable, S } } -/// Public metadata only. The reader retains private material behind item IDs. +/// Public metadata only. The reader resolves private material from short-lived +/// source descriptors behind item IDs when commit needs it. public nonisolated struct SSHImportItem: Identifiable, Sendable { public var id = UUID() public var category: SSHImportCategory @@ -102,11 +103,13 @@ public nonisolated struct SSHConfigLine: Sendable { } public nonisolated enum SSHImportSourceError: LocalizedError, Equatable { - case expectedFile, expectedFolder + case expectedFile, expectedFolder, unsupportedKnownHostsContent, sourceChanged public var errorDescription: String? { switch self { case .expectedFile: String(localized: "Choose or drop a single known_hosts file.") case .expectedFolder: String(localized: "Choose or drop a single .ssh folder.") + case .unsupportedKnownHostsContent: String(localized: "The selected file does not contain Known Hosts records.") + case .sourceChanged: String(localized: "An imported private key changed or became unavailable. Scan again.") } } } diff --git a/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift b/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift index d15b98e8..17057db4 100644 --- a/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift +++ b/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift @@ -1,7 +1,83 @@ 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] = [] diff --git a/Frameworks/TermindRemoteInfrastructure/SSHImport/SSHImportReader.swift b/Frameworks/TermindRemoteInfrastructure/SSHImport/SSHImportReader.swift index bb575de5..113b14a4 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSHImport/SSHImportReader.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSHImport/SSHImportReader.swift @@ -1,8 +1,10 @@ +import CryptoKit import Foundation import TermindApplication import TermindDomain -/// One import session owns its secrets. Only metadata leaves this actor until commit. +/// One import session owns only source descriptors and public metadata. Private +/// key text is read inside scan, unlock, or commit and never becomes actor state. public actor SSHImportReader: SSHImportReading { private let username: String private let home: URL? @@ -12,164 +14,264 @@ public actor SSHImportReader: SSHImportReading { self.home = home } - private struct Material { - var text: String - var certificate: String + private struct KeySource { + let url: URL + let digest: Data } - private var materials: [UUID: Material] = [:] + + private struct KeyCandidate { + let url: URL + let path: String + let digest: Data + let encrypted: Bool + } + + private struct CertificateSource { + let path: String + let text: String + } + + private struct FilePayload { + let text: String + let digest: Data + } + + private var keySources: [UUID: KeySource] = [:] + private var certificateSources: [CertificateSource] = [] + private var accessScopes: [URL] = [] private var stableIDs: [String: UUID] = [:] - public func discard() { materials.removeAll() } + public func discard() { + keySources.removeAll() + certificateSources.removeAll() + accessScopes.removeAll() + } public func scan(root: URL, knownHostsOnly: Bool, grants: [URL] = []) throws -> [SSHImportItem] { + discard() let scopes = [root] + grants + accessScopes = scopes let acquired = scopes.filter { $0.startAccessingSecurityScopedResource() } defer { for url in acquired { url.stopAccessingSecurityScopedResource() } } - materials.removeAll() + let sourceType = try root.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) guard knownHostsOnly ? sourceType.isRegularFile == true : sourceType.isDirectory == true else { throw knownHostsOnly ? SSHImportSourceError.expectedFile : SSHImportSourceError.expectedFolder } if knownHostsOnly { - return stabilize(SSHImportParser.knownHosts(try read(root), source: root.path)) + let payload = try readPayload(root) + let kind = SSHImportParser.contentKind(payload.text) + guard kind == .knownHosts || containsOnlyCommentsAndWhitespace(payload.text) else { + throw SSHImportSourceError.unsupportedKnownHostsContent + } + return stabilize(SSHImportParser.knownHosts(payload.text, source: root.path)) } - let root = root.standardizedFileURL + + let standardizedRoot = root.standardizedFileURL let authorized = scopes.map { $0.resolvingSymlinksInPath().standardizedFileURL.path } func allowed(_ url: URL) -> Bool { let path = url.resolvingSymlinksInPath().standardizedFileURL.path return authorized.contains { path == $0 || path.hasPrefix($0 + "/") } } - var files: [String: String] = [:] + + var configurationFiles: [String: String] = [:] + var knownHostItems: [SSHImportItem] = [] + var keyCandidates: [String: KeyCandidate] = [:] + var contentKinds: [String: SSHImportContentKind] = [:] + var availablePaths: Set = [] var inaccessible: [String] = [] var fileCount = 0 var totalBytes = 0 let manager = FileManager.default + for scope in scopes { var directory: ObjCBool = false guard manager.fileExists(atPath: scope.path, isDirectory: &directory) else { continue } let urls: [URL] if directory.boolValue { let enumerator = manager.enumerator( - at: scope, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: []) + at: scope, + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey], + options: []) urls = enumerator?.allObjects.compactMap { $0 as? URL } ?? [] } else { urls = [scope] } for url in urls { - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue } guard allowed(url) else { inaccessible.append(url.path) continue } fileCount += 1 - guard fileCount <= 4096 else { throw CocoaError(.fileReadTooLarge) } + guard fileCount <= 4_096 else { throw CocoaError(.fileReadTooLarge) } + let fileSize = values?.fileSize ?? 0 + totalBytes += fileSize + guard fileSize <= 8 * 1_024 * 1_024, totalBytes <= 64 * 1_024 * 1_024 else { + throw CocoaError(.fileReadTooLarge) + } + let path = url.standardizedFileURL.path do { - let value = try read(url) - totalBytes += value.utf8.count - guard totalBytes <= 64 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) } - files[url.standardizedFileURL.path] = value + let payload = try readPayload(url) + let kind = SSHImportParser.contentKind(payload.text) + availablePaths.insert(path) + contentKinds[path] = kind + switch kind { + case .privateKey: + keyCandidates[path] = KeyCandidate( + url: url, path: path, digest: payload.digest, + encrypted: isEncrypted(payload.text)) + case .userCertificate: + certificateSources.append( + CertificateSource( + path: path, + text: payload.text.trimmingCharacters(in: .whitespacesAndNewlines))) + case .knownHosts: + knownHostItems += SSHImportParser.knownHosts(payload.text, source: path) + case .configuration: + configurationFiles[path] = payload.text + case .unknown: + break + } } catch { - inaccessible.append(url.path) + inaccessible.append(path) } } } + var parser = SSHConfigParser( - root: root, home: home ?? root.deletingLastPathComponent(), - username: username, files: files) - var items = parser.parse() - let knownPath = root.appendingPathComponent("known_hosts").path - if let known = files[knownPath] { items += SSHImportParser.knownHosts(known, source: knownPath) } + root: standardizedRoot, home: home ?? standardizedRoot.deletingLastPathComponent(), + username: username, files: configurationFiles, + entryPaths: configurationFiles.keys.sorted()) + var items = parser.parse() + knownHostItems let hosts = items.compactMap(\.host) - let referenced = Set(hosts.flatMap(\.identityPaths)) - let privateFiles = files.keys.filter { isPrivateKey(files[$0] ?? "") } - let paths = Set(privateFiles).union(referenced.filter { files[$0] != nil }) - for path in paths.sorted() { - let content = files[path] ?? "" - guard isPrivateKey(content) else { + let referencedKeys = Set(hosts.flatMap(\.identityPaths)) + + for path in Set(keyCandidates.keys).union(referencedKeys).sorted() { + guard let candidate = keyCandidates[path] else { + if availablePaths.contains(path), contentKinds[path] != .privateKey { + items.append( + SSHImportItem( + category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, + source: path, selected: false, + issue: String(localized: "No supported private key in this file."), unavailable: true)) + } + continue + } + if candidate.encrypted { + let id = stableID(path + "\n") + keySources[id] = KeySource(url: candidate.url, digest: candidate.digest) + items.append( + SSHImportItem( + id: id, category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, + source: path, + issue: String(localized: "Enter the key passphrase to validate this private key."), + requiresPassphrase: true)) + continue + } + do { + let material = try inspect(candidate, passphrase: nil) + items += makeKeyItems(candidate: candidate, material: material) + } catch { items.append( SSHImportItem( category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, source: path, selected: false, issue: String(localized: "No supported private key in this file."), unavailable: true)) - continue - } - let explicitCertificates = hosts.filter { $0.identityPaths.contains(path) }.flatMap(\.certificatePaths) - let certificatePaths = Set(explicitCertificates + [path + "-cert.pub"]) - var variants: [String: [String]] = [:] - for certificatePath in certificatePaths.sorted() { - if let certificate = files[certificatePath] { - variants[certificate, default: []].append(certificatePath) - } - } - if files[path + "-cert.pub"] == nil { variants[""] = [] } - for certificate in variants.keys.sorted() { - let stable = path + "\n" + certificate - let id = stableID(stable) - materials[id] = Material(text: content, certificate: certificate) - var item = SSHImportItem( - id: id, category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, source: path) - item.certificatePaths = variants[certificate] ?? [] - do { - item.key = try inspect(id: id, name: item.name, passphrase: nil) - } catch { - if isEncrypted(content) { - item.requiresPassphrase = true - item.issue = String(localized: "Enter the key passphrase to validate this private key.") - } else { - item.selected = false - item.unavailable = true - item.issue = String(localized: "No supported private key in this file.") - } - } - items.append(item) } } - let missing = hosts.filter { !$0.usesDefaultIdentityFiles }.flatMap(\.identityPaths).filter { files[$0] == nil } - let certificateMissing = hosts.flatMap(\.certificatePaths).filter { files[$0] == nil } - for path in Set(inaccessible).union(parser.externalPaths).union(missing).union(certificateMissing).sorted() { + + let missingKeys = hosts.filter { !$0.usesDefaultIdentityFiles }.flatMap(\.identityPaths).filter { + !availablePaths.contains($0) + } + let missingCertificates = hosts.flatMap(\.certificatePaths).filter { !availablePaths.contains($0) } + for path in Set(inaccessible).union(parser.externalPaths).union(missingKeys).union(missingCertificates).sorted() + { items.append( SSHImportItem( - category: path == knownPath ? .knownHosts : parser.externalPaths.contains(path) ? .hosts : .keys, + category: parser.externalPaths.contains(path) ? .hosts : .keys, name: URL(fileURLWithPath: path).lastPathComponent, source: path, issue: String(localized: "Authorize access to this referenced file, or deselect it."), needsAuthorization: path)) } + // Missing explicit references must also be visible on the affected host. for index in items.indices { guard let host = items[index].host else { continue } let absent = (host.identityPaths + host.certificatePaths).filter { - files[$0] == nil && !(host.usesDefaultIdentityFiles && host.identityPaths.contains($0)) + !availablePaths.contains($0) && !(host.usesDefaultIdentityFiles && host.identityPaths.contains($0)) } items[index].warnings += absent.map { String(localized: "Referenced file is unavailable:") + " " + $0 } } return stabilize(items) } - public func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef { - try inspect(id: id, name: name, passphrase: passphrase) + public func unlock(id: UUID, name: String, passphrase: String) throws -> [SSHImportItem] { + guard let source = keySources[id] else { throw CocoaError(.fileReadUnknown) } + return try withAccess { + let payload = try readPayload(source.url) + guard payload.digest == source.digest, SSHImportParser.contentKind(payload.text) == .privateKey else { + throw SSHImportSourceError.sourceChanged + } + let material = try SSHKeyMaterial.inspect(privateKey: payload.text, passphrase: passphrase) + let candidate = KeyCandidate( + url: source.url, path: source.url.standardizedFileURL.path, + digest: source.digest, encrypted: true) + keySources.removeValue(forKey: id) + return makeKeyItems(candidate: candidate, material: material, name: name, hasPassphrase: true) + } } public func credentials(for ids: Set) throws -> [UUID: StoredSSHKeyCredential] { - var result: [UUID: StoredSSHKeyCredential] = [:] - for id in ids { - guard let material = materials[id] else { throw CocoaError(.fileReadUnknown) } - result[id] = .software(SSHPrivateKeySecret(privateKey: material.text)) + try withAccess { + var result: [UUID: StoredSSHKeyCredential] = [:] + for id in ids.sorted(by: { $0.uuidString < $1.uuidString }) { + guard let source = keySources[id] else { throw CocoaError(.fileReadUnknown) } + let payload = try readPayload(source.url) + guard payload.digest == source.digest, SSHImportParser.contentKind(payload.text) == .privateKey else { + throw SSHImportSourceError.sourceChanged + } + result[id] = .software(SSHPrivateKeySecret(privateKey: payload.text)) + } + return result } - return result } - private func inspect(id: UUID, name: String, passphrase: String?) throws -> SSHKeyRef { - guard let input = materials[id] else { throw CocoaError(.fileReadUnknown) } - let material = try SSHKeyMaterial.inspect(privateKey: input.text, passphrase: passphrase) - if !input.certificate.isEmpty { - _ = try SSHUserCertificate.parse(input.certificate, matching: material.publicKey) + private func inspect(_ candidate: KeyCandidate, passphrase: String?) throws -> SSHKeyMaterial { + try withAccess { + let payload = try readPayload(candidate.url) + guard payload.digest == candidate.digest else { throw SSHImportSourceError.sourceChanged } + return try SSHKeyMaterial.inspect(privateKey: payload.text, passphrase: passphrase) + } + } + + private func makeKeyItems( + candidate: KeyCandidate, material: SSHKeyMaterial, name: String? = nil, hasPassphrase: Bool = false + ) -> [SSHImportItem] { + var variants: [String: [String]] = [:] + for certificate in certificateSources { + if (try? SSHUserCertificate.parse(certificate.text, matching: material.publicKey)) != nil { + variants[certificate.text, default: []].append(certificate.path) + } + } + // The private key remains a valid variant in its own right. Hosts with + // an explicit CertificateFile select the matching certificate path; + // hosts without one continue to use the bare key. + variants[""] = [] + return variants.keys.sorted().map { certificate in + let id = stableID(candidate.path + "\n" + certificate) + keySources[id] = KeySource(url: candidate.url, digest: candidate.digest) + let label = name ?? URL(fileURLWithPath: candidate.path).lastPathComponent + let key = SSHKeyRef( + id: id, label: label, algorithm: material.algorithm, + publicKey: material.publicKey, certificate: certificate, fingerprint: material.fingerprint, + credentialRef: "termind.key.\(id.uuidString)", storage: .iCloud, keySize: material.keySize, + hasPassphrase: hasPassphrase, createdAt: Date()) + return SSHImportItem( + id: id, category: .keys, name: label, source: candidate.path, + key: key, certificatePaths: variants[certificate] ?? []) } - return SSHKeyRef( - id: id, label: name, algorithm: material.algorithm, - publicKey: material.publicKey, certificate: input.certificate, fingerprint: material.fingerprint, - credentialRef: "termind.key.\(id.uuidString)", storage: .iCloud, keySize: material.keySize, - hasPassphrase: passphrase?.isEmpty == false, createdAt: Date()) } private func stableID(_ source: String) -> UUID { @@ -183,7 +285,7 @@ public actor SSHImportReader: SSHImportReading { var occurrences: [String: Int] = [:] return values.map { item in var item = item - if materials[item.id] == nil { + if keySources[item.id] == nil { let token = "\(item.category.rawValue):\(item.source):\(item.name)" let occurrence = occurrences[token, default: 0] occurrences[token] = occurrence + 1 @@ -193,12 +295,29 @@ public actor SSHImportReader: SSHImportReading { } } - private func read(_ url: URL) throws -> String { + private func withAccess(_ operation: () throws -> T) rethrows -> T { + let acquired = accessScopes.filter { $0.startAccessingSecurityScopedResource() } + defer { for url in acquired { url.stopAccessingSecurityScopedResource() } } + return try operation() + } + + private func readPayload(_ url: URL) throws -> FilePayload { let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) - guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else { + guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1_024 * 1_024 else { throw CocoaError(.fileReadTooLarge) } - return try String(contentsOf: url, encoding: .utf8) + let data = try Data(contentsOf: url, options: [.mappedIfSafe]) + guard let text = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileReadInapplicableStringEncoding) + } + return FilePayload(text: text, digest: Data(SHA256.hash(data: data))) + } + + private func containsOnlyCommentsAndWhitespace(_ text: String) -> Bool { + text.components(separatedBy: .newlines).allSatisfy { + let line = $0.trimmingCharacters(in: .whitespacesAndNewlines) + return line.isEmpty || line.hasPrefix("#") + } } private func isEncrypted(_ text: String) -> Bool { @@ -215,12 +334,4 @@ public actor SSHImportReader: SSHImportReading { let cipher = String(data: data.subdata(in: offset + 4.. Bool { - [ - "-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----", - "-----BEGIN PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----BEGIN DSA PRIVATE KEY-----", - ].contains { text.hasPrefix($0) } - } - } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index fffc71f5..a6e89c9a 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -70,11 +70,13 @@ Known Hosts is independent of Host records: editing or deleting a Host does not change trust. Plain and OpenSSH `|1|` hashed endpoints share one matcher; hashed records keep their wire `hostname`, and their separate port field is ignored. -SSH imports stage selectable public metadata in a window-owned model while the -actor reader holds private keys until commit/cancel. Picking and drag-and-drop use -the same scoped validation; config parsing never executes commands. Commit writes -new credentials before one Vault save, cleans them up on failure, never overwrites -existing records, and never clears an existing trust mismatch. +SSH imports recursively classify files by content and stage only selectable public +metadata in a window-owned model. The actor reader retains scoped source descriptors +and content digests, never private-key text or passphrases; it rereads selected keys +transiently at unlock/commit and fails closed if a source changed. Picking and +drag-and-drop use the same scoped validation; config parsing never executes commands. +Commit writes new credentials before one Vault save, cleans them up on failure, never +overwrites existing records, and never clears an existing trust mismatch. App appearance is a global UserDefaults preference applied by the macOS app delegate through `NSApplication.appearance`; following System clears the override. diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index ff3cf8ca..bf0476e3 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -345,7 +345,9 @@ private actor MobileImportFixture: SSHImportReading { let items: [SSHImportItem] init(items: [SSHImportItem]) { self.items = items } func scan(root: URL, knownHostsOnly: Bool, grants: [URL]) -> [SSHImportItem] { items } - func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef { throw CocoaError(.coderInvalidValue) } + func unlock(id: UUID, name: String, passphrase: String) throws -> [SSHImportItem] { + throw CocoaError(.coderInvalidValue) + } func credentials(for ids: Set) -> [UUID: StoredSSHKeyCredential] { [:] } func discard() {} } diff --git a/TFM/Features/SSHImport/SSHImportView.swift b/TFM/Features/SSHImport/SSHImportView.swift index cb48cb89..43cd3c69 100644 --- a/TFM/Features/SSHImport/SSHImportView.swift +++ b/TFM/Features/SSHImport/SSHImportView.swift @@ -22,6 +22,7 @@ struct SSHImportView: View { } private struct SSHImportContent: View { + @Environment(AppModel.self) private var app @Bindable var model: SSHImportModel let knownHostsOnly: Bool let close: () -> Void @@ -49,7 +50,25 @@ private struct SSHImportContent: View { sourcePicker } if !knownHostsOnly { + Section("Hosts") { + Picker("Group", selection: $model.hostGroupID) { + Text("None").tag(HostGroup.ID?.none) + ForEach(app.hostLibrary.groups) { group in + Text(group.name).tag(HostGroup.ID?.some(group.id)) + } + } + LabeledContent("Color") { + ColorTagPicker(selection: $model.hostColor, layout: .compact) + } + VStack(alignment: .leading, spacing: Metrics.Space.s) { + Text("Tags") + TagField(tags: $model.hostTags, suggestions: app.hostLibrary.allTags) + } + } Section("Keys") { + LabeledContent("Color") { + ColorTagPicker(selection: $model.keyColor, layout: .compact) + } Picker("Store in", selection: $model.storage) { Label("iCloud", systemImage: "icloud.fill").tag(KeyStorage.iCloud) Label("This Mac", systemImage: "lock.laptopcomputer").tag(KeyStorage.thisDevice) @@ -63,7 +82,13 @@ private struct SSHImportContent: View { ForEach(SSHImportCategory.allCases) { category in if model.items.contains(where: { $0.category == category }) { Section { - categorySection(category) + categoryHeader(category) + if model.expanded.contains(category) { + ForEach(model.items.filter { $0.category == category }) { item in + itemRow(binding(for: item)) + .id(item.id) + } + } } } } @@ -189,27 +214,29 @@ private struct SSHImportContent: View { .disabled(total == 0) } - private func categorySection(_ category: SSHImportCategory) -> some View { - VStack(spacing: Metrics.Space.m) { - HStack { - Button { - if model.expanded.contains(category) { - model.expanded.remove(category) - } else { - model.expanded.insert(category) - } - } label: { - Image(systemName: model.expanded.contains(category) ? "chevron.down" : "chevron.right") - } - .buttonStyle(.plain) - .accessibilityLabel(category.title) - selectionButton(category: category, title: category.title) - }.font(.headline) - if model.expanded.contains(category) { - ForEach($model.items) { $item in - if item.category == category { itemRow($item) } + private func categoryHeader(_ category: SSHImportCategory) -> some View { + HStack { + Button { + if model.expanded.contains(category) { + model.expanded.remove(category) + } else { + model.expanded.insert(category) } + } label: { + Image(systemName: model.expanded.contains(category) ? "chevron.down" : "chevron.right") } + .buttonStyle(.plain) + .accessibilityLabel(category.title) + selectionButton(category: category, title: category.title) + }.font(.headline) + } + + private func binding(for item: SSHImportItem) -> Binding { + Binding { + model.items.first(where: { $0.id == item.id }) ?? item + } set: { updated in + guard let index = model.items.firstIndex(where: { $0.id == item.id }) else { return } + model.items[index] = updated } } @@ -273,8 +300,13 @@ private struct SSHImportContent: View { } if item.requiresPassphrase { SecureField( - "Passphrase", text: Binding(get: { passphrases[item.id] ?? "" }, set: { passphrases[item.id] = $0 }) + "Passphrase", + text: Binding(get: { passphrases[item.id] ?? "" }, set: { passphrases[item.id] = $0 }), + prompt: Text("Passphrase") ) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, Metrics.Space.m) Button("Unlock Key") { let passphrase = passphrases.removeValue(forKey: item.id) ?? "" Task { await model.unlock(item.id, passphrase: passphrase) } @@ -299,7 +331,6 @@ private struct SSHImportContent: View { if let issue = model.problem(for: item, includeWarningReminder: false), item.existingID == nil { Text(issue).font(.caption).foregroundStyle(item.unavailable ? Color.secondary : Color.orange) } - Divider() } } } diff --git a/TFMTests/SSHImportTests.swift b/TFMTests/SSHImportTests.swift index 84bba884..96b4ba4f 100644 --- a/TFMTests/SSHImportTests.swift +++ b/TFMTests/SSHImportTests.swift @@ -104,6 +104,86 @@ struct SSHImportTests { #expect(rows.compactMap(\.knownHost).contains { $0.matches(hostname: "other.example", port: 22) }) } + @Test + func contentClassifierDoesNotUseFilenames() throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + #expect(SSHImportParser.contentKind(material.privateKey) == .privateKey) + #expect(SSHImportParser.contentKind("example.invalid \(material.publicKey)") == .knownHosts) + #expect(SSHImportParser.contentKind("Host app\n HostName app.invalid") == .configuration) + #expect(SSHImportParser.contentKind("ordinary notes") == .unknown) + } + + @Test + func folderRecursivelyFindsRenamedContentAndDoesNotReparseIncludes() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let pieces = url.appendingPathComponent("pieces", isDirectory: true) + let secrets = url.appendingPathComponent("secrets", isDirectory: true) + try FileManager.default.createDirectory(at: pieces, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + try "Include pieces/*".write( + to: url.appendingPathComponent("entry.anything"), atomically: true, encoding: .utf8) + try "Host app\n HostName app.invalid\n User deploy\n IdentityFile secrets/credential.data".write( + to: pieces.appendingPathComponent("fragment.notes"), atomically: true, encoding: .utf8) + try material.privateKey.write( + to: secrets.appendingPathComponent("credential.data"), atomically: true, encoding: .utf8) + try "app.invalid \(material.publicKey)".write( + to: pieces.appendingPathComponent("trust.random"), atomically: true, encoding: .utf8) + + let rows = try await SSHImportReader().scan(root: url, knownHostsOnly: false) + + #expect(rows.compactMap(\.host).map(\.alias) == ["app"]) + #expect(rows.compactMap(\.key).count == 1) + #expect(rows.compactMap(\.knownHost).count == 1) + } + + @Test + func standaloneKnownHostsRejectsOtherSSHContentButAcceptsComments() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let file = url.appendingPathComponent("arbitrary-name") + let reader = SSHImportReader() + try "Host app\nHostName app.invalid".write(to: file, atomically: true, encoding: .utf8) + await #expect(throws: SSHImportSourceError.unsupportedKnownHostsContent) { + _ = try await reader.scan(root: file, knownHostsOnly: true) + } + try "# intentionally empty\n".write(to: file, atomically: true, encoding: .utf8) + #expect(try await reader.scan(root: file, knownHostsOnly: true).isEmpty) + } + + @Test + func privateKeyIsRereadAndDigestCheckedAtCommitBoundary() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let first = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + let second = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + let file = url.appendingPathComponent("credential") + try first.privateKey.write(to: file, atomically: true, encoding: .utf8) + let reader = SSHImportReader() + let rows = try await reader.scan(root: url, knownHostsOnly: false) + let id = try #require(rows.compactMap(\.key).first?.id) + + try second.privateKey.write(to: file, atomically: true, encoding: .utf8) + + await #expect(throws: SSHImportSourceError.sourceChanged) { + _ = try await reader.credentials(for: [id]) + } + } + + @Test + func importPreviewKeepsItemsAsDirectFormRows() throws { + let sourceRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + let source = try String( + contentsOf: sourceRoot.appendingPathComponent("TFM/Features/SSHImport/SSHImportView.swift"), + encoding: .utf8) + + #expect(source.contains("Form {")) + #expect(source.contains(".formStyle(.grouped)")) + #expect(source.contains("ForEach(model.items.filter")) + #expect(!source.contains("private func categorySection")) + } + @Test func droppedSourcesUseTheSameTypeValidationAsTheFilePicker() async throws { let url = try root() @@ -163,8 +243,13 @@ struct SSHImportTests { to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) try "prod.example \(material.publicKey)".write( to: url.appendingPathComponent("known_hosts"), atomically: true, encoding: .utf8) - let store = HostLibraryModel() + let group = HostGroup(name: "Imported") + let store = HostLibraryModel(groups: [group]) let model = SSHImportModel(library: store, reader: SSHImportReader()) + model.hostGroupID = group.id + model.hostColor = .teal + model.hostTags = ["production", "ssh"] + model.keyColor = .purple await model.scan(url, knownHostsOnly: false) #expect(model.selectedItems().count == 3) #expect(store.hosts.isEmpty && store.keys.isEmpty) @@ -174,6 +259,10 @@ struct SSHImportTests { #expect(store.hosts.count == 1 && store.keys.count == 1 && store.knownHosts.count == 1) let identity = try #require(store.identities.first) #expect(identity.username == "deploy" && identity.keyID == store.keys.first?.id) + #expect(store.hosts.first?.groupID == group.id) + #expect(store.hosts.first?.colorTag == .teal) + #expect(store.hosts.first?.tags == ["production", "ssh"]) + #expect(store.keys.first?.colorTag == .purple) #expect(store.keyAvailability(for: try #require(store.keys.first)) == .available) let again = SSHImportModel(library: store, reader: SSHImportReader()) await again.scan(url, knownHostsOnly: false) diff --git a/TFMTests/SSHUserCertificateTests.swift b/TFMTests/SSHUserCertificateTests.swift index 0216d416..e1110944 100644 --- a/TFMTests/SSHUserCertificateTests.swift +++ b/TFMTests/SSHUserCertificateTests.swift @@ -80,9 +80,9 @@ struct SSHUserCertificateTests { let certificate = try makeEd25519Certificate( publicKey: material.publicKey, type: 1, validAfter: 0, validBefore: UInt64.max) try material.privateKey.write(to: root.appendingPathComponent("deploy"), atomically: true, encoding: .utf8) - try certificate.write(to: root.appendingPathComponent("user-cert.pub"), atomically: true, encoding: .utf8) + try certificate.write(to: root.appendingPathComponent("identity.paper"), atomically: true, encoding: .utf8) try - "Host certified\nIdentityFile ~/.ssh/deploy\nCertificateFile ~/.ssh/user-cert.pub\nHost plain\nIdentityFile ~/.ssh/deploy" + "Host certified\nIdentityFile ~/.ssh/deploy\nCertificateFile ~/.ssh/identity.paper\nHost plain\nIdentityFile ~/.ssh/deploy" .write( to: root.appendingPathComponent("config"), atomically: true, encoding: .utf8) let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) @@ -94,6 +94,30 @@ struct SSHUserCertificateTests { #expect(model.canImport) } + @Test + @MainActor + func encryptedKeyMatchesCertificateByContentAfterUnlock() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("SSHCertificateImport-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "password") + let certificate = try makeEd25519Certificate( + publicKey: material.publicKey, type: 1, validAfter: 0, validBefore: UInt64.max) + try material.privateKey.write(to: root.appendingPathComponent("opaque.one"), atomically: true, encoding: .utf8) + try certificate.write(to: root.appendingPathComponent("opaque.two"), atomically: true, encoding: .utf8) + try "Host certified\nIdentityFile ~/.ssh/opaque.one\nCertificateFile ~/.ssh/opaque.two".write( + to: root.appendingPathComponent("opaque.three"), atomically: true, encoding: .utf8) + let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) + + await model.scan(root, knownHostsOnly: false) + let lockedID = try #require(model.items.first(where: \.requiresPassphrase)?.id) + await model.unlock(lockedID, passphrase: "password") + + let host = try #require(model.items.first(where: { $0.host != nil })) + #expect(model.chosenKey(for: host)?.certificate == certificate) + #expect(model.canImport) + } + private func makeEd25519Certificate( publicKey: String, type: UInt32, validAfter: UInt64, validBefore: UInt64 ) throws -> String {