diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index e1a6ef98..52151d14 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -38,10 +38,20 @@ including that no type AgentKit owns is redefined here. - The shell always starts with `Vault` and `Assistant`; Settings is the final Navigator page after the dynamic SSH/SFTP Workspaces. - Vault owns its navigation column, including quick-connect Recents, and routes - for Hosts, Keychain, Snippets, Welcome, and Host Editor. These routes never + for Hosts, Keychain, Snippets, Known Hosts, Welcome, and Host Editor. These routes never enter the Workspace deck. -- Known Hosts management belongs to the Host Editor draft for both new and - existing Hosts. Context-panel Host details are read-only. +- Known Hosts is an independent Vault card manager below Snippets. Host edits and + deletion never move or remove trust records. Plain endpoints and OpenSSH `|1|` + hashed endpoints use one matcher across all SSH callers. Hashed tokens retain + their original `hostname` wire field; their `port` field is ignored because the + port is part of the hashed input. No Host record is required to retain trust. +- SSH folder imports stage selectable public metadata in a window-owned model; + their actor-owned reader retains private keys until commit or cancellation. + File picking and native drag-and-drop feed the same source validation and preview. + File access is scoped to explicitly selected directories/files. Config parsing + never executes commands. New credentials are written before a single Vault + snapshot save and removed on failure. Existing records are never overwritten. + Imported trust does not clear existing mismatch warnings. - Assistant owns its conversation list through `AssistantLibraryModel`; conversations are not panes and do not participate in Workspace drag, split, close, or reconnect lifecycles. @@ -73,7 +83,7 @@ an honest unknown outcome. Duration is never inferred from client receive time, because SSH buffering can deliver start and completion events together. Feature views receive aggregate-facing Application Models rather than a generic -store API: `HostLibraryModel`, `CredentialLibraryModel`, `SnippetLibraryModel`, +store API: `HostLibraryModel`, `CredentialLibraryModel`, `KnownHostsLibraryModel`, `SnippetLibraryModel`, `ForwardingLibraryModel`, `TransferLibraryModel`, `AIConfigurationModel`, `SkillLibraryModel`, `CommandHistoryModel`, and `ToolAuditModel`. Aggregates that share one persisted vault snapshot still expose separate intent surfaces, so a feature cannot gain diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index 1b899b4d..18aa26ed 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -52,6 +52,7 @@ final class AppModel { private let hosts: HostLibraryModel let proAccess: ProAccessController var hostLibrary: HostLibraryModel { hosts } + let knownHosts: KnownHostsLibraryModel let credentials: CredentialLibraryModel let snippets: SnippetLibraryModel let forwardings: ForwardingLibraryModel @@ -112,6 +113,10 @@ final class AppModel { /// conversation, and every run re-arms the hook that would do it. private var titledConversationIDs = Set() + func makeSSHImportModel() -> SSHImportModel { + SSHImportModel(library: hosts, reader: SSHImportReader()) + } + init( hosts: HostLibraryModel, ai: AIConfigurationModel = AIConfigurationModel(), skills: SkillLibraryModel = SkillLibraryModel(), @@ -140,6 +145,7 @@ final class AppModel { self.hosts = hosts self.proAccess = proAccess self.onboarding = onboarding + knownHosts = KnownHostsLibraryModel(library: hosts) credentials = CredentialLibraryModel(library: hosts) snippets = SnippetLibraryModel(library: hosts) forwardings = ForwardingLibraryModel(library: hosts) diff --git a/Termind/Application/FeatureModels/HostLibraryModel.swift b/Termind/Application/FeatureModels/HostLibraryModel.swift index 035858f6..13fbd95e 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel.swift @@ -250,7 +250,7 @@ final class HostLibraryModel { /// hostname plus port. Newest keys are shown first in the host's details. func knownHosts(for host: Host) -> [KnownHostEntry] { knownHosts - .filter { $0.hostname == host.host && $0.port == host.port } + .filter { $0.matches(hostname: host.host, port: host.port) } .sorted { if $0.addedAt != $1.addedAt { return $0.addedAt > $1.addedAt } if $0.fingerprint != $1.fingerprint { return $0.fingerprint < $1.fingerprint } @@ -631,8 +631,7 @@ final class HostLibraryModel { @discardableResult func upsert( _ host: Host, - replacingPortForwardings rules: [PortForwardingRule], - replacingKnownHosts knownHostEntries: [KnownHostEntry]? = nil + replacingPortForwardings rules: [PortForwardingRule] ) -> Bool { guard validatedRoute(for: host) != nil else { storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.") @@ -654,34 +653,8 @@ final class HostLibraryModel { return false } - let normalizedKnownHosts = knownHostEntries.map { entries in - entries.map { entry in - var normalized = entry - normalized.hostname = host.host - normalized.port = host.port - normalized.fingerprint = entry.fingerprint.trimmingCharacters( - in: .whitespacesAndNewlines - ) - return normalized - } - } - if let normalizedKnownHosts { - let identities = normalizedKnownHosts.map(\.identity) - guard - normalizedKnownHosts.allSatisfy({ - !$0.fingerprint.isEmpty - }), - Set(normalizedKnownHosts.map(\.id)).count == normalizedKnownHosts.count, - Set(identities).count == identities.count - else { - storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.") - return false - } - } - let oldHosts = hosts let oldRules = portForwardings - let oldKnownHosts = knownHosts let existingHost = self.host(host.id) let existingRules = portForwardings(for: host.id) let nextByID = Dictionary(uniqueKeysWithValues: normalizedRules.map { ($0.id, $0) }) @@ -704,20 +677,9 @@ final class HostLibraryModel { } portForwardings.removeAll { $0.hostID == host.id } portForwardings.append(contentsOf: normalizedRules) - if let normalizedKnownHosts { - if let existingHost { - knownHosts.removeAll { - $0.hostname == existingHost.host && $0.port == existingHost.port - } - } - knownHosts.removeAll { $0.hostname == host.host && $0.port == host.port } - knownHosts.append(contentsOf: normalizedKnownHosts) - } - guard persistCore() else { hosts = oldHosts portForwardings = oldRules - knownHosts = oldKnownHosts return false } return true @@ -812,18 +774,16 @@ final class HostLibraryModel { /// first connection, which is the whole point — it is the escape hatch for a /// server that was legitimately rekeyed. func forget(knownHost id: KnownHostEntry.ID) { - knownHosts.removeAll { $0.id == id } - persistCore() + deleteKnownHosts([id]) } func forgetKnownHosts(for host: Host) { - knownHosts.removeAll { $0.hostname == host.host && $0.port == host.port } - persistCore() + deleteKnownHosts(Set(knownHosts(for: host).map(\.id))) } func trust(_ entry: KnownHostEntry) { let endpointMatches: (KnownHostEntry) -> Bool = { - $0.hostname == entry.hostname && $0.port == entry.port + $0.matches(hostname: entry.hostname, port: entry.port) } for index in knownHosts.indices where endpointMatches(knownHosts[index]) { knownHosts[index].isMismatched = false @@ -847,7 +807,7 @@ final class HostLibraryModel { func markKnownHostMismatched(hostname: String, port: Int) { var changed = false for index in knownHosts.indices - where knownHosts[index].hostname == hostname && knownHosts[index].port == port { + where knownHosts[index].matches(hostname: hostname, port: port) { knownHosts[index].isMismatched = true changed = true } @@ -1514,6 +1474,111 @@ final class HostLibraryModel { persistCore() } + @discardableResult + func saveKnownHost(_ entry: KnownHostEntry) -> Bool { + var entry = entry + entry.hostname = entry.hostname.trimmingCharacters(in: .whitespacesAndNewlines) + entry.fingerprint = entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) + guard entry.isValid, + !knownHosts.contains(where: { + $0.id != entry.id && $0.hostname == entry.hostname && $0.port == entry.port + && $0.identity == entry.identity + }) + else { + storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.") + return false + } + let previous = knownHosts + if let index = knownHosts.firstIndex(where: { $0.id == entry.id }) { + // Editing the address or key explicitly creates a new trust decision. + if knownHosts[index].identity != entry.identity || knownHosts[index].hostname != entry.hostname + || knownHosts[index].port != entry.port + { + entry.addedAt = Date() + entry.isMismatched = false + } + knownHosts[index] = entry + } else { + entry.addedAt = Date() + knownHosts.append(entry) + } + guard persistCore() else { + knownHosts = previous + return false + } + return true + } + + @discardableResult + func deleteKnownHosts(_ ids: Set) -> Bool { + let previous = knownHosts + knownHosts.removeAll { ids.contains($0.id) } + guard persistCore() else { + knownHosts = previous + return false + } + return true + } + + /// New records only. Existing credentials and host configurations are never overwritten. + func commitSSHImport(_ batch: SSHImportBatch) throws { + let oldHosts = hosts + let oldIdentities = identities + let oldKeys = keys + let oldKnownHosts = knownHosts + guard Set(batch.hosts.map(\.id)).isDisjoint(with: hosts.map(\.id)), + Set(batch.keys.map(\.id)).isDisjoint(with: keys.map(\.id)), + Set(batch.identities.map(\.id)).isDisjoint(with: identities.map(\.id)), + batch.knownHosts.allSatisfy(\.isValid) + else { throw CocoaError(.validationMultipleErrors) } + var written: [SSHKeyRef] = [] + do { + for key in batch.keys { + guard let secret = batch.credentials[key.id] else { + throw CocoaError(.validationMissingMandatoryProperty) + } + try keychain.setKeyCredential(secret, for: key.credentialRef, storage: key.storage) + written.append(key) + } + for var key in batch.keys { + key.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1 + keys.append(key) + } + for var identity in batch.identities { + identity.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1 + identities.append(identity) + } + for var host in batch.hosts { + host.sortIndex = nextSortIndex(in: nil) + hosts.append(host) + } + guard batch.hosts.allSatisfy({ validatedRoute(for: $0) != nil }) else { + throw CocoaError(.validationMultipleErrors) + } + knownHosts.append(contentsOf: batch.knownHosts) + guard persistCore() else { + throw TermindSSHError.library(storageError ?? String(localized: "Import failed.")) + } + } catch { + hosts = oldHosts + identities = oldIdentities + keys = oldKeys + knownHosts = oldKnownHosts + var cleanupFailed = false + for key in written { + do { try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) } catch { + cleanupFailed = true + } + } + if cleanupFailed { + storageError = String( + localized: "Import failed. Some newly stored keys could not be removed from Keychain.") + throw TermindSSHError.library(storageError!) + } + throw error + } + } + private func nextSortIndex(in group: HostGroup.ID?) -> Double { (hosts(in: group).last?.sortIndex ?? 0) + 1 } diff --git a/Termind/Application/FeatureModels/KnownHostsLibraryModel.swift b/Termind/Application/FeatureModels/KnownHostsLibraryModel.swift new file mode 100644 index 00000000..7aeea02c --- /dev/null +++ b/Termind/Application/FeatureModels/KnownHostsLibraryModel.swift @@ -0,0 +1,19 @@ +import Foundation + +@Observable +final class KnownHostsLibraryModel { + private let library: HostLibraryModel + init(library: HostLibraryModel) { self.library = library } + var entries: [KnownHostEntry] { library.knownHosts.sorted { $0.addedAt > $1.addedAt } } + var storageError: String? { library.storageError } + func entry(_ id: UUID) -> KnownHostEntry? { entries.first { $0.id == id } } + func search(_ query: String) -> [KnownHostEntry] { + entries.filter { + query.isEmpty || $0.displayAddress.localizedCaseInsensitiveContains(query) + || $0.fingerprint.localizedCaseInsensitiveContains(query) + || $0.algorithm.rawValue.localizedCaseInsensitiveContains(query) + } + } + func save(_ entry: KnownHostEntry) -> Bool { library.saveKnownHost(entry) } + func delete(_ ids: Set) -> Bool { library.deleteKnownHosts(ids) } +} diff --git a/Termind/Application/FeatureModels/SSHImportModel.swift b/Termind/Application/FeatureModels/SSHImportModel.swift new file mode 100644 index 00000000..44669db9 --- /dev/null +++ b/Termind/Application/FeatureModels/SSHImportModel.swift @@ -0,0 +1,400 @@ +import Foundation + +@Observable +final class SSHImportModel { + var items: [SSHImportItem] = [] + var expanded = Set(SSHImportCategory.allCases) + var storage: KeyStorage = .iCloud + private(set) var isWorking = false + private(set) var error: String? + private(set) var completed = false + private(set) var importedCount = 0 + private let library: HostLibraryModel + private let reader: any SSHImportReading + private var root: URL? + private var grants: [URL] = [] + var sourcePath: String? { root?.path } + private var knownHostsOnly = false + private var jumpIDs: [String: UUID] = [:] + + init(library: HostLibraryModel, reader: any SSHImportReading) { + self.library = library + self.reader = reader + } + + func scan(_ url: URL, knownHostsOnly: Bool) async { + guard !isWorking else { return } + grants = [] + items = [] + completed = false + root = url + self.knownHostsOnly = knownHostsOnly + await reload() + } + + func authorize(_ url: URL) async { + grants.append(url) + await reload() + } + + private func reload() async { + guard let root else { return } + isWorking = true + error = nil + defer { isWorking = false } + do { + let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + items = try await reader.scan(root: root, knownHostsOnly: knownHostsOnly, grants: grants) + addJumpDestinations() + for index in items.indices { + if let old = previous[items[index].id] { + items[index].selected = old.selected + items[index].acceptedWarnings = old.acceptedWarnings + items[index].preferredKeyID = old.preferredKeyID + if items[index].host != nil { items[index].name = old.name } + } + } + refreshDuplicates() + } catch { + self.error = + (error as? SSHImportSourceError)?.localizedDescription + ?? String(localized: "The SSH files could not be read.") + } + } + + private func addJumpDestinations() { + let originalIDs = items.filter { $0.host != nil }.map(\.id) + let jumps = items.compactMap(\.host).flatMap(\.jumps) + for jump in jumps where !items.contains(where: { $0.host?.alias == jump }) { + let userParts = jump.split(separator: "@", maxSplits: 1).map(String.init) + let address = userParts.last ?? jump + let host: String + let port: Int? + if address.hasPrefix("["), let endpoint = SSHImportParser.endpoint(address) { + (host, port) = endpoint + } else if address.filter({ $0 == ":" }).count == 1 { + let parts = address.split(separator: ":").map(String.init) + host = parts.first ?? address + port = parts.count == 2 ? Int(parts[1]) ?? 0 : 0 + } else { + host = address + port = nil + } + let saved = library.hosts.first { $0.name == host } + let identity = saved?.identityID.flatMap(library.identity) + let base = items.first { $0.host?.alias == host }?.host + let value = SSHImportedHost( + alias: jump, hostname: base?.hostname ?? saved?.host ?? host, + port: port ?? base?.port ?? saved?.port ?? 22, + username: userParts.count == 2 ? userParts[0] : base?.username ?? identity?.username ?? NSUserName(), + identityPaths: base?.identityPaths ?? [], existingKeyID: identity?.keyID) + let id = jumpIDs[jump] ?? UUID() + jumpIDs[jump] = id + items.append(SSHImportItem(id: id, category: .hosts, name: jump, source: "ProxyJump", host: value)) + } + // A multi-hop route must not rewrite the standalone configuration of its intermediate aliases. + for id in originalIDs { + guard let index = items.firstIndex(where: { $0.id == id }), + let chain = items[index].host?.jumps, chain.count > 1 + else { continue } + var previous = chain[0] + for alias in chain.dropFirst() { + guard let base = items.first(where: { $0.host?.alias == alias }), var host = base.host else { continue } + let routeAlias = previous + " → " + alias + if !items.contains(where: { $0.host?.alias == routeAlias }) { + host.alias = routeAlias + host.jumps = [previous] + let id = jumpIDs[routeAlias] ?? UUID() + jumpIDs[routeAlias] = id + items.append( + SSHImportItem( + id: id, category: .hosts, name: routeAlias, + source: items[index].source, warnings: base.warnings, host: host)) + } + previous = routeAlias + } + items[index].host?.jumps = [previous] + } + } + + func refreshDuplicates() { + for index in items.indices { + guard !items[index].unavailable else { continue } + if let key = items[index].key { + if let existing = library.keys.first(where: { + $0.fingerprint == key.fingerprint && $0.certificate == key.certificate + && library.keyAvailability(for: $0) == .available + }) { + items[index].existingID = existing.id + items[index].selected = false + } else if let duplicate = items[.. Bool = { + $0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port) + && $0.identity == entry.identity + } + if let existing = library.knownHosts.first(where: same) { + items[index].existingID = existing.id + items[index].selected = false + } else if let previous = items[.. Bool { !item.unavailable && item.existingID == nil } + func selectedItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] { + items.filter { (category == nil || $0.category == category) && selectable($0) && $0.selected } + } + func availableItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] { + items.filter { (category == nil || $0.category == category) && selectable($0) } + } + 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 + } + } + + func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] { + guard let host = item.host else { return [] } + if let id = host.existingKeyID, let key = library.key(id) { return [key] } + var seen: Set = [] + return host.identityPaths.flatMap { path in + items.filter { + guard $0.source == path && $0.category == .keys else { return false } + if host.certificatePaths.isEmpty { + return $0.certificatePaths.isEmpty || $0.certificatePaths.contains(path + "-cert.pub") + } + return !$0.certificatePaths.isEmpty && !Set($0.certificatePaths).isDisjoint(with: host.certificatePaths) + }.compactMap { candidate -> SSHKeyRef? in + if let id = candidate.existingID { + if let key = library.key(id) { return key } + if let other = items.first(where: { $0.id == id && $0.selected }) { return other.key } + return nil + } + return candidate.selected ? candidate.key : nil + } + }.filter { seen.insert($0.id).inserted } + } + + func chosenKey(for item: SSHImportItem) -> SSHKeyRef? { + let options = keyOptions(for: item) + return options.first { $0.id == item.preferredKeyID } ?? options.first + } + + private func identicalHost(_ item: SSHImportItem) -> Host? { + func matches(_ row: SSHImportItem, _ saved: Host, visited: Set) -> Bool { + guard !visited.contains(saved.id), visited.count < 8, let candidate = row.host, + saved.name == row.name && saved.host == candidate.hostname && saved.port == candidate.port, + let identity = saved.identityID.flatMap(library.identity), identity.username == candidate.username, + identity.keyID == chosenKey(for: row)?.id + else { return false } + if candidate.jumps.isEmpty { return saved.jumpHostID == nil } + guard candidate.jumps.count == 1, let alias = candidate.jumps.first, + let jump = saved.jumpHostID.flatMap(library.host) + else { return false } + if let next = items.first(where: { $0.host?.alias == alias }) { + return matches(next, jump, visited: visited.union([saved.id])) + } + return jump.name == alias + } + return library.hosts.first { matches(item, $0, visited: []) } + } + + func warnings(for item: SSHImportItem) -> [String] { + var warnings = item.warnings + if let entry = item.knownHost, + library.knownHosts.contains(where: { + let sameAddress = + $0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port) + || (!entry.isHashed && $0.matches(hostname: entry.hostname, port: entry.port)) + || (!$0.isHashed && entry.matches(hostname: $0.hostname, port: $0.port)) + return sameAddress && $0.identity != entry.identity + }) + { + warnings.append( + String(localized: "This address already has a different trusted fingerprint. Confirm adding this key.")) + } + return warnings + } + + func hasNameConflict(_ item: SSHImportItem) -> Bool { + guard item.host != nil else { return false } + return (library.hosts.contains { $0.name == item.name } && identicalHost(item) == nil) + || selectedItems(.hosts).contains { $0.id != item.id && $0.name == item.name } + } + + func problem(for item: SSHImportItem, includeWarningReminder: Bool = true) -> String? { + if !item.issue.isEmpty { return item.issue } + if item.requiresPassphrase { + return String(localized: "Enter the key passphrase to validate this private key.") + } + if includeWarningReminder && !warnings(for: item).isEmpty && !item.acceptedWarnings { + return String(localized: "Review and accept the import warnings.") + } + if let host = item.host { + guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !host.hostname.isEmpty, (1...65_535).contains(host.port) + else { return String(localized: "Invalid SSH record.") } + if hasNameConflict(item) { + return String(localized: "A different host already uses this name. Rename this import or deselect it.") + } + for jump in host.jumps { + if !library.hosts.contains(where: { $0.name == jump }) + && !items.contains(where: { $0.host?.alias == jump && ($0.selected || $0.existingID != nil) }) + { + return String(localized: "Select the required jump host or deselect this host.") + } + } + } + return nil + } + + var canImport: Bool { + !isWorking && !completed && !selectedItems().isEmpty + && selectedItems().allSatisfy { problem(for: $0) == nil } + && (try? makeBatch()) != nil + } + + func unlock(_ id: UUID, passphrase: String) async { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + isWorking = true + 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 = "" + refreshDuplicates() + } catch { + items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.") + } + } + + func commit() async { + refreshDuplicates() + guard canImport else { return } + isWorking = true + error = nil + defer { isWorking = false } + do { + var batch = try makeBatch() + batch.credentials = try await reader.credentials(for: Set(batch.keys.map(\.id))) + // Revalidate after awaiting the reader, because another window or sync can write meanwhile. + refreshDuplicates() + let refreshed = try makeBatch() + guard Set(refreshed.keys.map(\.id)) == Set(batch.keys.map(\.id)), + selectedItems().allSatisfy({ problem(for: $0) == nil }) + else { + throw CocoaError(.validationMultipleErrors) + } + batch.hosts = refreshed.hosts + batch.identities = refreshed.identities + batch.knownHosts = refreshed.knownHosts + try library.commitSSHImport(batch) + importedCount = batch.keys.count + batch.hosts.count + batch.knownHosts.count + completed = true + await reader.discard() + } catch { self.error = error.localizedDescription } + } + + func discard() async { await reader.discard() } + + private func makeBatch() throws -> SSHImportBatch { + var batch = SSHImportBatch() + batch.keys = selectedItems(.keys).compactMap { item in + guard var key = item.key else { return nil } + key.storage = storage + return key + } + batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost) + var built: [UUID: Host] = [:] + var visiting: Set = [] + func build(_ item: SSHImportItem, forcedJump: UUID? = nil) throws -> Host { + if let existing = item.existingID.flatMap(library.host) { return existing } + if let existing = built[item.id] { + if let forcedJump, existing.jumpHostID != forcedJump { throw ImportRouteError.conflictingChain } + return existing + } + guard visiting.insert(item.id).inserted, visiting.count <= 8, let imported = item.host else { + throw ImportRouteError.invalid + } + defer { visiting.remove(item.id) } + var jumpID = forcedJump + for alias in imported.jumps { + if let candidate = items.first(where: { + $0.host?.alias == alias && ($0.selected || $0.existingID != nil) + }) { + jumpID = try build(candidate, forcedJump: jumpID).id + } else if let existing = library.hosts.first(where: { $0.name == alias }) { + if let jumpID, existing.jumpHostID != jumpID { throw ImportRouteError.conflictingChain } + jumpID = existing.id + } else { + throw ImportRouteError.invalid + } + } + let keyID = chosenKey(for: item)?.id + let identity: Identity + if let existing = (library.identities + batch.identities).first(where: { + $0.username == imported.username && $0.keyID == keyID + }) { + identity = existing + } else { + identity = Identity(name: imported.username, username: imported.username, keyID: keyID) + batch.identities.append(identity) + } + let host = Host( + id: item.id, name: item.name, host: imported.hostname, port: imported.port, + identityID: identity.id, jumpHostID: jumpID) + built[item.id] = host + return host + } + // Build routes with the most hops first so intermediate aliases get their chain before reuse. + for item in selectedItems(.hosts).sorted(by: { ($0.host?.jumps.count ?? 0) > ($1.host?.jumps.count ?? 0) }) { + _ = try build(item) + } + batch.hosts = built.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + let all = Dictionary(uniqueKeysWithValues: (library.hosts + batch.hosts).map { ($0.id, $0) }) + for host in batch.hosts { + var current: Host? = host + var seen: Set = [] + while let node = current { + guard seen.insert(node.id).inserted, seen.count <= 8 else { throw ImportRouteError.invalid } + current = node.jumpHostID.flatMap { all[$0] } + } + } + return batch + } + + var routeProblem: String? { + do { + _ = try makeBatch() + return nil + } catch { return error.localizedDescription } + } +} + +nonisolated private enum ImportRouteError: LocalizedError { + case invalid, conflictingChain + var errorDescription: String? { + switch self { + case .invalid: String(localized: "The imported jump route is missing, cyclic, or exceeds 8 hops.") + case .conflictingChain: + String(localized: "These hosts require conflicting jump routes. Import the routes separately.") + } + } +} diff --git a/Termind/Application/Repositories/SSHImportBatch.swift b/Termind/Application/Repositories/SSHImportBatch.swift new file mode 100644 index 00000000..623ef02e --- /dev/null +++ b/Termind/Application/Repositories/SSHImportBatch.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Execution-only payload. Secrets are never part of the observable import preview. +nonisolated struct SSHImportBatch: Sendable { + var hosts: [Host] = [] + var identities: [Identity] = [] + var keys: [SSHKeyRef] = [] + var knownHosts: [KnownHostEntry] = [] + var credentials: [UUID: StoredSSHKeyCredential] = [:] +} diff --git a/Termind/Application/Repositories/SSHImportReading.swift b/Termind/Application/Repositories/SSHImportReading.swift new file mode 100644 index 00000000..f0ffd099 --- /dev/null +++ b/Termind/Application/Repositories/SSHImportReading.swift @@ -0,0 +1,8 @@ +import Foundation + +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 credentials(for ids: Set) async throws -> [UUID: StoredSSHKeyCredential] + func discard() async +} diff --git a/Termind/Domain/Hosts/KnownHost.swift b/Termind/Domain/Hosts/KnownHost.swift index 860cc012..41f44302 100644 --- a/Termind/Domain/Hosts/KnownHost.swift +++ b/Termind/Domain/Hosts/KnownHost.swift @@ -23,3 +23,27 @@ nonisolated struct KnownHostEntry: Identifiable, Codable, Hashable, Sendable { } } + +nonisolated extension KnownHostEntry { + var isHashed: Bool { hostname.hasPrefix("|") } + + var displayAddress: String { + isHashed ? hostname : Host(name: "", host: hostname, port: port).displayAddress + } + + func matches(hostname candidate: String, port candidatePort: Int) -> Bool { + if isHashed { + return SSHKnownHostMatcher.matches(hash: hostname, hostname: candidate, port: candidatePort) + } + return hostname.caseInsensitiveCompare(candidate) == .orderedSame && port == candidatePort + } + + var isValid: Bool { + let addressValid = + isHashed + ? SSHKnownHostMatcher.isValidHash(hostname) + : !hostname.isEmpty && !hostname.contains(where: { $0.isWhitespace || "*,!?|[]".contains($0) }) + && (1...65_535).contains(port) + return addressValid && !fingerprint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Termind/Domain/Hosts/SSHKnownHostMatcher.swift b/Termind/Domain/Hosts/SSHKnownHostMatcher.swift new file mode 100644 index 00000000..eddf215f --- /dev/null +++ b/Termind/Domain/Hosts/SSHKnownHostMatcher.swift @@ -0,0 +1,24 @@ +import CryptoKit +import Foundation + +nonisolated enum SSHKnownHostMatcher { + static func isValidHash(_ hash: String) -> Bool { components(hash) != nil } + + static func matches(hash: String, hostname: String, port: Int) -> Bool { + guard let (salt, expected) = components(hash) else { return false } + let address = port == 22 ? hostname.lowercased() : "[\(hostname.lowercased())]:\(port)" + let actual = HMAC.authenticationCode( + for: Data(address.utf8), using: SymmetricKey(data: salt) + ) + return Data(actual) == expected + } + + private static func components(_ hash: String) -> (Data, Data)? { + let parts = hash.split(separator: "|", omittingEmptySubsequences: false) + guard parts.count == 4, parts[0].isEmpty, parts[1] == "1", + let salt = Data(base64Encoded: String(parts[2])), salt.count == 20, + let digest = Data(base64Encoded: String(parts[3])), digest.count == 20 + else { return nil } + return (salt, digest) + } +} diff --git a/Termind/Domain/SSHImport/SSHConfigParser.swift b/Termind/Domain/SSHImport/SSHConfigParser.swift new file mode 100644 index 00000000..507c0b3f --- /dev/null +++ b/Termind/Domain/SSHImport/SSHConfigParser.swift @@ -0,0 +1,178 @@ +import Foundation + +/// Evaluates each concrete alias against the original ordered file tree. +/// Includes share the current Host/Match scope, just as textual configuration does. +nonisolated struct SSHConfigParser { + let root: URL + let home: URL + let username: String + let files: [String: String] + var externalPaths: Set = [] + var diagnostics: [SSHImportItem] = [] + + func expandedPath(_ path: String, host: SSHImportedHost? = nil) -> String { + var value = path + let replacements = [ + "%d": home.path, "%u": username, "%h": host?.hostname ?? "", + "%n": host?.alias ?? "", "%r": host?.username ?? username, + "%p": String(host?.port ?? 22), + ] + value = value.replacingOccurrences(of: "%%", with: "\u{1}") + for (token, replacement) in replacements { value = value.replacingOccurrences(of: token, with: replacement) } + value = value.replacingOccurrences(of: "\u{1}", with: "%") + if value == "~/.ssh" { + value = root.path + } else if value.hasPrefix("~/.ssh/") { + value = root.appendingPathComponent(String(value.dropFirst(7))).path + } else if value.hasPrefix("~/") { + value = home.appendingPathComponent(String(value.dropFirst(2))).path + } + if !value.hasPrefix("/") { value = root.appendingPathComponent(value).path } + return URL(fileURLWithPath: value).standardizedFileURL.path + } + + 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.") + } + } + 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 + } + + private struct Evaluation { + var active = true + var inMatch = false + var values: [String: String] = [:] + var identities: [String] = [] + var certificates: [String] = [] + var warnings: [String] = [] + var source = "" + } + + private mutating func lines(_ path: String) -> [SSHConfigLine] { + guard let content = files[path] else { + externalPaths.insert(path) + return [] + } + return content.components(separatedBy: .newlines).enumerated().compactMap { index, raw in + do { + let tokens = try SSHImportParser.tokens(raw) + guard let keyword = tokens.first else { return nil } + guard tokens.count > 1 else { throw ImportParseError.invalid } + return SSHConfigLine( + keyword: keyword.lowercased(), values: Array(tokens.dropFirst()), source: path, line: index + 1) + } catch { + let location = "\(path):\(index + 1)" + if !diagnostics.contains(where: { $0.source == location }) { + diagnostics.append( + SSHImportItem( + category: .hosts, name: URL(fileURLWithPath: path).lastPathComponent, + source: location, selected: false, issue: String(localized: "Invalid SSH record."), + unavailable: true)) + } + return nil + } + } + } + + private mutating func includes(_ line: SSHConfigLine) -> [String] { + line.values.flatMap { value -> [String] in + let pattern = expandedPath(value) + if pattern.contains("*") || pattern.contains("?") { + let matches = files.keys.filter { SSHImportParser.glob(pattern, matches: $0, path: true) }.sorted() + if matches.isEmpty && !pattern.hasPrefix(root.path + "/") { externalPaths.insert(pattern) } + return matches + } + if files[pattern] == nil { externalPaths.insert(pattern) } + return [pattern] + } + } + + private mutating func discover(_ path: String, stack: [String], aliases: inout [String]) { + guard !stack.contains(path), stack.count < 32 else { + diagnostics.append( + SSHImportItem( + category: .hosts, name: "Include", source: path, selected: false, + issue: String(localized: "Include cycle or nesting limit reached."), unavailable: true)) + return + } + for line in lines(path) { + if line.keyword == "host" { + for alias in line.values where SSHImportParser.concrete(alias) && !aliases.contains(alias) { + aliases.append(alias) + } + } else if line.keyword == "include" { + for file in includes(line) { discover(file, stack: stack + [path], aliases: &aliases) } + } + } + } + + private mutating func evaluate(_ path: String, alias: String, stack: [String], state: inout Evaluation) { + guard !stack.contains(path), stack.count < 32 else { + state.warnings.append(String(localized: "Include cycle or nesting limit reached.")) + return + } + for line in lines(path) { + let location = "\(line.source):\(line.line)" + if line.keyword == "host" { + state.active = SSHImportParser.matches(line.values, alias: alias) + state.inMatch = false + if line.values.contains(alias) && state.source.isEmpty { state.source = location } + continue + } + if line.keyword == "match" { + state.inMatch = true + state.active = false + state.warnings.append("\(location): Match") + continue + } + guard state.active && !state.inMatch else { continue } + if line.keyword == "include" { + for file in includes(line) { + if files[file] == nil { state.warnings.append("\(location): Include \(file)") } + evaluate(file, alias: alias, stack: stack + [path], state: &state) + } + } else if ["hostname", "port", "user", "proxyjump"].contains(line.keyword) { + if state.values[line.keyword] == nil { state.values[line.keyword] = line.values.first } + } else if line.keyword == "identityfile" { + if let value = line.values.first, !state.identities.contains(value) { state.identities.append(value) } + } else if line.keyword == "certificatefile" { + if let value = line.values.first { state.certificates.append(value) } + } else { + state.warnings.append("\(location): \(line.keyword)") + } + } + } +} diff --git a/Termind/Domain/SSHImport/SSHImportItem.swift b/Termind/Domain/SSHImport/SSHImportItem.swift new file mode 100644 index 00000000..56c72153 --- /dev/null +++ b/Termind/Domain/SSHImport/SSHImportItem.swift @@ -0,0 +1,63 @@ +import Foundation + +nonisolated enum SSHImportCategory: String, CaseIterable, Identifiable, Sendable { + case hosts, keys, knownHosts + var id: Self { self } + var title: String { + switch self { + case .hosts: String(localized: "Hosts") + case .keys: String(localized: "Keys") + case .knownHosts: String(localized: "Known Hosts") + } + } +} + +/// Public metadata only. The reader retains private material behind item IDs. +nonisolated struct SSHImportItem: Identifiable, Sendable { + var id = UUID() + var category: SSHImportCategory + var name: String + var source: String + var selected = true + var issue: String = "" + var unavailable = false + var requiresPassphrase = false + var needsAuthorization: String? + var warnings: [String] = [] + var acceptedWarnings = false + var host: SSHImportedHost? + var key: SSHKeyRef? + var knownHost: KnownHostEntry? + var preferredKeyID: UUID? + var existingID: UUID? + var certificatePaths: [String] = [] +} + +nonisolated struct SSHImportedHost: Sendable { + var alias: String + var hostname: String + var port = 22 + var username: String + var identityPaths: [String] = [] + var certificatePaths: [String] = [] + var jumps: [String] = [] + var existingKeyID: UUID? + var usesDefaultIdentityFiles = false +} + +nonisolated struct SSHConfigLine: Sendable { + var keyword: String + var values: [String] + var source: String + var line: Int +} + +nonisolated enum SSHImportSourceError: LocalizedError, Equatable { + case expectedFile, expectedFolder + 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.") + } + } +} diff --git a/Termind/Domain/SSHImport/SSHImportParser.swift b/Termind/Domain/SSHImport/SSHImportParser.swift new file mode 100644 index 00000000..8d32a015 --- /dev/null +++ b/Termind/Domain/SSHImport/SSHImportParser.swift @@ -0,0 +1,161 @@ +import CryptoKit +import Foundation + +nonisolated enum SSHImportParser { + /// Tokenizes OpenSSH's quoted arguments without invoking a shell. + 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 + } + + 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 + } + + 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 + } + + static func concrete(_ alias: String) -> Bool { + !alias.isEmpty && !alias.contains(where: { "*?!".contains($0) }) + } + + 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. + 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...unset) @@ -455,26 +451,12 @@ struct HostEditorView: View { || draft.sftpSessionInitialPath.hasPrefix("/")) && app.hostLibrary.validatedRoute(for: draft) != nil && draftForwardings.allSatisfy(\.isValid) - && knownHostsAreValid - } - - private var knownHostsAreValid: Bool { - let normalized = draftKnownHosts.map { entry in - SSHHostKeyIdentity( - algorithm: entry.algorithm, - fingerprint: entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) - ) - } - return normalized.allSatisfy { !$0.fingerprint.isEmpty } - && Set(normalized).count == normalized.count } private func load() { guard let hostID, let existing = app.hostLibrary.host(hostID) else { return } draft = existing draftForwardings = app.hostLibrary.portForwardings(for: hostID) - originalKnownHosts = app.hostLibrary.knownHosts(for: existing) - draftKnownHosts = originalKnownHosts } private func loadOnce() { @@ -494,8 +476,7 @@ struct HostEditorView: View { for index in draftForwardings.indices { draftForwardings[index].hostID = draft.id } if app.hostLibrary.upsert( draft, - replacingPortForwardings: draftForwardings, - replacingKnownHosts: knownHostsForSave + replacingPortForwardings: draftForwardings ) { closeTab() } else { @@ -503,193 +484,8 @@ struct HostEditorView: View { } } - private var knownHostsForSave: [KnownHostEntry] { - let originals = Dictionary(uniqueKeysWithValues: originalKnownHosts.map { ($0.id, $0) }) - return draftKnownHosts.map { entry in - var normalized = entry - normalized.fingerprint = entry.fingerprint.trimmingCharacters( - in: .whitespacesAndNewlines - ) - if let original = originals[entry.id], - original.algorithm == normalized.algorithm, - original.fingerprint == normalized.fingerprint - { - return normalized - } - normalized.addedAt = Date() - normalized.isMismatched = false - return normalized - } - } - /// Return to Hosts so the saved or retained record is immediately visible. private func closeTab() { window.openPage(.hosts) } } - -/// Known Hosts is part of the Host draft, including during creation. Nothing is -/// trusted or forgotten until the editor's Save commits the entire collection. -private struct KnownHostsEditorSection: View { - @Binding var entries: [KnownHostEntry] - @State private var newAlgorithm: SSHKeyAlgorithm = .ed25519 - @State private var newFingerprint = "" - - var body: some View { - Section("Known Hosts") { - if entries.contains(where: \.isMismatched) { - Label( - "This server recently presented a key that is not in the trusted set.", - systemImage: "exclamationmark.triangle.fill" - ) - .font(.caption) - .foregroundStyle(.orange) - } - - if entries.isEmpty { - Text( - "Add a trusted server key now, or let Termind save it on first connection." - ) - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach($entries) { $entry in - knownHostRow($entry) - } - } - - newHostKeyComposer - - Text("Changes take effect only after you save this Host.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - private func knownHostRow(_ entry: Binding) -> some View { - VStack(alignment: .leading, spacing: Metrics.Space.m) { - HStack { - Label("Host Key", systemImage: "key.horizontal") - .font(.headline) - .foregroundStyle(.secondary) - Spacer() - Button(role: .destructive) { - let id = entry.wrappedValue.id - entries.removeAll { $0.id == id } - } label: { - Label("Delete Host Key", systemImage: "trash") - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .help("Delete this Host Key") - } - - LabeledContent("Key Type") { - algorithmPicker(selection: entry.algorithm) - } - - LabeledContent("Fingerprint") { - TextField( - "Fingerprint", text: entry.fingerprint, - prompt: Text("SHA256:…") - ) - .labelsHidden() - .textFieldStyle(.plain) - .font(.body.monospaced()) - .multilineTextAlignment(.trailing) - } - - if entry.wrappedValue.isMismatched { - Label("Previously marked as mismatched", systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - } - } - .padding(Metrics.Space.m) - .background(Color.primary.opacity(0.045), in: .rect(cornerRadius: Metrics.Radius.medium)) - .overlay { - RoundedRectangle(cornerRadius: Metrics.Radius.medium) - .strokeBorder(.separator.opacity(0.7)) - } - } - - private var newHostKeyComposer: some View { - VStack(alignment: .leading, spacing: Metrics.Space.m) { - Label("Add Host Key", systemImage: "plus.circle.fill") - .font(.headline) - .foregroundStyle(.secondary) - - LabeledContent("Key Type") { - algorithmPicker(selection: $newAlgorithm) - } - - LabeledContent("Fingerprint") { - TextField( - "Fingerprint", text: $newFingerprint, - prompt: Text("SHA256:…") - ) - .labelsHidden() - .textFieldStyle(.plain) - .font(.body.monospaced()) - .multilineTextAlignment(.trailing) - .onSubmit(addHostKey) - } - - HStack { - if isDuplicateDraft { - Text("This host key is already in the list.") - .font(.caption) - .foregroundStyle(.orange) - } - Spacer() - Button("Add", systemImage: "plus", action: addHostKey) - .buttonStyle(.borderedProminent) - .disabled(!canAddHostKey) - } - } - .padding(Metrics.Space.m) - .background(Color.primary.opacity(0.045), in: .rect(cornerRadius: Metrics.Radius.medium)) - .overlay { - RoundedRectangle(cornerRadius: Metrics.Radius.medium) - .strokeBorder(.separator.opacity(0.7)) - } - } - - private func algorithmPicker(selection: Binding) -> some View { - Picker("Key Type", selection: selection) { - ForEach(SSHKeyAlgorithm.allCases) { algorithm in - Text(algorithm.displayName).tag(algorithm) - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - } - - private var normalizedNewFingerprint: String { - newFingerprint.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var draftIdentity: SSHHostKeyIdentity { - SSHHostKeyIdentity(algorithm: newAlgorithm, fingerprint: normalizedNewFingerprint) - } - - private var isDuplicateDraft: Bool { - !normalizedNewFingerprint.isEmpty && entries.contains { $0.identity == draftIdentity } - } - - private var canAddHostKey: Bool { - !normalizedNewFingerprint.isEmpty && !isDuplicateDraft - } - - private func addHostKey() { - guard canAddHostKey else { return } - entries.append( - KnownHostEntry( - hostname: "", algorithm: newAlgorithm, - fingerprint: normalizedNewFingerprint, addedAt: Date() - ) - ) - newFingerprint = "" - } -} diff --git a/Termind/Features/Hosts/HostsPageView.swift b/Termind/Features/Hosts/HostsPageView.swift index 4d90fd97..a13e4672 100644 --- a/Termind/Features/Hosts/HostsPageView.swift +++ b/Termind/Features/Hosts/HostsPageView.swift @@ -251,6 +251,10 @@ struct HostsPageView: ManagedCardPage { }, .separator, tagFilterMenu, + .separator, + NativeMenuEntry(title: String(localized: "Import .ssh Folder"), symbol: MenuSymbol.importItem) { + openWork(.sshImport(knownHostsOnly: false)) + }, ] } diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index 1776f6a7..b29e523b 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -201,6 +201,10 @@ struct KeychainPageView: ManagedCardPage { ) { window.openVaultWork(.identityEditor(nil), on: .keychain) }, + .separator, + NativeMenuEntry(title: String(localized: "Import .ssh Folder"), symbol: MenuSymbol.importItem) { + window.openVaultWork(.sshImport(knownHostsOnly: false), on: .keychain) + }, ] } diff --git a/Termind/Features/KnownHosts/KnownHostsPageView.swift b/Termind/Features/KnownHosts/KnownHostsPageView.swift new file mode 100644 index 00000000..3da791df --- /dev/null +++ b/Termind/Features/KnownHosts/KnownHostsPageView.swift @@ -0,0 +1,190 @@ +import SwiftUI + +@Observable +final class KnownHostsPageState { + var query = "" + var deleting: Set = [] + var error: String? +} + +struct KnownHostsPageView: ManagedCardPage { + typealias SectionID = String + typealias Item = KnownHostEntry + @Environment(AppModel.self) private var app + @Environment(WindowModel.self) private var window + let state: KnownHostsPageState + + var managedSections: [ManagedCardSection] { + [ + ManagedCardSection( + id: "knownHosts", title: String(localized: "Known Hosts"), + items: app.knownHosts.search(state.query), canAcceptDrop: false, showsWhenEmpty: false) + ] + } + var managedSelection: ManagedCardSelection { + ManagedCardSelection( + selected: { Set(window.vaultSelection(for: .knownHosts).compactMap(\.knownHostID)) }, + update: { ids in + window.setVaultSelection( + app.knownHosts.entries.filter { ids.contains($0.id) }.map { .knownHost($0.id) }, for: .knownHosts) + } + ) + } + var managedCapabilities: ManagedCardCapabilities { ManagedCardCapabilities() } + var isManagedSourceEmpty: Bool { app.knownHosts.entries.isEmpty } + func managedHeader(for section: ManagedCardSection, isDropTarget: Bool) -> some View { + CardSectionHeader(title: section.title, count: section.items.count) + } + func managedCard(for item: KnownHostEntry, context: ManagedCardItemContext) -> some View { + KnownHostCard( + entry: item, selected: context.isSelected, select: context.select, extend: context.extendSelection + ) + .contextMenu { + if context.actionItems.count == 1 { + Button("Edit") { window.openVaultWork(.knownHostEditor(item.id), on: .knownHosts) } + Button("Copy Fingerprint") { copyToPasteboard(item.fingerprint) } + Divider() + } + Button("Delete", role: .destructive) { state.deleting = Set(context.actionItems.map(\.id)) } + } + .onTapGesture(count: 2) { window.openVaultWork(.knownHostEditor(item.id), on: .knownHosts) } + } + func managedGhost(for item: KnownHostEntry) -> some View { + KnownHostCard(entry: item, selected: false, select: {}, extend: {}) + } + func managedBlankAreaMenu(for section: String?) -> [NativeMenuEntry] { + window.creationMenu(for: .knownHosts, locale: .current) + } + func managedEmptyState() -> some View { + ContentUnavailableView { + Label("No Known Hosts", systemImage: "touchid") + } description: { + Text("Import trusted server keys or add a fingerprint manually.") + } actions: { + Button("Import Known Hosts") { window.openVaultWork(.sshImport(knownHostsOnly: true), on: .knownHosts) } + Button("Add Host Key") { window.openVaultWork(.knownHostEditor(nil), on: .knownHosts) } + } + } + func managedNoResultsState() -> some View { ContentUnavailableView.search(text: state.query) } + var managedPresentation: KnownHostsPresentation { + KnownHostsPresentation( + state: state, ids: app.knownHosts.entries.map(\.id), + prune: { window.pruneVaultSelections(in: app.hostLibrary) }, + delete: { + if !app.knownHosts.delete(state.deleting) { state.error = app.knownHosts.storageError } + state.deleting = [] + window.pruneVaultSelections(in: app.hostLibrary) + }) + } +} + +private struct KnownHostCard: View { + let entry: KnownHostEntry + let selected: Bool + let select: () -> Void + let extend: () -> Void + var body: some View { + CardTile( + selectionID: VaultSelectionID.knownHost(entry.id), isSelected: selected, select: select, + extendSelection: extend + ) { + VStack(alignment: .leading, spacing: Metrics.Space.m) { + HStack { + CardIcon(symbol: "touchid", tint: entry.isMismatched ? .orange : TermindColor.accent) + VStack(alignment: .leading) { + Text(entry.isHashed ? String(localized: "Hashed Address") : entry.displayAddress) + .font(.headline).lineLimit(1) + Text(entry.algorithm.displayName).font(.caption).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + HStack { + if entry.isMismatched { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + .help("Previously marked as mismatched") + } + Text(entry.addedAt, style: .date).font(.caption2).foregroundStyle(.secondary) + Spacer() + } + } + } + .help(entry.displayAddress) + } +} + +struct KnownHostsPresentation: ManagedCardPresentationModifier { + @Bindable var state: KnownHostsPageState + let ids: [UUID] + let prune: () -> Void + let delete: () -> Void + func body(content: Content) -> some View { + content + .onAppear(perform: prune) + .onChange(of: ids) { prune() } + .confirmationDialog( + "Delete Host Keys?", + isPresented: Binding(get: { !state.deleting.isEmpty }, set: { if !$0 { state.deleting = [] } }) + ) { + Button("Delete", role: .destructive, action: delete) + } message: { + Text("The next connection will ask you to verify the server key again.") + } + .alert( + "Known Hosts Operation Failed", + isPresented: Binding(get: { state.error != nil }, set: { if !$0 { state.error = nil } }) + ) { + Button("OK") { state.error = nil } + } message: { + Text(state.error ?? "") + } + } +} + +struct KnownHostEditorView: View { + @Environment(AppModel.self) private var app + @Environment(WindowModel.self) private var window + let entryID: UUID? + @State private var draft = KnownHostEntry(hostname: "") + @State private var loaded = false + @State private var error: String? + var body: some View { + VaultDetailScaffold(title: "Host Key", symbol: "touchid", close: window.closeVaultWork) { + Form { + TextField("Hostname", text: $draft.hostname) + if !draft.isHashed { TextField("Port", value: $draft.port, format: .number.grouping(.never)) } + Picker("Key Type", selection: $draft.algorithm) { + ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) } + } + TextField("Fingerprint", text: $draft.fingerprint) + .font(.body.monospaced()) + if draft.isHashed { + Text("The original address and port cannot be recovered from this hash.").font(.caption) + .foregroundStyle(.secondary) + } + if draft.isMismatched { + Label("Previously marked as mismatched", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + if let error { Text(error).foregroundStyle(.red) } + }.formStyle(.grouped) + } footer: { + HStack { + Spacer() + Button("Cancel", action: window.closeVaultWork) + Button("Save") { + if app.knownHosts.save(draft) { + window.selectVaultItem(.knownHost(draft.id), on: .knownHosts) + window.closeVaultWork() + } else { + error = app.knownHosts.storageError + } + }.disabled(!draft.isValid) + }.padding() + } + .onAppear { + guard !loaded else { return } + loaded = true + if let entryID, let entry = app.knownHosts.entry(entryID) { draft = entry } + } + } +} diff --git a/Termind/Features/SSHImport/SSHImportView.swift b/Termind/Features/SSHImport/SSHImportView.swift new file mode 100644 index 00000000..9423fb55 --- /dev/null +++ b/Termind/Features/SSHImport/SSHImportView.swift @@ -0,0 +1,296 @@ +import SwiftUI + +struct SSHImportView: View { + @Environment(AppModel.self) private var app + @Environment(WindowModel.self) private var window + let knownHostsOnly: Bool + @State private var model: SSHImportModel? + var body: some View { + Group { + if let model { + SSHImportContent(model: model, knownHostsOnly: knownHostsOnly, close: window.closeVaultWork) + } else { + ProgressView() + } + } + .task { if model == nil { model = app.makeSSHImportModel() } } + .onDisappear { if let model { Task { await model.discard() } } } + } +} + +private struct SSHImportContent: View { + @Bindable var model: SSHImportModel + let knownHostsOnly: Bool + let close: () -> Void + @State private var passphrases: [UUID: String] = [:] + @State private var editingNames: Set = [] + @State private var nativeDropKind: NativeDropKind? + @State private var inputError: String? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VaultDetailScaffold( + title: knownHostsOnly ? "Import Known Hosts" : "Import .ssh Folder", symbol: "square.and.arrow.down", + close: closeWhenIdle + ) { + if model.completed { + VStack(alignment: .leading, spacing: Metrics.Space.m) { + Label("Import Complete", systemImage: "checkmark.circle").font(.headline) + Text("Imported \(model.importedCount) items.").foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + } else { + ScrollView { + VStack(alignment: .leading, spacing: Metrics.Space.l) { + sourcePicker + if !knownHostsOnly { + Picker("Store Keys in", selection: $model.storage) { + Text("iCloud").tag(KeyStorage.iCloud) + Text("This Mac").tag(KeyStorage.thisDevice) + } + } + if !model.items.isEmpty { + selectionButton(category: nil, title: String(localized: "Select All")) + ForEach(SSHImportCategory.allCases) { category in + if model.items.contains(where: { $0.category == category }) { + categorySection(category) + } + } + } + if let issue = model.routeProblem { + Text(issue).font(.caption).foregroundStyle(.orange) + } + if let error = inputError ?? model.error { Text(error).foregroundStyle(.red) } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .disabled(model.isWorking) + } + } footer: { + VStack(alignment: .leading, spacing: Metrics.Space.s) { + HStack { + if !model.items.isEmpty && !model.completed { + Text("\(model.selectedItems().count) selected") + .font(.caption).foregroundStyle(.secondary) + } + if model.isWorking { ProgressView().controlSize(.small) } + Spacer() + Button(model.completed ? "Done" : "Cancel", action: close).disabled(model.isWorking) + if !model.completed { + Button("Import") { + Task { + await model.commit() + passphrases = [:] + } + } + .buttonStyle(.borderedProminent).disabled(!model.canImport) + } + } + }.padding() + } + .overlay { + if nativeDropKind != nil { + DropTargetOverlay( + presentation: DropTargetPresentation( + title: String(localized: "Drag to Import"), + detail: knownHostsOnly + ? String(localized: "Known Hosts File") : String(localized: ".ssh Folder"), + systemImage: knownHostsOnly ? "touchid" : "folder" + )) + } + } + .overlay { + NativeDropTarget( + targetedKind: $nativeDropKind, acceptedKinds: [.files], + isEnabled: !model.isWorking && !model.completed, + receive: { receiveDrop($0.fileURLs) }) + } + .animation(Motion.dropTarget(reduceMotion: reduceMotion), value: nativeDropKind) + } + + private var sourcePicker: some View { + VStack(alignment: .leading, spacing: Metrics.Space.m) { + Label( + knownHostsOnly ? "Known Hosts File" : ".ssh Folder", systemImage: knownHostsOnly ? "touchid" : "folder" + ) + .font(.headline) + if let source = model.sourcePath { + Text(verbatim: source).font(.caption).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled) + } else { + Text( + knownHostsOnly + ? "Drop a known_hosts file here, or choose a file." + : "Drop a .ssh folder here, or choose a folder." + ) + .font(.caption).foregroundStyle(.secondary) + } + Button(knownHostsOnly ? "Choose Known Hosts File" : "Choose .ssh Folder") { + Task { + if let url = await SSHImportFilePicker.choose(folder: !knownHostsOnly) { + inputError = nil + passphrases = [:] + await model.scan(url, knownHostsOnly: knownHostsOnly) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.primary.opacity(0.035), in: .rect(cornerRadius: Metrics.Radius.medium)) + } + + private func receiveDrop(_ urls: [URL]) -> Bool { + guard urls.count == 1, let url = urls.first else { + inputError = (knownHostsOnly ? SSHImportSourceError.expectedFile : .expectedFolder).localizedDescription + return false + } + inputError = nil + passphrases = [:] + Task { await model.scan(url, knownHostsOnly: knownHostsOnly) } + return true + } + + private func closeWhenIdle() { if !model.isWorking { close() } } + + private func selectionButton(category: SSHImportCategory?, title: String) -> some View { + let total = model.availableItems(category).count + let selected = model.selectedItems(category).count + let symbol = selected == 0 ? "square" : selected == total ? "checkmark.square.fill" : "minus.square.fill" + return Button { + model.setSelected(selected != total, category: category) + } label: { + HStack { + Image(systemName: symbol) + Text(title) + Spacer() + Text("\(selected) / \(total)").monospacedDigit().foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .accessibilityValue( + selected == 0 + ? String(localized: "None selected") + : selected == total ? String(localized: "All selected") : String(localized: "Partially selected") + ) + .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) } + } + } + } + .padding() + .background(.primary.opacity(0.035), in: .rect(cornerRadius: Metrics.Radius.medium)) + } + + private func sourceLabel(_ source: String) -> String { + guard let root = model.sourcePath else { return source } + if source.hasPrefix(root + "/") { return String(source.dropFirst(root.count + 1)) } + if source.hasPrefix(root + ":") { + return URL(fileURLWithPath: root).lastPathComponent + source.dropFirst(root.count) + } + return source + } + + private func itemRow(_ binding: Binding) -> some View { + let item = binding.wrappedValue + return VStack(alignment: .leading, spacing: Metrics.Space.s) { + HStack(alignment: .center, spacing: Metrics.Space.s) { + Toggle(item.name, isOn: binding.selected) + .labelsHidden() + .toggleStyle(.checkbox) + .fixedSize() + .accessibilityLabel(item.name) + .accessibilityHint(item.source) + VStack(alignment: .leading) { + Text(item.name).fontWeight(.medium).lineLimit(2) + Text(sourceLabel(item.source)).font(.caption).foregroundStyle(.secondary).lineLimit(2).help( + item.source) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .onTapGesture { + if model.selectable(item) { binding.selected.wrappedValue.toggle() } + } + .accessibilityHidden(true) + } + .disabled(!model.selectable(item)) + if item.existingID != nil { + Label("Already imported", systemImage: "checkmark.circle").font(.caption).foregroundStyle(.secondary) + } + if item.host != nil && model.selectable(item) { + if model.hasNameConflict(item) || editingNames.contains(item.id) { + TextField( + "Name", + text: Binding( + get: { binding.name.wrappedValue }, + set: { + editingNames.insert(item.id) + binding.name.wrappedValue = $0 + } + )) + } + let options = model.keyOptions(for: item) + if options.count > 1 { + Picker( + "Key", + selection: Binding( + get: { model.chosenKey(for: item)?.id }, set: { binding.preferredKeyID.wrappedValue = $0 }) + ) { + ForEach(options) { key in Text(key.label).tag(Optional(key.id)) } + } + } + } + if item.requiresPassphrase { + SecureField( + "Passphrase", text: Binding(get: { passphrases[item.id] ?? "" }, set: { passphrases[item.id] = $0 }) + ) + Button("Unlock Key") { + let passphrase = passphrases.removeValue(forKey: item.id) ?? "" + Task { await model.unlock(item.id, passphrase: passphrase) } + } + } + if let path = item.needsAuthorization { + Button("Authorize File") { + Task { + if let url = await SSHImportFilePicker.choose(folder: path.contains("*"), suggestedPath: path) { + await model.authorize(url) + } + } + } + } + let warnings = model.warnings(for: item) + if !warnings.isEmpty { + ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in + Text(warning).font(.caption).foregroundStyle(.orange) + } + Toggle("Accept these import warnings", isOn: binding.acceptedWarnings).toggleStyle(.checkbox) + } + 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/Termind/Features/Settings/SyncSettings.swift b/Termind/Features/Settings/SyncSettings.swift index 1e908248..559dd6af 100644 --- a/Termind/Features/Settings/SyncSettings.swift +++ b/Termind/Features/Settings/SyncSettings.swift @@ -24,7 +24,7 @@ struct SyncSettings: View { VStack(alignment: .leading, spacing: Metrics.Space.m) { SyncSelectionRow( - title: "Vault", detail: "Hosts, identities, snippets, and port forwards.", + title: "Vault", detail: "Hosts, identities, snippets, known hosts, and port forwards.", isSelected: Binding( get: { includesVault }, set: { value in diff --git a/Termind/Features/Shell/WindowRoute.swift b/Termind/Features/Shell/WindowRoute.swift index 1e8c4c8f..04203e33 100644 --- a/Termind/Features/Shell/WindowRoute.swift +++ b/Termind/Features/Shell/WindowRoute.swift @@ -7,6 +7,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab case hosts case keychain case snippets + case knownHosts nonisolated var id: String { rawValue } @@ -16,6 +17,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab case .hosts: "Hosts" case .keychain: "Keychain" case .snippets: "Snippets" + case .knownHosts: "Known Hosts" } } @@ -25,6 +27,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab case .hosts: AppLocalization.string("Hosts", locale: locale) case .keychain: AppLocalization.string("Keychain", locale: locale) case .snippets: AppLocalization.string("Snippets", locale: locale) + case .knownHosts: AppLocalization.string("Known Hosts", locale: locale) } } @@ -34,6 +37,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab case .hosts: "server.rack" case .keychain: "key.fill" case .snippets: "curlybraces" + case .knownHosts: "touchid" } } } @@ -43,6 +47,7 @@ nonisolated enum VaultDestination: Hashable, Sendable { case hosts case keychain case snippets + case knownHosts init(page: VaultPage) { switch page { @@ -50,6 +55,7 @@ nonisolated enum VaultDestination: Hashable, Sendable { case .hosts: self = .hosts case .keychain: self = .keychain case .snippets: self = .snippets + case .knownHosts: self = .knownHosts } } @@ -59,6 +65,7 @@ nonisolated enum VaultDestination: Hashable, Sendable { case .hosts: .hosts case .keychain: .keychain case .snippets: .snippets + case .knownHosts: .knownHosts } } } @@ -72,6 +79,8 @@ nonisolated enum VaultWorkRoute: Hashable, Sendable { case identityEditor(Identity.ID?) case generateKey case importKey + case knownHostEditor(KnownHostEntry.ID?) + case sshImport(knownHostsOnly: Bool) case editKey(SSHKeyRef.ID) case exportKeys([SSHKeyRef.ID]) case snippetEditor(snippetID: Snippet.ID?, packageID: SnippetPackage.ID?) diff --git a/Termind/Features/Vault/VaultPageStates.swift b/Termind/Features/Vault/VaultPageStates.swift index 0d60f15f..023ff855 100644 --- a/Termind/Features/Vault/VaultPageStates.swift +++ b/Termind/Features/Vault/VaultPageStates.swift @@ -1,12 +1,13 @@ import Foundation -/// Stable identity for the ordered selections owned by the three Vault manager pages. +/// Stable identity for the ordered selections owned by the Vault manager pages. nonisolated struct VaultSelectionID: Hashable, Sendable, Identifiable { nonisolated enum Kind { static let host = "host" static let key = "key" static let identity = "identity" static let snippet = "snippet" + static let knownHost = "knownHost" } let kind: String @@ -32,6 +33,11 @@ nonisolated struct VaultSelectionID: Hashable, Sendable, Identifiable { Self(kind: Kind.snippet, rawID: id, page: .snippets) } + nonisolated static func knownHost(_ id: KnownHostEntry.ID) -> Self { + Self(kind: Kind.knownHost, rawID: id, page: .knownHosts) + } + + var knownHostID: KnownHostEntry.ID? { kind == Kind.knownHost ? rawID : nil } var hostID: Host.ID? { kind == Kind.host ? rawID : nil } var keyID: SSHKeyRef.ID? { kind == Kind.key ? rawID : nil } var identityID: Identity.ID? { kind == Kind.identity ? rawID : nil } @@ -65,15 +71,15 @@ final class VaultSelectionState { func clear(_ page: VaultPage) { selections[page] = [] } - func prune(available: Set) { - for page in VaultPage.allCases { + func prune(available: Set, pages: Set = Set(VaultPage.allCases)) { + for page in pages { let valid = selection(for: page).filter(available.contains) if valid != selection(for: page) { selections[page] = valid } } } } -/// The three manager pages' query and confirmation state, held for the life of +/// The manager pages' query and confirmation state, held for the life of /// the window rather than of the view. /// /// Vault pages are torn down and rebuilt every time the destination changes, so @@ -86,6 +92,7 @@ final class VaultPageStates { let hosts = HostsPageState() let keychain = KeychainPageState() let snippets = SnippetsPageState() + let knownHosts = KnownHostsPageState() let selection = VaultSelectionState() /// The query belonging to a page, or nil for Welcome, which searches hosts @@ -96,6 +103,7 @@ final class VaultPageStates { case .hosts: hosts.query case .keychain: keychain.query case .snippets: snippets.query + case .knownHosts: knownHosts.query } } } diff --git a/Termind/Features/Vault/VaultView.swift b/Termind/Features/Vault/VaultView.swift index 2fb27318..2acc6956 100644 --- a/Termind/Features/Vault/VaultView.swift +++ b/Termind/Features/Vault/VaultView.swift @@ -22,6 +22,7 @@ struct VaultView: View { case .hosts: HostsPageView(state: pages.hosts) case .keychain: KeychainPageView(state: pages.keychain) case .snippets: SnippetsPageView(state: pages.snippets) + case .knownHosts: KnownHostsPageView(state: pages.knownHosts) } } } @@ -57,6 +58,10 @@ struct VaultWorkDetailView: View { ) case .generateKey: KeychainGenerateDetail() + case .knownHostEditor(let id): + KnownHostEditorView(entryID: id) + case .sshImport(let knownHostsOnly): + SSHImportView(knownHostsOnly: knownHostsOnly) case .importKey: KeychainImportDetail() case .editKey(let id): diff --git a/Termind/Features/Workspaces/WindowModel.swift b/Termind/Features/Workspaces/WindowModel.swift index 56047e2c..9c0839e5 100644 --- a/Termind/Features/Workspaces/WindowModel.swift +++ b/Termind/Features/Workspaces/WindowModel.swift @@ -143,12 +143,12 @@ final class WindowModel { func pruneVaultSelections(in store: CredentialLibraryModel) { let available = Set(store.keys.map { VaultSelectionID.key($0.id) }) .union(store.identities.map { .identity($0.id) }) - vaultPages.selection.prune(available: available) + vaultPages.selection.prune(available: available, pages: [.keychain]) } func pruneVaultSelections(in store: SnippetLibraryModel) { vaultPages.selection.prune( - available: Set(store.snippets.map { VaultSelectionID.snippet($0.id) }) + available: Set(store.snippets.map { VaultSelectionID.snippet($0.id) }), pages: [.snippets] ) } @@ -157,6 +157,7 @@ final class WindowModel { .union(store.keys.map { .key($0.id) }) .union(store.identities.map { .identity($0.id) }) .union(store.snippets.map { .snippet($0.id) }) + .union(store.knownHosts.map { .knownHost($0.id) }) } typealias SidebarMode = WindowSidebarState.Mode @@ -373,6 +374,7 @@ final class WindowModel { case .hosts: return .hosts case .keychain: return .keychain case .snippets: return .snippets + case .knownHosts: return .knownHosts } } } diff --git a/Termind/Features/Workspaces/WorkspaceNavigatorView.swift b/Termind/Features/Workspaces/WorkspaceNavigatorView.swift index 84fef32e..4599efe6 100644 --- a/Termind/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/Termind/Features/Workspaces/WorkspaceNavigatorView.swift @@ -139,6 +139,7 @@ struct WorkspaceNavigatorView: View { case .hosts: app.hostLibrary.hosts.count case .keychain: app.hostLibrary.keys.count + app.hostLibrary.identities.count case .snippets: app.hostLibrary.snippets.count + case .knownHosts: app.knownHosts.entries.count } } diff --git a/Termind/Infrastructure/SSHImport/SSHImportReader.swift b/Termind/Infrastructure/SSHImport/SSHImportReader.swift new file mode 100644 index 00000000..a5f20140 --- /dev/null +++ b/Termind/Infrastructure/SSHImport/SSHImportReader.swift @@ -0,0 +1,216 @@ +import Foundation + +/// One import session owns its secrets. Only metadata leaves this actor until commit. +actor SSHImportReader: SSHImportReading { + private struct Material { + var text: String + var certificate: String + } + private var materials: [UUID: Material] = [:] + private var stableIDs: [String: UUID] = [:] + + func discard() { materials.removeAll() } + + func scan(root: URL, knownHostsOnly: Bool, grants: [URL] = []) throws -> [SSHImportItem] { + let scopes = [root] + grants + 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 root = 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 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: []) + urls = enumerator?.allObjects.compactMap { $0 as? URL } ?? [] + } else { + urls = [scope] + } + for url in urls { + let values = try? url.resourceValues(forKeys: [.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) } + do { + let value = try read(url) + totalBytes += value.utf8.count + guard totalBytes <= 64 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) } + files[url.standardizedFileURL.path] = value + } catch { + inaccessible.append(url.path) + } + } + } + var parser = SSHConfigParser( + root: root, home: URL(fileURLWithPath: NSHomeDirectoryForUser(NSUserName()) ?? NSHomeDirectory()), + username: NSUserName(), files: files) + var items = parser.parse() + let knownPath = root.appendingPathComponent("known_hosts").path + if let known = files[knownPath] { items += SSHImportParser.knownHosts(known, source: knownPath) } + 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 { + 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() { + items.append( + SSHImportItem( + category: path == knownPath ? .knownHosts : 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)) + } + items[index].warnings += absent.map { String(localized: "Referenced file is unavailable:") + " " + $0 } + } + return stabilize(items) + } + + func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef { + try inspect(id: id, name: name, passphrase: passphrase) + } + + 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)) + } + 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) + } + 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 { + if let id = stableIDs[source] { return id } + let id = UUID() + stableIDs[source] = id + return id + } + + private func stabilize(_ values: [SSHImportItem]) -> [SSHImportItem] { + var occurrences: [String: Int] = [:] + return values.map { item in + var item = item + if materials[item.id] == nil { + let token = "\(item.category.rawValue):\(item.source):\(item.name)" + let occurrence = occurrences[token, default: 0] + occurrences[token] = occurrence + 1 + item.id = stableID(token + ":\(occurrence)") + } + return item + } + } + + private func read(_ url: URL) throws -> String { + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else { + throw CocoaError(.fileReadTooLarge) + } + return try String(contentsOf: url, encoding: .utf8) + } + + private func isEncrypted(_ text: String) -> Bool { + if text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") || text.contains("DEK-Info:") { return true } + guard text.hasPrefix("-----BEGIN OPENSSH PRIVATE KEY-----") else { return false } + let encoded = text.components(separatedBy: .newlines).filter { !$0.hasPrefix("-----") }.joined() + let magic = Data("openssh-key-v1\0".utf8) + guard let data = Data(base64Encoded: encoded), data.starts(with: magic), data.count >= magic.count + 4 else { + return false + } + let offset = magic.count + let length = data[offset.. 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/Termind/Platform/macOS/SSHImportFilePicker.swift b/Termind/Platform/macOS/SSHImportFilePicker.swift new file mode 100644 index 00000000..96c29717 --- /dev/null +++ b/Termind/Platform/macOS/SSHImportFilePicker.swift @@ -0,0 +1,20 @@ +import AppKit + +@MainActor +enum SSHImportFilePicker { + static func choose(folder: Bool, suggestedPath: String? = nil) async -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = folder + panel.canChooseFiles = !folder + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = true + if let suggestedPath { + let url = URL(fileURLWithPath: suggestedPath) + panel.directoryURL = folder ? url : url.deletingLastPathComponent() + } else { + panel.directoryURL = URL(fileURLWithPath: NSHomeDirectoryForUser(NSUserName()) ?? NSHomeDirectory()) + } + let response = await panel.begin() + return response == .OK ? panel.url : nil + } +} diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index eebb968b..898eb670 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -21,42 +21,12 @@ } } }, - "Cached metrics are stale" : { + ".ssh Folder" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "缓存指标已过期" - } - } - } - }, - "Metrics collection failed" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "指标采集失败" - } - } - } - }, - "Not monitored" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未监控" - } - } - } - }, - "Sampled at" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "采样时间" + "value" : "“.ssh”文件夹" } } } @@ -294,6 +264,25 @@ } } }, + "%@@%@:%lld" : { + "comment" : "A label showing the username, hostname, and port of a remote SSH host.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@@%2$@:%3$lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@@%2$@:%3$lld" + } + } + } + }, "%@%@ of %@ (%lld%%)" : { "localizations" : { "en" : { @@ -830,16 +819,6 @@ } } }, - "host metrics" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "主机指标" - } - } - } - }, "~" : { "localizations" : { "zh-Hans" : { @@ -962,6 +941,16 @@ } } }, + "A different host already uses this name. Rename this import or deselect it." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已有其他主机使用此名称,请更改导入名称或取消选择。" + } + } + } + }, "A message can include at most 20 attachments." : { "localizations" : { "zh-Hans" : { @@ -1086,12 +1075,12 @@ } } }, - "API keys use iCloud Keychain, even when configuration sync is paused." : { + "Accept these import warnings" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "API密钥使用iCloud钥匙串,即使配置同步已暂停也会继续同步。" + "value" : "接受这些导入提示" } } } @@ -1219,6 +1208,7 @@ } }, "Add a trusted server key now, or let Termind save it on first connection." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1453,6 +1443,16 @@ } } }, + "All selected" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已全选" + } + } + } + }, "Allow Once" : { "localizations" : { "zh-Hans" : { @@ -1483,6 +1483,16 @@ } } }, + "Already imported" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已导入" + } + } + } + }, "Always Ask" : { "localizations" : { "zh-Hans" : { @@ -1565,6 +1575,16 @@ } } }, + "API keys use iCloud Keychain, even when configuration sync is paused." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "API密钥使用iCloud钥匙串,即使配置同步已暂停也会继续同步。" + } + } + } + }, "API Type" : { "localizations" : { "zh-Hans" : { @@ -1884,6 +1904,26 @@ } } }, + "Authorize access to this referenced file, or deselect it." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授权访问此引用文件,或取消选择。" + } + } + } + }, + "Authorize File" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授权访问文件" + } + } + } + }, "Automatic" : { "localizations" : { "zh-Hans" : { @@ -2058,6 +2098,16 @@ } } }, + "Cached metrics are stale" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缓存指标已过期" + } + } + } + }, "Call History" : { "localizations" : { "zh-Hans" : { @@ -2239,6 +2289,7 @@ } }, "Changes take effect only after you save this Host." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -2322,6 +2373,16 @@ } } }, + "Choose .ssh Folder" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择“.ssh”文件夹" + } + } + } + }, "Choose a private key or OpenSSH user certificate file." : { "localizations" : { "zh-Hans" : { @@ -2344,6 +2405,36 @@ } } }, + "Choose Known Hosts File" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择已知主机文件" + } + } + } + }, + "Choose or drop a single .ssh folder." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请选择或拖入一个“.ssh”文件夹。" + } + } + } + }, + "Choose or drop a single known_hosts file." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请选择或拖入一个known_hosts文件。" + } + } + } + }, "Cipher" : { "localizations" : { "zh-Hans" : { @@ -3718,6 +3809,7 @@ } }, "Delete Host Key" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3727,6 +3819,16 @@ } } }, + "Delete Host Keys?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除主机密钥?" + } + } + } + }, "Delete Package" : { "localizations" : { "zh-Hans" : { @@ -3810,6 +3912,7 @@ } }, "Delete this Host Key" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4334,6 +4437,26 @@ } } }, + "Drop a .ssh folder here, or choose a folder." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将“.ssh”文件夹拖到此处,或选择一个文件夹。" + } + } + } + }, + "Drop a known_hosts file here, or choose a file." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将known_hosts文件拖到此处,或选择一个文件。" + } + } + } + }, "Dropped image" : { "localizations" : { "zh-Hans" : { @@ -4686,6 +4809,16 @@ } } }, + "Enter the key passphrase to validate this private key." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入密钥口令以验证此私钥。" + } + } + } + }, "Enter the Passphrase for the selected key." : { "localizations" : { "zh-Hans" : { @@ -5693,6 +5826,16 @@ } } }, + "Hashed Address" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "哈希地址" + } + } + } + }, "Header name" : { "localizations" : { "zh-Hans" : { @@ -5893,6 +6036,16 @@ } } }, + "host metrics" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主机指标" + } + } + } + }, "Host: %@" : { "localizations" : { "zh-Hans" : { @@ -5955,6 +6108,7 @@ } }, "Hosts, identities, snippets, and port forwards." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5964,6 +6118,16 @@ } } }, + "Hosts, identities, snippets, known hosts, and port forwards." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主机、身份、命令片段、已知主机和端口转发。" + } + } + } + }, "How hard the model thinks" : { "localizations" : { "zh-Hans" : { @@ -6004,16 +6168,6 @@ } } }, - "iCloud Sync" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "iCloud同步" - } - } - } - }, "iCloud access is restricted on this Mac." : { "localizations" : { "zh-Hans" : { @@ -6044,6 +6198,16 @@ } } }, + "iCloud Sync" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud同步" + } + } + } + }, "ID" : { "localizations" : { "zh-Hans" : { @@ -6140,6 +6304,46 @@ } } }, + "Import .ssh Folder" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入“.ssh”文件夹" + } + } + } + }, + "Import Complete" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入完成" + } + } + } + }, + "Import failed." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入失败。" + } + } + } + }, + "Import failed. Some newly stored keys could not be removed from Keychain." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入失败,部分新存储的密钥无法从钥匙串中移除。" + } + } + } + }, "Import from File" : { "localizations" : { "zh-Hans" : { @@ -6160,6 +6364,16 @@ } } }, + "Import Known Hosts" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入已知主机" + } + } + } + }, "Import or generate a key, then create an identity with a username. Private keys stay in the Keychain." : { "localizations" : { "zh-Hans" : { @@ -6182,6 +6396,26 @@ } } }, + "Import trusted server keys or add a fingerprint manually." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入受信任的服务器密钥,或手动添加指纹。" + } + } + } + }, + "Imported %lld items." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已导入%lld项。" + } + } + } + }, "Imported From" : { "comment" : "A label for the source of a skill that was imported.", "isCommentAutoGenerated" : true, @@ -6214,6 +6448,16 @@ } } }, + "Include cycle or nesting limit reached." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Include存在循环或超出嵌套限制。" + } + } + } + }, "Included" : { "comment" : "A section of the Pro settings that lists the features included in Termind Pro.", "isCommentAutoGenerated" : true, @@ -6330,6 +6574,26 @@ } } }, + "Invalid SSH port." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效的SSH端口。" + } + } + } + }, + "Invalid SSH record." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效的SSH记录。" + } + } + } + }, "IOPS" : { "localizations" : { "zh-Hans" : { @@ -6579,6 +6843,26 @@ } } }, + "Known Hosts File" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已知主机文件" + } + } + } + }, + "Known Hosts Operation Failed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已知主机操作失败" + } + } + } + }, "Language" : { "localizations" : { "zh-Hans" : { @@ -7001,6 +7285,16 @@ } } }, + "Metrics collection failed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "指标采集失败" + } + } + } + }, "Metrics ready" : { "localizations" : { "zh-Hans" : { @@ -7669,6 +7963,16 @@ } } }, + "No Known Hosts" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无已知主机" + } + } + } + }, "No Match" : { "localizations" : { "zh-Hans" : { @@ -7769,6 +8073,17 @@ } } }, + "No private key selected. Configure credentials after import." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未选择私钥,请在导入后配置凭据。" + } + } + } + }, "No recognizable speech was captured. Try speaking closer to the microphone." : { "localizations" : { "zh-Hans" : { @@ -7885,6 +8200,16 @@ } } }, + "No supported private key in this file." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此文件中没有受支持的私钥。" + } + } + } + }, "No targets" : { "localizations" : { "zh-Hans" : { @@ -7977,6 +8302,16 @@ } } }, + "None selected" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未选择任何项" + } + } + } + }, "Not available on this Mac" : { "localizations" : { "zh-Hans" : { @@ -8017,6 +8352,16 @@ } } }, + "Not monitored" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未监控" + } + } + } + }, "Not saved" : { "localizations" : { "zh-Hans" : { @@ -8131,6 +8476,7 @@ }, "One purchase. Your advanced workspace, AI, MCP, and iCloud tools—forever." : { "comment" : "A description of the benefits of Termind Pro.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "zh-Hans" : { @@ -8519,6 +8865,16 @@ } } }, + "Partially selected" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已部分选择" + } + } + } + }, "Passive Opens" : { "localizations" : { "zh-Hans" : { @@ -9655,6 +10011,16 @@ } } }, + "Referenced file is unavailable:" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "引用的文件不可用:" + } + } + } + }, "Refresh" : { "localizations" : { "zh-Hans" : { @@ -10112,6 +10478,16 @@ } } }, + "Review and accept the import warnings." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请查看并接受导入提示。" + } + } + } + }, "Review Code" : { "localizations" : { "zh-Hans" : { @@ -10352,6 +10728,16 @@ } } }, + "Sampled at" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "采样时间" + } + } + } + }, "Save" : { "localizations" : { "zh-Hans" : { @@ -10692,6 +11078,16 @@ } } }, + "Select the required jump host or deselect this host." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请选择所需的跳板主机,或取消选择此主机。" + } + } + } + }, "Send" : { "localizations" : { "zh-Hans" : { @@ -10947,6 +11343,7 @@ } }, "SHA256:…" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11880,6 +12277,16 @@ } } }, + "Store Keys in" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥存储位置" + } + } + } + }, "Store password in" : { "localizations" : { "zh-Hans" : { @@ -12015,6 +12422,7 @@ }, "Sync your Vault through your private iCloud database" : { "comment" : "A feature of Termind Pro.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "zh-Hans" : { @@ -12141,16 +12549,6 @@ } } }, - "Terminal Input" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "终端输入" - } - } - } - }, "Terminal and AI preferences." : { "localizations" : { "zh-Hans" : { @@ -12161,6 +12559,16 @@ } } }, + "Terminal Input" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端输入" + } + } + } + }, "Terminal ready" : { "localizations" : { "zh-Hans" : { @@ -12545,16 +12953,6 @@ } } }, - "The iCloud Vault record %@ is invalid." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "iCloud默认记录%@无效。" - } - } - } - }, "The iCloud settings record %@ is invalid." : { "localizations" : { "zh-Hans" : { @@ -12565,6 +12963,16 @@ } } }, + "The iCloud Vault record %@ is invalid." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud默认记录%@无效。" + } + } + } + }, "The identity could not be deleted." : { "localizations" : { "zh-Hans" : { @@ -12595,6 +13003,16 @@ } } }, + "The imported jump route is missing, cyclic, or exceeds 8 hops." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入的跳板路径缺失、存在循环或超过8跳。" + } + } + } + }, "The Jump Host route is invalid or exceeds eight hosts." : { "localizations" : { "zh-Hans" : { @@ -12635,6 +13053,16 @@ } } }, + "The key or passphrase is invalid. Retry or deselect this key." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥或口令无效,请重试或取消选择此密钥。" + } + } + } + }, "The key was saved, but its previous Keychain copy could not be removed: %@" : { "localizations" : { "zh-Hans" : { @@ -12685,6 +13113,16 @@ } } }, + "The next connection will ask you to verify the server key again." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下次连接时将要求你重新验证服务器密钥。" + } + } + } + }, "The normalized image is larger than 5 MB." : { "localizations" : { "zh-Hans" : { @@ -12695,6 +13133,16 @@ } } }, + "The original address and port cannot be recovered from this hash." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法从此哈希值还原原始地址和端口。" + } + } + } + }, "The password was saved, but its previous Keychain copy could not be removed: %@" : { "localizations" : { "zh-Hans" : { @@ -13009,6 +13457,16 @@ } } }, + "The SSH files could not be read." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法读取SSH文件。" + } + } + } + }, "The SSH session is not ready for file transfer." : { "localizations" : { "zh-Hans" : { @@ -13069,6 +13527,16 @@ } } }, + "These hosts require conflicting jump routes. Import the routes separately." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这些主机需要的跳板路径存在冲突,请分别导入。" + } + } + } + }, "Thinking…" : { "comment" : "Text displayed in a label while an assistant is working.", "isCommentAutoGenerated" : true, @@ -13081,6 +13549,16 @@ } } }, + "This address already has a different trusted fingerprint. Confirm adding this key." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此地址已有不同的受信任指纹,请确认添加此密钥。" + } + } + } + }, "This address may expose the listener to other devices." : { "localizations" : { "zh-Hans" : { @@ -13182,6 +13660,7 @@ } }, "This host key is already in the list." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13329,6 +13808,7 @@ } }, "This server recently presented a key that is not in the trusted set." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13348,16 +13828,6 @@ } } }, - "This Vault record requires sync format %lld." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此保管库记录需要同步格式%lld。" - } - } - } - }, "This settings record requires sync format %lld." : { "localizations" : { "zh-Hans" : { @@ -13368,6 +13838,26 @@ } } }, + "This SSH record is not supported." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂不支持此SSH记录。" + } + } + } + }, + "This Vault record requires sync format %lld." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此保管库记录需要同步格式%lld。" + } + } + } + }, "Time" : { "extractionState" : "stale", "localizations" : { @@ -13870,8 +14360,19 @@ } } }, + "Unlock Key" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁密钥" + } + } + } + }, "Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase." : { "comment" : "A description of Termind Pro.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "zh-Hans" : { diff --git a/Termind/UI/Components/WindowToolbar.swift b/Termind/UI/Components/WindowToolbar.swift index 1350fa5a..d44cceb1 100644 --- a/Termind/UI/Components/WindowToolbar.swift +++ b/Termind/UI/Components/WindowToolbar.swift @@ -14,6 +14,7 @@ nonisolated enum WindowToolbarFindKind: String, Hashable, Sendable, CaseIterable case processes case keychain case snippets + case knownHosts case assistant case terminal case sftp @@ -549,6 +550,7 @@ struct WindowToolbarLeadingActions: View { } else if !actions.isEmpty { Menu { ForEach(actions) { action in + if action.startsGroup { Divider() } Button( action.localizedTitle(locale: locale), systemImage: action.symbol, @@ -574,15 +576,17 @@ struct WindowToolbarCreationAction: Identifiable { /// menu item and as a `NativeMenuEntry`, and a new verb that forgot its /// glyph would go unnoticed in one of the two. let symbol: String + let startsGroup: Bool let perform: () -> Void init( - id: String, title: String.LocalizationValue, symbol: String, + id: String, title: String.LocalizationValue, symbol: String, startsGroup: Bool = false, perform: @escaping () -> Void ) { self.id = id titleKey = title self.symbol = symbol + self.startsGroup = startsGroup self.perform = perform } @@ -627,6 +631,9 @@ extension WindowModel { /// right-click on that page's row in the Navigator offers, so a host can be /// added without first visiting the page to add it on. func creationActions(for page: VaultPage) -> [WindowToolbarCreationAction] { + let importFolder = WindowToolbarCreationAction( + id: "ssh-folder", title: "Import .ssh Folder", symbol: MenuSymbol.importItem, startsGroup: true + ) { self.openVaultWork(.sshImport(knownHostsOnly: false), on: page) } switch page { case .welcome: return [] @@ -644,6 +651,7 @@ extension WindowModel { .hostGroupEditor(groupID: nil, parentID: nil), on: .hosts ) }, + importFolder, ] case .keychain: return [ @@ -662,6 +670,19 @@ extension WindowModel { ) { self.openVaultWork(.identityEditor(nil), on: .keychain) }, + importFolder, + ] + case .knownHosts: + return [ + WindowToolbarCreationAction(id: "known-host", title: "Add Host Key", symbol: "plus") { + self.openVaultWork(.knownHostEditor(nil), on: .knownHosts) + }, + WindowToolbarCreationAction( + id: "known-hosts-import", title: "Import Known Hosts", symbol: MenuSymbol.importItem + ) { + self.openVaultWork(.sshImport(knownHostsOnly: true), on: .knownHosts) + }, + importFolder, ] case .snippets: return [ @@ -685,12 +706,11 @@ extension WindowModel { /// Those verbs as native menu entries, for the Navigator's own rows. func creationMenu(for page: VaultPage, locale: Locale) -> [NativeMenuEntry] { - creationActions(for: page).map { action in - NativeMenuEntry( - title: action.localizedTitle(locale: locale), - symbol: action.symbol, - action: action.perform + creationActions(for: page).flatMap { action in + let entry = NativeMenuEntry( + title: action.localizedTitle(locale: locale), symbol: action.symbol, action: action.perform ) + return action.startsGroup ? [.separator, entry] : [entry] } } @@ -723,6 +743,12 @@ extension WindowModel { query: Binding(get: { state.query }, set: { state.query = $0 }), focusRequest: findFocusRequest ) + case .knownHosts: + let state = vaultPages.knownHosts + return WindowToolbarFindContext( + query: Binding(get: { state.query }, set: { state.query = $0 }), + focusRequest: findFocusRequest + ) case .snippets: let state = vaultPages.snippets return WindowToolbarFindContext( diff --git a/TermindTests/ConnectionJourneyTests.swift b/TermindTests/ConnectionJourneyTests.swift index 31beff12..896d30d5 100644 --- a/TermindTests/ConnectionJourneyTests.swift +++ b/TermindTests/ConnectionJourneyTests.swift @@ -347,41 +347,16 @@ struct ConnectionJourneyTests { } @Test - func hostEditorCommitNormalizesAndReplacesKnownHostsWithTheHost() { + func hostEditorCommitDoesNotMoveSharedKnownHosts() { var host = Host(name: "Server", host: "old.example") - let oldKey = KnownHostEntry( - hostname: host.host, algorithm: .ed25519, fingerprint: "SHA256:old" - ) - let unrelated = KnownHostEntry( - hostname: "other.example", algorithm: .ed25519, fingerprint: "SHA256:other" - ) - let store = HostLibraryModel(hosts: [host], knownHosts: [oldKey, unrelated]) + let shared = Host(name: "Other Login", host: "old.example") + let entry = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:old") + let store = HostLibraryModel(hosts: [host, shared], knownHosts: [entry]) host.host = "new.example" host.port = 2222 - let manual = KnownHostEntry( - hostname: "", algorithm: .ed25519, fingerprint: " SHA256:manual " - ) - - #expect( - store.upsert( - host, replacingPortForwardings: [], replacingKnownHosts: [manual] - ) - ) - #expect(store.knownHosts.contains(unrelated)) - #expect(!store.knownHosts.contains { $0.hostname == "old.example" }) - let saved = store.knownHosts(for: host) - #expect(saved.count == 1) - #expect(saved[0].hostname == "new.example") - #expect(saved[0].port == 2222) - #expect(saved[0].algorithm == .ed25519) - #expect(saved[0].fingerprint == "SHA256:manual") - - #expect( - !store.upsert( - host, replacingPortForwardings: [], - replacingKnownHosts: [manual, manual] - ) - ) - #expect(store.knownHosts(for: host) == saved) + #expect(store.upsert(host, replacingPortForwardings: [])) + #expect(store.knownHosts == [entry]) + #expect(store.knownHosts(for: host).isEmpty) + #expect(store.knownHosts(for: shared) == [entry]) } } diff --git a/TermindTests/RepositoryOutboxTests.swift b/TermindTests/RepositoryOutboxTests.swift index 82048092..199ef000 100644 --- a/TermindTests/RepositoryOutboxTests.swift +++ b/TermindTests/RepositoryOutboxTests.swift @@ -250,6 +250,25 @@ struct RepositoryOutboxTests { #expect(try store.nextReady(at: Date()) == nil) } + @Test + func deletingHostPreservesItsIndependentKnownHostRecords() throws { + let container = try makeContainer("IndependentKnownHosts") + let context = container.mainContext + let host = Host(name: "Removed", host: "shared.example") + let remaining = Host(name: "Another Login", host: host.host) + let trust = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:trusted") + context.insert(PersistentHost(host)) + context.insert(PersistentHost(remaining)) + context.insert(PersistentKnownHost(trust)) + try context.save() + let repository = SwiftDataHostRepository(context: context) + try repository.requestHostDeletion(host.id) + try repository.clearReferencesForDeletedHost(host.id) + try repository.finalizeHostDeletion(host.id) + #expect(try repository.snapshot().value.hosts.map(\.id) == [remaining.id]) + #expect(try context.fetch(FetchDescriptor()).map(\.value) == [trust]) + } + private func makeContainer(_ name: String) throws -> ModelContainer { let configuration = localModelConfiguration( name, schema: HostPersistence.schema, isStoredInMemoryOnly: true diff --git a/TermindTests/SSHImportTests.swift b/TermindTests/SSHImportTests.swift new file mode 100644 index 00000000..0ddbbd54 --- /dev/null +++ b/TermindTests/SSHImportTests.swift @@ -0,0 +1,334 @@ +import CryptoKit +import Foundation +import Testing + +@testable import Termind + +@Suite +@MainActor +struct SSHImportTests { + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("SSHImport-\(UUID())") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func hash(_ address: String) -> String { + let salt = Data(repeating: 7, count: 20) + let digest = HMAC.authenticationCode(for: Data(address.utf8), using: SymmetricKey(data: salt)) + return "|1|\(salt.base64EncodedString())|\(Data(digest).base64EncodedString())" + } + + @Test + func hashedKeysMatchPortsAndSurvivePersistenceWithoutHosts() throws { + let entry = KnownHostEntry(hostname: hash("[2001:db8::1]:2222"), fingerprint: "SHA256:key") + let decoded = try JSONDecoder().decode(KnownHostEntry.self, from: JSONEncoder().encode(entry)) + #expect(decoded.matches(hostname: "2001:db8::1", port: 2222)) + #expect(!decoded.matches(hostname: "2001:db8::1", port: 22)) + let store = HostLibraryModel(knownHosts: [decoded]) + let host = Host(name: "IPv6", host: "2001:db8::1", port: 2222) + #expect(try store.expectedHostKeys(for: host) == [entry.identity]) + store.markKnownHostMismatched(hostname: host.host, port: host.port) + #expect(store.knownHosts[0].isMismatched) + store.forgetKnownHosts(for: host) + #expect(store.knownHosts.isEmpty) + } + + @Test + func configHonorsFirstValuesHostPatternsAndOrderedIncludes() { + let root = URL(fileURLWithPath: "/fixture/.ssh") + var parser = SSHConfigParser( + root: root, home: URL(fileURLWithPath: "/fixture"), username: "local", + files: [ + "/fixture/.ssh/config": """ + Host prod alias + HostName prod.example + User deploy + IdentityFile "~/.ssh/deploy key" + Include conf/*.conf + Host * !alias + Port 2222 + Host * + User fallback + """, + "/fixture/.ssh/conf/01.conf": "Port=2200\nIdentityFile ~/.ssh/second", + "/fixture/.ssh/conf/02.conf": "Port 2300", + ]) + let entries = parser.parse().compactMap(\.host) + #expect(entries.count == 2) + #expect(entries.allSatisfy { $0.port == 2200 && $0.username == "deploy" }) + #expect(entries[0].identityPaths == ["/fixture/.ssh/deploy key", "/fixture/.ssh/second"]) + } + + @Test + func configReportsCyclesAndDoesNotExecuteMatchOrProxyCommands() { + let root = URL(fileURLWithPath: "/fixture") + var parser = SSHConfigParser( + root: root, home: root, username: "local", + files: [ + "/fixture/config": + "Host app\nHostName app.example\nProxyCommand touch /tmp/never\nInclude loop\nMatch exec \"exit 0\"\nUser wrong", + "/fixture/loop": "Include config", + ]) + let entries = parser.parse() + let host = entries.first { $0.host != nil } + #expect(host?.host?.username == "local") + #expect(host?.warnings.contains(where: { $0.contains("proxycommand") }) == true) + #expect(host?.warnings.contains(where: { $0.contains("Match") }) == true) + #expect(entries.contains { $0.unavailable }) + } + + @Test + func knownHostsValidatesWireKeysAndPreservesMultipleEndpoints() throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + let content = """ + # comment + example,[2001:db8::1]:2222 \(material.publicKey) + \(hash("other.example")) \(material.publicKey) + @revoked revoked.example \(material.publicKey) + *.wild.example \(material.publicKey) + broken ssh-ed25519 AAAA + """ + let rows = SSHImportParser.knownHosts(content, source: "known_hosts") + #expect(rows.filter { !$0.unavailable }.count == 3) + #expect(rows.compactMap(\.knownHost).allSatisfy { $0.fingerprint == material.fingerprint }) + #expect(rows.filter(\.unavailable).count == 3) + #expect(rows.compactMap(\.knownHost).contains { $0.matches(hostname: "other.example", port: 22) }) + } + + @Test + func droppedSourcesUseTheSameTypeValidationAsTheFilePicker() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let file = url.appendingPathComponent("known_hosts") + try "# empty".write(to: file, atomically: true, encoding: .utf8) + let reader = SSHImportReader() + await #expect(throws: SSHImportSourceError.expectedFolder) { + _ = try await reader.scan(root: file, knownHostsOnly: false) + } + await #expect(throws: SSHImportSourceError.expectedFile) { + _ = try await reader.scan(root: url, knownHostsOnly: true) + } + #expect(try await reader.scan(root: file, knownHostsOnly: true).isEmpty) + } + + @Test + func malformedPrivateKeysAreUnavailableRatherThanAskingForAPassphrase() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + try "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----".write( + to: url.appendingPathComponent("broken"), atomically: true, encoding: .utf8) + let reader = SSHImportReader() + let rows = try await reader.scan(root: url, knownHostsOnly: false) + #expect(rows.count == 1) + #expect(rows[0].unavailable && !rows[0].selected && !rows[0].requiresPassphrase) + } + + @Test + func selectionStartsCheckedAndCollapseDoesNotChangeIt() { + let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) + model.items = [ + SSHImportItem(category: .hosts, name: "a", source: "config"), + SSHImportItem(category: .keys, name: "b", source: "key"), + SSHImportItem(category: .knownHosts, name: "c", source: "known_hosts"), + SSHImportItem(category: .keys, name: "bad", source: "bad", selected: false, unavailable: true), + ] + #expect(model.selectedItems().count == 3) + model.expanded.remove(.keys) + #expect(model.selectedItems().count == 3) + model.setSelected(false, category: .keys) + #expect(model.selectedItems().count == 2) + model.setSelected(false) + #expect(!model.canImport) + model.setSelected(true) + #expect(model.selectedItems().count == 3) + #expect(!model.items[3].selected) + } + + @Test + func folderImportMigratesSecretsAndIsIdempotent() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + try material.privateKey.write(to: url.appendingPathComponent("id_ed25519"), atomically: true, encoding: .utf8) + try "Host prod\n HostName prod.example\n User deploy\n IdentityFile ~/.ssh/id_ed25519\n".write( + 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 model = SSHImportModel(library: store, reader: SSHImportReader()) + await model.scan(url, knownHostsOnly: false) + #expect(model.selectedItems().count == 3) + #expect(store.hosts.isEmpty && store.keys.isEmpty) + #expect(model.canImport) + await model.commit() + #expect(model.completed) + #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.keyAvailability(for: try #require(store.keys.first)) == .available) + let again = SSHImportModel(library: store, reader: SSHImportReader()) + await again.scan(url, knownHostsOnly: false) + #expect(again.selectedItems().isEmpty) + #expect(!again.canImport) + } + + @Test + func encryptedKeyCanBeUnlockedAndDeselectionRefreshesHostAssociation() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "test-password") + try material.privateKey.write(to: url.appendingPathComponent("deploy"), atomically: true, encoding: .utf8) + try "Host prod\nIdentityFile ~/.ssh/deploy".write( + to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) + let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) + await model.scan(url, knownHostsOnly: false) + let id = try #require(model.items.first { $0.category == .keys }?.id) + #expect(!model.canImport) + await model.unlock(id, passphrase: "wrong") + #expect(!model.canImport) + await model.unlock(id, passphrase: "test-password") + #expect(model.canImport) + let host = try #require(model.items.first { $0.host != nil }) + #expect(model.chosenKey(for: host)?.id == id) + model.setSelected(false, category: .keys) + #expect(model.chosenKey(for: host) == nil) + #expect(model.canImport) + } + + @Test + func jumpDependenciesAndCyclesBlockCommit() { + let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) + model.items = [ + SSHImportItem( + category: .hosts, name: "app", source: "config", + host: SSHImportedHost(alias: "app", hostname: "app", username: "u", jumps: ["jump"])), + SSHImportItem( + category: .hosts, name: "jump", source: "config", + host: SSHImportedHost(alias: "jump", hostname: "jump", username: "u")), + ] + #expect(model.canImport) + model.items[1].selected = false + #expect(!model.canImport) + model.items[1].selected = true + model.items[1].host?.jumps = ["app"] + #expect(!model.canImport) + #expect(model.routeProblem != nil) + } + + @Test + func importRetainsMismatchWarningsAndRequiresConflictReview() async { + let original = KnownHostEntry(hostname: "host", fingerprint: "SHA256:old", isMismatched: true) + let store = HostLibraryModel(knownHosts: [original]) + let model = SSHImportModel(library: store, reader: SSHImportReader()) + model.items = [ + SSHImportItem( + category: .knownHosts, name: "host", source: "known_hosts", + knownHost: KnownHostEntry(hostname: "host", fingerprint: "SHA256:new")) + ] + #expect(!model.canImport) + model.items[0].acceptedWarnings = true + #expect(model.canImport) + await model.commit() + #expect(model.completed) + #expect(store.knownHosts.first?.isMismatched == true) + } + + @Test + func multiHopImportsPreserveStandaloneJumpConfigurations() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + try "Host app\nProxyJump first,second\nHost first\nHost second".write( + to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) + let store = HostLibraryModel() + let model = SSHImportModel(library: store, reader: SSHImportReader()) + await model.scan(url, knownHostsOnly: false) + #expect(model.canImport) + await model.commit() + #expect(model.completed) + let second = try #require(store.hosts.first { $0.name == "second" }) + #expect(second.jumpHostID == nil) + let app = try #require(store.hosts.first { $0.name == "app" }) + let intermediate = try #require(app.jumpHostID.flatMap(store.host)) + #expect(intermediate.host == "second") + #expect(intermediate.jumpHostID.flatMap(store.host)?.name == "first") + let again = SSHImportModel(library: store, reader: SSHImportReader()) + await again.scan(url, knownHostsOnly: false) + #expect(again.selectedItems().isEmpty) + } + + @Test + func externalKeyIsOnlyReadAfterExplicitAuthorization() async throws { + let url = try root() + let external = try root() + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: external) + } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + let keyURL = external.appendingPathComponent("deploy") + try material.privateKey.write(to: keyURL, atomically: true, encoding: .utf8) + try "Host app\nIdentityFile \(keyURL.path)".write( + to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) + let reader = SSHImportReader() + let before = try await reader.scan(root: url, knownHostsOnly: false) + #expect(before.compactMap(\.key).isEmpty) + #expect(before.contains { $0.needsAuthorization == keyURL.path }) + let after = try await reader.scan(root: url, knownHostsOnly: false, grants: [keyURL]) + #expect(after.compactMap(\.key).count == 1) + #expect(!after.contains { $0.needsAuthorization != nil }) + } + + @Test + func keychainPagePruningDoesNotEraseKnownHostSelection() { + let entry = KnownHostEntry(hostname: "host", fingerprint: "SHA256:key") + let store = HostLibraryModel(knownHosts: [entry]) + let window = WindowModel() + window.selectVaultItem(.knownHost(entry.id), on: .knownHosts) + window.pruneVaultSelections(in: CredentialLibraryModel(library: store)) + #expect(window.vaultSelection(for: .knownHosts) == [.knownHost(entry.id)]) + } + + @Test + func failedSnapshotRollsBackMetadataAndNewCredentials() throws { + let persistence = FailingImportPersistence() + let secrets = InMemorySecretStore() + let store = HostLibraryModel(persistence: persistence, secrets: secrets) + let key = SSHKeyRef(label: "Imported", credentialRef: "import-test") + let batch = SSHImportBatch( + hosts: [Host(name: "new", host: "new")], keys: [key], + knownHosts: [KnownHostEntry(hostname: "new", fingerprint: "SHA256:key")], + credentials: [key.id: .software(SSHPrivateKeySecret(privateKey: "test-private-material"))]) + #expect(throws: (any Error).self) { try store.commitSSHImport(batch) } + #expect(store.hosts.isEmpty && store.keys.isEmpty && store.knownHosts.isEmpty) + #expect(try secrets.keyCredential(for: key.credentialRef, storage: key.storage) == nil) + #expect(!store.saveKnownHost(KnownHostEntry(hostname: "new", fingerprint: "SHA256:key"))) + #expect(store.knownHosts.isEmpty) + } + + @Test + func vaultKnownHostNavigationUsesFingerprintAndRetainsSelection() { + let entry = KnownHostEntry(hostname: "host", fingerprint: "SHA256:key") + let store = HostLibraryModel(knownHosts: [entry]) + let window = WindowModel() + window.selectVaultItem(.knownHost(entry.id), on: .knownHosts) + window.vaultPages.knownHosts.query = "key" + window.openPage(.hosts) + window.openPage(.knownHosts) + window.pruneVaultSelections(in: store) + #expect(window.vaultSelection(for: .knownHosts) == [.knownHost(entry.id)]) + #expect(window.vaultPages.knownHosts.query == "key") + #expect(window.findKind == .knownHosts) + #expect(VaultPage.knownHosts.symbol == "touchid") + #expect(VaultPage.allCases.last == .knownHosts) + } +} + +@MainActor +private final class FailingImportPersistence: VaultPersisting { + func load() throws -> VaultSnapshot { VaultSnapshot() } + func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { throw CocoaError(.fileWriteUnknown) } + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { + throw CocoaError(.fileWriteUnknown) + } +} diff --git a/TermindTests/SSHUserCertificateTests.swift b/TermindTests/SSHUserCertificateTests.swift index 7f541c12..bda5eb5a 100644 --- a/TermindTests/SSHUserCertificateTests.swift +++ b/TermindTests/SSHUserCertificateTests.swift @@ -62,6 +62,30 @@ struct SSHUserCertificateTests { #expect(parsed.validityWarning != nil) } + @Test + @MainActor + func folderImportAssociatesExplicitCertificatesWithTheirHost() 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: nil) + 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 + "Host certified\nIdentityFile ~/.ssh/deploy\nCertificateFile ~/.ssh/user-cert.pub\nHost plain\nIdentityFile ~/.ssh/deploy" + .write( + to: root.appendingPathComponent("config"), atomically: true, encoding: .utf8) + let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader()) + await model.scan(root, knownHostsOnly: false) + let certified = try #require(model.items.first { $0.name == "certified" }) + let plain = try #require(model.items.first { $0.name == "plain" }) + #expect(model.chosenKey(for: certified)?.certificate == certificate) + #expect(model.chosenKey(for: plain)?.certificate == "") + #expect(model.canImport) + } + private func makeEd25519Certificate( publicKey: String, type: UInt32, validAfter: UInt64, validBefore: UInt64 ) throws -> String { diff --git a/TermindTests/VaultSyncTests.swift b/TermindTests/VaultSyncTests.swift index 488c3d34..60c6d15e 100644 --- a/TermindTests/VaultSyncTests.swift +++ b/TermindTests/VaultSyncTests.swift @@ -37,6 +37,55 @@ struct VaultSyncTests { } } + @Test + func importedKnownHostsSyncWithoutHostsAndPropagateEditsAndDeletion() throws { + let source = try makeContainer("KnownHostsSource") + let destination = try makeContainer("KnownHostsDestination") + let sourcePersistence = HostPersistence(context: source.mainContext) + let destinationPersistence = HostPersistence(context: destination.mainContext) + let library = HostLibraryModel(persistence: sourcePersistence) + let journal = VaultSyncJournal(context: source.mainContext) + let hashedAddress = + "|1|" + Data(repeating: 7, count: 20).base64EncodedString() + + "|" + Data(repeating: 9, count: 20).base64EncodedString() + let plain = KnownHostEntry(hostname: "sync.example", port: 2222, fingerprint: "SHA256:plain") + let hashed = KnownHostEntry(hostname: hashedAddress, fingerprint: "SHA256:hashed") + var published = VaultMutationSet.empty + library.didRecordVaultChanges = { published = $0 } + + try library.commitSSHImport(SSHImportBatch(knownHosts: [plain, hashed])) + #expect(published.keys == Set([plain, hashed].map { VaultRecordKey(kind: .knownHost, id: $0.id) })) + #expect(try source.mainContext.fetch(FetchDescriptor()).allSatisfy(\.needsUpload)) + + func transfer(_ id: UUID) throws { + let key = VaultRecordKey(kind: .knownHost, id: id) + let envelope = try #require(try journal.envelope(for: key)) + let recordID = CKRecord.ID( + recordName: key.recordName, + zoneID: CKRecordZone.ID( + zoneName: VaultSyncCoordinator.zoneName, ownerName: CKCurrentUserDefaultName)) + let record = try VaultSyncCoordinator.makeRecord(VaultOutgoingEntry(envelope: envelope), recordID: recordID) + let received = try VaultSyncCoordinator.envelope(from: record) + _ = try destinationPersistence.applyRemote([received]) + } + + try transfer(plain.id) + try transfer(hashed.id) + let received = try destinationPersistence.load() + #expect(received.hosts.isEmpty) + #expect(Set(received.knownHosts) == Set([plain, hashed])) + + var edited = plain + edited.isMismatched = true + #expect(library.saveKnownHost(edited)) + try transfer(plain.id) + #expect(try destinationPersistence.load().knownHosts.first { $0.id == plain.id }?.isMismatched == true) + + #expect(library.deleteKnownHosts([plain.id])) + try transfer(plain.id) + #expect(try destinationPersistence.load().knownHosts == [hashed]) + } + @Test func legacyCredentialPayloadsWithoutColorsDecodeAsNone() throws { let writer = UUID() diff --git a/TermindTests/WindowToolbarFindTests.swift b/TermindTests/WindowToolbarFindTests.swift index 49d969be..747ca78f 100644 --- a/TermindTests/WindowToolbarFindTests.swift +++ b/TermindTests/WindowToolbarFindTests.swift @@ -129,20 +129,33 @@ struct WindowToolbarFindTests { let window = WindowModel() window.openPage(.hosts) - #expect(window.creationActions.map(\.id) == ["host", "group"]) + #expect(window.creationActions.map(\.id) == ["host", "group", "ssh-folder"]) // Switching the sidebar to the Assistant must not put New Chat in the // titlebar while Hosts is still the page being looked at. window.sidebarMode = .assistant - #expect(window.creationActions.map(\.id) == ["host", "group"]) + #expect(window.creationActions.map(\.id) == ["host", "group", "ssh-folder"]) window.openPage(.keychain) - #expect(window.creationActions.map(\.id) == ["import", "generate", "identity"]) + #expect(window.creationActions.map(\.id) == ["import", "generate", "identity", "ssh-folder"]) window.openPage(.snippets) #expect(window.creationActions.map(\.id) == ["snippet", "package"]) } + @Test + func sshFolderImportIsLastAndStartsASeparateMenuGroup() { + let window = WindowModel() + for page in [VaultPage.hosts, .keychain, .knownHosts] { + let actions = window.creationActions(for: page) + #expect(actions.last?.id == "ssh-folder") + #expect(actions.last?.startsGroup == true) + let menu = window.creationMenu(for: page, locale: Locale(identifier: "en")) + #expect(menu[menu.count - 2].isSeparator) + #expect(menu.last?.title == "Import .ssh Folder") + } + } + /// Each Vault page keeps its own query, and keeps it across a page switch — /// the field lives in the titlebar and outlives the page under it. @Test