diff --git a/FrameworkTests/SSHAuthenticationSubmissionTests.swift b/FrameworkTests/SSHAuthenticationSubmissionTests.swift index 043dc90b..f97ea0b2 100644 --- a/FrameworkTests/SSHAuthenticationSubmissionTests.swift +++ b/FrameworkTests/SSHAuthenticationSubmissionTests.swift @@ -141,7 +141,7 @@ struct SSHAuthenticationSubmissionTests { } @Test - func takingAttemptSecretsLeavesOnlyReentryMarkersInSessionState() { + func takingAttemptSecretsMovesPlaintextIntoOneShotOwners() throws { let hostID = UUID() var retained = [ hostID: SSHAuthenticationSubmission( @@ -150,14 +150,22 @@ struct SSHAuthenticationSubmissionTests { ) ] - let attempt = retained.takeAuthenticationSecrets() + var attempt = retained.takeAuthenticationSecrets() - #expect(attempt[hostID]?.password == "one-use-password") - #expect(attempt[hostID]?.passphrase == "one-use-passphrase") + #expect(attempt[hostID]?.password == nil) + #expect(attempt[hostID]?.passphrase == nil) #expect(retained[hostID]?.password == nil) #expect(retained[hostID]?.passphrase == nil) #expect(retained[hostID]?.requiresPasswordReentry == true) #expect(retained[hostID]?.requiresPassphraseReentry == true) + + let password = try #require(attempt[hostID]?.passwordForConnection()) + let passphrase = try #require(attempt[hostID]?.passphraseForConnection()) + #expect(password.matches("one-use-password")) + #expect(passphrase.matches("one-use-passphrase")) + attempt[hostID]?.clearSecrets() + #expect(password.isConsumed) + #expect(passphrase.isConsumed) } } diff --git a/FrameworkTests/SSHInteractionPolicyTests.swift b/FrameworkTests/SSHInteractionPolicyTests.swift index d4cab150..9c5c1acf 100644 --- a/FrameworkTests/SSHInteractionPolicyTests.swift +++ b/FrameworkTests/SSHInteractionPolicyTests.swift @@ -187,10 +187,13 @@ struct SSHInteractionPolicyTests { } @Test func runtimeSecretCanBeConsumedOnlyOnceAcrossAllReferences() throws { - let secret = SSHSecret("one-use") + var source = Data("one-use".utf8) + let secret = SSHSecret(consumingUTF8: &source) let alias = secret var observedCount = 0 + #expect(source.isEmpty) + try secret.withUnsafeCStringAndConsume { buffer, count in observedCount = count #expect(buffer[0] == CChar(bitPattern: Character("o").asciiValue!)) diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index 5381ac19..6a8e2803 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -44,9 +44,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { ) throws -> SSHConnectionRoute func transportProxy(for host: Host) throws -> SSHTransportProxy? func sshCredentials(for identity: Identity) -> SSHCredentialResolution - func passwordCredential(for identity: Identity) throws -> String? func passwordSecret(for identity: Identity) throws -> SSHSecret? - func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? func recordIdentityCredentialAccess(_ identityIDs: Set, at date: Date) func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID) @@ -262,14 +260,6 @@ extension RemoteSessionHostStore { ).enforcingInteractionPolicy() } - public func connectionPasswordCredential(for identity: Identity) throws -> String? { - let password = try passwordCredential(for: identity) - if password != nil { - recordIdentityCredentialAccess([identity.id], at: Date()) - } - return password - } - public func connectionPasswordSecret(for identity: Identity) throws -> SSHSecret? { let password = try passwordSecret(for: identity) if password != nil { @@ -278,16 +268,6 @@ extension RemoteSessionHostStore { return password } - public func connectionKeyCredential( - for key: SSHKeyRef, identityID: Identity.ID? - ) throws -> StoredSSHKeyCredential? { - let credential = try keyCredential(for: key) - if credential != nil, let identityID { - recordIdentityCredentialAccess([identityID], at: Date()) - } - return credential - } - public func connectionKeySecretCredential( for key: SSHKeyRef, identityID: Identity.ID? ) throws -> SSHResolvedKeyCredential? { diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index 4eab9278..bf07ba43 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -29,7 +29,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } public var nodes: [Node] - public var secrets: any SecretStoring + public var secrets: any SSHRuntimeSecretResolving public var credentialAccessHandler: SSHCredentialAccessHandler? public func resolve( @@ -112,14 +112,16 @@ public nonisolated struct SSHConnectionPreparation: Sendable { identityID: identity.id, username: identity.username, permitsNone: !identity.hasSecret, - hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil + hasEphemeralSecret: node.submission?.hasPassword == true + || node.submission?.hasPassphrase == true ) } 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 + permitsNone: node.key == nil && node.submission?.hasPassword != true, + hasEphemeralSecret: node.submission?.hasPassword == true + || node.submission?.hasPassphrase == true ) } @@ -132,7 +134,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { var result: [SSHCredentials] = [] var retrievalFailure: (any Error)? if let key = node.key { - if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false { + if key.requiresPassphraseEntry, node.submission?.hasPassphrase != true { throw TermindSSHError.keyPassphraseRequired } let stored: SSHResolvedKeyCredential? @@ -149,14 +151,14 @@ public nonisolated struct SSHConnectionPreparation: Sendable { result.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: node.submission?.passphrase, + passphraseOverride: node.submission?.passphraseForConnection(), requiresPassphrase: key.requiresPassphraseEntry, requiresUserPresence: key.requiresUserPresence )) } } try Task.checkCancellation() - if let password = node.submission?.password { + if let password = node.submission?.passwordForConnection() { result.append(.password(password)) } else if node.submission == nil || node.submission?.identityID != nil, let identity = node.identity, identity.usesPassword @@ -215,7 +217,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } public init( - nodes: [Node], secrets: any SecretStoring, + nodes: [Node], secrets: any SSHRuntimeSecretResolving, credentialAccessHandler: SSHCredentialAccessHandler? = nil ) { self.nodes = nodes diff --git a/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift index ff6dc514..0e52ee09 100644 --- a/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift @@ -39,8 +39,8 @@ public final class CredentialLibraryModel { public func keyExportUnavailableReason(for host: Host) -> String? { library.keyExportUnavailableReason(for: host) } - public func sshConnectionConfiguration(for host: Host) throws -> SSHConnectionConfiguration { - try library.sshConnectionConfiguration(for: host) + public func prepareSSHConnection(for host: Host) throws -> SSHConnectionPreparation { + try library.prepareSSHConnection(for: host, submissions: [:]) } public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 003f0a3e..e2ae447c 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -460,7 +460,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { credentials = resolution.credentials } let expectsCredential = - submissions[host.id].map { $0.keyID != nil || $0.password != nil } + submissions[host.id].map { $0.keyID != nil || $0.hasPassword } ?? (identity(for: host)?.hasSecret == true) if credentials.isEmpty, expectsCredential { throw TermindSSHError.missingCredential } let expectedHostKeys = try expectedHostKeys( @@ -645,14 +645,16 @@ public final class HostLibraryModel: RemoteSessionHostStore { identityID: resolvedIdentity.id, username: resolvedIdentity.username, permitsNone: !resolvedIdentity.hasSecret, - hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil + hasEphemeralSecret: submission?.hasPassword == true + || submission?.hasPassphrase == true ) } return SSHAuthenticationReference( fallbackKeyID: submission?.keyID, username: submission?.username ?? "", - permitsNone: submission?.keyID == nil && submission?.password == nil, - hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil + permitsNone: submission?.keyID == nil && submission?.hasPassword != true, + hasEphemeralSecret: submission?.hasPassword == true + || submission?.hasPassphrase == true ) } @@ -662,9 +664,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { ) throws -> [SSHCredentials] { var credentials: [SSHCredentials] = [] if let keyID = submission.keyID, let key = key(keyID) { - if key.requiresPassphraseEntry, - submission.passphrase?.isEmpty != false - { + if key.requiresPassphraseEntry, !submission.hasPassphrase { throw TermindSSHError.keyPassphraseRequired } if let stored = try keySecretCredential(for: key) { @@ -674,14 +674,14 @@ public final class HostLibraryModel: RemoteSessionHostStore { credentials.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: submission.passphrase, + passphraseOverride: submission.passphraseForConnection(), requiresPassphrase: key.requiresPassphraseEntry, requiresUserPresence: key.requiresUserPresence ) ) } } - if let password = submission.password { + if let password = submission.passwordForConnection() { credentials.append(.password(password)) } else if let identityID = submission.identityID, let identity = identity(identityID), identity.usesPassword, @@ -1233,7 +1233,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { public func passwordAvailability(for identity: Identity) -> SecretAvailability { guard identity.usesPassword else { return .notConfigured } do { - return try passwordCredential(for: identity) == nil ? .missing : .available + return try keychain.containsString( + for: identity.credentialRef, storage: identity.passwordStorage + ) ? .available : .missing } catch { return .failed(error.localizedDescription) } @@ -1415,7 +1417,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { do { - return try keyCredential(for: key) == nil ? .missing : .available + return try keychain.containsKeyCredential( + for: key.credentialRef, storage: key.storage + ) ? .available : .missing } catch { return .failed(error.localizedDescription) } diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 58adb684..e048d5fa 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -669,23 +669,16 @@ public final class ServerMetricsModel: MetricsSnapshotReading { server.collectionState = .connecting server.isConnecting = true let route = hosts.route(for: host) - if let passwordHost = route.first(where: { routeHost in - submissions[server.id]?[routeHost.id]?.requiresPasswordReentry == true - }) { + if let passwordHost = submissions[server.id]?.firstHostRequiringPassword(in: route) { throw TermindSSHError.routeHost( hostID: passwordHost.id, authentication: true, serverChallenge: false, hostKey: false, message: TermindSSHError.missingCredential.localizedDescription ) } - if let passphraseHost = route.first(where: { routeHost in - guard let submission = submissions[server.id]?[routeHost.id], - submission.passphrase == nil, - let keyID = submission.keyID, - let key = hosts.key(keyID) - else { return false } - return key.requiresPassphraseEntry - }) { + if let passphraseHost = submissions[server.id]?.firstHostRequiringPassphrase( + in: route, key: hosts.key + ) { throw TermindSSHError.routeHost( hostID: passphraseHost.id, authentication: true, serverChallenge: false, hostKey: false, diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index f9cf6565..fcba3ea9 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -44,6 +44,16 @@ public nonisolated enum SSHHostKeyDecision: Equatable, Sendable { public typealias SSHHostKeyAuthorizer = @Sendable (SSHHostKeyChallenge) async throws -> SSHHostKeyDecision public typealias SSHAdditionalConnectionRequestResolver = @MainActor @Sendable () throws -> SSHConnectionRequest +public nonisolated struct SSHAuthorizedKeyInstall: Hashable, Sendable { + public var publicKey: String + public var name: String + + public init(publicKey: String, name: String) { + self.publicKey = publicKey + self.name = name + } +} + public nonisolated enum SSHHostTrustPolicy: Equatable, Sendable { case requireKnownHost case allowChallenge @@ -263,7 +273,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable { case secureEnclave(SecureEnclaveKeyCredential) public func sshCredential( - publicKey: String, certificate: String = "", passphraseOverride: String? = nil, + publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil, requiresPassphrase: Bool = false, requiresUserPresence: Bool = false ) -> SSHCredentials { switch self { @@ -273,7 +283,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable { publicKey: certificate.isEmpty ? (publicKey.isEmpty ? nil : publicKey) : certificate, - passphrase: passphraseOverride.map(SSHSecret.init), + passphrase: passphraseOverride, requiresPassphrase: requiresPassphrase ) case .secureEnclave(let credential): @@ -287,7 +297,7 @@ public nonisolated enum SSHResolvedKeyCredential: Sendable { nonisolated extension StoredSSHKeyCredential { public func sshCredential( - publicKey: String, certificate: String = "", passphraseOverride: String? = nil, + publicKey: String, certificate: String = "", passphraseOverride: SSHSecret? = nil, requiresPassphrase: Bool = false, requiresUserPresence: Bool = false ) -> SSHCredentials { switch self { diff --git a/Frameworks/TermindApplication/SSHRouteEvent.swift b/Frameworks/TermindApplication/SSHRouteEvent.swift index ad7d0d37..7931fb43 100644 --- a/Frameworks/TermindApplication/SSHRouteEvent.swift +++ b/Frameworks/TermindApplication/SSHRouteEvent.swift @@ -13,7 +13,7 @@ public nonisolated enum SSHRouteEvent: Hashable, Sendable { public typealias SSHRouteProgressHandler = @Sendable (SSHRouteEvent) -> Void -public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable { +public nonisolated struct SSHAuthenticationSubmission: Sendable { public var username: String public var password: String? public var passphrase: String? @@ -21,6 +21,8 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable { public var identityID: Identity.ID? = nil public var requiresPasswordReentry = false public var requiresPassphraseReentry = false + private var runtimePassword: SSHSecret? + private var runtimePassphrase: SSHSecret? public init( username: String, password: String? = nil, passphrase: String? = nil, keyID: SSHKeyRef.ID? = nil, identityID: Identity.ID? = nil @@ -32,22 +34,72 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable { self.identityID = identityID } + public var hasPassword: Bool { password != nil || runtimePassword != nil } + public var hasPassphrase: Bool { + passphrase?.isEmpty == false || runtimePassphrase != nil + } + + public func passwordForConnection() -> SSHSecret? { + runtimePassword ?? password.map(SSHSecret.init) + } + + public func passphraseForConnection() -> SSHSecret? { + runtimePassphrase ?? passphrase.map(SSHSecret.init) + } + public mutating func clearSecrets() { - if password != nil { requiresPasswordReentry = true } - if passphrase != nil { requiresPassphraseReentry = true } + if hasPassword { requiresPasswordReentry = true } + if hasPassphrase { requiresPassphraseReentry = true } password = nil passphrase = nil + runtimePassword?.clear() + runtimePassword = nil + runtimePassphrase?.clear() + runtimePassphrase = nil + } + + fileprivate mutating func prepareForConnectionAttempt() { + if let password { + runtimePassword = SSHSecret(password) + self.password = nil + } + if let passphrase { + if !passphrase.isEmpty { + runtimePassphrase = SSHSecret(passphrase) + } + self.passphrase = nil + } } } -nonisolated extension Dictionary where Value == SSHAuthenticationSubmission { +nonisolated extension Dictionary where Key == Host.ID, Value == SSHAuthenticationSubmission { + public func firstHostRequiringPassword(in route: [Host]) -> Host? { + route.first { self[$0.id]?.requiresPasswordReentry == true } + } + + public func firstHostRequiringPassphrase( + in route: [Host], key: (SSHKeyRef.ID) -> SSHKeyRef? + ) -> 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 + } + } + /// Moves plaintext answers into one connection attempt while leaving only /// non-secret selection metadata in the session's retry state. public mutating func takeAuthenticationSecrets() -> Self { - let result = self - for key in keys { - self[key]?.clearSecrets() + var result: Self = [:] + for key in Array(keys) { + guard var retained = self[key] else { continue } + var attempt = retained + attempt.prepareForConnectionAttempt() + retained.clearSecrets() + self[key] = retained + result[key] = attempt } return result } diff --git a/Frameworks/TermindApplication/SSHSecret.swift b/Frameworks/TermindApplication/SSHSecret.swift index 368a6c36..1c6df658 100644 --- a/Frameworks/TermindApplication/SSHSecret.swift +++ b/Frameworks/TermindApplication/SSHSecret.swift @@ -1,33 +1,64 @@ import Darwin import Foundation +/// Centralized clearing for short-lived buffers that temporarily contain secrets. +public nonisolated enum SecretMemory { + public static func clear(_ bytes: inout Data) { + bytes.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress, raw.count > 0 else { return } + _ = memset_s(base, raw.count, 0, raw.count) + } + bytes.removeAll(keepingCapacity: false) + } +} + /// A one-shot UTF-8 secret used only while authenticating a connection. /// /// Copies of an `SSHSecret` share one backing object. Consuming or clearing any -/// reference therefore invalidates every reference instead of creating another -/// plaintext lifetime through Swift value copying. The backing bytes are -/// overwritten with `memset_s` after use and again from `deinit` as a -/// defensive fallback. +/// reference therefore invalidates every reference. The plaintext lives in one +/// explicitly owned, non-copy-on-write allocation that is overwritten before it +/// is released. public nonisolated final class SSHSecret: @unchecked Sendable { public struct Consumed: Error, Sendable { public init() {} } - private let lock = NSLock() - private var storage: Data? + private struct Storage { + var pointer: UnsafeMutableRawPointer + var count: Int - public init(_ value: String) { - var bytes = Data(value.utf8) - bytes.append(0) - storage = bytes + func clearAndDeallocate() { + _ = memset_s(pointer, count + 1, 0, count + 1) + pointer.deallocate() + } } - /// Transfers a freshly read UTF-8 buffer into wipeable ownership without - /// first materializing an immutable Swift `String`. + private let lock = NSLock() + private var storage: Storage? + + public convenience init(_ value: String) { + var bytes = Data(value.utf8) + self.init(consumingUTF8: &bytes) + } + + /// Copies a freshly read UTF-8 buffer into wipeable ownership, then clears + /// the source buffer before returning. The final allocation includes a NUL + /// terminator without asking `Data` to grow or reallocate its plaintext. public init(consumingUTF8 bytes: inout Data) { - bytes.append(0) - storage = bytes - bytes = Data() + let count = bytes.count + let pointer = UnsafeMutableRawPointer.allocate( + byteCount: count + 1, + alignment: MemoryLayout.alignment + ) + if count > 0 { + bytes.withUnsafeBytes { raw in + guard let source = raw.baseAddress else { return } + pointer.copyMemory(from: source, byteCount: count) + } + } + pointer.storeBytes(of: UInt8(0), toByteOffset: count, as: UInt8.self) + SecretMemory.clear(&bytes) + storage = Storage(pointer: pointer, count: count) } deinit { clear() } @@ -37,56 +68,46 @@ public nonisolated final class SSHSecret: @unchecked Sendable { } /// Exposes the UTF-8 bytes once, including a trailing NUL outside `count`. - /// The memory is wiped before this method returns or throws. + /// The allocation is wiped before this method returns or throws. public func withUnsafeCStringAndConsume( _ body: (UnsafePointer, Int) throws -> Result ) throws -> Result { - var bytes = try take() - defer { Self.wipe(&bytes) } - return try bytes.withUnsafeBytes { raw in - guard let base = raw.baseAddress?.assumingMemoryBound(to: CChar.self) else { - throw Consumed() - } - return try body(base, max(0, raw.count - 1)) - } + let storage = try take() + defer { storage.clearAndDeallocate() } + return try body( + UnsafePointer(storage.pointer.assumingMemoryBound(to: CChar.self)), + storage.count + ) } - /// Test and diagnostic support without extending the secret's lifetime. - /// Production authentication should use `withUnsafeCStringAndConsume`. + /// Test support without extending the secret's lifetime. public func matches(_ value: String) -> Bool { let expected = Array(value.utf8) return lock.withLock { - guard let storage, storage.count == expected.count + 1 else { return false } - return storage.dropLast().elementsEqual(expected) + guard let storage, storage.count == expected.count else { return false } + let bytes = UnsafeRawBufferPointer(start: storage.pointer, count: storage.count) + return bytes.elementsEqual(expected) } } public func clear() { guard - var bytes = lock.withLock({ () -> Data? in + let storage = lock.withLock({ () -> Storage? in let value = storage - storage = nil + self.storage = nil return value }) else { return } - Self.wipe(&bytes) + storage.clearAndDeallocate() } - private func take() throws -> Data { + private func take() throws -> Storage { try lock.withLock { guard let value = storage else { throw Consumed() } storage = nil return value } } - - private static func wipe(_ bytes: inout Data) { - bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in - guard let base = raw.baseAddress, raw.count > 0 else { return } - _ = memset_s(base, raw.count, 0, raw.count) - } - bytes.removeAll(keepingCapacity: false) - } } public nonisolated func == (lhs: SSHSecret, rhs: String) -> Bool { diff --git a/Frameworks/TermindApplication/SecretStoring.swift b/Frameworks/TermindApplication/SecretStoring.swift index 9b627cb3..03f03fe3 100644 --- a/Frameworks/TermindApplication/SecretStoring.swift +++ b/Frameworks/TermindApplication/SecretStoring.swift @@ -9,16 +9,21 @@ public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable { } -public nonisolated protocol SecretStoring: Sendable { - func string(for account: String, storage: PasswordStorage) throws -> String? +public nonisolated protocol SSHRuntimeSecretResolving: Sendable { func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? + func sshKeyCredential( + for account: String, storage: KeyStorage + ) throws -> SSHResolvedKeyCredential? +} + +public nonisolated protocol SecretStoring: SSHRuntimeSecretResolving { + func string(for account: String, storage: PasswordStorage) throws -> String? + func containsString(for account: String, storage: PasswordStorage) throws -> Bool func setString(_ value: String?, for account: String, storage: PasswordStorage) throws func keyCredential( for account: String, storage: KeyStorage ) throws -> StoredSSHKeyCredential? - func sshKeyCredential( - for account: String, storage: KeyStorage - ) throws -> SSHResolvedKeyCredential? + func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool func setKeyCredential( _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage ) throws @@ -27,6 +32,14 @@ public nonisolated protocol SecretStoring: Sendable { } nonisolated extension SecretStoring { + public func containsString(for account: String, storage: PasswordStorage) throws -> Bool { + try string(for: account, storage: storage) != nil + } + + public func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool { + try keyCredential(for: account, storage: storage) != nil + } + public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { try string(for: account, storage: storage).map(SSHSecret.init) } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 0b8f149c..734f8a21 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -248,13 +248,15 @@ public final class AgentHostResolver: Sendable { } + var retainedSubmissions = submissions + let attemptSubmissions = retainedSubmissions.takeAuthenticationSecrets() return InteractiveResolution( configuration: try store.sshConnectionConfiguration( - for: destination, submissions: submissions, + for: destination, submissions: attemptSubmissions, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ), - submissions: submissions, temporaryHostKeys: temporaryHostKeys + submissions: retainedSubmissions, temporaryHostKeys: temporaryHostKeys ) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index a41a5cce..98e4ca9f 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -327,9 +327,7 @@ public final class PortForwardingController { host: host, route: hosts.route(for: host), purpose: .portForwarding, username: context.submissions[host.id]?.username ?? hosts.identity(for: host)?.username) update(context, state: .preparing) - if let passwordHost = context.route.first(where: { routeHost in - context.submissions[routeHost.id]?.requiresPasswordReentry == true - }) { + if let passwordHost = context.submissions.firstHostRequiringPassword(in: context.route) { requestAuthentication( context, error: .routeHost( @@ -341,14 +339,9 @@ public final class PortForwardingController { context.attemptID = nil return } - if let passphraseHost = context.route.first(where: { routeHost in - guard let submission = context.submissions[routeHost.id], - submission.passphrase == nil, - let keyID = submission.keyID, - let key = hosts.key(keyID) - else { return false } - return key.requiresPassphraseEntry - }) { + if let passphraseHost = context.submissions.firstHostRequiringPassphrase( + in: context.route, key: hosts.key + ) { requestAuthentication( context, error: .routeHost( diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift index c059627a..437ed70b 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift @@ -111,8 +111,18 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable { _ publicKey: String, named keyName: String, using configuration: SSHConnectionConfiguration ) async throws { - try await SSHPTYChannel.installAuthorizedKey( - publicKey, named: keyName, + try await installAuthorizedKeys( + [SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)], + using: configuration + ) + } + + public func installAuthorizedKeys( + _ keys: [SSHAuthorizedKeyInstall], + using configuration: SSHConnectionConfiguration + ) async throws { + try await SSHPTYChannel.installAuthorizedKeys( + keys, using: SSHConnectionRequest(configuration: configuration, keyboardInteractive: keyboardInteractive), logger: logger, maxSessionsPolicy: maxSessionsPolicy ) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift index 49ee2f51..3103b315 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionOpener.swift @@ -32,8 +32,20 @@ public nonisolated enum SSHConnectionOpener { logger: Logger = ApplicationLogger.disabled("termind.ssh-key-install"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { - try await SSHPTYChannelWorker.installAuthorizedKey( - publicKey, named: keyName, using: request, logger: logger, + try await installAuthorizedKeys( + [SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)], + using: request, logger: logger, + maxSessionsPolicy: maxSessionsPolicy + ) + } + + public static func installAuthorizedKeys( + _ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest, + logger: Logger = ApplicationLogger.disabled("termind.ssh-key-install"), + maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() + ) async throws { + try await SSHPTYChannelWorker.installAuthorizedKeys( + keys, using: request, logger: logger, maxSessionsPolicy: maxSessionsPolicy) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 1d2fe3ad..54f6dc9e 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -594,8 +594,20 @@ public actor SSHPTYChannel { logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { - try await SSHConnectionOpener.installAuthorizedKey( - publicKey, named: keyName, using: request, logger: logger, + try await installAuthorizedKeys( + [SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)], + using: request, logger: logger, + maxSessionsPolicy: maxSessionsPolicy + ) + } + + public static func installAuthorizedKeys( + _ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest, + logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), + maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() + ) async throws { + try await SSHConnectionOpener.installAuthorizedKeys( + keys, using: request, logger: logger, maxSessionsPolicy: maxSessionsPolicy) } @@ -883,25 +895,51 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { - let input = try authorizedKeyInstallInput(publicKey: publicKey, keyName: keyName) - let session = SSHConnection(logger: logger, maxSessionsPolicy: maxSessionsPolicy) + try await installAuthorizedKeys( + [SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)], + using: request, logger: logger, + maxSessionsPolicy: maxSessionsPolicy + ) + } + + public static func installAuthorizedKeys( + _ keys: [SSHAuthorizedKeyInstall], + using request: SSHConnectionRequest, + logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), + maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() + ) async throws { + let connection = SSHConnection(logger: logger, maxSessionsPolicy: maxSessionsPolicy) + try await installAuthorizedKeys(keys, using: request, connection: connection) + } + + static func installAuthorizedKeys( + _ keys: [SSHAuthorizedKeyInstall], + using request: SSHConnectionRequest, + connection: any SSHConnectionProtocol + ) async throws { + guard !keys.isEmpty else { return } + let inputs = try keys.map { + try authorizedKeyInstallInput(publicKey: $0.publicKey, keyName: $0.name) + } do { - try await session.connect(using: request) + try await connection.connect(using: request) let command = #"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"# - let result = try await session.execute( - command, workingDirectory: nil, stdin: input, timeout: 20, - outputLimit: 64 * 1024, onOutput: nil) - guard result.exitCode == 0 else { - let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) - throw TermindSSHError.library( - detail.isEmpty - ? "The remote authorized_keys update exited with status \(result.exitCode)." - : "The remote authorized_keys update failed: \(detail)") + for input in inputs { + let result = try await connection.execute( + command, workingDirectory: nil, stdin: input, timeout: 20, + outputLimit: 64 * 1024, onOutput: nil) + guard result.exitCode == 0 else { + let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + throw TermindSSHError.library( + detail.isEmpty + ? "The remote authorized_keys update exited with status \(result.exitCode)." + : "The remote authorized_keys update failed: \(detail)") + } } - await session.disconnect() + await connection.disconnect() } catch { - await session.disconnect() + await connection.disconnect() throw error } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift index 40666376..c11b89fb 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift @@ -90,11 +90,11 @@ public nonisolated enum SSHSocketConnector { var request = Data( "CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n".utf8 ) - defer { wipe(&request) } + defer { SecretMemory.clear(&request) } if let password = proxy.password, !proxy.username.isEmpty { var plaintext = Data(proxy.username.utf8) plaintext.append(UInt8(ascii: ":")) - defer { wipe(&plaintext) } + defer { SecretMemory.clear(&plaintext) } try password.withUnsafeCStringAndConsume { buffer, count in plaintext.append( buffer.withMemoryRebound(to: UInt8.self, capacity: count) { @@ -103,7 +103,7 @@ public nonisolated enum SSHSocketConnector { ) } var token = plaintext.base64EncodedData() - defer { wipe(&token) } + defer { SecretMemory.clear(&token) } request.append(Data("Proxy-Authorization: Basic ".utf8)) request.append(token) request.append(Data("\r\n".utf8)) @@ -152,7 +152,7 @@ public nonisolated enum SSHSocketConnector { throw TermindSSHError.proxy("The SOCKS5 username or password is too long.") } var auth = Data([0x01, UInt8(username.count)]) - defer { wipe(&auth) } + defer { SecretMemory.clear(&auth) } auth.append(username) auth.append(UInt8(count)) auth.append( @@ -206,14 +206,6 @@ public nonisolated enum SSHSocketConnector { _ = try readExactly(socket, count: addressLength + 2) } - private static func wipe(_ data: inout Data) { - data.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in - guard let base = raw.baseAddress, raw.count > 0 else { return } - _ = memset_s(base, raw.count, 0, raw.count) - } - data.removeAll(keepingCapacity: false) - } - private static func openTCP(host: String, port: Int, timeout: TimeInterval) throws -> Int32 { var hints = addrinfo() hints.ai_family = AF_UNSPEC diff --git a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift index c93091b4..72f091d1 100644 --- a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift +++ b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift @@ -51,6 +51,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { return value } + public func containsString(for account: String, storage: PasswordStorage) throws -> Bool { + try validateStringReference(account) + return try contains( + account, service: Self.passwordService, + synchronizable: storage == .iCloud + ) + } + public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { try validateStringReference(account) guard @@ -66,7 +74,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { try validateStringReference(account) var data = value?.data(using: .utf8) defer { - if data != nil { Self.wipe(&data!) } + if data != nil { SecretMemory.clear(&data!) } } try setData( data, for: account, service: Self.passwordService, @@ -96,6 +104,14 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { } } + public func containsKeyCredential(for account: String, storage: KeyStorage) throws -> Bool { + try validate(account, prefix: "termind.key.") + return try contains( + account, service: service(for: storage), + synchronizable: storage == .iCloud + ) + } + public func sshKeyCredential( for account: String, storage: KeyStorage ) throws -> SSHResolvedKeyCredential? { @@ -106,11 +122,10 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { synchronizable: storage == .iCloud ) else { return nil } + defer { SecretMemory.clear(&data) } switch storage { case .thisDevice, .iCloud: - defer { Self.wipe(&data) } - var privateKey = try Self.decodePrivateKeyBytes(from: data) - return .software(SSHSecret(consumingUTF8: &privateKey)) + return .software(try Self.decodePrivateKeySecret(from: &data)) case .secureEnclave: guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data)) @@ -133,7 +148,7 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { throw KeychainSecretError.unexpectedValue } defer { - if data != nil { Self.wipe(&data!) } + if data != nil { SecretMemory.clear(&data!) } } try setData( data, for: account, service: service(for: storage), @@ -144,45 +159,51 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { ) } - private static func wipe(_ data: inout Data) { - data.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in - guard let base = raw.baseAddress, raw.count > 0 else { return } - _ = memset_s(base, raw.count, 0, raw.count) - } - data.removeAll(keepingCapacity: false) - } - - /// Decodes the synthesized `SSHPrivateKeySecret` JSON directly into a - /// mutable byte buffer so connection setup never materializes another - /// immutable Swift `String` containing the private key. - private static func decodePrivateKeyBytes(from data: Data) throws -> Data { - let key = Data("\"privateKey\"".utf8) - guard let keyRange = data.range(of: key) else { - throw KeychainSecretError.unexpectedValue - } - var index = keyRange.upperBound + /// 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) } } - skipWhitespace() - guard index < data.endIndex, data[index] == 0x3A else { - throw KeychainSecretError.unexpectedValue + func consume(_ expected: UInt8) throws { + guard index < data.endIndex, data[index] == expected else { + throw KeychainSecretError.unexpectedValue + } + index = data.index(after: index) } - index = data.index(after: index) + skipWhitespace() - guard index < data.endIndex, data[index] == 0x22 else { - throw KeychainSecretError.unexpectedValue + try consume(0x7B) + skipWhitespace() + for expected in Array("\"privateKey\"".utf8) { + try consume(expected) } - index = data.index(after: index) + 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 { return result } + 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 } @@ -260,6 +281,18 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { return data } + private func contains(_ account: String, service: String, synchronizable: Bool) throws -> Bool { + guard !account.isEmpty else { return false } + var query = baseQuery(account, service: service, synchronizable: synchronizable) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnAttributes as String] = true + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return false } + guard status == errSecSuccess else { throw KeychainSecretError.status(status) } + return true + } + private func setData( _ value: Data?, for account: String, service: String, synchronizable: Bool, accessible: CFString diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 9307a4d0..3c3dc0c5 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -94,10 +94,13 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r socket/libssh2 handshake-to-auth attempt, never a discovery connection. Generations reject retired callbacks, credential results and timers. - Every resolved Password, software Private Key, Key Passphrase and proxy Password is one-shot per physical connection - attempt. Runtime copies share a wipeable byte buffer; the transport clears it after the authentication call and every - success, failure and cancellation boundary clears it again. Retry/session/Agent/Metrics/transfer state retains only - non-secret Identity/Key provenance. Saved material is reread from Keychain for another physical connection; temporary - Passwords and every Passphrase must be entered again. UI authentication fields clear synchronously on submission. + attempt. Runtime copies share one explicitly owned, non-copy-on-write allocation; source `Data`, proxy frames and the + allocation are overwritten before release. The transport clears it after the authentication call and every success, + failure and cancellation boundary clears it again. UI submissions move plaintext into that one-shot owner before + leaving retry state; retry/session/Agent/Metrics/transfer state retains only reentry flags and non-secret Identity/Key + provenance. Saved material is reread from Keychain for another physical connection; temporary Passwords and every + Passphrase must be entered again. UI authentication fields clear synchronously on submission. Availability checks query + Keychain metadata without reading secret data; the legacy software-Key JSON envelope is parsed strictly and fails closed. - Desktop SSH/SFTP submission uses `RemoteSessionHostStore`: explicit password wins; custom input never borrows an unselected Identity password. Only explicit input changes selected credentials; custom input reuses the Host Identity. Identity/secret writes precede separate Host association. Keep temporary credentials, validation/passphrase ordering, @@ -120,6 +123,8 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r - 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. +- Exporting several authorized Keys resolves credentials immediately before work, authenticates once per Host and opens + one exec Channel per Key on that connection. Local validation completes before the remote account is changed. - Welcome parses bounded SSH grammar without shell/OpenSSH config execution. Exact aliases supply metadata; overrides create session route snapshots, never replaced with saved endpoints. Routes cap at eight Hosts and reject cycles. Connect saves nothing. Explicit Save copies changed records/prerequisites in one Vault transaction, rolling back new diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 87aa78ca..49daa1da 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -239,3 +239,21 @@ never justifies extraction. Keep schemas, native lists/terminal teardown and loc `"channels_only"` raw value, so synced settings need no migration. - **Verification:** TFM and generic iOS Simulator builds passed. Sixty-six focused connection/Channel/SFTP tests passed; changed-file strict formatting and Chinese localization checks passed. + +### 2026-09-20 — one-shot SSH secret ownership and connection attempts (complete) + +- **Evidence:** runtime credentials were represented by copy-on-write `Data` after leaving Keychain, so appending a NUL + could abandon an uncleared allocation. Temporary form answers were copied as `String` values into attempt dictionaries; + five feature owners repeated password/passphrase reentry routing. Authorized-Key export resolved every selected Host's + credentials eagerly and reused one one-shot configuration across several physical connections. +- **Action:** give `SSHSecret` one explicitly owned allocation; move submitted plaintext into that owner before an attempt; + centralize route reentry selection; narrow connection-time secret resolution; query Keychain existence without reading + values; fail closed on malformed legacy private-Key envelopes; authenticate once per Host for multi-Key export. +- **Result:** source `Data`, proxy frames and one-shot allocations are cleared through one `SecretMemory` boundary. Retry + state retains only selection metadata and reentry flags, while attempt copies share invalidating secret owners. The + session host port no longer exposes its legacy plaintext credential reads. Export preparations contain references only, + resolve immediately before work and open one connection with sequential exec Channels for all selected Keys. This is a + safety/ownership refactor; no production-line reduction is claimed. +- **Verification:** macOS and generic iOS Simulator builds passed. Fifty-three focused authentication, Agent and + authorized-Key tests passed; all 381 Framework tests passed. The default macOS run finished with 1,792 passed, 64 + opt-in skips and no failures. Changed-file strict formatting, diff checks and Chinese localization passed. diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 19735106..25eb76c4 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -559,9 +559,7 @@ final class MobileWorkspace: Identifiable { purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel, username: hosts.identity(for: host)?.username, mode: reconnecting ? .reconnection : .connection) - if let passwordHost = route.first(where: { routeHost in - submissions[routeHost.id]?.requiresPasswordReentry == true - }) { + if let passwordHost = submissions.firstHostRequiringPassword(in: route) { authenticationRequired = true authenticationMode = .localIdentity authenticationHostID = passwordHost.id @@ -573,14 +571,9 @@ final class MobileWorkspace: Identifiable { busy = false return } - if let passphraseHost = route.first(where: { routeHost in - guard let submission = submissions[routeHost.id], - submission.passphrase == nil, - let keyID = submission.keyID, - let key = hosts.key(keyID) - else { return false } - return key.requiresPassphraseEntry - }) { + if let passphraseHost = submissions.firstHostRequiringPassphrase( + in: route, key: hosts.key + ) { authenticationRequired = true authenticationMode = .localIdentity authenticationHostID = passphraseHost.id diff --git a/TFM/Application/FeatureModels/TransferLibraryModel.swift b/TFM/Application/FeatureModels/TransferLibraryModel.swift index e2eba226..375f30f4 100644 --- a/TFM/Application/FeatureModels/TransferLibraryModel.swift +++ b/TFM/Application/FeatureModels/TransferLibraryModel.swift @@ -85,17 +85,9 @@ extension TransferLibraryModel: RemoteSessionHostStore { func sshCredentials(for identity: Identity) -> SSHCredentialResolution { library.sshCredentials(for: identity) } - func passwordCredential(for identity: Identity) throws -> String? { - try library.passwordCredential(for: identity) - } - func passwordSecret(for identity: Identity) throws -> SSHSecret? { try library.passwordSecret(for: identity) } - func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? { - try library.keyCredential(for: key) - } - func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? { try library.keySecretCredential(for: key) } diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index c45feb9d..05a8d695 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -1389,15 +1389,15 @@ struct ExportKeyToHostsSheet: View { } guard !targets.isEmpty else { return } - var configurations: [(Host.ID, SSHConnectionConfiguration)] = [] + var preparations: [(Host.ID, SSHConnectionPreparation)] = [] for host in targets { if let reason = app.credentials.keyExportUnavailableReason(for: host) { states[host.id] = .failed(reason) continue } do { - configurations.append( - (host.id, try app.credentials.sshConnectionConfiguration(for: host)) + preparations.append( + (host.id, try app.credentials.prepareSSHConnection(for: host)) ) states[host.id] = .running } catch { @@ -1405,18 +1405,19 @@ struct ExportKeyToHostsSheet: View { } } - let exportKeys = keys.map { (name: $0.label, publicKey: $0.publicKey) } + let exportKeys = keys.map { + SSHAuthorizedKeyInstall(publicKey: $0.publicKey, name: $0.label) + } let connectionCoordinator = app.connections Task { await withTaskGroup(of: (Host.ID, String?).self) { group in - for (id, configuration) in configurations { + for (id, preparation) in preparations { group.addTask { do { - for key in exportKeys { - try await connectionCoordinator.installAuthorizedKey( - key.publicKey, named: key.name, using: configuration - ) - } + let configuration = try await preparation.resolve() + try await connectionCoordinator.installAuthorizedKeys( + exportKeys, using: configuration + ) return (id, nil) } catch { return (id, error.localizedDescription) diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 767efc0b..ffbd32a8 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -314,23 +314,16 @@ final class SFTPChannelModel: Identifiable { username: stagedAuthentication?.username ?? store.identity(for: host)?.username, mode: reconnecting ? .reconnection : .connection ) - if let passwordHost = route.first(where: { routeHost in - suppliedAuthentications[routeHost.id]?.requiresPasswordReentry == true - }) { + if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route) { requestAuthentication( host: passwordHost, store: store, requiresPassphrase: false ) return } - if let passphraseHost = route.first(where: { routeHost in - guard let submission = suppliedAuthentications[routeHost.id], - submission.passphrase == nil, - let keyID = submission.keyID, - let key = store.key(keyID) - else { return false } - return key.requiresPassphraseEntry - }) { + if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase( + in: route, key: store.key + ) { requestAuthentication( host: passphraseHost, store: store, requiresPassphrase: true, preserveSubmission: true @@ -366,12 +359,12 @@ final class SFTPChannelModel: Identifiable { credentials.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: suppliedAuthentication.passphrase, + passphraseOverride: suppliedAuthentication.passphraseForConnection(), requiresPassphrase: key.requiresPassphraseEntry, requiresUserPresence: key.requiresUserPresence )) } - if let password = suppliedAuthentication.password { + if let password = suppliedAuthentication.passwordForConnection() { credentials.append(.password(password)) } else if let identityID = suppliedAuthentication.identityID, let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword @@ -398,9 +391,9 @@ final class SFTPChannelModel: Identifiable { ? suppliedAuthentication.keyID : nil, username: suppliedAuthentication.username, permitsNone: suppliedAuthentication.keyID == nil - && suppliedAuthentication.password == nil, - hasEphemeralSecret: suppliedAuthentication.password != nil - || suppliedAuthentication.passphrase != nil + && !suppliedAuthentication.hasPassword, + hasEphemeralSecret: suppliedAuthentication.hasPassword + || suppliedAuthentication.hasPassphrase ) ) } else { @@ -489,10 +482,10 @@ final class SFTPChannelModel: Identifiable { let submitted = self.suppliedAuthentications[authenticationHost.id] let retryNeedsPassphrase = submitted?.requiresPasswordReentry != true - && submitted?.passphrase == nil + && submitted?.hasPassphrase != true && (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID) .flatMap { store.key($0) }?.requiresPassphraseEntry == true - let returnsToIdentity = submitted?.passphrase != nil + let returnsToIdentity = submitted?.requiresPassphraseReentry == true self.requestAuthentication( host: authenticationHost, store: store, failure: error.localizedDescription, diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 3f34ab35..4240ca80 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -489,23 +489,18 @@ final class SSHSessionModel: Identifiable { host: host, route: route, purpose: .sshSession, username: username, mode: reconnecting ? .reconnection : .connection ) - if let passwordHost = route.first(where: { routeHost in - suppliedAuthentications[routeHost.id]?.requiresPasswordReentry == true - }), let submission = suppliedAuthentications[passwordHost.id] { + if let passwordHost = suppliedAuthentications.firstHostRequiringPassword(in: route), + let submission = suppliedAuthentications[passwordHost.id] + { requestAuthentication( for: passwordHost, username: submission.username, selectedKeyID: submission.keyID, requiresPassphrase: false ) return } - if let passphraseHost = route.first(where: { routeHost in - guard let submission = suppliedAuthentications[routeHost.id], - submission.passphrase == nil, - let keyID = submission.keyID, - let key = store.key(keyID) - else { return false } - return key.requiresPassphraseEntry - }), let submission = suppliedAuthentications[passphraseHost.id] { + if let passphraseHost = suppliedAuthentications.firstHostRequiringPassphrase( + in: route, key: store.key + ), let submission = suppliedAuthentications[passphraseHost.id] { requestAuthentication( for: passphraseHost, username: submission.username, selectedKeyID: submission.keyID, requiresPassphrase: true, @@ -534,7 +529,7 @@ final class SSHSessionModel: Identifiable { if let suppliedAuthentication { hasConfiguredCredential = suppliedAuthentication.keyID != nil - || suppliedAuthentication.password != nil + || suppliedAuthentication.hasPassword || suppliedAuthentication.identityID.flatMap(store.identity)?.hasSecret == true if let keyID = suppliedAuthentication.keyID { if let key = store.key(keyID) { @@ -545,7 +540,7 @@ final class SSHSessionModel: Identifiable { credentials.append( credential.sshCredential( publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: suppliedAuthentication.passphrase, + passphraseOverride: suppliedAuthentication.passphraseForConnection(), requiresPassphrase: key.requiresPassphraseEntry, requiresUserPresence: key.requiresUserPresence ) @@ -564,7 +559,7 @@ final class SSHSessionModel: Identifiable { ) } } - if let password = suppliedAuthentication.password { + if let password = suppliedAuthentication.passwordForConnection() { credentials.append(.password(password)) } else if let identityID = suppliedAuthentication.identityID, let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword @@ -634,7 +629,7 @@ final class SSHSessionModel: Identifiable { let identity = store.identity(for: jump) let submitted = suppliedAuthentications[jump.id] let retryNeedsPassphrase = - submitted?.passphrase == nil + submitted?.hasPassphrase != true && (submitted?.keyID ?? identity?.keyID) .flatMap { store.key($0) }?.requiresPassphraseEntry == true connectionAttemptID = nil @@ -665,16 +660,16 @@ final class SSHSessionModel: Identifiable { identityID: identityID, username: username, permitsNone: identity?.hasSecret == false, - hasEphemeralSecret: suppliedAuthentication?.password != nil - || suppliedAuthentication?.passphrase != nil + hasEphemeralSecret: suppliedAuthentication?.hasPassword == true + || suppliedAuthentication?.hasPassphrase == true ) } else { authenticationReference = SSHAuthenticationReference( fallbackKeyID: selectedKeyID, username: username, - permitsNone: selectedKeyID == nil && suppliedAuthentication?.password == nil, - hasEphemeralSecret: suppliedAuthentication?.password != nil - || suppliedAuthentication?.passphrase != nil + permitsNone: selectedKeyID == nil && suppliedAuthentication?.hasPassword != true, + hasEphemeralSecret: suppliedAuthentication?.hasPassword == true + || suppliedAuthentication?.hasPassphrase == true ) } connectionTask = Task { [weak self, weak store] in @@ -714,10 +709,10 @@ final class SSHSessionModel: Identifiable { ?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil) let retryNeedsPassphrase = submitted?.requiresPasswordReentry != true - && submitted?.passphrase == nil + && submitted?.hasPassphrase != true && authenticationKeyID.flatMap { store.key($0) }? .requiresPassphraseEntry == true - let returnsToIdentity = submitted?.passphrase != nil + let returnsToIdentity = submitted?.requiresPassphraseReentry == true requestAuthentication( for: authenticationHost, username: submitted?.username ?? authenticationIdentity?.username diff --git a/TFMTests/AuthorizedKeyExportTests.swift b/TFMTests/AuthorizedKeyExportTests.swift index ca49d612..28a5b16f 100644 --- a/TFMTests/AuthorizedKeyExportTests.swift +++ b/TFMTests/AuthorizedKeyExportTests.swift @@ -4,10 +4,10 @@ import TermindDomain import TermindInfrastructure import TermindPersistence import TermindPresentation -import TermindRemoteInfrastructure import Testing @testable import Termind +@testable import TermindRemoteInfrastructure struct AuthorizedKeyExportTests { @Test @@ -35,4 +35,104 @@ struct AuthorizedKeyExportTests { == "# Production Server Key by Termind\nssh-ed25519 AAAAC3Nza\n" ) } + + @Test + func legacyPrivateKeyEnvelopeRequiresACompleteJSONObject() throws { + var encoded = try JSONEncoder().encode( + SSHPrivateKeySecret(privateKey: "-----BEGIN TEST-----\nsecret\n-----END TEST-----") + ) + let secret = try KeychainSecretStore.decodePrivateKeySecret(from: &encoded) + #expect(encoded.isEmpty) + #expect(secret.matches("-----BEGIN TEST-----\nsecret\n-----END TEST-----")) + + #expect(throws: KeychainSecretError.self) { + var data = Data(#"{"privateKey":"secret""#.utf8) + _ = try KeychainSecretStore.decodePrivateKeySecret( + from: &data + ) + } + #expect(throws: KeychainSecretError.self) { + var data = Data(#"{"privateKey":"secret"} trailing"#.utf8) + _ = try KeychainSecretStore.decodePrivateKeySecret( + from: &data + ) + } + } + + @Test + func multipleKeysReuseOneAuthenticatedConnection() async throws { + let password = SSHSecret("one-use") + let request = SSHConnectionRequest( + configuration: SSHConnectionConfiguration( + hostID: UUID(), host: "server.invalid", port: 22, + username: "deploy", credentials: [.password(password)], + expectedHostKeys: [] + ) + ) + let connection = AuthorizedKeyConnection() + + try await SSHPTYChannelWorker.installAuthorizedKeys( + [ + SSHAuthorizedKeyInstall(publicKey: "ssh-ed25519 first", name: "First"), + SSHAuthorizedKeyInstall(publicKey: "ssh-ed25519 second", name: "Second"), + ], + using: request, connection: connection + ) + + let snapshot = await connection.snapshot() + #expect(snapshot.connects == 1) + #expect(snapshot.executions == 2) + #expect(snapshot.disconnects == 1) + #expect( + snapshot.inputs == [ + "# First Key by Termind\nssh-ed25519 first\n", + "# Second Key by Termind\nssh-ed25519 second\n", + ]) + #expect(password.isConsumed) + } +} + +private actor AuthorizedKeyConnection: SSHConnectionProtocol { + struct Snapshot: Sendable { + var connects: Int + var executions: Int + var disconnects: Int + var inputs: [String] + } + + private var connects = 0 + private var executions = 0 + private var disconnects = 0 + private var inputs: [String] = [] + + func connect(using configuration: SSHConnectionConfiguration) async throws { + connects += 1 + } + + func execute( + _ command: String, workingDirectory: String?, stdin: Data?, + timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? + ) async throws -> SSHExecResult { + executions += 1 + inputs.append(stdin.map { String(decoding: $0, as: UTF8.self) } ?? "") + return SSHExecResult( + stdout: "", stderr: "", exitCode: 0, + stdoutTruncated: false, stderrTruncated: false + ) + } + + func openSFTPChannel() async throws -> any SFTPChannelProtocol { + throw TermindSSHError.disconnected + } + + func disconnect() async { + disconnects += 1 + } + + func snapshot() -> Snapshot { + Snapshot( + connects: connects, executions: executions, + disconnects: disconnects, inputs: inputs + ) + } }