From b58c8beb5e500dd9d51e4389d2de978ddcf96db1 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 28 Sep 2026 18:37:21 +0800 Subject: [PATCH] fix(ssh): restore saved passphrases and authentication saving --- .../SSHConnectionPreparationTests.swift | 2 +- .../SSHInteractionPolicyTests.swift | 6 +- FrameworkTests/SavedPassphraseTests.swift | 326 ++++++++++++++++++ Frameworks/Resources/Localizable.xcstrings | 34 ++ .../Connections/RemoteSessionHostStore.swift | 45 ++- .../Connections/SSHAuthenticationPrompt.swift | 4 +- .../SSHConnectionPreparation.swift | 35 +- .../CredentialLibraryModel.swift | 3 + .../FeatureModels/HostLibraryModel.swift | 36 +- .../Metrics/ServerMetricsModel.swift | 20 +- .../SSHConnectionTypes.swift | 38 +- .../TermindApplication/SSHRouteEvent.swift | 5 +- Frameworks/TermindApplication/SSHSecret.swift | 10 + .../TermindApplication/SecretStoring.swift | 43 ++- .../ConnectionDecisionForms.swift | 68 ++-- .../Strings/Keychain.swift | 5 + .../Agent/AgentConnections.swift | 27 +- .../SSH/PortForwardingController.swift | 18 +- .../Security/KeychainSecretStore.swift | 299 +++++++++++----- .../FeatureModels/MobileKeyEditorModel.swift | 32 +- .../Keychain/MobileCredentialsView.swift | 5 +- TFI/Features/Keychain/MobileKeyEditor.swift | 5 + TFI/Features/Workspaces/MobileWorkspace.swift | 15 +- .../MobilePassphraseFormLayoutTests.swift | 73 ++++ TFITests/MobileVaultFeatureTests.swift | 66 ++++ TFM/Features/Keychain/KeychainPageView.swift | 77 +++-- TFM/Features/SFTP/SFTPChannelModel.swift | 25 +- TFM/Features/Terminal/SSHSessionModel.swift | 28 +- TFMTests/AgentHostAuthenticationTests.swift | 10 +- TFMTests/KeyPassphraseEnvelopeTests.swift | 102 ++++++ TFMTests/PassphraseFormLayoutTests.swift | 34 ++ TFMTests/PortForwardingJourneyTests.swift | 6 +- 32 files changed, 1261 insertions(+), 241 deletions(-) create mode 100644 FrameworkTests/SavedPassphraseTests.swift create mode 100644 TFITests/MobilePassphraseFormLayoutTests.swift create mode 100644 TFMTests/KeyPassphraseEnvelopeTests.swift create mode 100644 TFMTests/PassphraseFormLayoutTests.swift diff --git a/FrameworkTests/SSHConnectionPreparationTests.swift b/FrameworkTests/SSHConnectionPreparationTests.swift index 4a42fd17..38fc32ac 100644 --- a/FrameworkTests/SSHConnectionPreparationTests.swift +++ b/FrameworkTests/SSHConnectionPreparationTests.swift @@ -180,7 +180,7 @@ struct SSHConnectionPreparationTests { } @Test - func protectedKeyIsNotReadBeforePassphraseIsRequested() async { + func protectedKeyWithoutSavedPassphraseRequestsEntry() async { let key = SSHKeyRef( label: "Protected", publicKey: "ssh-ed25519 test", credentialRef: "key.protected", hasPassphrase: true diff --git a/FrameworkTests/SSHInteractionPolicyTests.swift b/FrameworkTests/SSHInteractionPolicyTests.swift index 9c5c1acf..16849e14 100644 --- a/FrameworkTests/SSHInteractionPolicyTests.swift +++ b/FrameworkTests/SSHInteractionPolicyTests.swift @@ -149,7 +149,7 @@ struct SSHInteractionPolicyTests { #expect(password == "current-password") } - @Test func additionalResolutionSkipsPassphraseKeyAndUsesIdentityPassword() throws { + @Test func additionalResolutionChecksForSavedPassphraseAndUsesIdentityPassword() throws { let secrets = CountingSecretStore() let protectedKey = SSHKeyRef( label: "Encrypted", publicKey: "ssh-ed25519 public", @@ -174,9 +174,9 @@ struct SSHInteractionPolicyTests { let request = try store.additionalConnectionRequest( from: SSHAdditionalConnectionSnapshot(target: node) ) - #expect(secrets.keyReads == 0) + #expect(secrets.keyReads == 1) guard case .interactionRequired = request.configuration.credentials[0] else { - Issue.record("Passphrase key must remain unread") + Issue.record("A key without a saved passphrase must require interaction") return } guard case .password(let password) = request.configuration.credentials[1] else { diff --git a/FrameworkTests/SavedPassphraseTests.swift b/FrameworkTests/SavedPassphraseTests.swift new file mode 100644 index 00000000..d8b22e0b --- /dev/null +++ b/FrameworkTests/SavedPassphraseTests.swift @@ -0,0 +1,326 @@ +import Foundation +import Observation +import Synchronization +import TermindDomain +import Testing + +@testable import TermindApplication + +@Suite @MainActor +struct SavedPassphraseTests { + @Test func keychainCardObservesSavingAndClearingThePassphrase() throws { + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let store = HostLibraryModel() + try store.upsert(key, credential: .software(.init(privateKey: "encrypted"))) + let credentials = CredentialLibraryModel(library: store) + let changed = Mutex(false) + withObservationTracking { + #expect(!credentials.hasSavedPassphrase(for: key)) + } onChange: { + changed.withLock { $0 = true } + } + try store.saveKeyPassphrase("saved", for: key.id) + #expect(changed.withLock { $0 }) + #expect(credentials.hasSavedPassphrase(for: key)) + changed.withLock { $0 = false } + withObservationTracking { + #expect(credentials.hasSavedPassphrase(for: key)) + } onChange: { + changed.withLock { $0 = true } + } + try store.upsert(key, credential: .software(.init(privateKey: "encrypted"))) + #expect(changed.withLock { $0 }) + #expect(!credentials.hasSavedPassphrase(for: key)) + } + + @Test(arguments: [false, true], [false, true]) + func passphraseSaveIsExplicitAndPreservesStagedIdentity(save: Bool, allowsSaving: Bool) throws { + let secrets = InMemorySecretStore() + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let host = Host(name: "Host", host: "example.invalid") + let store = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets) + try secrets.setKeyCredential( + .software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage) + let prompt = SSHAuthenticationPrompt( + host: host, selectedIdentityID: identity.id, username: "deploy", selectedKeyID: key.id, + requiresPassphrase: true, allowsSaving: allowsSaving) + let staged = SSHAuthenticationSubmission( + username: "deploy", password: "fallback", keyID: key.id, identityID: identity.id) + let result = try store.passphraseSubmission(for: prompt, existing: staged, passphrase: "saved", save: save) + #expect(result.password == "fallback") + #expect(result.identityID == identity.id) + #expect(result.keyID == key.id) + #expect(result.passphrase == "saved") + #expect(store.host(host.id) == host) + #expect(store.identities == [identity]) + #expect(store.requiresPassphraseEntry(for: key) == !(save && allowsSaving)) + guard case .software(let credential)? = try secrets.keyCredential(for: key.credentialRef, storage: key.storage) + else { + Issue.record("Missing key") + return + } + #expect(credential.passphrase == (save && allowsSaving ? "saved" : nil)) + } + + @Test func savingWithoutAStagedSubmissionDoesNotSaveTheHostIdentity() throws { + let secrets = InMemorySecretStore() + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let store = HostLibraryModel(keys: [key], secrets: secrets) + try secrets.setKeyCredential( + .software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage) + let host = Host(name: "Host", host: "example.invalid") + let prompt = SSHAuthenticationPrompt( + host: host, selectedIdentityID: nil, username: "deploy", selectedKeyID: key.id, requiresPassphrase: true) + let result = try store.passphraseSubmission(for: prompt, existing: nil, passphrase: "saved", save: true) + #expect(result.username == "deploy") + #expect(store.hosts.isEmpty && store.identities.isEmpty) + #expect(!store.requiresPassphraseEntry(for: key)) + } + + @Test func missingKeySaveFailsWithoutMutatingTheSubmission() { + let store = HostLibraryModel() + let keyID = UUID() + let staged = SSHAuthenticationSubmission(username: "deploy", password: "fallback", keyID: keyID) + let prompt = SSHAuthenticationPrompt( + host: Host(name: "Host", host: "example.invalid"), selectedIdentityID: nil, username: "deploy", + selectedKeyID: keyID, requiresPassphrase: true) + #expect(throws: TermindSSHError.self) { + _ = try store.passphraseSubmission(for: prompt, existing: staged, passphrase: "saved", save: true) + } + #expect(staged.passphrase == nil && staged.password == "fallback") + } + + @Test func savedPassphrasesAllowSilentTargetAndJumpConnectionsAndAreReread() throws { + let secrets = InMemorySecretStore() + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let store = HostLibraryModel(keys: [key], identities: [identity], secrets: secrets) + try secrets.setKeyCredential( + .software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage + ) + func node(_ name: String) -> SSHAdditionalConnectionSnapshot.Node { + .init( + id: UUID(), name: name, host: "example.invalid", port: 22, + authentication: .init(identityID: identity.id, username: "deploy"), expectedHostKeys: [], proxy: nil) + } + let snapshot = SSHAdditionalConnectionSnapshot(target: node("Target"), jumps: [node("Jump")]) + let first = try store.additionalConnectionRequest(from: snapshot) + defer { first.configuration.clearSecrets() } + for credential in first.configuration.credentials + first.configuration.jumps.flatMap(\.credentials) { + guard case .privateKey(_, _, let passphrase, let requiresEntry) = credential else { + Issue.record("Saved passphrase must be usable silently") + continue + } + #expect(!requiresEntry) + #expect(passphrase?.matches("saved") == true) + } + #expect( + SSHAuthenticationPromptResolver.host( + Host(name: "Host", host: "example.invalid", identityID: identity.id), using: store + ).requiresPassphrase == false) + try store.saveKeyPassphrase("replacement", for: key.id) + let second = try store.additionalConnectionRequest(from: snapshot) + defer { second.configuration.clearSecrets() } + guard case .privateKey(_, _, let passphrase, _) = second.configuration.credentials.first else { + Issue.record("Expected refreshed key") + return + } + #expect(passphrase?.matches("replacement") == true) + } + + @Test func preparationUsesSavedPassphrasesWithoutPrompts() async throws { + let secrets = InMemorySecretStore() + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let host = Host(name: "Host", host: "example.invalid", identityID: identity.id) + try secrets.setKeyCredential( + .software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage + ) + let preparation = SSHConnectionPreparation( + nodes: [ + .init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil) + ], secrets: secrets) + let configuration = try await preparation.resolve(trustPolicy: .allowChallenge) + defer { configuration.clearSecrets() } + guard case .privateKey(_, _, let passphrase, let requiresEntry) = configuration.credentials.first else { + Issue.record("Expected saved key") + return + } + #expect(!requiresEntry && passphrase?.matches("saved") == true) + } + + @Test(arguments: [false, true], [false, true]) + func clearingOrDeletingAKeyZeroesRollbackSecrets(delete: Bool, failsSave: Bool) throws { + let observation = ZeroizedAllocations() + let secrets = TrackedVaultSecrets(observation: observation) + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let persistence = PassphrasePersistence(snapshot: VaultSnapshot(keys: [key])) + let store = HostLibraryModel(persistence: persistence, secrets: secrets) + try secrets.setKeyCredential( + .software(.init(privateKey: "encrypted", passphrase: "saved")), for: key.credentialRef, storage: key.storage + ) + persistence.failsSave = failsSave + do { + if delete { + try store.delete(keyID: key.id) + } else { + try store.upsert(key, credential: .software(.init(privateKey: "encrypted"))) + } + #expect(!failsSave) + } catch { #expect(failsSave) } + #expect(observation.count == 2 && observation.allZero) + let current = try secrets.keyCredential(for: key.credentialRef, storage: key.storage) + if delete && !failsSave { + #expect(current == nil && store.key(key.id) == nil) + } else { + guard case .software(let value)? = current else { + Issue.record("Missing key") + return + } + #expect(value.privateKey == "encrypted") + #expect(value.passphrase == (failsSave ? "saved" : nil)) + #expect(store.key(key.id) != nil) + } + } + + @Test func preparationFailureZeroesSavedSecretsEvenWhileAliasesRemainAlive() async { + let observation = ZeroizedAllocations() + let secrets = RetainedPreparationSecrets(observation: observation) + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let host = Host(name: "Host", host: "example.invalid", identityID: identity.id) + let preparation = SSHConnectionPreparation( + nodes: [ + .init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil) + ], secrets: secrets) + await #expect(throws: TermindSSHError.self) { _ = try await preparation.resolve() } + #expect(secrets.key.isConsumed && secrets.passphrase.isConsumed) + #expect(observation.count == 2 && observation.allZero) + } + + @Test func cancellationAfterKeychainReadZeroesSavedSecrets() async { + let observation = ZeroizedAllocations() + let (events, continuation) = AsyncStream.makeStream() + let gate = DispatchSemaphore(value: 0) + let secrets = RetainedPreparationSecrets(observation: observation) { + continuation.yield(()) + gate.wait() + } + let key = SSHKeyRef(label: "Protected", credentialRef: "termind.key.fixture", hasPassphrase: true) + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let host = Host(name: "Host", host: "example.invalid", identityID: identity.id) + let preparation = SSHConnectionPreparation( + nodes: [ + .init(host: host, identity: identity, key: key, submission: nil, trustedKeys: [], proxyIdentity: nil) + ], secrets: secrets) + let task = Task { try await preparation.resolve(trustPolicy: .allowChallenge) } + var iterator = events.makeAsyncIterator() + _ = await iterator.next() + task.cancel() + gate.signal() + await #expect(throws: CancellationError.self) { _ = try await task.value } + #expect(observation.count == 2 && observation.allZero) + continuation.finish() + } + + @Test func replacedSavedPassphraseAndAllAttemptBuffersAreZeroedBeforeDeallocation() throws { + let observation = ZeroizedAllocations() + let key = SSHSecret("private-key") + let saved = SSHSecret("saved-passphrase") + let override = SSHSecret("override-passphrase") + for secret in [key, saved, override] { secret.observeZeroization { observation.record($0) } } + let credential = SSHResolvedKeyCredential.software(key, passphrase: saved).sshCredential( + publicKey: "public", passphraseOverride: override, requiresPassphrase: true) + #expect(observation.count == 1) + credential.clearSecrets() + #expect(observation.count == 3) + #expect(observation.allZero) + } + + @Test(arguments: [false, true]) + func consumingSavedPassphraseZeroesAllocationOnSuccessAndThrow(fails: Bool) throws { + let observation = ZeroizedAllocations() + let secret = SSHSecret("saved") + secret.observeZeroization { observation.record($0) } + do { + try secret.withUnsafeCStringAndConsume { _, _ in + if fails { throw CancellationError() } + } + } catch { #expect(fails && error is CancellationError) } + #expect(observation.count == 1 && observation.allZero) + } +} + +private nonisolated final class ZeroizedAllocations: @unchecked Sendable { + private let lock = NSLock() + private var observations: [Bool] = [] + var count: Int { lock.withLock { observations.count } } + var allZero: Bool { lock.withLock { observations.allSatisfy { $0 } } } + func record(_ bytes: UnsafeRawBufferPointer) { + lock.withLock { observations.append(!bytes.isEmpty && bytes.allSatisfy { $0 == 0 }) } + } +} + +private nonisolated final class RetainedPreparationSecrets: SSHRuntimeSecretResolving, Sendable { + let key = SSHSecret("encrypted") + let passphrase = SSHSecret("saved") + let onRead: @Sendable () -> Void + init(observation: ZeroizedAllocations, onRead: @escaping @Sendable () -> Void = {}) { + self.onRead = onRead + key.observeZeroization { observation.record($0) } + passphrase.observeZeroization { observation.record($0) } + } + func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { nil } + func sshKeyCredential(for account: String, storage: KeyStorage) throws -> SSHResolvedKeyCredential? { + onRead() + return .software(key, passphrase: passphrase) + } +} + +private nonisolated final class TrackedVaultSecrets: SecretStoring, Sendable { + let base = InMemorySecretStore() + let observation: ZeroizedAllocations + init(observation: ZeroizedAllocations) { self.observation = observation } + func string(for account: String, storage: PasswordStorage) throws -> String? { + try base.string(for: account, storage: storage) + } + func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { + try base.setString(value, for: account, storage: storage) + } + func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { + try base.keyCredential(for: account, storage: storage) + } + func sshKeyCredential(for account: String, storage: KeyStorage) throws -> SSHResolvedKeyCredential? { + let result = try base.sshKeyCredential(for: account, storage: storage) + if case .software(let key, let passphrase) = result { + key.observeZeroization { [observation] in observation.record($0) } + passphrase?.observeZeroization { [observation] in observation.record($0) } + } + return result + } + func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws { + try base.setKeyCredential(value, for: account, storage: storage) + } + func removePassword(_ account: String, storage: PasswordStorage) throws { + try base.removePassword(account, storage: storage) + } + func removeKeyCredential(_ account: String, storage: KeyStorage) throws { + try base.removeKeyCredential(account, storage: storage) + } +} + +@MainActor private final class PassphrasePersistence: 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 + } + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { + VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: []) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 094a8e4e..a3cb91d0 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -19574,6 +19574,23 @@ } } }, + "Passphrase Saved" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已保存密码短语" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已儲存金鑰密碼" + } + } + } + }, "Passphrase required" : { "extractionState" : "manual", "localizations" : { @@ -23891,6 +23908,23 @@ } } }, + "Save Passphrase" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存密码短语" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "儲存金鑰密碼" + } + } + } + }, "Save Workspace Template" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index 9e732411..f5f2b72d 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -59,6 +59,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { func sshCredentials(for identity: Identity) -> SSHCredentialResolution func passwordSecret(for identity: Identity) throws -> SSHSecret? func passwordAvailability(for identity: Identity) -> SecretAvailability + func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID) func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) @@ -69,6 +70,36 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { } extension RemoteSessionHostStore { + public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws { + throw TermindSSHError.missingCredential + } + + public func passphraseSubmission( + for prompt: SSHAuthenticationPrompt, existing: SSHAuthenticationSubmission?, + passphrase: String?, save: Bool + ) throws -> SSHAuthenticationSubmission { + guard let passphrase, !passphrase.isEmpty else { throw TermindSSHError.keyPassphraseRequired } + var submission = + existing + ?? SSHAuthenticationSubmission( + username: prompt.username, keyID: prompt.selectedKeyID, identityID: prompt.selectedIdentityID) + guard let keyID = submission.keyID else { throw TermindSSHError.missingCredential } + if save && prompt.allowsSaving { try saveKeyPassphrase(passphrase, for: keyID) } + submission.passphrase = passphrase + return submission + } + + /// Query the local Keychain rather than relying on synchronized metadata. + /// Every probe releases its private key and passphrase immediately. + public func requiresPassphraseEntry(for key: SSHKeyRef) -> Bool { + guard key.hasPassphrase else { return false } + do { + guard let credential = try keySecretCredential(for: key) else { return true } + defer { credential.clearSecrets() } + return !credential.hasSavedPassphrase + } catch { return true } + } + public func snippet(_ id: Snippet.ID) -> Snippet? { nil } public func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? { nil } public var customPasswordStorage: PasswordStorage { .iCloud } @@ -292,6 +323,15 @@ extension RemoteSessionHostStore { public func additionalConnectionRequest( from snapshot: SSHAdditionalConnectionSnapshot ) throws -> SSHConnectionRequest { + var completed = false + var resolvedCredentials: [SSHCredentials] = [] + var resolvedProxies: [SSHSecret] = [] + defer { + if !completed { + for credential in resolvedCredentials { credential.clearSecrets() } + for secret in resolvedProxies { secret.clear() } + } + } func proxy( _ value: SSHAdditionalConnectionSnapshot.Proxy?, ownerHostID: Host.ID ) throws -> SSHTransportProxy? { @@ -307,6 +347,7 @@ extension RemoteSessionHostStore { } let password = try connectionPasswordSecret(for: identity) guard let password else { throw TermindSSHError.missingCredential } + resolvedProxies.append(password) return SSHTransportProxy( kind: value.kind, hostname: value.hostname, port: value.port, username: value.username, password: password, @@ -332,7 +373,7 @@ extension RemoteSessionHostStore { let keyID = resolvedIdentity?.keyID ?? reference.fallbackKeyID if let keyID { if let key = key(keyID) { - if key.requiresPassphraseEntry || key.requiresUserPresence { + if key.requiresUserPresence || requiresPassphraseEntry(for: key) { credentials.append(.interactionRequired) } else { do { @@ -373,6 +414,7 @@ extension RemoteSessionHostStore { if reference.hasEphemeralSecret { throw SSHInteractionRequired() } if !reference.permitsNone { throw failures.first ?? TermindSSHError.missingCredential } } + resolvedCredentials += credentials return (username, credentials) } @@ -396,6 +438,7 @@ extension RemoteSessionHostStore { charset: snapshot.target.charset, authenticationReference: snapshot.target.authentication ) + completed = true return SSHConnectionRequest( configuration: configuration, interactionPolicy: .forbidden ).enforcingInteractionPolicy() diff --git a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift index 6a62a66e..b34a6101 100644 --- a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift +++ b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift @@ -68,7 +68,9 @@ public enum SSHAuthenticationPromptResolver { let needsPassphrase = requiresPassphrase ?? (submission?.requiresPasswordReentry != true - && keyID.flatMap(store.key)?.requiresPassphraseEntry == true + && keyID.flatMap(store.key).map { + (mode == .serverChallenge && $0.hasPassphrase) || store.requiresPassphraseEntry(for: $0) + } == true && submission?.passphrase == nil) return SSHAuthenticationPrompt( host: host, selectedIdentityID: submission?.identityID ?? identity?.id, diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index e953dab3..2d3cc68b 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -44,7 +44,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } return try await withTaskCancellationHandler { let result = try await task.value - try Task.checkCancellation() + do { try Task.checkCancellation() } catch { + result.clearSecrets() + throw error + } return result } onCancel: { task.cancel() @@ -58,10 +61,22 @@ public nonisolated struct SSHConnectionPreparation: Sendable { throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) } var jumps: [SSHRouteHopConfiguration] = [] + var completed = false + var resolvedCredentials: [SSHCredentials] = [] + var resolvedProxies: [SSHSecret] = [] + defer { + if !completed { + for credential in resolvedCredentials { credential.clearSecrets() } + for secret in resolvedProxies { secret.clear() } + } + } for (offset, node) in nodes.dropLast().enumerated() { do { try Task.checkCancellation() let material = try credentials(node) + resolvedCredentials += material.1 + let resolvedProxy = try proxy(node) + if let password = resolvedProxy?.password { resolvedProxies.append(password) } if trustPolicy == .requireKnownHost, node.trustedKeys.isEmpty { throw TermindSSHError.untrustedHost } @@ -70,7 +85,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port, username: material.0, credentials: material.1, expectedHostKeys: node.trustedKeys, - proxy: try proxy(node), + proxy: resolvedProxy, authenticationReference: authenticationReference(for: node) )) } catch { @@ -90,13 +105,17 @@ public nonisolated struct SSHConnectionPreparation: Sendable { discoveryOnly ? ("", [SSHCredentials]()) : try credentials(target) + resolvedCredentials += material.1 + let resolvedProxy = try proxy(target) + if let password = resolvedProxy?.password { resolvedProxies.append(password) } if !discoveryOnly, trustPolicy == .requireKnownHost, target.trustedKeys.isEmpty { throw TermindSSHError.untrustedHost } + completed = true return SSHConnectionConfiguration( hostID: target.host.id, host: target.host.host, port: target.host.port, username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys, - proxy: try proxy(target), jumps: jumps, + proxy: resolvedProxy, jumps: jumps, charset: target.host.charset, authenticationReference: authenticationReference(for: target) ) @@ -126,11 +145,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable { .trimmingCharacters(in: .whitespacesAndNewlines) guard !username.isEmpty else { throw TermindSSHError.missingUsername } var result: [SSHCredentials] = [] + var completed = false + defer { if !completed { for credential in result { credential.clearSecrets() } } } var retrievalFailure: (any Error)? if let key = node.key { - if key.requiresPassphraseEntry, node.submission?.hasPassphrase != true { - throw TermindSSHError.keyPassphraseRequired - } let stored: SSHResolvedKeyCredential? do { stored = try secrets.sshKeyCredential(for: key.credentialRef, storage: key.storage) @@ -139,6 +157,10 @@ public nonisolated struct SSHConnectionPreparation: Sendable { retrievalFailure = error } if let stored { + if key.hasPassphrase, !stored.hasSavedPassphrase, node.submission?.hasPassphrase != true { + stored.clearSecrets() + throw TermindSSHError.keyPassphraseRequired + } result.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, @@ -169,6 +191,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { if result.isEmpty, node.key != nil || (usesIdentity && node.identity?.hasSecret == true) { throw TermindSSHError.missingCredential } + completed = true return (username, result) } diff --git a/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift index 245eef52..cff6be7a 100644 --- a/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift @@ -34,6 +34,9 @@ public final class CredentialLibraryModel { public func editablePassword(for identity: Identity) throws -> String? { try library.editablePassword(for: identity) } + public func hasSavedPassphrase(for key: SSHKeyRef) -> Bool { + key.hasPassphrase && !library.requiresPassphraseEntry(for: key) + } public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { library.keyAvailability(for: key) } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 744082b7..25a27d11 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -26,6 +26,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { /// The latest local persistence failure. Secret text is never included. public private(set) var storageError: String? private let vault: VaultStore + private var keyCredentialRevision = 0 private let keychain: any SecretStoring private let defaults: UserDefaults private var expansionState = LibraryExpansionState() @@ -730,7 +731,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { private func sshCredentials(for submission: SSHAuthenticationSubmission) throws -> [SSHCredentials] { var credentials: [SSHCredentials] = [] if let keyID = submission.keyID, let key = key(keyID) { - if key.requiresPassphraseEntry, !submission.hasPassphrase { + if requiresPassphraseEntry(for: key), !submission.hasPassphrase { throw TermindSSHError.keyPassphraseRequired } if let stored = try keySecretCredential(for: key) { @@ -802,7 +803,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { if let keyID = identity.keyID { if let key = key(keyID) { - if key.requiresPassphraseEntry { + if requiresPassphraseEntry(for: key) { requiredPassphraseKeyID = key.id } else { do { @@ -1469,11 +1470,15 @@ public final class HostLibraryModel: RemoteSessionHostStore { } let previousCredential = try (credential != nil || changesLocation) - ? previousKey.flatMap { try keyCredential(for: $0) } + ? previousKey.flatMap { try keySecretCredential(for: $0) } : nil - let destinationCredential = credential ?? (changesLocation ? previousCredential : nil) + let destinationCredential = credential?.resolvedCredential() ?? (changesLocation ? previousCredential : nil) + defer { + previousCredential?.clearSecrets() + destinationCredential?.clearSecrets() + } if let destinationCredential { - try keychain.setKeyCredential( + try keychain.setResolvedKeyCredential( destinationCredential, for: key.credentialRef, storage: key.storage ) } @@ -1494,7 +1499,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { key.credentialRef, storage: key.storage ) } else if destinationCredential != nil, let previousKey, let previousCredential { - try keychain.setKeyCredential( + try keychain.setResolvedKeyCredential( previousCredential, for: previousKey.credentialRef, storage: previousKey.storage ) @@ -1510,6 +1515,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { storageError ?? String(localized: "The key could not be saved.") ) } + if destinationCredential != nil { keyCredentialRevision &+= 1 } if changesLocation, let previousKey { do { try keychain.removeKeyCredential( @@ -1551,8 +1557,19 @@ public final class HostLibraryModel: RemoteSessionHostStore { try keychain.keyCredential(for: key.credentialRef, storage: key.storage) } + public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws { + guard !passphrase.isEmpty, let key = key(keyID), key.hasPassphrase, key.storage != .secureEnclave else { + throw TermindSSHError.missingCredential + } + let secret = SSHSecret(passphrase) + defer { secret.clear() } + try keychain.setKeyPassphrase(secret, for: key.credentialRef, storage: key.storage) + keyCredentialRevision &+= 1 + } + public func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? { - try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage) + _ = keyCredentialRevision + return try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage) } public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { @@ -1567,7 +1584,8 @@ public final class HostLibraryModel: RemoteSessionHostStore { public func delete(keyID: SSHKeyRef.ID) throws { guard let key = key(keyID) else { return } - let credential = try keyCredential(for: key) + let credential = try keySecretCredential(for: key) + defer { credential?.clearSecrets() } try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) let keySnapshot = keys let identitySnapshot = identities @@ -1579,7 +1597,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { keys = keySnapshot identities = identitySnapshot if let credential { - try keychain.setKeyCredential( + try keychain.setResolvedKeyCredential( credential, for: key.credentialRef, storage: key.storage ) } diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 21f95863..0dc12ae5 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -296,13 +296,15 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } return } - if server.authenticationPrompt?.requiresPassphrase == true, - var staged = submissions[server.id]?[host.id] - { - guard let passphrase, !passphrase.isEmpty else { return } - staged.passphrase = passphrase - submissions[server.id]?[host.id] = staged - retry(server) + if let prompt = server.authenticationPrompt, prompt.requiresPassphrase { + do { + submissions[server.id, default: [:]][host.id] = try hosts.passphraseSubmission( + for: prompt, existing: submissions[server.id]?[host.id], passphrase: passphrase, save: save) + retry(server) + } catch { + server.authenticationPrompt?.failureMessage = error.localizedDescription + server.status = error.localizedDescription + } return } let chosenKey = identity?.keyID ?? keyID @@ -367,7 +369,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { let keyID = staged?.keyID ?? identity?.keyID let passphrase = staged?.requiresPasswordReentry != true - && keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true + && keyID.flatMap { hosts.key($0) }?.hasPassphrase == true && staged?.passphrase == nil server.authenticationHost = host server.authenticationPrompt = SSHAuthenticationPromptResolver.host( @@ -730,7 +732,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { ) } if let passphraseHost = submissions[server.id]?.firstHostRequiringPassphrase( - in: route, key: hosts.key + in: route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry ) { throw TermindSSHError.routeHost( hostID: passphraseHost.id, authentication: true, diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index ee00c6ac..b55ba394 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -329,25 +329,39 @@ public nonisolated enum SSHCredentials: Sendable { } public nonisolated enum SSHResolvedKeyCredential: Sendable { - case software(SSHSecret) + case software(SSHSecret, passphrase: SSHSecret? = nil) case secureEnclave(SecureEnclaveKeyCredential) + public var hasSavedPassphrase: Bool { + if case .software(_, let passphrase) = self { return passphrase != nil && passphrase?.isConsumed == false } + return false + } + + public func clearSecrets() { + if case .software(let privateKey, let passphrase) = self { + privateKey.clear() + passphrase?.clear() + } + } + public func sshCredential( publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil, requiresPassphrase: Bool = false, requiresUserPresence: Bool = false ) -> SSHCredentials { switch self { - case .software(let privateKey): - .privateKey( + case .software(let privateKey, let savedPassphrase): + let hasSavedPassphrase = self.hasSavedPassphrase + if let passphraseOverride, savedPassphrase !== passphraseOverride { savedPassphrase?.clear() } + return .privateKey( privateKey: privateKey, publicKey: certificate.isEmpty ? (publicKey.isEmpty ? nil : publicKey) : certificate, - passphrase: passphraseOverride, - requiresPassphrase: requiresPassphrase + passphrase: passphraseOverride ?? savedPassphrase, + requiresPassphrase: requiresPassphrase && !hasSavedPassphrase ) case .secureEnclave(let credential): - .secureEnclave( + return .secureEnclave( credential: credential, publicKey: publicKey, requiresUserPresence: requiresUserPresence ) @@ -356,13 +370,23 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable { } nonisolated extension StoredSSHKeyCredential { + public func resolvedCredential() -> SSHResolvedKeyCredential { + switch self { + case .software(let secret): + .software(SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init)) + case .secureEnclave(let key): .secureEnclave(key) + } + } + public func sshCredential( publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil, requiresPassphrase: Bool = false, requiresUserPresence: Bool = false ) -> SSHCredentials { switch self { case .software(let secret): - SSHResolvedKeyCredential.software(SSHSecret(secret.privateKey)).sshCredential( + SSHResolvedKeyCredential.software( + SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init) + ).sshCredential( publicKey: publicKey, certificate: certificate, passphraseOverride: passphraseOverride, requiresPassphrase: requiresPassphrase, diff --git a/Frameworks/TermindApplication/SSHRouteEvent.swift b/Frameworks/TermindApplication/SSHRouteEvent.swift index f50fd19d..bf53ae13 100644 --- a/Frameworks/TermindApplication/SSHRouteEvent.swift +++ b/Frameworks/TermindApplication/SSHRouteEvent.swift @@ -131,13 +131,14 @@ nonisolated extension Dictionary where Key == Host.ID, Value == SSHAuthenticatio } public func firstHostRequiringPassphrase( - in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef? + in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef?, + requiresEntry: (SSHKeyRef) -> Bool = { $0.requiresPassphraseEntry } ) -> Host? { route.first { host in guard let submission = self[host.id], !submission.hasPassphrase, let keyID = submission.keyID else { return false } - return key(keyID)?.requiresPassphraseEntry == true + return key(keyID).map(requiresEntry) == true } } diff --git a/Frameworks/TermindApplication/SSHSecret.swift b/Frameworks/TermindApplication/SSHSecret.swift index 1c6df658..eab57229 100644 --- a/Frameworks/TermindApplication/SSHSecret.swift +++ b/Frameworks/TermindApplication/SSHSecret.swift @@ -26,9 +26,11 @@ public nonisolated final class SSHSecret: @unchecked Sendable { private struct Storage { var pointer: UnsafeMutableRawPointer var count: Int + var didZeroize: (@Sendable (UnsafeRawBufferPointer) -> Void)? func clearAndDeallocate() { _ = memset_s(pointer, count + 1, 0, count + 1) + didZeroize?(UnsafeRawBufferPointer(start: pointer, count: count + 1)) pointer.deallocate() } } @@ -63,6 +65,8 @@ public nonisolated final class SSHSecret: @unchecked Sendable { deinit { clear() } + public var byteCount: Int { lock.withLock { storage?.count ?? 0 } } + public var isConsumed: Bool { lock.withLock { storage == nil } } @@ -101,6 +105,12 @@ public nonisolated final class SSHSecret: @unchecked Sendable { storage.clearAndDeallocate() } + /// Internal test seam: observes the allocation only after memset_s and + /// before deallocation, never after the pointer has become invalid. + func observeZeroization(_ observer: @escaping @Sendable (UnsafeRawBufferPointer) -> Void) { + lock.withLock { storage?.didZeroize = observer } + } + private func take() throws -> Storage { try lock.withLock { guard let value = storage else { throw Consumed() } diff --git a/Frameworks/TermindApplication/SecretStoring.swift b/Frameworks/TermindApplication/SecretStoring.swift index 03f03fe3..e6ee4a1f 100644 --- a/Frameworks/TermindApplication/SecretStoring.swift +++ b/Frameworks/TermindApplication/SecretStoring.swift @@ -3,8 +3,10 @@ import TermindDomain public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable { public var privateKey: String - public init(privateKey: String) { + public var passphrase: String? + public init(privateKey: String, passphrase: String? = nil) { self.privateKey = privateKey + self.passphrase = passphrase } } @@ -23,10 +25,13 @@ public nonisolated protocol SecretStoring: SSHRuntimeSecretResolving { func keyCredential( for account: String, storage: KeyStorage ) throws -> StoredSSHKeyCredential? + func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool func setKeyCredential( _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage ) throws + func setResolvedKeyCredential(_ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage) + throws func removePassword(_ account: String, storage: PasswordStorage) throws func removeKeyCredential(_ account: String, storage: KeyStorage) throws } @@ -40,6 +45,39 @@ nonisolated extension SecretStoring { try keyCredential(for: account, storage: storage) != nil } + /// Default for in-memory/test stores. The Keychain adapter overrides this + /// to update its JSON envelope entirely through wipeable byte buffers. + public func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws { + defer { passphrase?.clear() } + guard case .software(var secret)? = try keyCredential(for: account, storage: storage) else { + throw TermindSSHError.missingCredential + } + secret.passphrase = try passphrase?.withUnsafeCStringAndConsume { bytes, count in + String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self) + } + try setKeyCredential(.software(secret), for: account, storage: storage) + } + + /// Test/in-memory fallback; persistent adapters must write the wipeable + /// credential directly, without first materializing String secrets. + public func setResolvedKeyCredential( + _ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage + ) throws { + defer { credential.clearSecrets() } + switch credential { + case .software(let key, let passphrase): + let text = try key.withUnsafeCStringAndConsume { bytes, count in + String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self) + } + let phrase = try passphrase?.withUnsafeCStringAndConsume { bytes, count in + String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self) + } + try setKeyCredential(.software(.init(privateKey: text, passphrase: phrase)), for: account, storage: storage) + case .secureEnclave(let key): + try setKeyCredential(.secureEnclave(key), for: account, storage: storage) + } + } + public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { try string(for: account, storage: storage).map(SSHSecret.init) } @@ -48,7 +86,8 @@ nonisolated extension SecretStoring { for account: String, storage: KeyStorage ) throws -> SSHResolvedKeyCredential? { switch try keyCredential(for: account, storage: storage) { - case .software(let secret): .software(SSHSecret(secret.privateKey)) + case .software(let secret): + .software(SSHSecret(secret.privateKey), passphrase: secret.passphrase.map(SSHSecret.init)) case .secureEnclave(let credential): .secureEnclave(credential) case nil: nil } diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index 30686ef4..9a864999 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -111,9 +111,13 @@ public struct SSHAuthenticationForm: View { if prompt.keyboardInteractive == nil, let keyboard { keyboard.bridge( canSubmit: canContinue, - submit: { commit(save: !needsPassphrase) }, cancel: cancel) + submit: { commit(save: prompt.allowsSaving) }, cancel: cancel) } } + .onDisappear { + password = "" + passphrase = "" + } .onAppear { if identityID != nil, selectedIdentity == nil { identityID = nil } loadPassword(for: selectedIdentity ?? initialCustomIdentity) @@ -226,43 +230,29 @@ public struct SSHAuthenticationForm: View { @ViewBuilder private var actions: some View { - if needsPassphrase { - ViewThatFits(in: .horizontal) { - HStack { - cancelButton - Spacer() - passphraseContinueButton - } - VStack(spacing: 10) { - cancelButton.frame(maxWidth: .infinity, alignment: .leading) - passphraseContinueButton.frame(maxWidth: .infinity, alignment: .trailing) - } + ViewThatFits(in: .horizontal) { + HStack { + cancelButton + Spacer() + continueButton + if prompt.allowsSaving { saveAndContinueButton } } - } else { - ViewThatFits(in: .horizontal) { + VStack(spacing: 10) { HStack { cancelButton Spacer() continueButton - if prompt.allowsSaving { saveAndContinueButton } } - VStack(spacing: 10) { + #if os(iOS) HStack { - cancelButton Spacer() - continueButton + if prompt.allowsSaving { saveAndContinueButton } } - #if os(iOS) - HStack { - Spacer() - if prompt.allowsSaving { saveAndContinueButton } - } - #else - if prompt.allowsSaving { - saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing) - } - #endif - } + #else + if prompt.allowsSaving { + saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing) + } + #endif } } } @@ -272,7 +262,16 @@ public struct SSHAuthenticationForm: View { .keyboardShortcut(.cancelAction) } + @ViewBuilder private var continueButton: some View { + if prompt.allowsSaving { + baseContinueButton + } else { + baseContinueButton.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + } + } + + private var baseContinueButton: some View { Button { commit(save: false) } label: { @@ -281,17 +280,11 @@ public struct SSHAuthenticationForm: View { .disabled(!canContinue) } - private var passphraseContinueButton: some View { - continueButton - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - } - private var saveAndContinueButton: some View { Button { commit(save: true) } label: { - ConnectionActionLabel(title: "Save and Continue", symbol: "checkmark") + ConnectionActionLabel(title: AppStrings.Keychain.saveAndContinue, symbol: "checkmark") } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) @@ -309,7 +302,8 @@ public struct SSHAuthenticationForm: View { } private func commit(save: Bool) { - let submittedPassword = save ? password : (password.isEmpty ? nil : password) + let save = save && prompt.allowsSaving + let submittedPassword = needsPassphrase ? nil : (save ? password : (password.isEmpty ? nil : password)) let submittedPassphrase = passphrase.isEmpty ? nil : passphrase password = "" passphrase = "" diff --git a/Frameworks/TermindPresentation/Strings/Keychain.swift b/Frameworks/TermindPresentation/Strings/Keychain.swift index 1284fade..75651cc4 100644 --- a/Frameworks/TermindPresentation/Strings/Keychain.swift +++ b/Frameworks/TermindPresentation/Strings/Keychain.swift @@ -2,6 +2,9 @@ import Foundation extension AppStrings { public nonisolated enum Keychain { + public static var savePassphrase: LocalizedStringResource { "Save Passphrase" } + public static var saveAndContinue: LocalizedStringResource { "Save and Continue" } + public static var addAndConnectToAHostBeforeExportingAKeyToIt: LocalizedStringResource { "Add and connect to a host before exporting a key to it." } @@ -206,6 +209,8 @@ extension AppStrings { "No secret" } + public static var passphraseSaved: LocalizedStringResource { "Passphrase Saved" } + public static var passphraseRequired: LocalizedStringResource { "Passphrase Required" } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index c9638a97..6eb630ad 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -311,19 +311,15 @@ public final class AgentHostResolver: Sendable { case .cancelled, .keyboardInteractive: throw AgentToolError.cancelled case .submit(let answer): - if case .passphrase = requirement.mode, - var retained = submissions[node.id] - { - // A protected key may have been chosen beside a password - // in the preceding Identity step. The passphrase card only - // returns the newly-entered secret, so merge it into that - // staged submission instead of replacing (and silently - // dropping) the password and other choices. - guard let passphrase = answer.passphrase, !passphrase.isEmpty else { - throw TermindSSHError.keyPassphraseRequired + if case .passphrase = requirement.mode { + do { + submissions[node.id] = try store.passphraseSubmission( + for: request.journeyPrompt, existing: submissions[node.id], + passphrase: answer.passphrase, save: answer.save) + } catch { + failure = error.localizedDescription + continue } - retained.passphrase = passphrase - submissions[node.id] = retained } else { submissions[node.id] = try submission(from: answer, for: node) } @@ -414,7 +410,9 @@ public final class AgentHostResolver: Sendable { guard forcedHostID == nil || forcedHostID == host.id else { return nil } if let submission = submissions[host.id] { if let keyID = submission.keyID, - store.key(keyID)?.requiresPassphraseEntry == true, + store.key(keyID).map({ + (forcedHostID == host.id && $0.hasPassphrase) || store.requiresPassphraseEntry(for: $0) + }) == true, submission.passphrase?.isEmpty != false { return AuthenticationRequirement(mode: .passphrase(keyID: keyID)) @@ -430,6 +428,9 @@ public final class AgentHostResolver: Sendable { guard let identity = store.identity(for: host) else { return AuthenticationRequirement(mode: .identity) } + if forcedHostID == host.id, let keyID = identity.keyID, store.key(keyID)?.hasPassphrase == true { + return AuthenticationRequirement(mode: .passphrase(keyID: keyID)) + } let resolution = store.sshCredentials(for: identity) if let keyID = resolution.requiredPassphraseKeyID { return AuthenticationRequirement(mode: .passphrase(keyID: keyID)) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index e0fba2e8..f268f97c 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -282,10 +282,16 @@ public final class PortForwardingController { } return } - if prompt.requiresPassphrase, var staged = context.submissions[host.id] { - guard let passphrase, !passphrase.isEmpty else { return } - staged.passphrase = passphrase - context.submissions[host.id] = staged + if prompt.requiresPassphrase { + do { + context.submissions[host.id] = try hosts.passphraseSubmission( + for: prompt, existing: context.submissions[host.id], passphrase: passphrase, save: save) + } catch { + var failed = prompt + failed.failureMessage = error.localizedDescription + context.setPrompt(failed) + return + } } else { let chosenKey = identity?.keyID ?? keyID if save { @@ -374,7 +380,7 @@ public final class PortForwardingController { return } if let passphraseHost = context.submissions.firstHostRequiringPassphrase( - in: context.route, key: hosts.key + in: context.route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry ) { requestAuthentication( context, @@ -549,7 +555,7 @@ public final class PortForwardingController { let keyID = staged?.keyID ?? identity?.keyID let passphrase = staged?.requiresPasswordReentry != true - && keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true + && keyID.flatMap(hosts.key)?.hasPassphrase == true && staged?.passphrase == nil context.setPrompt( SSHAuthenticationPromptResolver.host( diff --git a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift index 72f091d1..e09714e5 100644 --- a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift +++ b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift @@ -90,11 +90,12 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { { try validate(account, prefix: "termind.key.") guard - let data = try data( + var data = try data( for: account, service: service(for: storage), synchronizable: storage == .iCloud ) else { return nil } + defer { SecretMemory.clear(&data) } switch storage { case .thisDevice, .iCloud: return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data)) @@ -125,7 +126,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { defer { SecretMemory.clear(&data) } switch storage { case .thisDevice, .iCloud: - return .software(try Self.decodePrivateKeySecret(from: &data)) + return try Self.decodeKeySecrets(from: &data) case .secureEnclave: guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data)) @@ -159,90 +160,222 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { ) } - /// Strictly decodes the legacy single-field JSON envelope without creating - /// an immutable Swift `String` containing the private key. Corrupted shapes - /// fail closed instead of being searched for a matching field substring. - static func decodePrivateKeySecret(from data: inout Data) throws -> SSHSecret { - defer { SecretMemory.clear(&data) } - var index = data.startIndex - func skipWhitespace() { - while index < data.endIndex, [0x20, 0x09, 0x0A, 0x0D].contains(data[index]) { - index = data.index(after: index) - } + public func setResolvedKeyCredential( + _ credential: SSHResolvedKeyCredential, for account: String, storage: KeyStorage + ) throws { + defer { credential.clearSecrets() } + try validate(account, prefix: "termind.key.") + var encoded: Data + switch (credential, storage) { + case (.software(let key, let passphrase), .thisDevice), (.software(let key, let passphrase), .iCloud): + encoded = try Self.encodeKeySecrets(privateKey: key, passphrase: passphrase) + case (.secureEnclave(let key), .secureEnclave): encoded = key.dataRepresentation + default: throw KeychainSecretError.unexpectedValue } - func consume(_ expected: UInt8) throws { - guard index < data.endIndex, data[index] == expected else { - throw KeychainSecretError.unexpectedValue - } - index = data.index(after: index) - } - - skipWhitespace() - try consume(0x7B) - skipWhitespace() - for expected in Array("\"privateKey\"".utf8) { - try consume(expected) - } - skipWhitespace() - try consume(0x3A) - skipWhitespace() - try consume(0x22) - - var result = Data() - defer { SecretMemory.clear(&result) } - while index < data.endIndex { - let byte = data[index] - index = data.index(after: index) - if byte == 0x22 { - skipWhitespace() - try consume(0x7D) - skipWhitespace() - guard index == data.endIndex else { - throw KeychainSecretError.unexpectedValue - } - return SSHSecret(consumingUTF8: &result) - } - guard byte == 0x5C else { - guard byte >= 0x20 else { throw KeychainSecretError.unexpectedValue } - result.append(byte) - continue - } - guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue } - let escape = data[index] - index = data.index(after: index) - switch escape { - case 0x22, 0x5C, 0x2F: result.append(escape) - case 0x62: result.append(0x08) - case 0x66: result.append(0x0C) - case 0x6E: result.append(0x0A) - case 0x72: result.append(0x0D) - case 0x74: result.append(0x09) - case 0x75: - var value: UInt32 = 0 - for _ in 0..<4 { - guard index < data.endIndex, let digit = hex(data[index]) else { - throw KeychainSecretError.unexpectedValue - } - value = value * 16 + UInt32(digit) - index = data.index(after: index) - } - guard let scalar = Unicode.Scalar(value), !(0xD800...0xDFFF).contains(value) else { - throw KeychainSecretError.unexpectedValue - } - result.append(contentsOf: String(scalar).utf8) - default: - throw KeychainSecretError.unexpectedValue - } - } - throw KeychainSecretError.unexpectedValue + defer { SecretMemory.clear(&encoded) } + try setData( + encoded, for: account, service: service(for: storage), synchronizable: storage == .iCloud, + accessible: storage == .iCloud + ? kSecAttrAccessibleWhenUnlocked : kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ) } - private static func hex(_ byte: UInt8) -> UInt8? { - switch byte { - case 0x30...0x39: byte - 0x30 - case 0x41...0x46: byte - 0x41 + 10 - case 0x61...0x66: byte - 0x61 + 10 - default: nil + public func setKeyPassphrase(_ passphrase: SSHSecret?, for account: String, storage: KeyStorage) throws { + defer { passphrase?.clear() } + guard storage != .secureEnclave, + let credential = try sshKeyCredential(for: account, storage: storage), + case .software(let privateKey, _) = credential + else { throw KeychainSecretError.unexpectedValue } + defer { credential.clearSecrets() } + var encoded = try Self.encodeKeySecrets(privateKey: privateKey, passphrase: passphrase) + defer { SecretMemory.clear(&encoded) } + try setData( + encoded, for: account, service: service(for: storage), synchronizable: storage == .iCloud, + accessible: storage == .iCloud + ? kSecAttrAccessibleWhenUnlocked : kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ) + } + + /// The editor's legacy envelope and the runtime reader share the same wire + /// format. Secret values never become immutable Strings in the runtime path. + static func encodeKeySecrets(privateKey: SSHSecret, passphrase: SSHSecret?) throws -> Data { + defer { + privateKey.clear() + passphrase?.clear() + } + var output = Data() + var succeeded = false + defer { if !succeeded { SecretMemory.clear(&output) } } + func append(_ secret: SSHSecret) throws { + output.append(0x22) + try secret.withUnsafeCStringAndConsume { pointer, count in + for byte in UnsafeRawBufferPointer(start: pointer, count: count) { + switch byte { + case 0x22, 0x5C: output.append(contentsOf: [0x5C, byte]) + case 0..<0x20: + let digits = Array("0123456789abcdef".utf8) + output.append(contentsOf: [ + 0x5C, 0x75, 0x30, 0x30, digits[Int(byte >> 4)], digits[Int(byte & 15)], + ]) + default: output.append(byte) + } + } + } + output.append(0x22) + } + // Reserve an upper bound before writing any plaintext, avoiding Data + // reallocation while appending escaped secret bytes. + output.reserveCapacity(64 + 6 * (privateKey.byteCount + (passphrase?.byteCount ?? 0))) + output.append(contentsOf: "{\"privateKey\":".utf8) + try append(privateKey) + if let passphrase { + output.append(contentsOf: ",\"passphrase\":".utf8) + try append(passphrase) + } + output.append(0x7D) + succeeded = true + return output + } + + static func decodePrivateKeySecret(from data: inout Data) throws -> SSHSecret { + let credential = try decodeKeySecrets(from: &data) + guard case .software(let key, let passphrase) = credential else { + throw KeychainSecretError.unexpectedValue + } + passphrase?.clear() + return key + } + + static func decodeKeySecrets( + from data: inout Data, observeSecret: ((SSHSecret) -> Void)? = nil + ) throws -> SSHResolvedKeyCredential { + defer { SecretMemory.clear(&data) } + var index = data.startIndex + var privateKey: SSHSecret? + var passphrase: SSHSecret? + var succeeded = false + defer { + if !succeeded { + privateKey?.clear() + passphrase?.clear() + } + } + func skipWhitespace() { + while index < data.endIndex, [0x20, 0x09, 0x0A, 0x0D].contains(data[index]) { index += 1 } + } + func consume(_ expected: UInt8) throws { + guard index < data.endIndex, data[index] == expected else { throw KeychainSecretError.unexpectedValue } + index += 1 + } + func hexUnit() throws -> UInt32 { + var value: UInt32 = 0 + for _ in 0..<4 { + guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue } + let byte = data[index] + let digit: UInt8 + switch byte { + case 0x30...0x39: digit = byte - 0x30 + case 0x41...0x46: digit = byte - 0x41 + 10 + case 0x61...0x66: digit = byte - 0x61 + 10 + default: throw KeychainSecretError.unexpectedValue + } + value = value * 16 + UInt32(digit) + index += 1 + } + return value + } + func readString() throws -> SSHSecret { + try consume(0x22) + var bytes = Data() + bytes.reserveCapacity(data.count) + defer { SecretMemory.clear(&bytes) } + while index < data.endIndex { + let byte = data[index] + index += 1 + if byte == 0x22 { + func validateUTF8() throws { + var decoder = Unicode.UTF8() + var iterator = bytes.makeIterator() + while true { + switch decoder.decode(&iterator) { + case .scalarValue: continue + case .emptyInput: return + case .error: throw KeychainSecretError.unexpectedValue + } + } + } + try validateUTF8() + let secret = SSHSecret(consumingUTF8: &bytes) + observeSecret?(secret) + return secret + } + guard byte == 0x5C else { + guard byte >= 0x20 else { throw KeychainSecretError.unexpectedValue } + bytes.append(byte) + continue + } + guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue } + let escape = data[index] + index += 1 + switch escape { + case 0x22, 0x5C, 0x2F: bytes.append(escape) + case 0x62: bytes.append(0x08) + case 0x66: bytes.append(0x0C) + case 0x6E: bytes.append(0x0A) + case 0x72: bytes.append(0x0D) + case 0x74: bytes.append(0x09) + case 0x75: + var scalar = try hexUnit() + if (0xD800...0xDBFF).contains(scalar) { + try consume(0x5C) + try consume(0x75) + let low = try hexUnit() + guard (0xDC00...0xDFFF).contains(low) else { throw KeychainSecretError.unexpectedValue } + scalar = 0x10000 + ((scalar - 0xD800) << 10) + low - 0xDC00 + } + guard let value = Unicode.Scalar(scalar) else { throw KeychainSecretError.unexpectedValue } + Unicode.UTF8.encode(value) { bytes.append($0) } + default: throw KeychainSecretError.unexpectedValue + } + } + throw KeychainSecretError.unexpectedValue + } + skipWhitespace() + try consume(0x7B) + var sawPassphrase = false + while true { + skipWhitespace() + let name = try readString() + defer { name.clear() } + skipWhitespace() + try consume(0x3A) + skipWhitespace() + if name.matches("privateKey"), privateKey == nil { + privateKey = try readString() + } else if name.matches("passphrase"), !sawPassphrase { + sawPassphrase = true + if index < data.endIndex, data[index] == 0x6E { + for byte in Array("null".utf8) { try consume(byte) } + } else { + passphrase = try readString() + if passphrase?.byteCount == 0 { + passphrase?.clear() + passphrase = nil + } + } + } else { + throw KeychainSecretError.unexpectedValue + } + skipWhitespace() + if index < data.endIndex, data[index] == 0x2C { + index += 1 + continue + } + try consume(0x7D) + skipWhitespace() + guard index == data.endIndex, let privateKey else { throw KeychainSecretError.unexpectedValue } + succeeded = true + return .software(privateKey, passphrase: passphrase) } } diff --git a/TFI/Application/FeatureModels/MobileKeyEditorModel.swift b/TFI/Application/FeatureModels/MobileKeyEditorModel.swift index 95fe011a..bd25a747 100644 --- a/TFI/Application/FeatureModels/MobileKeyEditorModel.swift +++ b/TFI/Application/FeatureModels/MobileKeyEditorModel.swift @@ -29,8 +29,17 @@ final class MobileKeyEditorModel: Identifiable { var cipher = SSHKeyMaterial.Cipher.aes256 var rounds = 100 var confirmation = "" - var privateKey = "" + var privateKey = "" { + didSet { + if privateKey != originalPrivateKey, oldValue == originalPrivateKey, !originalPassphrase.isEmpty { + passphrase = "" + } + } + } var passphrase = "" + var savePassphrase = true + private var originalPassphrase = "" + private var originalSavePassphrase = true var certificate: String var requiresUserPresence = false private(set) var isLoadingPrivateKey: Bool @@ -49,6 +58,8 @@ final class MobileKeyEditorModel: Identifiable { self.mode = mode self.credentials = credentials original = key + savePassphrase = mode != .edit + originalSavePassphrase = mode != .edit label = key?.label ?? "" storage = key?.storage ?? defaultStorage certificate = key?.certificate ?? "" @@ -76,7 +87,9 @@ final class MobileKeyEditorModel: Identifiable { var isDirty: Bool { label != (original?.label ?? "") || storage != (original?.storage ?? .iCloud) - || certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey || !passphrase.isEmpty + || certificate != (original?.certificate ?? "") || privateKey != originalPrivateKey + || passphrase != originalPassphrase + || savePassphrase != originalSavePassphrase || algorithm != .ed25519 || requiresUserPresence || colorTag != (original?.colorTag ?? .none) || curveBits != 256 || rsaBits != 3072 || cipher != .aes256 || rounds != 100 || !confirmation.isEmpty } @@ -154,6 +167,10 @@ final class MobileKeyEditorModel: Identifiable { } privateKey = secret.privateKey originalPrivateKey = secret.privateKey + passphrase = secret.passphrase ?? "" + originalPassphrase = passphrase + savePassphrase = !passphrase.isEmpty + originalSavePassphrase = savePassphrase didLoadPrivateKey = true } catch { privateKeyLoadError = error.localizedDescription @@ -259,7 +276,9 @@ final class MobileKeyEditorModel: Identifiable { key.keySize = material.keySize key.hasPassphrase = enteredPassphrase != nil || (keepsOriginalPrivateKey && key.hasPassphrase) try credentials.upsert( - key, credential: .software(SSHPrivateKeySecret(privateKey: input)) + key, + credential: .software( + SSHPrivateKeySecret(privateKey: input, passphrase: savePassphrase ? enteredPassphrase : nil)) ) clearSensitiveDrafts() return @@ -304,17 +323,20 @@ final class MobileKeyEditorModel: Identifiable { certificate: normalizedCertificate, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: storage, keySize: material.keySize, hasPassphrase: passphrase != nil, createdAt: .now, colorTag: colorTag) - secret = .software(SSHPrivateKeySecret(privateKey: material.privateKey)) + secret = .software( + SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil)) } try credentials.upsert(key, credential: secret) clearSensitiveDrafts() } - private func clearSensitiveDrafts() { + func clearSensitiveDrafts() { + didLoadPrivateKey = mode != .edit privateKey = "" originalPrivateKey = "" passphrase = "" confirmation = "" + originalPassphrase = "" inspectedMaterial = nil } } diff --git a/TFI/Features/Keychain/MobileCredentialsView.swift b/TFI/Features/Keychain/MobileCredentialsView.swift index a016ba75..476c2765 100644 --- a/TFI/Features/Keychain/MobileCredentialsView.swift +++ b/TFI/Features/Keychain/MobileCredentialsView.swift @@ -193,7 +193,10 @@ private struct MobileKeyCredentialCard: View { @ViewBuilder private var primaryChips: some View { MobileCardChip(text: keyDisplayName) if key.hasPassphrase { - MobileCardChip(title: AppStrings.Keychain.passphraseRequired, symbol: "lock.fill") + MobileCardChip( + title: credentials.hasSavedPassphrase(for: key) + ? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired, + symbol: "lock.fill") } } diff --git a/TFI/Features/Keychain/MobileKeyEditor.swift b/TFI/Features/Keychain/MobileKeyEditor.swift index 03fcb2a5..803c9e9d 100644 --- a/TFI/Features/Keychain/MobileKeyEditor.swift +++ b/TFI/Features/Keychain/MobileKeyEditor.swift @@ -53,6 +53,8 @@ struct MobileKeyEditor: View { RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $model.confirmation) .multilineTextAlignment(.trailing).disabled(model.passphrase.isEmpty) } + Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase) + .disabled(model.passphrase.isEmpty) if !model.passphrase.isEmpty { Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $model.cipher) { ForEach(SSHKeyMaterial.Cipher.allCases) { Text($0.displayName).tag($0) } @@ -106,6 +108,8 @@ struct MobileKeyEditor: View { RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.passphrase) .multilineTextAlignment(.leading) .disabled(!model.canEditPrivateKey) + Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase) + .disabled(!model.canEditPrivateKey || model.passphrase.isEmpty) } } } else { @@ -159,6 +163,7 @@ struct MobileKeyEditor: View { } message: { Text(fileError ?? "") } + .onDisappear { model.clearSensitiveDrafts() } .task { await model.loadPrivateKey() } .task(id: model.validationToken) { await model.derivePublicMaterial() } } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index ad70ab95..de24a435 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -387,10 +387,15 @@ final class MobileWorkspace: Identifiable { connect() return } - if authenticationPrompt?.requiresPassphrase == true, var staged = submissions[host.id] { - guard let passphrase, !passphrase.isEmpty else { return } - staged.passphrase = passphrase - submissions[host.id] = staged + if let prompt = authenticationPrompt, prompt.requiresPassphrase { + do { + submissions[host.id] = try hosts.passphraseSubmission( + for: prompt, existing: submissions[host.id], passphrase: passphrase, save: save) + } catch { + connectionFailure = error.localizedDescription + authenticationPrompt?.failureMessage = error.localizedDescription + return + } } else { let chosenKey = identity?.keyID ?? keyID if save { @@ -684,7 +689,7 @@ final class MobileWorkspace: Identifiable { return } if let passphraseHost = submissions.firstHostRequiringPassphrase( - in: route, key: hosts.key + in: route, key: hosts.key, requiresEntry: hosts.requiresPassphraseEntry ) { authenticationRequired = true authenticationMode = .localIdentity diff --git a/TFITests/MobilePassphraseFormLayoutTests.swift b/TFITests/MobilePassphraseFormLayoutTests.swift new file mode 100644 index 00000000..11704cb0 --- /dev/null +++ b/TFITests/MobilePassphraseFormLayoutTests.swift @@ -0,0 +1,73 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite(.serialized) @MainActor +struct MobilePassphraseFormLayoutTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0]) + func passphraseActionsFitCompactAndRegularWidths(language: String, width: Double) throws { + let host = TermindDomain.Host(name: "Production", host: "server.example.invalid") + let prompt = SSHAuthenticationPrompt(host: host, username: "deploy", requiresPassphrase: true) + let controller = UIHostingController( + rootView: SSHAuthenticationForm( + prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: HostLibraryModel() + ).environment(\.locale, Locale(identifier: language)).frame(width: width)) + let size = controller.sizeThatFits(in: CGSize(width: width, height: 1000)) + #expect(abs(size.width - width) < 1) + #expect(size.height > 100 && size.height < 500) + controller.view.frame = CGRect(origin: .zero, size: size) + controller.view.layoutIfNeeded() + } + + @Test( + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"), + arguments: ["en", "zh-Hans"], [false, true]) + func rendersPassphraseActionsInANativeWindow(language: String, showKeys: Bool) async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let host = TermindDomain.Host(name: "Production", host: "server.example.invalid") + let prompt = SSHAuthenticationPrompt(host: host, username: "deploy", requiresPassphrase: true) + let app = try MobileAppModel(inMemory: true) + for saved in [false, true] { + let key = SSHKeyRef( + label: saved ? "Saved Key" : "Unsaved Key", publicKey: "ssh-ed25519 fixture", + credentialRef: "termind.key.\(UUID())", hasPassphrase: true) + try app.credentials.upsert( + key, credential: .software(.init(privateKey: "fixture", passphrase: saved ? "fixture-phrase" : nil))) + } + let controller = UIHostingController( + rootView: Group { + if showKeys { + NavigationStack { MobileCredentialsView(app: app) } + } else { + SSHAuthenticationForm( + prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: app.library + ).frame(width: 320).padding() + } + }.environment(\.locale, Locale(identifier: language))) + let window = UIWindow(windowScene: scene) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(300)) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let data = try #require(image.pngData()) + #expect(data.count > 10000) + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent( + "termind-passphrase-ios-\(language)-\(showKeys ? "keys" : "form").png") + try data.write(to: url) + print("Passphrase preview: \(url.path)") + } +} diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 59e59e18..cc2b3194 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -379,6 +379,72 @@ struct MobileVaultFeatureTests { app.removeScene(sceneID) } + @Test func savedPassphraseDefaultsAndEditingPreserveOrRemoveTheSavedValue() async throws { + let app = try MobileAppModel(inMemory: true) + let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials) + #expect(generator.savePassphrase) + generator.passphrase = "test-only-passphrase" + generator.confirmation = generator.passphrase + try await generator.save() + let original = try #require(app.credentials.keys.first) + let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original) + await editor.loadPrivateKey() + #expect(editor.savePassphrase && !editor.isDirty) + #expect(editor.passphrase == "test-only-passphrase") + editor.label = "Renamed" + try await editor.save() + guard case .software(let saved)? = try app.credentials.keyCredential(for: original) else { + Issue.record("Expected saved key") + return + } + #expect(saved.passphrase == "test-only-passphrase") + let updated = try #require(app.credentials.key(original.id)) + let remover = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: updated) + await remover.loadPrivateKey() + remover.savePassphrase = false + #expect(remover.isDirty) + try await remover.save() + guard case .software(let removed)? = try app.credentials.keyCredential(for: updated) else { + Issue.record("Expected saved key") + return + } + #expect(removed.passphrase == nil) + #expect(removed.privateKey == saved.privateKey) + #expect(app.credentials.key(original.id)?.hasPassphrase == true) + let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials) + #expect(importer.savePassphrase) + importer.privateKey = saved.privateKey + importer.passphrase = "test-only-passphrase" + importer.savePassphrase = false + try await importer.save() + let imported = try #require(app.credentials.keys.first { $0.id != original.id }) + guard case .software(let importedSecret)? = try app.credentials.keyCredential(for: imported) else { + Issue.record("Expected imported key") + return + } + #expect(importedSecret.passphrase == nil) + } + + @Test func replacingPrivateKeyDoesNotReuseItsSavedPassphrase() async throws { + let app = try MobileAppModel(inMemory: true) + let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials) + generator.passphrase = "old-passphrase" + generator.confirmation = generator.passphrase + try await generator.save() + let key = try #require(app.credentials.keys.first) + let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key) + await editor.loadPrivateKey() + editor.privateKey = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: nil).privateKey + #expect(editor.passphrase.isEmpty) + try await editor.save() + guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else { + Issue.record("Expected replacement key") + return + } + #expect(secret.passphrase == nil) + #expect(app.credentials.key(key.id)?.hasPassphrase == false) + } + @Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws { let app = try MobileAppModel(inMemory: true) let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials) diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index 1148e2cf..e6d5444d 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -437,7 +437,7 @@ struct KeychainImportDetail: View { private func save( name: String, colorTag: TagColor, storage: KeyStorage, privateKey: String, - passphrase: String?, certificate: String, material: SSHKeyMaterial + passphrase: String?, savePassphrase: Bool, certificate: String, material: SSHKeyMaterial ) async throws -> SSHKeyRef { let key = SSHKeyRef( label: name, @@ -450,7 +450,7 @@ struct KeychainImportDetail: View { try app.credentials.upsert( key, credential: .software( - SSHPrivateKeySecret(privateKey: privateKey) + SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil) ) ) window.selectVaultItem(.key(key.id), on: .keychain) @@ -468,11 +468,11 @@ private struct SoftwareKeyEditor: View { let title: LocalizedStringResource let symbol: String let close: () -> Void - let loadPrivateKey: (() throws -> String)? + let loadPrivateKey: (() throws -> SSHPrivateKeySecret)? let save: ( _ name: String, _ colorTag: TagColor, _ storage: KeyStorage, _ privateKey: String, - _ passphrase: String?, _ certificate: String, _ material: SSHKeyMaterial + _ passphrase: String?, _ savePassphrase: Bool, _ certificate: String, _ material: SSHKeyMaterial ) async throws -> SSHKeyRef let saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? let delete: (() throws -> Void)? @@ -483,6 +483,9 @@ private struct SoftwareKeyEditor: View { @State private var privateKey = "" @State private var originalPrivateKey = "" @State private var passphrase = "" + @State private var originalPassphrase = "" + @State private var savePassphrase = true + @State private var originalSavePassphrase = true @State private var certificate: String @State private var material: SSHKeyMaterial? @State private var isLoading: Bool @@ -502,12 +505,12 @@ private struct SoftwareKeyEditor: View { existing: SSHKeyRef? = nil, title: LocalizedStringResource, symbol: String, close: @escaping () -> Void, defaultStorage: KeyStorage = .iCloud, - loadPrivateKey: (() throws -> String)? = nil, + loadPrivateKey: (() throws -> SSHPrivateKeySecret)? = nil, saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? = nil, delete: (() throws -> Void)? = nil, save: @escaping ( - String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial + String, TagColor, KeyStorage, String, String?, Bool, String, SSHKeyMaterial ) async throws -> SSHKeyRef ) { self.existing = existing @@ -579,6 +582,8 @@ private struct SoftwareKeyEditor: View { prompt: Text(AppStrings.Common.optional) ) .disabled(!canEditPrivateKey) + Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase) + .disabled(!canEditPrivateKey || passphrase.isEmpty) } header: { fileSectionHeader(AppStrings.Keychain.privateKey, canChoose: canEditPrivateKey) { chooseFile(for: .privateKey) @@ -661,7 +666,18 @@ private struct SoftwareKeyEditor: View { await commitChanges() } } + .onChange(of: privateKey) { old, value in + if value != originalPrivateKey, old == originalPrivateKey, !originalPassphrase.isEmpty { + passphrase = "" + } + } .onDisappear { + privateKey = "" + originalPrivateKey = "" + passphrase = "" + originalPassphrase = "" + material = nil + isLoading = loadPrivateKey != nil guard let editingToken else { return } editingSession.unregister(editingToken) } @@ -735,7 +751,9 @@ private struct SoftwareKeyEditor: View { private var isDirty: Bool { guard let existing else { return !name.isEmpty || !privateKey.isEmpty || !certificate.isEmpty } return name != existing.label || colorTag != existing.colorTag || storage != existing.storage - || privateKey != originalPrivateKey || !passphrase.isEmpty || certificate != existing.certificate + || privateKey != originalPrivateKey || passphrase != originalPassphrase + || savePassphrase != originalSavePassphrase + || certificate != existing.certificate } private func cancelEditing() { @@ -786,7 +804,7 @@ private struct SoftwareKeyEditor: View { } _ = try await save( name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, storage, - privateKey, passphrase, normalizedCertificate, inspected + privateKey, passphrase, savePassphrase, normalizedCertificate, inspected ) isWorking = false return true @@ -845,8 +863,13 @@ private struct SoftwareKeyEditor: View { private func load() { guard isLoading else { return } do { - privateKey = try loadPrivateKey?() ?? "" + let secret = try loadPrivateKey?() + privateKey = secret?.privateKey ?? "" originalPrivateKey = privateKey + passphrase = secret?.passphrase ?? "" + originalPassphrase = passphrase + savePassphrase = !passphrase.isEmpty + originalSavePassphrase = savePassphrase } catch { errorMessage = error.localizedDescription canEditPrivateKey = false @@ -942,6 +965,7 @@ struct KeychainGenerateDetail: View { private func generate( label: String, colorTag: TagColor, generation: SSHKeyMaterial.Generation, passphrase: String?, + savePassphrase: Bool, cipher: SSHKeyMaterial.Cipher, rounds: Int, storage: KeyStorage, requiresUserPresence: Bool ) async throws -> SSHKeyRef { @@ -979,7 +1003,7 @@ struct KeychainGenerateDetail: View { try app.credentials.upsert( key, credential: .software( - SSHPrivateKeySecret(privateKey: material.privateKey) + SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil) ) ) } @@ -999,7 +1023,7 @@ private struct GenerateKeySheet: View { let generate: ( - String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int, + String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int, KeyStorage, Bool ) async throws -> SSHKeyRef let close: () -> Void @@ -1011,6 +1035,7 @@ private struct GenerateKeySheet: View { @State private var curveBits = 256 @State private var rsaBits = 3072 @State private var passphrase = "" + @State private var savePassphrase = true @State private var confirmation = "" @State private var cipher = SSHKeyMaterial.Cipher.aes256 @State private var rounds = 100 @@ -1028,7 +1053,7 @@ private struct GenerateKeySheet: View { defaultStorage: KeyStorage = .iCloud, generate: @escaping ( - String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int, + String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int, KeyStorage, Bool ) async throws -> SSHKeyRef, close: @escaping () -> Void @@ -1097,6 +1122,8 @@ private struct GenerateKeySheet: View { prompt: Text(AppStrings.Common.optional)) RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $confirmation) .disabled(passphrase.isEmpty) + Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase) + .disabled(passphrase.isEmpty) if !passphrase.isEmpty { Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $cipher) { ForEach(SSHKeyMaterial.Cipher.allCases) { cipher in @@ -1136,6 +1163,10 @@ private struct GenerateKeySheet: View { } .padding() } + .onDisappear { + passphrase = "" + confirmation = "" + } } @MainActor @@ -1146,9 +1177,11 @@ private struct GenerateKeySheet: View { _ = try await generate( label.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, generation, storage == .secureEnclave || passphrase.isEmpty ? nil : passphrase, - cipher, rounds, storage, + savePassphrase, cipher, rounds, storage, storage == .secureEnclave && requiresUserPresence ) + passphrase = "" + confirmation = "" close() } catch { errorMessage = error.localizedDescription @@ -1196,10 +1229,10 @@ struct KeychainEditDetail: View { }, delete: { try delete(key) }, save: { - name, colorTag, storage, privateKey, passphrase, certificate, material in + name, colorTag, storage, privateKey, passphrase, savePassphrase, certificate, material in try await saveSoftwareKey( key, name: name, colorTag: colorTag, storage: storage, - privateKey: privateKey, passphrase: passphrase, + privateKey: privateKey, passphrase: passphrase, savePassphrase: savePassphrase, certificate: certificate, material: material ) } @@ -1210,13 +1243,13 @@ struct KeychainEditDetail: View { } } - private func loadPrivateKey(_ key: SSHKeyRef) throws -> String { + private func loadPrivateKey(_ key: SSHKeyRef) throws -> SSHPrivateKeySecret { guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else { throw TermindSSHError.library( String(localized: AppStrings.Keychain.thePrivateKeyIsUnavailableOnThisMac) ) } - return secret.privateKey + return secret } private func delete(_ key: SSHKeyRef) throws { @@ -1226,7 +1259,7 @@ struct KeychainEditDetail: View { private func saveSoftwareKey( _ original: SSHKeyRef, name: String, colorTag: TagColor, storage: KeyStorage, - privateKey: String, passphrase: String?, certificate: String, + privateKey: String, passphrase: String?, savePassphrase: Bool, certificate: String, material: SSHKeyMaterial ) async throws -> SSHKeyRef { var updated = original @@ -1238,12 +1271,12 @@ struct KeychainEditDetail: View { updated.certificate = certificate updated.fingerprint = material.fingerprint updated.keySize = material.keySize - let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original)) + let keepsOriginalPrivateKey = privateKey == (try loadPrivateKey(original)).privateKey updated.hasPassphrase = passphrase != nil || (keepsOriginalPrivateKey && original.hasPassphrase) try app.credentials.upsert( updated, credential: .software( - SSHPrivateKeySecret(privateKey: privateKey) + SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil) ) ) window.selectVaultItem(.key(updated.id), on: .keychain) @@ -1821,7 +1854,9 @@ private struct KeyCard: View { CardChip(text: keyDisplayName) if key.hasPassphrase { CardChip( - text: String(localized: AppStrings.Keychain.passphraseRequired), + text: String( + localized: app.credentials.hasSavedPassphrase(for: key) + ? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired), symbol: "lock.fill" ) } diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 6aab401f..67c134a0 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -353,7 +353,7 @@ final class SFTPChannelModel: Identifiable { return } if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase( - in: route, key: store.key + in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry ) { requestAuthentication( host: passphraseHost, store: store, @@ -732,15 +732,18 @@ final class SFTPChannelModel: Identifiable { return } - if prompt.requiresPassphrase, - var staged = suppliedAuthentications[host.id] - { - guard let passphrase, !passphrase.isEmpty else { return } - staged.passphrase = passphrase - suppliedAuthentications[host.id] = staged - pendingAuthentication = nil - connectionJourney.resume() - connect(using: store) + if prompt.requiresPassphrase { + do { + suppliedAuthentications[host.id] = try store.passphraseSubmission( + for: prompt, existing: suppliedAuthentications[host.id], passphrase: passphrase, save: save) + pendingAuthentication = nil + connectionJourney.resume() + connect(using: store) + } catch { + var failed = prompt + failed.failureMessage = error.localizedDescription + pendingAuthentication = failed + } return } @@ -1171,7 +1174,7 @@ final class SFTPChannelModel: Identifiable { let needsPassphrase = requiresPassphrase ?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && selectedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true) + && selectedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true) pendingAuthentication = SSHAuthenticationPromptResolver.host( host, using: store, submission: submission, username: username, selectedKeyID: selectedKeyID, diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index ff410485..e65af9f8 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -420,7 +420,7 @@ final class SSHSessionModel: Identifiable { return } if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase( - in: route, key: store.key + in: route, key: store.key, requiresEntry: store.requiresPassphraseEntry ), let submission = suppliedAuthentications[passphraseHost.id] { requestAuthentication( for: passphraseHost, username: submission.username, @@ -645,8 +645,7 @@ final class SSHSessionModel: Identifiable { let retryNeedsPassphrase = submitted?.requiresPasswordReentry != true && submitted?.hasPassphrase != true - && authenticationKeyID.flatMap { store.key($0) }? - .requiresPassphraseEntry == true + && authenticationKeyID.flatMap { store.key($0) }?.hasPassphrase == true let returnsToIdentity = submitted?.requiresPassphraseReentry == true requestAuthentication( for: authenticationHost, @@ -703,15 +702,18 @@ final class SSHSessionModel: Identifiable { return } - if prompt.requiresPassphrase, - var staged = suppliedAuthentications[host.id] - { - guard let passphrase, !passphrase.isEmpty else { return } - staged.passphrase = passphrase - suppliedAuthentications[host.id] = staged - pendingAuthentication = nil - connectionJourney.resume() - connect(using: store) + if prompt.requiresPassphrase { + do { + suppliedAuthentications[host.id] = try store.passphraseSubmission( + for: prompt, existing: suppliedAuthentications[host.id], passphrase: passphrase, save: save) + pendingAuthentication = nil + connectionJourney.resume() + connect(using: store) + } catch { + var failed = prompt + failed.failureMessage = error.localizedDescription + pendingAuthentication = failed + } return } @@ -745,7 +747,7 @@ final class SSHSessionModel: Identifiable { for: host, username: trimmedUsername, selectedKeyID: submittedKeyID, failureMessage: error.localizedDescription, requiresPassphrase: - submittedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true + submittedKeyID.flatMap { store.key($0) }.map { store.requiresPassphraseEntry(for: $0) } == true ) return } diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index e09a66bd..320245ec 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -255,8 +255,8 @@ struct AgentHostAuthenticationTests { #expect(configuration.username == "deploy") } - @Test - func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection() async throws { + @Test(arguments: [false, true]) + func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection(save: Bool) async throws { let keychain = MemoryHostSecretStore() let reference = "termind.key.\(UUID().uuidString)" let key = SSHKeyRef( @@ -291,7 +291,7 @@ struct AgentHostAuthenticationTests { return .submit( AgentSSHAuthenticationAnswer( username: request.username, passphrase: "one-use", - keyID: key.id, save: false + keyID: key.id, save: save )) } } @@ -323,6 +323,10 @@ struct AgentHostAuthenticationTests { return } #expect(stored.privateKey == "encrypted") + #expect(stored.passphrase == (save ? "one-use" : nil)) + #expect(store.requiresPassphraseEntry(for: key) == !save) + #expect(store.host(host.id)?.identityID == nil) + configuration.clearSecrets() #expect(store.key(key.id)?.requiresPassphraseEntry == true) } diff --git a/TFMTests/KeyPassphraseEnvelopeTests.swift b/TFMTests/KeyPassphraseEnvelopeTests.swift new file mode 100644 index 00000000..118973b2 --- /dev/null +++ b/TFMTests/KeyPassphraseEnvelopeTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing + +@testable import TermindApplication +@testable import TermindRemoteInfrastructure + +struct KeyPassphraseEnvelopeTests { + @Test(arguments: [ + #"{"privateKey":"encrypted","passphrase":"密语😀\n\"\\"}"#, + #"{"passphrase":"\u5bc6\u8bed\ud83d\ude00\n\"\\","privateKey":"encrypted"}"#, + ]) + func oldEnvelopeDecodesWithoutImmutableSecretStrings(_ json: String) throws { + var data = Data(json.utf8) + let observation = EnvelopeZeroization() + let credential = try KeychainSecretStore.decodeKeySecrets(from: &data) + guard case .software(let key, let passphrase) = credential else { + Issue.record("Expected software key") + return + } + let phrase = try #require(passphrase) + for secret in [key, phrase] { secret.observeZeroization { observation.record($0) } } + #expect(key.matches("encrypted")) + #expect(phrase.matches("密语😀\n\"\\")) + #expect(data.isEmpty) + var encoded = try KeychainSecretStore.encodeKeySecrets(privateKey: key, passphrase: phrase) + let editor = try JSONDecoder().decode(SSHPrivateKeySecret.self, from: encoded) + #expect(editor.privateKey == "encrypted" && editor.passphrase == "密语😀\n\"\\") + SecretMemory.clear(&encoded) + #expect(observation.count == 2 && observation.allZero) + } + + @Test(arguments: [ + #"{"privateKey":"key"}"#, #"{"privateKey":"key","passphrase":null}"#, #"{"passphrase":"","privateKey":"key"}"#, + ]) + func envelopesWithoutSavedPassphraseRemainCompatible(_ json: String) throws { + var data = Data(json.utf8) + let credential = try KeychainSecretStore.decodeKeySecrets(from: &data) + defer { credential.clearSecrets() } + #expect(!credential.hasSavedPassphrase) + #expect(data.isEmpty) + } + + @Test(arguments: [ + #"{"privateKey":"key","privateKey":"duplicate"}"#, + #"{"privateKey":"key","passphrase":"first","passphrase":"second"}"#, + #"{"privateKey":"key","passphrase":12}"#, + #"{"privateKey":"key","passphrase":"\ud800"}"#, + #"{"privateKey":"key","passphrase":"saved"} trailing"#, + #"{"passphrase":"saved"}"#, + #"{"privateKey":"key","extra":"saved"}"#, + #"{"privateKey":"key","passphrase":"saved",}"#, + ]) + func malformedEnvelopeZeroesEveryDecodedBuffer(_ json: String) { + let observation = EnvelopeZeroization() + var data = Data(json.utf8) + #expect(throws: KeychainSecretError.self) { + _ = try KeychainSecretStore.decodeKeySecrets(from: &data) { secret in + secret.observeZeroization { observation.record($0) } + } + } + #expect(data.isEmpty) + #expect(observation.count > 0 && observation.allZero) + } + + @Test func keychainSourceBytesAreZeroedOnParseFailure() { + let observation = EnvelopeZeroization() + let bytes = Array( + (#"{"privateKey":"key","passphrase":"saved"} invalid"# + String(repeating: " ", count: 4096)).utf8) + let pointer = UnsafeMutableRawPointer.allocate(byteCount: bytes.count, alignment: 1) + bytes.withUnsafeBytes { pointer.copyMemory(from: $0.baseAddress!, byteCount: bytes.count) } + var data = Data( + bytesNoCopy: pointer, count: bytes.count, + deallocator: .custom { pointer, count in + observation.record(UnsafeRawBufferPointer(start: pointer, count: count)) + pointer.deallocate() + }) + #expect(throws: KeychainSecretError.self) { _ = try KeychainSecretStore.decodeKeySecrets(from: &data) } + #expect(observation.count == 1 && observation.allZero) + } + + @Test func serializationFailureStillZeroesTheUnusedPassphrase() { + let observation = EnvelopeZeroization() + let consumed = SSHSecret("key") + consumed.clear() + let phrase = SSHSecret("saved") + phrase.observeZeroization { observation.record($0) } + #expect(throws: SSHSecret.Consumed.self) { + _ = try KeychainSecretStore.encodeKeySecrets(privateKey: consumed, passphrase: phrase) + } + #expect(observation.count == 1 && observation.allZero) + } +} + +private nonisolated final class EnvelopeZeroization: @unchecked Sendable { + private let lock = NSLock() + private var observations: [Bool] = [] + var count: Int { lock.withLock { observations.count } } + var allZero: Bool { lock.withLock { observations.allSatisfy { $0 } } } + func record(_ bytes: UnsafeRawBufferPointer) { + lock.withLock { observations.append(!bytes.isEmpty && bytes.allSatisfy { $0 == 0 }) } + } +} diff --git a/TFMTests/PassphraseFormLayoutTests.swift b/TFMTests/PassphraseFormLayoutTests.swift new file mode 100644 index 00000000..56f79922 --- /dev/null +++ b/TFMTests/PassphraseFormLayoutTests.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing + +@Suite @MainActor +struct PassphraseFormLayoutTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [false, true]) + func passphraseActionsFitTheAuthenticationCard(language: String, allowsSaving: Bool) throws { + let host = TermindDomain.Host(name: "Production", host: "server.example.invalid") + let prompt = SSHAuthenticationPrompt( + host: host, username: "deploy", requiresPassphrase: true, allowsSaving: allowsSaving) + let view = NSHostingView( + rootView: SSHAuthenticationForm( + prompt: prompt, submit: { _, _, _, _, _, _ in }, cancel: {}, library: HostLibraryModel() + ).environment(\.locale, Locale(identifier: language)).frame(width: 420)) + let size = view.fittingSize + #expect(size.width == 420) + #expect(size.height > 100 && size.height < 400) + view.frame = NSRect(origin: .zero, size: size) + view.layoutSubtreeIfNeeded() + if ProcessInfo.processInfo.environment["TERMIND_PASSPHRASE_PREVIEW"] == "1", allowsSaving { + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent( + "termind-passphrase-macos-\(language).png") + try png.write(to: url) + print("Passphrase preview: \(url.path)") + } + } +} diff --git a/TFMTests/PortForwardingJourneyTests.swift b/TFMTests/PortForwardingJourneyTests.swift index 3917dd61..7e0d9fa9 100644 --- a/TFMTests/PortForwardingJourneyTests.swift +++ b/TFMTests/PortForwardingJourneyTests.swift @@ -164,7 +164,8 @@ struct PortForwardingJourneyTests { #expect(credentials.controller.state(for: credentialRule.id) == .awaitingAuthentication) } - @Test func encryptedKeyPassphraseResumesPreparationInTheJourney() async throws { + @Test(arguments: [false, true]) + func encryptedKeyPassphraseResumesPreparationInTheJourney(save: Bool) async throws { let material = try SSHKeyMaterial.generate(.ed25519, comment: "forwarding", passphrase: "test-passphrase") let key = SSHKeyRef( label: "Encrypted", algorithm: .ed25519, publicKey: material.publicKey, @@ -185,9 +186,10 @@ struct PortForwardingJourneyTests { #expect(prompt.requiresPassphrase) controller.submitAuthentication( for: rule.id, promptID: prompt.id, identityID: identity.id, username: "deploy", - password: nil, passphrase: "test-passphrase", keyID: key.id, save: false) + password: nil, passphrase: "test-passphrase", keyID: key.id, save: save) try await wait { controller.state(for: rule.id) == .running(activeConnections: 0) } #expect(!context.journey.plainTextLog.contains("test-passphrase")) + #expect(library.requiresPassphraseEntry(for: key) == !save) } @Test func savedCredentialsContinueTheExplicitOpeningWithoutStartingOtherRules() async throws {