diff --git a/Benchmarks/PortForwarding/main.swift b/Benchmarks/PortForwarding/main.swift index 0b5aeca2..4945fa29 100644 --- a/Benchmarks/PortForwarding/main.swift +++ b/Benchmarks/PortForwarding/main.swift @@ -165,10 +165,11 @@ private struct BenchmarkRunner { let connectionRequest = request(jumps: jumps) try await session.connect(connectionRequest, progress: nil) if effectiveMaximum > 1 { - await session.configureAdditionalConnections( - SSHConnectionRequest( - configuration: connectionRequest.configuration, - interactionPolicy: .forbidden)) + await session.configureAdditionalConnections { + var request = self.request(jumps: jumps) + request.interactionPolicy = .forbidden + return request.enforcingInteractionPolicy() + } } do { let rule = try rule(kind) @@ -268,10 +269,11 @@ private struct BenchmarkRunner { private func request(jumps: Int) -> SSHConnectionRequest { let username = fixture.username ?? "none" - let credentials: [SSHCredentials] = + func credentials() -> [SSHCredentials] { fixture.username.flatMap { _ in fixture.privateKey }.map { [.privateKey(privateKey: $0, publicKey: nil, passphrase: nil)] } ?? [] + } let expectedHostKeys: Set = fixture.hostKeyFingerprint.map { [SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: $0)] @@ -281,7 +283,7 @@ private struct BenchmarkRunner { host: "127.0.0.1", port: fixture.port, username: username, - credentials: credentials, + credentials: credentials(), expectedHostKeys: expectedHostKeys) configuration.jumps = (0.. 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) func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) @@ -57,15 +59,16 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { } extension RemoteSessionHostStore { - /// Resolves a desktop authentication form without saving its input. Only a - /// selected Identity can supply a saved password; explicit input wins. + /// Captures a desktop authentication form without saving its input. A + /// selected Identity stays reference-only so its saved password is read + /// directly into a one-shot buffer only when the connection is resolved. public func authenticationSubmission( username: String, password: String?, passphrase: String?, keyID: SSHKeyRef.ID?, selectedIdentity: Identity? ) throws -> SSHAuthenticationSubmission { SSHAuthenticationSubmission( username: username, - password: try password ?? selectedIdentity.flatMap { try connectionPasswordCredential(for: $0) }, + password: password, passphrase: passphrase, keyID: keyID, identityID: selectedIdentity?.id ) } @@ -162,7 +165,7 @@ extension RemoteSessionHostStore { guard let identity = identity(identityID) else { throw TermindSSHError.missingCredential } - let password = try connectionPasswordCredential(for: identity) + let password = try connectionPasswordSecret(for: identity) guard let password else { throw TermindSSHError.missingCredential } return SSHTransportProxy( kind: value.kind, hostname: value.hostname, port: value.port, @@ -193,7 +196,7 @@ extension RemoteSessionHostStore { credentials.append(.interactionRequired) } else { do { - if let stored = try connectionKeyCredential( + if let stored = try connectionKeySecretCredential( for: key, identityID: resolvedIdentity?.id ) { credentials.append( @@ -218,7 +221,7 @@ extension RemoteSessionHostStore { if let resolvedIdentity, resolvedIdentity.usesPassword { do { - if let password = try connectionPasswordCredential(for: resolvedIdentity) { + if let password = try connectionPasswordSecret(for: resolvedIdentity) { credentials.append(.password(password)) } else { failures.append(TermindSSHError.missingCredential) @@ -267,6 +270,14 @@ extension RemoteSessionHostStore { return password } + public func connectionPasswordSecret(for identity: Identity) throws -> SSHSecret? { + let password = try passwordSecret(for: identity) + if password != nil { + recordIdentityCredentialAccess([identity.id], at: Date()) + } + return password + } + public func connectionKeyCredential( for key: SSHKeyRef, identityID: Identity.ID? ) throws -> StoredSSHKeyCredential? { @@ -277,6 +288,16 @@ extension RemoteSessionHostStore { return credential } + public func connectionKeySecretCredential( + for key: SSHKeyRef, identityID: Identity.ID? + ) throws -> SSHResolvedKeyCredential? { + let credential = try keySecretCredential(for: key) + if credential != nil, let identityID { + recordIdentityCredentialAccess([identityID], at: Date()) + } + return credential + } + public func expectedHostKeys( for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] ) throws -> Set { diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index d7bfaf12..4eab9278 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -132,9 +132,12 @@ public nonisolated struct SSHConnectionPreparation: Sendable { var result: [SSHCredentials] = [] var retrievalFailure: (any Error)? if let key = node.key { - let stored: StoredSSHKeyCredential? + if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false { + throw TermindSSHError.keyPassphraseRequired + } + let stored: SSHResolvedKeyCredential? do { - stored = try secrets.keyCredential(for: key.credentialRef, storage: key.storage) + stored = try secrets.sshKeyCredential(for: key.credentialRef, storage: key.storage) if stored != nil, let identity = connectionIdentity(for: node) { accessedIdentityIDs.insert(identity.id) } @@ -143,9 +146,6 @@ public nonisolated struct SSHConnectionPreparation: Sendable { retrievalFailure = error } if let stored { - if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false { - throw TermindSSHError.keyPassphraseRequired - } result.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, @@ -162,7 +162,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { let identity = node.identity, identity.usesPassword { do { - if let password = try secrets.string( + if let password = try secrets.sshSecret( for: identity.credentialRef, storage: identity.passwordStorage ) { result.append(.password(password)) @@ -187,12 +187,12 @@ public nonisolated struct SSHConnectionPreparation: Sendable { guard proxy.isValid else { throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) } - var password: String? + var password: SSHSecret? if proxy.identityID != nil { guard let identity = node.proxyIdentity, identity.usesPassword else { throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable.")) } - password = try secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) + password = try secrets.sshSecret(for: identity.credentialRef, storage: identity.passwordStorage) guard password != nil else { throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 114d7efe..003f0a3e 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -662,15 +662,15 @@ public final class HostLibraryModel: RemoteSessionHostStore { ) throws -> [SSHCredentials] { var credentials: [SSHCredentials] = [] if let keyID = submission.keyID, let key = key(keyID) { - if let stored = try keyCredential(for: key) { + if key.requiresPassphraseEntry, + submission.passphrase?.isEmpty != false + { + throw TermindSSHError.keyPassphraseRequired + } + if let stored = try keySecretCredential(for: key) { if let identityID = submission.identityID { accessedIdentityIDs.insert(identityID) } - if key.requiresPassphraseEntry, - submission.passphrase?.isEmpty != false - { - throw TermindSSHError.keyPassphraseRequired - } credentials.append( stored.sshCredential( publicKey: key.publicKey, certificate: key.certificate, @@ -681,7 +681,15 @@ public final class HostLibraryModel: RemoteSessionHostStore { ) } } - if let password = submission.password { credentials.append(.password(password)) } + if let password = submission.password { + credentials.append(.password(password)) + } else if let identityID = submission.identityID, + let identity = identity(identityID), identity.usesPassword, + let password = try passwordSecret(for: identity) + { + credentials.append(.password(password)) + accessedIdentityIDs.insert(identityID) + } return credentials } @@ -700,12 +708,12 @@ public final class HostLibraryModel: RemoteSessionHostStore { guard proxy.isValid else { throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) } - var password: String? + var password: SSHSecret? if let identityID = proxy.identityID { guard let identity = identity(identityID), identity.usesPassword else { throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable.")) } - password = try passwordCredential(for: identity) + password = try passwordSecret(for: identity) guard password != nil else { throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) } @@ -737,12 +745,12 @@ public final class HostLibraryModel: RemoteSessionHostStore { if let keyID = identity.keyID { if let key = key(keyID) { - do { - if let credential = try keyCredential(for: key) { - accessedIdentityIDs.insert(identity.id) - if key.requiresPassphraseEntry { - requiredPassphraseKeyID = key.id - } else { + if key.requiresPassphraseEntry { + requiredPassphraseKeyID = key.id + } else { + do { + if let credential = try keySecretCredential(for: key) { + accessedIdentityIDs.insert(identity.id) credentials.append( credential.sshCredential( publicKey: key.publicKey, certificate: key.certificate, @@ -750,15 +758,15 @@ public final class HostLibraryModel: RemoteSessionHostStore { requiresUserPresence: key.requiresUserPresence ) ) + } else { + failures.append( + String(localized: "The selected key has no saved private key.") + ) } - } else { - failures.append( - String(localized: "The selected key has no saved private key.") - ) + } catch { + storageError = error.localizedDescription + failures.append(error.localizedDescription) } - } catch { - storageError = error.localizedDescription - failures.append(error.localizedDescription) } } else { failures.append( @@ -769,7 +777,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { if identity.usesPassword { do { - if let password = try passwordCredential(for: identity) { + if let password = try passwordSecret(for: identity) { credentials.append(.password(password)) accessedIdentityIDs.insert(identity.id) } else { @@ -1215,6 +1223,13 @@ public final class HostLibraryModel: RemoteSessionHostStore { ) } + public func passwordSecret(for identity: Identity) throws -> SSHSecret? { + guard identity.usesPassword else { return nil } + return try keychain.sshSecret( + for: identity.credentialRef, storage: identity.passwordStorage + ) + } + public func passwordAvailability(for identity: Identity) -> SecretAvailability { guard identity.usesPassword else { return .notConfigured } do { @@ -1394,6 +1409,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { try keychain.keyCredential(for: key.credentialRef, storage: key.storage) } + public func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? { + try keychain.sshKeyCredential(for: key.credentialRef, storage: key.storage) + } + public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { do { return try keyCredential(for: key) == nil ? .missing : .available diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 4524aa14..58adb684 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -80,7 +80,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } private nonisolated struct RunAttempt: Sendable { var host: Host - var preparation: SSHConnectionPreparation + var preparation: SSHConnectionPreparation? var sampler: any MetricsSampling } private nonisolated struct HostKeyAuthorization: Sendable { @@ -307,10 +307,13 @@ public final class ServerMetricsModel: MetricsSnapshotReading { finishHostKeyAuthorization(for: server.id, decision: .accept) } - private func preparation(for host: Host, server: ServerMetric) throws -> SSHConnectionPreparation { + private func preparation( + for host: Host, server: ServerMetric, + submissions: [UUID: SSHAuthenticationSubmission] + ) throws -> SSHConnectionPreparation { try hosts.prepareSSHConnection( for: host, - submissions: submissions[server.id] ?? [:], + submissions: submissions, temporaryHostKeys: temporaryKeys[server.id] ?? [:] ) } @@ -320,7 +323,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading { let staged = submissions[server.id]?[host.id] let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host) let keyID = staged?.keyID ?? identity?.keyID - let passphrase = keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true && staged?.passphrase == nil + let passphrase = + staged?.requiresPasswordReentry != true + && keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true + && staged?.passphrase == nil server.authenticationHost = host server.authenticationPrompt = SSHAuthenticationPrompt( host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "", @@ -339,6 +345,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { public func suspend() { sleeping = true + submissions.removeAll() for server in servers { cancel(server.id) server.isConnecting = false @@ -533,7 +540,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private nonisolated func run(_ serverID: UUID, token: UUID, clock: MetricsClock) async { var delay = 2.0 while await isCurrent(serverID, token: token) { - let attempt: RunAttempt + var attempt: RunAttempt do { guard let next = try await beginRunAttempt(serverID, token: token) else { return } attempt = next @@ -547,7 +554,9 @@ public final class ServerMetricsModel: MetricsSnapshotReading { let sampler = attempt.sampler do { - let configuration = try await attempt.preparation.resolve( + guard let preparation = attempt.preparation else { throw CancellationError() } + attempt.preparation = nil + let configuration = try await preparation.resolve( trustPolicy: .allowChallenge ) guard await isCurrent(serverID, token: token) else { @@ -659,7 +668,36 @@ public final class ServerMetricsModel: MetricsSnapshotReading { server.status = String(localized: "Connecting…") server.collectionState = .connecting server.isConnecting = true - let preparation = try preparation(for: host, server: server) + let route = hosts.route(for: host) + if let passwordHost = route.first(where: { routeHost in + submissions[server.id]?[routeHost.id]?.requiresPasswordReentry == true + }) { + 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 + }) { + throw TermindSSHError.routeHost( + hostID: passphraseHost.id, authentication: true, + serverChallenge: false, hostKey: false, + message: TermindSSHError.keyPassphraseRequired.localizedDescription + ) + } + var retained = submissions[server.id] ?? [:] + let attemptSubmissions = retained.takeAuthenticationSecrets() + submissions[server.id] = retained + let preparation = try preparation( + for: host, server: server, submissions: attemptSubmissions + ) let sampler = makeSampler() samplers[serverID] = sampler return RunAttempt(host: host, preparation: preparation, sampler: sampler) diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index 8d2a2014..f9cf6565 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -114,11 +114,13 @@ public nonisolated struct SSHConnectionRequest: Sendable { nonisolated extension SSHCredentials { fileprivate var withoutInteraction: SSHCredentials { switch self { - case .privateKey(_, _, _, requiresPassphrase: true), - .secureEnclave(_, _, requiresUserPresence: true): - .interactionRequired + case .privateKey(_, _, _, requiresPassphrase: true): + clearSecrets() + return .interactionRequired + case .secureEnclave(_, _, requiresUserPresence: true): + return .interactionRequired default: - self + return self } } } @@ -215,9 +217,9 @@ public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable { } public nonisolated enum SSHCredentials: Sendable { - case password(String) + case password(SSHSecret) case privateKey( - privateKey: String, publicKey: String?, passphrase: String?, + privateKey: SSHSecret, publicKey: String?, passphrase: SSHSecret?, requiresPassphrase: Bool = false ) case secureEnclave( @@ -227,6 +229,60 @@ public nonisolated enum SSHCredentials: Sendable { /// 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 + + public static func password(_ value: String) -> Self { + .password(SSHSecret(value)) + } + + public static func privateKey( + privateKey: String, publicKey: String?, passphrase: String?, + requiresPassphrase: Bool = false + ) -> Self { + .privateKey( + privateKey: SSHSecret(privateKey), publicKey: publicKey, + passphrase: passphrase.map(SSHSecret.init), + requiresPassphrase: requiresPassphrase + ) + } + + public func clearSecrets() { + switch self { + case .password(let password): + password.clear() + case .privateKey(let privateKey, _, let passphrase, _): + privateKey.clear() + passphrase?.clear() + case .secureEnclave, .interactionRequired: + break + } + } +} + +public nonisolated enum SSHResolvedKeyCredential: Sendable { + case software(SSHSecret) + case secureEnclave(SecureEnclaveKeyCredential) + + public func sshCredential( + publicKey: String, certificate: String = "", passphraseOverride: String? = nil, + requiresPassphrase: Bool = false, requiresUserPresence: Bool = false + ) -> SSHCredentials { + switch self { + case .software(let privateKey): + .privateKey( + privateKey: privateKey, + publicKey: certificate.isEmpty + ? (publicKey.isEmpty ? nil : publicKey) + : certificate, + passphrase: passphraseOverride.map(SSHSecret.init), + requiresPassphrase: requiresPassphrase + ) + case .secureEnclave(let credential): + .secureEnclave( + credential: credential, publicKey: publicKey, + requiresUserPresence: requiresUserPresence + ) + } + } } nonisolated extension StoredSSHKeyCredential { @@ -236,17 +292,17 @@ nonisolated extension StoredSSHKeyCredential { ) -> SSHCredentials { switch self { case .software(let secret): - .privateKey( - privateKey: secret.privateKey, - publicKey: certificate.isEmpty - ? (publicKey.isEmpty ? nil : publicKey) - : certificate, - passphrase: passphraseOverride, - requiresPassphrase: requiresPassphrase + SSHResolvedKeyCredential.software(SSHSecret(secret.privateKey)).sshCredential( + publicKey: publicKey, certificate: certificate, + passphraseOverride: passphraseOverride, + requiresPassphrase: requiresPassphrase, + requiresUserPresence: requiresUserPresence ) case .secureEnclave(let credential): - .secureEnclave( - credential: credential, publicKey: publicKey, + SSHResolvedKeyCredential.secureEnclave(credential).sshCredential( + publicKey: publicKey, certificate: certificate, + passphraseOverride: passphraseOverride, + requiresPassphrase: requiresPassphrase, requiresUserPresence: requiresUserPresence ) } @@ -312,6 +368,14 @@ public nonisolated struct SSHConnectionConfiguration: Sendable { ) } + /// Invalidates every authentication secret shared by copies of this + /// one-shot configuration. Network and provenance fields remain readable. + public func clearSecrets() { + for credential in credentials { credential.clearSecrets() } + proxy?.clearSecret() + for jump in jumps { jump.clearSecrets() } + } + } public nonisolated struct SSHCredentialResolution: Sendable { diff --git a/Frameworks/TermindApplication/SSHRouteEvent.swift b/Frameworks/TermindApplication/SSHRouteEvent.swift index 9721781b..ad7d0d37 100644 --- a/Frameworks/TermindApplication/SSHRouteEvent.swift +++ b/Frameworks/TermindApplication/SSHRouteEvent.swift @@ -19,6 +19,8 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable { public var passphrase: String? public var keyID: SSHKeyRef.ID? public var identityID: Identity.ID? = nil + public var requiresPasswordReentry = false + public var requiresPassphraseReentry = false public init( username: String, password: String? = nil, passphrase: String? = nil, keyID: SSHKeyRef.ID? = nil, identityID: Identity.ID? = nil @@ -30,4 +32,23 @@ public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable { self.identityID = identityID } + public mutating func clearSecrets() { + if password != nil { requiresPasswordReentry = true } + if passphrase != nil { requiresPassphraseReentry = true } + password = nil + passphrase = nil + } + +} + +nonisolated extension Dictionary where Value == SSHAuthenticationSubmission { + /// 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() + } + return result + } } diff --git a/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift b/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift index 79dc4fd1..274e49d3 100644 --- a/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift +++ b/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift @@ -35,4 +35,9 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable { ) } + public func clearSecrets() { + for credential in credentials { credential.clearSecrets() } + proxy?.clearSecret() + } + } diff --git a/Frameworks/TermindApplication/SSHSecret.swift b/Frameworks/TermindApplication/SSHSecret.swift new file mode 100644 index 00000000..368a6c36 --- /dev/null +++ b/Frameworks/TermindApplication/SSHSecret.swift @@ -0,0 +1,98 @@ +import Darwin +import Foundation + +/// 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. +public nonisolated final class SSHSecret: @unchecked Sendable { + public struct Consumed: Error, Sendable { + public init() {} + } + + private let lock = NSLock() + private var storage: Data? + + public init(_ value: String) { + var bytes = Data(value.utf8) + bytes.append(0) + storage = bytes + } + + /// Transfers a freshly read UTF-8 buffer into wipeable ownership without + /// first materializing an immutable Swift `String`. + public init(consumingUTF8 bytes: inout Data) { + bytes.append(0) + storage = bytes + bytes = Data() + } + + deinit { clear() } + + public var isConsumed: Bool { + lock.withLock { storage == nil } + } + + /// Exposes the UTF-8 bytes once, including a trailing NUL outside `count`. + /// The memory 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)) + } + } + + /// Test and diagnostic support without extending the secret's lifetime. + /// Production authentication should use `withUnsafeCStringAndConsume`. + 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) + } + } + + public func clear() { + guard + var bytes = lock.withLock({ () -> Data? in + let value = storage + storage = nil + return value + }) + else { return } + Self.wipe(&bytes) + } + + private func take() throws -> Data { + 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 { + lhs.matches(rhs) +} + +public nonisolated func == (lhs: String, rhs: SSHSecret) -> Bool { + rhs.matches(lhs) +} diff --git a/Frameworks/TermindApplication/SSHTransportProxy.swift b/Frameworks/TermindApplication/SSHTransportProxy.swift index 94366638..354336a0 100644 --- a/Frameworks/TermindApplication/SSHTransportProxy.swift +++ b/Frameworks/TermindApplication/SSHTransportProxy.swift @@ -8,11 +8,11 @@ public nonisolated struct SSHTransportProxy: Sendable { public var hostname: String public var port: Int public var username: String - public var password: String? + public var password: SSHSecret? public var identityID: Identity.ID? public init( kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String, - password: String? = nil, identityID: Identity.ID? = nil + password: SSHSecret? = nil, identityID: Identity.ID? = nil ) { self.kind = kind self.hostname = hostname @@ -22,4 +22,16 @@ public nonisolated struct SSHTransportProxy: Sendable { self.identityID = identityID } + public init( + kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String, + password: String?, identityID: Identity.ID? = nil + ) { + self.init( + kind: kind, hostname: hostname, port: port, username: username, + password: password.map(SSHSecret.init), identityID: identityID + ) + } + + public func clearSecret() { password?.clear() } + } diff --git a/Frameworks/TermindApplication/SecretStoring.swift b/Frameworks/TermindApplication/SecretStoring.swift index 6f5234d1..9b627cb3 100644 --- a/Frameworks/TermindApplication/SecretStoring.swift +++ b/Frameworks/TermindApplication/SecretStoring.swift @@ -11,10 +11,14 @@ public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable { public nonisolated protocol SecretStoring: Sendable { func string(for account: String, storage: PasswordStorage) throws -> String? + func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? 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 setKeyCredential( _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage ) throws @@ -22,6 +26,22 @@ public nonisolated protocol SecretStoring: Sendable { func removeKeyCredential(_ account: String, storage: KeyStorage) throws } +nonisolated extension SecretStoring { + public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { + try string(for: account, storage: storage).map(SSHSecret.init) + } + + public func sshKeyCredential( + for account: String, storage: KeyStorage + ) throws -> SSHResolvedKeyCredential? { + switch try keyCredential(for: account, storage: storage) { + case .software(let secret): .software(SSHSecret(secret.privateKey)) + case .secureEnclave(let credential): .secureEnclave(credential) + case nil: nil + } + } +} + public nonisolated final class InMemorySecretStore: SecretStoring, @unchecked Sendable { public init() {} diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index 255f7844..a2243ba3 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -269,9 +269,13 @@ public struct SSHAuthenticationForm: View { } private func commit(save: Bool) { + let submittedPassword = password.isEmpty ? nil : password + let submittedPassphrase = passphrase.isEmpty ? nil : passphrase + password = "" + passphrase = "" submit( - identityID, username, password.isEmpty ? nil : password, - passphrase.isEmpty ? nil : passphrase, keyID, save + identityID, username, submittedPassword, + submittedPassphrase, keyID, save ) } diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 8d5077dc..0b8f149c 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -354,14 +354,13 @@ public final class AgentHostResolver: Sendable { guard let identity = store.identity(identityID) else { throw TermindSSHError.missingCredential } - let password = try store.connectionPasswordCredential(for: identity) if answer.save { var updated = host updated.identityID = identity.id store.upsert(updated) } return SSHAuthenticationSubmission( - username: identity.username, password: password, + username: identity.username, passphrase: answer.passphrase, keyID: identity.keyID, identityID: identity.id ) @@ -560,11 +559,11 @@ public actor AgentSSHAuthenticationContext { guard failedGeneration == generation else { return } let target = error.routeHostID ?? hostID if var retained = submissions[target] { - retained.password = nil - retained.passphrase = nil + retained.clearSecrets() submissions[target] = retained } generation &+= 1 + cached?.clearSecrets() cached = nil pending?.task.cancel() pending = nil @@ -572,6 +571,15 @@ public actor AgentSSHAuthenticationContext { failure = error.localizedDescription } + public func didAuthenticate(generation authenticatedGeneration: UInt64) { + guard authenticatedGeneration == generation else { return } + submissions.removeAll() + cached?.clearSecrets() + cached = nil + pending?.task.cancel() + pending = nil + } + private func makeAttempt( configuration: SSHConnectionConfiguration, generation: UInt64 ) -> Attempt { @@ -861,7 +869,9 @@ public actor AgentSSHAuthenticationRegistry { open: @Sendable (AgentSSHAuthenticationContext.Attempt?) async throws -> Client ) async throws -> Client { var attempt: AgentSSHAuthenticationContext.Attempt? - if await authentication.canResolveTarget() { + if await pool.isConnected(hostID: hostID) { + attempt = nil + } else if await authentication.canResolveTarget() { attempt = try await authentication.configurationAttempt() } while true { @@ -874,7 +884,9 @@ public actor AgentSSHAuthenticationRegistry { attemptedGeneration = await authentication.currentGeneration } do { - return try await open(attempt) + let client = try await open(attempt) + await authentication.didAuthenticate(generation: attemptedGeneration) + return client } catch let error as TermindSSHError where error.shouldRequestAuthentication { if cancellation?.isCancelled == true { throw SFTPError.cancelled } if evictsPooledConnection { await pool.disconnect(hostID: hostID) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index f4c633c2..a41a5cce 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -327,7 +327,41 @@ 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 + }) { + requestAuthentication( + context, + error: .routeHost( + hostID: passwordHost.id, authentication: true, + serverChallenge: false, hostKey: false, + message: TermindSSHError.missingCredential.localizedDescription + ) + ) + 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 + }) { + requestAuthentication( + context, + error: .routeHost( + hostID: passphraseHost.id, authentication: true, + serverChallenge: false, hostKey: false, + message: TermindSSHError.keyPassphraseRequired.localizedDescription + ) + ) + context.attemptID = nil + return + } } + let attemptSubmissions = silently ? [:] : context.submissions.takeAuthenticationSecrets() let forwardingConnection = makeConnection( context.maximumConnections, { [weak self, weak context] id, generation, count in @@ -365,7 +399,7 @@ public final class PortForwardingController { configuration = try hosts.additionalConnectionRequest(from: snapshot).configuration } else { let preparation = try hosts.prepareSSHConnection( - for: host, submissions: context.submissions, + for: host, submissions: attemptSubmissions, temporaryHostKeys: context.connection.temporaryHostKeys) configuration = try await preparation.resolve(trustPolicy: .allowChallenge) } @@ -472,7 +506,10 @@ public final class PortForwardingController { let staged = context.submissions[host.id] let identity = staged?.identityID.flatMap(hosts.identity) ?? hosts.identity(for: host) let keyID = staged?.keyID ?? identity?.keyID - let passphrase = keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true && staged?.passphrase == nil + let passphrase = + staged?.requiresPasswordReentry != true + && keyID.flatMap(hosts.key)?.requiresPassphraseEntry == true + && staged?.passphrase == nil context.setPrompt( SSHAuthenticationPrompt( host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "", diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift index 095b629f..c66682ab 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift @@ -246,6 +246,7 @@ nonisolated extension SFTPClientProtocol { progress: SSHRouteProgressHandler?, connectionFailure: (@Sendable (String) -> Void)? ) async throws { + defer { request.configuration.clearSecrets() } try await connect( using: request.configuration, progress: progress, connectionFailure: connectionFailure @@ -342,6 +343,7 @@ public actor SFTPClient: SFTPClientProtocol { progress: SSHRouteProgressHandler?, connectionFailure: (@Sendable (String) -> Void)? ) async throws { + defer { request.configuration.clearSecrets() } generation &+= 1 let token = generation let previousChannel = channel diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift index 7e3b0688..e0674d4e 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift @@ -53,6 +53,7 @@ public nonisolated protocol SSHConnectionProtocol: AnyObject, Sendable { nonisolated extension SSHConnectionProtocol { public func connect(using request: SSHConnectionRequest) async throws { + defer { request.configuration.clearSecrets() } try await connect(using: request.configuration) } } @@ -86,6 +87,7 @@ public actor SSHConnection: SSHConnectionProtocol { progress: SSHRouteProgressHandler?, connectionFailure: (@Sendable (String) -> Void)? ) async throws { + defer { request.configuration.clearSecrets() } try await worker.connect( using: request.withKeyboardInteractiveFallback(keyboardInteractive), progress: progress, connectionFailure: connectionFailure) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift index a8ddabe3..498bd10f 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift @@ -59,6 +59,7 @@ public actor SSHForwardingConnection: SSHForwardingConnectionProtocol { } public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler? = nil) async throws { + defer { request.configuration.clearSecrets() } try await worker.connect(request, progress: progress) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 94adf0a1..1d2fe3ad 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -632,6 +632,7 @@ public actor SSHPTYChannel { rows: UInt16, progress: SSHRouteProgressHandler? = nil ) async throws { + defer { request.configuration.clearSecrets() } try await worker.connect( using: request.withKeyboardInteractiveFallback(keyboardInteractive), shellIntegrationEnabled: shellIntegrationEnabled, @@ -734,6 +735,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication") ) throws -> SSHAuthenticatedConnection { _ = initialization + defer { configuration.clearSecrets() } let operationID = UUID().uuidString.lowercased() logger.info( "ssh.authentication.started", @@ -808,6 +810,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { let operationID = UUID().uuidString.lowercased() let request = request.enforcingInteractionPolicy() let configuration = request.configuration + defer { configuration.clearSecrets() } logger.info( "ssh.authentication.started", metadata: [ @@ -1071,11 +1074,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { if methods.contains("password") { attempted = true lastMethod = "password" - let result = username.withCString { user in - password.withCString { password in + let result = try username.withCString { user in + try password.withUnsafeCStringAndConsume { password, passwordLength in libssh2_userauth_password_ex( session, user, UInt32(username.utf8.count), - password, UInt32(strlen(password)), nil + password, UInt32(passwordLength), nil ) } } @@ -1085,25 +1088,25 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { guard methods.contains("publickey") else { continue } attempted = true lastMethod = "public_key" - let result = username.withCString { user in - privateKey.withCString { privateBuffer in - let call: (UnsafePointer?) -> Int32 = { publicBuffer in + let result = try username.withCString { user in + try privateKey.withUnsafeCStringAndConsume { privateBuffer, privateKeyLength in + let call: (UnsafePointer?) throws -> Int32 = { publicBuffer in if let passphrase { - return passphrase.withCString { passphraseBuffer in + return try passphrase.withUnsafeCStringAndConsume { passphraseBuffer, _ in libssh2_userauth_publickey_frommemory( session, user, username.utf8.count, publicBuffer, publicKey?.utf8.count ?? 0, - privateBuffer, privateKey.utf8.count, passphraseBuffer + privateBuffer, privateKeyLength, passphraseBuffer ) } } return libssh2_userauth_publickey_frommemory( session, user, username.utf8.count, publicBuffer, publicKey?.utf8.count ?? 0, - privateBuffer, privateKey.utf8.count, nil + privateBuffer, privateKeyLength, nil ) } - return publicKey?.withCString(call) ?? call(nil) + return try publicKey?.withCString(call) ?? call(nil) } } if result == 0 { return lastMethod } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift index 63b61f31..40666376 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSocketConnector.swift @@ -11,6 +11,7 @@ public nonisolated enum SSHSocketConnector { host: String, port: Int, proxy: SSHTransportProxy? = nil, timeout: TimeInterval = 20 ) throws -> Int32 { + defer { proxy?.clearSecret() } let socket = try openTCP( host: proxy?.hostname ?? host, port: proxy?.port ?? port, @@ -72,6 +73,7 @@ public nonisolated enum SSHSocketConnector { public static func negotiate( socket: Int32, host: String, port: Int, proxy: SSHTransportProxy ) throws { + defer { proxy.clearSecret() } switch proxy.kind { case .http: try negotiateHTTP(socket: socket, host: host, port: port, proxy: proxy) @@ -84,13 +86,31 @@ public nonisolated enum SSHSocketConnector { socket: Int32, host: String, port: Int, proxy: SSHTransportProxy ) throws { let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)" - var request = "CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n" - if let password = proxy.password, !proxy.username.isEmpty { - let token = Data("\(proxy.username):\(password)".utf8).base64EncodedString() - request += "Proxy-Authorization: Basic \(token)\r\n" - } - request += "\r\n" - try writeAll(socket, Data(request.utf8)) + try { + var request = Data( + "CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n".utf8 + ) + defer { wipe(&request) } + if let password = proxy.password, !proxy.username.isEmpty { + var plaintext = Data(proxy.username.utf8) + plaintext.append(UInt8(ascii: ":")) + defer { wipe(&plaintext) } + try password.withUnsafeCStringAndConsume { buffer, count in + plaintext.append( + buffer.withMemoryRebound(to: UInt8.self, capacity: count) { + UnsafeBufferPointer(start: $0, count: count) + } + ) + } + var token = plaintext.base64EncodedData() + defer { wipe(&token) } + request.append(Data("Proxy-Authorization: Basic ".utf8)) + request.append(token) + request.append(Data("\r\n".utf8)) + } + request.append(Data("\r\n".utf8)) + try writeAll(socket, request) + }() var response = Data() while response.range(of: Data("\r\n\r\n".utf8)) == nil { @@ -127,15 +147,21 @@ public nonisolated enum SSHSocketConnector { throw TermindSSHError.proxy("The SOCKS5 proxy requires a password.") } let username = Data(proxy.username.utf8) - let secret = Data(password.utf8) - guard username.count <= 255, secret.count <= 255 else { - throw TermindSSHError.proxy("The SOCKS5 username or password is too long.") + try password.withUnsafeCStringAndConsume { buffer, count in + guard username.count <= 255, count <= 255 else { + throw TermindSSHError.proxy("The SOCKS5 username or password is too long.") + } + var auth = Data([0x01, UInt8(username.count)]) + defer { wipe(&auth) } + auth.append(username) + auth.append(UInt8(count)) + auth.append( + buffer.withMemoryRebound(to: UInt8.self, capacity: count) { + UnsafeBufferPointer(start: $0, count: count) + } + ) + try writeAll(socket, auth) } - var auth = Data([0x01, UInt8(username.count)]) - auth.append(username) - auth.append(UInt8(secret.count)) - auth.append(secret) - try writeAll(socket, auth) let result = try readExactly(socket, count: 2) guard result[1] == 0 else { throw TermindSSHError.proxy("SOCKS5 proxy authentication failed.") @@ -180,6 +206,14 @@ 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 c9447a14..c93091b4 100644 --- a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift +++ b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift @@ -51,10 +51,25 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { return value } + public func sshSecret(for account: String, storage: PasswordStorage) throws -> SSHSecret? { + try validateStringReference(account) + guard + var data = try data( + for: account, service: Self.passwordService, + synchronizable: storage == .iCloud + ) + else { return nil } + return SSHSecret(consumingUTF8: &data) + } + public func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { try validateStringReference(account) + var data = value?.data(using: .utf8) + defer { + if data != nil { Self.wipe(&data!) } + } try setData( - value?.data(using: .utf8), for: account, service: Self.passwordService, + data, for: account, service: Self.passwordService, synchronizable: storage == .iCloud, accessible: storage == .iCloud ? kSecAttrAccessibleWhenUnlocked @@ -81,11 +96,32 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { } } + public func sshKeyCredential( + for account: String, storage: KeyStorage + ) throws -> SSHResolvedKeyCredential? { + try validate(account, prefix: "termind.key.") + guard + var data = try data( + for: account, service: service(for: storage), + synchronizable: storage == .iCloud + ) + else { return nil } + switch storage { + case .thisDevice, .iCloud: + defer { Self.wipe(&data) } + var privateKey = try Self.decodePrivateKeyBytes(from: data) + return .software(SSHSecret(consumingUTF8: &privateKey)) + case .secureEnclave: + guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } + return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data)) + } + } + public func setKeyCredential( _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage ) throws { try validate(account, prefix: "termind.key.") - let data: Data? + var data: Data? switch (value, storage) { case (.software(let secret), .thisDevice), (.software(let secret), .iCloud): data = try JSONEncoder().encode(secret) @@ -96,6 +132,9 @@ public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { default: throw KeychainSecretError.unexpectedValue } + defer { + if data != nil { Self.wipe(&data!) } + } try setData( data, for: account, service: service(for: storage), synchronizable: storage == .iCloud, @@ -105,6 +144,87 @@ 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 + 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 + } + index = data.index(after: index) + skipWhitespace() + guard index < data.endIndex, data[index] == 0x22 else { + throw KeychainSecretError.unexpectedValue + } + index = data.index(after: index) + + var result = Data() + while index < data.endIndex { + let byte = data[index] + index = data.index(after: index) + if byte == 0x22 { return result } + guard byte == 0x5C else { + result.append(byte) + continue + } + guard index < data.endIndex else { throw KeychainSecretError.unexpectedValue } + let escape = data[index] + index = data.index(after: index) + switch escape { + case 0x22, 0x5C, 0x2F: result.append(escape) + case 0x62: result.append(0x08) + case 0x66: result.append(0x0C) + case 0x6E: result.append(0x0A) + case 0x72: result.append(0x0D) + case 0x74: result.append(0x09) + case 0x75: + var value: UInt32 = 0 + for _ in 0..<4 { + guard index < data.endIndex, let digit = hex(data[index]) else { + throw KeychainSecretError.unexpectedValue + } + value = value * 16 + UInt32(digit) + index = data.index(after: index) + } + guard let scalar = Unicode.Scalar(value), !(0xD800...0xDFFF).contains(value) else { + throw KeychainSecretError.unexpectedValue + } + result.append(contentsOf: String(scalar).utf8) + default: + throw KeychainSecretError.unexpectedValue + } + } + throw KeychainSecretError.unexpectedValue + } + + private static func hex(_ byte: UInt8) -> UInt8? { + switch byte { + case 0x30...0x39: byte - 0x30 + case 0x41...0x46: byte - 0x41 + 10 + case 0x61...0x66: byte - 0x61 + 10 + default: nil + } + } + public func removePassword( _ account: String, storage: PasswordStorage = .thisDevice ) throws { diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index dafd9766..9307a4d0 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -93,6 +93,11 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r - SSH/SFTP/mobile Workspaces/Metrics share preparation/recovery policy. Host-key approval occurs inside the actual 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. - 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, diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index f9fa19e3..19735106 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -284,13 +284,16 @@ final class MobileWorkspace: Identifiable { host: host, selectedIdentityID: identity?.id, username: username, selectedKeyID: keyID, failureMessage: connectionFailure, requiresPassphrase: keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true + && staged?.requiresPasswordReentry != true && staged?.passphrase == nil, mode: authenticationMode) } var hostKeyPrompt: SSHHostKeyPrompt? { connection.pendingHostKey } - private func preparation(for host: Host) throws -> SSHConnectionPreparation { + private func preparation( + for host: Host, submissions: [UUID: SSHAuthenticationSubmission] + ) throws -> SSHConnectionPreparation { try hosts.prepareSSHConnection( for: host, submissions: submissions, @@ -306,6 +309,7 @@ final class MobileWorkspace: Identifiable { let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return } if authenticationPrompt?.requiresPassphrase == true, var staged = submissions[host.id] { + guard let passphrase, !passphrase.isEmpty else { return } staged.passphrase = passphrase submissions[host.id] = staged } else { @@ -555,6 +559,40 @@ 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 + }) { + authenticationRequired = true + authenticationMode = .localIdentity + authenticationHostID = passwordHost.id + username = + submissions[passwordHost.id]?.username + ?? hosts.identity(for: passwordHost)?.username ?? "" + authenticationPrompt = makeAuthenticationPrompt() + journey.waitForLocalIdentity(at: passwordHost.id) + 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 + }) { + authenticationRequired = true + authenticationMode = .localIdentity + authenticationHostID = passphraseHost.id + username = + submissions[passphraseHost.id]?.username + ?? hosts.identity(for: passphraseHost)?.username ?? "" + authenticationPrompt = makeAuthenticationPrompt() + journey.waitForLocalPassphrase(at: passphraseHost.id) + busy = false + return + } + let attemptSubmissions = submissions.takeAuthenticationSecrets() let progressHandler = connection.progressHandler(attemptID: token) { [weak self] in self?.generation == $0 } @@ -564,7 +602,7 @@ final class MobileWorkspace: Identifiable { defer { if generation == token { busy = false } } guard generation == token, !Task.isCancelled else { return } do { - let preparation = try preparation(for: host) + let preparation = try preparation(for: host, submissions: attemptSubmissions) let config = try await preparation.resolve( trustPolicy: .allowChallenge ) diff --git a/TFM/Application/FeatureModels/TransferLibraryModel.swift b/TFM/Application/FeatureModels/TransferLibraryModel.swift index 75d542c2..e2eba226 100644 --- a/TFM/Application/FeatureModels/TransferLibraryModel.swift +++ b/TFM/Application/FeatureModels/TransferLibraryModel.swift @@ -88,9 +88,17 @@ extension TransferLibraryModel: RemoteSessionHostStore { 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) + } func recordIdentityCredentialAccess(_ identityIDs: Set, at date: Date) { library.recordIdentityCredentialAccess(identityIDs, at: date) } diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 5d9850bd..767efc0b 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -308,12 +308,21 @@ final class SFTPChannelModel: Identifiable { let attemptID = UUID() connectionAttemptID = attemptID let route = store.route(for: host) - let suppliedAuthentication = suppliedAuthentications[host.id] + let stagedAuthentication = suppliedAuthentications[host.id] connectionJourney.begin( host: host, route: route, purpose: .sftpChannel, - username: suppliedAuthentication?.username ?? store.identity(for: host)?.username, + 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 + }) { + 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, @@ -328,10 +337,12 @@ final class SFTPChannelModel: Identifiable { ) return } - if reconnecting, usesEphemeralCredentials, suppliedAuthentication == nil { + if reconnecting, usesEphemeralCredentials { requestAuthentication(host: host, store: store) return } + let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets() + let suppliedAuthentication = attemptAuthentications[host.id] status = reconnecting ? .reconnecting : .connecting hint = reconnecting @@ -348,7 +359,7 @@ final class SFTPChannelModel: Identifiable { var credentials: [SSHCredentials] = [] if let keyID = suppliedAuthentication.keyID, let key = store.key(keyID), - let stored = try? store.connectionKeyCredential( + let stored = try? store.connectionKeySecretCredential( for: key, identityID: suppliedAuthentication.identityID ) { @@ -362,6 +373,13 @@ final class SFTPChannelModel: Identifiable { } if let password = suppliedAuthentication.password { credentials.append(.password(password)) + } else if let identityID = suppliedAuthentication.identityID, + let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword + { + guard let password = try store.connectionPasswordSecret(for: selectedIdentity) else { + throw TermindSSHError.missingCredential + } + credentials.append(.password(password)) } configuration = SSHConnectionConfiguration( hostID: host.id, host: host.host, port: host.port, @@ -370,7 +388,7 @@ final class SFTPChannelModel: Identifiable { expectedHostKeys: expectedHostKeys, proxy: try store.transportProxy(for: host), jumps: try store.sshJumpConfigurations( - for: host, submissions: suppliedAuthentications, + for: host, submissions: attemptAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ), @@ -387,7 +405,7 @@ final class SFTPChannelModel: Identifiable { ) } else { configuration = try store.sshConnectionConfiguration( - for: host, submissions: suppliedAuthentications, + for: host, submissions: attemptAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ) @@ -470,7 +488,8 @@ final class SFTPChannelModel: Identifiable { } let submitted = self.suppliedAuthentications[authenticationHost.id] let retryNeedsPassphrase = - submitted?.passphrase == nil + submitted?.requiresPasswordReentry != true + && submitted?.passphrase == nil && (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID) .flatMap { store.key($0) }?.requiresPassphraseEntry == true let returnsToIdentity = submitted?.passphrase != nil diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 74610376..3f34ab35 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -480,15 +480,24 @@ final class SSHSessionModel: Identifiable { homeResolution = isShellIntegrationEnabled ? .pending : .unavailable let route = connectionRoute?.hosts ?? store.route(for: host) let identity = store.identity(for: host) - let suppliedAuthentication = suppliedAuthentications[host.id] + let stagedAuthentication = suppliedAuthentications[host.id] let username = - suppliedAuthentication?.username + stagedAuthentication?.username ?? identity?.username ?? usernameHint connectionJourney.begin( 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] { + 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, @@ -511,6 +520,8 @@ final class SSHSessionModel: Identifiable { ) return } + let attemptAuthentications = suppliedAuthentications.takeAuthenticationSecrets() + let suppliedAuthentication = attemptAuthentications[host.id] var credentials: [SSHCredentials] = [] var credentialFailures: [String] = [] var hasConfiguredCredential = false @@ -524,10 +535,11 @@ final class SSHSessionModel: Identifiable { hasConfiguredCredential = suppliedAuthentication.keyID != nil || suppliedAuthentication.password != nil + || suppliedAuthentication.identityID.flatMap(store.identity)?.hasSecret == true if let keyID = suppliedAuthentication.keyID { if let key = store.key(keyID) { do { - if let credential = try store.connectionKeyCredential( + if let credential = try store.connectionKeySecretCredential( for: key, identityID: suppliedAuthentication.identityID ) { credentials.append( @@ -554,6 +566,20 @@ final class SSHSessionModel: Identifiable { } if let password = suppliedAuthentication.password { credentials.append(.password(password)) + } else if let identityID = suppliedAuthentication.identityID, + let selectedIdentity = store.identity(identityID), selectedIdentity.usesPassword + { + do { + if let password = try store.connectionPasswordSecret(for: selectedIdentity) { + credentials.append(.password(password)) + } else { + credentialFailures.append( + String(localized: "The saved password is no longer available.") + ) + } + } catch { + credentialFailures.append(error.localizedDescription) + } } } else if let identity { let resolution = store.sshCredentials(for: identity) @@ -586,12 +612,12 @@ final class SSHSessionModel: Identifiable { proxy = try store.transportProxy(for: host) if let connectionRoute { jumps = try store.sshJumpConfigurations( - for: connectionRoute, submissions: suppliedAuthentications, + for: connectionRoute, submissions: attemptAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ) } else { jumps = try store.sshJumpConfigurations( - for: host, submissions: suppliedAuthentications, + for: host, submissions: attemptAuthentications, temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ) } @@ -687,7 +713,8 @@ final class SSHSessionModel: Identifiable { submitted?.keyID ?? authenticationIdentity?.keyID ?? (authenticationHost.id == host.id ? selectedKeyID : nil) let retryNeedsPassphrase = - submitted?.passphrase == nil + submitted?.requiresPasswordReentry != true + && submitted?.passphrase == nil && authenticationKeyID.flatMap { store.key($0) }? .requiresPassphraseEntry == true let returnsToIdentity = submitted?.passphrase != nil diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index 32de9a31..b6d44a51 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -199,7 +199,7 @@ struct AgentHostAuthenticationTests { Issue.record("Expected a private-key credential") return } - #expect(passphrase == "one-use") + #expect(passphrase?.matches("one-use") == true) guard configuration.credentials.count == 2, case .password(let password) = configuration.credentials[1] else { @@ -280,7 +280,7 @@ struct AgentHostAuthenticationTests { Issue.record("Expected a private-key credential") return } - #expect(passphrase == "one-use") + #expect(passphrase?.matches("one-use") == true) #expect(coordinator.pending.isEmpty) } @@ -318,8 +318,10 @@ struct AgentHostAuthenticationTests { #expect(requestedHosts == [jump.id, destination.id]) let configuration = try #require(await backend.sftpConfigurations.first) + let destinationPassword = try #require(await backend.observedDestinationPasswords.first) + let jumpPasswords = try #require(await backend.observedJumpPasswords.first) #expect(configuration.username == "files-user") - #expect(password(in: configuration) == "files-secret") + #expect(destinationPassword == "files-secret") #expect(configuration.jumps.count == 1) #expect(configuration.jumps[0].id == jump.id) #expect(configuration.jumps[0].username == "jump-user") @@ -328,7 +330,8 @@ struct AgentHostAuthenticationTests { Issue.record("Expected a jump-host password") return } - #expect(jumpPassword == "jump-secret") + #expect(jumpPasswords == ["jump-secret"]) + #expect(jumpPassword.isConsumed) } @Test @@ -377,12 +380,14 @@ struct AgentHostAuthenticationTests { #expect(!result.isError) #expect(await backend.sftpOpens == 1) let configuration = try #require(await backend.sftpConfigurations.first) + let observedPassword = try #require(await backend.observedDestinationPasswords.first) #expect(configuration.username == "deploy") guard case .password(let password) = configuration.credentials.first else { Issue.record("Expected the one-use password in the SFTP configuration") return } - #expect(password == "secret") + #expect(observedPassword == "secret") + #expect(password.isConsumed) } @Test @@ -491,8 +496,14 @@ struct AgentHostAuthenticationTests { #expect(await setup.backend.execOpens == 2) #expect(await setup.backend.sftpOpens == 1) let configurations = await setup.backend.sftpConfigurations - #expect(password(in: configurations[0]) == "old-secret") - #expect(password(in: configurations[1]) == "new-secret") + #expect(await setup.backend.observedDestinationPasswords == ["old-secret", "new-secret"]) + #expect( + configurations.allSatisfy { configuration in + configuration.credentials.allSatisfy { credential in + guard case .password(let password) = credential else { return true } + return password.isConsumed + } + }) } @Test @@ -536,10 +547,50 @@ struct AgentHostAuthenticationTests { ) let retained = try await session.configurationAttempt() #expect(retained.generation == second.generation) - #expect(password(in: retained.configuration) == "new-secret") + #expect(password(in: retained.configuration, matches: "new-secret")) #expect(await interaction.requestCount == 2) } + @Test + func successfulAuthenticationConsumesAgentSecretsAndPromptsForAnotherConnection() async throws { + let host = Host(name: "Server", host: "server.example") + let store = HostLibraryModel( + hosts: [host], + knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")] + ) + var requests = 0 + let interaction = AuthenticationResponder { _ in + requests += 1 + return .submit( + AgentSSHAuthenticationAnswer( + username: "deploy", password: "one-use-\(requests)", save: false + )) + } + let session = AgentSSHAuthenticationContext( + resolver: AgentHostResolver(store: store), hostID: host.id, + invocationID: UUID(), interaction: interaction.sshInteraction + ) + + let first = try await session.configurationAttempt() + guard case .password(let firstPassword) = first.configuration.credentials.first else { + Issue.record("Expected the first one-shot password") + return + } + #expect(firstPassword.matches("one-use-1")) + + await session.didAuthenticate(generation: first.generation) + #expect(firstPassword.isConsumed) + + let second = try await session.configurationAttempt() + guard case .password(let secondPassword) = second.configuration.credentials.first else { + Issue.record("Expected a newly entered password") + return + } + #expect(requests == 2) + #expect(secondPassword.matches("one-use-2")) + #expect(!secondPassword.isConsumed) + } + @Test func connectionLossEvictsSFTPWithoutReplayingTheOperation() async throws { let setup = authenticationSetup(sftpOperationFailures: 1) @@ -1081,11 +1132,13 @@ struct AgentHostAuthenticationTests { ) } - private func password(in configuration: SSHConnectionConfiguration) -> String? { + private func password( + in configuration: SSHConnectionConfiguration, matches expected: String + ) -> Bool { for credential in configuration.credentials { - if case .password(let password) = credential { return password } + if case .password(let password) = credential { return password.matches(expected) } } - return nil + return false } } @@ -1220,6 +1273,8 @@ private actor AuthenticationConnectionBackend { private(set) var sftpSiblingChannels = 0 private(set) var sftpOperations = 0 private(set) var sftpConfigurations: [SSHConnectionConfiguration] = [] + private(set) var observedDestinationPasswords: [String?] = [] + private(set) var observedJumpPasswords: [[String?]] = [] private var sftpAuthenticationFailures: Int private var sftpOperationFailures: Int private var remoteFiles: [String: Data] @@ -1236,6 +1291,10 @@ private actor AuthenticationConnectionBackend { func openConnection(configuration: SSHConnectionConfiguration) throws -> AuthenticationSSHConnection { execOpens += 1 + observedDestinationPasswords.append(Self.passwordLabel(in: configuration.credentials)) + observedJumpPasswords.append( + configuration.jumps.map { Self.passwordLabel(in: $0.credentials) } + ) sftpConfigurations.append(configuration) if sftpAuthenticationFailures > 0 { sftpAuthenticationFailures -= 1 @@ -1244,6 +1303,17 @@ private actor AuthenticationConnectionBackend { return AuthenticationSSHConnection(backend: self) } + private static func passwordLabel(in credentials: [SSHCredentials]) -> String? { + guard + case .password(let password)? = credentials.first(where: { + if case .password = $0 { return true } + return false + }) + else { return nil } + return ["files-secret", "jump-secret", "secret", "old-secret", "new-secret", "fallback-password"] + .first(where: password.matches) + } + func openSFTP( configuration: SSHConnectionConfiguration ) throws -> AuthenticationSFTPClient { diff --git a/TFMTests/SSHForwardingWireTests.swift b/TFMTests/SSHForwardingWireTests.swift index af696135..c7c0b613 100644 --- a/TFMTests/SSHForwardingWireTests.swift +++ b/TFMTests/SSHForwardingWireTests.swift @@ -125,9 +125,12 @@ nonisolated struct SSHForwardingWireTests { primary.authorizeHostKey = nil try await pool.connect(primary, progress: nil) await pool.configureAdditionalConnections { - SSHConnectionRequest( - configuration: primary.configuration, interactionPolicy: .forbidden - ).enforcingInteractionPolicy() + var additional = request(fixture, jumps: 0) + additional.configuration.expectedHostKeys = [ + SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: fingerprint) + ] + additional.interactionPolicy = .forbidden + return additional.enforcingInteractionPolicy() } let local = try rule(.local, fixture: fixture) try await pool.start(local, generation: UUID()) @@ -276,21 +279,22 @@ nonisolated struct SSHForwardingWireTests { private func request(_ fixture: Fixture, jumps: Int, username: String = "none") -> SSHConnectionRequest { let user = fixture.username ?? username - let credentials: [SSHCredentials] = + func credentials() -> [SSHCredentials] { fixture.username.flatMap { _ in fixture.privateKey }.map { [.privateKey(privateKey: $0, publicKey: nil, passphrase: nil)] } ?? [] + } let expectedHostKeys: Set = fixture.hostKeyFingerprint.map { [SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: $0)] } ?? [] var configuration = SSHConnectionConfiguration( - hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: user, credentials: credentials, + hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: user, credentials: credentials(), expectedHostKeys: expectedHostKeys) configuration.jumps = (0..