From 5f519eaa6c0a80fd5d3be3cc4bbfab528318ab42 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 20 Sep 2026 17:57:49 +0800 Subject: [PATCH] Keep additional SSH credentials reference-only --- .../SSHInteractionPolicyTests.swift | 187 ++++++++++++++++-- .../Connections/RemoteSessionHostStore.swift | 159 +++++++++++++-- .../SSHAdditionalConnectionSnapshot.swift | 88 +++++++++ .../SSHConnectionPreparation.swift | 30 ++- .../FeatureModels/HostLibraryModel.swift | 42 +++- .../SSHConnectionTypes.swift | 82 +++++++- .../SSHRouteHopConfiguration.swift | 11 +- .../SSHTransportProxy.swift | 7 +- .../SSH/PortForwardingController.swift | 21 +- .../SSH/SSHForwardingConnection.swift | 4 +- .../SSH/SSHForwardingConnectionPool.swift | 9 +- .../SSH/SSHPTYChannel.swift | 11 +- .../Transfers/SFTPEditService.swift | 38 ++-- .../Transfers/SFTPTransferContext.swift | 17 +- .../Transfers/SFTPTransferCoordinator.swift | 21 +- Memory/ARCHITECTURE.md | 13 +- TFI/Features/Workspaces/MobileWorkspace.swift | 38 ++-- ...MobileCommandGeneratorAndUploadTests.swift | 8 +- TFM/Features/SFTP/SFTPChannelModel.swift | 45 +++-- TFM/Features/Terminal/SSHSessionModel.swift | 63 ++++-- .../Terminal/TerminalPlaceholderView.swift | 13 +- .../Workspaces/WorkspaceContentView.swift | 2 +- TFMTests/AgentHostAuthenticationTests.swift | 4 +- TFMTests/ArchitectureBoundaryTests.swift | 18 ++ TFMTests/PortForwardingReconnectTests.swift | 12 +- TFMTests/SFTPEditTests.swift | 13 +- TFMTests/SFTPTransferContextTests.swift | 28 +-- TFMTests/SSHForwardingWireTests.swift | 7 +- 28 files changed, 808 insertions(+), 183 deletions(-) create mode 100644 Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift diff --git a/FrameworkTests/SSHInteractionPolicyTests.swift b/FrameworkTests/SSHInteractionPolicyTests.swift index 58c7ddfb..f38c42f8 100644 --- a/FrameworkTests/SSHInteractionPolicyTests.swift +++ b/FrameworkTests/SSHInteractionPolicyTests.swift @@ -35,9 +35,11 @@ struct SSHInteractionPolicyTests { } } - @Test func additionalConnectionRefreshesDurableTrustWithoutReusingOnceOnlyApproval() { + @Test func additionalSnapshotRetainsRouteAndIDsWithoutCredentialMaterial() { let host = Host(name: "Server", host: "example.invalid") let jump = Host(name: "Jump", host: "jump.invalid") + let identityID = UUID() + let keyID = UUID() let trusted = KnownHostEntry(hostname: host.host, port: 22, algorithm: .ed25519, fingerprint: "saved") let store = HostLibraryModel(hosts: [host, jump], knownHosts: [trusted]) let configuration = SSHConnectionConfiguration( @@ -47,17 +49,180 @@ struct SSHInteractionPolicyTests { SSHRouteHopConfiguration( id: jump.id, name: jump.name, host: jump.host, port: 22, username: "jump-user", credentials: [], expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: "once-only")]) - ]) - let additional = store.configurationForAdditionalConnection(from: configuration) - #expect(additional.expectedHostKeys == [trusted.identity]) + ], + authenticationReference: SSHAuthenticationReference( + identityID: identityID, fallbackKeyID: keyID, + username: "temporary-user" + )) + var withReference = configuration + withReference.jumps[0].authenticationReference = SSHAuthenticationReference( + fallbackKeyID: keyID, username: "jump-user", permitsNone: false + ) + + let additional = store.additionalConnectionSnapshot(from: withReference) + #expect(additional.target.expectedHostKeys == [trusted.identity]) #expect(additional.jumps[0].expectedHostKeys.isEmpty) - #expect(additional.host == host.host) - #expect(additional.username == "temporary-user") - #expect(additional.jumps[0].username == "jump-user") - if case .password(let value) = additional.credentials.first { - #expect(value == "temporary-password") - } else { - Issue.record("The live credential snapshot must be retained") + #expect(additional.target.host == host.host) + #expect(additional.target.authentication.username == "temporary-user") + #expect(additional.target.authentication.identityID == identityID) + #expect(additional.target.authentication.fallbackKeyID == keyID) + #expect(additional.jumps[0].authentication.username == "jump-user") + #expect(additional.jumps[0].authentication.fallbackKeyID == keyID) + } + + @Test func forbiddenPolicyReplacesProtectedKeysButKeepsPasswordFallback() { + let configuration = SSHConnectionConfiguration( + hostID: UUID(), host: "example.invalid", port: 22, username: "user", + credentials: [ + .secureEnclave( + credential: SecureEnclaveKeyCredential(dataRepresentation: Data("handle".utf8)), + publicKey: "public", requiresUserPresence: true + ), + .privateKey( + privateKey: "encrypted", publicKey: "public", passphrase: "one-time", + requiresPassphrase: true + ), + .password("saved-password"), + ], expectedHostKeys: []) + + let request = SSHConnectionRequest( + configuration: configuration, interactionPolicy: .forbidden + ).enforcingInteractionPolicy() + #expect(request.configuration.credentials.count == 3) + guard case .interactionRequired = request.configuration.credentials[0] else { + Issue.record("Touch ID key must become an interaction marker") + return } + guard case .interactionRequired = request.configuration.credentials[1] else { + Issue.record("Passphrase key must become an interaction marker") + return + } + guard case .password(let password) = request.configuration.credentials[2] else { + Issue.record("Password fallback must remain available") + return + } + #expect(password == "saved-password") + } + + @Test func additionalResolutionUsesCurrentIdentityAndSkipsProtectedKeyReads() throws { + let secrets = CountingSecretStore() + let protectedKey = SSHKeyRef( + label: "Touch ID", publicKey: "ecdsa-sha2-nistp256 public", + credentialRef: "key.touch-id", storage: .secureEnclave, + requiresUserPresence: true + ) + let fallbackKey = SSHKeyRef( + label: "Fallback", publicKey: "ssh-ed25519 fallback", + credentialRef: "key.fallback" + ) + let identity = Identity( + name: "Current", username: "current-user", + credentialRef: "password.current", keyID: protectedKey.id + ) + try secrets.setString("current-password", for: identity.credentialRef, storage: identity.passwordStorage) + let store = HostLibraryModel( + keys: [protectedKey, fallbackKey], identities: [identity], secrets: secrets + ) + let node = SSHAdditionalConnectionSnapshot.Node( + id: UUID(), name: "Server", host: "example.invalid", port: 22, + authentication: SSHAuthenticationReference( + identityID: identity.id, fallbackKeyID: fallbackKey.id, + username: "stale-user" + ), + expectedHostKeys: [], proxy: nil + ) + + let request = try store.additionalConnectionRequest( + from: SSHAdditionalConnectionSnapshot(target: node) + ) + #expect(request.configuration.username == "current-user") + #expect(secrets.keyReads == 0) + #expect(secrets.stringReads == 1) + guard case .interactionRequired = request.configuration.credentials[0] else { + Issue.record("The current Identity key must take priority and remain unread") + return + } + guard case .password(let password) = request.configuration.credentials[1] else { + Issue.record("The current Identity password must remain available") + return + } + #expect(password == "current-password") + } + + @Test func additionalResolutionSkipsPassphraseKeyAndUsesIdentityPassword() throws { + let secrets = CountingSecretStore() + let protectedKey = SSHKeyRef( + label: "Encrypted", publicKey: "ssh-ed25519 public", + credentialRef: "key.encrypted", hasPassphrase: true + ) + let identity = Identity( + name: "Deploy", username: "deploy", + credentialRef: "password.deploy", keyID: protectedKey.id + ) + try secrets.setString("saved-password", for: identity.credentialRef, storage: identity.passwordStorage) + let store = HostLibraryModel( + keys: [protectedKey], identities: [identity], secrets: secrets + ) + let node = SSHAdditionalConnectionSnapshot.Node( + id: UUID(), name: "Server", host: "example.invalid", port: 22, + authentication: SSHAuthenticationReference( + identityID: identity.id, username: "stale" + ), + expectedHostKeys: [], proxy: nil + ) + + let request = try store.additionalConnectionRequest( + from: SSHAdditionalConnectionSnapshot(target: node) + ) + #expect(secrets.keyReads == 0) + guard case .interactionRequired = request.configuration.credentials[0] else { + Issue.record("Passphrase key must remain unread") + return + } + guard case .password(let password) = request.configuration.credentials[1] else { + Issue.record("Identity password must remain available") + return + } + #expect(password == "saved-password") + } +} + +private nonisolated final class CountingSecretStore: SecretStoring, @unchecked Sendable { + private let lock = NSLock() + private var strings: [String: String] = [:] + private var keys: [String: StoredSSHKeyCredential] = [:] + private(set) var stringReads = 0 + private(set) var keyReads = 0 + + func string(for account: String, storage: PasswordStorage) throws -> String? { + lock.withLock { + stringReads += 1 + return strings[account] + } + } + + func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { + lock.withLock { strings[account] = value } + } + + func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { + lock.withLock { + keyReads += 1 + return keys[account] + } + } + + func setKeyCredential( + _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage + ) throws { + lock.withLock { keys[account] = value } + } + + func removePassword(_ account: String, storage: PasswordStorage) throws { + lock.withLock { strings[account] = nil } + } + + func removeKeyCredential(_ account: String, storage: KeyStorage) throws { + lock.withLock { keys[account] = nil } } } diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index ed009b35..f5c36218 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -104,22 +104,159 @@ extension RemoteSessionHostStore { return updated } - /// Refresh only durable trust after a successful handshake. The connection may - /// have prompted before its configuration was captured; once-only approval is - /// deliberately not promoted into permission for additional connections. - public func configurationForAdditionalConnection( + /// Captures the authenticated route without retaining password, key, or + /// passphrase material. Once-only trust is deliberately not promoted into + /// permission for additional connections. + public func additionalConnectionSnapshot( from configuration: SSHConnectionConfiguration - ) -> SSHConnectionConfiguration { + ) -> SSHAdditionalConnectionSnapshot { func keys(id: UUID, hostname: String, port: Int) -> Set { Set(knownHosts(for: Host(id: id, name: hostname, host: hostname, port: port)).map(\.identity)) } - var result = configuration - result.expectedHostKeys = keys(id: result.hostID, hostname: result.host, port: result.port) - for index in result.jumps.indices { - let hop = result.jumps[index] - result.jumps[index].expectedHostKeys = keys(id: hop.id, hostname: hop.host, port: hop.port) + + func proxy(_ value: SSHTransportProxy?) -> SSHAdditionalConnectionSnapshot.Proxy? { + value.map { + SSHAdditionalConnectionSnapshot.Proxy( + kind: $0.kind, hostname: $0.hostname, port: $0.port, + identityID: $0.identityID + ) + } } - return result + + let target = SSHAdditionalConnectionSnapshot.Node( + id: configuration.hostID, name: configuration.host, + host: configuration.host, port: configuration.port, + authentication: configuration.authenticationReference, + expectedHostKeys: keys( + id: configuration.hostID, hostname: configuration.host, + port: configuration.port + ), + proxy: proxy(configuration.proxy) + ) + let jumps = configuration.jumps.map { hop in + SSHAdditionalConnectionSnapshot.Node( + id: hop.id, name: hop.name, host: hop.host, port: hop.port, + authentication: hop.authenticationReference, + expectedHostKeys: keys(id: hop.id, hostname: hop.host, port: hop.port), + proxy: proxy(hop.proxy) + ) + } + return SSHAdditionalConnectionSnapshot(target: target, jumps: jumps) + } + + /// Resolves current Identity/Key contents immediately before a silent open. + /// The returned request is one-shot and must not be retained as session state. + public func additionalConnectionRequest( + from snapshot: SSHAdditionalConnectionSnapshot + ) throws -> SSHConnectionRequest { + func proxy( + _ value: SSHAdditionalConnectionSnapshot.Proxy? + ) throws -> SSHTransportProxy? { + guard let value else { return nil } + guard let identityID = value.identityID else { + return SSHTransportProxy( + kind: value.kind, hostname: value.hostname, port: value.port, + username: "" + ) + } + guard let identity = identity(identityID) else { + throw TermindSSHError.missingCredential + } + let password = try connectionPasswordCredential(for: identity) + guard let password else { throw TermindSSHError.missingCredential } + return SSHTransportProxy( + kind: value.kind, hostname: value.hostname, port: value.port, + username: identity.username, password: password, + identityID: identity.id + ) + } + + func authentication( + _ node: SSHAdditionalConnectionSnapshot.Node + ) throws -> (username: String, credentials: [SSHCredentials]) { + let reference = node.authentication + let resolvedIdentity = reference.identityID.flatMap(identity) + if reference.identityID != nil, resolvedIdentity == nil { + throw TermindSSHError.missingCredential + } + + let username = (resolvedIdentity?.username ?? reference.username) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !username.isEmpty else { throw TermindSSHError.missingUsername } + + var credentials: [SSHCredentials] = [] + var failures: [any Error] = [] + let keyID = resolvedIdentity?.keyID ?? reference.fallbackKeyID + if let keyID { + if let key = key(keyID) { + if key.requiresPassphraseEntry || key.requiresUserPresence { + credentials.append(.interactionRequired) + } else { + do { + if let stored = try connectionKeyCredential( + for: key, identityID: resolvedIdentity?.id + ) { + credentials.append( + stored.sshCredential( + publicKey: key.publicKey, + certificate: key.certificate, + requiresPassphrase: key.requiresPassphraseEntry, + requiresUserPresence: key.requiresUserPresence + ) + ) + } else { + failures.append(TermindSSHError.missingCredential) + } + } catch { + failures.append(error) + } + } + } else { + failures.append(TermindSSHError.missingCredential) + } + } + + if let resolvedIdentity, resolvedIdentity.usesPassword { + do { + if let password = try connectionPasswordCredential(for: resolvedIdentity) { + credentials.append(.password(password)) + } else { + failures.append(TermindSSHError.missingCredential) + } + } catch { + failures.append(error) + } + } + + if credentials.isEmpty { + if reference.hasEphemeralSecret { throw SSHInteractionRequired() } + if !reference.permitsNone { throw failures.first ?? TermindSSHError.missingCredential } + } + return (username, credentials) + } + + let targetAuthentication = try authentication(snapshot.target) + let jumps = try snapshot.jumps.map { node in + let resolved = try authentication(node) + return SSHRouteHopConfiguration( + id: node.id, name: node.name, host: node.host, port: node.port, + username: resolved.username, credentials: resolved.credentials, + expectedHostKeys: node.expectedHostKeys, + proxy: try proxy(node.proxy), + authenticationReference: node.authentication + ) + } + let configuration = SSHConnectionConfiguration( + hostID: snapshot.target.id, host: snapshot.target.host, + port: snapshot.target.port, username: targetAuthentication.username, + credentials: targetAuthentication.credentials, + expectedHostKeys: snapshot.target.expectedHostKeys, + proxy: try proxy(snapshot.target.proxy), jumps: jumps, + authenticationReference: snapshot.target.authentication + ) + return SSHConnectionRequest( + configuration: configuration, interactionPolicy: .forbidden + ).enforcingInteractionPolicy() } public func connectionPasswordCredential(for identity: Identity) throws -> String? { diff --git a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift new file mode 100644 index 00000000..f6ac312a --- /dev/null +++ b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift @@ -0,0 +1,88 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// A reusable SSH route with no authentication material. Network topology is +/// frozen for the live session; credentials are resolved from Identity/Key IDs +/// immediately before each additional connection opens. +public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable { + public struct Proxy: Equatable, Sendable { + public var kind: HostProxy.ProtocolKind + public var hostname: String + public var port: Int + public var identityID: Identity.ID? + + public init( + kind: HostProxy.ProtocolKind, hostname: String, port: Int, + identityID: Identity.ID? + ) { + self.kind = kind + self.hostname = hostname + self.port = port + self.identityID = identityID + } + } + + public struct Node: Equatable, Sendable { + public var id: Host.ID + public var name: String + public var host: String + public var port: Int + public var authentication: SSHAuthenticationReference + public var expectedHostKeys: Set + public var proxy: Proxy? + + public init( + id: Host.ID, name: String, host: String, port: Int, + authentication: SSHAuthenticationReference, + expectedHostKeys: Set, proxy: Proxy? + ) { + self.id = id + self.name = name + self.host = host + self.port = port + self.authentication = authentication + self.expectedHostKeys = expectedHostKeys + self.proxy = proxy + } + } + + public var target: Node + public var jumps: [Node] + + public init(target: Node, jumps: [Node] = []) { + self.target = target + self.jumps = jumps + } + + /// Drops all resolved authentication material while retaining the route's + /// explicit Identity/Key provenance. Durable trust may be refreshed by the + /// host store before this value is retained for additional connections. + public init(configuration: SSHConnectionConfiguration) { + func proxy(_ value: SSHTransportProxy?) -> Proxy? { + value.map { + Proxy( + kind: $0.kind, hostname: $0.hostname, port: $0.port, + identityID: $0.identityID + ) + } + } + target = Node( + id: configuration.hostID, name: configuration.host, + host: configuration.host, port: configuration.port, + authentication: configuration.authenticationReference, + expectedHostKeys: configuration.expectedHostKeys, + proxy: proxy(configuration.proxy) + ) + jumps = configuration.jumps.map { + Node( + id: $0.id, name: $0.name, host: $0.host, port: $0.port, + authentication: $0.authenticationReference, + expectedHostKeys: $0.expectedHostKeys, proxy: proxy($0.proxy) + ) + } + } + + public var hostID: Host.ID { target.id } +} diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index e47d8d52..d7bfaf12 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -76,7 +76,8 @@ 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, accessedIdentityIDs: &accessedIdentityIDs) + proxy: try proxy(node, accessedIdentityIDs: &accessedIdentityIDs), + authenticationReference: authenticationReference(for: node) )) } catch { if error is CancellationError { throw error } @@ -100,7 +101,25 @@ public nonisolated struct SSHConnectionPreparation: Sendable { 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, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps + proxy: try proxy(target, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps, + authenticationReference: authenticationReference(for: target) + ) + } + + private func authenticationReference(for node: Node) -> SSHAuthenticationReference { + if let identity = connectionIdentity(for: node) { + return SSHAuthenticationReference( + identityID: identity.id, + username: identity.username, + permitsNone: !identity.hasSecret, + hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil + ) + } + return SSHAuthenticationReference( + fallbackKeyID: node.submission?.keyID ?? node.key?.id, + username: node.submission?.username ?? "", + permitsNone: node.key == nil && node.submission?.password == nil, + hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil ) } @@ -130,7 +149,9 @@ public nonisolated struct SSHConnectionPreparation: Sendable { result.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: node.submission?.passphrase + passphraseOverride: node.submission?.passphrase, + requiresPassphrase: key.requiresPassphraseEntry, + requiresUserPresence: key.requiresUserPresence )) } } @@ -179,7 +200,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } return SSHTransportProxy( kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, - username: proxy.username, password: password + username: proxy.username, password: password, + identityID: proxy.identityID ) } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 0fa1d586..114d7efe 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -480,6 +480,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy, accessedIdentityIDs: &accessedIdentityIDs + ), + authenticationReference: additionalAuthenticationReference( + host: host, submission: submissions[host.id] ) ) } @@ -595,6 +598,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { expectedHostKeys: expectedHostKeys, proxy: try transportProxy( for: host, accessedIdentityIDs: &accessedIdentityIDs + ), + authenticationReference: additionalAuthenticationReference( + host: host, submission: nil ) ) } @@ -621,10 +627,35 @@ public final class HostLibraryModel: RemoteSessionHostStore { expectedHostKeys: expectedHostKeys, proxy: try transportProxy( for: host, accessedIdentityIDs: &accessedIdentityIDs + ), + authenticationReference: additionalAuthenticationReference( + host: host, submission: submission ) ) } + private func additionalAuthenticationReference( + host: Host, submission: SSHAuthenticationSubmission? + ) -> SSHAuthenticationReference { + let resolvedIdentity = + submission?.identityID.flatMap(identity) + ?? (submission == nil ? identity(for: host) : nil) + if let resolvedIdentity { + return SSHAuthenticationReference( + identityID: resolvedIdentity.id, + username: resolvedIdentity.username, + permitsNone: !resolvedIdentity.hasSecret, + hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil + ) + } + return SSHAuthenticationReference( + fallbackKeyID: submission?.keyID, + username: submission?.username ?? "", + permitsNone: submission?.keyID == nil && submission?.password == nil, + hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil + ) + } + private func sshCredentials( for submission: SSHAuthenticationSubmission, accessedIdentityIDs: inout Set @@ -643,7 +674,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { credentials.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: submission.passphrase + passphraseOverride: submission.passphrase, + requiresPassphrase: key.requiresPassphraseEntry, + requiresUserPresence: key.requiresUserPresence ) ) } @@ -680,7 +713,8 @@ public final class HostLibraryModel: RemoteSessionHostStore { } return SSHTransportProxy( kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, - username: proxy.username, password: password + username: proxy.username, password: password, + identityID: proxy.identityID ) } @@ -711,7 +745,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { } else { credentials.append( credential.sshCredential( - publicKey: key.publicKey, certificate: key.certificate + publicKey: key.publicKey, certificate: key.certificate, + requiresPassphrase: key.requiresPassphraseEntry, + requiresUserPresence: key.requiresUserPresence ) ) } diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index ac8741ce..8d2a2014 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -42,13 +42,15 @@ public nonisolated enum SSHHostKeyDecision: Equatable, Sendable { } public typealias SSHHostKeyAuthorizer = @Sendable (SSHHostKeyChallenge) async throws -> SSHHostKeyDecision +public typealias SSHAdditionalConnectionRequestResolver = @MainActor @Sendable () throws -> SSHConnectionRequest public nonisolated enum SSHHostTrustPolicy: Equatable, Sendable { case requireKnownHost case allowChallenge } -/// Controls Termind authentication forms. System key authorization (Touch ID) remains allowed. +/// Controls every authentication action that may require user presence, +/// including Termind forms, Touch ID, and device-password authorization. public nonisolated enum SSHInteractionPolicy: Equatable, Sendable { case allowed case forbidden @@ -95,6 +97,11 @@ public nonisolated struct SSHConnectionRequest: Sendable { public func enforcingInteractionPolicy() -> Self { guard interactionPolicy == .forbidden else { return self } var request = self + request.configuration.credentials = request.configuration.credentials.map { $0.withoutInteraction } + for index in request.configuration.jumps.indices { + request.configuration.jumps[index].credentials = + request.configuration.jumps[index].credentials.map { $0.withoutInteraction } + } request.authorizeHostKey = { _ in throw SSHInteractionRequired() } request.keyboardInteractive = { challenge in guard challenge.prompts.isEmpty else { throw SSHInteractionRequired() } @@ -104,6 +111,18 @@ public nonisolated struct SSHConnectionRequest: Sendable { } } +nonisolated extension SSHCredentials { + fileprivate var withoutInteraction: SSHCredentials { + switch self { + case .privateKey(_, _, _, requiresPassphrase: true), + .secureEnclave(_, _, requiresUserPresence: true): + .interactionRequired + default: + self + } + } +} + public nonisolated struct SSHHandshakeFailure: Hashable, Sendable { public enum Kind: String, Hashable, Sendable { case peerClosed @@ -197,13 +216,23 @@ public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable { public nonisolated enum SSHCredentials: Sendable { case password(String) - case privateKey(privateKey: String, publicKey: String?, passphrase: String?) - case secureEnclave(credential: SecureEnclaveKeyCredential, publicKey: String) + case privateKey( + privateKey: String, publicKey: String?, passphrase: String?, + requiresPassphrase: Bool = false + ) + case secureEnclave( + credential: SecureEnclaveKeyCredential, publicKey: String, + requiresUserPresence: Bool = false + ) + /// A public-key credential exists, but the current policy may not obtain it + /// without presenting Touch ID, a device-password prompt, or a passphrase form. + case interactionRequired } nonisolated extension StoredSSHKeyCredential { public func sshCredential( - publicKey: String, certificate: String = "", passphraseOverride: String? = nil + publicKey: String, certificate: String = "", passphraseOverride: String? = nil, + requiresPassphrase: Bool = false, requiresUserPresence: Bool = false ) -> SSHCredentials { switch self { case .software(let secret): @@ -212,14 +241,44 @@ nonisolated extension StoredSSHKeyCredential { publicKey: certificate.isEmpty ? (publicKey.isEmpty ? nil : publicKey) : certificate, - passphrase: passphraseOverride + passphrase: passphraseOverride, + requiresPassphrase: requiresPassphrase ) case .secureEnclave(let credential): - .secureEnclave(credential: credential, publicKey: publicKey) + .secureEnclave( + credential: credential, publicKey: publicKey, + requiresUserPresence: requiresUserPresence + ) } } } +/// Non-secret provenance retained by a live connection so its reusable +/// additional-connection snapshot can resolve current credentials by ID. +public nonisolated struct SSHAuthenticationReference: Hashable, Sendable { + public var identityID: Identity.ID? + /// Used only when no Identity owns this login. An Identity always resolves + /// its current Key, so changing the Identity updates later connections. + public var fallbackKeyID: SSHKeyRef.ID? + /// Used only when there is no Identity. Identity-owned logins always use + /// the Identity's current username. + public var username: String + public var permitsNone: Bool + public var hasEphemeralSecret: Bool + + public init( + identityID: Identity.ID? = nil, fallbackKeyID: SSHKeyRef.ID? = nil, + username: String = "", + permitsNone: Bool = false, hasEphemeralSecret: Bool = false + ) { + self.identityID = identityID + self.fallbackKeyID = fallbackKeyID + self.username = username + self.permitsNone = permitsNone + self.hasEphemeralSecret = hasEphemeralSecret + } +} + public nonisolated struct SSHConnectionConfiguration: Sendable { public var hostID: Host.ID public var host: String @@ -229,10 +288,12 @@ public nonisolated struct SSHConnectionConfiguration: Sendable { public var expectedHostKeys: Set public var proxy: SSHTransportProxy? public var jumps: [SSHRouteHopConfiguration] = [] + public var authenticationReference: SSHAuthenticationReference public init( hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials], expectedHostKeys: Set, proxy: SSHTransportProxy? = nil, - jumps: [SSHRouteHopConfiguration] = [] + jumps: [SSHRouteHopConfiguration] = [], + authenticationReference: SSHAuthenticationReference? = nil ) { self.hostID = hostID self.host = host @@ -242,6 +303,13 @@ public nonisolated struct SSHConnectionConfiguration: Sendable { self.expectedHostKeys = expectedHostKeys self.proxy = proxy self.jumps = jumps + self.authenticationReference = + authenticationReference + ?? SSHAuthenticationReference( + username: username, + permitsNone: credentials.isEmpty, + hasEphemeralSecret: !credentials.isEmpty + ) } } diff --git a/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift b/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift index 04294ba5..79dc4fd1 100644 --- a/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift +++ b/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift @@ -12,9 +12,11 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable { public var credentials: [SSHCredentials] public var expectedHostKeys: Set public var proxy: SSHTransportProxy? + public var authenticationReference: SSHAuthenticationReference public init( id: Host.ID, name: String, host: String, port: Int, username: String, credentials: [SSHCredentials], - expectedHostKeys: Set, proxy: SSHTransportProxy? = nil + expectedHostKeys: Set, proxy: SSHTransportProxy? = nil, + authenticationReference: SSHAuthenticationReference? = nil ) { self.id = id self.name = name @@ -24,6 +26,13 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable { self.credentials = credentials self.expectedHostKeys = expectedHostKeys self.proxy = proxy + self.authenticationReference = + authenticationReference + ?? SSHAuthenticationReference( + username: username, + permitsNone: credentials.isEmpty, + hasEphemeralSecret: !credentials.isEmpty + ) } } diff --git a/Frameworks/TermindApplication/SSHTransportProxy.swift b/Frameworks/TermindApplication/SSHTransportProxy.swift index 13790ffd..94366638 100644 --- a/Frameworks/TermindApplication/SSHTransportProxy.swift +++ b/Frameworks/TermindApplication/SSHTransportProxy.swift @@ -9,12 +9,17 @@ public nonisolated struct SSHTransportProxy: Sendable { public var port: Int public var username: String public var password: String? - public init(kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String, password: String? = nil) { + public var identityID: Identity.ID? + public init( + kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String, + password: String? = nil, identityID: Identity.ID? = nil + ) { self.kind = kind self.hostname = hostname self.port = port self.username = username self.password = password + self.identityID = identityID } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index 909b59e5..f4c633c2 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -77,7 +77,7 @@ public final class PortForwardingConnection { fileprivate var connected = false fileprivate var ready = false fileprivate var silentAttempt = false - fileprivate var configuration: SSHConnectionConfiguration? + fileprivate var additionalSnapshot: SSHAdditionalConnectionSnapshot? fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:] fileprivate let maximumConnections: Int @@ -361,8 +361,8 @@ public final class PortForwardingController { try Task.checkCancellation() let configuration: SSHConnectionConfiguration if silently { - guard let snapshot = context.configuration else { throw TermindSSHError.missingCredential } - configuration = snapshot + guard let snapshot = context.additionalSnapshot else { throw TermindSSHError.missingCredential } + configuration = try hosts.additionalConnectionRequest(from: snapshot).configuration } else { let preparation = try hosts.prepareSSHConnection( for: host, submissions: context.submissions, @@ -400,12 +400,13 @@ public final class PortForwardingController { context.task = nil context.connected = true if !silently { - context.configuration = hosts.configurationForAdditionalConnection(from: configuration) + context.additionalSnapshot = hosts.additionalConnectionSnapshot(from: configuration) } - if let snapshot = context.configuration { - await forwardingConnection.configureAdditionalConnections( - SSHConnectionRequest(configuration: snapshot, interactionPolicy: .forbidden) - .enforcingInteractionPolicy()) + if let snapshot = context.additionalSnapshot { + await forwardingConnection.configureAdditionalConnections { [weak self] in + guard let self else { throw CancellationError() } + return try self.hosts.additionalConnectionRequest(from: snapshot) + } } context.submissions.removeAll() context.connection.temporaryHostKeys.removeAll() @@ -551,7 +552,7 @@ public final class PortForwardingController { } private func recover(_ context: PortForwardingConnection, message: String) { - guard context.connection.hasConnectedOnce, context.configuration != nil else { + guard context.connection.hasConnectedOnce, context.additionalSnapshot != nil else { fail(context, message: message) return } @@ -599,7 +600,7 @@ public final class PortForwardingController { context.setPrompt(nil) context.hostKeyRequestID = nil context.submissions.removeAll() - context.configuration = nil + context.additionalSnapshot = nil context.connection.disconnect() if connections[context.hostID] === context { connections[context.hostID] = nil } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift index ebc8e9ca..a8ddabe3 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift @@ -28,7 +28,7 @@ public nonisolated struct SSHForwardingPoolStatus: Hashable, Sendable { public protocol SSHForwardingConnectionProtocol: Actor { func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws - func configureAdditionalConnections(_ request: SSHConnectionRequest) async + func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async func connectionPoolStatus() async -> SSHForwardingPoolStatus func start(_ rule: PortForwardingRule, generation: UUID) async throws func stop(_ id: PortForwardingRule.ID) async @@ -36,7 +36,7 @@ public protocol SSHForwardingConnectionProtocol: Actor { } extension SSHForwardingConnectionProtocol { - public func configureAdditionalConnections(_ request: SSHConnectionRequest) async {} + public func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async {} public func connectionPoolStatus() async -> SSHForwardingPoolStatus { .single } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift index 9272eb26..13c199c2 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift @@ -31,7 +31,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { private var primaryID: UUID? private var registrations: [PortForwardingRule.ID: RuleRegistration] = [:] private var counts: [UUID: [PortForwardingRule.ID: Int]] = [:] - private var additionalRequest: SSHConnectionRequest? + private var additionalRequest: SSHAdditionalConnectionRequestResolver? private var opening: [UUID: Slot] = [:] private var installing: [UUID: Slot] = [:] private var retryTask: Task? @@ -78,8 +78,8 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { } } - func configureAdditionalConnections(_ request: SSHConnectionRequest) async { - additionalRequest = request.enforcingInteractionPolicy() + func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async { + additionalRequest = resolver fillPool() } @@ -172,9 +172,10 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { let id = UUID() let slot = makeSlot(id: id) opening[id] = slot - let request = additionalRequest! + let resolver = additionalRequest! Task { [weak self] in do { + let request = try await resolver() try await slot.connection.connect(request, progress: nil) await self?.opened(slot) } catch { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 1b681de1..94adf0a1 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -1037,12 +1037,14 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { var secureEnclaveError: (any Error)? var remaining = credentials var attemptedInteractive = false + var skippedInteractiveCredential = false while true { guard let index = remaining.firstIndex(where: { credential in switch credential { case .password: methods.contains("password") - case .privateKey, .secureEnclave: methods.contains("publickey") + case .privateKey, .secureEnclave, .interactionRequired: + methods.contains("publickey") } }) else { @@ -1079,7 +1081,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } if result == 0 { return lastMethod } } - case .privateKey(let privateKey, let publicKey, let passphrase): + case .privateKey(let privateKey, let publicKey, let passphrase, _): guard methods.contains("publickey") else { continue } attempted = true lastMethod = "public_key" @@ -1105,7 +1107,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } if result == 0 { return lastMethod } - case .secureEnclave(let credential, let publicKey): + case .secureEnclave(let credential, let publicKey, _): guard methods.contains("publickey") else { continue } guard let publicBlob = SecureEnclaveKey.publicBlob(from: publicKey) else { secureEnclaveError = TermindSSHError.library( @@ -1131,10 +1133,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } if result == 0 { return lastMethod } secureEnclaveError = signer.error + case .interactionRequired: + skippedInteractiveCredential = true } methods = try authenticationMethods(username: username, session: session) if libssh2_userauth_authenticated(session) == 1 { return lastMethod } } + if skippedInteractiveCredential { throw SSHInteractionRequired() } if let secureEnclaveError { throw TermindSSHError.authenticationFailed(secureEnclaveError.localizedDescription) } diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditService.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditService.swift index e5ea0937..25d79bca 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditService.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditService.swift @@ -24,20 +24,22 @@ public final class SFTPEditService { } let hops: [Hop] - init(_ configuration: SSHConnectionConfiguration) { - func proxy(_ value: SSHTransportProxy?) -> [String] { + init(_ snapshot: SSHAdditionalConnectionSnapshot) { + func proxy(_ value: SSHAdditionalConnectionSnapshot.Proxy?) -> [String] { guard let value else { return [] } - return [value.kind.rawValue, value.hostname, String(value.port), value.username] + return [value.kind.rawValue, value.hostname, String(value.port)] } hops = - configuration.jumps.map { + snapshot.jumps.map { Hop( - host: $0.host, port: $0.port, username: $0.username, keys: $0.expectedHostKeys, + host: $0.host, port: $0.port, username: $0.authentication.username, + keys: $0.expectedHostKeys, proxy: proxy($0.proxy)) } + [ Hop( - host: configuration.host, port: configuration.port, username: configuration.username, - keys: configuration.expectedHostKeys, proxy: proxy(configuration.proxy)) + host: snapshot.target.host, port: snapshot.target.port, + username: snapshot.target.authentication.username, + keys: snapshot.target.expectedHostKeys, proxy: proxy(snapshot.target.proxy)) ] } } @@ -64,12 +66,12 @@ public final class SFTPEditService { public func open( _ file: RemoteFile, hostID: Host.ID, connection: SFTPTransferConnection, client: any SFTPChannelProtocol, homeDirectory: String? = nil, - configuration: SSHConnectionConfiguration? = nil + snapshot: SSHAdditionalConnectionSnapshot? = nil ) async throws -> URL { validateHosts() if let existing = model.file(hostID: hostID, path: file.path) { guard !existing.isInvalidated, - matchesDestination(existing.id, connection: connection, configuration: configuration) + matchesDestination(existing.id, connection: connection, snapshot: snapshot) else { throw SFTPEditFailure.unavailable } return existing.localURL } @@ -77,7 +79,7 @@ public final class SFTPEditService { if let task = opening[key] { let url = try await task.value if let existing = model.file(hostID: hostID, path: file.path), - !matchesDestination(existing.id, connection: connection, configuration: configuration) + !matchesDestination(existing.id, connection: connection, snapshot: snapshot) { throw SFTPEditFailure.unavailable } @@ -126,7 +128,7 @@ public final class SFTPEditService { guard generations[hostID, default: 0] == generation else { throw SFTPEditFailure.unavailable } if let originalHost { originalHosts[hostID] = originalHost } let id = model.register(hostID: hostID, file: original, localURL: url, digest: digest) - destinations[id] = destination(connection, configuration: configuration) + destinations[id] = destination(connection, snapshot: snapshot) validateHosts() startMonitoring() } @@ -235,14 +237,14 @@ public final class SFTPEditService { public func upload( _ id: UUID, connection: SFTPTransferConnection, client: any SFTPChannelProtocol, homeDirectory: String? = nil, replacing: Bool = false, - configuration: SSHConnectionConfiguration? = nil, + snapshot: SSHAdditionalConnectionSnapshot? = nil, didUpload: @escaping () -> Void = {} ) async -> Bool { validateHosts() guard let entry = model.file(id), !entry.isPreparing, entry.transferID == nil, !entry.isInvalidated else { return false } - guard matchesDestination(id, connection: connection, configuration: configuration) else { return false } + guard matchesDestination(id, connection: connection, snapshot: snapshot) else { return false } model.update(id) { $0.isPreparing = true $0.error = nil @@ -301,18 +303,18 @@ public final class SFTPEditService { } private func destination( - _ connection: SFTPTransferConnection, configuration: SSHConnectionConfiguration? + _ connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot? ) -> Destination? { - if let configuration { return Destination(configuration) } + if let snapshot { return Destination(snapshot) } if case .saved(let saved) = connection { return Destination(saved) } - if case .context(let session) = connection { return Destination(session.configuration) } + if case .context(let session) = connection { return Destination(session.snapshot) } return nil } private func matchesDestination( - _ id: UUID, connection: SFTPTransferConnection, configuration: SSHConnectionConfiguration? + _ id: UUID, connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot? ) -> Bool { - guard destinations[id] == destination(connection, configuration: configuration) else { + guard destinations[id] == destination(connection, snapshot: snapshot) else { model.update(id) { $0.isInvalidated = true $0.error = SFTPEditFailure.unavailable.localizedDescription diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift index 6631ad8d..10c685ba 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift @@ -16,7 +16,8 @@ public nonisolated enum SFTPTransferPrimaryRole: Hashable, Sendable { @MainActor public final class SFTPTransferContext { public let id = UUID() - public let configuration: SSHConnectionConfiguration + public let snapshot: SSHAdditionalConnectionSnapshot + private weak var hostStore: (any RemoteSessionHostStore)? private var clientFactory: SFTPTransferCoordinator.ClientFactory private var primary: (any SFTPChannelProtocol)? private var ownerAttached = true @@ -31,11 +32,13 @@ public final class SFTPTransferContext { var availabilityChanged: (() -> Void)? public init( - configuration: SSHConnectionConfiguration, channel: any SFTPChannelProtocol, + snapshot: SSHAdditionalConnectionSnapshot, hostStore: (any RemoteSessionHostStore)? = nil, + channel: any SFTPChannelProtocol, primaryRole: SFTPTransferPrimaryRole = .control, clientFactory: @escaping SFTPTransferCoordinator.ClientFactory = { SFTPClient() } ) { - self.configuration = configuration + self.snapshot = snapshot + self.hostStore = hostStore self.primary = channel self.clientFactory = clientFactory self.primaryRole = primaryRole @@ -54,6 +57,10 @@ public final class SFTPTransferContext { clientFactory = factory } + public func configureHostStore(_ store: any RemoteSessionHostStore) { + hostStore = store + } + func suspendTransfer(_ id: UUID) { transfers.remove(id) Task { await closeIfUnused() } @@ -203,9 +210,9 @@ public final class SFTPTransferContext { } private func openSilently(cancellation: TransferCancellationToken?) async throws -> any SFTPChannelProtocol { + guard let hostStore else { throw SFTPError.notConnected } let client = clientFactory() - let request = SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden) - .enforcingInteractionPolicy() + let request = try hostStore.additionalConnectionRequest(from: snapshot) let opening = Task { try await client.connect(using: request, progress: nil, connectionFailure: nil) } diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift index c405a384..5efccde1 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift @@ -10,7 +10,7 @@ public nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable { } public nonisolated enum SFTPTransferConnection: Sendable { - case saved(SSHConnectionConfiguration) + case saved(SSHAdditionalConnectionSnapshot) case context(SFTPTransferContext) case borrowed(id: UUID, channel: any SFTPChannelProtocol) @@ -40,7 +40,11 @@ public nonisolated struct SFTPRemoteCopySource: Sendable { } public init(hostID: Host.ID, configuration: SSHConnectionConfiguration, files: [RemoteFile]) { - self.init(hostID: hostID, connection: .saved(configuration), files: files) + self.init( + hostID: hostID, + connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)), + files: files + ) } } @@ -276,7 +280,7 @@ public final class SFTPTransferCoordinator { enqueue( direction: direction, batchID: batchID, localURL: localURL, remotePath: remotePath, byteCount: byteCount, hostID: hostID, - connection: .saved(configuration), scopedRoot: scopedRoot, + connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)), scopedRoot: scopedRoot, overwrite: overwrite, homeDirectory: homeDirectory, completion: completion ) } @@ -329,9 +333,9 @@ public final class SFTPTransferCoordinator { ) { enqueueRemoteCopy( batchID: batchID, sourcePath: sourcePath, sourceHostID: sourceHostID, - sourceConnection: .saved(sourceConfiguration), + sourceConnection: .saved(SSHAdditionalConnectionSnapshot(configuration: sourceConfiguration)), destinationPath: destinationPath, destinationHostID: destinationHostID, - destinationConnection: .saved(destinationConfiguration), + destinationConnection: .saved(SSHAdditionalConnectionSnapshot(configuration: destinationConfiguration)), byteCount: byteCount, overwrite: overwrite, completion: completion ) } @@ -722,13 +726,10 @@ public final class SFTPTransferCoordinator { switch connection { case .context(let context): return try await context.acquire(mode: mode, cancellation: cancellation) - case .saved(let configuration): + case .saved(let snapshot): let client = clientFactory() do { - let request = SSHConnectionRequest( - configuration: configuration, - interactionPolicy: .forbidden - ).enforcingInteractionPolicy() + let request = try store.additionalConnectionRequest(from: snapshot) let openingRequest = request let opening = Task { try await client.connect(using: openingRequest, progress: nil, connectionFailure: nil) diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 98e70f7a..dafd9766 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -104,11 +104,14 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r - User sessions own challenge queues; Agents use interaction queues. Desktop external auth separates app/MCP origins; stopping MCP preserves app requests. Mobile has no app auth sheet. Forwarding/Metrics own Journey challenges, never external-window fallback. -- Terminal drops/additional transfer connections forbid app password/passphrase/MFA/trust forms throughout the route, - but permit system key authorization. Username-only/SSH `none` remain eligible: actual connection success, not saved - credential presence, decides. Cancellation interrupts opening. A terminal drop always tries its independent SFTP - preflight first; any non-cancellation connection failure, including interaction-required authentication, falls back to - SFTP Channels on the already authenticated connection belonging to the PTY Channel without presenting a form. +- Terminal drops/additional transfer and forwarding connections forbid password/passphrase/MFA/trust forms and system + user-presence authorization (Touch ID or device password) throughout the route. Reusable snapshots freeze target, + Jump Host and proxy network data but retain authentication only as Identity IDs, plus a Key ID only when no Identity + exists. Each silent open resolves the Identity's current username/password/Key from Vault and Keychain. Protected Keys + are skipped before their secret is read; password and other noninteractive methods remain eligible. Passphrases and + temporary passwords never enter reusable snapshots. Username-only/SSH `none` remain eligible. Cancellation interrupts + opening. A terminal drop tries an independent SFTP preflight when durable references are available; any non-cancellation + failure falls back to SFTP Channels on the already authenticated PTY connection without presenting a form. - Username-only Identities try `none`, then advertised methods. Device-local `lastUsedAt` tracks successful saved-secret reads, not authentication. Batch route reads; missing/availability/edit reads do not count. First access per Identity/ process writes directly without Vault journal/refresh; sync strips this field and preserves local values on apply. diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 7a0cea2c..f9fa19e3 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -81,7 +81,7 @@ final class MobileWorkspace: Identifiable { let sftpEdits: SFTPEditService? let transfers: SFTPTransferCoordinator? private var transferContext: SFTPTransferContext? - private var transferConfiguration: SSHConnectionConfiguration? + private var transferSnapshot: SSHAdditionalConnectionSnapshot? private(set) var uploadNotice: WorkspaceUploadNotice? private var pendingUpload: MobilePendingUpload? private var uploadPreparationTask: Task? @@ -184,12 +184,12 @@ final class MobileWorkspace: Identifiable { case .ssh: return TerminalUploadAvailability.problem( isConnected: connected, - hasTransferConfiguration: transferConfiguration != nil, + hasTransferConfiguration: transferSnapshot != nil, isWorkingDirectoryPending: workingDirectory == nil, workingDirectory: workingDirectory ) case .sftp: - guard connected, transferConfiguration != nil, sftp != nil else { + guard connected, transferSnapshot != nil, sftp != nil else { return String(localized: "The SFTP channel is not connected.") } return nil @@ -638,10 +638,11 @@ final class MobileWorkspace: Identifiable { journey.receive(.connected(host.id)) } guard generation == token, !Task.isCancelled else { return } - let additionalConfiguration = hosts.configurationForAdditionalConnection(from: config) - transferConfiguration = additionalConfiguration + let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config) + transferSnapshot = additionalSnapshot if let sftp { - transferContext = SFTPTransferContext(configuration: additionalConfiguration, channel: sftp) + transferContext = SFTPTransferContext( + snapshot: additionalSnapshot, hostStore: hosts, channel: sftp) } submissions.removeAll() let wasReconnecting = connection.markConnected() @@ -768,7 +769,7 @@ final class MobileWorkspace: Identifiable { sftpChannel.detach() busy = false connected = false - transferConfiguration = nil + transferSnapshot = nil status = String(localized: "Disconnected") connectionFailure = reconnectState.lastError journey.fail(reconnectState.lastError) @@ -797,7 +798,7 @@ final class MobileWorkspace: Identifiable { sftpChannel.detach() busy = false connected = false - transferConfiguration = nil + transferSnapshot = nil activeHistoryID = nil authenticationRequired = false authenticationPrompt = nil @@ -831,7 +832,7 @@ final class MobileWorkspace: Identifiable { busy = false connected = false ssh = nil - transferConfiguration = nil + transferSnapshot = nil switch result { case .success: connectionResumeIntent = .none @@ -865,7 +866,7 @@ final class MobileWorkspace: Identifiable { sftpChannel.detach() busy = false connected = false - transferConfiguration = nil + transferSnapshot = nil status = String(localized: "Reconnecting") if announcesLoss, descriptor.kind == .ssh { appendTerminalNotice(String(localized: "Connection lost; reconnecting…")) @@ -914,7 +915,7 @@ final class MobileWorkspace: Identifiable { generation = UUID() attempt?.cancel() attempt = nil - transferConfiguration = nil + transferSnapshot = nil let oldSSH = ssh let oldSFTP = sftp let oldTransferContext = transferContext @@ -1008,7 +1009,7 @@ final class MobileWorkspace: Identifiable { let urls = urls.filter(\.isFileURL) guard !urls.isEmpty else { return false } cancelPendingUpload() - guard transfers != nil, let configuration = transferConfiguration, + guard transfers != nil, let snapshot = transferSnapshot, let destination = uploadDestination else { showUploadNotice( @@ -1040,11 +1041,7 @@ final class MobileWorkspace: Identifiable { client = sftp } else { client = try await connectionServices.connectSFTP( - SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden) - .enforcingInteractionPolicy(), - nil, - nil - ) + hosts.additionalConnectionRequest(from: snapshot), nil, nil) ownsClient = true } try Task.checkCancellation() @@ -1069,7 +1066,8 @@ final class MobileWorkspace: Identifiable { let context: SFTPTransferContext if ownsClient { context = SFTPTransferContext( - configuration: configuration, channel: client, primaryRole: .donatedConnection) + snapshot: snapshot, hostStore: hosts, + channel: client, primaryRole: .donatedConnection) } else { guard let existing = transferContext else { throw SFTPError.notConnected } context = existing @@ -1111,7 +1109,7 @@ final class MobileWorkspace: Identifiable { } func prepareRemoteCopy(_ payload: SFTPRemoteItemsPayload) { - guard let transfers, transferConfiguration != nil, + guard let transfers, transferSnapshot != nil, let sftp, connected else { operationError = String(localized: "The SFTP connection is not available.") @@ -1152,7 +1150,7 @@ final class MobileWorkspace: Identifiable { } func prepareDownload(_ file: RemoteFile, to destination: URL) { - guard transfers != nil, transferConfiguration != nil, + guard transfers != nil, transferSnapshot != nil, let sftp, connected else { operationError = String(localized: "The SFTP connection is not available.") diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 063d9e2d..566dc992 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -997,7 +997,7 @@ struct MobileUploadBatchTests { batch, policy: .replace, hostID: hostID, - connection: .saved(configuration), + connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)), client: client, coordinator: coordinator ) @@ -1033,7 +1033,8 @@ struct MobileUploadBatchTests { await #expect(throws: (any Error).self) { try await SFTPLocalUploadPlanner.enqueue( - batch, policy: .replace, hostID: hostID, connection: .saved(configuration), + batch, policy: .replace, hostID: hostID, + connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)), client: client, coordinator: coordinator) } #expect(await client.listedDirectories == [target.path]) @@ -1056,7 +1057,8 @@ struct MobileUploadBatchTests { let task = Task { @MainActor in withUnsafeCurrentTask { $0?.cancel() } return try await SFTPLocalUploadPlanner.enqueue( - batch, policy: .replace, hostID: hostID, connection: .saved(configuration), + batch, policy: .replace, hostID: hostID, + connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)), client: client, coordinator: coordinator) } diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 8ca03519..5d9850bd 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -151,7 +151,7 @@ final class SFTPChannelModel: Identifiable { private var client: (any SFTPChannelProtocol)? private var transferContext: SFTPTransferContext? - private var configuration: SSHConnectionConfiguration? + private var additionalSnapshot: SSHAdditionalConnectionSnapshot? private var usesEphemeralCredentials = false private var connectionTask: Task? private var startsOnMount = true @@ -178,6 +178,8 @@ final class SFTPChannelModel: Identifiable { initialPath: String = "", client: (any SFTPChannelProtocol)? = nil, configuration: SSHConnectionConfiguration? = nil, + additionalSnapshot: SSHAdditionalConnectionSnapshot? = nil, + hostStore: (any RemoteSessionHostStore)? = nil, primaryRole: SFTPTransferPrimaryRole = .control, homeResolution: RemoteHomeResolutionState = .pending, automaticRefreshInterval: Duration = .seconds(15) @@ -190,10 +192,13 @@ final class SFTPChannelModel: Identifiable { self.pathText = path self.client = client startsOnMount = client == nil - self.configuration = configuration - if let client, let configuration { + let retainedSnapshot = + additionalSnapshot ?? configuration.map(SSHAdditionalConnectionSnapshot.init(configuration:)) + self.additionalSnapshot = retainedSnapshot + if let client, let retainedSnapshot { transferContext = SFTPTransferContext( - configuration: configuration, channel: client, primaryRole: primaryRole) + snapshot: retainedSnapshot, hostStore: hostStore, + channel: client, primaryRole: primaryRole) } self.homeResolution = homeResolution self.automaticRefreshInterval = automaticRefreshInterval @@ -350,7 +355,9 @@ final class SFTPChannelModel: Identifiable { credentials.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: suppliedAuthentication.passphrase + passphraseOverride: suppliedAuthentication.passphrase, + requiresPassphrase: key.requiresPassphraseEntry, + requiresUserPresence: key.requiresUserPresence )) } if let password = suppliedAuthentication.password { @@ -366,6 +373,16 @@ final class SFTPChannelModel: Identifiable { for: host, submissions: suppliedAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge + ), + authenticationReference: SSHAuthenticationReference( + identityID: suppliedAuthentication.identityID, + fallbackKeyID: suppliedAuthentication.identityID == nil + ? suppliedAuthentication.keyID : nil, + username: suppliedAuthentication.username, + permitsNone: suppliedAuthentication.keyID == nil + && suppliedAuthentication.password == nil, + hasEphemeralSecret: suppliedAuthentication.password != nil + || suppliedAuthentication.passphrase != nil ) ) } else { @@ -415,9 +432,10 @@ final class SFTPChannelModel: Identifiable { } self.connectionJourney.receive(.connected(host.id)) self.client = client - let transferConfiguration = store.configurationForAdditionalConnection(from: configuration) - self.transferContext = SFTPTransferContext(configuration: transferConfiguration, channel: client) - self.configuration = transferConfiguration + let additionalSnapshot = store.additionalConnectionSnapshot(from: configuration) + self.transferContext = SFTPTransferContext( + snapshot: additionalSnapshot, hostStore: store, channel: client) + self.additionalSnapshot = additionalSnapshot self.suppliedAuthentications.removeAll() self.temporaryHostKeys.removeAll() self.pendingAuthentication = nil @@ -483,6 +501,7 @@ final class SFTPChannelModel: Identifiable { func bindStatus(to store: any RemoteSessionHostStore) { statusStore = store + transferContext?.configureHostStore(store) store.setConnectionStatus(status, connectionID: id, hostID: hostID) } @@ -507,7 +526,7 @@ final class SFTPChannelModel: Identifiable { let transferContext = transferContext self.transferContext = nil self.client = nil - configuration = nil + additionalSnapshot = nil usesEphemeralCredentials = false status = .disconnected homeResolution = .unavailable @@ -533,7 +552,7 @@ final class SFTPChannelModel: Identifiable { let transferContext = transferContext self.transferContext = nil self.client = nil - configuration = nil + additionalSnapshot = nil status = .failed(reconnectState.lastError) homeResolution = .unavailable hint = reconnectState.lastError @@ -851,7 +870,7 @@ final class SFTPChannelModel: Identifiable { do { let url = try await edits.open( file, hostID: hostID, connection: connection, client: client, - homeDirectory: homeResolution.resolvedDirectory, configuration: configuration) + homeDirectory: homeResolution.resolvedDirectory, snapshot: additionalSnapshot) completion(url) } catch { operationError = error.localizedDescription } } @@ -881,7 +900,7 @@ final class SFTPChannelModel: Identifiable { guard status == .connected, let connection = transferConnection, let client else { return false } return await edits.upload( id, connection: connection, client: client, - homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, configuration: configuration + homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, snapshot: additionalSnapshot ) { [weak self] in self?.scheduleSilentRefresh() } } @@ -1068,7 +1087,7 @@ final class SFTPChannelModel: Identifiable { let transferContext = transferContext self.transferContext = nil self.client = nil - configuration = nil + additionalSnapshot = nil status = .reconnecting hint = String(localized: "Reconnecting") let host = store.host(hostID) diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 7d86b66b..74610376 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -228,10 +228,10 @@ final class SSHSessionModel: Identifiable { private var reconnectController: SessionReconnectController { connection.reconnect } private var workingDirectoryObservation: AnyCancellable? private var remoteWindowTitleObservation: AnyCancellable? - /// The exact authenticated route used by the live terminal, retained only - /// in memory so a drop can open an independent SFTP connection without - /// asking for credentials a second time. Cleared with the SSH connection. - private(set) var transferConfiguration: SSHConnectionConfiguration? + /// The live terminal's frozen route and credential IDs. Password, Key, and + /// passphrase material are resolved only for each attempted transfer + /// connection and never retained here. Cleared with the SSH connection. + private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot? private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24) var connectionRoute: SSHConnectionRoute? private var transientHost: Host? @@ -371,7 +371,17 @@ final class SSHSessionModel: Identifiable { } var agentConnectionConfiguration: SSHConnectionConfiguration? { - transferConfiguration + guard let transferSnapshot, let statusStore else { return nil } + return try? statusStore.additionalConnectionRequest(from: transferSnapshot).configuration + } + + var transferHostStore: (any RemoteSessionHostStore)? { statusStore } + + func additionalConnectionRequest( + from snapshot: SSHAdditionalConnectionSnapshot + ) throws -> SSHConnectionRequest { + guard let statusStore else { throw TermindSSHError.missingCredential } + return try statusStore.additionalConnectionRequest(from: snapshot) } func requestTerminalFocus() { @@ -450,7 +460,7 @@ final class SSHSessionModel: Identifiable { homeResolution = .unavailable let reconnecting = connection.hasConnectedOnce && connection.isRecovering workingDirectory = nil - transferConfiguration = nil + transferSnapshot = nil commandExcludedFromHistory = nil statusStore = store let attemptID = UUID() @@ -523,7 +533,9 @@ final class SSHSessionModel: Identifiable { credentials.append( credential.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: suppliedAuthentication.passphrase + passphraseOverride: suppliedAuthentication.passphrase, + requiresPassphrase: key.requiresPassphraseEntry, + requiresUserPresence: key.requiresUserPresence ) ) } else { @@ -621,13 +633,31 @@ final class SSHSessionModel: Identifiable { connection.clearHostKeyPrompt() connectionTask?.cancel() status = reconnecting ? .reconnecting : .connecting + let authenticationReference: SSHAuthenticationReference + if let identityID = suppliedAuthentication?.identityID ?? (suppliedAuthentication == nil ? identity?.id : nil) { + authenticationReference = SSHAuthenticationReference( + identityID: identityID, + username: username, + permitsNone: identity?.hasSecret == false, + hasEphemeralSecret: suppliedAuthentication?.password != nil + || suppliedAuthentication?.passphrase != nil + ) + } else { + authenticationReference = SSHAuthenticationReference( + fallbackKeyID: selectedKeyID, + username: username, + permitsNone: selectedKeyID == nil && suppliedAuthentication?.password == nil, + hasEphemeralSecret: suppliedAuthentication?.password != nil + || suppliedAuthentication?.passphrase != nil + ) + } connectionTask = Task { [weak self, weak store] in guard let self, let store else { return } do { try await openSSH( host: host, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps, - attemptID: attemptID, + authenticationReference: authenticationReference, attemptID: attemptID, authorizeHostKey: { [weak self, weak store] challenge in guard let self, let store else { throw CancellationError() } return await self.connection.authorizeHostKey( @@ -760,7 +790,7 @@ final class SSHSessionModel: Identifiable { remoteWindowTitle = nil workingDirectory = nil homeResolution = .unavailable - transferConfiguration = nil + transferSnapshot = nil pendingInitialCommand = nil commandExcludedFromHistory = nil pendingAuthentication = nil @@ -795,7 +825,7 @@ final class SSHSessionModel: Identifiable { host: Host, username: String, credentials: [SSHCredentials], expectedHostKeys: Set, proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration], - attemptID: UUID, + authenticationReference: SSHAuthenticationReference, attemptID: UUID, authorizeHostKey: @escaping SSHHostKeyAuthorizer ) async throws { let wasReconnecting = connection.isRecovering @@ -803,7 +833,8 @@ final class SSHSessionModel: Identifiable { let configuration = SSHConnectionConfiguration( hostID: host.id, host: host.host, port: host.port, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, - proxy: proxy, jumps: jumps + proxy: proxy, jumps: jumps, + authenticationReference: authenticationReference ) let backend = terminalBackend let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel( @@ -838,9 +869,9 @@ final class SSHSessionModel: Identifiable { ) try Task.checkCancellation() guard connectionAttemptID == attemptID else { throw CancellationError() } - transferConfiguration = - statusStore?.configurationForAdditionalConnection(from: configuration) - ?? configuration + transferSnapshot = + statusStore?.additionalConnectionSnapshot(from: configuration) + ?? SSHAdditionalConnectionSnapshot(configuration: configuration) suppliedAuthentications.removeAll() temporaryHostKeys.removeAll() _ = connection.markConnected() @@ -876,7 +907,7 @@ final class SSHSessionModel: Identifiable { remoteWindowTitle = nil workingDirectory = nil homeResolution = .unavailable - transferConfiguration = nil + transferSnapshot = nil connectionAttemptID = nil ssh = nil switch result { @@ -921,7 +952,7 @@ final class SSHSessionModel: Identifiable { connectionTask = nil if let ssh { Task { await ssh.disconnect() } } ssh = nil - transferConfiguration = nil + transferSnapshot = nil status = .reconnecting let host = connectionHost(hostID, using: store) if let connectionRoute, let host { diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index 4eb471d4..11cb0afd 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -180,7 +180,7 @@ struct TerminalPlaceholderView: View { private var uploadTargetProblem: String? { TerminalUploadAvailability.problem( isConnected: sshSession.status == .connected, - hasTransferConfiguration: sshSession.transferConfiguration != nil, + hasTransferConfiguration: sshSession.transferSnapshot != nil, isWorkingDirectoryPending: sshSession.homeResolution.isPending, workingDirectory: sshSession.workingDirectory ) @@ -201,7 +201,7 @@ struct TerminalPlaceholderView: View { guard !files.isEmpty else { return false } cancelPendingUpload() guard uploadTargetProblem == nil, - let configuration = sshSession.transferConfiguration, + let snapshot = sshSession.transferSnapshot, let workingDirectory = sshSession.workingDirectory else { showUploadNotice( @@ -232,9 +232,9 @@ struct TerminalPlaceholderView: View { let resolved = try await TerminalUploadConnectionResolver.resolve( isCurrent: { sshSession.connectionAttemptID == generation }, connectIndependent: { - try await app.connections.connectSFTP( - using: SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden), - progress: nil + let request = try sshSession.additionalConnectionRequest(from: snapshot) + return try await app.connections.connectSFTP( + using: request, progress: nil ) }, openPTYSFTPChannel: { @@ -248,7 +248,8 @@ struct TerminalPlaceholderView: View { hostID: sshSession.hostID, initialPath: destination, client: client, - configuration: configuration, + additionalSnapshot: snapshot, + hostStore: sshSession.transferHostStore, primaryRole: resolved.primaryRole, homeResolution: sshSession.homeResolution ) diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 5a69fccc..1d9a05ae 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -536,7 +536,7 @@ private struct PaneView: View { case .sshSession(let sshSession): if let problem = TerminalUploadAvailability.problem( isConnected: sshSession.status == .connected, - hasTransferConfiguration: sshSession.transferConfiguration != nil, + hasTransferConfiguration: sshSession.transferSnapshot != nil, isWorkingDirectoryPending: sshSession.homeResolution.isPending, workingDirectory: sshSession.workingDirectory ) { diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index db3574fe..32de9a31 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -195,7 +195,7 @@ struct AgentHostAuthenticationTests { let configuration = try await session.configuration() - guard case .privateKey(_, _, let passphrase) = configuration.credentials.first else { + guard case .privateKey(_, _, let passphrase, _) = configuration.credentials.first else { Issue.record("Expected a private-key credential") return } @@ -276,7 +276,7 @@ struct AgentHostAuthenticationTests { ))) let resolved = try await configuration.value - guard case .privateKey(_, _, let passphrase) = resolved.credentials.first else { + guard case .privateKey(_, _, let passphrase, _) = resolved.credentials.first else { Issue.record("Expected a private-key credential") return } diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index abe484f2..c180fe6f 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -43,6 +43,24 @@ struct ArchitectureBoundaryTests { } } + @Test + func additionalConnectionOwnersRetainReferencesInsteadOfCredentials() throws { + let paths = [ + "../Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift", + "Features/Terminal/SSHSessionModel.swift", + "../Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift", + ] + for path in paths { + let source = try String(contentsOf: sourceRoot.appending(path: path), encoding: .utf8) + #expect(containsToken("SSHAdditionalConnectionSnapshot", in: source)) + } + let transferContext = try String( + contentsOf: sourceRoot.appending( + path: "../Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift"), + encoding: .utf8) + #expect(!containsToken("SSHConnectionConfiguration", in: transferContext)) + } + @Test func mobileLayersKeepUIAndInfrastructureSeparate() throws { try assertNoImports( diff --git a/TFMTests/PortForwardingReconnectTests.swift b/TFMTests/PortForwardingReconnectTests.swift index 7a091ae8..98e37eb2 100644 --- a/TFMTests/PortForwardingReconnectTests.swift +++ b/TFMTests/PortForwardingReconnectTests.swift @@ -169,7 +169,7 @@ struct PortForwardingReconnectTests { #expect(!context.journey.isPresented) } - @Test func temporaryPasswordAndKeyPassphraseSurviveOnlyInTheConnectionSnapshot() async throws { + @Test func temporaryPasswordAndKeyPassphraseAreAbsentFromRecoverySnapshot() async throws { let fixture = ReconnectFixture() defer { fixture.stop() } let material = try SSHKeyMaterial.generate(.ed25519, comment: "recovery", passphrase: "temporary-passphrase") @@ -192,15 +192,11 @@ struct PortForwardingReconnectTests { try await fixture.waitForRunning([0]) let request = try #require(await fixture.sessions.last!.requests.first) #expect(request.interactionPolicy == .forbidden) - #expect(request.configuration.credentials.count == 2) - guard case .privateKey(_, _, let passphrase) = request.configuration.credentials[0], - case .password(let password) = request.configuration.credentials[1] - else { - Issue.record("Expected the authenticated credential snapshot") + #expect(request.configuration.credentials.count == 1) + guard case .interactionRequired = request.configuration.credentials[0] else { + Issue.record("Expected a reference-only interaction marker") return } - #expect(passphrase == "temporary-passphrase") - #expect(password == "temporary-password") #expect(fixture.library.identity(identity.id)?.usesPassword == false) #expect(fixture.context?.journey.plainTextLog.contains("temporary-") == false) } diff --git a/TFMTests/SFTPEditTests.swift b/TFMTests/SFTPEditTests.swift index b8b40476..f34b8b5a 100644 --- a/TFMTests/SFTPEditTests.swift +++ b/TFMTests/SFTPEditTests.swift @@ -186,19 +186,20 @@ struct SFTPEditTests { @Test func reconnectingWithADifferentAccountCannotUploadAnOldCopy() async throws { let fixture = Fixture() - var configuration = SSHConnectionConfiguration( - hostID: fixture.hostID, host: "test.invalid", port: 22, username: "first", credentials: [], - expectedHostKeys: []) + var snapshot = SSHAdditionalConnectionSnapshot( + configuration: SSHConnectionConfiguration( + hostID: fixture.hostID, host: "test.invalid", port: 22, + username: "first", credentials: [], expectedHostKeys: [])) let url = try await fixture.service.open( fixture.client.file, hostID: fixture.hostID, connection: fixture.connection, - client: fixture.client, configuration: configuration) + client: fixture.client, snapshot: snapshot) defer { fixture.clean(url) } try Data([9]).write(to: url) let id = try #require(fixture.service.model.files.first?.id) - configuration.username = "second" + snapshot.target.authentication.username = "second" #expect( await fixture.service.upload( - id, connection: fixture.connection, client: fixture.client, configuration: configuration) == false) + id, connection: fixture.connection, client: fixture.client, snapshot: snapshot) == false) #expect(fixture.service.model.file(id)?.isInvalidated == true) #expect(await fixture.client.uploads == 0) } diff --git a/TFMTests/SFTPTransferContextTests.swift b/TFMTests/SFTPTransferContextTests.swift index efffe8ba..d48bf501 100644 --- a/TFMTests/SFTPTransferContextTests.swift +++ b/TFMTests/SFTPTransferContextTests.swift @@ -100,9 +100,9 @@ struct SFTPTransferContextTests { let primary = TransferChannelClient(harness: harness, primary: true) let (context, coordinator) = fixture(primary: primary, harness: harness) coordinator.enqueueRemoteCopy( - batchID: UUID(), sourcePath: "/source", sourceHostID: context.configuration.hostID, + batchID: UUID(), sourcePath: "/source", sourceHostID: context.snapshot.hostID, sourceConnection: .context(context), destinationPath: "/copy", - destinationHostID: context.configuration.hostID, destinationConnection: .context(context), + destinationHostID: context.snapshot.hostID, destinationConnection: .context(context), byteCount: 1, overwrite: false) try await eventually { await harness.started == 1 } await context.releaseOwner() @@ -290,12 +290,14 @@ struct SFTPTransferContextTests { let primary = TransferChannelClient(harness: harness, primary: true) let configuration = SSHConnectionConfiguration( hostID: UUID(), host: "terminal", port: 22, username: "user", credentials: [], expectedHostKeys: []) + let store = HostLibraryModel() let coordinator = SFTPTransferCoordinator( - store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) }, + store: store, clientFactory: { TransferChannelClient(harness: harness) }, globalLimit: 15, perHostLimit: 5, strategy: .connectionsPreferred) let upload = SFTPChannelModel( hostID: configuration.hostID, initialPath: "/destination", client: primary, - configuration: configuration, primaryRole: .donatedConnection) + additionalSnapshot: SSHAdditionalConnectionSnapshot(configuration: configuration), + hostStore: store, primaryRole: .donatedConnection) let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } @@ -325,14 +327,16 @@ struct SFTPTransferContextTests { @Test func twoTransferContextsForOneHostShareTheSameFiveTransferLimit() async throws { let harness = TransferChannelHarness(behaviors: []) + let store = HostLibraryModel() let coordinator = SFTPTransferCoordinator( - store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) }, + store: store, clientFactory: { TransferChannelClient(harness: harness) }, globalLimit: 15, perHostLimit: 5, strategy: .connectionsPreferred) let configuration = SSHConnectionConfiguration( hostID: UUID(), host: "same-host", port: 22, username: "user", credentials: [], expectedHostKeys: []) let contexts = (0..<2).map { _ in SFTPTransferContext( - configuration: configuration, channel: TransferChannelClient(harness: harness), + snapshot: SSHAdditionalConnectionSnapshot(configuration: configuration), hostStore: store, + channel: TransferChannelClient(harness: harness), primaryRole: .donatedConnection) } for context in contexts { enqueue(4, context: context, coordinator: coordinator) } @@ -352,13 +356,15 @@ struct SFTPTransferContextTests { primaryRole: SFTPTransferPrimaryRole = .control, strategy: SFTPConcurrencyStrategy = .connectionsPreferred ) -> (SFTPTransferContext, SFTPTransferCoordinator) { + let store = HostLibraryModel() + let configuration = SSHConnectionConfiguration( + hostID: UUID(), host: "fixture", port: 22, username: "user", + credentials: [], expectedHostKeys: []) let context = SFTPTransferContext( - configuration: SSHConnectionConfiguration( - hostID: UUID(), host: "fixture", port: 22, username: "user", - credentials: [], expectedHostKeys: []), + snapshot: SSHAdditionalConnectionSnapshot(configuration: configuration), hostStore: store, channel: primary, primaryRole: primaryRole) let coordinator = SFTPTransferCoordinator( - store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) }, + store: store, clientFactory: { TransferChannelClient(harness: harness) }, globalLimit: limit, perHostLimit: limit, strategy: strategy) return (context, coordinator) } @@ -376,7 +382,7 @@ struct SFTPTransferContextTests { coordinator.enqueue( direction: .upload, batchID: UUID(), localURL: URL(fileURLWithPath: "/tmp/termind-test-\(index)"), remotePath: "/file-\(index)", - byteCount: byteCount, hostID: context.configuration.hostID, connection: .context(context), + byteCount: byteCount, hostID: context.snapshot.hostID, connection: .context(context), scopedRoot: URL(fileURLWithPath: "/tmp"), overwrite: true) } } diff --git a/TFMTests/SSHForwardingWireTests.swift b/TFMTests/SSHForwardingWireTests.swift index b4db8c51..af696135 100644 --- a/TFMTests/SSHForwardingWireTests.swift +++ b/TFMTests/SSHForwardingWireTests.swift @@ -124,8 +124,11 @@ nonisolated struct SSHForwardingWireTests { ] primary.authorizeHostKey = nil try await pool.connect(primary, progress: nil) - await pool.configureAdditionalConnections( - SSHConnectionRequest(configuration: primary.configuration, interactionPolicy: .forbidden)) + await pool.configureAdditionalConnections { + SSHConnectionRequest( + configuration: primary.configuration, interactionPolicy: .forbidden + ).enforcingInteractionPolicy() + } let local = try rule(.local, fixture: fixture) try await pool.start(local, generation: UUID()) defer { Task { await pool.disconnect() } }