Add Known Hosts manager and SSH folder import

This commit is contained in:
Kaede Akino committed 2026-09-11 00:00:38 +08:00
1 parent f0fdcf4ddb
commit afa77ae16a
33 files changed
+2848 -388

No files matched your search

+14 -4
View File
@@ -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
+6
View File
@@ -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<UUID>()
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)
@@ -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<UUID>) -> 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
}
@@ -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<UUID>) -> Bool { library.deleteKnownHosts(ids) }
}
@@ -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[..<index].first(where: {
$0.key?.fingerprint == key.fingerprint && $0.key?.certificate == key.certificate
}) {
items[index].existingID = duplicate.existingID ?? duplicate.id
items[index].selected = false
}
}
if let entry = items[index].knownHost {
let same: (KnownHostEntry) -> 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[..<index].first(where: { $0.knownHost.map(same) == true }) {
items[index].existingID = previous.existingID ?? previous.id
items[index].selected = false
}
}
}
for index in items.indices where items[index].host != nil {
if let existing = identicalHost(items[index]) {
items[index].existingID = existing.id
items[index].selected = false
}
}
}
func selectable(_ item: SSHImportItem) -> 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<UUID> = []
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<UUID>) -> 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<UUID> = []
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<UUID> = []
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.")
}
}
}
@@ -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] = [:]
}
@@ -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<UUID>) async throws -> [UUID: StoredSSHKeyCredential]
func discard() async
}
+24
View File
@@ -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
}
}
@@ -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<Insecure.SHA1>.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)
}
}
@@ -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<String> = []
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)")
}
}
}
}
@@ -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.")
}
}
}
@@ -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)..<close]), port)
}
guard !value.isEmpty else { return nil }
return (value, 22)
}
static func knownHosts(_ text: String, source: String) -> [SSHImportItem] {
var result: [SSHImportItem] = []
for (offset, raw) in text.components(separatedBy: .newlines).enumerated() {
let fields = raw.split(whereSeparator: \.isWhitespace).map(String.init)
guard let first = fields.first, !first.hasPrefix("#") else { continue }
let location = "\(source):\(offset + 1)"
do {
guard !first.hasPrefix("@") else { throw ImportParseError.unsupported }
guard fields.count >= 3, let algorithm = SSHKeyAlgorithm(rawValue: fields[1]),
let blob = Data(base64Encoded: fields[2]), validPublicKey(blob, algorithm: algorithm)
else { throw ImportParseError.invalid }
let fingerprint =
"SHA256:"
+ Data(SHA256.hash(data: blob)).base64EncodedString()
.replacingOccurrences(of: "=", with: "")
let addresses = first.split(separator: ",", omittingEmptySubsequences: false).map(String.init)
// Negation belongs to the whole pattern list, not to one split entry.
guard !addresses.contains(where: { $0.contains(where: { "*!?".contains($0) }) }) else {
throw ImportParseError.unsupported
}
for address in addresses {
let endpoint = address.hasPrefix("|") ? (address, 22) : endpoint(address)
guard let (hostname, port) = endpoint else { throw ImportParseError.invalid }
let entry = KnownHostEntry(
hostname: hostname, port: port, algorithm: algorithm, fingerprint: fingerprint, addedAt: Date()
)
guard entry.isValid else { throw ImportParseError.invalid }
result.append(
SSHImportItem(
category: .knownHosts, name: entry.displayAddress, source: location, knownHost: entry
))
}
} catch {
result.append(
SSHImportItem(
category: .knownHosts, name: first, source: location, selected: false,
issue: error.localizedDescription, unavailable: true
))
}
}
return result
}
/// Validate the wire key structure as well as its outer algorithm label.
static func validPublicKey(_ blob: Data, algorithm: SSHKeyAlgorithm) -> Bool {
var cursor = 0
func field() -> Data? {
guard cursor + 4 <= blob.count else { return nil }
let length = blob[cursor..<cursor + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
cursor += 4
guard Int(length) <= blob.count - cursor else { return nil }
defer { cursor += Int(length) }
return blob.subdata(in: cursor..<cursor + Int(length))
}
guard let name = field(), String(data: name, encoding: .utf8) == algorithm.rawValue else { return false }
switch algorithm {
case .ed25519:
guard field()?.count == 32 else { return false }
case .rsa:
guard let exponent = field(), !exponent.isEmpty, let modulus = field(), !modulus.isEmpty else {
return false
}
case .ecdsa256, .ecdsa384, .ecdsa521:
let curve = algorithm.rawValue.replacingOccurrences(of: "ecdsa-sha2-", with: "")
guard let name = field(), String(data: name, encoding: .utf8) == curve,
let point = field(), point.first == 4,
point.count == (algorithm == .ecdsa256 ? 65 : algorithm == .ecdsa384 ? 97 : 133)
else { return false }
}
return cursor == blob.count
}
}
nonisolated enum ImportParseError: LocalizedError {
case invalid, unsupported
var errorDescription: String? {
switch self {
case .invalid: String(localized: "Invalid SSH record.")
case .unsupported: String(localized: "This SSH record is not supported.")
}
}
}
@@ -19,8 +19,6 @@ struct HostEditorView: View {
@State private var draft = Host(name: "", host: "")
@State private var draftForwardings: [PortForwardingRule] = []
@State private var draftKnownHosts: [KnownHostEntry] = []
@State private var originalKnownHosts: [KnownHostEntry] = []
@State private var route: Route?
@State private var saveError: String?
@State private var didLoad = false
@@ -161,8 +159,6 @@ struct HostEditorView: View {
openEditor: { route = .forwarding($0) }
)
KnownHostsEditorSection(entries: $draftKnownHosts)
Section("Group & Tags") {
Picker("Group", selection: groupChoice) {
Text("None").tag(Choice<HostGroup.ID>.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<KnownHostEntry>) -> 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<SSHKeyAlgorithm>) -> 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 = ""
}
}
@@ -251,6 +251,10 @@ struct HostsPageView: ManagedCardPage {
},
.separator,
tagFilterMenu,
.separator,
NativeMenuEntry(title: String(localized: "Import .ssh Folder"), symbol: MenuSymbol.importItem) {
openWork(.sshImport(knownHostsOnly: false))
},
]
}
@@ -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)
},
]
}
@@ -0,0 +1,190 @@
import SwiftUI
@Observable
final class KnownHostsPageState {
var query = ""
var deleting: Set<UUID> = []
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<String, KnownHostEntry>] {
[
ManagedCardSection(
id: "knownHosts", title: String(localized: "Known Hosts"),
items: app.knownHosts.search(state.query), canAcceptDrop: false, showsWhenEmpty: false)
]
}
var managedSelection: ManagedCardSelection<UUID> {
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<String, UUID> { ManagedCardCapabilities() }
var isManagedSourceEmpty: Bool { app.knownHosts.entries.isEmpty }
func managedHeader(for section: ManagedCardSection<String, KnownHostEntry>, isDropTarget: Bool) -> some View {
CardSectionHeader(title: section.title, count: section.items.count)
}
func managedCard(for item: KnownHostEntry, context: ManagedCardItemContext<KnownHostEntry>) -> 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 }
}
}
}
@@ -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<UUID> = []
@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<SSHImportItem>) -> 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()
}
}
}
+1 -1
View File
@@ -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
+9
View File
@@ -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?)
+12 -4
View File
@@ -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<VaultSelectionID>) {
for page in VaultPage.allCases {
func prune(available: Set<VaultSelectionID>, pages: Set<VaultPage> = 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
}
}
}
+5
View File
@@ -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):
@@ -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
}
}
}
@@ -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
}
}
@@ -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<UUID>) 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..<offset + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
guard Int(length) <= data.count - offset - 4 else { return false }
let cipher = String(data: data.subdata(in: offset + 4..<offset + 4 + Int(length)), encoding: .utf8)
return cipher != nil && cipher != "none"
}
private func isPrivateKey(_ text: String) -> 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) }
}
}
@@ -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
}
}
File diff suppressed because it is too large. Load diff
+32 -6
View File
@@ -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(
+8 -33
View File
@@ -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])
}
}
+19
View File
@@ -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<PersistentKnownHost>()).map(\.value) == [trust])
}
private func makeContainer(_ name: String) throws -> ModelContainer {
let configuration = localModelConfiguration(
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
+334
View File
@@ -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<Insecure.SHA1>.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)
}
}
@@ -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 {
+49
View File
@@ -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<PersistentVaultSyncEntry>()).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()
+16 -3
View File
@@ -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