diff --git a/FrameworkTests/DeferredKeyMetadataTests.swift b/FrameworkTests/DeferredKeyMetadataTests.swift new file mode 100644 index 00000000..dd0fdc87 --- /dev/null +++ b/FrameworkTests/DeferredKeyMetadataTests.swift @@ -0,0 +1,57 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence +import Testing + +@Suite @MainActor +struct DeferredKeyMetadataTests { + @Test func encryptedKeyWithoutKnownAlgorithmSurvivesStoreReopening() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appending(path: "vault.store") + let key = SSHKeyRef( + label: "Encrypted", algorithm: .unknown, credentialRef: "private-reference", hasPassphrase: true) + do { + let container = try TermindPersistence.makeContainer(url: url) + let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext)) + try library.upsert(key) + } + for _ in 0..<2 { + let container = try TermindPersistence.makeContainer(url: url) + let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext)) + let saved = try #require(library.key(key.id)) + #expect(saved.algorithm == .unknown && saved.hasPassphrase) + #expect(saved.publicKey.isEmpty && saved.fingerprint.isEmpty) + } + } + + @Test func v3CarriesUnknownAlgorithmsAndLegacyWritersRejectThem() throws { + let key = SSHKeyRef( + label: "Encrypted", algorithm: .unknown, credentialRef: "private-reference", hasPassphrase: true) + let payload = VaultRecordPayload.sshKey(key) + let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) + #expect(envelope.formatVersion == 3) + let decoded = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) + guard case .sshKey(let roundTrip) = decoded.payload else { + Issue.record("Missing key") + return + } + #expect(roundTrip.algorithm == .unknown && roundTrip.hasPassphrase && roundTrip.publicKey.isEmpty) + for version in [1, 2] { + let legacy = VaultEnvelope( + key: payload.key, version: envelope.version, payload: payload, formatVersion: version) + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(legacy) } + } + } + + @Test(arguments: [1, 2]) func legacyKeyRecordsRemainReadable(_ version: Int) throws { + let key = SSHKeyRef(label: "Legacy", algorithm: .rsa, publicKey: "public", keySize: 3072, hasPassphrase: true) + let payload = VaultRecordPayload.sshKey(key) + let envelope = VaultEnvelope( + key: payload.key, version: .baseline(writerID: UUID()), payload: payload, formatVersion: version) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)).payload == payload) + } +} diff --git a/FrameworkTests/KeyExportTests.swift b/FrameworkTests/KeyExportTests.swift index e817fed0..7349f000 100644 --- a/FrameworkTests/KeyExportTests.swift +++ b/FrameworkTests/KeyExportTests.swift @@ -5,210 +5,414 @@ import Testing @Suite @MainActor struct KeyExportTests { - @Test func openingAndEmptySelectionNeverInstallAnything() async { + @Test func openingAndMissingSelectionNeverConnect() async throws { 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) + let model = fixture.model() + #expect(!model.canStart) + model.start() + #expect(await fixture.transport.connects == 0) + model.hostID = fixture.host.id + #expect(model.canStart) + #expect(fixture.library.keyExportUnavailableReason(for: fixture.host) == nil) } - @Test func missingPublicKeyCannotBeExported() async { + @Test func existingPublicKeyExportsWithoutReadingPrivateMaterialAndAttachesCustomIdentity() async throws { 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) + let model = fixture.model() + model.hostID = fixture.host.id + model.start() + try await wait { model.phase == .succeeded } + #expect(await fixture.transport.derivations == 0) + #expect(await fixture.transport.installed == [fixture.key.publicKey]) + let host = try #require(fixture.library.host(fixture.host.id)) + let identity = try #require(fixture.library.identity(for: host)) + #expect(identity.isHostCustom && identity.ownerHostID == host.id) + #expect(identity.keyID == fixture.key.id && identity.username == "deploy") + #expect(fixture.library.identity(fixture.identity.id)?.keyID == nil) + #expect(fixture.library.host(fixture.other.id)?.identityID == fixture.identity.id) } - @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 absentPublicKeyIsDerivedOnlyForTheCurrentExport() async throws { + let fixture = KeyExportFixture(publicKey: "") + try fixture.storePrivateKey() + let model = fixture.model() + model.hostID = fixture.host.id + model.start() + try await wait { model.phase == .succeeded } + #expect(await fixture.transport.derivations == 1) + #expect(await fixture.transport.installed == ["ssh-ed25519 derived"]) + #expect(fixture.library.key(fixture.key.id)?.publicKey == "") + #expect(fixture.library.key(fixture.key.id)?.fingerprint == "") } - @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 encryptedKeyWithoutSavedPassphraseRequiresTheExportForm() async throws { + let fixture = KeyExportFixture(publicKey: "", encrypted: true) + try fixture.storePrivateKey() + let model = fixture.model() + model.hostID = fixture.host.id + #expect(model.needsExportPassphrase && !model.canStart) + model.exportPassphrase = "unlock" + #expect(model.canStart) + model.start() + try await wait { model.phase == .succeeded } + #expect(model.authenticationPrompt == nil) + #expect(model.exportPassphrase.isEmpty) + #expect(await fixture.transport.passphrases == ["unlock"]) + #expect(fixture.library.requiresPassphraseEntry(for: fixture.key)) } - @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 savedPassphraseIsReusedAndBadPassphraseReturnsToTheForm() async throws { + let fixture = KeyExportFixture(publicKey: "", encrypted: true) + try fixture.storePrivateKey(passphrase: "wrong") + await fixture.transport.requirePassphrase("correct") + let model = fixture.model() + model.hostID = fixture.host.id + #expect(!model.needsExportPassphrase) + model.start() + try await wait { model.passphraseError != nil } + #expect(model.phase == .editing && !model.journey.isPresented) + #expect(model.needsExportPassphrase && model.authenticationPrompt == nil) + #expect(await fixture.transport.connects == 0) + model.exportPassphrase = "correct" + model.saveExportPassphrase = true + model.start() + try await wait { model.phase == .succeeded } + guard case .software(let secret)? = try fixture.library.keyCredential(for: fixture.key) else { + Issue.record("Missing private key") + return + } + #expect(secret.passphrase == "correct") + #expect(fixture.library.key(fixture.key.id)?.publicKey == "") } - @Test func exportsEverySelectedKeyOncePerRemainingHost() async { + @Test func missingHostIdentityUsesTheConnectionJourney() async throws { 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]) + var host = fixture.host + host.identityID = nil + #expect(fixture.library.saveHost(host)) + let model = fixture.model() + model.hostID = host.id + model.start() + try await wait { model.authenticationPrompt != nil } + let prompt = try #require(model.authenticationPrompt) + #expect(model.journey.waitsForLocalIdentity) + model.submitAuthentication( + promptID: prompt.id, identityID: nil, username: "alice", password: "one-use", + passphrase: nil, keyID: nil, save: false) + try await wait { model.phase == .succeeded } + let saved = try #require(fixture.library.host(host.id)) + let identity = try #require(fixture.library.identity(for: saved)) + #expect(identity.isHostCustom && identity.username == "alice" && !identity.usesPassword) } - @Test func removedDestinationsAndEmptyKeyBatchesNeverStartAnExport() async { + @Test func firstUseTrustIsPresentedAndCancellationNeverInstalls() async throws { 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) + await fixture.transport.challengeTrust() + let model = fixture.model() + model.hostID = fixture.host.id + model.start() + try await wait { model.connection.pendingHostKey != nil } + #expect(model.journey.checkpoint?.kind == .hostKey) + model.cancel() + try await waitAsync { await fixture.transport.disconnects > 0 } + #expect(await fixture.transport.installed.isEmpty) + #expect(fixture.library.knownHosts.isEmpty) } - @Test func revokedTrustFailsWithoutConnectingAndDoesNotBlockOtherHosts() async { + @Test func acceptingTrustContinuesTheSameExport() async throws { 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) + await fixture.transport.challengeTrust() + let model = fixture.model() + model.hostID = fixture.host.id + model.start() + try await wait { model.connection.pendingHostKey != nil } + let prompt = try #require(model.connection.pendingHostKey) + model.acceptHostKey(prompt.observation, operationID: model.operationID, remember: true) + try await wait { model.phase == .succeeded } + #expect(fixture.library.knownHosts.count == 1) + #expect(await fixture.transport.connects == 1) } - @Test func runningExportRejectsDuplicateSubmissions() async { + @Test func anOldTrustCallbackCannotApproveANewAttemptForTheSameHost() async throws { 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) + await fixture.transport.challengeTrust() + let model = fixture.model() + model.hostID = fixture.host.id + model.start() + try await wait { model.connection.pendingHostKey != nil } + let observation = try #require(model.connection.pendingHostKey?.observation) + let oldOperation = model.operationID + model.cancel() + model.start() + try await wait { model.connection.pendingHostKey != nil } + model.acceptHostKey(observation, operationID: oldOperation, remember: true) + #expect(model.connection.pendingHostKey != nil && fixture.library.knownHosts.isEmpty) + #expect(await fixture.transport.installed.isEmpty) + model.acceptHostKey(observation, operationID: model.operationID, remember: false) + try await wait { model.phase == .succeeded } + } + + @Test func aConnectionArrivingAfterCancellationIsClosedWithoutExporting() async throws { + let fixture = KeyExportFixture() + await fixture.transport.delayConnection() + let model = fixture.model() + model.hostID = fixture.host.id + model.start() + try await waitAsync { await fixture.transport.isWaiting } + model.cancel() + await fixture.transport.finishConnection() + try await waitAsync { await fixture.transport.disconnects > 0 } + #expect(await fixture.transport.installed.isEmpty) + #expect(fixture.library.host(fixture.host.id)?.identityID == fixture.identity.id) + } + + @Test func jumpLoginAndKeyboardAuthenticationStayInTheJourney() async throws { + let fixture = KeyExportFixture() + var jump = fixture.other + jump.identityID = nil + #expect(fixture.library.saveHost(jump)) + var target = fixture.host + target.jumpHostID = jump.id + #expect(fixture.library.saveHost(target)) + await fixture.transport.challengeKeyboard() + let model = fixture.model() + model.hostID = target.id + model.start() + try await wait { model.authenticationPrompt != nil } + let prompt = try #require(model.authenticationPrompt) + #expect(prompt.host.id == jump.id) + model.submitAuthentication( + promptID: prompt.id, identityID: nil, username: "gateway", password: "temporary", + passphrase: nil, keyID: nil, save: false) + try await wait { model.connection.keyboardAuthentication.current != nil } + let challenge = try #require(model.connection.keyboardAuthentication.current) + #expect(challenge.hostID == jump.id) + model.connection.keyboardAuthentication.respond(challenge.id, answers: ["one-time-code"]) + try await wait { model.phase == .succeeded } + #expect(await fixture.transport.keyboardAnswers == ["one-time-code"]) + #expect(fixture.library.identity(for: jump) == nil) + let saved = try #require(fixture.library.host(target.id)) + #expect(fixture.library.identity(for: saved)?.username == "deploy") + } + + @Test func proxyAuthenticationIsSeparateFromTheAttachedSSHIdentity() async throws { + let fixture = KeyExportFixture() + var target = fixture.host + target.proxy = HostProxy(hostname: "proxy.invalid") + #expect(fixture.library.saveHost(target)) + await fixture.transport.challengeProxy() + let model = fixture.model() + model.hostID = target.id + model.start() + try await wait { model.authenticationPrompt != nil } + let prompt = try #require(model.authenticationPrompt) + #expect(prompt.isProxy) + model.submitAuthentication( + promptID: prompt.id, identityID: nil, username: "proxy-user", password: "temporary", + passphrase: nil, keyID: nil, save: false) + try await wait { model.phase == .succeeded } + let saved = try #require(fixture.library.host(target.id)) + #expect(fixture.library.identity(for: saved)?.username == "deploy") + #expect(saved.proxy?.identityID == nil) + #expect(await fixture.transport.connects == 2) + } + + @Test func bindingFailureRollsBackAndRetryDoesNotExecuteAgain() async throws { + let fixture = KeyExportFixture() + let persistence = ExportPersistence( + snapshot: VaultSnapshot( + hosts: [fixture.host, fixture.other], identities: [fixture.identity], keys: [fixture.key])) + let library = HostLibraryModel(persistence: persistence) + let model = KeyExportModel(key: fixture.key, library: library, services: fixture.services) + model.hostID = fixture.host.id + persistence.failsSave = true + model.start() + try await wait { if case .attachmentFailed = model.phase { true } else { false } } + #expect(model.didExport) + #expect(library.host(fixture.host.id)?.identityID == fixture.identity.id) + #expect(library.identities.count == 1) + persistence.failsSave = false + model.retry() + #expect(model.phase == .succeeded) + #expect(await fixture.transport.installed.count == 1) + #expect(await fixture.transport.connects == 1) + } + + @Test func attachingCopiesSavedPasswordWithoutSharingItsReference() throws { + let fixture = KeyExportFixture() + try fixture.library.upsert(fixture.identity, passwordUpdate: .replace("saved-password")) + try fixture.library.attachExportedKey(fixture.key.id, to: fixture.host.id, username: "deploy") + let host = try #require(fixture.library.host(fixture.host.id)) + let custom = try #require(fixture.library.identity(for: host)) + let shared = try #require(fixture.library.identity(fixture.identity.id)) + #expect(custom.credentialRef != shared.credentialRef) + #expect(try fixture.library.editablePassword(for: custom) == "saved-password") + #expect(fixture.library.delete(identityID: custom.id)) + #expect(try fixture.library.editablePassword(for: shared) == "saved-password") + } + + @Test func scriptFailureRequiresExplicitRetryAndNeverAttaches() async throws { + let fixture = KeyExportFixture() + await fixture.transport.failInstall() + let model = fixture.model() + model.hostID = fixture.host.id + model.start() + try await wait { if case .failed = model.phase { true } else { false } } + #expect(model.executionStarted && !model.didExport) + #expect(fixture.library.host(fixture.host.id)?.identityID == fixture.identity.id) + #expect(await fixture.transport.installed.count == 1) + model.editOptions() + #expect(!model.journey.isPresented) + model.options.script = "exit 0" + #expect(model.canStart) + #expect(await fixture.transport.installed.count == 1) + } + + @Test func invalidOptionsNeverConnect() { + let fixture = KeyExportFixture() + let model = fixture.model() + model.hostID = fixture.host.id + model.options.filename = "../authorized_keys" + #expect(!model.canStart) + model.options.filename = "keys" + model.options.script = " " + #expect(!model.canStart) + } + + private func wait(_ condition: @MainActor () -> Bool) async throws { + try await waitAsync { condition() } + } + private func waitAsync(_ condition: () async -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while !(await condition()) { + guard ContinuousClock.now < deadline else { throw CocoaError(.coderInvalidValue) } + try await Task.sleep(for: .milliseconds(5)) + } } } @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 +private struct KeyExportFixture { + let key: SSHKeyRef + let identity = Identity(name: "Shared", username: "deploy") + let host: TermindDomain.Host + let other: 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) + let transport = ExportTransport() + init(publicKey: String = "ssh-ed25519 existing", encrypted: Bool = false) { + key = SSHKeyRef(label: "Deploy", publicKey: publicKey, credentialRef: "key", hasPassphrase: encrypted) + host = TermindDomain.Host(name: "First", host: "first.invalid", identityID: identity.id) + other = TermindDomain.Host(name: "Second", host: "second.invalid", identityID: identity.id) + library = HostLibraryModel(hosts: [host, other], keys: [key], identities: [identity]) } - - func model(_ recorder: KeyExportRecorder) -> KeyExportModel { - KeyExportModel( - keys: [key], credentials: credentials, services: KeyExportServices(install: recorder.install)) + var services: KeyExportServices { + KeyExportServices(derivePublicKey: transport.derive, connect: transport.connect) + } + func model() -> KeyExportModel { KeyExportModel(key: key, library: library, services: services) } + func storePrivateKey(passphrase: String? = nil) throws { + try library.upsert( + key, credential: .software(SSHPrivateKeySecret(privateKey: "private", passphrase: passphrase))) } } -actor KeyExportRecorder { - struct Call: Sendable { - let hostID: UUID - let publicKeys: [String] - let names: [String] - let username: String - let trustedKeys: Set +private actor ExportTransport: KeyExportSession { + var derivations = 0 + var connects = 0 + var disconnects = 0 + var installed: [String] = [] + var passphrases: [String] = [] + var keyboardAnswers: [String] = [] + private var expectedPassphrase: String? + private var asksTrust = false + private var installFails = false + private var delaysConnection = false + private var asksKeyboard = false + private var asksProxy = false + private var connectionContinuation: CheckedContinuation? + var isWaiting: Bool { connectionContinuation != nil } + func requirePassphrase(_ value: String) { expectedPassphrase = value } + func challengeTrust() { asksTrust = true } + func challengeKeyboard() { asksKeyboard = true } + func challengeProxy() { asksProxy = true } + func failInstall() { installFails = true } + func delayConnection() { delaysConnection = true } + func finishConnection() { + connectionContinuation?.resume() + connectionContinuation = nil } - - 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 derive(_ key: SSHKeyRef, _ credential: SSHResolvedKeyCredential, _ override: SSHSecret?) async throws -> String + { + defer { + credential.clearSecrets() + override?.clear() + } + derivations += 1 + if case .software(_, let saved) = credential, let passphrase = override ?? saved { + let value = try passphrase.withUnsafeCStringAndConsume { pointer, _ in String(cString: pointer) } + passphrases.append(value) + if let expectedPassphrase, value != expectedPassphrase { throw CocoaError(.coderInvalidValue) } + } + return "ssh-ed25519 derived" } - - 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 + func connect(_ request: SSHConnectionRequest, _ progress: SSHRouteProgressHandler?) async throws + -> any KeyExportSession + { + defer { request.configuration.clearSecrets() } + connects += 1 + var opened = false + defer { if !opened { disconnects += 1 } } + if asksProxy { + asksProxy = false + throw TermindSSHError.proxyAuthentication( + hostID: request.configuration.hostID, serverChallenge: true, message: "Proxy authentication required") + } + if delaysConnection { + await withCheckedContinuation { connectionContinuation = $0 } + } + if asksTrust { + let config = request.configuration + let challenge = SSHHostKeyChallenge( + hostID: config.hostID, + observation: SSHHostKeyObservation( + hostname: config.host, port: config.port, algorithm: .ed25519, fingerprint: "SHA256:test"), + expectedHostKeys: []) + let decision = try await request.authorizeHostKey?(challenge) + guard decision == .accept else { + throw CancellationError() } } - if configuration.hostID == failingHost { throw ExportFailure() } + if asksKeyboard { + let config = request.configuration + let hop = config.jumps.first + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: UUID(), hostID: hop?.id ?? config.hostID, + hostname: hop?.host ?? config.host, port: hop?.port ?? config.port, + username: hop?.username ?? config.username, round: 1, name: "Verification", instruction: "", + prompts: [.init(text: "Code", echo: false)]) + keyboardAnswers = try await request.keyboardInteractive?(challenge) ?? [] + } + opened = true + return self } - - func waitUntilStarted() async { - if !calls.isEmpty { return } - await withCheckedContinuation { started = $0 } + func install(publicKey: String, options: KeyExportOptions) async throws { + installed.append(publicKey) + if installFails { throw CocoaError(.fileWriteUnknown) } } + func disconnect() async { disconnects += 1 } +} - func resume() { - release?.resume() - release = nil +@MainActor +private final class ExportPersistence: VaultPersisting { + var snapshot: VaultSnapshot + var failsSave = false + init(snapshot: VaultSnapshot) { self.snapshot = snapshot } + func load() throws -> VaultSnapshot { snapshot } + func save(_ snapshot: VaultSnapshot, touching keys: Set) throws -> VaultMutationSet { + if failsSave { throw CocoaError(.fileWriteUnknown) } + self.snapshot = snapshot + return .empty } - - private struct ExportFailure: LocalizedError { - var errorDescription: String? { "Fixture export failure" } + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { + VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: []) } } diff --git a/FrameworkTests/SSHAgentForwardingPersistenceTests.swift b/FrameworkTests/SSHAgentForwardingPersistenceTests.swift index 5712e442..e5ba23d5 100644 --- a/FrameworkTests/SSHAgentForwardingPersistenceTests.swift +++ b/FrameworkTests/SSHAgentForwardingPersistenceTests.swift @@ -44,7 +44,7 @@ struct SSHAgentForwardingPersistenceTests { #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope) envelope.formatVersion = 1 #expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) } - #expect(SyncRecordFormats.vault.writingVersion(for: "sshKey") == 2) + #expect(SyncRecordFormats.vault.writingVersion(for: "sshKey") == 3) #expect(SyncRecordFormats.vault.supports(kind: "host", version: 1)) } } diff --git a/FrameworkTests/WebAuthnVaultMetadataTests.swift b/FrameworkTests/WebAuthnVaultMetadataTests.swift index 3d16e040..a7f29a01 100644 --- a/FrameworkTests/WebAuthnVaultMetadataTests.swift +++ b/FrameworkTests/WebAuthnVaultMetadataTests.swift @@ -54,7 +54,7 @@ struct WebAuthnVaultMetadataTests { #expect(reloaded.storage == .external) let payload = VaultRecordPayload.sshKey(reloaded) let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) - #expect(envelope.formatVersion == 2) + #expect(envelope.formatVersion == SyncRecordFormats.vault.writingVersion(for: "sshKey")) let decoded = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) #expect(decoded.payload == payload) let remoteContainer = try TermindPersistence.makeContainer(inMemory: true) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 87ec23e2..a1d32391 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -2607,6 +2607,23 @@ } } }, + "Add an SSH host before exporting a key." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请先添加SSH主机,再导出密钥。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請先新增SSH主機,再匯出金鑰。" + } + } + } + }, "Add and connect to a host before exporting a key to it." : { "extractionState" : "manual", "localizations" : { @@ -2743,6 +2760,23 @@ } } }, + "Advanced" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高级" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "進階" + } + } + } + }, "Agent" : { "extractionState" : "manual", "localizations" : { @@ -11473,6 +11507,23 @@ } } }, + "Export and Attach" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出并绑定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出並綁定" + } + } + } + }, "Export as SKILL.md" : { "comment" : "A button that exports a skill as a `.md` file.", "extractionState" : "manual", @@ -11492,6 +11543,23 @@ } } }, + "Export to Host" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出到主机" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出到主機" + } + } + } + }, "Export to Hosts" : { "extractionState" : "manual", "localizations" : { @@ -11600,6 +11668,23 @@ } } }, + "Exporting public key…" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在导出公钥…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在匯出公鑰…" + } + } + } + }, "Exposed Tools" : { "extractionState" : "manual", "localizations" : { @@ -11908,6 +11993,23 @@ } } }, + "Filename — $2" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件名—$2" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檔案名稱—$2" + } + } + } + }, "Files" : { "extractionState" : "manual", "localizations" : { @@ -15586,6 +15688,23 @@ } } }, + "Location — $1" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目录—$1" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目錄—$1" + } + } + } + }, "Locked" : { "extractionState" : "manual", "localizations" : { @@ -19468,6 +19587,23 @@ } } }, + "Optional. If empty, the public key is derived only when exporting." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可选。留空时,仅在导出时派生公钥。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選填。留空時,僅在匯出時衍生公鑰。" + } + } + } + }, "Orange" : { "extractionState" : "manual", "localizations" : { @@ -20280,6 +20416,23 @@ } } }, + "Pending Identification" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待确认" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "待確認" + } + } + } + }, "Pending uploads: %lld" : { "extractionState" : "manual", "localizations" : { @@ -21450,6 +21603,40 @@ } } }, + "Public key exported and attached." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公钥已导出并绑定。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公鑰已匯出並綁定。" + } + } + } + }, + "Public key exported. The host could not be updated." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公钥已导出,但未能更新主机配置。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公鑰已匯出,但未能更新主機設定。" + } + } + } + }, "Publishes commits to a remote." : { "extractionState" : "manual", "localizations" : { @@ -23310,6 +23497,23 @@ } } }, + "Retry Attachment" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重试绑定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重試綁定" + } + } + } + }, "Retry Now" : { "extractionState" : "manual", "localizations" : { @@ -24537,7 +24741,7 @@ "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "指令碼" + "value" : "指令稿" } } } @@ -28601,6 +28805,29 @@ } } }, + "The default script supports UNIX systems." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The default script supports UNIX systems." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认脚本适用于UNIX系统。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "預設腳本適用於UNIX系統。" + } + } + } + }, "The device path does not exist." : { "extractionState" : "manual", "localizations" : { @@ -28737,6 +28964,40 @@ } } }, + "The export did not complete. Check the remote file before running the script again." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出未完成。再次运行脚本前,请检查远程文件。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出未完成。再次執行指令稿前,請檢查遠端檔案。" + } + } + } + }, + "The export script exited with status %lld." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出脚本以状态码%lld退出。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出指令稿以狀態碼%lld結束。" + } + } + } + }, "The extracted text is longer than 200,000 characters." : { "extractionState" : "manual", "localizations" : { @@ -31971,6 +32232,52 @@ } } }, + "Type %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类型:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "類型:%@" + } + } + } + }, + "Type Unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type Unknown" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类型未知" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "類型未知" + } + } + } + }, "Type something at the shell prompt before generating a command." : { "extractionState" : "manual", "localizations" : { @@ -32506,6 +32813,23 @@ } } }, + "Unlock this key to derive its public key for this export." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁此密钥,为本次导出派生公钥。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "解鎖此金鑰,為本次匯出衍生公鑰。" + } + } + } + }, "Unnamed group" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index 6406dd85..7c21f3b6 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -10,6 +10,7 @@ public nonisolated enum ConnectionPurpose: String, Hashable, Sendable { case serialSession case metrics case portForwarding + case keyExport } public nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable { @@ -468,6 +469,7 @@ extension ConnectionPurpose { case .serialSession: "serial session" case .metrics: "metrics" case .portForwarding: "port forwarding" + case .keyExport: "key export" } } } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 0c5e3bd4..2b7d0869 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -417,32 +417,8 @@ public final class HostLibraryModel: RemoteSessionHostStore { return keys } - /// Why a saved host cannot currently receive a key without stopping for - /// another question. Export is deliberately limited to hosts whose login - /// and host-key trust have already been established elsewhere in the app. public func keyExportUnavailableReason(for host: Host) -> String? { - guard isEligible(host, for: .keyExport) else { - return HostProtocolUnavailableError(useCase: .keyExport).localizedDescription - } - guard let identity = identity(for: host) else { - return String(localized: "No identity") - } - guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return String(localized: "No username") - } - let hasAvailablePassword = passwordAvailability(for: identity) == .available - let hasUsableKey = - identity.keyID.flatMap { key($0) }.map { - keyAvailability(for: $0) == .available - } == true - guard !identity.hasSecret || hasAvailablePassword || hasUsableKey else { - return String(localized: "No saved credential") - } - let knownHosts = knownHosts(for: host) - guard !knownHosts.isEmpty else { - return String(localized: "Connect once to trust this host") - } - return nil + isEligible(host, for: .keyExport) ? nil : HostProtocolUnavailableError(useCase: .keyExport).localizedDescription } /// Resolves model references and Keychain secrets before work leaves the @@ -1225,6 +1201,55 @@ public final class HostLibraryModel: RemoteSessionHostStore { return saved } + /// Commit the destination's private login and host reference together. Reusable + /// identities (and their password references) are never changed by an export. + public func attachExportedKey(_ keyID: SSHKeyRef.ID, to hostID: Host.ID, username: String) throws { + guard let index = hosts.firstIndex(where: { $0.id == hostID }), key(keyID) != nil, + isEligible(hosts[index], for: .keyExport), !username.isEmpty + else { throw TermindSSHError.missingCredential } + let host = hosts[index] + let source = identity(for: host) + let owned = customIdentity(for: hostID, purpose: .ssh) + var destination = + owned ?? Identity(name: host.name, username: username, ownerHostID: hostID, customPurpose: .ssh) + destination.username = username + destination.keyID = keyID + var copiedReference: String? + if source?.id != owned?.id || owned == nil { + destination.credentialRef = "" + if let source, source.usesPassword { + guard let password = try editablePassword(for: source) else { + throw TermindSSHError.missingCredential + } + let reference = "termind.password.\(UUID().uuidString)" + destination.passwordStorage = source.passwordStorage + try keychain.setString(password, for: reference, storage: destination.passwordStorage) + destination.credentialRef = reference + copiedReference = reference + } + } + let previousIdentities = identities + let previousHosts = hosts + if let identityIndex = identities.firstIndex(where: { $0.id == destination.id }) { + identities[identityIndex] = destination + } else { + identities.append(destination) + } + hosts[index].sshIdentityID = destination.id + guard persistCore() else { + identities = previousIdentities + hosts = previousHosts + if let copiedReference { + try keychain.removePassword(copiedReference, storage: destination.passwordStorage) + } + throw TermindSSHError.library(storageError ?? String(localized: "Unable to Save")) + } + if let owned, !owned.credentialRef.isEmpty, owned.credentialRef != destination.credentialRef { + try? keychain.removePassword(owned.credentialRef, storage: owned.passwordStorage) + } + didChangeHostConnection?(hostID) + } + /// Updates an Identity and its Keychain password as one operation. /// /// The editor supplies intent rather than an optional string so a blank @@ -1547,7 +1572,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { ) } if !key.certificate.isEmpty { - _ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey) + _ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey.isEmpty ? nil : key.publicKey) } let keySnapshot = keys let changesLocation = diff --git a/Frameworks/TermindApplication/FeatureModels/KeyExportModel.swift b/Frameworks/TermindApplication/FeatureModels/KeyExportModel.swift index 6ce73e9c..c77fc499 100644 --- a/Frameworks/TermindApplication/FeatureModels/KeyExportModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/KeyExportModel.swift @@ -4,87 +4,428 @@ import TermindDomain import struct TermindDomain.Host -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 public final class KeyExportModel { - public enum ExportState: Equatable { - case running - case succeeded + public enum Phase: Equatable { + case editing, preparing, authenticating, exporting, succeeded case failed(String) + case attachmentFailed(String) } - 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 + public let keyID: SSHKeyRef.ID + public var key: SSHKeyRef? { library.key(keyID) } + public var hostID: Host.ID? { + didSet { if oldValue != hostID { cancel() } } + } + public var exportPassphrase = "" + public var saveExportPassphrase = false + public private(set) var passphraseError: String? + public var options: KeyExportOptions + public let connection = RemoteSessionConnectionController() + public var journey: ConnectionJourney { connection.journey } + public var operationID: UUID { generation } + public private(set) var phase: Phase = .editing + public private(set) var authenticationPrompt: SSHAuthenticationPrompt? + public private(set) var didExport = false + public private(set) var executionStarted = false + private let library: HostLibraryModel private let services: KeyExportServices + private var task: Task? + private var generation = UUID() + private var session: (any KeyExportSession)? + private var submissions: [Host.ID: SSHAuthenticationSubmission] = [:] + private var proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] = [:] + private var derivedPublicKey: String? + private var derivationPassphrase: SSHSecret? + private var savesDerivationPassphrase = false + private var derivationPassphraseToSave: String? + private var username = "" + private var targetSnapshot: Host? + private var keySnapshot: SSHKeyRef? + private var routeSnapshot: [Host] = [] - public init(keys: [SSHKeyRef], credentials: CredentialLibraryModel, services: KeyExportServices) { - self.keys = keys - self.credentials = credentials + public init(key: SSHKeyRef, library: HostLibraryModel, services: KeyExportServices) { + keyID = key.id + self.library = library self.services = services + options = KeyExportOptions(script: KeyExportOptions.defaultScript(keyName: key.label)) } - public func updateSelection(_ ids: Set) { - guard !isExporting, !isFinished else { return } - selected = ids - } - - 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. - targets = credentials.hosts.filter { selected.contains($0.id) } - selected.formIntersection(targets.map(\.id)) - guard !targets.isEmpty else { return } - isExporting = true - var preparations: [(Host.ID, SSHConnectionPreparation)] = [] - for host in targets { - if let reason = credentials.keyExportUnavailableReason(for: host) { - states[host.id] = .failed(reason) - continue - } - do { - preparations.append((host.id, try credentials.prepareSSHConnection(for: host))) - states[host.id] = .running - } catch { - states[host.id] = .failed(error.localizedDescription) - } + public var isBusy: Bool { + switch phase { + case .preparing, .authenticating, .exporting: true + default: false } - 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(exportKeys, configuration) - return (id, nil) - } catch { - return (id, error.localizedDescription) + } + public var needsExportPassphrase: Bool { + guard derivedPublicKey == nil || keySnapshot != key, let key, + key.publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return false } + return passphraseError != nil || (key.hasPassphrase && library.requiresPassphraseEntry(for: key)) + } + public var canStart: Bool { + !isBusy && !didExport && key != nil && hosts.contains { $0.id == hostID } && options.isValid + && (!needsExportPassphrase || !exportPassphrase.isEmpty) + } + public var hosts: [Host] { library.hosts(matching: "", for: .keyExport) } + + public func start() { + guard canStart, task == nil, let hostID, let host = library.host(hostID), let key else { return } + if keySnapshot != key { derivedPublicKey = nil } + targetSnapshot = host + keySnapshot = key + routeSnapshot = library.route(for: host) + executionStarted = false + derivationPassphrase = exportPassphrase.isEmpty ? nil : SSHSecret(exportPassphrase) + derivationPassphraseToSave = saveExportPassphrase && !exportPassphrase.isEmpty ? exportPassphrase : nil + savesDerivationPassphrase = saveExportPassphrase + exportPassphrase = "" + passphraseError = nil + journey.begin( + host: host, route: routeSnapshot, purpose: .keyExport, username: library.identity(for: host)?.username) + launch() + } + + public func retry() { + guard !isBusy else { return } + if didExport { + attach() + } else { + start() + } + } + + public func editOptions() { + guard case .failed = phase else { return } + connection.disconnect() + } + + public func cancel() { + generation = UUID() + task?.cancel() + task = nil + if let session { Task { await session.disconnect() } } + session = nil + authenticationPrompt = nil + connection.disconnect() + clearSecrets() + derivedPublicKey = nil + didExport = false + executionStarted = false + phase = .editing + exportPassphrase = "" + passphraseError = nil + targetSnapshot = nil + keySnapshot = nil + routeSnapshot = [] + } + + public func acceptHostKey(_ observation: SSHHostKeyObservation, operationID: UUID, remember: Bool) { + guard operationID == generation else { return } + guard isBusy, snapshotsAreCurrent else { + cancel() + return + } + if remember { + connection.trustHostKey(observation, using: library) + } else { + _ = connection.useTemporaryHostKey(observation) + } + } + + public func submitAuthentication( + promptID: UUID, identityID: Identity.ID?, username: String, password: String?, + passphrase: String?, keyID: SSHKeyRef.ID?, save: Bool + ) { + guard let prompt = authenticationPrompt, prompt.id == promptID, snapshotsAreCurrent, task == nil else { return } + do { + if prompt.isProxy { + let submission = SSHProxyAuthenticationSubmission( + username: username, password: password, identityID: identityID) + if save { + _ = try library.saveProxyAuthentication( + submission, for: library.host(prompt.host.id) ?? prompt.host, + selectedIdentity: identityID.flatMap(library.identity), + suppliedPassword: password) + refreshRouteSnapshot() + } + proxySubmissions[prompt.host.id] = submission + } else if prompt.requiresPassphrase { + submissions[prompt.host.id] = try library.passphraseSubmission( + for: prompt, existing: submissions[prompt.host.id], passphrase: passphrase, save: save) + } else { + let identity = identityID.flatMap(library.identity) + let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw TermindSSHError.missingUsername } + let selectedKey = identity?.keyID ?? keyID + if save { + let saved = identity ?? Identity(name: name, username: name, keyID: selectedKey) + try library.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep) + guard var host = library.host(prompt.host.id) else { throw TermindSSHError.missingCredential } + host.identityID = saved.id + guard library.saveHost(host) else { + throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save")) } + refreshRouteSnapshot() + } + submissions[prompt.host.id] = SSHAuthenticationSubmission( + username: name, password: password, passphrase: passphrase, keyID: selectedKey, + identityID: identityID) + } + authenticationPrompt = nil + journey.resume() + launch() + } catch { + var failed = prompt + failed.failureMessage = error.localizedDescription + authenticationPrompt = failed + } + } + + private func refreshRouteSnapshot() { + if let hostID, let host = library.host(hostID) { + targetSnapshot = host + routeSnapshot = library.route(for: host) + } + } + + private var snapshotsAreCurrent: Bool { + keySnapshot == key && targetSnapshot?.id == hostID + && routeSnapshot.allSatisfy { old in library.host(old.id)?.hasSameConnectionRoute(as: old) == true } + } + + private func check(_ id: UUID) throws { + try Task.checkCancellation() + guard generation == id, snapshotsAreCurrent else { throw CancellationError() } + } + + private func launch() { + guard task == nil, let host = targetSnapshot, let key = keySnapshot else { return } + let id = UUID() + generation = id + phase = .preparing + let options = options + task = Task { [weak self] in + guard let self else { return } + do { + try check(id) + guard options.isValid, library.keyExportUnavailableReason(for: host) == nil else { + throw HostProtocolUnavailableError(useCase: .keyExport) + } + let publicKey: String + if !key.publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + publicKey = key.publicKey + } else if let derivedPublicKey { + publicKey = derivedPublicKey + } else { + guard let credential = try library.keySecretCredential(for: key) else { + throw TermindSSHError.missingCredential + } + defer { credential.clearSecrets() } + if key.hasPassphrase && !credential.hasSavedPassphrase && derivationPassphrase == nil { + requestDerivationPassphrase(failure: String(localized: "Passphrase required")) + task = nil + return + } + let passphrase = derivationPassphrase + derivationPassphrase = nil + defer { passphrase?.clear() } + do { + publicKey = try await services.derivePublicKey(key, credential, passphrase) + } catch { + try check(id) + guard key.hasPassphrase else { throw error } + clearSecrets() + requestDerivationPassphrase(failure: error.localizedDescription) + task = nil + return + } + try check(id) + if savesDerivationPassphrase, let derivationPassphraseToSave { + try library.saveKeyPassphrase(derivationPassphraseToSave, for: key.id) + } + derivationPassphraseToSave = nil + savesDerivationPassphrase = false + self.derivedPublicKey = publicKey + } + if !key.certificate.isEmpty { _ = try SSHUserCertificate.parse(key.certificate, matching: publicKey) } + try check(id) + if let pending = proxySubmissions.firstHostRequiringProxyPassword(in: routeSnapshot) { + requestAuthentication( + .proxyAuthentication( + hostID: pending.id, serverChallenge: false, + message: String(localized: "The proxy password is unavailable."))) + task = nil + return + } + if let pending = submissions.firstHostRequiringPassword(in: routeSnapshot) { + requestAuthentication( + .routeHost( + hostID: pending.id, authentication: true, serverChallenge: false, hostKey: false, + message: TermindSSHError.missingCredential.localizedDescription)) + task = nil + return + } + if let pending = submissions.firstHostRequiringPassphrase( + in: routeSnapshot, key: library.key, requiresEntry: library.requiresPassphraseEntry) + { + requestAuthentication( + .routeHost( + hostID: pending.id, authentication: true, serverChallenge: false, hostKey: false, + message: TermindSSHError.keyPassphraseRequired.localizedDescription)) + task = nil + return + } + let attemptSubmissions = submissions.takeAuthenticationSecrets() + let attemptProxies = proxySubmissions.takeProxyAuthenticationSecrets() + let preparation = try library.prepareSSHConnection( + for: host, submissions: attemptSubmissions, proxySubmissions: attemptProxies, + temporaryHostKeys: connection.temporaryHostKeys) + let configuration = try await preparation.resolve(trustPolicy: .allowChallenge) + defer { configuration.clearSecrets() } + try check(id) + username = configuration.username + phase = .authenticating + let request = SSHConnectionRequest( + configuration: configuration, + authorizeHostKey: { [weak self] challenge in + guard let self else { return .reject } + return try await self.authorize(challenge, generation: id) + }, + keyboardInteractive: { [weak self] challenge in + guard let self else { throw CancellationError() } + return try await self.answer(challenge, generation: id) + }, + securityKeySigning: { [weak self] request in + guard let self else { throw CancellationError() } + return try await self.sign(request, generation: id) + }) + let opened = try await services.connect( + request, + connection.progressHandler(attemptID: id, isCurrent: { [weak self] in self?.generation == $0 })) + do { try check(id) } catch { + await opened.disconnect() + throw error + } + session = opened + phase = .exporting + executionStarted = true + do { + try await opened.install(publicKey: publicKey, options: options) + await opened.disconnect() + } catch { + await opened.disconnect() + throw error + } + try check(id) + session = nil + task = nil + didExport = true + clearSecrets() + derivedPublicKey = nil + connection.disconnect() + attach() + } catch { + guard generation == id else { return } + session = nil + task = nil + if error is CancellationError { + cancel() + } else if !executionStarted, let ssh = error as? TermindSSHError, + ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication + { + requestAuthentication(ssh) + } else { + clearSecrets() + let message = + executionStarted + ? String( + localized: + "The export did not complete. Check the remote file before running the script again.") + + "\n" + error.localizedDescription + : error.localizedDescription + phase = .failed(message) + journey.fail(message) } } - for await (id, error) in group { - states[id] = error.map(ExportState.failed) ?? .succeeded + } + } + + private func authorize(_ challenge: SSHHostKeyChallenge, generation: UUID) async throws -> SSHHostKeyDecision { + try check(generation) + let decision = await connection.authorizeHostKey(challenge) + try check(generation) + return decision + } + + private func answer(_ challenge: SSHKeyboardInteractiveChallenge, generation: UUID) async throws -> [String] { + try check(generation) + let answers = try await connection.answerKeyboardInteractive(challenge) + try check(generation) + return answers + } + + private func sign(_ request: SecurityKeySigningRequest, generation: UUID) async throws -> SecurityKeyAssertion { + try check(generation) + let assertion = try await connection.signWithSecurityKey(request) + try check(generation) + return assertion + } + + private func requestDerivationPassphrase(failure: String) { + passphraseError = failure + phase = .editing + journey.hide() + } + + private func requestAuthentication(_ error: TermindSSHError) { + guard let host = library.host(error.routeHostID ?? hostID ?? UUID()) else { + clearSecrets() + phase = .failed(error.localizedDescription) + journey.fail(error.localizedDescription) + return + } + if error.isProxyAuthentication { + authenticationPrompt = SSHAuthenticationPromptResolver.proxy( + for: host, using: library, submission: proxySubmissions[host.id], + failureMessage: error.localizedDescription, + mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity) + journey.waitForProxyAuthentication(at: host.id, failure: error.localizedDescription) + } else { + authenticationPrompt = SSHAuthenticationPromptResolver.host( + host, using: library, submission: submissions[host.id], failureMessage: error.localizedDescription, + mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity) + if authenticationPrompt?.requiresPassphrase == true { + journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription) + } else if error.isServerAuthenticationChallenge { + journey.waitForAuthentication(at: host.id, failure: error.localizedDescription) + } else { + journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription) } } - isExporting = false - isFinished = true + phase = .authenticating + } + + private func attach() { + guard didExport, let hostID else { return } + do { + guard snapshotsAreCurrent else { throw TermindSSHError.missingCredential } + try library.attachExportedKey(keyID, to: hostID, username: username) + phase = .succeeded + } catch { + phase = .attachmentFailed(error.localizedDescription) + } + } + + private func clearSecrets() { + for id in submissions.keys { submissions[id]?.clearSecrets() } + for id in proxySubmissions.keys { proxySubmissions[id]?.clearSecrets() } + submissions.removeAll() + proxySubmissions.removeAll() + derivationPassphrase?.clear() + derivationPassphrase = nil + derivationPassphraseToSave = nil + savesDerivationPassphrase = false } } diff --git a/Frameworks/TermindApplication/FeatureModels/KeyExportOptions.swift b/Frameworks/TermindApplication/FeatureModels/KeyExportOptions.swift new file mode 100644 index 00000000..5ffbce38 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/KeyExportOptions.swift @@ -0,0 +1,61 @@ +import Foundation +import TermindDomain + +public nonisolated struct KeyExportOptions: Equatable, Sendable { + public var directory: String + public var filename: String + public var script: String + + public init(directory: String = ".ssh", filename: String = "authorized_keys", script: String) { + self.directory = directory + self.filename = filename + self.script = script + } + + public var isValid: Bool { + !directory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !filename.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !filename.contains("/") && !filename.contains("\\") && ![".", ".."].contains(filename) + && !directory.contains("\0") && !filename.contains("\0") && !script.contains("\0") + } + + public static func defaultScript(keyName: String) -> String { + let name = keyName.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined( + separator: " " + ) + .filter { !$0.isNewline && $0 != "\0" } + let comment = RemoteShellCommand.escapeArgument("# \(name) Key by Termind") + return """ + set -eu + umask 077 + mkdir -p -- "$1" + touch -- "$1/$2" + chmod 700 "$1" + chmod 600 "$1/$2" + if ! LC_ALL=C grep -Fqx -- "$3" "$1/$2"; then + if [ -s "$1/$2" ]; then printf '\\n' >> "$1/$2"; fi + printf '%s\\n%s\\n' \(comment) "$3" >> "$1/$2" + fi + """ + } +} + +public nonisolated protocol KeyExportSession: Sendable { + func install(publicKey: String, options: KeyExportOptions) async throws + func disconnect() async +} + +public nonisolated struct KeyExportServices: Sendable { + public let derivePublicKey: @Sendable (SSHKeyRef, SSHResolvedKeyCredential, SSHSecret?) async throws -> String + public let connect: @Sendable (SSHConnectionRequest, SSHRouteProgressHandler?) async throws -> any KeyExportSession + + public init( + derivePublicKey: @escaping @Sendable (SSHKeyRef, SSHResolvedKeyCredential, SSHSecret?) async throws -> String, + connect: + @escaping @Sendable (SSHConnectionRequest, SSHRouteProgressHandler?) async throws -> any KeyExportSession + ) { + self.derivePublicKey = derivePublicKey + self.connect = connect + } +} diff --git a/Frameworks/TermindDomain/Credentials/SSHKey.swift b/Frameworks/TermindDomain/Credentials/SSHKey.swift index 199b2cdb..12cfe6fc 100644 --- a/Frameworks/TermindDomain/Credentials/SSHKey.swift +++ b/Frameworks/TermindDomain/Credentials/SSHKey.swift @@ -12,6 +12,7 @@ public nonisolated enum KeyStorage: String, Codable, Hashable, Sendable, CaseIte } public nonisolated enum SSHKeyAlgorithm: String, Codable, Hashable, Sendable, CaseIterable, Identifiable { + case unknown case securityKey = "sk-ecdsa-sha2-nistp256@openssh.com" case ed25519 = "ssh-ed25519" case ecdsa256 = "ecdsa-sha2-nistp256" diff --git a/Frameworks/TermindDomain/Credentials/SSHKeyAlgorithm+Display.swift b/Frameworks/TermindDomain/Credentials/SSHKeyAlgorithm+Display.swift index 295dd8ea..4d1ef11b 100644 --- a/Frameworks/TermindDomain/Credentials/SSHKeyAlgorithm+Display.swift +++ b/Frameworks/TermindDomain/Credentials/SSHKeyAlgorithm+Display.swift @@ -1,6 +1,7 @@ extension SSHKeyAlgorithm { public nonisolated var displayName: String { switch self { + case .unknown: String(localized: "Pending Identification") case .securityKey: "ECDSA-SK P-256" case .ed25519: "Ed25519" case .ecdsa256: "ECDSA P-256" @@ -12,6 +13,7 @@ extension SSHKeyAlgorithm { public nonisolated var typeName: String { switch self { + case .unknown: String(localized: "Pending Identification") case .securityKey: "Security Key" case .ed25519: "Ed25519" case .ecdsa256, .ecdsa384, .ecdsa521: "ECDSA" diff --git a/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift b/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift index fbce2a7e..44f73113 100644 --- a/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift +++ b/Frameworks/TermindDomain/SSHImport/SSHImportParser.swift @@ -209,6 +209,7 @@ public nonisolated enum SSHImportParser { } guard let name = field(), String(data: name, encoding: .utf8) == algorithm.rawValue else { return false } switch algorithm { + case .unknown: return false case .securityKey: guard field() == Data("nistp256".utf8), let point = field(), point.count == 65, point.first == 4, let application = field(), !application.isEmpty diff --git a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift index bb4bde15..14afffdf 100644 --- a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift +++ b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift @@ -46,7 +46,7 @@ public nonisolated struct SyncRecordFormats: Sendable { public static let vault = Self([ "host": Format(readingVersions: [1, 2], writingVersion: 2), "hostGroup": Format(readingVersions: [1, 2, 3], writingVersion: 3), "identity": v1, - "sshKey": Format(readingVersions: [1, 2], writingVersion: 2), + "sshKey": Format(readingVersions: [1, 2, 3], writingVersion: 3), "knownHost": v1, "snippet": v1, "snippetPackage": v1, "portForwarding": v1, "workspaceTemplate": v1, // Legacy readers only; these cases cannot pass Vault upload eligibility. diff --git a/Frameworks/TermindPersistence/VaultSyncPersistence.swift b/Frameworks/TermindPersistence/VaultSyncPersistence.swift index af2166ab..341b73d4 100644 --- a/Frameworks/TermindPersistence/VaultSyncPersistence.swift +++ b/Frameworks/TermindPersistence/VaultSyncPersistence.swift @@ -89,6 +89,9 @@ public nonisolated enum VaultSyncCodec { decoders[VaultRecordKind.hostGroup.rawValue]?[2] = { try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue } + decoders[VaultRecordKind.sshKey.rawValue]?[3] = { + try JSONDecoder().decode(VaultWireV3.Envelope.self, from: $0).domainValue + } decoders[VaultRecordKind.hostGroup.rawValue]?[3] = { try JSONDecoder().decode(VaultWireV3.Envelope.self, from: $0).domainValue } diff --git a/Frameworks/TermindPersistence/VaultWireV2.swift b/Frameworks/TermindPersistence/VaultWireV2.swift index 7de5b375..4b48625b 100644 --- a/Frameworks/TermindPersistence/VaultWireV2.swift +++ b/Frameworks/TermindPersistence/VaultWireV2.swift @@ -247,6 +247,9 @@ nonisolated enum VaultWireV2 { } else if case .hostGroup(let group) = envelope.payload { payload = .hostGroup(try HostGroup(group)) } else if case .sshKey(let key) = envelope.payload { + guard SSHKeyAlgorithm(rawValue: key.algorithm.rawValue) != nil else { + throw CocoaError(.coderInvalidValue) + } guard key.storage != .external || key.algorithm == .securityKey else { throw CocoaError(.coderInvalidValue) } diff --git a/Frameworks/TermindPersistence/VaultWireV3.swift b/Frameworks/TermindPersistence/VaultWireV3.swift index 30fe5905..a994bed6 100644 --- a/Frameworks/TermindPersistence/VaultWireV3.swift +++ b/Frameworks/TermindPersistence/VaultWireV3.swift @@ -1,7 +1,7 @@ import Foundation import TermindDomain -/// Group-scoped Tailscale host identities require an explicit V3 wire contract. +/// V3 preserves group-scoped Tailscale identities and keys whose algorithm is not yet known. nonisolated enum VaultWireV3 { struct HostGroup: Codable { var base: VaultWireV1.HostGroup @@ -41,7 +41,181 @@ nonisolated enum VaultWireV3 { } } + typealias KeyStorage = VaultWireV1.KeyStorage + typealias TagColor = VaultWireV1.TagColor + enum SSHKeyAlgorithm: String, Codable { + case unknown + case securityKey = "sk-ecdsa-sha2-nistp256@openssh.com" + case ed25519 = "ssh-ed25519" + case ecdsa256 = "ecdsa-sha2-nistp256" + case ecdsa384 = "ecdsa-sha2-nistp384" + case ecdsa521 = "ecdsa-sha2-nistp521" + case rsa = "ssh-rsa" + } + struct SecurityKeyReference: Codable { + enum Verification: String, Codable { case required, preferred, discouraged } + var credentialID: Data + var publicKeyX963: Data + var userVerification: Verification + var requiresUserPresence: Bool + init(_ value: TermindDomain.SecurityKeyCredential) { + credentialID = value.credentialID + publicKeyX963 = value.publicKeyX963 + userVerification = Verification(rawValue: value.userVerification.rawValue)! + requiresUserPresence = value.requiresUserPresence + } + var domainValue: TermindDomain.SecurityKeyCredential { + TermindDomain.SecurityKeyCredential( + credentialID: credentialID, publicKeyX963: publicKeyX963, + userVerification: .init(rawValue: userVerification.rawValue)!, + requiresUserPresence: requiresUserPresence) + } + } + struct SSHKeyRef: Codable, Identifiable { + var id: UUID = UUID() + var label: String + var algorithm: SSHKeyAlgorithm = .ed25519 + var publicKey: String = "" + var certificate: String = "" + var fingerprint: String = "" + var securityKey: SecurityKeyReference? + var credentialRef: String = "" + var storage: KeyStorage = .iCloud + var keySize: Int? + var hasPassphrase = false + var requiresUserPresence = false + var createdAt: Date = Date(timeIntervalSince1970: 0) + var colorTag: TagColor = .none + var sortIndex: Double = 0 + init( + id: UUID = UUID(), label: String, algorithm: SSHKeyAlgorithm = .ed25519, publicKey: String = "", + certificate: String = "", fingerprint: String = "", credentialRef: String = "", + storage: KeyStorage = .iCloud, + securityKey: SecurityKeyReference? = nil, keySize: Int? = nil, hasPassphrase: Bool = false, + requiresUserPresence: Bool = false, + createdAt: Date = Date(timeIntervalSince1970: 0), colorTag: TagColor = .none, sortIndex: Double = 0 + ) { + self.id = id + self.label = label + self.algorithm = algorithm + self.publicKey = publicKey + self.certificate = certificate + self.fingerprint = fingerprint + self.securityKey = securityKey + self.credentialRef = credentialRef + self.storage = storage + self.keySize = keySize + self.hasPassphrase = hasPassphrase + self.requiresUserPresence = requiresUserPresence + self.createdAt = createdAt + self.colorTag = colorTag + self.sortIndex = sortIndex + } + private enum CodingKeys: String, CodingKey { + case id, label, algorithm, publicKey, certificate, fingerprint + case securityKey, credentialRef, storage, keySize, hasPassphrase, requiresUserPresence + case createdAt, colorTag, sortIndex + } + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try container.decode(UUID.self, forKey: .id), + label: try container.decode(String.self, forKey: .label), + algorithm: try container.decode(SSHKeyAlgorithm.self, forKey: .algorithm), + publicKey: try container.decode(String.self, forKey: .publicKey), + certificate: try container.decode(String.self, forKey: .certificate), + fingerprint: try container.decode(String.self, forKey: .fingerprint), + credentialRef: try container.decode(String.self, forKey: .credentialRef), + storage: try container.decode(KeyStorage.self, forKey: .storage), + securityKey: try container.decodeIfPresent(SecurityKeyReference.self, forKey: .securityKey), + keySize: try container.decodeIfPresent(Int.self, forKey: .keySize), + hasPassphrase: try container.decode(Bool.self, forKey: .hasPassphrase), + requiresUserPresence: try container.decode(Bool.self, forKey: .requiresUserPresence), + createdAt: try container.decode(Date.self, forKey: .createdAt), + colorTag: (try? container.decode(TagColor.self, forKey: .colorTag)) ?? .none, + sortIndex: try container.decode(Double.self, forKey: .sortIndex) + ) + } + init(_ value: TermindDomain.SSHKeyRef) { + self.securityKey = value.securityKey.map(SecurityKeyReference.init) + self.id = value.id + self.label = value.label + self.algorithm = SSHKeyAlgorithm(rawValue: value.algorithm.rawValue)! + self.publicKey = value.publicKey + self.certificate = value.certificate + self.fingerprint = value.fingerprint + self.credentialRef = value.credentialRef + // Retain the existing external-authenticator storage representation. + self.storage = value.storage == .external ? .thisDevice : KeyStorage(rawValue: value.storage.rawValue)! + self.keySize = value.keySize + self.hasPassphrase = value.hasPassphrase + self.requiresUserPresence = value.requiresUserPresence + self.createdAt = value.createdAt + self.colorTag = TagColor(rawValue: value.colorTag.rawValue)! + self.sortIndex = value.sortIndex + } + var domainValue: TermindDomain.SSHKeyRef { + var result = TermindDomain.SSHKeyRef(label: "") + result.securityKey = securityKey?.domainValue + result.id = id + result.label = label + result.algorithm = TermindDomain.SSHKeyAlgorithm(rawValue: algorithm.rawValue)! + result.publicKey = publicKey + result.certificate = certificate + result.fingerprint = fingerprint + result.credentialRef = credentialRef + result.storage = + algorithm == .securityKey ? .external : TermindDomain.KeyStorage(rawValue: storage.rawValue)! + result.keySize = keySize + result.hasPassphrase = hasPassphrase + result.requiresUserPresence = requiresUserPresence + result.createdAt = createdAt + result.colorTag = TermindDomain.TagColor(rawValue: colorTag.rawValue)! + result.sortIndex = sortIndex + return result + } + } + /// Shipped V2 has only control-plane-scoped host IDs. Keep its shape frozen. + struct TailscaleConfiguration: Codable { + var network: TailscaleNetwork + var username: String + var exitNodeID: String? + var hostPreferences: [String: TailscaleHostPreferences] + + init(_ value: TailscaleGroupConfiguration) throws { + guard value.hostIdentityVersion == .legacy else { throw CocoaError(.coderInvalidValue) } + network = value.network + username = value.username + exitNodeID = value.exitNodeID + hostPreferences = Dictionary( + uniqueKeysWithValues: value.hostPreferences.map { ($0.key.uuidString.lowercased(), $0.value) }) + } + + private enum CodingKeys: String, CodingKey { case network, username, exitNodeID, hostPreferences } + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + network = try values.decode(TailscaleNetwork.self, forKey: .network) + username = try values.decode(String.self, forKey: .username) + exitNodeID = try values.decodeIfPresent(String.self, forKey: .exitNodeID) + hostPreferences = try values.decode([String: TailscaleHostPreferences].self, forKey: .hostPreferences) + guard hostPreferences.keys.allSatisfy({ UUID(uuidString: $0)?.uuidString.lowercased() == $0 }) else { + throw DecodingError.dataCorruptedError( + forKey: .hostPreferences, in: values, debugDescription: "Invalid host identifier") + } + } + + var domainValue: TailscaleGroupConfiguration { + var value = TailscaleGroupConfiguration(network: network, hostIdentityVersion: .legacy) + value.username = username + value.exitNodeID = exitNodeID + value.hostPreferences = Dictionary( + uniqueKeysWithValues: hostPreferences.map { (UUID(uuidString: $0.key)!, $0.value) }) + return value + } + } + enum Payload: Codable { + case sshKey(SSHKeyRef) case hostGroup(HostGroup) } @@ -53,12 +227,17 @@ nonisolated enum VaultWireV3 { var payload: Payload? init(_ envelope: VaultEnvelope) throws { - guard envelope.key.kind == .hostGroup else { throw CocoaError(.coderInvalidValue) } + guard [.hostGroup, .sshKey].contains(envelope.key.kind) else { throw CocoaError(.coderInvalidValue) } formatVersion = 3 key = envelope.key version = envelope.version isDeleted = envelope.isDeleted - if case .hostGroup(let group) = envelope.payload { + if case .sshKey(let key) = envelope.payload { + guard key.storage != .external || key.algorithm == .securityKey else { + throw CocoaError(.coderInvalidValue) + } + payload = .sshKey(SSHKeyRef(key)) + } else if case .hostGroup(let group) = envelope.payload { payload = .hostGroup(HostGroup(group)) } else if envelope.payload == nil { payload = nil @@ -70,6 +249,7 @@ nonisolated enum VaultWireV3 { var domainValue: VaultEnvelope { let value: VaultRecordPayload? switch payload { + case .sshKey(let key): value = .sshKey(key.domainValue) case .hostGroup(let group): value = .hostGroup(group.domainValue) case nil: value = nil } diff --git a/Frameworks/TermindPresentation/ConnectionJourneySections.swift b/Frameworks/TermindPresentation/ConnectionJourneySections.swift index 4003f06c..1a7112a6 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneySections.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneySections.swift @@ -15,6 +15,7 @@ extension ConnectionPurpose { case (.sftpChannel, .routeDestination): "folder.fill" case (.serialSession, _): "cable.connector" case (.metrics, _): "waveform.path.ecg" + case (.keyExport, _): "key.fill" case (.portForwarding, _): "arrow.left.arrow.right" } } @@ -89,7 +90,7 @@ public struct ConnectionJourneyHeader: View { private var detail: String { let protocolName: String = switch journey.purpose { - case .sshSession, .metrics, .portForwarding: String(localized: AppStrings.Vault.ssh) + case .sshSession, .metrics, .portForwarding, .keyExport: String(localized: AppStrings.Vault.ssh) case .telnetSession: String(localized: AppStrings.Vault.telnet) case .sftpChannel: String(localized: AppStrings.SFTP.sftp) case .serialSession: String(localized: AppStrings.Vault.serial) @@ -131,6 +132,7 @@ public struct ConnectionJourneyStatus: View { case .sshSession, .telnetSession, .serialSession: String(localized: AppStrings.Connections.terminalReady) case .sftpChannel: String(localized: AppStrings.Connections.fileBrowserReady) case .metrics: String(localized: AppStrings.Connections.metricsReady) + case .keyExport: String(localized: AppStrings.KeyExport.completed) case .portForwarding: String(localized: AppStrings.Connections.forwardingReady) } case .failed(let message): message diff --git a/Frameworks/TermindPresentation/KeyExportForm.swift b/Frameworks/TermindPresentation/KeyExportForm.swift new file mode 100644 index 00000000..405df5d0 --- /dev/null +++ b/Frameworks/TermindPresentation/KeyExportForm.swift @@ -0,0 +1,112 @@ +import SwiftUI +import TermindApplication +import TermindDomain + +public struct KeyExportForm: View { + @Bindable private var model: KeyExportModel + @State private var advanced = false + + public init(model: KeyExportModel) { self.model = model } + + public var body: some View { + Form { + if let key = model.key { + Section { + HStack(spacing: 12) { + Image(systemName: "key.fill").font(.title2).foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 4) { + Text(key.label).font(.headline) + Text( + key.algorithm == .unknown + ? AppStrings.Keychain.unknownKeyType + : AppStrings.Keychain.keyType(key.algorithm.displayName) + ).font(.caption).foregroundStyle(.secondary) + } + } + } + } + if model.phase == .succeeded { + Section { + Label(AppStrings.KeyExport.completed.swiftUIKey, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } else if case .attachmentFailed(let message) = model.phase { + Section { + Label(AppStrings.KeyExport.attachmentFailed.swiftUIKey, systemImage: "exclamationmark.triangle") + Text(message).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + Button(AppStrings.KeyExport.retryAttachment.swiftUIKey) { model.retry() } + } + } else { + if case .failed(let message) = model.phase { + Section { + Text(message).font(.caption).foregroundStyle(.red).textSelection(.enabled) + } + } + Section { + Picker(AppStrings.Vault.host.swiftUIKey, selection: $model.hostID) { + Text(AppStrings.KeyExport.selectHost).tag(UUID?.none) + ForEach(model.hosts) { host in Text(host.name).tag(Optional(host.id)) } + } + if model.hosts.isEmpty { Text(AppStrings.KeyExport.noHosts).foregroundStyle(.secondary) } + LabeledContent(AppStrings.KeyExport.location.swiftUIKey) { + TextField( + AppStrings.KeyExport.location.swiftUIKey, text: $model.options.directory.singleLineText + ).labelsHidden().multilineTextAlignment(.trailing).singleLinePaste() + } + LabeledContent(AppStrings.KeyExport.filename.swiftUIKey) { + TextField( + AppStrings.KeyExport.filename.swiftUIKey, text: $model.options.filename.singleLineText + ) + .labelsHidden().multilineTextAlignment(.trailing).singleLinePaste() + } + } + if model.needsExportPassphrase { + Section { + RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.exportPassphrase) + Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.saveExportPassphrase) + if let error = model.passphraseError { Text(error).font(.caption).foregroundStyle(.red) } + } footer: { + Text(AppStrings.KeyExport.passphraseHelp) + } + } + Section { + Text(AppStrings.KeyExport.scriptHelp).font(.caption).foregroundStyle(.secondary) + DisclosureGroup(AppStrings.KeyExport.advanced.swiftUIKey, isExpanded: $advanced) { + scriptEditor + } + } + } + } + .formStyle(.grouped) + .disabled(model.isBusy) + #if os(iOS) + .textInputAutocapitalization(.never).autocorrectionDisabled() + #endif + } + + private var scriptFont: Font { + #if os(macOS) + .caption.monospaced() + #else + .body.monospaced() + #endif + } + + private var scriptEditor: some View { + TextEditor(text: $model.options.script) + .font(scriptFont) + .multilineTextAlignment(.leading) + .frame(minHeight: 240) + .scrollContentBackground(.hidden) + .overlay(alignment: .topLeading) { + if model.options.script.isEmpty { + Text(AppStrings.KeyExport.script) + .font(scriptFont) + .foregroundStyle(.tertiary) + .padding(.leading, 5) + .allowsHitTesting(false) + } + } + .accessibilityLabel(AppStrings.KeyExport.script.swiftUIKey) + } +} diff --git a/Frameworks/TermindPresentation/Strings/KeyExport.swift b/Frameworks/TermindPresentation/Strings/KeyExport.swift new file mode 100644 index 00000000..393a7b24 --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/KeyExport.swift @@ -0,0 +1,28 @@ +import Foundation + +extension AppStrings { + public nonisolated enum KeyExport { + public static var exportAndAttach: LocalizedStringResource { "Export and Attach" } + public static var completed: LocalizedStringResource { "Public key exported and attached." } + public static var attachmentFailed: LocalizedStringResource { + "Public key exported. The host could not be updated." + } + public static var retryAttachment: LocalizedStringResource { "Retry Attachment" } + public static var location: LocalizedStringResource { "Location — $1" } + public static var filename: LocalizedStringResource { "Filename — $2" } + public static var advanced: LocalizedStringResource { "Advanced" } + public static var script: LocalizedStringResource { "Script" } + public static var scriptHelp: LocalizedStringResource { + "The default script supports UNIX systems." + } + public static var optionalPublicKey: LocalizedStringResource { + "Optional. If empty, the public key is derived only when exporting." + } + public static var passphraseHelp: LocalizedStringResource { + "Unlock this key to derive its public key for this export." + } + public static var exporting: LocalizedStringResource { "Exporting public key…" } + public static var selectHost: LocalizedStringResource { "Select Host" } + public static var noHosts: LocalizedStringResource { "Add an SSH host before exporting a key." } + } +} diff --git a/Frameworks/TermindPresentation/Strings/Keychain.swift b/Frameworks/TermindPresentation/Strings/Keychain.swift index 75651cc4..bbfb25e6 100644 --- a/Frameworks/TermindPresentation/Strings/Keychain.swift +++ b/Frameworks/TermindPresentation/Strings/Keychain.swift @@ -4,6 +4,8 @@ extension AppStrings { public nonisolated enum Keychain { public static var savePassphrase: LocalizedStringResource { "Save Passphrase" } public static var saveAndContinue: LocalizedStringResource { "Save and Continue" } + public static func keyType(_ name: String) -> LocalizedStringResource { "Type \(name)" } + public static var unknownKeyType: LocalizedStringResource { "Type Unknown" } public static var addAndConnectToAHostBeforeExportingAKeyToIt: LocalizedStringResource { "Add and connect to a host before exporting a key to it." @@ -97,8 +99,8 @@ extension AppStrings { "Elliptic Curve" } - public static var exportToHosts: LocalizedStringResource { - "Export to Hosts" + public static var exportToHost: LocalizedStringResource { + "Export to Host" } public static var exported: LocalizedStringResource { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift index e7ea4f55..d98a9cbe 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift @@ -123,6 +123,25 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable { } } + public func keyExportServices() -> KeyExportServices { + KeyExportServices( + derivePublicKey: { key, credential, passphrase in + try await SSHKeyExportSession.derivePublicKey(key: key, credential: credential, passphrase: passphrase) + }, + connect: { [self] request, progress in + let connection = SSHConnection( + keyboardInteractive: keyboardInteractive, logger: logger, maxSessionsPolicy: maxSessionsPolicy) + do { + try await connection.connect(using: request, progress: progress, connectionFailure: nil) + try Task.checkCancellation() + return SSHKeyExportSession(connection: connection) + } catch { + await connection.disconnect() + throw error + } + }) + } + public func installAuthorizedKey( _ publicKey: String, named keyName: String, using configuration: SSHConnectionConfiguration diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyExportSession.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyExportSession.swift new file mode 100644 index 00000000..bdaf944d --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyExportSession.swift @@ -0,0 +1,58 @@ +import Foundation +import TermindApplication +import TermindDomain + +nonisolated struct SSHKeyExportSession: KeyExportSession { + let connection: any SSHConnectionProtocol + + func install(publicKey: String, options: KeyExportOptions) async throws { + let command = try Self.command(publicKey: publicKey, options: options) + let result = try await connection.execute( + command, workingDirectory: nil, stdin: nil, timeout: 20, outputLimit: 64 * 1024, onOutput: nil) + guard result.exitCode == 0 else { + throw TermindSSHError.library( + String(localized: "The export script exited with status \(result.exitCode).") + + "\n" + result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + func disconnect() async { await connection.disconnect() } + + static func command(publicKey: String, options: KeyExportOptions) throws -> String { + guard options.isValid else { throw CocoaError(.coderInvalidValue) } + let key = try SSHKeyMaterial.publicMaterial(publicKey).publicKey + let launcher = """ + case "$1" in + /*) directory=$1 ;; + '~') directory=$HOME ;; + '~/'*) directory="$HOME/${1#??}" ;; + *) directory="$HOME/$1" ;; + esac + exec sh -c "$4" termind-key-export "$directory" "$2" "$3" + """ + return "sh -c " + + [launcher, "termind-key-export", options.directory, options.filename, key, options.script] + .map(RemoteShellCommand.escapeArgument).joined(separator: " ") + } + + static func derivePublicKey( + key: SSHKeyRef, credential: SSHResolvedKeyCredential, passphrase: SSHSecret? + ) async throws -> String { + try await Task.detached(priority: .userInitiated) { + defer { + credential.clearSecrets() + passphrase?.clear() + } + guard case .software(let privateKey, let saved) = credential else { + throw TermindSSHError.missingCredential + } + let password = passphrase ?? saved + let passwordText = try password?.withUnsafeCStringAndConsume { bytes, _ in String(cString: bytes) } + return try privateKey.withUnsafeCStringAndConsume { bytes, _ in + try SSHKeyMaterial.inspect( + privateKey: String(cString: bytes), passphrase: passwordText, comment: key.label + ).publicKey + } + }.value + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial+Import.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial+Import.swift new file mode 100644 index 00000000..f13d00d0 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial+Import.swift @@ -0,0 +1,180 @@ +import Foundation +import OpenSSL +import TermindDomain + +nonisolated extension SSHKeyMaterial { + public struct PrivateKeyMetadata: Sendable { + public var algorithm: SSHKeyAlgorithm + public var keySize: Int? + public var isEncrypted: Bool + } + + /// Reads the key envelope without deriving or populating a public key. An encrypted + /// PKCS#8 envelope deliberately leaves its algorithm unknown until it is unlocked. + public static func privateKeyMetadata(_ text: String) throws -> PrivateKeyMetadata { + let lines = text.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) + guard let header = lines.first, let footer = lines.last, + header.hasPrefix("-----BEGIN "), header.hasSuffix("PRIVATE KEY-----"), + footer == header.replacingOccurrences(of: "BEGIN", with: "END") + else { throw SSHKeyMaterialError.invalidPrivateKey("Invalid private key envelope.") } + let body = lines.dropFirst().dropLast().filter { !$0.contains(":") }.joined() + guard let bytes = Data(base64Encoded: body), !bytes.isEmpty else { + throw SSHKeyMaterialError.invalidPrivateKey("Invalid private key encoding.") + } + if header == "-----BEGIN OPENSSH PRIVATE KEY-----" { + let magic = Data("openssh-key-v1\0".utf8) + guard bytes.starts(with: magic) else { throw invalidEnvelope } + var offset = magic.count + guard let cipher = readSSHField(bytes, offset: &offset), + let kdf = readSSHField(bytes, offset: &offset), + let options = readSSHField(bytes, offset: &offset), offset + 4 <= bytes.count + else { throw invalidEnvelope } + let count = bytes[offset..= 8) else { + throw invalidEnvelope + } + if !encrypted { try validateOpenSSHPrivateBlock(privateBlob, algorithm: material.algorithm) } + return PrivateKeyMetadata( + algorithm: material.algorithm, keySize: material.keySize, isEncrypted: encrypted) + } + if header == "-----BEGIN ENCRYPTED PRIVATE KEY-----" { + let valid = bytes.withUnsafeBytes { buffer -> Bool in + var pointer = buffer.bindMemory(to: UInt8.self).baseAddress + let start = pointer + guard let value = d2i_X509_SIG(nil, &pointer, bytes.count) else { return false } + defer { X509_SIG_free(value) } + return pointer == start?.advanced(by: bytes.count) + } + guard valid else { throw invalidEnvelope } + return PrivateKeyMetadata(algorithm: .unknown, keySize: nil, isEncrypted: true) + } + if lines.contains(where: { $0.hasPrefix("DEK-Info:") }) { + guard lines.contains(where: { $0.contains("Proc-Type: 4,ENCRYPTED") }), bytes.count >= 16, + ["-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----"].contains(header) + else { throw invalidEnvelope } + return PrivateKeyMetadata( + algorithm: header.contains("RSA") ? .rsa : .unknown, keySize: nil, isEncrypted: true) + } + return try text.withCString { buffer in + guard let bio = BIO_new_mem_buf(buffer, Int32(text.utf8.count)) else { throw invalidEnvelope } + defer { BIO_free(bio) } + guard let key = PEM_read_bio_PrivateKey(bio, nil, { _, _, _, _ in 0 }, nil) else { + throw invalidEnvelope + } + defer { EVP_PKEY_free(key) } + let bits = Int(EVP_PKEY_get_bits(key)) + let algorithm: SSHKeyAlgorithm + switch EVP_PKEY_get_base_id(key) { + case EVP_PKEY_ED25519: algorithm = .ed25519 + case EVP_PKEY_RSA: algorithm = .rsa + case EVP_PKEY_EC: + switch bits { + case 256: algorithm = .ecdsa256 + case 384: algorithm = .ecdsa384 + case 521: algorithm = .ecdsa521 + default: throw SSHKeyMaterialError.unsupportedAlgorithm("EC \(bits)") + } + default: throw SSHKeyMaterialError.unsupportedAlgorithm("PEM") + } + return PrivateKeyMetadata(algorithm: algorithm, keySize: bits, isEncrypted: false) + } + } + + private static func validateOpenSSHPrivateBlock(_ bytes: Data, algorithm: SSHKeyAlgorithm) throws { + guard bytes.count >= 8, bytes.prefix(4) == bytes.dropFirst(4).prefix(4) else { throw invalidEnvelope } + var offset = 8 + guard readSSHField(bytes, offset: &offset) == Data(algorithm.rawValue.utf8) else { throw invalidEnvelope } + switch algorithm { + case .ed25519: + guard let publicKey = readSSHField(bytes, offset: &offset), publicKey.count == 32, + let privateKey = readSSHField(bytes, offset: &offset), privateKey.count == 64, + privateKey.suffix(32) == publicKey + else { throw invalidEnvelope } + case .rsa: + for _ in 0..<6 { + guard let field = readSSHField(bytes, offset: &offset), !field.isEmpty else { throw invalidEnvelope } + } + case .ecdsa256, .ecdsa384, .ecdsa521: + guard readSSHField(bytes, offset: &offset) == Data(algorithm.rawValue.dropFirst(11).utf8), + let point = readSSHField(bytes, offset: &offset), point.first == 4, + let scalar = readSSHField(bytes, offset: &offset), !scalar.isEmpty + else { throw invalidEnvelope } + case .unknown, .securityKey: throw invalidEnvelope + } + guard readSSHField(bytes, offset: &offset) != nil else { throw invalidEnvelope } + let padding = bytes.dropFirst(offset) + guard padding.count <= 255, + padding.enumerated().allSatisfy({ $0.element == UInt8($0.offset + 1) }) + else { throw invalidEnvelope } + } + + public static func imported(privateKey: String, publicKey: String) throws -> SSHKeyMaterial { + let metadata = try privateKeyMetadata(privateKey) + let publicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !publicKey.isEmpty else { + return SSHKeyMaterial( + privateKey: privateKey, publicKey: "", algorithm: metadata.algorithm, + fingerprint: "", keySize: metadata.keySize) + } + var material = try publicMaterial(publicKey) + guard material.algorithm != .securityKey, + metadata.algorithm == .unknown || material.algorithm == metadata.algorithm + else { throw SSHUserCertificateError.keyMismatch } + material.privateKey = privateKey + return material + } + + /// Validates a single OpenSSH public key, including the method inside its wire blob. + public static func publicMaterial(_ text: String) throws -> SSHKeyMaterial { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + let fields = text.split(whereSeparator: \.isWhitespace) + guard !text.contains("\n"), !text.contains("\r"), !text.contains("\0"), fields.count >= 2, + let algorithm = SSHKeyAlgorithm(rawValue: String(fields[0])), algorithm != .unknown, + let blob = Data(base64Encoded: String(fields[1])) + else { throw SSHUserCertificateError.invalidFormat } + var offset = 0 + guard readSSHField(blob, offset: &offset) == Data(fields[0].utf8) else { + throw SSHUserCertificateError.invalidFormat + } + switch algorithm { + case .ed25519: + guard readSSHField(blob, offset: &offset)?.count == 32 else { throw SSHUserCertificateError.invalidFormat } + case .rsa: + guard let exponent = readSSHField(blob, offset: &offset), !exponent.isEmpty, + let modulus = readSSHField(blob, offset: &offset), !modulus.isEmpty + else { throw SSHUserCertificateError.invalidFormat } + case .ecdsa256, .ecdsa384, .ecdsa521, .securityKey: + let curve = algorithm == .securityKey ? "nistp256" : String(algorithm.rawValue.dropFirst(11)) + let length = algorithm == .ecdsa521 ? 133 : algorithm == .ecdsa384 ? 97 : 65 + guard readSSHField(blob, offset: &offset) == Data(curve.utf8), + let point = readSSHField(blob, offset: &offset), point.count == length, point.first == 4 + else { throw SSHUserCertificateError.invalidFormat } + if algorithm == .securityKey { + guard let application = readSSHField(blob, offset: &offset), !application.isEmpty else { + throw SSHUserCertificateError.invalidFormat + } + } + case .unknown: throw SSHUserCertificateError.invalidFormat + } + guard offset == blob.count else { throw SSHUserCertificateError.invalidFormat } + return SSHKeyMaterial( + privateKey: "", publicKey: text, algorithm: algorithm, + fingerprint: SSHWireEncoding.fingerprint(of: blob), + keySize: inferredKeySize(for: algorithm, publicBlob: blob)) + } + + private static var invalidEnvelope: SSHKeyMaterialError { + .invalidPrivateKey("Invalid private key envelope.") + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift index f054ffe3..b76c9764 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift @@ -232,10 +232,11 @@ public nonisolated struct SSHKeyMaterial: Sendable { } } - private static func inferredKeySize( + static func inferredKeySize( for algorithm: SSHKeyAlgorithm, publicBlob: Data ) -> Int? { switch algorithm { + case .unknown: nil case .ed25519: 256 case .securityKey, .ecdsa256: 256 case .ecdsa384: 384 @@ -255,13 +256,18 @@ public nonisolated struct SSHKeyMaterial: Sendable { return (bytes.count - 1) * 8 + (8 - first.leadingZeroBitCount) } - private static func readSSHField(_ data: Data, offset: inout Int) -> Data? { - guard offset + 4 <= data.count else { return nil } - let length = data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | Int($1) } - offset += 4 - guard length >= 0, offset + length <= data.count else { return nil } - defer { offset += length } - return data[offset..<(offset + length)] + static func readSSHField(_ data: Data, offset: inout Int) -> Data? { + // Data slices retain their original indices. Offsets in SSH blobs are byte + // counts relative to the blob, so translate them from startIndex first. + guard offset >= 0, offset <= data.count, data.count - offset >= 4 else { return nil } + let headerStart = data.index(data.startIndex, offsetBy: offset) + let payloadStart = data.index(headerStart, offsetBy: 4) + let length = data[headerStart.. String? { diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index a4b3fd4e..350315f1 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -190,9 +190,7 @@ final class MobileAppModel { let background = dependencies.background let workspaceConnectionServices = dependencies.workspaceConnections let connectionCoordinator = dependencies.connectionCoordinator - keyExportServices = KeyExportServices { keys, configuration in - try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration) - } + keyExportServices = connectionCoordinator.keyExportServices() activateWorkspaceSession = dependencies.activateWorkspaceSession closeWorkspaceSession = dependencies.closeWorkspaceSession self.background = background diff --git a/TFI/Application/FeatureModels/KeyExportModel+Summary.swift b/TFI/Application/FeatureModels/KeyExportModel+Summary.swift deleted file mode 100644 index 11235f7a..00000000 --- a/TFI/Application/FeatureModels/KeyExportModel+Summary.swift +++ /dev/null @@ -1,15 +0,0 @@ -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/Application/FeatureModels/MobileKeyEditorModel.swift b/TFI/Application/FeatureModels/MobileKeyEditorModel.swift index 2c0b8862..fbccddcf 100644 --- a/TFI/Application/FeatureModels/MobileKeyEditorModel.swift +++ b/TFI/Application/FeatureModels/MobileKeyEditorModel.swift @@ -40,11 +40,17 @@ final class MobileKeyEditorModel: Identifiable { var confirmation = "" var privateKey = "" { didSet { + if canEditPrivateKey, !isLoadingPrivateKey, !originalPrivateKey.isEmpty, + privateKey != originalPrivateKey, publicKey == original?.publicKey + { + publicKey = "" + } if privateKey != originalPrivateKey, oldValue == originalPrivateKey, !originalPassphrase.isEmpty { passphrase = "" } } } + var publicKey: String var passphrase = "" var savePassphrase = true private var originalPassphrase = "" @@ -72,6 +78,7 @@ final class MobileKeyEditorModel: Identifiable { label = key?.label ?? "" storage = key?.storage ?? defaultStorage certificate = key?.certificate ?? "" + publicKey = key?.publicKey ?? "" colorTag = key?.colorTag ?? .none let availability = key.map { credentials.keyAvailability(for: $0) } let keyIsAvailable = availability == .some(.available) @@ -97,7 +104,8 @@ final class MobileKeyEditorModel: Identifiable { var isDirty: Bool { usesWebAuthn || !securityKeyReference.isEmpty || verification != .preferred || label != (original?.label ?? "") || storage != (original?.storage ?? .iCloud) - || certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey + || publicKey != (original?.publicKey ?? "") || certificate != (original?.certificate ?? "") + || privateKey != originalPrivateKey || passphrase != originalPassphrase || savePassphrase != originalSavePassphrase || algorithm != .ed25519 || requiresUserPresence || colorTag != (original?.colorTag ?? .none) @@ -145,7 +153,7 @@ final class MobileKeyEditorModel: Identifiable { guard !certificate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } do { return try SSHUserCertificate.parse( - certificate, matching: inspectedMaterial?.publicKey ?? original?.publicKey + certificate, matching: publicKey.isEmpty ? nil : publicKey ).validityWarning } catch { return error.localizedDescription @@ -158,8 +166,7 @@ final class MobileKeyEditorModel: Identifiable { case .rsa: .rsa(bits: rsaBits) } } - var publicKey: String? { inspectedMaterial?.publicKey ?? original?.publicKey } - var fingerprint: String? { inspectedMaterial?.fingerprint ?? original?.fingerprint } + var fingerprint: String? { (try? SSHKeyMaterial.publicMaterial(publicKey))?.fingerprint } var isEnclave: Bool { original?.storage == .secureEnclave } var canSelectThisDevice: Bool { original?.storage != .iCloud } var canUseEnclave: Bool { SecureEnclaveKey.isAvailable } @@ -167,7 +174,7 @@ final class MobileKeyEditorModel: Identifiable { guard let original, let inspectedMaterial else { return false } return inspectedMaterial.publicKey != original.publicKey } - var validationToken: String { privateKey + "\u{0}" + passphrase } + var validationToken: String { privateKey + "\u{0}" + publicKey } func loadPrivateKey() async { guard mode == .edit, let original, original.storage != .secureEnclave, !didLoadPrivateKey else { return } @@ -195,32 +202,12 @@ final class MobileKeyEditorModel: Identifiable { } } - func derivePublicMaterial() async { + func refreshPublicMaterial() async { guard storage != .secureEnclave, !privateKey.isEmpty else { inspectedMaterial = nil return } - if mode == .edit, privateKey == originalPrivateKey, passphrase.isEmpty, let original { - inspectedMaterial = SSHKeyMaterial( - privateKey: privateKey, publicKey: original.publicKey, algorithm: original.algorithm, - fingerprint: original.fingerprint, keySize: original.keySize - ) - return - } - do { - try await Task.sleep(for: .milliseconds(250)) - let privateKey = privateKey - let passphrase = passphrase.isEmpty ? nil : passphrase - let comment = resolvedLabel - let material = try await Task.detached(priority: .utility) { - try SSHKeyMaterial.inspect(privateKey: privateKey, passphrase: passphrase, comment: comment) - }.value - try Task.checkCancellation() - inspectedMaterial = material - } catch is CancellationError { - } catch { - inspectedMaterial = nil - } + inspectedMaterial = try? SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey) } func loadFile(_ url: URL) async throws { @@ -243,7 +230,7 @@ final class MobileKeyEditorModel: Identifiable { try Task.checkCancellation() guard let certificate = result.certificate else { throw SoftwareKeyFileImporter.unexpectedFileError() } _ = try SSHUserCertificate.parse( - certificate, matching: inspectedMaterial?.publicKey ?? original?.publicKey) + certificate, matching: publicKey.isEmpty ? nil : publicKey) self.certificate = certificate } @@ -282,31 +269,24 @@ final class MobileKeyEditorModel: Identifiable { if !canEditPrivateKey { key.label = label.trimmingCharacters(in: .whitespacesAndNewlines) key.colorTag = colorTag + if publicKey != key.publicKey { + let material = publicKey.isEmpty ? nil : try SSHKeyMaterial.publicMaterial(publicKey) + key.publicKey = material?.publicKey ?? "" + key.fingerprint = material?.fingerprint ?? "" + key.algorithm = material?.algorithm ?? key.algorithm + key.keySize = material?.keySize ?? key.keySize + } key.certificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines) try credentials.upsert(key) return } let input = privateKey let enteredPassphrase = passphrase.isEmpty ? nil : passphrase - let keepsOriginalPrivateKey = input == originalPrivateKey - let material: SSHKeyMaterial - if keepsOriginalPrivateKey { - material = SSHKeyMaterial( - privateKey: input, publicKey: key.publicKey, algorithm: key.algorithm, - fingerprint: key.fingerprint, keySize: key.keySize - ) - } else { - let comment = resolvedLabel - material = try await Task.detached(priority: .userInitiated) { - try SSHKeyMaterial.inspect( - privateKey: input, passphrase: enteredPassphrase, comment: comment - ) - }.value - try Task.checkCancellation() - } + let material = try SSHKeyMaterial.imported(privateKey: input, publicKey: publicKey) let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCertificate.isEmpty { - _ = try SSHUserCertificate.parse(normalizedCertificate, matching: material.publicKey) + _ = try SSHUserCertificate.parse( + normalizedCertificate, matching: material.publicKey.isEmpty ? nil : material.publicKey) } key.label = label.trimmingCharacters(in: .whitespacesAndNewlines) key.colorTag = colorTag @@ -316,7 +296,7 @@ final class MobileKeyEditorModel: Identifiable { key.certificate = normalizedCertificate key.fingerprint = material.fingerprint key.keySize = material.keySize - key.hasPassphrase = enteredPassphrase != nil || (keepsOriginalPrivateKey && key.hasPassphrase) + key.hasPassphrase = try SSHKeyMaterial.privateKeyMetadata(input).isEncrypted try credentials.upsert( key, credential: .software( @@ -347,24 +327,27 @@ final class MobileKeyEditorModel: Identifiable { let cipher = cipher let rounds = rounds let generates = mode == .generate + let publicKey = publicKey let material = try await Task.detached(priority: .userInitiated) { if generates { return try SSHKeyMaterial.generate( generation, comment: comment, passphrase: passphrase, cipher: cipher, rounds: rounds) } - return try SSHKeyMaterial.inspect(privateKey: input, passphrase: passphrase, comment: comment) + return try SSHKeyMaterial.imported(privateKey: input, publicKey: publicKey) }.value try Task.checkCancellation() let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCertificate.isEmpty { - _ = try SSHUserCertificate.parse(normalizedCertificate, matching: material.publicKey) + _ = try SSHUserCertificate.parse( + normalizedCertificate, matching: material.publicKey.isEmpty ? nil : material.publicKey) } key = SSHKeyRef( label: label.trimmingCharacters(in: .whitespacesAndNewlines), algorithm: material.algorithm, publicKey: material.publicKey, certificate: normalizedCertificate, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: storage, keySize: material.keySize, - hasPassphrase: passphrase != nil, createdAt: .now, colorTag: colorTag) + hasPassphrase: try SSHKeyMaterial.privateKeyMetadata(material.privateKey).isEncrypted, createdAt: .now, + colorTag: colorTag) secret = .software( SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil)) } diff --git a/TFI/Features/Keychain/MobileCredentialsView.swift b/TFI/Features/Keychain/MobileCredentialsView.swift index 7b447add..625b44eb 100644 --- a/TFI/Features/Keychain/MobileCredentialsView.swift +++ b/TFI/Features/Keychain/MobileCredentialsView.swift @@ -35,25 +35,24 @@ struct MobileCredentialsView: View { MobileCardGrid { ForEach(keys) { key in MobileActionCard { - if !key.publicKey.isEmpty { exportingKey = key } + exportingKey = key } content: { MobileKeyCredentialCard(key: key, credentials: app.credentials) } actions: { - Button(AppStrings.Keychain.exportToHosts.swiftUIKey, systemImage: "square.and.arrow.up") + Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: "square.and.arrow.up") { exportingKey = key } - .disabled(key.publicKey.isEmpty) Divider() Menu(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") { - Button(AppStrings.Keychain.copyPublicKey.swiftUIKey, systemImage: "doc.on.doc") { + Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: "doc.on.doc") { UIPasteboard.general.string = key.publicKey } .disabled(key.publicKey.isEmpty) - Button(AppStrings.KnownHosts.copyFingerprint.swiftUIKey, systemImage: "doc.on.doc") - { + Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: "doc.on.doc") { UIPasteboard.general.string = key.fingerprint } + .disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty) } Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { if key.algorithm == .securityKey { @@ -123,10 +122,10 @@ struct MobileCredentialsView: View { .sheet(item: $editingSecurityKey) { key in SecurityKeyEditor(credentials: app.credentials, existing: key) } .sheet(item: $editing) { MobileIdentityEditor(app: app, identity: $0) } .sheet(item: $keyEditor) { MobileKeyEditor(model: $0) } - .sheet(item: $exportingKey) { key in - MobileKeyExportSheet( - library: app.library, credentials: app.credentials, - model: KeyExportModel(keys: [key], credentials: app.credentials, services: app.keyExportServices)) + .navigationDestination(item: $exportingKey) { key in + MobileKeyExportDetailView( + library: app.library, + model: KeyExportModel(key: key, library: app.library, services: app.keyExportServices)) } .confirmationDialog( AppStrings.Keychain.deleteIdentityConfirmation, @@ -179,8 +178,11 @@ private struct MobileKeyCredentialCard: View { MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor) VStack(alignment: .leading, spacing: 6) { Text(key.label).font(.headline).lineLimit(1) - Text(key.fingerprint).font(.caption.monospaced()).foregroundStyle(.secondary) - .lineLimit(1).truncationMode(.middle) + Text( + key.algorithm == .unknown + ? AppStrings.Keychain.unknownKeyType : AppStrings.Keychain.keyType(keyDisplayName) + ).font(.caption).foregroundStyle(.secondary) + .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -197,7 +199,6 @@ private struct MobileKeyCredentialCard: View { } @ViewBuilder private var primaryChips: some View { - MobileCardChip(text: keyDisplayName) if key.hasPassphrase { MobileCardChip( title: credentials.hasSavedPassphrase(for: key) diff --git a/TFI/Features/Keychain/MobileKeyEditor.swift b/TFI/Features/Keychain/MobileKeyEditor.swift index de531f31..52fdf3ee 100644 --- a/TFI/Features/Keychain/MobileKeyEditor.swift +++ b/TFI/Features/Keychain/MobileKeyEditor.swift @@ -156,6 +156,11 @@ struct MobileKeyEditor: View { } } if !model.usesWebAuthn && model.mode != .generate && model.storage != .secureEnclave { + Section(AppStrings.Keychain.publicKey.swiftUIKey) { + TextEditor(text: $model.publicKey).font(.caption.monospaced()).frame(minHeight: 90) + .textInputAutocapitalization(.never).autocorrectionDisabled() + Text(AppStrings.KeyExport.optionalPublicKey).font(.caption).foregroundStyle(.secondary) + } Section(AppStrings.Keychain.sshCertificate.swiftUIKey) { Button(AppStrings.Keychain.chooseFile.swiftUIKey, systemImage: "folder") { choosingCertificate = true @@ -169,9 +174,9 @@ struct MobileKeyEditor: View { if !model.usesWebAuthn, let warning = model.certificateWarning { Text(warning).font(.caption).foregroundStyle(.orange) } - if !model.usesWebAuthn, let publicKey = model.publicKey { + if !model.usesWebAuthn, model.storage == .secureEnclave, !model.publicKey.isEmpty { Section(AppStrings.Keychain.publicKey.swiftUIKey) { - Text(publicKey).font(.caption.monospaced()).textSelection(.enabled) + Text(model.publicKey).font(.caption.monospaced()).textSelection(.enabled) } } if !model.usesWebAuthn, let fingerprint = model.fingerprint { @@ -208,7 +213,7 @@ struct MobileKeyEditor: View { model.clearSensitiveDrafts() } .task { await model.loadPrivateKey() } - .task(id: model.validationToken) { await model.derivePublicMaterial() } + .task(id: model.validationToken) { await model.refreshPublicMaterial() } } private func receiveReference(_ providers: [NSItemProvider]) -> Bool { diff --git a/TFI/Features/Keychain/MobileKeyExportDetailView.swift b/TFI/Features/Keychain/MobileKeyExportDetailView.swift new file mode 100644 index 00000000..6a17288f --- /dev/null +++ b/TFI/Features/Keychain/MobileKeyExportDetailView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import TermindApplication +import TermindPresentation + +struct MobileKeyExportDetailView: View { + let library: HostLibraryModel + @State var model: KeyExportModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { + if model.journey.isPresented { + journey + } else { + KeyExportForm(model: model) + } + } + .navigationTitle(AppStrings.Keychain.exportToHost.swiftUIKey) + .toolbar { + ToolbarItem(placement: .primaryAction) { + if model.didExport { + Button(AppStrings.Common.done.swiftUIKey) { dismiss() } + } else if case .failed = model.phase, model.journey.isPresented { + Button(AppStrings.Common.edit.swiftUIKey) { model.editOptions() } + } else if !model.journey.isPresented { + Button(AppStrings.KeyExport.exportAndAttach.swiftUIKey) { model.start() } + .disabled(!model.canStart) + } else if model.phase == .exporting { + ProgressView().accessibilityLabel(AppStrings.KeyExport.exporting.swiftUIKey) + } + } + } + .onDisappear { model.cancel() } + } + + private var journey: some View { + let promptID = model.authenticationPrompt?.id + let hostKey = model.connection.pendingHostKey + let operationID = model.operationID + return MobileConnectionJourneyView( + journey: model.journey, library: library, authenticationPrompt: model.authenticationPrompt, + hostKey: hostKey, reconnectState: nil, + submit: { identity, username, password, passphrase, key, save in + guard let promptID else { return } + model.submitAuthentication( + promptID: promptID, identityID: identity, username: username, password: password, + passphrase: passphrase, keyID: key, save: save) + }, + accept: { remember in + guard let hostKey else { return } + model.acceptHostKey(hostKey.observation, operationID: operationID, remember: remember) + }, + retry: model.retry, retryReconnectNow: {}, stopReconnecting: {}, cancel: { dismiss() }, + securityKeySigning: model.connection.securityKeySigning, + keyboardAuthentication: model.connection.keyboardAuthentication, + cancelKeyboardAuthentication: { dismiss() }) + } +} diff --git a/TFI/Features/Keychain/MobileKeyExportSheet.swift b/TFI/Features/Keychain/MobileKeyExportSheet.swift deleted file mode 100644 index 2b262175..00000000 --- a/TFI/Features/Keychain/MobileKeyExportSheet.swift +++ /dev/null @@ -1,13 +0,0 @@ -import SwiftUI -import TermindApplication - -struct MobileKeyExportSheet: View { - let library: HostLibraryModel - let credentials: CredentialLibraryModel - @State var model: KeyExportModel - @Environment(\.dismiss) private var dismiss - - var body: some View { - MobileMultiHostPicker(library: library, credentials: credentials, exporting: model, close: { dismiss() }) - } -} diff --git a/TFI/Features/Keychain/MobileMultiHostPicker+KeyExport.swift b/TFI/Features/Keychain/MobileMultiHostPicker+KeyExport.swift deleted file mode 100644 index 9131d51d..00000000 --- a/TFI/Features/Keychain/MobileMultiHostPicker+KeyExport.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI -import TermindApplication -import TermindDomain -import TermindPresentation - -extension MobileMultiHostPicker where Header == MobileKeyExportHeader, RowDetail == MobileKeyExportHostDetail { - init( - library: HostLibraryModel, credentials: CredentialLibraryModel, - exporting model: KeyExportModel, close: @escaping () -> Void - ) { - self.init( - library: library, useCase: .keyExport, selected: [], - title: AppStrings.Keychain.exportToHosts, - actionTitle: model.isFinished ? AppStrings.Common.done : AppStrings.Common.export, - emptyDescription: AppStrings.Keychain.addAndConnectToAHostBeforeExportingAKeyToIt, - requiresSelection: !model.isFinished, - isSelectable: { credentials.keyExportUnavailableReason(for: $0) == nil }, - interaction: MobileMultiHostPickerInteraction( - allowsSelection: !model.isExporting && !model.isFinished, - allowsDismissal: !model.isExporting, - allowsConfirmation: !model.isExporting, - dismissesOnSave: false), - displayedHosts: model.isExporting || model.isFinished ? model.targets : nil, - selectionChanged: model.updateSelection, - save: { hostIDs in - if model.isFinished { - close() - } else { - Task { await model.export(to: hostIDs) } - } - }, - header: { MobileKeyExportHeader(model: model) }, - rowDetail: { host in - MobileKeyExportHostDetail( - state: model.states[host.id], unavailable: credentials.keyExportUnavailableReason(for: host)) - }) - } -} - -struct MobileKeyExportHeader: View { - let model: KeyExportModel - - var body: some View { - Section { - if let key = model.keys.first { Label(key.label, systemImage: "key") } - if model.isFinished { - Text(model.summary).accessibilityIdentifier("key-export.summary") - } else { - Text(AppStrings.Common.selected(model.selected.count)).foregroundStyle(.secondary) - } - } - } -} - -struct MobileKeyExportHostDetail: View { - let state: KeyExportModel.ExportState? - let unavailable: String? - - var body: some View { - switch state { - case .running: - ProgressView() - case .succeeded: - Label(AppStrings.Keychain.exported.swiftUIKey, systemImage: "checkmark.circle.fill") - .font(.caption).foregroundStyle(.secondary) - case .failed(let message): - Label(message, systemImage: "exclamationmark.triangle") - .font(.caption).foregroundStyle(.red) - case nil: - if let unavailable { - Text(unavailable).font(.caption).foregroundStyle(.secondary) - } - } - } -} diff --git a/TFI/Features/KnownHosts/MobileKnownHostsView.swift b/TFI/Features/KnownHosts/MobileKnownHostsView.swift index af5ecd59..e7418df3 100644 --- a/TFI/Features/KnownHosts/MobileKnownHostsView.swift +++ b/TFI/Features/KnownHosts/MobileKnownHostsView.swift @@ -147,7 +147,7 @@ struct MobileKnownHostEditor: View { } Section(AppStrings.KnownHosts.hostKey.swiftUIKey) { Picker(AppStrings.KnownHosts.keyType.swiftUIKey, selection: $draft.algorithm) { - ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) } + ForEach(SSHKeyAlgorithm.allCases.filter { $0 != .unknown }) { Text($0.displayName).tag($0) } } LabeledContent(AppStrings.KnownHosts.fingerprint.swiftUIKey) { TextField( diff --git a/TFITests/MobileKeyExportRenderingTests.swift b/TFITests/MobileKeyExportRenderingTests.swift index 14e93fb2..37565e9b 100644 --- a/TFITests/MobileKeyExportRenderingTests.swift +++ b/TFITests/MobileKeyExportRenderingTests.swift @@ -14,95 +14,56 @@ import UIKit ) @MainActor struct MobileKeyExportRenderingTests { - @Test func exportUsesTheNativeHostPickerAcrossSubmissionStates() async throws { + @Test func exportUsesANativeDetailAndShowsTheKeyPassphraseInTheForm() async throws { let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) for language in ["en", "zh-Hans"] { - let fixture = KeyExportFixture() - let recorder = KeyExportRecorder(failingHost: fixture.alpha.id, blocks: true) - let model = fixture.model(recorder) - let presentation = KeyExportRenderingPresentation() + let fixture = try MobileExportFixture(encrypted: true) + let model = fixture.model() + model.hostID = fixture.host.id let previous = scene.keyWindow let window = UIWindow(windowScene: scene) let controller = UIHostingController( - rootView: KeyExportRenderingHost(fixture: fixture, model: model, presentation: presentation) - .environment(\.locale, Locale(identifier: language))) + rootView: + NavigationStack { + MobileKeyExportDetailView(library: fixture.library, model: model) + }.environment(\.locale, Locale(identifier: language))) window.rootViewController = controller window.windowLevel = .alert + 1 window.makeKeyAndVisible() defer { - presentation.isPresented = false window.isHidden = true window.rootViewController = nil previous?.makeKey() } - try await Task.sleep(for: .milliseconds(700)) - presentation.isPresented = true - try await capture(window, language: language, state: "selecting") - let sheet = try #require(controller.presentedViewController) - let navigation = try #require(descendant(UINavigationController.self, in: sheet)) - let confirm = try #require(confirmation(in: navigation)) - #expect(!confirm.isEnabled) - if UIDevice.current.userInterfaceIdiom == .pad { - let frame = try #require(sheet.presentationController?.frameOfPresentedViewInContainerView) - #expect(abs(frame.midX - window.bounds.midX) < 2) - #expect(abs(frame.midY - window.bounds.midY) < 2) - } - let export = Task { await model.export(to: [fixture.alpha.id]) } - await recorder.waitUntilStarted() - try await capture(window, language: language, state: "running") - #expect(try !#require(confirmation(in: navigation)).isEnabled) - await recorder.resume() - await export.value - #expect(model.isFinished) - try await capture(window, language: language, state: "failed") - #expect(try #require(confirmation(in: navigation)).isEnabled) + try await Task.sleep(for: .milliseconds(800)) + #expect(controller.presentedViewController == nil) + #expect(model.needsExportPassphrase && !model.canStart && !model.journey.isPresented) + try capture(window, language: language) + let passphraseField = try #require( + descendants(window).compactMap { $0 as? UITextField }.first { $0.isSecureTextEntry }) + #expect(passphraseField.becomeFirstResponder()) + try await Task.sleep(for: .milliseconds(400)) + try capture(window, language: language, state: "keyboard") + window.endEditing(true) + model.exportPassphrase = "secret" + #expect(model.canStart) } } - private func descendant(_ type: T.Type, in controller: UIViewController) -> T? { - if let result = controller as? T { return result } - return controller.children.lazy.compactMap { descendant(type, in: $0) }.first + private func descendants(_ view: UIView) -> [UIView] { + [view] + view.subviews.flatMap(descendants) } - private func confirmation(in navigation: UINavigationController) -> UIBarButtonItem? { - guard let item = navigation.topViewController?.navigationItem else { return nil } - let buttons = item.trailingItemGroups.flatMap(\.barButtonItems) + (item.rightBarButtonItems ?? []) - return buttons.first { $0.accessibilityIdentifier == "host-picker.confirm" } ?? buttons.first - } - - private func capture(_ window: UIWindow, language: String, state: String) async throws { + private func capture(_ window: UIWindow, language: String, state: String = "form") throws { window.layoutIfNeeded() - try await Task.sleep(for: .milliseconds(800)) let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) } - let data = try #require(image.pngData()) let directory = FileManager.default.temporaryDirectory.appending(path: "key-export-screenshots") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone" let output = directory.appending(path: "\(device)-\(language)-\(state).png") - try data.write(to: output) + try #require(image.pngData()).write(to: output) print("Key export snapshot: \(output.path)") } } - -@MainActor @Observable -private final class KeyExportRenderingPresentation { - var isPresented = false -} - -private struct KeyExportRenderingHost: View { - let fixture: KeyExportFixture - let model: KeyExportModel - @Bindable var presentation: KeyExportRenderingPresentation - @Environment(\.horizontalSizeClass) private var sizeClass - - var body: some View { - Color.clear - .sheet(isPresented: $presentation.isPresented) { - MobileKeyExportSheet(library: fixture.library, credentials: fixture.credentials, model: model) - } - // Match the presenting-window environment established by MobileRootView. - .environment(\.mobileLayout, MobileLayout(sizeClass: sizeClass)) - } -} diff --git a/TFITests/MobileKeyExportTests.swift b/TFITests/MobileKeyExportTests.swift index a0710bb6..24f93e4d 100644 --- a/TFITests/MobileKeyExportTests.swift +++ b/TFITests/MobileKeyExportTests.swift @@ -1,113 +1,99 @@ import Foundation import TermindApplication import TermindDomain +import TermindRemoteInfrastructure import Testing @testable import TermindIOS @Suite @MainActor struct MobileKeyExportTests { - @Test func searchAndSelectAllOnlyChangeVisibleEligibleHosts() { - let fixture = KeyExportFixture() - let model = MobileMultiHostPickerModel( - library: fixture.library, useCase: .keyExport, selected: [], - isSelectable: { fixture.credentials.keyExportUnavailableReason(for: $0) == nil }) - #expect(model.hosts.count == 3) - model.toggle(fixture.untrusted.id) - #expect(model.selected.isEmpty) - #expect(!model.isSelectable(fixture.untrusted)) - model.query = "Alpha" - model.toggleAllResults() - #expect(model.selected == [fixture.alpha.id]) - #expect(model.allResultsSelected) - model.query = "Beta" - model.toggleAllResults() - #expect(model.selected == [fixture.alpha.id, fixture.beta.id]) - model.toggleAllResults() - #expect(model.selected == [fixture.alpha.id]) - model.query = "no match" - model.toggleAllResults() - #expect(model.selected == [fixture.alpha.id]) - #expect(!model.allResultsSelected) + @Test func encryptedImportCanSaveWithoutPassphraseOrPublicKey() async throws { + let library = HostLibraryModel() + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret") + let editor = MobileKeyEditorModel(mode: .importKey, credentials: CredentialLibraryModel(library: library)) + editor.privateKey = material.privateKey + #expect(editor.canSave) + try await editor.save() + let key = try #require(library.keys.first) + #expect(key.hasPassphrase && key.publicKey.isEmpty && key.fingerprint.isEmpty) + #expect(key.algorithm == .unknown) + #expect(library.requiresPassphraseEntry(for: key)) + let export = KeyExportModel( + key: key, library: library, services: RemoteConnectionCoordinator().keyExportServices()) + #expect(export.needsExportPassphrase && export.authenticationPrompt == nil) } + @Test func manuallyEnteredPublicKeyCanBeChangedAndClearedWithoutUnlocking() async throws { + let library = HostLibraryModel() + let credentials = CredentialLibraryModel(library: library) + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret") + let importer = MobileKeyEditorModel(mode: .importKey, credentials: credentials) + importer.privateKey = material.privateKey + importer.publicKey = material.publicKey + try await importer.save() + let original = try #require(library.keys.first) + #expect(original.publicKey == material.publicKey && original.fingerprint == material.fingerprint) + let export = KeyExportModel( + key: original, library: library, services: RemoteConnectionCoordinator().keyExportServices()) + #expect(!export.needsExportPassphrase) + let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original) + await editor.loadPrivateKey() + editor.publicKey = "malformed" + await #expect(throws: (any Error).self) { try await editor.save() } + #expect(library.key(original.id) == original) + editor.publicKey = "" + try await editor.save() + let cleared = try #require(library.key(original.id)) + #expect(cleared.publicKey.isEmpty && cleared.fingerprint.isEmpty && cleared.hasPassphrase) + } + + @Test func typedPassphraseDoesNotMakeAnUnencryptedKeyEncrypted() async throws { + let library = HostLibraryModel() + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + let editor = MobileKeyEditorModel(mode: .importKey, credentials: CredentialLibraryModel(library: library)) + editor.privateKey = material.privateKey + editor.passphrase = "not-encryption" + try await editor.save() + #expect(library.keys.first?.hasPassphrase == false) + } + + @Test func replacingPrivateKeyPreservesANewManuallyEnteredPublicKey() async throws { + let library = HostLibraryModel() + let credentials = CredentialLibraryModel(library: library) + let generator = MobileKeyEditorModel(mode: .generate, credentials: credentials) + try await generator.save() + let original = try #require(library.keys.first) + let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: "secret") + let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original) + await editor.loadPrivateKey() + editor.publicKey = replacement.publicKey + editor.privateKey = replacement.privateKey + #expect(editor.publicKey == replacement.publicKey) + try await editor.save() + let saved = try #require(library.key(original.id)) + #expect(saved.publicKey == replacement.publicKey && saved.fingerprint == replacement.fingerprint) + #expect(saved.hasPassphrase && library.requiresPassphraseEntry(for: saved)) + } } @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 +struct MobileExportFixture { + let key: SSHKeyRef 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) + let host: TermindDomain.Host + init(encrypted: Bool = false) throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: encrypted ? "secret" : nil) + key = SSHKeyRef( + label: "Deployment Key", algorithm: encrypted ? .unknown : material.algorithm, + publicKey: encrypted ? "" : material.publicKey, + credentialRef: "key", hasPassphrase: encrypted) + let identity = Identity(name: "Deploy", username: "deploy") + host = TermindDomain.Host(name: "Production", host: "server.example.com", identityID: identity.id) + library = HostLibraryModel(hosts: [host], keys: [key], identities: [identity]) + try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey))) } - - 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" } + func model() -> KeyExportModel { + KeyExportModel(key: key, library: library, services: RemoteConnectionCoordinator().keyExportServices()) } } diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 7b53de45..d031d520 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -215,37 +215,40 @@ struct MobileVaultFeatureTests { try await generator.save() let generated = try #require(app.credentials.keys.first) #expect(generated.label == "ECDSA") + #expect(!generated.publicKey.isEmpty && !generated.fingerprint.isEmpty) let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: nil) let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials) #expect(!importer.canSave) #expect(importer.defaultName == "") importer.privateKey = replacement.privateKey - await importer.derivePublicMaterial() + await importer.refreshPublicMaterial() #expect(importer.defaultName == "Ed25519") #expect(importer.canSave) try await importer.save() let imported = try #require(app.credentials.keys.first { $0.id != generated.id }) #expect(imported.label == "Ed25519") + #expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty) let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: generated) await editor.loadPrivateKey() editor.label = "" editor.privateKey = replacement.privateKey #expect(editor.defaultName == "") - await editor.derivePublicMaterial() + await editor.refreshPublicMaterial() #expect(editor.defaultName == "Ed25519") #expect(editor.canSave) try await editor.save() let edited = try #require(app.credentials.key(generated.id)) #expect(edited.label == "Ed25519") #expect(edited.algorithm == .ed25519) + #expect(edited.publicKey.isEmpty && edited.fingerprint.isEmpty) let reopened = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: edited) #expect(reopened.label == "Ed25519") await reopened.loadPrivateKey() reopened.privateKey = "invalid replacement" #expect(reopened.defaultName == "") - await reopened.derivePublicMaterial() + await reopened.refreshPublicMaterial() #expect(reopened.defaultName == "") reopened.privateKey = "" #expect(reopened.defaultName == "") @@ -534,7 +537,7 @@ struct MobileVaultFeatureTests { return } #expect(importedSecret.passphrase == nil && imported.hasPassphrase) - #expect(imported.publicKey == generated.publicKey) + #expect(imported.publicKey.isEmpty) } @Test func replacingPrivateKeyDoesNotReuseItsSavedPassphrase() async throws { @@ -581,7 +584,7 @@ struct MobileVaultFeatureTests { let invalid = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials) invalid.label = "Invalid" - invalid.privateKey = secret.privateKey + invalid.privateKey = "not a private key" invalid.passphrase = "wrong" await #expect(throws: (any Error).self) { try await invalid.save() } #expect(app.credentials.keys.count == 1) @@ -592,7 +595,8 @@ struct MobileVaultFeatureTests { importer.passphrase = "test-only-passphrase" try await importer.save() #expect(app.credentials.keys.count == 2) - #expect(app.credentials.keys.allSatisfy { $0.fingerprint == key.fingerprint }) + let imported = try #require(app.credentials.keys.first { $0.id != key.id }) + #expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty && imported.hasPassphrase) let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key) await editor.loadPrivateKey() @@ -627,7 +631,9 @@ struct MobileVaultFeatureTests { let replacement = try SSHKeyMaterial.generate(.rsa(bits: 2048), comment: "replacement", passphrase: nil) editor.privateKey = replacement.privateKey - await editor.derivePublicMaterial() + #expect(editor.publicKey.isEmpty) + editor.publicKey = replacement.publicKey + await editor.refreshPublicMaterial() #expect(editor.replacesPublicKey) #expect(editor.fingerprint == replacement.fingerprint) try await editor.save() diff --git a/TFM/Features/Keychain/KeyExportDetailView.swift b/TFM/Features/Keychain/KeyExportDetailView.swift new file mode 100644 index 00000000..ba0effba --- /dev/null +++ b/TFM/Features/Keychain/KeyExportDetailView.swift @@ -0,0 +1,73 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation + +struct KeyExportDetailView: View { + let keyID: SSHKeyRef.ID + @Environment(AppModel.self) private var app + @Environment(WindowModel.self) private var window + @State private var model: KeyExportModel? + + var body: some View { + VaultDetailScaffold( + title: AppStrings.Keychain.exportToHost, symbol: "square.and.arrow.up", close: window.closeVaultWork + ) { + if let model { + if model.journey.isPresented { + journey(model) + } else { + KeyExportForm(model: model) + } + } else { + ContentUnavailableView(AppStrings.Keychain.keyUnavailable.swiftUIKey, systemImage: "key.slash") + } + } footer: { + HStack { + if model?.phase == .exporting { + ProgressView().controlSize(.small) + Text(AppStrings.KeyExport.exporting).font(.caption) + } + Spacer() + Button( + model?.didExport == true ? AppStrings.Common.done.swiftUIKey : AppStrings.Common.cancel.swiftUIKey, + action: window.closeVaultWork) + if let model, case .failed = model.phase, model.journey.isPresented { + Button(AppStrings.Common.edit.swiftUIKey) { model.editOptions() } + } + if let model, !model.didExport, !model.journey.isPresented { + Button(AppStrings.KeyExport.exportAndAttach.swiftUIKey) { model.start() } + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + .disabled(!model.canStart) + } + }.padding(Metrics.Space.l) + } + .task(id: keyID) { + model?.cancel() + model = app.hostLibrary.key(keyID).map { + KeyExportModel(key: $0, library: app.hostLibrary, services: app.connections.keyExportServices()) + } + } + .onDisappear { model?.cancel() } + } + + private func journey(_ model: KeyExportModel) -> some View { + let promptID = model.authenticationPrompt?.id + let operationID = model.operationID + return ConnectionJourneyView( + journey: model.journey, authenticationPrompt: model.authenticationPrompt, + hostKey: model.connection.pendingHostKey, + submitAuthentication: { identity, username, password, passphrase, key, save in + guard let promptID else { return } + model.submitAuthentication( + promptID: promptID, identityID: identity, username: username, password: password, + passphrase: passphrase, keyID: key, save: save) + }, + continueHostKey: { model.acceptHostKey($0, operationID: operationID, remember: false) }, + trustHostKey: { model.acceptHostKey($0, operationID: operationID, remember: true) }, + denyHostKey: window.closeVaultWork, retry: model.retry, cancel: window.closeVaultWork, + securityKeySigning: model.connection.securityKeySigning, + keyboardAuthentication: model.connection.keyboardAuthentication, + cancelKeyboardAuthentication: window.closeVaultWork, isEmbedded: true) + } +} diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index 208ce8fe..8aafb09b 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -275,8 +275,8 @@ struct KeychainPageView: ManagedCardPage { } private func requestExport(_ keys: [SSHKeyRef]) { - guard !keys.isEmpty else { return } - window.openVaultWork(.exportKeys(keys.map(\.id)), on: .keychain) + guard keys.count == 1, let key = keys.first else { return } + window.openVaultWork(.exportKey(key.id), on: .keychain) } private func requestDelete(_ items: [Item]) { @@ -450,7 +450,7 @@ struct KeychainImportDetail: View { algorithm: material.algorithm, publicKey: material.publicKey, certificate: certificate, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID().uuidString)", storage: storage, - keySize: material.keySize, hasPassphrase: passphrase != nil, + keySize: material.keySize, hasPassphrase: try SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted, createdAt: Date(), colorTag: colorTag ) try app.credentials.upsert( @@ -485,7 +485,8 @@ private struct SoftwareKeyEditor: View { _ name: String, _ colorTag: TagColor, _ storage: KeyStorage, _ privateKey: String, _ passphrase: String?, _ savePassphrase: Bool, _ certificate: String, _ material: SSHKeyMaterial ) async throws -> SSHKeyRef - let saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? + let saveMetadata: + ((_ name: String, _ colorTag: TagColor, _ certificate: String, _ publicKey: String) throws -> SSHKeyRef)? let delete: (() throws -> Void)? @State private var name: String @@ -493,6 +494,7 @@ private struct SoftwareKeyEditor: View { @State private var storage: KeyStorage @State private var privateKey = "" @State private var originalPrivateKey = "" + @State private var publicKey: String @State private var passphrase = "" @State private var originalPassphrase = "" @State private var savePassphrase = true @@ -518,7 +520,9 @@ private struct SoftwareKeyEditor: View { defaultStorage: KeyStorage = .iCloud, saveSecurityKey: ((String, TagColor, SecurityKeyCredential) throws -> Void)? = nil, loadPrivateKey: (() throws -> SSHPrivateKeySecret)? = nil, - saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? = nil, + saveMetadata: ( + (_ name: String, _ colorTag: TagColor, _ certificate: String, _ publicKey: String) throws -> SSHKeyRef + )? = nil, delete: (() throws -> Void)? = nil, save: @escaping ( @@ -538,6 +542,7 @@ private struct SoftwareKeyEditor: View { _colorTag = State(initialValue: existing?.colorTag ?? .none) _storage = State(initialValue: existing?.storage ?? defaultStorage) _certificate = State(initialValue: existing?.certificate ?? "") + _publicKey = State(initialValue: existing?.publicKey ?? "") _isLoading = State(initialValue: loadPrivateKey != nil) } @@ -615,10 +620,10 @@ private struct SoftwareKeyEditor: View { if !usesWebAuthn { Section(AppStrings.Keychain.publicKey.swiftUIKey) { - Text(publicKeyDisplay) - .font(.caption.monospaced()) - .foregroundStyle(material == nil ? .secondary : .primary) - .textSelection(.enabled) + keyTextEditor( + text: $publicKey, minHeight: 100, placeholder: AppStrings.KeyExport.optionalPublicKey + ) + .accessibilityLabel(AppStrings.Keychain.publicKey.swiftUIKey) } Section { @@ -703,6 +708,9 @@ private struct SoftwareKeyEditor: View { } } .onChange(of: privateKey) { old, value in + if !isLoading, !originalPrivateKey.isEmpty, value != originalPrivateKey, publicKey == existing?.publicKey { + publicKey = "" + } if value != originalPrivateKey, old == originalPrivateKey, !originalPassphrase.isEmpty { passphrase = "" } @@ -727,7 +735,7 @@ private struct SoftwareKeyEditor: View { } message: { Text(AppStrings.Keychain.deleteKeyWarning) } - .task(id: validationToken) { await derivePublicMaterial() } + .task(id: validationToken) { refreshPublicMaterial() } } /// A key blob field drawn on the form's own background. @@ -786,11 +794,13 @@ private struct SoftwareKeyEditor: View { @MainActor private var isDirty: Bool { - guard let existing else { return !name.isEmpty || !privateKey.isEmpty || !certificate.isEmpty } + guard let existing else { + return !name.isEmpty || !privateKey.isEmpty || !publicKey.isEmpty || !certificate.isEmpty + } return name != existing.label || colorTag != existing.colorTag || storage != existing.storage || privateKey != originalPrivateKey || passphrase != originalPassphrase || savePassphrase != originalSavePassphrase - || certificate != existing.certificate + || publicKey != existing.publicKey || certificate != existing.certificate } private func cancelEditing() { @@ -815,36 +825,22 @@ private struct SoftwareKeyEditor: View { return true } let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines) - if !canEditPrivateKey, let existing, let saveMetadata { + if !canEditPrivateKey, existing != nil, let saveMetadata { if !normalizedCertificate.isEmpty { - _ = try SSHUserCertificate.parse(normalizedCertificate, matching: existing.publicKey) + _ = try SSHUserCertificate.parse( + normalizedCertificate, matching: publicKey.isEmpty ? nil : publicKey) } _ = try saveMetadata( - name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, normalizedCertificate + name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, normalizedCertificate, publicKey ) isWorking = false return true } let passphrase = passphrase.isEmpty ? nil : passphrase - let inspected: SSHKeyMaterial - if let existing, privateKey == originalPrivateKey { - inspected = SSHKeyMaterial( - privateKey: privateKey, publicKey: existing.publicKey, - algorithm: existing.algorithm, fingerprint: existing.fingerprint, - keySize: existing.keySize - ) - } else { - let keyText = privateKey - let comment = name - inspected = try await Task.detached(priority: .userInitiated) { - try SSHKeyMaterial.inspect( - privateKey: keyText, passphrase: passphrase, comment: comment - ) - }.value - } + let inspected = try SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey) if !normalizedCertificate.isEmpty { _ = try SSHUserCertificate.parse( - normalizedCertificate, matching: inspected.publicKey + normalizedCertificate, matching: inspected.publicKey.isEmpty ? nil : inspected.publicKey ) } _ = try await save( @@ -869,7 +865,7 @@ private struct SoftwareKeyEditor: View { } } - private var validationToken: String { privateKey + "\u{0}" + passphrase } + private var validationToken: String { privateKey + "\u{0}" + publicKey } private var defaultName: String { if usesWebAuthn { return String(localized: AppStrings.SecurityKey.title) } @@ -892,15 +888,11 @@ private struct SoftwareKeyEditor: View { ) } - private var publicKeyDisplay: String { - material?.publicKey ?? existing?.publicKey ?? String(localized: AppStrings.Keychain.derivedFromThePrivateKey) - } - private var certificateWarning: String? { guard !certificate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } do { return try SSHUserCertificate.parse( - certificate, matching: material?.publicKey ?? existing?.publicKey + certificate, matching: publicKey.isEmpty ? nil : publicKey ).validityWarning } catch { return error.localizedDescription @@ -927,25 +919,8 @@ private struct SoftwareKeyEditor: View { isLoading = false } - private func derivePublicMaterial() async { - guard !privateKey.isEmpty else { - material = nil - return - } - do { - try await Task.sleep(for: .milliseconds(250)) - let privateKey = privateKey - let passphrase = passphrase.isEmpty ? nil : passphrase - let comment = name - material = try await Task.detached(priority: .utility) { - try SSHKeyMaterial.inspect( - privateKey: privateKey, passphrase: passphrase, comment: comment - ) - }.value - } catch is CancellationError { - } catch { - material = nil - } + private func refreshPublicMaterial() { + material = try? SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey) } private func receiveReferenceText(_ providers: [NSItemProvider]) -> Bool { @@ -1321,9 +1296,9 @@ struct KeychainEditDetail: View { existing: key, title: AppStrings.Keychain.editKey, symbol: "pencil", close: window.closeVaultWork, loadPrivateKey: { try loadPrivateKey(key) }, - saveMetadata: { name, colorTag, certificate in + saveMetadata: { name, colorTag, certificate, publicKey in try saveSoftwareKeyMetadata( - key, name: name, colorTag: colorTag, certificate: certificate + key, name: name, colorTag: colorTag, certificate: certificate, publicKey: publicKey ) }, delete: { try delete(key) }, @@ -1370,8 +1345,7 @@ struct KeychainEditDetail: View { updated.certificate = certificate updated.fingerprint = material.fingerprint updated.keySize = material.keySize - let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original)).privateKey - updated.hasPassphrase = passphrase != nil || (keepsOriginalPrivateKey && original.hasPassphrase) + updated.hasPassphrase = try SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted try app.credentials.upsert( updated, credential: .software( @@ -1383,11 +1357,18 @@ struct KeychainEditDetail: View { } private func saveSoftwareKeyMetadata( - _ original: SSHKeyRef, name: String, colorTag: TagColor, certificate: String + _ original: SSHKeyRef, name: String, colorTag: TagColor, certificate: String, publicKey: String ) throws -> SSHKeyRef { var updated = original updated.label = name updated.colorTag = colorTag + if publicKey != original.publicKey { + let material = publicKey.isEmpty ? nil : try SSHKeyMaterial.publicMaterial(publicKey) + updated.publicKey = material?.publicKey ?? "" + updated.fingerprint = material?.fingerprint ?? "" + updated.algorithm = material?.algorithm ?? updated.algorithm + updated.keySize = material?.keySize ?? updated.keySize + } updated.certificate = certificate try app.credentials.upsert(updated) window.selectVaultItem(.key(updated.id), on: .keychain) @@ -1526,303 +1507,6 @@ private struct SecureEnclaveKeyEditor: View { /// Installs one or more public keys on several saved destinations. A checked row is an /// 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 { - let keys: [SSHKeyRef] - - @Environment(AppModel.self) private var app - @Environment(WindowModel.self) private var window - @Environment(\.dismiss) private var dismiss - - @State private var chooser = ManagedChooserState(mode: .multiple) - @State private var exportModel: KeyExportModel? - @FocusState private var searchFocused: Bool - - private var query: String { chooser.query } - private var queryBinding: Binding { - Binding(get: { chooser.query }, set: { chooser.query = $0 }) - } - private var hosts: [Host] { app.credentials.searchHosts(query) } - private var eligibleHosts: [Host] { - hosts.filter { app.credentials.keyExportUnavailableReason(for: $0) == nil } - } - 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) { - VStack(spacing: 0) { - header - Divider().opacity(0.7) - content - } - } footer: { - footer - } - .onAppear { searchFocused = true } - .onChange(of: chooser.query) { - chooser.queryDidChange( - visible: hosts.map(\.id), enabled: Set(eligibleHosts.map(\.id)) - ) - } - .interactiveDismissDisabled(isExporting) - } - - private var header: some View { - VStack(alignment: .leading, spacing: Metrics.Space.m) { - HStack(spacing: Metrics.Space.m) { - CardIcon(symbol: "key.fill", tint: TermindColor.accent) - VStack(alignment: .leading, spacing: Metrics.Space.xxs) { - Text(AppStrings.Keychain.exportToHosts) - .font(.headline) - Text(keySummary) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - HStack(spacing: Metrics.Space.s) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField( - AppStrings.Vault.searchHosts.swiftUIKey, text: queryBinding.singleLineText, axis: .horizontal - ).singleLinePaste() - .lineLimit(1) - .textFieldStyle(.plain) - .focused($searchFocused) - .onKeyPress(.upArrow) { moveCursor(-1) } - .onKeyPress(.downArrow) { moveCursor(1) } - .onKeyPress(.space) { toggleCursor() } - if !query.isEmpty { - Button { - chooser.query = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(AppStrings.Common.clearSearch)) - } - } - .padding(Metrics.Space.s) - .background( - .quaternary.opacity(0.5), - in: .rect(cornerRadius: Metrics.Radius.medium) - ) - } - .padding(Metrics.Space.xl) - } - - @ViewBuilder - private var content: some View { - if app.credentials.hosts.isEmpty { - ContentUnavailableView { - Label(AppStrings.Vault.noHosts.swiftUIKey, systemImage: "server.rack") - } description: { - Text(AppStrings.Keychain.addAndConnectToAHostBeforeExportingAKeyToIt) - } - } else if hosts.isEmpty { - ContentUnavailableView.search(text: query) - } else { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(hosts) { host in - row(for: host) - .frame(height: 52) - .id(host.id) - } - } - } - .onAppear { scrollCursorIntoView(using: proxy) } - .onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) } - .onChange(of: hosts.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) } - } - .padding(.horizontal, Metrics.Space.l) - .padding(.vertical, Metrics.Space.s) - } - } - - private func scrollCursorIntoView(using proxy: ScrollViewProxy) { - guard let cursor = chooser.cursor else { return } - proxy.scrollTo(cursor) - } - - private func row(for host: Host) -> some View { - let unavailable = app.credentials.keyExportUnavailableReason(for: host) - return Button { - chooser.activate(host.id) - } label: { - HStack(spacing: Metrics.Space.m) { - SelectionMark( - mode: .multiple, isSelected: chooser.selected.contains(host.id) - ) - - VStack(alignment: .leading, spacing: Metrics.Space.xxs) { - Text(host.name) - .foregroundStyle(unavailable == nil ? .primary : .secondary) - .lineLimit(1) - Text(app.credentials.loginAddress(for: host)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer(minLength: Metrics.Space.s) - stateLabel(for: host, unavailable: unavailable) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Metrics.Space.m) - .padding(.vertical, Metrics.Space.s) - .contentShape(.rect) - } - .buttonStyle(.plain) - .disabled(unavailable != nil || isExporting || isFinished) - .accessibilityAddTraits(chooser.selected.contains(host.id) ? .isSelected : []) - } - - @ViewBuilder - private func stateLabel(for host: Host, unavailable: String?) -> some View { - switch states[host.id] { - case .running: - ProgressView() - .controlSize(.small) - case .succeeded: - Label(AppStrings.Keychain.exported.swiftUIKey, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.caption) - case .failed(let message): - Label(AppStrings.Common.failed.swiftUIKey, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - .font(.caption) - .help(Text(message)) - case nil: - if let unavailable { - Text(unavailable) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - } - } - - private var footer: some View { - HStack { - if isFinished { - let failures = states.values.reduce(into: 0) { count, state in - if case .failed = state { count += 1 } - } - Text( - failures == 0 - ? successSummary(hostCount: states.count) - : String( - localized: AppStrings.Keychain.exportedFailed( - count1: states.count - failures, failures: failures)) - ) - .font(.caption) - .foregroundStyle( - failures == 0 ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red) - ) - .monospacedDigit() - } else { - Text(AppStrings.Common.selected(chooser.selected.count)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - - Button( - allEligibleChecked - ? AppStrings.Common.deselectAll.swiftUIKey : AppStrings.Common.selectAll.swiftUIKey - ) { - toggleAllEligible() - } - .disabled(eligibleHosts.isEmpty || isExporting) - } - - Spacer() - - Button( - isFinished ? AppStrings.Common.done.swiftUIKey : AppStrings.Common.cancel.swiftUIKey, action: close - ) - .keyboardShortcut(.cancelAction) - .disabled(isExporting) - - if !isFinished { - Button(AppStrings.Common.export.swiftUIKey, action: export) - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(chooser.selected.isEmpty || isExporting) - } - } - .padding(Metrics.Space.l) - } - - private func close() { - if window.selectedDestination == .vault { - window.closeVaultWork() - } else { - dismiss() - } - } - - private var allEligibleChecked: Bool { chooser.allChecked(in: eligibleHosts.map(\.id)) } - - private var keySummary: String { - if keys.count == 1 { - return keys[0].label - } - return String(localized: AppStrings.Keychain.keysNamed(keys.count)) - } - - private func successSummary(hostCount: Int) -> String { - if keys.count == 1 { - return hostCount == 1 - ? String(localized: AppStrings.Keychain.exportedTo1Host) - : String(localized: AppStrings.Keychain.exportedToHosts(hostCount)) - } - return hostCount == 1 - ? String(localized: AppStrings.Keychain.exportedKeysTo1Host(keys.count)) - : String(localized: AppStrings.Keychain.exportedKeysToHosts(count: keys.count, hostCount: hostCount)) - } - - /// Select All follows the visible, filtered list, matching the host chooser: - /// a filter must never cause an invisible machine to be modified. - private func toggleAllEligible() { - chooser.toggleAll(in: eligibleHosts.map(\.id)) - } - - private func moveCursor(_ delta: Int) -> KeyPress.Result { - let ids = eligibleHosts.map(\.id) - chooser.moveCursor(by: delta, through: ids) - return ids.isEmpty ? .ignored : .handled - } - - private func toggleCursor() -> KeyPress.Result { - guard let cursor = chooser.cursor else { return .ignored } - chooser.toggle(cursor) - return .handled - } - - private func export() { - 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) } - } -} - // MARK: - Key card /// The singular actions for one key, shared by its card and editor surfaces. @@ -1834,17 +1518,17 @@ struct KeyObjectActions: View { let export: () -> Void var body: some View { - Button(AppStrings.Keychain.exportToHosts.swiftUIKey, systemImage: MenuSymbol.export, action: export) - .disabled(key.publicKey.isEmpty) + Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: MenuSymbol.export, action: export) Divider() Menu(AppStrings.Common.copy.swiftUIKey, systemImage: MenuSymbol.copy) { - Button(AppStrings.Keychain.copyPublicKey.swiftUIKey, systemImage: MenuSymbol.copy) { + Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: MenuSymbol.copy) { copyToPasteboard(key.publicKey) } .disabled(key.publicKey.isEmpty) - Button(AppStrings.KnownHosts.copyFingerprint.swiftUIKey, systemImage: MenuSymbol.copy) { + Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: MenuSymbol.copy) { copyToPasteboard(key.fingerprint) } + .disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty) } Button(AppStrings.Common.edit.swiftUIKey, systemImage: MenuSymbol.edit, action: edit) } @@ -1883,41 +1567,22 @@ private struct KeyCard: View { tint: key.colorTag.color ?? TermindColor.accent ) - // The fingerprint is deliberately *not* selectable, and this - // is not a style preference. - // - // A SHA256 fingerprint is wider than the widest card, so it - // has to truncate. `.textSelection(.enabled)` used to be on - // the line below, and it laid out correctly right up until - // someone clicked it: entering selection mode re-lays the - // text out at its full width, and it walked out of the card - // and over its neighbour. That made it look like a rendering - // glitch rather than a layout bug, because no static - // screenshot ever showed it. - // - // Nothing is lost. The whole fingerprint is on the tooltip - // below and one right-click away via Copy Fingerprint, which - // is a better trade than a card that changes size when you - // touch it. The old forwarding manager had the same defect for the - // same reason. - // - // The frame keeps the column to the width it is offered, so - // no future subtitle can push the card wide either. VStack(alignment: .leading, spacing: 1) { Text(key.label) .font(.headline) .lineLimit(1) - Text(key.fingerprint) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) + Text( + key.algorithm == .unknown + ? AppStrings.Keychain.unknownKeyType : AppStrings.Keychain.keyType(keyDisplayName) + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) } HStack(spacing: Metrics.Space.xs) { - CardChip(text: keyDisplayName) if key.hasPassphrase { CardChip( text: String( @@ -1960,9 +1625,6 @@ private struct KeyCard: View { .contextMenu { if actionKeys.count == 1, actionIdentities.isEmpty { KeyObjectActions(key: key, edit: edit, export: export) - } else if actionIdentities.isEmpty { - Button(AppStrings.Keychain.exportToHosts.swiftUIKey, systemImage: MenuSymbol.export, action: export) - .disabled(actionKeys.isEmpty || actionKeys.contains { $0.publicKey.isEmpty }) } Divider() DestructiveMenuButton(AppStrings.Common.delete, action: delete) diff --git a/TFM/Features/KnownHosts/KnownHostsPageView.swift b/TFM/Features/KnownHosts/KnownHostsPageView.swift index fa6551d2..4d976df2 100644 --- a/TFM/Features/KnownHosts/KnownHostsPageView.swift +++ b/TFM/Features/KnownHosts/KnownHostsPageView.swift @@ -216,7 +216,7 @@ struct KnownHostEditorView: View { } Section(AppStrings.KnownHosts.hostKey.swiftUIKey) { Picker(AppStrings.KnownHosts.keyType.swiftUIKey, selection: $draft.algorithm) { - ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) } + ForEach(SSHKeyAlgorithm.allCases.filter { $0 != .unknown }) { Text($0.displayName).tag($0) } } TextField( AppStrings.KnownHosts.fingerprint.swiftUIKey, text: $draft.fingerprint.singleLineText, diff --git a/TFM/Features/Shell/WindowRoute.swift b/TFM/Features/Shell/WindowRoute.swift index 6669e1d2..88f23e44 100644 --- a/TFM/Features/Shell/WindowRoute.swift +++ b/TFM/Features/Shell/WindowRoute.swift @@ -96,7 +96,7 @@ nonisolated enum VaultWorkRoute: Hashable, Sendable { case knownHostEditor(KnownHostEntry.ID?) case sshImport(knownHostsOnly: Bool) case editKey(SSHKeyRef.ID) - case exportKeys([SSHKeyRef.ID]) + case exportKey(SSHKeyRef.ID) case snippetEditor(snippetID: Snippet.ID?, packageID: SnippetPackage.ID?) case snippetPackageEditor(packageID: SnippetPackage.ID?, parentID: SnippetPackage.ID?) case snippetExecutionTargets(SnippetHostChooserRequest) diff --git a/TFM/Features/Vault/VaultView.swift b/TFM/Features/Vault/VaultView.swift index e5d09c5b..843450e1 100644 --- a/TFM/Features/Vault/VaultView.swift +++ b/TFM/Features/Vault/VaultView.swift @@ -72,8 +72,8 @@ struct VaultWorkDetailView: View { KeychainImportDetail() case .editKey(let id): KeychainEditDetail(keyID: id) - case .exportKeys(let ids): - ExportKeyToHostsSheet(keys: ids.compactMap(app.hostLibrary.key)) + case .exportKey(let id): + KeyExportDetailView(keyID: id) case .snippetEditor(let snippetID, let packageID): SnippetEditorView( snippet: snippetID.flatMap(app.hostLibrary.snippet), packageID: packageID, diff --git a/TFMTests/DeferredKeyMaterialTests.swift b/TFMTests/DeferredKeyMaterialTests.swift new file mode 100644 index 00000000..f3c617aa --- /dev/null +++ b/TFMTests/DeferredKeyMaterialTests.swift @@ -0,0 +1,155 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +struct DeferredKeyMaterialTests { + @Test func nestedSSHFieldsAcceptDataSlicesWithNonzeroIndices() throws { + let container = Data([99, 99, 0, 0, 0, 7, 0, 0, 0, 3, 1, 2, 3]) + let slice = container.dropFirst(2) + #expect(slice.startIndex != 0) + var offset = 0 + let nested = try #require(SSHKeyMaterial.readSSHField(slice, offset: &offset)) + #expect(offset == slice.count) + var nestedOffset = 0 + #expect(SSHKeyMaterial.readSSHField(nested, offset: &nestedOffset) == Data([1, 2, 3])) + #expect(nestedOffset == nested.count) + } + + @Test func invalidSSHFieldBoundsFailWithoutTrappingOrAdvancing() { + for bytes in [Data([0, 0, 0]), Data([255, 255, 255, 255]), Data([0, 0, 0, 2, 1])] { + for initialOffset in [-1, 0, Int.max] { + var offset = initialOffset + #expect(SSHKeyMaterial.readSSHField(bytes, offset: &offset) == nil) + #expect(offset == initialOffset) + } + } + } + + @Test(arguments: [SSHKeyMaterial.Generation.ed25519, .rsa(bits: 2048), .ecdsa(bits: 256)]) + func importRecognizesEncryptionWithoutUnlockingOrDeriving(_ generation: SSHKeyMaterial.Generation) throws { + let encrypted = try SSHKeyMaterial.generate(generation, comment: "test", passphrase: "secret") + let metadata = try SSHKeyMaterial.privateKeyMetadata(encrypted.privateKey) + #expect(metadata.isEncrypted && metadata.algorithm == .unknown) + let imported = try SSHKeyMaterial.imported(privateKey: encrypted.privateKey, publicKey: "") + #expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty && imported.algorithm == .unknown) + let supplied = try SSHKeyMaterial.imported(privateKey: encrypted.privateKey, publicKey: encrypted.publicKey) + #expect(supplied.fingerprint == encrypted.fingerprint && supplied.algorithm == encrypted.algorithm) + let plaintext = try SSHKeyMaterial.generate(generation, comment: "", passphrase: nil) + #expect(try !SSHKeyMaterial.privateKeyMetadata(plaintext.privateKey).isEncrypted) + #expect(try SSHKeyMaterial.imported(privateKey: plaintext.privateKey, publicKey: "").publicKey.isEmpty) + } + + @Test(arguments: [ + "", "not a key", "-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----", + ]) + func invalidPrivateEnvelopesCannotBeSaved(_ input: String) { + #expect(throws: (any Error).self) { try SSHKeyMaterial.imported(privateKey: input, publicKey: "") } + } + + @Test func suppliedPublicKeyMustHaveAValidWireBlob() throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + for value in [ + "ssh-ed25519 AAAA", material.publicKey + "\n" + material.publicKey, + material.publicKey.replacingOccurrences(of: "ssh-ed25519", with: "ssh-rsa"), + ] { + #expect(throws: (any Error).self) { try SSHKeyMaterial.publicMaterial(value) } + } + } + + @Test func opensshAndTraditionalPEMEncryptionIsDetectedWithoutAPassphrase() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + for (format, type) in [("", "ed25519"), ("PEM", "rsa"), ("PEM", "ecdsa")] { + let path = directory.appending(path: UUID().uuidString).path + var args = ["-q", "-t", type, "-N", "fixture-passphrase", "-f", path] + if !format.isEmpty { args += ["-m", format] } + try run("/usr/bin/ssh-keygen", args) + let text = try String(contentsOfFile: path, encoding: .utf8) + #expect(try SSHKeyMaterial.privateKeyMetadata(text).isEncrypted) + #expect(try SSHKeyMaterial.imported(privateKey: text, publicKey: "").publicKey.isEmpty) + } + } + + @Test(arguments: ["ed25519", "rsa", "ecdsa"]) + func editingUnencryptedOpenSSHKeysReadsNestedMetadataWithoutDeriving(_ type: String) throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appending(path: "key").path + try run("/usr/bin/ssh-keygen", ["-q", "-t", type, "-N", "", "-f", path]) + let privateKey = try String(contentsOfFile: path, encoding: .utf8) + let publicKey = try String(contentsOfFile: path + ".pub", encoding: .utf8) + #expect(try !SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted) + let material = try SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey) + #expect(material.algorithm != .unknown && !material.fingerprint.isEmpty) + #expect(try SSHKeyMaterial.imported(privateKey: privateKey, publicKey: "").publicKey.isEmpty) + } + + @Test func defaultExportScriptQuotesArgumentsAndDoesNotDuplicateTheKey() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appending(path: "keys '$HOME; $(touch injected)") + let name = "authorized_keys';touch injected;#" + let material = try SSHKeyMaterial.generate(.ed25519, comment: "$(touch injected)", passphrase: nil) + let options = KeyExportOptions( + directory: directory.path, filename: name, + script: KeyExportOptions.defaultScript(keyName: "Work\n$(touch injected)")) + let command = try SSHKeyExportSession.command(publicKey: material.publicKey, options: options) + try run("/bin/sh", ["-c", command], directory: root) + try run("/bin/sh", ["-c", command], directory: root) + let file = directory.appending(path: name) + let contents = try String(contentsOf: file, encoding: .utf8) + #expect(contents.components(separatedBy: material.publicKey).count == 2) + #expect(contents.hasPrefix("# Work $(touch injected) Key by Termind\n")) + #expect(!FileManager.default.fileExists(atPath: root.appending(path: "injected").path)) + #expect(try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? Int == 0o700) + #expect(try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? Int == 0o600) + } + + @Test func customScriptReceivesThreeLiteralArguments() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "comment", passphrase: nil) + let options = KeyExportOptions( + directory: root.path, filename: "custom", + script: "printf '%s\\n%s\\n' \"$2\" \"$3\" > \"$1/$2\"") + try run("/bin/sh", ["-c", SSHKeyExportSession.command(publicKey: material.publicKey, options: options)]) + #expect( + try String(contentsOf: root.appending(path: "custom"), encoding: .utf8) == "custom\n\(material.publicKey)\n" + ) + } + + @Test func defaultScriptPreservesAnExistingKeyWithoutATrailingNewline() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let existing = try SSHKeyMaterial.generate(.ed25519, comment: "existing", passphrase: nil) + let next = try SSHKeyMaterial.generate(.ed25519, comment: "next", passphrase: nil) + let file = root.appending(path: "authorized_keys") + try existing.publicKey.write(to: file, atomically: true, encoding: .utf8) + let options = KeyExportOptions(directory: root.path, script: KeyExportOptions.defaultScript(keyName: "Next")) + try run("/bin/sh", ["-c", SSHKeyExportSession.command(publicKey: next.publicKey, options: options)]) + let lines = try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map(String.init) + #expect(lines == [existing.publicKey, "# Next Key by Termind", next.publicKey]) + } + + private func run(_ executable: String, _ arguments: [String], directory: URL? = nil) throws { + let process = Process() + process.executableURL = URL(filePath: executable) + process.arguments = arguments + process.currentDirectoryURL = directory + let errors = Pipe() + process.standardError = errors + try process.run() + process.waitUntilExit() + let output = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + #expect(process.terminationStatus == 0, "\(output)") + guard process.terminationStatus == 0 else { throw CocoaError(.executableRuntimeMismatch) } + } +} diff --git a/TFMTests/KeyExportRenderingTests.swift b/TFMTests/KeyExportRenderingTests.swift new file mode 100644 index 00000000..d690e47b --- /dev/null +++ b/TFMTests/KeyExportRenderingTests.swift @@ -0,0 +1,60 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure +import Testing + +@testable import Termind + +@Suite(.requiresVisibleWindow, .serialized) @MainActor +struct KeyExportRenderingTests { + @Test(arguments: ["en", "zh-Hans"]) + func encryptedExportShowsItsPassphraseInsideTheVaultForm(_ language: String) async throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret") + let key = SSHKeyRef(label: "Deployment Key", algorithm: .unknown, credentialRef: "key", hasPassphrase: true) + let host = TermindDomain.Host(name: "Production", host: "server.example.com") + let library = HostLibraryModel(hosts: [host], keys: [key]) + try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey))) + let app = AppModel(hosts: library) + let navigation = WindowModel() + navigation.openVaultWork(.exportKey(key.id), on: .keychain) + let controller = NSHostingController( + rootView: + VaultWorkspaceView().environment(app).environment(navigation) + .environment(\.locale, Locale(identifier: language)) + .frame(width: 1040, height: 760)) + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 1040, height: 760), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + controller.sizingOptions = [] + window.contentViewController = controller + window.makeKeyAndOrderFront(nil) + defer { + window.close() + app.shutdownForTermination() + } + try await Task.sleep(for: .milliseconds(700)) + controller.view.layoutSubtreeIfNeeded() + #expect(window.attachedSheet == nil) + #expect(descendants(controller.view).contains { $0 is NSSecureTextField }) + #expect(app.externalAuthentication.pending.isEmpty) + guard let image = controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds) else { + Issue.record("Unable to capture export form") + return + } + controller.view.cacheDisplay(in: controller.view.bounds, to: image) + let directory = FileManager.default.temporaryDirectory.appending(path: "key-export-screenshots") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let output = directory.appending(path: "macos-\(language)-form.png") + let data = try #require(image.representation(using: .png, properties: [:])) + Attachment.record(data, named: output.lastPathComponent) + try data.write(to: output) + print("Key export snapshot: \(output.path)") + } + + private func descendants(_ view: NSView) -> [NSView] { + [view] + view.subviews.flatMap(descendants) + } +}