diff --git a/FrameworkTests/KeyExportTests.swift b/FrameworkTests/KeyExportTests.swift new file mode 100644 index 00000000..e817fed0 --- /dev/null +++ b/FrameworkTests/KeyExportTests.swift @@ -0,0 +1,214 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite @MainActor +struct KeyExportTests { + @Test func openingAndEmptySelectionNeverInstallAnything() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder() + let model = fixture.model(recorder) + #expect(model.selected.isEmpty) + await model.export(to: []) + #expect(await recorder.calls.isEmpty) + #expect(!model.isFinished) + } + + @Test func missingPublicKeyCannotBeExported() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder() + let model = KeyExportModel( + keys: [SSHKeyRef(label: "Missing")], credentials: fixture.credentials, + services: KeyExportServices(install: recorder.install)) + await model.export(to: [fixture.alpha.id]) + #expect(await recorder.calls.isEmpty) + } + + @Test func submitRevalidatesChangedHostCapabilities() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder() + let model = fixture.model(recorder) + var changed = fixture.alpha + changed.sshEnabled = false + changed.telnetEnabled = true + #expect(fixture.library.saveHost(changed)) + await model.export(to: [fixture.alpha.id, fixture.beta.id]) + #expect(model.isFinished) + if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected unavailable host failure") } + #expect(model.states[fixture.beta.id] == .succeeded) + #expect(await recorder.calls.map(\.hostID) == [fixture.beta.id]) + } + + @Test func preparationFailureDoesNotPreventOtherDestinations() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder() + let model = fixture.model(recorder) + var changed = fixture.alpha + changed.jumpHostID = fixture.untrusted.id + #expect(fixture.library.saveHost(changed)) + await model.export(to: [fixture.alpha.id, fixture.beta.id]) + if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected route preparation failure") } + #expect(model.states[fixture.beta.id] == .succeeded) + #expect(await recorder.calls.map(\.hostID) == [fixture.beta.id]) + } + + @Test func installsOnlyTheChosenPublicKeyAndReportsPartialFailure() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder(failingHost: fixture.beta.id) + let model = fixture.model(recorder) + await model.export(to: [fixture.alpha.id, fixture.beta.id]) + let calls = await recorder.calls + #expect(Set(calls.map(\.hostID)) == [fixture.alpha.id, fixture.beta.id]) + #expect(calls.allSatisfy { $0.publicKeys == [fixture.key.publicKey] && $0.names == [fixture.key.label] }) + #expect(calls.allSatisfy { $0.username == "deploy" && !$0.trustedKeys.isEmpty }) + #expect(model.states[fixture.alpha.id] == .succeeded) + #expect(model.states[fixture.beta.id] == .failed("Fixture export failure")) + #expect(model.isFinished && !model.isExporting) + } + + @Test func exportsEverySelectedKeyOncePerRemainingHost() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder() + let keys = [fixture.key, SSHKeyRef(label: "Backup", publicKey: "ssh-ed25519 backup-public-key")] + let model = KeyExportModel( + keys: keys, credentials: fixture.credentials, services: KeyExportServices(install: recorder.install)) + model.updateSelection([fixture.alpha.id, fixture.beta.id]) + fixture.library.projectHostDeletion(fixture.alpha.id) + await model.export(to: [fixture.alpha.id, fixture.beta.id, fixture.beta.id]) + let calls = await recorder.calls + #expect(calls.count == 1) + #expect(calls.first?.hostID == fixture.beta.id) + #expect(calls.first?.publicKeys == keys.map(\.publicKey)) + #expect(calls.first?.names == keys.map(\.label)) + #expect(model.targets.map(\.id) == [fixture.beta.id]) + #expect(model.selected == [fixture.beta.id]) + #expect(model.states == [fixture.beta.id: .succeeded]) + } + + @Test func removedDestinationsAndEmptyKeyBatchesNeverStartAnExport() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder() + let empty = KeyExportModel( + keys: [], credentials: fixture.credentials, services: KeyExportServices(install: recorder.install)) + await empty.export(to: [fixture.alpha.id]) + #expect(!empty.isExporting && !empty.isFinished) + let model = fixture.model(recorder) + fixture.library.projectHostDeletion(fixture.alpha.id) + await model.export(to: [fixture.alpha.id]) + #expect(!model.isExporting && !model.isFinished) + #expect(model.selected.isEmpty && model.targets.isEmpty) + #expect(await recorder.calls.isEmpty) + } + + @Test func revokedTrustFailsWithoutConnectingAndDoesNotBlockOtherHosts() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder() + let model = fixture.model(recorder) + let trust = fixture.library.knownHosts.filter { $0.hostname == fixture.alpha.host } + #expect(fixture.library.deleteKnownHosts(Set(trust.map(\.id)))) + await model.export(to: [fixture.alpha.id, fixture.beta.id, fixture.untrusted.id]) + #expect(await recorder.calls.map(\.hostID) == [fixture.beta.id]) + if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected revoked trust failure") } + if case .failed = model.states[fixture.untrusted.id] {} else { Issue.record("Expected untrusted host failure") } + #expect(model.states[fixture.beta.id] == .succeeded) + } + + @Test func runningExportRejectsDuplicateSubmissions() async { + let fixture = KeyExportFixture() + let recorder = KeyExportRecorder(blocks: true) + let model = fixture.model(recorder) + let task = Task { await model.export(to: [fixture.alpha.id]) } + await recorder.waitUntilStarted() + #expect(model.isExporting && !model.isFinished) + #expect(model.states[fixture.alpha.id] == .running) + await model.export(to: [fixture.alpha.id, fixture.beta.id]) + #expect(model.selected == [fixture.alpha.id]) + #expect(await recorder.calls.count == 1) + await recorder.resume() + await task.value + #expect(model.isFinished && !model.isExporting) + #expect(model.states[fixture.alpha.id] == .succeeded) + await model.export(to: [fixture.alpha.id]) + #expect(await recorder.calls.count == 1) + } +} + +@MainActor +struct KeyExportFixture { + let key = SSHKeyRef(label: "Deployment Key", publicKey: "ssh-ed25519 fixture-public-key") + let alpha: TermindDomain.Host + let beta: TermindDomain.Host + let untrusted: TermindDomain.Host + let library: HostLibraryModel + let credentials: CredentialLibraryModel + + init() { + let identity = Identity(name: "Deployment", username: "deploy") + alpha = TermindDomain.Host(name: "Alpha", host: "alpha.example.invalid", identityID: identity.id) + beta = TermindDomain.Host(name: "Beta", host: "beta.example.invalid", identityID: identity.id) + untrusted = TermindDomain.Host(name: "Untrusted", host: "untrusted.example.invalid", identityID: identity.id) + var telnet = TermindDomain.Host(name: "Telnet", host: "telnet.example.invalid", identityID: identity.id) + telnet.sshEnabled = false + telnet.telnetEnabled = true + library = HostLibraryModel( + hosts: [alpha, beta, untrusted, telnet], identities: [identity], + knownHosts: [alpha, beta].map { KnownHostEntry(hostname: $0.host, fingerprint: "SHA256:fixture") }) + credentials = CredentialLibraryModel(library: library) + } + + func model(_ recorder: KeyExportRecorder) -> KeyExportModel { + KeyExportModel( + keys: [key], credentials: credentials, services: KeyExportServices(install: recorder.install)) + } +} + +actor KeyExportRecorder { + struct Call: Sendable { + let hostID: UUID + let publicKeys: [String] + let names: [String] + let username: String + let trustedKeys: Set + } + + private(set) var calls: [Call] = [] + private let failingHost: UUID? + private let blocks: Bool + private var started: CheckedContinuation? + private var release: CheckedContinuation? + + init(failingHost: UUID? = nil, blocks: Bool = false) { + self.failingHost = failingHost + self.blocks = blocks + } + + func install(_ keys: [SSHAuthorizedKeyInstall], _ configuration: SSHConnectionConfiguration) async throws { + calls.append( + Call( + hostID: configuration.hostID, publicKeys: keys.map(\.publicKey), names: keys.map(\.name), + username: configuration.username, trustedKeys: configuration.expectedHostKeys)) + if blocks { + await withCheckedContinuation { continuation in + release = continuation + started?.resume() + started = nil + } + } + if configuration.hostID == failingHost { throw ExportFailure() } + } + + func waitUntilStarted() async { + if !calls.isEmpty { return } + await withCheckedContinuation { started = $0 } + } + + func resume() { + release?.resume() + release = nil + } + + private struct ExportFailure: LocalizedError { + var errorDescription: String? { "Fixture export failure" } + } +} diff --git a/FrameworkTests/SSHWireEncodingTests.swift b/FrameworkTests/SSHWireEncodingTests.swift new file mode 100644 index 00000000..01e01cd6 --- /dev/null +++ b/FrameworkTests/SSHWireEncodingTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing + +@testable import TermindRemoteInfrastructure + +struct SSHWireEncodingTests { + @Test func nonnegativeMpintsMatchRFC4251WireValues() { + // RFC 4251 section 5: zero is an empty string; positive high-bit values need a sign octet. + let vectors: [(Data, Data)] = [ + (Data(), Data([0, 0, 0, 0])), + (Data([0, 0]), Data([0, 0, 0, 0])), + (Data([0, 0, 0x12, 0x34]), Data([0, 0, 0, 2, 0x12, 0x34])), + (Data([0x7f]), Data([0, 0, 0, 1, 0x7f])), + (Data([0x80]), Data([0, 0, 0, 2, 0, 0x80])), + (Data([0, 0x9a, 0x37, 0x8f, 0x9b]), Data([0, 0, 0, 5, 0, 0x9a, 0x37, 0x8f, 0x9b])), + ] + for (integer, wire) in vectors { #expect(SSHWireEncoding.mpint(integer) == wire) } + } +} diff --git a/FrameworkTests/SecurityKeyEditorTests.swift b/FrameworkTests/SecurityKeyEditorTests.swift new file mode 100644 index 00000000..8c9ed417 --- /dev/null +++ b/FrameworkTests/SecurityKeyEditorTests.swift @@ -0,0 +1,80 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite @MainActor +struct SecurityKeyEditorTests { + private let credential = SecurityKeyCredential( + credentialID: Data([1, 2, 3]), publicKeyX963: Data([4, 5, 6]), userVerification: .discouraged) + + @Test func savesEditedMetadataWithoutReplacingKeyIdentityOrPublicMaterial() async { + let existing = SSHKeyRef( + label: "Original", algorithm: .securityKey, publicKey: "public-key", fingerprint: "fingerprint", + storage: .thisDevice, securityKey: credential) + var saved: SSHKeyRef? + var validated: SecurityKeyCredential? + let model = SecurityKeyEditorModel( + existing: existing, exportReference: { _ in "reference" }, validate: { validated = $0 }, + persist: { saved = $0 }) + model.load() + #expect(model.label == "Original" && model.reference == "reference") + #expect(model.verification == .discouraged && model.canSave) + model.label = " Renamed " + model.colorTag = .purple + model.verification = .required + #expect(await model.save()) + var expected = existing + expected.label = "Renamed" + expected.colorTag = .purple + expected.securityKey?.userVerification = .required + #expect(saved == expected) + #expect(validated == expected.securityKey) + #expect(model.error == nil && !model.isSaving) + } + + @Test func validationAndPersistenceFailuresKeepTheEditorOpen() async { + var persisted = false + let existing = SSHKeyRef(label: "Key", securityKey: credential) + let rejected = SecurityKeyEditorModel( + existing: existing, exportReference: { _ in "reference" }, validate: { _ in throw EditFailure() }, + persist: { _ in persisted = true }) + rejected.load() + #expect(!(await rejected.save())) + #expect(!persisted && rejected.error == "Fixture edit failure") + #expect(rejected.canSave) + let failed = SecurityKeyEditorModel( + existing: existing, exportReference: { _ in "reference" }, validate: { _ in }, + persist: { _ in throw EditFailure() }) + failed.load() + #expect(!(await failed.save())) + #expect(failed.error == "Fixture edit failure" && failed.canSave) + } + + @Test func cancellationBeforeSaveDoesNotPersistOrReportAnError() async { + var persisted = false + let model = SecurityKeyEditorModel( + existing: SSHKeyRef(label: "Key", securityKey: credential), exportReference: { _ in "reference" }, + validate: { _ in }, persist: { _ in persisted = true }) + model.load() + let task = Task { await model.save() } + task.cancel() + #expect(!(await task.value)) + #expect(!persisted && model.error == nil && !model.isSaving) + } + + @Test func missingCredentialCannotBeSaved() async { + var persisted = false + let model = SecurityKeyEditorModel( + existing: SSHKeyRef(label: "Missing"), exportReference: { _ in "reference" }, validate: { _ in }, + persist: { _ in persisted = true }) + model.load() + #expect(!model.canSave && model.error != nil) + #expect(!(await model.save())) + #expect(!persisted) + } + + private struct EditFailure: LocalizedError { + var errorDescription: String? { "Fixture edit failure" } + } +} diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index fd987cc5..c8fb4459 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -380,10 +380,7 @@ extension RemoteSessionHostStore { if let stored = try connectionKeySecretCredential(for: key) { credentials.append( stored.sshCredential( - publicKey: key.publicKey, - certificate: key.certificate, - requiresPassphrase: key.requiresPassphraseEntry, - requiresUserPresence: key.requiresUserPresence, keyName: key.label + for: key ) ) } else { diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index 970bae33..372c3662 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -163,10 +163,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } result.append( stored.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: node.submission?.passphraseForConnection(), - requiresPassphrase: key.requiresPassphraseEntry, - requiresUserPresence: key.requiresUserPresence, keyName: key.label + for: key, passphraseOverride: node.submission?.passphraseForConnection() )) } } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index fffbbb08..9ebf5aee 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -754,10 +754,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { if let stored = try keySecretCredential(for: key) { credentials.append( stored.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: submission.passphraseForConnection(), - requiresPassphrase: key.requiresPassphraseEntry, - requiresUserPresence: key.requiresUserPresence, keyName: key.label + for: key, passphraseOverride: submission.passphraseForConnection() ) ) } @@ -827,9 +824,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { if let credential = try keySecretCredential(for: key) { credentials.append( credential.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - requiresPassphrase: key.requiresPassphraseEntry, - requiresUserPresence: key.requiresUserPresence, keyName: key.label + for: key ) ) } else { diff --git a/TFI/Application/FeatureModels/MobileKeyExportModel.swift b/Frameworks/TermindApplication/FeatureModels/KeyExportModel.swift similarity index 58% rename from TFI/Application/FeatureModels/MobileKeyExportModel.swift rename to Frameworks/TermindApplication/FeatureModels/KeyExportModel.swift index e7f197bb..6ce73e9c 100644 --- a/TFI/Application/FeatureModels/MobileKeyExportModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/KeyExportModel.swift @@ -1,55 +1,51 @@ import Foundation import Observation -import TermindApplication import TermindDomain -import TermindPresentation import struct TermindDomain.Host -struct MobileKeyExportServices: Sendable { - let install: @Sendable ([SSHAuthorizedKeyInstall], SSHConnectionConfiguration) async throws -> Void +public struct KeyExportServices: Sendable { + public let install: @Sendable ([SSHAuthorizedKeyInstall], SSHConnectionConfiguration) async throws -> Void + + public init( + install: @escaping @Sendable ([SSHAuthorizedKeyInstall], SSHConnectionConfiguration) async throws -> Void + ) { + self.install = install + } } @MainActor @Observable -final class MobileKeyExportModel { - enum ExportState: Equatable { +public final class KeyExportModel { + public enum ExportState: Equatable { case running case succeeded case failed(String) } - let key: SSHKeyRef - private(set) var selected: Set = [] - private(set) var states: [Host.ID: ExportState] = [:] - private(set) var isExporting = false - private(set) var isFinished = false - private(set) var targets: [Host] = [] + public let keys: [SSHKeyRef] + public private(set) var selected: Set = [] + public private(set) var states: [Host.ID: ExportState] = [:] + public private(set) var isExporting = false + public private(set) var isFinished = false + public private(set) var targets: [Host] = [] private let credentials: CredentialLibraryModel - private let services: MobileKeyExportServices + private let services: KeyExportServices - init(key: SSHKeyRef, credentials: CredentialLibraryModel, services: MobileKeyExportServices) { - self.key = key + public init(keys: [SSHKeyRef], credentials: CredentialLibraryModel, services: KeyExportServices) { + self.keys = keys self.credentials = credentials self.services = services } - var summary: LocalizedStringResource { - let failures = states.values.filter { if case .failed = $0 { true } else { false } }.count - if failures > 0 { - return AppStrings.Keychain.exportedFailed(count1: states.count - failures, failures: failures) - } - return states.count == 1 - ? AppStrings.Keychain.exportedTo1Host : AppStrings.Keychain.exportedToHosts(states.count) - } - - func updateSelection(_ ids: Set) { + public func updateSelection(_ ids: Set) { guard !isExporting, !isFinished else { return } selected = ids } - func export(to hostIDs: [Host.ID]) async { - guard !key.publicKey.isEmpty, !hostIDs.isEmpty, !isExporting, !isFinished else { return } + public func export(to hostIDs: [Host.ID]) async { + guard !keys.isEmpty, keys.allSatisfy({ !$0.publicKey.isEmpty }), !hostIDs.isEmpty, !isExporting, !isFinished + else { return } selected = Set(hostIDs) // Re-read saved destinations before preparing any credentials. Removed hosts // are never contacted, and newly unavailable hosts receive an explicit failure. @@ -70,14 +66,14 @@ final class MobileKeyExportModel { states[host.id] = .failed(error.localizedDescription) } } - let keys = [SSHAuthorizedKeyInstall(publicKey: key.publicKey, name: key.label)] + let exportKeys = keys.map { SSHAuthorizedKeyInstall(publicKey: $0.publicKey, name: $0.label) } let install = services.install await withTaskGroup(of: (Host.ID, String?).self) { group in for (id, preparation) in preparations { group.addTask { do { let configuration = try await preparation.resolve() - try await install(keys, configuration) + try await install(exportKeys, configuration) return (id, nil) } catch { return (id, error.localizedDescription) diff --git a/Frameworks/TermindApplication/FeatureModels/SecurityKeyEditorModel.swift b/Frameworks/TermindApplication/FeatureModels/SecurityKeyEditorModel.swift new file mode 100644 index 00000000..20a1bb84 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/SecurityKeyEditorModel.swift @@ -0,0 +1,68 @@ +import Foundation +import Observation +import TermindDomain + +/// Editable metadata for an existing hardware key; validation and persistence remain injected. +@MainActor +@Observable +public final class SecurityKeyEditorModel { + public var label = "" + public var colorTag = TagColor.none + public var verification: SecurityKeyCredential.UserVerification = .preferred + public private(set) var reference = "" + public private(set) var error: String? + public private(set) var isSaving = false + public var canSave: Bool { originalCredential != nil && !isSaving } + public let existing: SSHKeyRef + + private var originalCredential: SecurityKeyCredential? + private let exportReference: (SecurityKeyCredential) throws -> String + private let validate: (SecurityKeyCredential) throws -> Void + private let persist: (SSHKeyRef) throws -> Void + + public init( + existing: SSHKeyRef, + exportReference: @escaping (SecurityKeyCredential) throws -> String, + validate: @escaping (SecurityKeyCredential) throws -> Void, + persist: @escaping (SSHKeyRef) throws -> Void + ) { + self.existing = existing + self.exportReference = exportReference + self.validate = validate + self.persist = persist + } + + public func load() { + label = existing.label + colorTag = existing.colorTag + do { + guard let credential = existing.securityKey else { throw TermindSSHError.missingCredential } + originalCredential = credential + verification = credential.userVerification + reference = try exportReference(credential) + } catch { self.error = error.localizedDescription } + } + + public func save() async -> Bool { + guard !isSaving else { return false } + isSaving = true + error = nil + defer { isSaving = false } + do { + guard var credential = originalCredential else { throw TermindSSHError.missingCredential } + credential.userVerification = verification + try validate(credential) + try Task.checkCancellation() + var key = existing + key.label = label.trimmingCharacters(in: .whitespacesAndNewlines) + key.colorTag = colorTag + key.securityKey = credential + try persist(key) + return true + } catch is CancellationError { + } catch { + self.error = error.localizedDescription + } + return false + } +} diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index 388d3ef3..dc49ff90 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -356,6 +356,13 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable { } } + public func sshCredential(for key: SSHKeyRef, passphraseOverride: SSHSecret? = nil) -> SSHCredentials { + sshCredential( + publicKey: key.publicKey, certificate: key.certificate, passphraseOverride: passphraseOverride, + requiresPassphrase: key.requiresPassphraseEntry, + requiresUserPresence: key.requiresUserPresence, keyName: key.label) + } + public func sshCredential( publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil, requiresPassphrase: Bool = false, requiresUserPresence: Bool = false, keyName: String = "Security Key" diff --git a/Frameworks/TermindPresentation/SecurityKeyEditorForm.swift b/Frameworks/TermindPresentation/SecurityKeyEditorForm.swift new file mode 100644 index 00000000..79b156fa --- /dev/null +++ b/Frameworks/TermindPresentation/SecurityKeyEditorForm.swift @@ -0,0 +1,63 @@ +import SwiftUI +import TermindApplication +import TermindDomain + +public struct SecurityKeyEditorForm: View { + @State private var model: SecurityKeyEditorModel + @State private var task: Task? + private let close: () -> Void + + public init( + existing: SSHKeyRef, + exportReference: @escaping (SecurityKeyCredential) throws -> String, + validate: @escaping (SecurityKeyCredential) throws -> Void, + persist: @escaping (SSHKeyRef) throws -> Void, + close: @escaping () -> Void + ) { + _model = State( + initialValue: SecurityKeyEditorModel( + existing: existing, exportReference: exportReference, validate: validate, persist: persist)) + self.close = close + } + + public var body: some View { + VStack(spacing: 0) { + Form { + Section { + Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font( + .headline) + VaultNameField(text: $model.label, prompt: Text(AppStrings.SecurityKey.title)) + VaultColorField(selection: $model.colorTag) + } + Section { + LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) { + Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill") + } + WebAuthnVerificationPicker(selection: $model.verification) + if !model.reference.isEmpty { + Text(AppStrings.SecurityKey.reference).font(.headline) + Text(model.reference).font(.caption.monospaced()).textSelection(.enabled) + } + Text(model.existing.publicKey).font(.caption.monospaced()).textSelection(.enabled) + } + if let error = model.error { Text(error).foregroundStyle(.red) } + } + .formStyle(.grouped) + HStack { + Button(AppStrings.Common.cancel.swiftUIKey) { + task?.cancel() + close() + } + Spacer() + if model.isSaving { ProgressView().controlSize(.small) } + Button(AppStrings.Common.save.swiftUIKey) { + task = Task { if await model.save() { close() } } + } + .buttonStyle(.borderedProminent) + .disabled(!model.canSave) + }.padding() + } + .task { model.load() } + .onDisappear { task?.cancel() } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentProtocol.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentProtocol.swift index f86e930e..786fd00c 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentProtocol.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentProtocol.swift @@ -59,11 +59,8 @@ nonisolated struct SSHAgentProtocol { } enum Failure: Error { case invalid, unavailable } - static func uint32(_ value: UInt32) -> Data { - var value = value.bigEndian - return withUnsafeBytes(of: &value) { Data($0) } - } - static func field(_ data: Data) -> Data { uint32(UInt32(data.count)) + data } + static func uint32(_ value: UInt32) -> Data { SSHWireEncoding.uint32(value) } + static func field(_ data: Data) -> Data { SSHWireEncoding.field(data) } static func frame(_ data: Data) -> Data { field(data) } struct Reader { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift index ce1f3113..f054ffe3 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift @@ -1,5 +1,4 @@ import CSSH2 -import CryptoKit import Foundation import OpenSSL import TermindDomain @@ -115,7 +114,7 @@ public nonisolated struct SSHKeyMaterial: Sendable { + (trimmedComment.isEmpty ? "" : " \(trimmedComment)") return SSHKeyMaterial( privateKey: privateKey, publicKey: publicKey, algorithm: algorithm, - fingerprint: fingerprint(of: publicBlob), + fingerprint: SSHWireEncoding.fingerprint(of: publicBlob), keySize: keySize ?? inferredKeySize(for: algorithm, publicBlob: publicBlob) ) } @@ -269,12 +268,6 @@ public nonisolated struct SSHKeyMaterial: Sendable { value?.isEmpty == false ? value : nil } - private static func fingerprint(of blob: Data) -> String { - let digest = Data(SHA256.hash(data: blob)).base64EncodedString() - .replacingOccurrences(of: "=", with: "") - return "SHA256:\(digest)" - } - private static func connectionError(_ session: OpaquePointer, code: Int32) -> String { var message: UnsafeMutablePointer? var length = Int32(0) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHWireEncoding.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHWireEncoding.swift new file mode 100644 index 00000000..0343d937 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHWireEncoding.swift @@ -0,0 +1,25 @@ +import CryptoKit +import Foundation + +/// RFC 4251 fields shared by public keys, signatures, and agent replies. +nonisolated enum SSHWireEncoding { + static func uint32(_ value: UInt32) -> Data { + var value = value.bigEndian + return withUnsafeBytes(of: &value) { Data($0) } + } + + static func field(_ data: Data) -> Data { uint32(UInt32(data.count)) + data } + + /// Encodes a nonnegative integer, including its SSH field length. + static func mpint(_ bytes: S) -> Data { + var value = Data(Data(bytes).drop(while: { $0 == 0 })) + if value.first.map({ $0 & 0x80 != 0 }) == true { value.insert(0, at: value.startIndex) } + return field(value) + } + + static func fingerprint(of blob: Data) -> String { + let digest = Data(SHA256.hash(data: blob)).base64EncodedString() + .replacingOccurrences(of: "=", with: "") + return "SHA256:\(digest)" + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift b/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift index aba72a65..affd42a5 100644 --- a/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift +++ b/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift @@ -57,7 +57,7 @@ public nonisolated enum SecureEnclaveKey { dataRepresentation: privateKey.dataRepresentation ), publicKey: publicKey, - fingerprint: fingerprint(of: publicBlob) + fingerprint: SSHWireEncoding.fingerprint(of: publicBlob) ) } @@ -80,8 +80,8 @@ public nonisolated enum SecureEnclaveKey { let rawSignature = try privateKey.signature(for: data).rawRepresentation try Task.checkCancellation() guard rawSignature.count == 64 else { throw SecureEnclaveKeyError.invalidSignature } - return sshField(mpint(rawSignature.prefix(32))) - + sshField(mpint(rawSignature.suffix(32))) + return SSHWireEncoding.mpint(rawSignature.prefix(32)) + + SSHWireEncoding.mpint(rawSignature.suffix(32)) } catch { throw signingError(error) } @@ -113,30 +113,9 @@ public nonisolated enum SecureEnclaveKey { } private static func sshPublicBlob(for x963Representation: Data) -> Data { - sshField(Data("ecdsa-sha2-nistp256".utf8)) - + sshField(Data("nistp256".utf8)) - + sshField(x963Representation) + SSHWireEncoding.field(Data("ecdsa-sha2-nistp256".utf8)) + + SSHWireEncoding.field(Data("nistp256".utf8)) + + SSHWireEncoding.field(x963Representation) } - private static func fingerprint(of blob: Data) -> String { - let digest = Data(SHA256.hash(data: blob)).base64EncodedString() - .replacingOccurrences(of: "=", with: "") - return "SHA256:\(digest)" - } - - private static func mpint(_ bytes: S) -> Data { - var value = Data(bytes).drop(while: { $0 == 0 }) - if value.isEmpty { return Data() } - if value.first.map({ $0 & 0x80 != 0 }) == true { - value.insert(0, at: value.startIndex) - } - return Data(value) - } - - private static func sshField(_ value: Data) -> Data { - var length = UInt32(value.count).bigEndian - var result = withUnsafeBytes(of: &length) { Data($0) } - result.append(value) - return result - } } diff --git a/Frameworks/TermindRemoteInfrastructure/Security/WebAuthnSSH.swift b/Frameworks/TermindRemoteInfrastructure/Security/WebAuthnSSH.swift index 6647bca9..f9bef504 100644 --- a/Frameworks/TermindRemoteInfrastructure/Security/WebAuthnSSH.swift +++ b/Frameworks/TermindRemoteInfrastructure/Security/WebAuthnSSH.swift @@ -62,18 +62,18 @@ public nonisolated enum WebAuthnSSH { public static func publicBlob(_ credential: SecurityKeyCredential) throws -> Data { try validate(credential) - return field(Data(algorithm.utf8)) + field(Data("nistp256".utf8)) - + field(credential.publicKeyX963) + field(Data(SecurityKeyCredential.relyingParty.utf8)) + return SSHWireEncoding.field(Data(algorithm.utf8)) + SSHWireEncoding.field(Data("nistp256".utf8)) + + SSHWireEncoding.field(credential.publicKeyX963) + + SSHWireEncoding.field(Data(SecurityKeyCredential.relyingParty.utf8)) } public static func keyReference( _ credential: SecurityKeyCredential, label: String, colorTag: TagColor = .none ) throws -> SSHKeyRef { let blob = try publicBlob(credential) - let fingerprint = Data(SHA256.hash(data: blob)).base64EncodedString().replacingOccurrences(of: "=", with: "") return SSHKeyRef( label: label, algorithm: .securityKey, - publicKey: "\(algorithm) \(blob.base64EncodedString())", fingerprint: "SHA256:\(fingerprint)", + publicKey: "\(algorithm) \(blob.base64EncodedString())", fingerprint: SSHWireEncoding.fingerprint(of: blob), storage: .thisDevice, securityKey: credential, keySize: 256, requiresUserPresence: credential.requiresUserPresence, createdAt: .now, colorTag: colorTag) } @@ -137,9 +137,10 @@ public nonisolated enum WebAuthnSSH { throw Failure.invalidResponse } let raw = signature.rawRepresentation - let pair = mpint(Data(raw.prefix(32))) + mpint(Data(raw.suffix(32))) - return field(Data(signatureAlgorithm.utf8)) + field(pair) + Data(auth[32..<37]) - + field(Data(origin.utf8)) + field(assertion.clientDataJSON) + field(extensions) + let pair = SSHWireEncoding.mpint(Data(raw.prefix(32))) + SSHWireEncoding.mpint(Data(raw.suffix(32))) + return SSHWireEncoding.field(Data(signatureAlgorithm.utf8)) + SSHWireEncoding.field(pair) + Data(auth[32..<37]) + + SSHWireEncoding.field(Data(origin.utf8)) + SSHWireEncoding.field(assertion.clientDataJSON) + + SSHWireEncoding.field(extensions) } private static func validateAuthenticator(_ data: Data, requiresVerification: Bool, requiresPresence: Bool = true) @@ -169,18 +170,6 @@ public nonisolated enum WebAuthnSSH { .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } - private static func field(_ data: Data) -> Data { - let count = UInt32(data.count) - return Data([ - UInt8(count >> 24), UInt8(truncatingIfNeeded: count >> 16), - UInt8(truncatingIfNeeded: count >> 8), UInt8(truncatingIfNeeded: count), - ]) + data - } - - private static func mpint(_ data: Data) -> Data { - let value = Data(data.drop(while: { $0 == 0 })) - return field((value.first.map { $0 & 0x80 != 0 } == true ? Data([0]) : Data()) + value) - } } /// Bounded definite-length CBOR parser for authenticator data, with duplicate-map-key rejection. diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 13cea419..f35d6359 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -36,7 +36,7 @@ final class MobileAppModel { let hosts: any HostRepository let library: HostLibraryModel let credentials: CredentialLibraryModel - let keyExportServices: MobileKeyExportServices + let keyExportServices: KeyExportServices let snippets: SnippetLibraryModel let knownHosts: KnownHostsLibraryModel let forwardingLibrary: ForwardingLibraryModel @@ -188,7 +188,7 @@ final class MobileAppModel { let background = dependencies.background let workspaceConnectionServices = dependencies.workspaceConnections let connectionCoordinator = dependencies.connectionCoordinator - keyExportServices = MobileKeyExportServices { keys, configuration in + keyExportServices = KeyExportServices { keys, configuration in try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration) } activateWorkspaceSession = dependencies.activateWorkspaceSession diff --git a/TFI/Application/FeatureModels/KeyExportModel+Summary.swift b/TFI/Application/FeatureModels/KeyExportModel+Summary.swift new file mode 100644 index 00000000..11235f7a --- /dev/null +++ b/TFI/Application/FeatureModels/KeyExportModel+Summary.swift @@ -0,0 +1,15 @@ +import Foundation +import TermindApplication +import TermindPresentation + +extension KeyExportModel { + var summary: LocalizedStringResource { + let failures = states.values.filter { if case .failed = $0 { true } else { false } }.count + if failures > 0 { + return AppStrings.Keychain.exportedFailed(count1: states.count - failures, failures: failures) + } + return states.count == 1 + ? AppStrings.Keychain.exportedTo1Host : AppStrings.Keychain.exportedToHosts(states.count) + } + +} diff --git a/TFI/Features/Keychain/MobileCredentialsView.swift b/TFI/Features/Keychain/MobileCredentialsView.swift index f9b42bb2..6e122c28 100644 --- a/TFI/Features/Keychain/MobileCredentialsView.swift +++ b/TFI/Features/Keychain/MobileCredentialsView.swift @@ -126,7 +126,7 @@ struct MobileCredentialsView: View { .sheet(item: $exportingKey) { key in MobileKeyExportSheet( library: app.library, credentials: app.credentials, - model: MobileKeyExportModel(key: key, credentials: app.credentials, services: app.keyExportServices)) + model: KeyExportModel(keys: [key], credentials: app.credentials, services: app.keyExportServices)) } .confirmationDialog( AppStrings.Keychain.deleteIdentityConfirmation, diff --git a/TFI/Features/Keychain/MobileKeyExportSheet.swift b/TFI/Features/Keychain/MobileKeyExportSheet.swift index d869405b..2b262175 100644 --- a/TFI/Features/Keychain/MobileKeyExportSheet.swift +++ b/TFI/Features/Keychain/MobileKeyExportSheet.swift @@ -4,7 +4,7 @@ import TermindApplication struct MobileKeyExportSheet: View { let library: HostLibraryModel let credentials: CredentialLibraryModel - @State var model: MobileKeyExportModel + @State var model: KeyExportModel @Environment(\.dismiss) private var dismiss var body: some View { diff --git a/TFI/Features/Keychain/MobileMultiHostPicker+KeyExport.swift b/TFI/Features/Keychain/MobileMultiHostPicker+KeyExport.swift index 90b2f08c..9131d51d 100644 --- a/TFI/Features/Keychain/MobileMultiHostPicker+KeyExport.swift +++ b/TFI/Features/Keychain/MobileMultiHostPicker+KeyExport.swift @@ -6,7 +6,7 @@ import TermindPresentation extension MobileMultiHostPicker where Header == MobileKeyExportHeader, RowDetail == MobileKeyExportHostDetail { init( library: HostLibraryModel, credentials: CredentialLibraryModel, - exporting model: MobileKeyExportModel, close: @escaping () -> Void + exporting model: KeyExportModel, close: @escaping () -> Void ) { self.init( library: library, useCase: .keyExport, selected: [], @@ -38,11 +38,11 @@ extension MobileMultiHostPicker where Header == MobileKeyExportHeader, RowDetail } struct MobileKeyExportHeader: View { - let model: MobileKeyExportModel + let model: KeyExportModel var body: some View { Section { - Label(model.key.label, systemImage: "key") + if let key = model.keys.first { Label(key.label, systemImage: "key") } if model.isFinished { Text(model.summary).accessibilityIdentifier("key-export.summary") } else { @@ -53,7 +53,7 @@ struct MobileKeyExportHeader: View { } struct MobileKeyExportHostDetail: View { - let state: MobileKeyExportModel.ExportState? + let state: KeyExportModel.ExportState? let unavailable: String? var body: some View { diff --git a/TFI/Features/Keychain/SecurityKeyEditor.swift b/TFI/Features/Keychain/SecurityKeyEditor.swift index 9f23194b..2a839bc5 100644 --- a/TFI/Features/Keychain/SecurityKeyEditor.swift +++ b/TFI/Features/Keychain/SecurityKeyEditor.swift @@ -9,89 +9,14 @@ struct SecurityKeyEditor: View { let existing: SSHKeyRef var close: (() -> Void)? = nil @Environment(\.dismiss) private var dismiss - @State private var label = "" - @State private var colorTag = TagColor.none - @State private var verification: SecurityKeyCredential.UserVerification = .preferred - @State private var reference = "" - @State private var originalCredential: SecurityKeyCredential? - @State private var error: String? - @State private var running = false - @State private var task: Task? var body: some View { - VStack(spacing: 0) { - Form { - Section { - Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font( - .headline) - VaultNameField(text: $label, prompt: Text(AppStrings.SecurityKey.title)) - VaultColorField(selection: $colorTag) - } - Section { - LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) { - Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill") - } - WebAuthnVerificationPicker(selection: $verification) - if !reference.isEmpty { - Text(AppStrings.SecurityKey.reference).font(.headline) - Text(reference).font(.caption.monospaced()).textSelection(.enabled) - } - Text(existing.publicKey).font(.caption.monospaced()).textSelection(.enabled) - } - if let error { Text(error).foregroundStyle(.red) } - } - .formStyle(.grouped) - HStack { - Button(AppStrings.Common.cancel.swiftUIKey) { - task?.cancel() - finish() - } - Spacer() - if running { ProgressView().controlSize(.small) } - Button(AppStrings.Common.save.swiftUIKey) { - task = Task { await save() } - } - .buttonStyle(.borderedProminent) - .disabled(running || originalCredential == nil) - }.padding() - } - .task { - label = existing.label - colorTag = existing.colorTag - do { - guard let credential = existing.securityKey else { - throw TermindSSHError.missingCredential - } - originalCredential = credential - verification = credential.userVerification - reference = try WebAuthnSSH.exportReference(credential) - } catch { self.error = error.localizedDescription } - } - .onDisappear { task?.cancel() } + SecurityKeyEditorForm( + existing: existing, exportReference: WebAuthnSSH.exportReference, validate: WebAuthnSSH.validate, + persist: { try credentials.upsert($0) }, close: finish) } private func finish() { if let close { close() } else { dismiss() } } - - private func save() async { - running = true - error = nil - defer { running = false } - do { - guard var credential = originalCredential else { throw TermindSSHError.missingCredential } - credential.userVerification = verification - try WebAuthnSSH.validate(credential) - try Task.checkCancellation() - var key = existing - key.label = label.trimmingCharacters(in: .whitespacesAndNewlines) - key.colorTag = colorTag - key.securityKey = credential - try credentials.upsert(key) - finish() - } catch is CancellationError { - } catch { - self.error = error.localizedDescription - } - } } diff --git a/TFITests/MobileKeyExportRenderingTests.swift b/TFITests/MobileKeyExportRenderingTests.swift index 7fca575a..d12b6ca7 100644 --- a/TFITests/MobileKeyExportRenderingTests.swift +++ b/TFITests/MobileKeyExportRenderingTests.swift @@ -94,7 +94,7 @@ private final class KeyExportRenderingPresentation { private struct KeyExportRenderingHost: View { let fixture: KeyExportFixture - let model: MobileKeyExportModel + let model: KeyExportModel @Bindable var presentation: KeyExportRenderingPresentation @Environment(\.horizontalSizeClass) private var sizeClass diff --git a/TFITests/MobileKeyExportTests.swift b/TFITests/MobileKeyExportTests.swift index f21181e2..a0710bb6 100644 --- a/TFITests/MobileKeyExportTests.swift +++ b/TFITests/MobileKeyExportTests.swift @@ -7,16 +7,6 @@ import Testing @Suite @MainActor struct MobileKeyExportTests { - @Test func openingAndEmptySelectionNeverInstallAnything() async { - let fixture = KeyExportFixture() - let recorder = KeyExportRecorder() - let model = fixture.model(recorder) - #expect(model.selected.isEmpty) - await model.export(to: []) - #expect(await recorder.calls.isEmpty) - #expect(!model.isFinished) - } - @Test func searchAndSelectAllOnlyChangeVisibleEligibleHosts() { let fixture = KeyExportFixture() let model = MobileMultiHostPickerModel( @@ -41,77 +31,6 @@ struct MobileKeyExportTests { #expect(!model.allResultsSelected) } - @Test func missingPublicKeyCannotBeExported() async { - let fixture = KeyExportFixture() - let recorder = KeyExportRecorder() - let model = MobileKeyExportModel( - key: SSHKeyRef(label: "Missing"), credentials: fixture.credentials, - services: MobileKeyExportServices(install: recorder.install)) - await model.export(to: [fixture.alpha.id]) - #expect(await recorder.calls.isEmpty) - } - - @Test func submitRevalidatesChangedHostCapabilities() async { - let fixture = KeyExportFixture() - let recorder = KeyExportRecorder() - let model = fixture.model(recorder) - var changed = fixture.alpha - changed.sshEnabled = false - changed.telnetEnabled = true - #expect(fixture.library.saveHost(changed)) - await model.export(to: [fixture.alpha.id, fixture.beta.id]) - #expect(model.isFinished) - if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected unavailable host failure") } - #expect(model.states[fixture.beta.id] == .succeeded) - #expect(await recorder.calls.map(\.hostID) == [fixture.beta.id]) - } - - @Test func preparationFailureDoesNotPreventOtherDestinations() async { - let fixture = KeyExportFixture() - let recorder = KeyExportRecorder() - let model = fixture.model(recorder) - var changed = fixture.alpha - changed.jumpHostID = fixture.untrusted.id - #expect(fixture.library.saveHost(changed)) - await model.export(to: [fixture.alpha.id, fixture.beta.id]) - if case .failed = model.states[fixture.alpha.id] {} else { Issue.record("Expected route preparation failure") } - #expect(model.states[fixture.beta.id] == .succeeded) - #expect(await recorder.calls.map(\.hostID) == [fixture.beta.id]) - } - - @Test func installsOnlyTheChosenPublicKeyAndReportsPartialFailure() async { - let fixture = KeyExportFixture() - let recorder = KeyExportRecorder(failingHost: fixture.beta.id) - let model = fixture.model(recorder) - await model.export(to: [fixture.alpha.id, fixture.beta.id]) - let calls = await recorder.calls - #expect(Set(calls.map(\.hostID)) == [fixture.alpha.id, fixture.beta.id]) - #expect(calls.allSatisfy { $0.publicKeys == [fixture.key.publicKey] && $0.names == [fixture.key.label] }) - #expect(calls.allSatisfy { $0.username == "deploy" && !$0.trustedKeys.isEmpty }) - #expect(model.states[fixture.alpha.id] == .succeeded) - #expect(model.states[fixture.beta.id] == .failed("Fixture export failure")) - #expect(model.isFinished && !model.isExporting) - #expect(!String(localized: model.summary).isEmpty) - } - - @Test func runningExportRejectsDuplicateSubmissions() async { - let fixture = KeyExportFixture() - let recorder = KeyExportRecorder(blocks: true) - let model = fixture.model(recorder) - let task = Task { await model.export(to: [fixture.alpha.id]) } - await recorder.waitUntilStarted() - #expect(model.isExporting && !model.isFinished) - #expect(model.states[fixture.alpha.id] == .running) - await model.export(to: [fixture.alpha.id, fixture.beta.id]) - #expect(model.selected == [fixture.alpha.id]) - #expect(await recorder.calls.count == 1) - await recorder.resume() - await task.value - #expect(model.isFinished && !model.isExporting) - #expect(model.states[fixture.alpha.id] == .succeeded) - await model.export(to: [fixture.alpha.id]) - #expect(await recorder.calls.count == 1) - } } @MainActor @@ -137,9 +56,9 @@ struct KeyExportFixture { credentials = CredentialLibraryModel(library: library) } - func model(_ recorder: KeyExportRecorder) -> MobileKeyExportModel { - MobileKeyExportModel( - key: key, credentials: credentials, services: MobileKeyExportServices(install: recorder.install)) + func model(_ recorder: KeyExportRecorder) -> KeyExportModel { + KeyExportModel( + keys: [key], credentials: credentials, services: KeyExportServices(install: recorder.install)) } } diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index c63ce7d0..26e42774 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -1528,12 +1528,6 @@ private struct SecureEnclaveKeyEditor: View { /// explicit authorization to change that account's `authorized_keys`; opening /// the sheet never inherits the Keychain card selection as a host selection. struct ExportKeyToHostsSheet: View { - private enum ExportState: Equatable { - case running - case succeeded - case failed(String) - } - let keys: [SSHKeyRef] @Environment(AppModel.self) private var app @@ -1541,7 +1535,7 @@ struct ExportKeyToHostsSheet: View { @Environment(\.dismiss) private var dismiss @State private var chooser = ManagedChooserState(mode: .multiple) - @State private var states: [Host.ID: ExportState] = [:] + @State private var exportModel: KeyExportModel? @FocusState private var searchFocused: Bool private var query: String { chooser.query } @@ -1552,8 +1546,9 @@ struct ExportKeyToHostsSheet: View { private var eligibleHosts: [Host] { hosts.filter { app.credentials.keyExportUnavailableReason(for: $0) == nil } } - private var isExporting: Bool { states.values.contains(.running) } - private var isFinished: Bool { !states.isEmpty && !isExporting } + private var states: [Host.ID: KeyExportModel.ExportState] { exportModel?.states ?? [:] } + private var isExporting: Bool { exportModel?.isExporting ?? false } + private var isFinished: Bool { exportModel?.isFinished ?? false } var body: some View { VaultDetailScaffold(title: AppStrings.Keychain.exportToHosts, symbol: "square.and.arrow.up", close: close) { @@ -1811,51 +1806,21 @@ struct ExportKeyToHostsSheet: View { } private func export() { - let targets = app.credentials.searchHosts("").filter { - chooser.selected.contains($0.id) - } - guard !targets.isEmpty else { return } - - var preparations: [(Host.ID, SSHConnectionPreparation)] = [] - for host in targets { - if let reason = app.credentials.keyExportUnavailableReason(for: host) { - states[host.id] = .failed(reason) - continue - } - do { - preparations.append( - (host.id, try app.credentials.prepareSSHConnection(for: host)) - ) - states[host.id] = .running - } catch { - states[host.id] = .failed(error.localizedDescription) - } - } - - let exportKeys = keys.map { - SSHAuthorizedKeyInstall(publicKey: $0.publicKey, name: $0.label) - } - let connectionCoordinator = app.connections - Task { - await withTaskGroup(of: (Host.ID, String?).self) { group in - for (id, preparation) in preparations { - group.addTask { - do { - let configuration = try await preparation.resolve() - try await connectionCoordinator.installAuthorizedKeys( - exportKeys, using: configuration - ) - return (id, nil) - } catch { - return (id, error.localizedDescription) - } - } - } - for await (id, error) in group { - states[id] = error.map(ExportState.failed) ?? .succeeded - } - } + guard !isExporting, !isFinished else { return } + let model: KeyExportModel + if let exportModel { + model = exportModel + } else { + let connectionCoordinator = app.connections + model = KeyExportModel( + keys: keys, credentials: app.credentials, + services: KeyExportServices { keys, configuration in + try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration) + }) + exportModel = model } + let selected = Array(chooser.selected) + Task { await model.export(to: selected) } } } diff --git a/TFM/Features/Keychain/SecurityKeyEditor.swift b/TFM/Features/Keychain/SecurityKeyEditor.swift index 9f23194b..2a839bc5 100644 --- a/TFM/Features/Keychain/SecurityKeyEditor.swift +++ b/TFM/Features/Keychain/SecurityKeyEditor.swift @@ -9,89 +9,14 @@ struct SecurityKeyEditor: View { let existing: SSHKeyRef var close: (() -> Void)? = nil @Environment(\.dismiss) private var dismiss - @State private var label = "" - @State private var colorTag = TagColor.none - @State private var verification: SecurityKeyCredential.UserVerification = .preferred - @State private var reference = "" - @State private var originalCredential: SecurityKeyCredential? - @State private var error: String? - @State private var running = false - @State private var task: Task? var body: some View { - VStack(spacing: 0) { - Form { - Section { - Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font( - .headline) - VaultNameField(text: $label, prompt: Text(AppStrings.SecurityKey.title)) - VaultColorField(selection: $colorTag) - } - Section { - LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) { - Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill") - } - WebAuthnVerificationPicker(selection: $verification) - if !reference.isEmpty { - Text(AppStrings.SecurityKey.reference).font(.headline) - Text(reference).font(.caption.monospaced()).textSelection(.enabled) - } - Text(existing.publicKey).font(.caption.monospaced()).textSelection(.enabled) - } - if let error { Text(error).foregroundStyle(.red) } - } - .formStyle(.grouped) - HStack { - Button(AppStrings.Common.cancel.swiftUIKey) { - task?.cancel() - finish() - } - Spacer() - if running { ProgressView().controlSize(.small) } - Button(AppStrings.Common.save.swiftUIKey) { - task = Task { await save() } - } - .buttonStyle(.borderedProminent) - .disabled(running || originalCredential == nil) - }.padding() - } - .task { - label = existing.label - colorTag = existing.colorTag - do { - guard let credential = existing.securityKey else { - throw TermindSSHError.missingCredential - } - originalCredential = credential - verification = credential.userVerification - reference = try WebAuthnSSH.exportReference(credential) - } catch { self.error = error.localizedDescription } - } - .onDisappear { task?.cancel() } + SecurityKeyEditorForm( + existing: existing, exportReference: WebAuthnSSH.exportReference, validate: WebAuthnSSH.validate, + persist: { try credentials.upsert($0) }, close: finish) } private func finish() { if let close { close() } else { dismiss() } } - - private func save() async { - running = true - error = nil - defer { running = false } - do { - guard var credential = originalCredential else { throw TermindSSHError.missingCredential } - credential.userVerification = verification - try WebAuthnSSH.validate(credential) - try Task.checkCancellation() - var key = existing - key.label = label.trimmingCharacters(in: .whitespacesAndNewlines) - key.colorTag = colorTag - key.securityKey = credential - try credentials.upsert(key) - finish() - } catch is CancellationError { - } catch { - self.error = error.localizedDescription - } - } } diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 87139f6c..7c6bf2db 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -392,10 +392,7 @@ final class SFTPChannelModel: Identifiable { { credentials.append( stored.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: suppliedAuthentication.passphraseForConnection(), - requiresPassphrase: key.requiresPassphraseEntry, - requiresUserPresence: key.requiresUserPresence, keyName: key.label + for: key, passphraseOverride: suppliedAuthentication.passphraseForConnection() )) } if let password = suppliedAuthentication.passwordForConnection() { diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index abe11bf1..999bb355 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -459,10 +459,7 @@ final class SSHSessionModel: Identifiable { if let credential = try store.connectionKeySecretCredential(for: key) { credentials.append( credential.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: suppliedAuthentication.passphraseForConnection(), - requiresPassphrase: key.requiresPassphraseEntry, - requiresUserPresence: key.requiresUserPresence, keyName: key.label + for: key, passphraseOverride: suppliedAuthentication.passphraseForConnection() ) ) } else {