diff --git a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift index 6bf09022..dc84f24d 100644 --- a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift +++ b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift @@ -263,7 +263,7 @@ struct CloudSyncSafetyPersistenceTests { #expect(!TermindSchemaV40.models.contains { $0 == PersistentVaultSyncQuarantine.self }) #expect(!TermindSchemaV40.models.contains { $0 == PersistentAIConfigurationSyncQuarantine.self }) #expect(!TermindSchemaV40.models.contains { $0 == PersistentMetricsSyncQuarantine.self }) - #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV43.versionIdentifier) + #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV44.versionIdentifier) } @Test diff --git a/FrameworkTests/NotificationPreferencesTests.swift b/FrameworkTests/NotificationPreferencesTests.swift index e221a240..3a5b84e1 100644 --- a/FrameworkTests/NotificationPreferencesTests.swift +++ b/FrameworkTests/NotificationPreferencesTests.swift @@ -145,7 +145,7 @@ struct NotificationPreferencesTests { let envelope = SettingsEnvelope( key: payload.key, version: .next(after: nil, writerID: UUID()), payload: payload) #expect(!envelope.isValid) - #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV43.versionIdentifier) + #expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV44.versionIdentifier) #expect(TermindSchemaV41.models.contains { $0 == PersistentNotificationPreferences.self }) #expect(!TermindSchemaV40.models.contains { $0 == PersistentNotificationPreferences.self }) } diff --git a/FrameworkTests/SSHAgentForwardingPersistenceTests.swift b/FrameworkTests/SSHAgentForwardingPersistenceTests.swift new file mode 100644 index 00000000..2e273cde --- /dev/null +++ b/FrameworkTests/SSHAgentForwardingPersistenceTests.swift @@ -0,0 +1,50 @@ +import Foundation +import SwiftData +import TermindDomain +import TermindPersistence +import Testing + +@Suite @MainActor +struct SSHAgentForwardingPersistenceTests { + @Test func version43HostMigratesDisabledAndNewSettingSurvivesReopen() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("Migration.store") + let host = TermindDomain.Host(name: "Existing", host: "localhost", startupSnippetID: UUID()) + do { + let schema = Schema(versionedSchema: TermindSchemaV43.self) + let container = try ModelContainer( + for: schema, + configurations: [ + ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none) + ]) + container.mainContext.insert(TermindSchemaV32HostModels.PersistentHost(host)) + try container.mainContext.save() + } + do { + let container = try TermindPersistence.makeContainer(url: url) + let record = try #require(container.mainContext.fetch(FetchDescriptor()).first) + #expect(!record.agentForwardingEnabled) + #expect(record.value.startupSnippetID == host.startupSnippetID) + record.agentForwardingEnabled = true + try container.mainContext.save() + } + let reopened = try TermindPersistence.makeContainer(url: url) + #expect( + try reopened.mainContext.fetch(FetchDescriptor()).first?.value.agentForwardingEnabled + == true) + } + + @Test func hostVersionTwoRoundTripsAndCannotBeWrittenAsVersionOne() throws { + let host = TermindDomain.Host(name: "Forwarded", host: "localhost", agentForwardingEnabled: true) + let payload = VaultRecordPayload.host(host) + var envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) + #expect(envelope.formatVersion == 2) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope) + envelope.formatVersion = 1 + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) } + #expect(SyncRecordFormats.vault.writingVersion(for: "sshKey") == 1) + #expect(SyncRecordFormats.vault.supports(kind: "host", version: 1)) + } +} diff --git a/FrameworkTests/SSHAgentForwardingTests.swift b/FrameworkTests/SSHAgentForwardingTests.swift new file mode 100644 index 00000000..96cef332 --- /dev/null +++ b/FrameworkTests/SSHAgentForwardingTests.swift @@ -0,0 +1,239 @@ +import CryptoKit +import Foundation +import OpenSSL +import TermindApplication +import TermindDomain +import TermindPersistence +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +struct SSHAgentForwardingTests { + @Test func framesSurviveFragmentationAndCoalescing() throws { + let first = SSHAgentProtocol.frame(Data([11])) + let second = SSHAgentProtocol.frame(Data([13, 0, 0, 0, 0])) + var decoder = SSHAgentProtocol() + for byte in first.dropLast() { + try decoder.append(Data([byte])) + #expect(try decoder.next() == nil) + } + try decoder.append(Data(first.suffix(1)) + second) + #expect(try decoder.next() == Data([11])) + #expect(try decoder.next() == Data([13, 0, 0, 0, 0])) + #expect(try decoder.next() == nil) + } + + @Test func oversizedAndEmptyFramesAreRejected() { + for length: UInt32 in [0, UInt32(SSHAgentProtocol.maximumPacket + 1), UInt32.max] { + var decoder = SSHAgentProtocol() + #expect(throws: (any Error).self) { try decoder.append(SSHAgentProtocol.uint32(length)) } + } + } + + @Test @MainActor func managementAndMalformedSignRequestsNeverReachSigner() async { + let provider = RecordingAgent() + for packet in [Data([17]), Data([18]), Data([19]), Data([13]), Data([11, 0])] { + #expect( + await SSHAgentProtocol.response(to: packet, provider: provider) == SSHAgentProtocol.frame(Data([5]))) + } + #expect(provider.signCalls == 0) + } + + @Test @MainActor func signatureFlagsAndDataArePreserved() async { + let provider = RecordingAgent() + let payload = + Data([13]) + SSHAgentProtocol.field(Data([1, 2])) + + SSHAgentProtocol.field(Data([3, 4])) + SSHAgentProtocol.uint32(4) + #expect( + await SSHAgentProtocol.response(to: payload, provider: provider) + == SSHAgentProtocol.frame(Data([14]) + SSHAgentProtocol.field(Data([9])))) + #expect(provider.signCalls == 1) + #expect(provider.lastFlags == 4) + #expect(provider.lastData == Data([3, 4])) + } + + @Test(arguments: [ + SSHKeyMaterial.Generation.ed25519, .ecdsa(bits: 256), .ecdsa(bits: 384), .ecdsa(bits: 521), .rsa(bits: 2048), + ]) + func signaturesVerifyIndependently(_ generation: SSHKeyMaterial.Generation) throws { + let material = try SSHKeyMaterial.generate(generation, comment: "fixture", passphrase: nil) + let data = Data("SSH agent independent verification".utf8) + let flags: [UInt32] = material.algorithm == .rsa ? [0, 2, 4] : [0] + for flag in flags { + let blob = try SSHAgentKeySigner.sign( + credential: .software(SSHSecret(material.privateKey)), passphrase: nil, + algorithm: material.algorithm, data: data, flags: flag) + try verify(blob, data: data, material: material) + } + } + + @Test func encryptedKeyUsesSavedPassphraseAndClearsSecrets() throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: "fixture-password") + let key = SSHSecret(material.privateKey) + let password = SSHSecret("fixture-password") + let data = Data("encrypted agent key".utf8) + let signature = try SSHAgentKeySigner.sign( + credential: .software(key, passphrase: password), passphrase: nil, + algorithm: material.algorithm, data: data, flags: 0) + #expect(key.isConsumed && password.isConsumed) + try verify(signature, data: data, material: material, password: "fixture-password") + } + + @Test @MainActor func disablingAndReenablingDoesNotReviveOldCapability() async throws { + var host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true) + let library = HostLibraryModel(hosts: [host]) + let factory = VaultSSHAgent.factory(library: library) + let request = SSHConnectionRequest( + configuration: .init( + hostID: host.id, host: host.host, port: 22, username: "fixture", credentials: [], expectedHostKeys: [])) + let agent = try #require(try await factory(request)) + #expect(try await agent.identities().isEmpty) + host.agentForwardingEnabled = false + library.upsert(host) + host.agentForwardingEnabled = true + library.upsert(host) + await #expect(throws: (any Error).self) { try await agent.identities() } + #expect(try await factory(request) != nil) + } + + @Test @MainActor func forbiddenInteractionUsesSavedPasswordButNeverPrompts() async throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: "fixture-password") + let host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true) + let key = SSHKeyRef( + label: "Protected", algorithm: .ed25519, publicKey: material.publicKey, + credentialRef: "fixture", storage: .thisDevice, hasPassphrase: true) + let secrets = InMemorySecretStore() + try secrets.setKeyCredential( + .software(.init(privateKey: material.privateKey)), for: "fixture", storage: .thisDevice) + let library = HostLibraryModel(hosts: [host], keys: [key], secrets: secrets) + let factory = VaultSSHAgent.factory( + library: library, + unlock: { _ in + Issue.record("Forbidden request opened an unlock prompt") + return ["fixture-password"] + }) + let request = SSHConnectionRequest( + configuration: .init( + hostID: host.id, host: host.host, port: 22, + username: "fixture", credentials: [], expectedHostKeys: []), interactionPolicy: .forbidden) + let agent = try #require(try await factory(request)) + let identity = try #require(try await agent.identities().first) + await #expect(throws: SSHInteractionRequired.self) { + try await agent.sign(key: identity.blob, data: Data([1]), flags: 0) + } + try secrets.setKeyCredential( + .software(.init(privateKey: material.privateKey, passphrase: "fixture-password")), + for: "fixture", storage: .thisDevice) + #expect(try await !agent.sign(key: identity.blob, data: Data([1]), flags: 0).isEmpty) + try library.delete(keyID: key.id) + #expect(try await agent.identities().isEmpty) + await #expect(throws: (any Error).self) { try await agent.sign(key: identity.blob, data: Data([1]), flags: 0) } + } + + @Test @MainActor func revocationDuringUnlockDiscardsTheAnswer() async throws { + let material = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: "fixture-password") + let host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true) + let key = SSHKeyRef( + label: "Protected", algorithm: .ed25519, publicKey: material.publicKey, + credentialRef: "fixture", storage: .thisDevice, hasPassphrase: true) + let secrets = InMemorySecretStore() + try secrets.setKeyCredential( + .software(.init(privateKey: material.privateKey)), for: "fixture", storage: .thisDevice) + let library = HostLibraryModel(hosts: [host], keys: [key], secrets: secrets) + let factory = VaultSSHAgent.factory( + library: library, + unlock: { _ in + await MainActor.run { + var disabled = host + disabled.agentForwardingEnabled = false + library.upsert(disabled) + } + return ["fixture-password"] + }) + let request = SSHConnectionRequest( + configuration: .init( + hostID: host.id, host: host.host, port: 22, + username: "fixture", credentials: [], expectedHostKeys: [])) + let agent = try #require(try await factory(request)) + let identity = try #require(try await agent.identities().first) + await #expect(throws: (any Error).self) { try await agent.sign(key: identity.blob, data: Data([1]), flags: 0) } + await agent.close() + await #expect(throws: (any Error).self) { try await agent.identities() } + } + + @Test func hostLegacyDecodeDefaultsOffAndRoundTrips() throws { + let host = Host(name: "fixture", host: "localhost", agentForwardingEnabled: true) + let encoder = JSONEncoder() + let encoded = try encoder.encode(host) + #expect(try JSONDecoder().decode(Host.self, from: encoded).agentForwardingEnabled) + var json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "agentForwardingEnabled") + let legacy = try JSONSerialization.data(withJSONObject: json) + #expect(try !JSONDecoder().decode(Host.self, from: legacy).agentForwardingEnabled) + } + + private func verify(_ blob: Data, data: Data, material: SSHKeyMaterial, password: String = "") throws { + var reader = SSHAgentProtocol.Reader(blob) + let name = String(decoding: try reader.field(), as: UTF8.self) + var signature = try reader.field() + #expect(reader.isEmpty) + if name.hasPrefix("ecdsa-") { + var fields = SSHAgentProtocol.Reader(signature) + let r = try fields.field() + let s = try fields.field() + let pair = try #require(ECDSA_SIG_new()) + defer { ECDSA_SIG_free(pair) } + let rb = r.withUnsafeBytes { BN_bin2bn($0.bindMemory(to: UInt8.self).baseAddress, Int32($0.count), nil) } + let sb = s.withUnsafeBytes { BN_bin2bn($0.bindMemory(to: UInt8.self).baseAddress, Int32($0.count), nil) } + #expect(ECDSA_SIG_set0(pair, rb, sb) == 1) + var der: UnsafeMutablePointer? + let length = i2d_ECDSA_SIG(pair, &der) + let pointer = try #require(der) + signature = Data(bytes: pointer, count: Int(length)) + CRYPTO_free(pointer, nil, 0) + } + let bio = try #require(BIO_new(BIO_s_mem())) + defer { BIO_free(bio) } + _ = material.privateKey.withCString { BIO_write(bio, $0, Int32(material.privateKey.utf8.count)) } + let key = try #require( + password.withCString { + PEM_read_bio_PrivateKey(bio, nil, nil, UnsafeMutableRawPointer(mutating: $0)) + }) + defer { EVP_PKEY_free(key) } + let digest: OpaquePointer? = + switch name { + case "ssh-ed25519": nil + case "ssh-rsa": EVP_sha1() + case "rsa-sha2-512", "ecdsa-sha2-nistp521": EVP_sha512() + case "ecdsa-sha2-nistp384": EVP_sha384() + default: EVP_sha256() + } + let context = try #require(EVP_MD_CTX_new()) + defer { EVP_MD_CTX_free(context) } + #expect(EVP_DigestVerifyInit(context, nil, digest, nil, key) == 1) + let result = signature.withUnsafeBytes { sig in + data.withUnsafeBytes { message in + EVP_DigestVerify( + context, sig.bindMemory(to: UInt8.self).baseAddress, sig.count, + message.bindMemory(to: UInt8.self).baseAddress, message.count) + } + } + #expect(result == 1) + } +} + +@MainActor +private final class RecordingAgent: SSHAgentForwarding { + var signCalls = 0 + var lastFlags: UInt32 = 0 + var lastData = Data() + func identities() async throws -> [SSHAgentIdentity] { [] } + func sign(key: Data, data: Data, flags: UInt32) async throws -> Data { + signCalls += 1 + lastFlags = flags + lastData = data + return Data([9]) + } + func close() async {} +} diff --git a/FrameworkTests/SyncRecordEvolutionTests.swift b/FrameworkTests/SyncRecordEvolutionTests.swift index 7c4db141..22ab6fa2 100644 --- a/FrameworkTests/SyncRecordEvolutionTests.swift +++ b/FrameworkTests/SyncRecordEvolutionTests.swift @@ -25,10 +25,10 @@ struct SyncRecordEvolutionTests { #expect(try VaultSyncJournal(context: container.mainContext).pendingEnvelopes().isEmpty) } - @Test func versionTwoHostDecoderLeavesOtherKindsOnVersionOne() throws { + @Test func versionThreeHostDecoderLeavesOtherKindsOnVersionOne() throws { // A test-only future protocol adds a field that an old writer would lose. - // The historical decoder upgrades in memory; only explicit editing writes V2. - nonisolated struct HostV2: Codable, Sendable { + // The historical decoder upgrades in memory; only explicit editing writes V3. + nonisolated struct HostV3: Codable, Sendable { var formatVersion: Int var key: SyncRecordKey var name: String @@ -38,20 +38,20 @@ struct SyncRecordEvolutionTests { var kind: String var id: UUID } - let decoder = VersionedRecordDecoder([ + let decoder = VersionedRecordDecoder([ "host": [ 1: { data in let old = try VaultSyncCodec.decode(data) guard case .host(let host) = old.payload else { throw CocoaError(.coderReadCorrupt) } - return HostV2( - formatVersion: 2, key: SyncRecordKey(kind: "host", id: old.key.id), name: host.name, + return HostV3( + formatVersion: 3, key: SyncRecordKey(kind: "host", id: old.key.id), name: host.name, futureValue: "inherited") }, - 2: { try JSONDecoder().decode(HostV2.self, from: $0) }, + 3: { try JSONDecoder().decode(HostV3.self, from: $0) }, ] ]) let formats = SyncRecordFormats([ - "host": .init(readingVersions: [1, 2], writingVersion: 2), + "host": .init(readingVersions: [1, 3], writingVersion: 3), "snippet": .init(readingVersions: [1], writingVersion: 1), ]) var upgraded = try decoder.decode(Self.hostV1) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index a3cb91d0..d4fbb9db 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -2870,6 +2870,52 @@ } } }, + "Agent Forwarding" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent Forwarding" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent转发" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent轉送" + } + } + } + }, + "Agent Forwarding request failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent Forwarding request failed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent转发请求失败" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent轉送要求失敗" + } + } + } + }, "Agent error" : { "extractionState" : "manual", "localizations" : { @@ -3154,6 +3200,29 @@ } } }, + "Allow this host to request signatures using all keys in Termind. Private keys stay on this device." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow this host to request signatures using all keys in Termind. Private keys stay on this device." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许此主机请求使用Termind中的全部密钥签名。私钥保留在此设备上。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "允許此主機要求使用Termind中的所有金鑰簽署。私密金鑰保留在此裝置上。" + } + } + } + }, "Already imported" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift index 31807b03..d9c6742d 100644 --- a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift +++ b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift @@ -161,6 +161,7 @@ nonisolated public struct AgentSSHInteraction: Sendable { public typealias HostKeyConfirming = @Sendable (AgentSSHHostKeyRequest) async -> AgentSSHHostKeyResponse + public var presentationContextID: UUID? public var authenticate: Authenticating public var confirmHostKey: HostKeyConfirming @@ -186,7 +187,11 @@ nonisolated public struct AgentSSHInteraction: Sendable { return answers } - public init(authenticate: @escaping Authenticating, confirmHostKey: @escaping HostKeyConfirming) { + public init( + presentationContextID: UUID? = nil, authenticate: @escaping Authenticating, + confirmHostKey: @escaping HostKeyConfirming + ) { + self.presentationContextID = presentationContextID self.authenticate = authenticate self.confirmHostKey = confirmHostKey } diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index db397c5c..bf8255ae 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -3449,6 +3449,7 @@ public final class WeakAgentUserInteraction { /// `AgentSSHInteraction`. Weak for the same reason `request` is. public var sshInteraction: AgentSSHInteraction { AgentSSHInteraction( + presentationContextID: conversation?.id, authenticate: { [weak conversation] request in guard let conversation else { return .cancelled } return await conversation.requestSSHAuthentication(request) diff --git a/Frameworks/TermindApplication/Connections/SSHAgentForwarding.swift b/Frameworks/TermindApplication/Connections/SSHAgentForwarding.swift new file mode 100644 index 00000000..39f1d3dc --- /dev/null +++ b/Frameworks/TermindApplication/Connections/SSHAgentForwarding.swift @@ -0,0 +1,29 @@ +import Foundation + +/// A connection-scoped capability. No private material crosses the SSH protocol. +public nonisolated struct SSHAgentIdentity: Sendable, Equatable { + public let blob: Data + public let comment: String + public init(blob: Data, comment: String) { + self.blob = blob + self.comment = comment + } +} + +public nonisolated protocol SSHAgentForwarding: Sendable { + @MainActor func identities() async throws -> [SSHAgentIdentity] + /// Returns the SSH signature blob (algorithm string and signature string). + @MainActor func sign(key: Data, data: Data, flags: UInt32) async throws -> Data + @MainActor func close() async +} + +public typealias SSHAgentForwardingFactory = @Sendable (SSHConnectionRequest) async throws -> (any SSHAgentForwarding)? + +public struct SSHAgentInteractionContext: Sendable { + public let unlock: SSHKeyboardInteractiveHandler? + public let canInteract: @MainActor @Sendable () -> Bool + public init(unlock: SSHKeyboardInteractiveHandler? = nil, canInteract: @escaping @MainActor @Sendable () -> Bool) { + self.unlock = unlock + self.canInteract = canInteract + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 25a27d11..a05f5ac3 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -10,9 +10,26 @@ import struct TermindDomain.Host /// types for the views. Previews can still construct an in-memory store directly. @Observable public final class HostLibraryModel: RemoteSessionHostStore { - public private(set) var hosts: [Host] = [] + public private(set) var hosts: [Host] = [] { + didSet { + for old in oldValue where old.agentForwardingEnabled { + if !hosts.contains(where: { $0.id == old.id && $0.agentForwardingEnabled }) { + agentForwardingRevisions[old.id, default: 0] += 1 + } + } + } + } + @ObservationIgnored private var agentForwardingRevisions: [UUID: UInt64] = [:] + public func agentForwardingRevision(for hostID: UUID) -> UInt64 { + agentForwardingRevisions[hostID, default: 0] + } + public var agentForwardingSecrets: any SSHRuntimeSecretResolving { keychain } + public private(set) var groups: [HostGroup] = [] - public private(set) var keys: [SSHKeyRef] = [] + public private(set) var keys: [SSHKeyRef] = [] { + didSet { if oldValue != keys { keyCredentialRevision &+= 1 } } + } + public var agentForwardingKeyRevision: Int { keyCredentialRevision } public private(set) var identities: [Identity] = [] public private(set) var snippets: [Snippet] = [] public private(set) var snippetPackages: [SnippetPackage] = [] diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index b55ba394..ef2823cc 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -121,6 +121,9 @@ public nonisolated struct SSHInteractionRequired: LocalizedError, Sendable { /// the connection must already contain a matching expected key. public nonisolated struct SSHConnectionRequest: Sendable { public var configuration: SSHConnectionConfiguration + public var agentForwarding: (any SSHAgentForwarding)? + public var permitsAgentForwarding = true + public var agentForwardingContextID: UUID? public var authorizeHostKey: SSHHostKeyAuthorizer? public var keyboardInteractive: SSHKeyboardInteractiveHandler? public var interactionPolicy: SSHInteractionPolicy @@ -131,13 +134,15 @@ public nonisolated struct SSHConnectionRequest: Sendable { authorizeHostKey: SSHHostKeyAuthorizer? = nil, keyboardInteractive: SSHKeyboardInteractiveHandler? = nil, interactionPolicy: SSHInteractionPolicy = .allowed, - hostKeyConstraints: [Host.ID: SSHHostKeyConstraint] = [:] + hostKeyConstraints: [Host.ID: SSHHostKeyConstraint] = [:], + agentForwardingContextID: UUID? = nil ) { self.configuration = configuration self.authorizeHostKey = authorizeHostKey self.keyboardInteractive = keyboardInteractive self.interactionPolicy = interactionPolicy self.hostKeyConstraints = hostKeyConstraints + self.agentForwardingContextID = agentForwardingContextID } public func withKeyboardInteractiveFallback(_ handler: SSHKeyboardInteractiveHandler?) -> Self { diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index 9c817e00..5a3298cd 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -151,6 +151,8 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { public var tags: [String] = [] public var colorTag: TagColor = .none public var shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings + /// Allows the destination to request signatures from this device's Vault keys. + public var agentForwardingEnabled = false /// A reusable script submitted after each interactive SSH shell becomes ready. /// The body is resolved from the Snippet library for every connection attempt. public var startupSnippetID: Snippet.ID? @@ -280,7 +282,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { groupID: HostGroup.ID? = nil, tags: [String] = [], colorTag: TagColor = .none, detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0, shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings, - startupSnippetID: Snippet.ID? = nil, + startupSnippetID: Snippet.ID? = nil, agentForwardingEnabled: Bool = false, environmentVariables: [HostEnvironmentVariable] = [], portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections ) { @@ -306,6 +308,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { self.sortIndex = sortIndex self.shellIntegrationMode = shellIntegrationMode self.startupSnippetID = startupSnippetID + self.agentForwardingEnabled = agentForwardingEnabled self.environmentVariables = environmentVariables self.portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections( portForwardingMaxConnections) @@ -317,7 +320,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { case charset case sshEnabled, telnetEnabled, telnetPort, telnetIdentityID, telnetCharset, telnetProxy case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode - case startupSnippetID, environmentVariables, portForwardingMaxConnections + case agentForwardingEnabled, startupSnippetID, environmentVariables, portForwardingMaxConnections } public init(from decoder: any Decoder) throws { @@ -349,6 +352,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { shellIntegrationMode = try values.decodeIfPresent(String.self, forKey: .shellIntegrationMode) .flatMap(HostShellIntegrationMode.init(rawValue:)) ?? .followGlobalSettings + agentForwardingEnabled = try values.decodeIfPresent(Bool.self, forKey: .agentForwardingEnabled) ?? false startupSnippetID = try values.decodeIfPresent(Snippet.ID.self, forKey: .startupSnippetID) environmentVariables = try values.decodeIfPresent([HostEnvironmentVariable].self, forKey: .environmentVariables) ?? [] @@ -380,6 +384,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { try values.encodeIfPresent(detectedDistribution, forKey: .detectedDistribution) try values.encode(sortIndex, forKey: .sortIndex) try values.encode(shellIntegrationMode.rawValue, forKey: .shellIntegrationMode) + try values.encode(agentForwardingEnabled, forKey: .agentForwardingEnabled) try values.encodeIfPresent(startupSnippetID, forKey: .startupSnippetID) try values.encode(environmentVariables, forKey: .environmentVariables) try values.encode(portForwardingMaxConnections, forKey: .portForwardingMaxConnections) diff --git a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift index 729540f3..3e5e0845 100644 --- a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift +++ b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift @@ -44,7 +44,7 @@ public nonisolated struct SyncRecordFormats: Sendable { private static let v1 = Format(readingVersions: [1], writingVersion: 1) public static let vault = Self([ - "host": v1, "hostGroup": v1, "identity": v1, "sshKey": v1, + "host": Format(readingVersions: [1, 2], writingVersion: 2), "hostGroup": v1, "identity": v1, "sshKey": v1, "knownHost": v1, "snippet": v1, "snippetPackage": v1, "portForwarding": v1, "workspaceTemplate": v1, // Legacy readers only; these cases cannot pass Vault upload eligibility. diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index f698f62c..0864c1b6 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -883,6 +883,14 @@ public enum TermindSchemaV43: VersionedSchema { } } +public enum TermindSchemaV44: VersionedSchema { + public static let versionIdentifier = Schema.Version(44, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV43.models.filter { $0 != TermindSchemaV32HostModels.PersistentHost.self } + + [TermindSchemaV44HostModels.PersistentHost.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -897,7 +905,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV30.self, TermindSchemaV31.self, TermindSchemaV34.self, TermindSchemaV35.self, TermindSchemaV36.self, TermindSchemaV37.self, TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self, - TermindSchemaV42.self, TermindSchemaV43.self, + TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, ] } @@ -1000,11 +1008,12 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV40.self, toVersion: TermindSchemaV41.self), MigrationStage.lightweight(fromVersion: TermindSchemaV41.self, toVersion: TermindSchemaV42.self), MigrationStage.lightweight(fromVersion: TermindSchemaV42.self, toVersion: TermindSchemaV43.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV43.self, toVersion: TermindSchemaV44.self), ] } } -public typealias PersistentHost = TermindSchemaV32HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV44HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV33IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey public typealias PersistentKnownHost = TermindSchemaV30KnownHostModels.PersistentKnownHost diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 00c32d65..aac54557 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -6,7 +6,7 @@ import SwiftData @MainActor public enum TermindPersistence { // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV43.self) + public static let schema = Schema(versionedSchema: TermindSchemaV44.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPersistence/TermindSchemaV44HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV44HostModels.swift new file mode 100644 index 00000000..c25f2922 --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV44HostModels.swift @@ -0,0 +1,127 @@ +import Foundation +import SwiftData +import TermindDomain + +import struct TermindDomain.Host + +public enum TermindSchemaV44HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var name = "" + public var address = "" + public var sshEnabled = true + public var port = 22 + public var identityID: UUID? + public var charset = RemoteCharset.utf8.rawValue + public var telnetEnabled = false + public var telnetPort = 23 + public var telnetIdentityID: UUID? + public var telnetCharset = RemoteCharset.utf8.rawValue + public var telnetProxyProtocol: String? + public var telnetProxyHostname = "" + public var telnetProxyPort = 0 + public var telnetProxyUsername = "" + public var telnetProxyIdentityID: UUID? + public var jumpHostID: UUID? + public var proxyProtocol: String? + public var proxyHostname = "" + public var proxyPort = 0 + public var proxyUsername = "" + public var proxyIdentityID: UUID? + public var sftpSessionInitialPath = "" + public var groupID: UUID? + public var tags: [String] = [] + public var colorTag = TagColor.none.rawValue + public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue + public var agentForwardingEnabled = false + public var startupSnippetID: UUID? + public var environmentVariableNames: [String] = [] + public var environmentVariableValues: [String] = [] + public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections + public var detectedDistributionID: String? + public var detectedDistributionLikeIDs: [String] = [] + public var sortIndex = 0.0 + + public init(_ value: Host) { + id = value.id + apply(value) + } + + public func apply(_ value: Host) { + name = value.name + address = value.host + sshEnabled = value.sshEnabled + port = value.sshPort + identityID = value.sshIdentityID + charset = value.sshCharset.rawValue + telnetEnabled = value.telnetEnabled + telnetPort = value.telnetPort + telnetIdentityID = value.telnetIdentityID + telnetCharset = value.telnetCharset.rawValue + telnetProxyProtocol = value.telnetProxy?.kind.rawValue + telnetProxyHostname = value.telnetProxy?.hostname ?? "" + telnetProxyPort = value.telnetProxy?.port ?? 0 + telnetProxyUsername = value.telnetProxy?.username ?? "" + telnetProxyIdentityID = value.telnetProxy?.identityID + jumpHostID = value.jumpHostID + proxyProtocol = value.proxy?.kind.rawValue + proxyHostname = value.proxy?.hostname ?? "" + proxyPort = value.proxy?.port ?? 0 + proxyUsername = value.proxy?.username ?? "" + proxyIdentityID = value.proxy?.identityID + sftpSessionInitialPath = value.sftpChannelInitialPath + groupID = value.groupID + tags = value.tags + colorTag = value.colorTag.rawValue + shellIntegrationMode = value.shellIntegrationMode.rawValue + startupSnippetID = value.startupSnippetID + agentForwardingEnabled = value.agentForwardingEnabled + environmentVariableNames = value.environmentVariables.map(\.variable) + environmentVariableValues = value.environmentVariables.map(\.value) + portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( + value.portForwardingMaxConnections) + detectedDistributionID = value.detectedDistribution?.id + detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? [] + sortIndex = value.sortIndex + } + + public var value: Host { + var host = Host( + id: id, name: name, host: address, port: port, identityID: identityID, + charset: RemoteCharset(rawValue: charset) ?? .utf8, + sshEnabled: sshEnabled, telnetEnabled: telnetEnabled, telnetPort: telnetPort, + telnetIdentityID: telnetIdentityID, + telnetCharset: RemoteCharset(rawValue: telnetCharset) ?? .utf8, + groupID: groupID, tags: tags, + colorTag: TagColor(rawValue: colorTag) ?? .none, + detectedDistribution: detectedDistributionID.flatMap { + DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs) + }, + sortIndex: sortIndex, + shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode) + ?? .followGlobalSettings, + startupSnippetID: startupSnippetID, agentForwardingEnabled: agentForwardingEnabled, + environmentVariables: zip(environmentVariableNames, environmentVariableValues).map { + HostEnvironmentVariable(variable: $0, value: $1) + }, + portForwardingMaxConnections: portForwardingMaxConnections + ?? Host.defaultPortForwardingMaxConnections + ) + host.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let telnetProxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: telnetProxyProtocol) { + host.telnetProxy = HostProxy( + kind: kind, hostname: telnetProxyHostname, port: telnetProxyPort, + username: telnetProxyUsername, identityID: telnetProxyIdentityID) + } + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/Frameworks/TermindPersistence/VaultSyncPersistence.swift b/Frameworks/TermindPersistence/VaultSyncPersistence.swift index 1b3e73ab..da9c5107 100644 --- a/Frameworks/TermindPersistence/VaultSyncPersistence.swift +++ b/Frameworks/TermindPersistence/VaultSyncPersistence.swift @@ -60,17 +60,22 @@ public nonisolated enum VaultSyncCodec { guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] + if envelope.key.kind == .host, envelope.formatVersion == 2 { + return try encoder.encode(VaultWireV2.Envelope(envelope)) + } return try encoder.encode(VaultWireV1.Envelope(envelope)) } - private static let decoder = VersionedRecordDecoder( - Dictionary( - uniqueKeysWithValues: VaultRecordKind.allCases.map { kind in - ( - kind.rawValue, - [1: { data in try JSONDecoder().decode(VaultWireV1.Envelope.self, from: data).domainValue }] - ) - })) + private static let decoder: VersionedRecordDecoder = { + var decoders: [String: [Int: @Sendable (Data) throws -> VaultEnvelope]] = [:] + for kind in VaultRecordKind.allCases { + decoders[kind.rawValue] = [1: { try JSONDecoder().decode(VaultWireV1.Envelope.self, from: $0).domainValue }] + } + decoders[VaultRecordKind.host.rawValue]?[2] = { + try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue + } + return VersionedRecordDecoder(decoders) + }() public static func decode(_ data: Data) throws -> VaultEnvelope { let envelope = try decoder.decode(data) diff --git a/Frameworks/TermindPersistence/VaultWireV1Validation.swift b/Frameworks/TermindPersistence/VaultWireV1Validation.swift index 31e077d1..5a1486d4 100644 --- a/Frameworks/TermindPersistence/VaultWireV1Validation.swift +++ b/Frameworks/TermindPersistence/VaultWireV1Validation.swift @@ -23,6 +23,7 @@ nonisolated extension VaultWireV1 { } switch payload { case .host(let value): + guard !value.agentForwardingEnabled else { throw CocoaError(.coderInvalidValue) } try require(RemoteCharset(rawValue: value.sshCharset.rawValue)) try require(RemoteCharset(rawValue: value.telnetCharset.rawValue)) try require(TagColor(rawValue: value.colorTag.rawValue)) diff --git a/Frameworks/TermindPersistence/VaultWireV2.swift b/Frameworks/TermindPersistence/VaultWireV2.swift new file mode 100644 index 00000000..63140b68 --- /dev/null +++ b/Frameworks/TermindPersistence/VaultWireV2.swift @@ -0,0 +1,60 @@ +import Foundation +import TermindDomain + +/// V2 is host-only. Reuses the frozen V1 fields without changing their encoding. +nonisolated enum VaultWireV2 { + struct Host: Codable { + var base: VaultWireV1.Host + var agentForwardingEnabled: Bool + enum CodingKeys: String, CodingKey { case agentForwardingEnabled } + init(_ value: TermindDomain.Host) throws { + var legacy = value + legacy.agentForwardingEnabled = false + try VaultWireV1.validateRepresentable(.host(legacy)) + base = VaultWireV1.Host(legacy) + agentForwardingEnabled = value.agentForwardingEnabled + } + init(from decoder: any Decoder) throws { + base = try VaultWireV1.Host(from: decoder) + agentForwardingEnabled = try decoder.container(keyedBy: CodingKeys.self).decode( + Bool.self, forKey: .agentForwardingEnabled) + } + func encode(to encoder: any Encoder) throws { + try base.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(agentForwardingEnabled, forKey: .agentForwardingEnabled) + } + var domainValue: TermindDomain.Host { + var value = base.domainValue + value.agentForwardingEnabled = agentForwardingEnabled + return value + } + } + enum Payload: Codable { case host(Host) } + struct Envelope: Codable { + var formatVersion: Int + var key: VaultRecordKey + var version: VaultVersion + var isDeleted: Bool + var payload: Payload? + init(_ envelope: VaultEnvelope) throws { + formatVersion = 2 + key = envelope.key + version = envelope.version + isDeleted = envelope.isDeleted + if case .host(let host) = envelope.payload { + payload = .host(try Host(host)) + } else if envelope.payload == nil { + payload = nil + } else { + throw CocoaError(.coderInvalidValue) + } + } + var domainValue: VaultEnvelope { + var value: VaultRecordPayload? + if case .host(let host) = payload { value = .host(host.domainValue) } + return VaultEnvelope( + key: key, version: version, isDeleted: isDeleted, payload: value, formatVersion: formatVersion) + } + } +} diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index 28ddb68d..c1bb4580 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -22,6 +22,7 @@ public struct HostIntegrationSection: View { public struct HostSessionSection: View { @Binding private var charset: RemoteCharset @Binding private var sftpInitialPath: String + @Binding private var agentForwardingEnabled: Bool @Binding private var startupSnippetID: Snippet.ID? @Binding private var environmentVariables: [HostEnvironmentVariable] private let snippets: [Snippet] @@ -32,6 +33,7 @@ public struct HostSessionSection: View { charset: Binding, sftpInitialPath: Binding, startupSnippetID: Binding = .constant(nil), + agentForwardingEnabled: Binding = .constant(false), environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]), snippets: [Snippet] = [], snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil }, @@ -40,6 +42,7 @@ public struct HostSessionSection: View { _charset = charset _sftpInitialPath = sftpInitialPath _startupSnippetID = startupSnippetID + _agentForwardingEnabled = agentForwardingEnabled _environmentVariables = environmentVariables self.snippets = snippets self.snippetPackagePath = snippetPackagePath @@ -66,6 +69,11 @@ public struct HostSessionSection: View { } .pickerStyle(.menu) .menuOrder(.fixed) + Toggle(isOn: $agentForwardingEnabled) { + Text(AppStrings.Connections.agentForwarding) + Text(AppStrings.Connections.agentForwardingDescription) + .font(.caption).foregroundStyle(.secondary) + } LabeledContent("SFTP Initial Path") { TextField("SFTP Initial Path", text: $sftpInitialPath.singleLineText, prompt: Text("~")) .singleLinePaste() diff --git a/Frameworks/TermindPresentation/Strings/Connections.swift b/Frameworks/TermindPresentation/Strings/Connections.swift index ccb5e6d8..835bafe9 100644 --- a/Frameworks/TermindPresentation/Strings/Connections.swift +++ b/Frameworks/TermindPresentation/Strings/Connections.swift @@ -2,6 +2,10 @@ import Foundation extension AppStrings { public nonisolated enum Connections { + public static var agentForwarding: LocalizedStringResource { "Agent Forwarding" } + public static var agentForwardingDescription: LocalizedStringResource { + "Allow this host to request signatures using all keys in Termind. Private keys stay on this device." + } public static var authenticationRequest: LocalizedStringResource { "Authentication Request" diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 6eb630ad..9fbdb1aa 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -490,6 +490,8 @@ public final class AgentHostResolver: Sendable { ) } + public func agentForwardingEnabled(for id: UUID) -> Bool { store.host(id)?.agentForwardingEnabled == true } + /// A stable description of *which machine, reached how, and as whom* — the /// only question a pooled connection has to keep answering the same way. /// @@ -794,7 +796,8 @@ public actor AgentSSHAuthenticationContext { challenge, invocationID: self.invocationID) guard await self.currentGeneration == generation else { throw CancellationError() } return answers - } + }, + agentForwardingContextID: interaction.presentationContextID ), generation: generation ) @@ -1065,7 +1068,7 @@ public actor AgentSSHAuthenticationRegistry { open: @Sendable (AgentSSHAuthenticationContext.Attempt?) async throws -> Client ) async throws -> Client { var attempt: AgentSSHAuthenticationContext.Attempt? - if await pool.isConnected(hostID: hostID) { + if await pool.isConnected(hostID: hostID), !(await resolver.agentForwardingEnabled(for: hostID)) { attempt = nil } else if await authentication.canResolveTarget() { attempt = try await authentication.configurationAttempt() @@ -1099,6 +1102,11 @@ public actor AgentSSHAuthenticationRegistry { interaction: AgentSSHInteraction, operation: @Sendable (AgentSSHAuthenticationContext) async throws -> Value ) async throws -> Value { + if await resolver.agentForwardingEnabled(for: hostID) { + return try await operation( + AgentSSHAuthenticationContext( + resolver: resolver, hostID: hostID, invocationID: invocationID, interaction: interaction)) + } let token: UUID let context: AgentSSHAuthenticationContext if var entry = entries[hostID] { @@ -1179,6 +1187,13 @@ public actor AgentConnectionPool { } private let configuration: ConfigurationResolving + private final class ForwardedConnection: @unchecked Sendable { + weak var value: (any SSHConnectionProtocol)? + init(_ value: any SSHConnectionProtocol) { self.value = value } + } + private var forwardedConnections: [UUID: [ForwardedConnection]] = [:] + private var forwardedOpenings: [UUID: [UUID: Task]] = [:] + private let forwardingEnabled: @Sendable (UUID) async -> Bool private let routeFingerprint: FingerprintResolving private let openConnection: ConnectionOpening private let openSFTP: SFTPOpening @@ -1390,6 +1405,7 @@ public actor AgentConnectionPool { public init( configuration: @escaping ConfigurationResolving, routeFingerprint: @escaping FingerprintResolving, + forwardingEnabled: @escaping @Sendable (UUID) async -> Bool = { _ in false }, openConnection: @escaping ConnectionOpening, openSFTP: @escaping SFTPOpening, sftpConcurrencyScheduler: SFTPConcurrencyScheduler = SFTPConcurrencyScheduler(), @@ -1398,6 +1414,7 @@ public actor AgentConnectionPool { ) { self.configuration = configuration self.routeFingerprint = routeFingerprint + self.forwardingEnabled = forwardingEnabled self.openConnection = openConnection self.openSFTP = openSFTP self.sftpConcurrencyScheduler = sftpConcurrencyScheduler @@ -1439,6 +1456,20 @@ public actor AgentConnectionPool { hostID: UUID, connection: any SSHConnectionProtocol, operation: @escaping @Sendable () async throws -> Value ) async throws -> Value { + if forwardedConnections[hostID]?.contains(where: { $0.value === connection }) == true { + defer { + forwardedConnections[hostID]?.removeAll { $0.value == nil || $0.value === connection } + if forwardedConnections[hostID]?.isEmpty == true { forwardedConnections[hostID] = nil } + } + do { + let result = try await operation() + await connection.disconnect() + return result + } catch { + await connection.disconnect() + throw error + } + } guard let pooled = entries[hostID]?.connection, pooled === connection else { throw SSHCommandExecutionError.connectionUnavailable(TermindSSHError.disconnected) } @@ -1457,7 +1488,40 @@ public actor AgentConnectionPool { public func connection( hostID: UUID, configuration override: RequestResolving? = nil ) async throws -> any SSHConnectionProtocol { - try await resolve(hostID: hostID, configuration: override) + if await forwardingEnabled(hostID) { + var request: SSHConnectionRequest + if let override { + request = try await override(hostID) + } else { + request = SSHConnectionRequest( + configuration: try await configuration(hostID), interactionPolicy: .forbidden) + } + let openingID = UUID() + let isolated = request + let task = Task { [openConnection] in try await openConnection(isolated) } + forwardedOpenings[hostID, default: [:]][openingID] = task + do { + let connection = try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + guard forwardedOpenings[hostID]?[openingID] != nil, !Task.isCancelled else { + await connection.disconnect() + throw CancellationError() + } + forwardedOpenings[hostID]?[openingID] = nil + if forwardedOpenings[hostID]?.isEmpty == true { forwardedOpenings[hostID] = nil } + forwardedConnections[hostID] = (forwardedConnections[hostID] ?? []).filter { $0.value != nil } + forwardedConnections[hostID, default: []].append(ForwardedConnection(connection)) + return connection + } catch { + forwardedOpenings[hostID]?[openingID] = nil + if forwardedOpenings[hostID]?.isEmpty == true { forwardedOpenings[hostID] = nil } + throw error + } + } + return try await resolve(hostID: hostID, configuration: override) } public func sftpChannel( @@ -1472,7 +1536,7 @@ public actor AgentConnectionPool { return primary.channel } if sftpStates[hostID] != nil { await disconnect(hostID: hostID) } - let connection = try await connection(hostID: hostID, configuration: override) + let connection = try await resolve(hostID: hostID, configuration: override) let opening: SFTPPrimaryOpen let ownsOpening: Bool if let existing = sftpPrimaryOpens[hostID], existing.fingerprint == fingerprint, @@ -1546,16 +1610,22 @@ public actor AgentConnectionPool { @discardableResult public func disconnect(hostID: UUID) async -> Bool { cancelConnectionOpenings(hostID: hostID) + let opening = forwardedOpenings.removeValue(forKey: hostID) ?? [:] + for task in opening.values { task.cancel() } + let forwarded = forwardedConnections.removeValue(forKey: hostID) ?? [] + for connection in forwarded { await connection.value?.disconnect() } let stateClosed = await disconnectSFTPState(hostID: hostID) activeExecLeases[hostID] = nil let entry = entries.removeValue(forKey: hostID) stopSweepingIfEmpty() await entry?.connection.disconnect() - return stateClosed || entry != nil + return stateClosed || entry != nil || !forwarded.isEmpty } public func closeAll() async { let hostIDs = Set(entries.keys) + .union(forwardedConnections.keys) + .union(forwardedOpenings.keys) .union(sftpStates.keys) .union(pending.keys.map(\.hostID)) .union(sftpPrimaryOpens.keys) @@ -1647,7 +1717,7 @@ public actor AgentConnectionPool { } let id = UUID() let task = Task { [configuration, openConnection] () async throws -> any SSHConnectionProtocol in - let request: SSHConnectionRequest + var request: SSHConnectionRequest if let override { request = try await override(hostID) } else { @@ -1655,6 +1725,7 @@ public actor AgentConnectionPool { configuration: try await configuration(hostID) ) } + request.permitsAgentForwarding = false return try await openConnection(request) } pending[key] = InFlight(id: id, task: task, waiters: [:]) diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift index 01551b1e..af4c2e51 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift @@ -33,6 +33,7 @@ public struct AgentHostServices { connections: AgentConnectionPool( configuration: { try await resolver.configuration(for: $0) }, routeFingerprint: { try await resolver.routeFingerprint(for: $0) }, + forwardingEnabled: { await store.host($0)?.agentForwardingEnabled == true }, openConnection: { try await coordinator.connectConnection(using: $0) }, openSFTP: { try await coordinator.connectSFTP(using: $0) }, sftpConcurrencyScheduler: sftpConcurrencyScheduler diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift index 437ed70b..e7ea4f55 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift @@ -7,6 +7,21 @@ import TermindDomain /// SSH/SFTP transports. Features receive this service instead of constructing /// infrastructure clients in views. public nonisolated final class RemoteConnectionCoordinator: Sendable { + private final class AgentConfiguration: @unchecked Sendable { + let lock = NSLock() + var factory: SSHAgentForwardingFactory? + func resolve(_ request: SSHConnectionRequest) async throws -> (any SSHAgentForwarding)? { + let factory = lock.withLock { self.factory } + return try await factory?(request) + } + } + private let agentConfiguration = AgentConfiguration() + public func updateAgentForwarding(_ factory: SSHAgentForwardingFactory?) { + agentConfiguration.lock.withLock { agentConfiguration.factory = factory } + } + private var agentFactory: SSHAgentForwardingFactory { + { [agentConfiguration] request in try await agentConfiguration.resolve(request) } + } private let keyboardInteractive: SSHKeyboardInteractiveHandler? private let maxSessionsPolicy: SSHMaxSessionsPolicy let logger: Logger @@ -39,6 +54,7 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable { ) -> SSHPTYChannel { SSHPTYChannel( output: output, exit: exit, shellIntegration: shellIntegration, keyboardInteractive: keyboardInteractive, + agentForwardingFactory: agentFactory, logger: logger, maxSessionsPolicy: maxSessionsPolicy ) } @@ -51,7 +67,7 @@ public nonisolated final class RemoteConnectionCoordinator: Sendable { public func makeConnection() -> any SSHConnectionProtocol { SSHConnection( - keyboardInteractive: keyboardInteractive, logger: logger, + keyboardInteractive: keyboardInteractive, agentForwardingFactory: agentFactory, logger: logger, maxSessionsPolicy: maxSessionsPolicy) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentChannels.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentChannels.swift new file mode 100644 index 00000000..f8dfa970 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentChannels.swift @@ -0,0 +1,129 @@ +import CSSH2 +import Foundation +import TermindApplication + +/// Accessed only on the owning SSH queue. Signing tasks never enter libssh2. +nonisolated final class SSHAgentChannels: @unchecked Sendable { + private final class Channel: @unchecked Sendable { + let pointer: OpaquePointer + var decoder = SSHAgentProtocol() + var output = Data() + var offset = 0 + var task: Task? + var closing = false + init(_ pointer: OpaquePointer) { self.pointer = pointer } + } + private let provider: any SSHAgentForwarding + private let queue: DispatchQueue + private let wake: @Sendable () -> Void + private var channels: [Channel] = [] + private var stopped = false + var hasChannels: Bool { !channels.isEmpty } + + init(provider: any SSHAgentForwarding, queue: DispatchQueue, wake: @escaping @Sendable () -> Void) { + self.provider = provider + self.queue = queue + self.wake = wake + } + func accept(_ pointer: OpaquePointer) { + let channel = Channel(pointer) + channel.closing = stopped || channels.count >= 16 + channels.append(channel) + } + func shutdown() { + guard !stopped else { return } + stopped = true + for channel in channels { channel.task?.cancel() } + channels.removeAll() // session_free owns the remaining libssh2 handles. + let provider = provider + Task { await provider.close() } + } + deinit { shutdown() } + + @discardableResult + func advance(deadline: UInt64, permitsIO: () -> Bool = { true }) -> Bool { + guard !stopped else { return false } + var progressed = false + let turn = channels + if channels.count > 1 { channels.append(channels.removeFirst()) } + for channel in turn { + guard permitsIO(), DispatchTime.now().uptimeNanoseconds < deadline else { break } + let drainedEOF = + libssh2_channel_eof(channel.pointer) == 1 + && channel.task == nil && channel.output.isEmpty && channel.decoder.bytes.isEmpty + && libssh2_poll_channel_read(channel.pointer, 0) == 0 + if channel.closing || drainedEOF { + channel.task?.cancel() + channel.closing = true + let result = libssh2_channel_free(channel.pointer) + if result != LIBSSH2_ERROR_EAGAIN { + channels.removeAll { $0 === channel } + progressed = true + } + continue + } + if channel.offset < channel.output.count { + let written = channel.output.withUnsafeBytes { + libssh2_channel_write_ex( + channel.pointer, 0, + $0.baseAddress!.advanced(by: channel.offset).assumingMemoryBound(to: CChar.self), + $0.count - channel.offset) + } + if written > 0 { + channel.offset += written + progressed = true + } else if written != LIBSSH2_ERROR_EAGAIN { + channel.closing = true + } + if channel.offset < channel.output.count { continue } + channel.output.removeAll(keepingCapacity: false) + channel.offset = 0 + } + guard channel.task == nil else { continue } + do { + if let packet = try channel.decoder.next() { + start(packet, channel: channel) + progressed = true + continue + } + var bytes = [UInt8]( + repeating: 0, count: min(8192, SSHAgentProtocol.maximumPacket + 4 - channel.decoder.bytes.count)) + let read = libssh2_channel_read_ex(channel.pointer, 0, &bytes, bytes.count) + if read > 0 { + try channel.decoder.append(Data(bytes.prefix(read))) + progressed = true + if let packet = try channel.decoder.next() { start(packet, channel: channel) } + } else if (read == 0 && libssh2_channel_eof(channel.pointer) == 1) + || (read != 0 && read != LIBSSH2_ERROR_EAGAIN) + { + channel.closing = true + progressed = true + } + } catch { channel.closing = true } + } + return progressed + } + + private func start(_ packet: Data, channel: Channel) { + let provider = provider + channel.task = Task { [weak self, weak channel] in + let response = await SSHAgentProtocol.response(to: packet, provider: provider) + guard !Task.isCancelled, let self, let channel else { return } + self.queue.async { [weak self, weak channel] in + guard let self, let channel, !self.stopped, !channel.closing else { return } + channel.task = nil + channel.output = response + self.wake() + } + } + } + + static let callback: + @convention(c) ( + OpaquePointer?, OpaquePointer?, UnsafeMutablePointer? + ) -> Void = { _, channel, abstract in + guard let channel, let opaque = abstract?.pointee else { return } + let buffer = Unmanaged.fromOpaque(opaque).takeUnretainedValue() + buffer.agentChannels?.accept(channel) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentKeySigner.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentKeySigner.swift new file mode 100644 index 00000000..d94dc896 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentKeySigner.swift @@ -0,0 +1,140 @@ +import CSSH2 +import CryptoKit +import Foundation +import OpenSSL +import TermindApplication +import TermindDomain + +/// Uses the same pinned libssh2 OpenSSL parsers as SSH login, including encrypted +/// OpenSSH keys. Parser contexts are local to this operation, never the live session. +nonisolated enum SSHAgentKeySigner { + static func sign( + credential: SSHResolvedKeyCredential, passphrase: SSHSecret?, + algorithm: SSHKeyAlgorithm, data: Data, flags: UInt32, allowInteraction: Bool = true + ) throws -> Data { + defer { + credential.clearSecrets() + passphrase?.clear() + } + guard flags & ~UInt32(6) == 0, algorithm == .rsa || flags == 0 else { + throw SSHAgentProtocol.Failure.invalid + } + let name: String + let payload: Data + switch credential { + case .secureEnclave(let key): + guard algorithm == .ecdsa256 else { throw SSHAgentProtocol.Failure.invalid } + name = algorithm.rawValue + payload = try SecureEnclaveKey.sign(data, credential: key, allowInteraction: allowInteraction) + case .software(let secret, let savedPassphrase): + let passphrase = passphrase ?? savedPassphrase + defer { savedPassphrase?.clear() } + defer { + secret.clear() + passphrase?.clear() + } + _ = initialization + guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw SSHAgentProtocol.Failure.unavailable + } + defer { _ = libssh2_session_free(session) } + var key: OpaquePointer? + let parser = + switch algorithm { + case .rsa: parseRSA + case .ed25519: parseEd25519 + default: parseECDSA + } + let result = try secret.withUnsafeCStringAndConsume { bytes, count in + let parse: (UnsafePointer?) -> Int32 = { password in + parser(&key, session, bytes, count, password) + } + if let passphrase { return try passphrase.withUnsafeCStringAndConsume { p, _ in parse(p) } } + return parse(nil) + } + defer { if let key { EVP_PKEY_free(key) } } + guard result == 0, let key else { throw SSHAgentProtocol.Failure.unavailable } + var signature: UnsafeMutablePointer? + var length = 0 + defer { if let signature { libssh2_free(session, signature) } } + let status: Int32 + switch algorithm { + case .ed25519: + name = algorithm.rawValue + status = data.withUnsafeBytes { + signEd25519(key, session, &signature, &length, $0.bindMemory(to: UInt8.self).baseAddress, $0.count) + } + case .rsa: + let hash: Data + if flags & 4 != 0 { + name = "rsa-sha2-512" + hash = Data(SHA512.hash(data: data)) + } else if flags & 2 != 0 { + name = "rsa-sha2-256" + hash = Data(SHA256.hash(data: data)) + } else { + name = "ssh-rsa" + hash = Data(Insecure.SHA1.hash(data: data)) + } + status = hash.withUnsafeBytes { + let sign = flags == 0 ? signRSA1 : signRSA2 + return sign(session, key, $0.bindMemory(to: UInt8.self).baseAddress, $0.count, &signature, &length) + } + default: + name = algorithm.rawValue + let hash: Data = + switch algorithm { + case .ecdsa384: Data(SHA384.hash(data: data)) + case .ecdsa521: Data(SHA512.hash(data: data)) + default: Data(SHA256.hash(data: data)) + } + status = hash.withUnsafeBytes { + signECDSA(session, key, $0.bindMemory(to: UInt8.self).baseAddress, $0.count, &signature, &length) + } + } + guard status == 0, let signature, length > 0 else { throw SSHAgentProtocol.Failure.unavailable } + payload = Data(bytes: signature, count: length) + } + return SSHAgentProtocol.field(Data(name.utf8)) + SSHAgentProtocol.field(payload) + } + private static let initialization: Void = { precondition(libssh2_init(0) == 0) }() +} + +// libssh2-spm 2.0.3 uses OpenSSL 3 EVP_PKEY contexts for all three parsers. +// These are already shipped symbols, like _libssh2_pub_priv_keyfilememory used +// by SSHKeyMaterial. Keep the independent signature tests when updating libssh2. +@_silgen_name("_libssh2_rsa_new_private_frommemory") +private nonisolated func parseRSA( + _ key: UnsafeMutablePointer, _ session: OpaquePointer, _ bytes: UnsafePointer, _ count: Int, + _ passphrase: UnsafePointer? +) -> Int32 +@_silgen_name("_libssh2_ecdsa_new_private_frommemory") +private nonisolated func parseECDSA( + _ key: UnsafeMutablePointer, _ session: OpaquePointer, _ bytes: UnsafePointer, _ count: Int, + _ passphrase: UnsafePointer? +) -> Int32 +@_silgen_name("_libssh2_ed25519_new_private_frommemory") +private nonisolated func parseEd25519( + _ key: UnsafeMutablePointer, _ session: OpaquePointer, _ bytes: UnsafePointer, _ count: Int, + _ passphrase: UnsafePointer? +) -> Int32 +@_silgen_name("_libssh2_rsa_sha1_sign") +private nonisolated func signRSA1( + _ session: OpaquePointer, _ key: OpaquePointer, _ hash: UnsafePointer?, _ count: Int, + _ signature: UnsafeMutablePointer?>, _ length: UnsafeMutablePointer +) -> Int32 +@_silgen_name("_libssh2_rsa_sha2_sign") +private nonisolated func signRSA2( + _ session: OpaquePointer, _ key: OpaquePointer, _ hash: UnsafePointer?, _ count: Int, + _ signature: UnsafeMutablePointer?>, _ length: UnsafeMutablePointer +) -> Int32 +@_silgen_name("_libssh2_ecdsa_sign") +private nonisolated func signECDSA( + _ session: OpaquePointer, _ key: OpaquePointer, _ hash: UnsafePointer?, _ count: Int, + _ signature: UnsafeMutablePointer?>, _ length: UnsafeMutablePointer +) -> Int32 +@_silgen_name("_libssh2_ed25519_sign") +private nonisolated func signEd25519( + _ key: OpaquePointer, _ session: OpaquePointer, _ signature: UnsafeMutablePointer?>, + _ length: UnsafeMutablePointer, _ bytes: UnsafePointer?, _ count: Int +) -> Int32 diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentProtocol.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentProtocol.swift new file mode 100644 index 00000000..f86e930e --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHAgentProtocol.swift @@ -0,0 +1,86 @@ +import Foundation +import TermindApplication + +/// Incremental, bounded SSH agent framing. Unknown management messages fail closed. +nonisolated struct SSHAgentProtocol { + static let maximumPacket = 256 * 1024 + private(set) var bytes = Data() + + mutating func append(_ data: Data) throws { + guard bytes.count + data.count <= Self.maximumPacket + 4 else { throw Failure.invalid } + bytes.append(data) + if bytes.count >= 4 { + var reader = Reader(bytes) + let length = try reader.uint32() + guard length > 0, length <= Self.maximumPacket else { throw Failure.invalid } + } + } + + mutating func next() throws -> Data? { + guard bytes.count >= 4 else { return nil } + var reader = Reader(bytes) + let length = Int(try reader.uint32()) + guard length > 0, length <= Self.maximumPacket else { throw Failure.invalid } + guard bytes.count >= length + 4 else { return nil } + let packet = Data(bytes.dropFirst(4).prefix(length)) + bytes = Data(bytes.dropFirst(length + 4)) + return packet + } + + static func response(to packet: Data, provider: any SSHAgentForwarding) async -> Data { + do { + try Task.checkCancellation() + guard let kind = packet.first else { throw Failure.invalid } + var reader = Reader(Data(packet.dropFirst())) + switch kind { + case 11: // SSH2_AGENTC_REQUEST_IDENTITIES + guard reader.isEmpty else { throw Failure.invalid } + let identities = try await provider.identities() + try Task.checkCancellation() + guard identities.count <= 1024 else { throw Failure.invalid } + var reply = Data([12]) + uint32(UInt32(identities.count)) + for identity in identities { + reply += field(identity.blob) + field(Data(identity.comment.utf8)) + guard reply.count <= maximumPacket else { throw Failure.invalid } + } + return frame(reply) + case 13: // SSH2_AGENTC_SIGN_REQUEST + let key = try reader.field() + let data = try reader.field() + let flags = try reader.uint32() + guard reader.isEmpty else { throw Failure.invalid } + let signature = try await provider.sign(key: key, data: data, flags: flags) + try Task.checkCancellation() + guard signature.count < maximumPacket - 5 else { throw Failure.invalid } + return frame(Data([14]) + field(signature)) + default: return frame(Data([5])) + } + } catch { return frame(Data([5])) } + } + + enum Failure: Error { case invalid, unavailable } + static func uint32(_ value: UInt32) -> Data { + var value = value.bigEndian + return withUnsafeBytes(of: &value) { Data($0) } + } + static func field(_ data: Data) -> Data { uint32(UInt32(data.count)) + data } + static func frame(_ data: Data) -> Data { field(data) } + + struct Reader { + let data: Data + var offset = 0 + init(_ data: Data) { self.data = Data(data) } + var isEmpty: Bool { offset == data.count } + mutating func uint32() throws -> UInt32 { + guard data.count - offset >= 4 else { throw Failure.invalid } + defer { offset += 4 } + return data[offset.. Data { + let count = Int(try uint32()) + guard count <= data.count - offset else { throw Failure.invalid } + defer { offset += count } + return data.subdata(in: offset.. SSHConnectionBuffer { + func installBuffer(tuning: SSHForwardingTuning = .current, deferIO: Bool = false) -> SSHConnectionBuffer { lock.withLock { precondition(!isClosed, "Cannot configure I/O for a closed SSH connection") - if let buffer { return buffer } - let installed = SSHConnectionBuffer(libssh2Session: libssh2Session, tuning: tuning) + if let buffer { + if !deferIO { buffer.installIO(libssh2Session) } + return buffer + } + let installed = SSHConnectionBuffer(libssh2Session: libssh2Session, tuning: tuning, deferIO: deferIO) buffer = installed return installed } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift index 476b91ee..a4613da6 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift @@ -104,14 +104,17 @@ public typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void public actor SSHConnection: SSHConnectionProtocol { private let keyboardInteractive: SSHKeyboardInteractiveHandler? + private let agentForwardingFactory: SSHAgentForwardingFactory? private nonisolated let worker: SSHConnectionWorker public init( keyboardInteractive: SSHKeyboardInteractiveHandler? = nil, + agentForwardingFactory: SSHAgentForwardingFactory? = nil, logger: Logger = ApplicationLogger.disabled("termind.ssh-connection"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) { self.keyboardInteractive = keyboardInteractive + self.agentForwardingFactory = agentForwardingFactory worker = SSHConnectionWorker(logger: logger, maxSessionsPolicy: maxSessionsPolicy) } @@ -129,8 +132,10 @@ public actor SSHConnection: SSHConnectionProtocol { connectionFailure: (@Sendable (String) -> Void)? ) async throws { defer { request.configuration.clearSecrets() } + var request = request.withKeyboardInteractiveFallback(keyboardInteractive) + request.agentForwarding = try await agentForwardingFactory?(request) try await worker.connect( - using: request.withKeyboardInteractiveFallback(keyboardInteractive), + using: request, progress: progress, connectionFailure: connectionFailure) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionBuffer.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionBuffer.swift index 31a27da5..58af9d38 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionBuffer.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionBuffer.swift @@ -8,6 +8,7 @@ import Foundation /// calls instead. The receive callback also stops packet processing at the /// watermark, bounding replies generated while libssh2 drains input. nonisolated final class SSHConnectionBuffer { + var agentChannels: SSHAgentChannels? private let output = SSHByteQueue() private var disposed = false private let tuning: SSHForwardingTuning @@ -31,11 +32,15 @@ nonisolated final class SSHConnectionBuffer { var isBackpressured: Bool { output.count >= tuning.highWater } var wantsWrite: Bool { !output.isEmpty } - init(libssh2Session: OpaquePointer, tuning: SSHForwardingTuning = .current) { + init(libssh2Session: OpaquePointer, tuning: SSHForwardingTuning = .current, deferIO: Bool = false) { self.tuning = tuning readAllowance = tuning.readBudget writeAllowance = tuning.quantum libssh2_session_abstract(libssh2Session)?.pointee = Unmanaged.passUnretained(self).toOpaque() + if !deferIO { installIO(libssh2Session) } + } + + func installIO(_ libssh2Session: OpaquePointer) { libssh2_session_callback_set2( libssh2Session, LIBSSH2_CALLBACK_SEND, unsafeBitCast(Self.send, to: (@convention(c) () -> Void).self)) @@ -70,6 +75,8 @@ nonisolated final class SSHConnectionBuffer { /// Teardown cannot wait on a peer. Discard final protocol writes and return /// EOF to reads, so libssh2 can release channels/listeners without EAGAIN. func dispose(libssh2Session: OpaquePointer) { + agentChannels?.shutdown() + agentChannels = nil disposed = true let result = libssh2_session_free(libssh2Session) assert(result == 0, "SSH session disposal must finish without socket I/O") diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift index b7258b7d..c958948a 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift @@ -27,8 +27,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { deinit { pointer.deallocate() } } + private var agentForwardingRequested = false + private var agentRequestOwner: UUID? + private final class SSHExecChannel: @unchecked Sendable { - enum Phase { case opening, starting, running, closing, freeing } + enum Phase { case opening, forwarding, starting, running, closing, freeing } let id = UUID() let command: StableCString @@ -167,12 +170,20 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { let opened = try await SSHConnectionOpener.openAuthenticatedConnection( using: request, timeout: 20, progress: progress, logger: logger) let channelBudget = maxSessionsPolicy.makeBudget() - try await queue.perform { + try await queue.perform { [self] in try Task.checkCancellation() self.connection = opened self.hostID = hostID self.charset = request.configuration.charset self.buffer = opened.installBuffer() + if let provider = request.agentForwarding { + self.buffer?.agentChannels = SSHAgentChannels(provider: provider, queue: self.queue) { + [weak self] in self?.wakeAll() + } + libssh2_session_callback_set2( + opened.libssh2Session, LIBSSH2_CALLBACK_AUTHAGENT, + unsafeBitCast(SSHAgentChannels.callback, to: (@convention(c) () -> Void).self)) + } self.channelBudget = channelBudget self.connectionFailure = connectionFailure self.didReportConnectionFailure = false @@ -327,7 +338,12 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { return } controlDirections = 0 - var progressed = advanceOpen(session) + var progressed = + buffer.agentChannels?.advance( + deadline: min( + deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 2), + permitsIO: { !buffer.isBackpressured }) ?? false + if advanceOpen(session) { progressed = true } while !buffer.isBackpressured, DispatchTime.now().uptimeNanoseconds < deadline, let id = ready.pop() @@ -335,6 +351,9 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { guard let command = commands[id] else { continue } if advance(command, session: session, deadline: deadline) { progressed = true } } + if buffer.agentChannels?.advance(deadline: deadline, permitsIO: { !buffer.isBackpressured }) == true { + progressed = true + } if advanceOpen(session) { progressed = true } if keepaliveDue { var next: Int32 = 0 @@ -405,13 +424,19 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { } opening = nil command.execChannel = execChannel - command.phase = .starting + command.phase = buffer?.agentChannels == nil ? .starting : .forwarding ready.insert(command.id) return true } private func advance(_ command: SSHExecChannel, session: OpaquePointer, deadline: UInt64) -> Bool { if let cancellation = cancellationError(for: command), command.terminalError == nil { + if agentRequestOwner == command.id { + // A cancelled, unanswered connection-wide request cannot safely + // be repeated on another channel: the peer may already have accepted it. + failConnection(session, error: cancellation) + return false + } command.terminalError = cancellation if command.execChannel == nil { finish(command, throwing: cancellation) @@ -423,6 +448,30 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { switch command.phase { case .opening: return false + case .forwarding: + if agentForwardingRequested { + command.phase = .starting + ready.insert(command.id) + return true + } + guard agentRequestOwner == nil || agentRequestOwner == command.id else { return false } + agentRequestOwner = command.id + let result = libssh2_channel_request_auth_agent(execChannel) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + agentRequestOwner = nil + if result != 0 { + command.terminalError = SSHCommandExecutionError.channelUnavailable( + SSHConnectionOpener.connectionError(session, prefix: "Agent Forwarding request failed")) + command.phase = .closing + } else { + agentForwardingRequested = true + command.phase = .starting + } + ready.insert(command.id) + return true case .starting: let result = libssh2_channel_process_startup( execChannel, "exec", 4, command.command.pointer, UInt32(command.command.count)) @@ -657,6 +706,9 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { } private func cleanup(error: any Error) { + buffer?.agentChannels?.shutdown() + agentForwardingRequested = false + agentRequestOwner = nil generation += 1 scheduled = false timer?.cancel() diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift index 6d4446a7..ba1311e6 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift @@ -31,12 +31,14 @@ public actor SSHMetricsSampler: MetricsSampling { self.logger = logger } public func connect(_ configuration: SSHConnectionConfiguration) async throws { - hostID = configuration.hostID - try await adapter.withConnection { try await $0.connect(using: configuration) } + try await connect(SSHConnectionRequest(configuration: configuration)) } public func connect(_ request: SSHConnectionRequest) async throws { + var request = request + request.permitsAgentForwarding = false hostID = request.configuration.hostID - try await adapter.withConnection { try await $0.connect(using: request) } + let isolated = request + try await adapter.withConnection { try await $0.connect(using: isolated) } } public func disconnect() async { await adapter.disconnect() } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 787e0f7d..c0089b89 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -647,6 +647,7 @@ public actor SSHPTYChannel { private nonisolated let worker: SSHPTYChannelWorker private let keyboardInteractive: SSHKeyboardInteractiveHandler? + private let agentForwardingFactory: SSHAgentForwardingFactory? private var environmentVariables: [HostEnvironmentVariable] = [] private var environmentRejection: EnvironmentRejectionHandler = { _ in } @@ -654,10 +655,12 @@ public actor SSHPTYChannel { output: @escaping OutputHandler, exit: @escaping ExitHandler, shellIntegration: @escaping ShellIntegrationHandler = { _ in }, keyboardInteractive: SSHKeyboardInteractiveHandler? = nil, + agentForwardingFactory: SSHAgentForwardingFactory? = nil, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-pty-channel"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) { self.keyboardInteractive = keyboardInteractive + self.agentForwardingFactory = agentForwardingFactory worker = SSHPTYChannelWorker( output: output, exit: exit, shellIntegrationHandler: shellIntegration, logger: logger, maxSessionsPolicy: maxSessionsPolicy) @@ -732,8 +735,10 @@ public actor SSHPTYChannel { progress: SSHRouteProgressHandler? = nil ) async throws { defer { request.configuration.clearSecrets() } + var request = request.withKeyboardInteractiveFallback(keyboardInteractive) + request.agentForwarding = try await agentForwardingFactory?(request) try await worker.connect( - using: request.withKeyboardInteractiveFallback(keyboardInteractive), + using: request, environmentVariables: environmentVariables, environmentRejection: environmentRejection, shellIntegrationEnabled: shellIntegrationEnabled, @@ -1127,6 +1132,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { throw Self.connectionError(created, prefix: "Unable to open SSH PTY Channel") } ptyChannel = openedChannel + if let provider = request.agentForwarding { + let transport = openedConnection.installBuffer(deferIO: true) + transport.agentChannels = SSHAgentChannels(provider: provider, queue: queue) { [weak self] in + self?.pump() + } + libssh2_session_callback_set2( + created, LIBSSH2_CALLBACK_AUTHAGENT, + unsafeBitCast(SSHAgentChannels.callback, to: (@convention(c) () -> Void).self)) + guard libssh2_channel_request_auth_agent(openedChannel) == 0 else { + throw Self.connectionError(created, prefix: "Agent Forwarding request failed") + } + } let pty = libssh2_channel_request_pty_ex( openedChannel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows), @@ -1439,6 +1456,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { socketEvents?.update(read: false, write: true) return } + var agentProgress = + buffer.agentChannels?.advance( + deadline: min( + deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 2), + permitsIO: { !buffer.isBackpressured }) ?? false if let ptyChannel { let resizeResult = ptyResize.flush { size in libssh2_channel_request_pty_size_ex( @@ -1493,6 +1515,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } + agentProgress = + (buffer.agentChannels?.advance(deadline: deadline, permitsIO: { !buffer.isBackpressured }) ?? false) + || agentProgress + if agentProgress { queue.async { [weak self] in self?.pump() } } var next = Int32(0) let keepalive = libssh2_keepalive_send(session, &next) if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { @@ -1618,6 +1644,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func closeTerminalSession() { + buffer?.agentChannels?.shutdown() finishCharsetOutput() pendingWrites.removeAll() ptyResize.reset() @@ -1640,6 +1667,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func cleanup() { + buffer?.agentChannels?.shutdown() pumpTimer?.cancel() pumpTimer = nil let connection = connection diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/VaultSSHAgent.swift b/Frameworks/TermindRemoteInfrastructure/SSH/VaultSSHAgent.swift new file mode 100644 index 00000000..afaac41f --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/VaultSSHAgent.swift @@ -0,0 +1,139 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// One capability per physical connection. Deliberately does not cache private +/// keys or passphrases across requests, callers, or connections. +@MainActor +public final class VaultSSHAgent: SSHAgentForwarding { + private let library: HostLibraryModel + private let hostID: UUID + private let revision: UInt64 + private let connectionID = UUID() + private let hostname: String + private let port: Int + private let username: String + private let interactionPolicy: SSHInteractionPolicy + private let unlock: SSHKeyboardInteractiveHandler? + private let canInteract: @MainActor @Sendable () -> Bool + private var closed = false + + public static func factory( + library: HostLibraryModel, + unlock: SSHKeyboardInteractiveHandler? = nil, + canInteract: @escaping @MainActor @Sendable () -> Bool = { true }, + interactionContext: (@MainActor @Sendable (SSHConnectionRequest) -> SSHAgentInteractionContext)? = nil + ) -> SSHAgentForwardingFactory { + { request in + await MainActor.run { + guard request.permitsAgentForwarding, + library.host(request.configuration.hostID)?.agentForwardingEnabled == true + else { return nil } + let context = interactionContext?(request) + return VaultSSHAgent( + library: library, request: request, + unlock: context?.unlock ?? unlock, canInteract: context?.canInteract ?? canInteract) + } + } + } + + private init( + library: HostLibraryModel, request: SSHConnectionRequest, + unlock: SSHKeyboardInteractiveHandler?, canInteract: @escaping @MainActor @Sendable () -> Bool + ) { + self.library = library + hostID = request.configuration.hostID + revision = library.agentForwardingRevision(for: hostID) + hostname = request.configuration.host + port = request.configuration.port + username = request.configuration.username + interactionPolicy = request.interactionPolicy + self.unlock = unlock ?? request.keyboardInteractive + self.canInteract = canInteract + } + + private func check() throws { + try Task.checkCancellation() + guard !closed, library.host(hostID)?.agentForwardingEnabled == true, + library.agentForwardingRevision(for: hostID) == revision + else { throw SSHAgentProtocol.Failure.unavailable } + } + + public func identities() async throws -> [SSHAgentIdentity] { + try check() + var result: [SSHAgentIdentity] = [] + var seen = Set() + for key in library.keys { + guard library.keyAvailability(for: key) == .available, let blob = Self.blob(key.publicKey) else { continue } + if let certificate = Self.blob(key.certificate), seen.insert(certificate).inserted { + result.append(SSHAgentIdentity(blob: certificate, comment: key.label)) + } + if seen.insert(blob).inserted { result.append(SSHAgentIdentity(blob: blob, comment: key.label)) } + } + return result + } + + public func sign(key blob: Data, data: Data, flags: UInt32) async throws -> Data { + try check() + guard + let key = library.keys.first(where: { + (Self.blob($0.publicKey) == blob || Self.blob($0.certificate) == blob) + && library.keyAvailability(for: $0) == .available + }) + else { throw SSHAgentProtocol.Failure.unavailable } + let keyRevision = library.agentForwardingKeyRevision + let secrets = library.agentForwardingSecrets + let resolution = Task.detached { try secrets.sshKeyCredential(for: key.credentialRef, storage: key.storage) } + guard let credential = try await resolution.value else { throw SSHAgentProtocol.Failure.unavailable } + defer { credential.clearSecrets() } + let needsPassphrase = key.hasPassphrase && !credential.hasSavedPassphrase + let needsInteraction = needsPassphrase || key.requiresUserPresence + if needsInteraction { + guard interactionPolicy == .allowed, canInteract() else { throw SSHInteractionRequired() } + } + var passphrase: SSHSecret? + defer { passphrase?.clear() } + if needsPassphrase { + guard let unlock else { throw SSHInteractionRequired() } + let challenge = SSHKeyboardInteractiveChallenge( + connectionID: connectionID, hostID: hostID, hostname: hostname, port: port, + username: username, round: 0, name: String(localized: "Agent Forwarding"), + instruction: key.label + "\n" + key.fingerprint, + prompts: [.init(text: String(localized: "Passphrase"), echo: false)]) + var answers = try await unlock(challenge) + defer { answers.removeAll() } + guard answers.count == 1 else { throw CancellationError() } + passphrase = SSHSecret(answers[0]) + } + try check() + guard library.agentForwardingKeyRevision == keyRevision, library.keys.contains(key), + !needsInteraction || canInteract() + else { throw SSHInteractionRequired() } + let password = passphrase + let allowInteraction = interactionPolicy == .allowed && canInteract() + let task = Task.detached { + try Task.checkCancellation() + return try SSHAgentKeySigner.sign( + credential: credential, passphrase: password, algorithm: key.algorithm, data: data, flags: flags, + allowInteraction: allowInteraction) + } + let signature = try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + try check() + guard library.agentForwardingKeyRevision == keyRevision, library.keys.contains(key), + !needsInteraction || canInteract() + else { + throw SSHAgentProtocol.Failure.unavailable + } + return signature + } + public func close() { closed = true } + private static func blob(_ line: String) -> Data? { + let parts = line.split(whereSeparator: \.isWhitespace) + guard parts.count >= 2 else { return nil } + return Data(base64Encoded: String(parts[1])) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift b/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift index 44dfe0cb..2ca3738a 100644 --- a/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift +++ b/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift @@ -63,9 +63,12 @@ public nonisolated enum SecureEnclaveKey { /// Returns the SSH ECDSA signature payload: two length-prefixed mpints (r, s). /// libssh2 adds the outer algorithm and signature strings around this value. - public static func sign(_ data: Data, credential: SecureEnclaveKeyCredential) throws -> Data { + public static func sign(_ data: Data, credential: SecureEnclaveKeyCredential, allowInteraction: Bool = true) throws + -> Data + { guard isAvailable else { throw SecureEnclaveKeyError.unavailable } let context = LAContext() + context.interactionNotAllowed = !allowInteraction context.localizedReason = String(localized: "Use your Secure Enclave SSH key") let privateKey = try SecureEnclave.P256.Signing.PrivateKey( dataRepresentation: credential.dataRepresentation, diff --git a/Scripts/ssh-agent-forwarding-fixture.py b/Scripts/ssh-agent-forwarding-fixture.py new file mode 100644 index 00000000..b104cd3a --- /dev/null +++ b/Scripts/ssh-agent-forwarding-fixture.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Ephemeral loopback OpenSSH fixture for SSHAgentForwardingWireTests. + +Creates fresh keys, three unprivileged sshd instances (destination, second hop, +and forwarding-denied destination), and a bare Git repository. Never reads or +changes the user's SSH configuration or authorized_keys. Requires OpenSSH/git. +Pass --metadata PATH to TEST_RUNNER_TERMIND_AGENT_FORWARDING_FIXTURE for Xcode. +""" +import argparse +import base64 +import getpass +import hashlib +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import tempfile +import time + + +def run(*args): + subprocess.run(args, check=True, stdout=subprocess.DEVNULL) + + +def port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--metadata", required=True) + args = parser.parse_args() + metadata = Path(args.metadata) + processes = [] + logs = [] + stopped = False + + def stop(*_): + nonlocal stopped + stopped = True + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + with tempfile.TemporaryDirectory(prefix="termind-agent-wire-") as directory: + root = Path(directory) + try: + for name in ("server", "login", "forwarded"): + run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)) + (root / "authorized_keys").write_text((root / "login.pub").read_text() + (root / "forwarded.pub").read_text()) + run("git", "init", "--bare", "--quiet", str(root / "fixture.git")) + shell = root / "fixture-shell" + shell.write_text('#!/bin/sh\nif [ -n "$SSH_ORIGINAL_COMMAND" ]; then exec /bin/sh -c "$SSH_ORIGINAL_COMMAND"; else exec /bin/sh -i; fi\n') + shell.chmod(0o700) + ports = [port() for _ in range(3)] + for index, listen_port in enumerate(ports): + config = root / f"sshd-{index}.conf" + config.write_text(f"""Port {listen_port} +ListenAddress 127.0.0.1 +HostKey {root / 'server'} +PidFile {root / f'sshd-{index}.pid'} +AuthorizedKeysFile {root / 'authorized_keys'} +StrictModes no +PasswordAuthentication no +KbdInteractiveAuthentication no +UsePAM no +AllowAgentForwarding {'no' if index == 2 else 'yes'} +AllowTcpForwarding yes +ForceCommand {shell} +LogLevel VERBOSE +""") + log = open(root / f"sshd-{index}.log", "w+") + logs.append(log) + processes.append(subprocess.Popen(["/usr/sbin/sshd", "-D", "-e", "-f", str(config)], stderr=log)) + time.sleep(1) + for process, log in zip(processes, logs): + if process.poll() is not None: + log.seek(0) + raise RuntimeError(log.read()) + blob = base64.b64decode((root / "server.pub").read_text().split()[1]) + values = { + "port": ports[0], "targetPort": ports[1], "deniedPort": ports[2], + "username": getpass.getuser(), "loginPrivateKey": (root / "login").read_text(), + "forwardedPrivateKey": (root / "forwarded").read_text(), + "forwardedPublicKey": (root / "forwarded.pub").read_text(), + "fingerprint": "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("="), + "gitPath": str(root / "fixture.git"), + } + descriptor = os.open(metadata, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as file: + json.dump(values, file) + print(f"Agent forwarding fixture ready: {metadata}", flush=True) + while not stopped: + if any(process.poll() is not None for process in processes): + raise RuntimeError("sshd exited unexpectedly") + time.sleep(0.2) + finally: + metadata.unlink(missing_ok=True) + for process in processes: + process.terminate() + for process in processes: + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + for log in logs: + log.close() + + +if __name__ == "__main__": + main() diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index a36797d9..13cea419 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -119,6 +119,7 @@ final class MobileAppModel { } var error: String? let terminalSettings: TerminalSettingsModel + let agentForwardingRouter = MobileAgentForwardingRouter() let sshSettings: SSHSettingsModel let sftpSettings: SFTPSettingsModel let workspaceSettings: WorkspaceSettingsModel @@ -298,6 +299,10 @@ final class MobileAppModel { persistence: persistence, secrets: secrets, localDefaults: memoryOnly ? nil : settingsDefaults ) + let forwardingRouter = agentForwardingRouter + connectionCoordinator.updateAgentForwarding( + VaultSSHAgent.factory( + library: library, interactionContext: { forwardingRouter.context(for: $0) })) if !memoryOnly { try? library.migrateCustomPasswordsToICloud() } vaultSync?.beforeSend = { [weak library] in try library?.migrateCustomPasswordsToICloud() } metrics = MobileMetricsModel( diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 0278d11d..10691be8 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -51,7 +51,20 @@ struct MobileRootView: View { scene.workspaceSession.renameWorkspace(descriptor.id, to: title) } } + .sheet( + isPresented: Binding( + get: { scene.agentForwarding.current != nil }, + set: { if !$0 { scene.agentForwarding.cancelAll() } } + ) + ) { + if let challenge = scene.agentForwarding.current { + MobileAgentForwardingSheet(interaction: scene.agentForwarding, challenge: challenge) + .id(challenge.id) + } + } + .onDisappear { scene.agentForwarding.isActive = false } .onChange(of: scenePhase, initial: true) { _, phase in + scene.agentForwarding.isActive = phase != .background app.sceneChanged(scene.id, phase: phase, workspaceSessionID: scene.workspaceSession.id) MobileHomeScreenQuickActions.refresh(app: app) } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 595c31be..cf193e12 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -14,6 +14,7 @@ import struct TermindDomain.Host @Observable final class MobileSceneModel { let id = UUID() + let agentForwarding = MobileAgentForwardingInteraction() let workspaceSession: MobileWorkspaceSessionModel let assistant: MobileAssistantSceneModel var selectedDestination: MobileDestination? = .vault { @@ -45,6 +46,7 @@ final class MobileSceneModel { assistant = app.assistant.makeSceneModel() assistant.paneAccess = app.paneAccess(for: self) app.registerPaneScene(self) + app.agentForwardingRouter.register(self) } var activeWorkspace: MobileWorkspace? { diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 281eae74..4547e2a2 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -200,6 +200,7 @@ struct MobileHostEditor: View { charset: $draft.sshCharset, sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: startupSnippetChoice, + agentForwardingEnabled: $draft.agentForwardingEnabled, environmentVariables: $draft.environmentVariables, snippets: library.snippets, snippetPackagePath: library.snippetPackagePath, diff --git a/TFI/Features/Workspaces/MobileAgentForwardingInteraction.swift b/TFI/Features/Workspaces/MobileAgentForwardingInteraction.swift new file mode 100644 index 00000000..108b4185 --- /dev/null +++ b/TFI/Features/Workspaces/MobileAgentForwardingInteraction.swift @@ -0,0 +1,87 @@ +import Foundation +import SwiftUI +import TermindApplication +import TermindPresentation + +/// Each scene owns its pending requests. No UIKit presentation singleton is used. +@MainActor +@Observable +final class MobileAgentForwardingInteraction { + private(set) var pending: [SSHKeyboardInteractiveChallenge] = [] + private var waiters: [UUID: CheckedContinuation<[String], any Error>] = [:] + var isActive = false { + didSet { if !isActive { cancelAll() } } + } + var current: SSHKeyboardInteractiveChallenge? { pending.first } + + func request(_ challenge: SSHKeyboardInteractiveChallenge) async throws -> [String] { + guard isActive, pending.count < 16 else { throw SSHInteractionRequired() } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + waiters[challenge.id] = continuation + pending.append(challenge) + } + } onCancel: { + Task { @MainActor [weak self] in self?.respond(challenge.id, answers: nil) } + } + } + func respond(_ id: UUID, answers: [String]?) { + guard let waiter = waiters.removeValue(forKey: id) else { return } + pending.removeAll { $0.id == id } + if let answers { waiter.resume(returning: answers) } else { waiter.resume(throwing: CancellationError()) } + } + func cancelAll() { + for id in Array(waiters.keys) { respond(id, answers: nil) } + } +} + +@MainActor +final class MobileAgentForwardingRouter { + private struct Entry { + weak var scene: MobileSceneModel? + } + private var scenes: [UUID: Entry] = [:] + func register(_ scene: MobileSceneModel) { scenes[scene.id] = Entry(scene: scene) } + func context(for request: SSHConnectionRequest) -> SSHAgentInteractionContext { + let live = scenes.values.compactMap(\.scene) + let candidates: [MobileSceneModel] + if let contextID = request.agentForwardingContextID { + candidates = live.filter { scene in + scene.workspaceSession.workspaces.contains { $0.id == contextID } + || scene.assistant.library.selectedConversationID == contextID + } + } else { + candidates = live.filter { $0.agentForwarding.isActive } + } + // An ambiguous or inactive owner cannot authorize a signature. + guard candidates.count == 1, let scene = candidates.first else { + return SSHAgentInteractionContext(canInteract: { false }) + } + let presenter = scene.agentForwarding + return SSHAgentInteractionContext( + unlock: { [weak presenter] challenge in + guard let presenter else { throw CancellationError() } + return try await presenter.request(challenge) + }, + canInteract: { [weak presenter] in presenter?.isActive == true }) + } +} + +struct MobileAgentForwardingSheet: View { + let interaction: MobileAgentForwardingInteraction + let challenge: SSHKeyboardInteractiveChallenge + var body: some View { + NavigationStack { + SSHKeyboardInteractiveForm( + challenge: challenge, + submit: { interaction.respond(challenge.id, answers: $0) }, + cancel: { interaction.respond(challenge.id, answers: nil) } + ) + .padding() + .navigationTitle(AppStrings.Connections.agentForwarding.swiftUIKey) + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium, .large]) + } +} diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index de24a435..ec20fd76 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -724,7 +724,7 @@ final class MobileWorkspace: Identifiable { trustPolicy: .allowChallenge ) guard generation == token, !Task.isCancelled else { return } - let request = SSHConnectionRequest( + var request = SSHConnectionRequest( configuration: config, authorizeHostKey: { [weak self] challenge in guard let self else { throw CancellationError() } @@ -737,6 +737,7 @@ final class MobileWorkspace: Identifiable { hostKeyConstraints: connectionOrigin.hostKeyConstraints ) + request.agentForwardingContextID = id var externalFile: RemoteFile? if descriptor.kind == .ssh { let backend = backend diff --git a/TFITests/MobileAgentForwardingInteractionTests.swift b/TFITests/MobileAgentForwardingInteractionTests.swift new file mode 100644 index 00000000..67ee1a32 --- /dev/null +++ b/TFITests/MobileAgentForwardingInteractionTests.swift @@ -0,0 +1,52 @@ +import Foundation +import TermindApplication +import Testing + +@testable import TermindIOS + +@Suite @MainActor +struct MobileAgentForwardingInteractionTests { + private func challenge() -> SSHKeyboardInteractiveChallenge { + .init( + connectionID: UUID(), hostID: UUID(), hostname: "fixture", port: 22, + username: "user", round: 0, name: "Agent Forwarding", instruction: "Fixture key", + prompts: [.init(text: "Passphrase", echo: false)]) + } + @Test func inactiveSceneCannotRequestUnlock() async { + let model = MobileAgentForwardingInteraction() + await #expect(throws: SSHInteractionRequired.self) { try await model.request(challenge()) } + #expect(model.current == nil) + } + @Test func requestsQueueAndBackgroundCancelsRemainingWaiters() async throws { + let model = MobileAgentForwardingInteraction() + model.isActive = true + let first = challenge() + let second = challenge() + let a = Task { try await model.request(first) } + for _ in 0..<100 where model.current == nil { await Task.yield() } + let b = Task { try await model.request(second) } + for _ in 0..<100 where model.pending.count < 2 { await Task.yield() } + #expect(model.pending.map(\.id) == [first.id, second.id]) + model.respond(first.id, answers: ["fixture"]) + #expect(try await a.value == ["fixture"]) + #expect(model.current?.id == second.id) + model.isActive = false + await #expect(throws: CancellationError.self) { try await b.value } + #expect(model.pending.isEmpty) + } + @Test func cancellationRemovesOnlyItsOwnRequest() async throws { + let model = MobileAgentForwardingInteraction() + model.isActive = true + let first = challenge() + let second = challenge() + let a = Task { try await model.request(first) } + for _ in 0..<100 where model.current == nil { await Task.yield() } + let b = Task { try await model.request(second) } + for _ in 0..<100 where model.pending.count < 2 { await Task.yield() } + a.cancel() + await #expect(throws: CancellationError.self) { try await a.value } + #expect(model.current?.id == second.id) + model.respond(second.id, answers: ["second"]) + #expect(try await b.value == ["second"]) + } +} diff --git a/TFITests/MobileHostIntegrationRenderingTests.swift b/TFITests/MobileHostIntegrationRenderingTests.swift index 267ecb6e..5bba3c32 100644 --- a/TFITests/MobileHostIntegrationRenderingTests.swift +++ b/TFITests/MobileHostIntegrationRenderingTests.swift @@ -74,6 +74,44 @@ struct MobileHostIntegrationRenderingTests { to: FileManager.default.temporaryDirectory.appending(path: "host-integration-mobile-\(language).png")) } + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func agentForwardingFollowsStartupSnippet(language: String) async throws { + let controller = UIHostingController( + rootView: Form { + HostSessionSection( + charset: .constant(.utf8), sftpInitialPath: .constant(""), + agentForwardingEnabled: .constant(true)) + }.environment(\.locale, Locale(identifier: language))) + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: 375, height: 650) + window.rootViewController = controller + window.windowLevel = .alert + 1 + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + try await Task.sleep(for: .milliseconds(400)) + controller.view.layoutIfNeeded() + let toggle = try #require(descendants(controller.view).compactMap { $0 as? UISwitch }.first) + #expect(toggle.isOn) + let pickers = descendants(controller.view).compactMap { $0 as? UIButton }.filter { $0.showsMenuAsPrimaryAction } + let startup = try #require(pickers.last) + #expect( + toggle.convert(toggle.bounds, to: controller.view).midY + > startup.convert(startup.bounds, to: controller.view).midY) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + #expect(window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)) + } + let data = try #require(image.pngData()) + Attachment.record(data, named: "agent-forwarding-mobile-\(language).png") + try data.write( + to: FileManager.default.temporaryDirectory.appending(path: "agent-forwarding-mobile-\(language).png")) + } + private func descendants(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(descendants) } diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 2889e359..4569316a 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -13,6 +13,41 @@ import struct TermindDomain.Host @Suite @MainActor struct MobileWorkspaceSessionTests { + @Test func agentForwardingStaysWithTheConversationScene() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let app = try fixture.makeApp() + let owner = MobileSceneModel(app: app, sessionID: "agent-owner") + let other = MobileSceneModel(app: app, sessionID: "agent-other") + owner.agentForwarding.isActive = true + other.agentForwarding.isActive = true + let conversation = ConversationModel() + conversation.composer.draft = "Agent forwarding" + _ = try #require(owner.assistant.library.startConversation(conversation)) + owner.openAssistantNotification(conversation.id) + let hostID = UUID() + let request = SSHConnectionRequest( + configuration: .init( + hostID: hostID, host: "fixture", port: 22, username: "user", credentials: [], expectedHostKeys: []), + agentForwardingContextID: conversation.id) + let context = app.agentForwardingRouter.context(for: request) + #expect(context.canInteract()) + let handler = try #require(context.unlock) + let pending = Task { + try await handler( + .init( + connectionID: UUID(), hostID: hostID, + hostname: "fixture", port: 22, username: "user", round: 0, name: "Agent Forwarding", + instruction: "Fixture", prompts: [.init(text: "Passphrase", echo: false)])) + } + for _ in 0..<100 where owner.agentForwarding.current == nil { await Task.yield() } + #expect(owner.agentForwarding.current != nil) + #expect(other.agentForwarding.current == nil) + owner.agentForwarding.isActive = false + #expect(!context.canInteract()) + await #expect(throws: CancellationError.self) { try await pending.value } + } + @Test(arguments: [true, false]) func assistantNotificationPrefersActiveThenStableOrderWithinMatchingScenes(_ visible: Bool) throws { let fixture = try Fixture() diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 09d0b8a3..7181f9a6 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -205,6 +205,7 @@ final class AppModel { self.externalAuthentication = externalAuthentication let connectionCoordinator = dependencies.connectionCoordinator.withKeyboardInteractive( externalAuthentication.keyboardInteractive) + connectionCoordinator.updateAgentForwarding(VaultSSHAgent.factory(library: hosts)) connectionCoordinator.updateSSHPreferences(sshSettings.preferences) self.serverMetrics = suppliedMetrics diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index a4ebae6f..8e7274a1 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -286,6 +286,7 @@ struct HostEditorView: View { charset: $draft.sshCharset, sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: startupSnippetChoice, + agentForwardingEnabled: $draft.agentForwardingEnabled, environmentVariables: $draft.environmentVariables, snippets: app.hostLibrary.snippets, snippetPackagePath: app.hostLibrary.snippetPackagePath, diff --git a/TFMTests/AgentConnectionPoolTests.swift b/TFMTests/AgentConnectionPoolTests.swift index 3e6f2215..01e86873 100644 --- a/TFMTests/AgentConnectionPoolTests.swift +++ b/TFMTests/AgentConnectionPoolTests.swift @@ -13,6 +13,44 @@ import Testing @Suite struct AgentConnectionPoolTests { + @Test func forwardedCommandsExecuteOnSeparateConnectionsAndReleaseThem() async throws { + let backend = StubBackend() + let pool = makePool(backend, forwardingEnabled: true) + let host = UUID() + for _ in 0..<2 { + let result = try await pool.execute( + "true", workingDirectory: nil, stdin: nil, + timeout: 5, outputLimit: 1024, hostID: host) + #expect(result.exitCode == 0) + } + #expect(await backend.execOpens == 2) + #expect(await backend.execDisconnects == 2) + } + + @Test func closeAllDiscardsALateForwardingConnection() async throws { + let backend = StubBackend() + let gate = Gate() + let pool = makePool(backend, openerGate: gate, forwardingEnabled: true) + let opening = Task { try await pool.connection(hostID: UUID()) } + while await gate.arrivals == 0 { await Task.yield() } + await pool.closeAll() + await gate.open() + await #expect(throws: CancellationError.self) { try await opening.value } + #expect(await backend.execDisconnects == 1) + } + + @Test func forwardingConnectionsAreIsolatedAndClosedWithThePool() async throws { + let backend = StubBackend() + let pool = makePool(backend, forwardingEnabled: true) + let host = UUID() + let first = try await pool.connection(hostID: host) + let second = try await pool.connection(hostID: host) + #expect(first !== second) + #expect(await backend.execOpens == 2) + await pool.closeAll() + #expect(await backend.execDisconnects == 2) + } + @Test func exclusiveConnectionAdapterAdmitsOnlyOneOperationAtATime() async throws { let firstGate = Gate() @@ -365,7 +403,7 @@ private func serial(_ connection: any SSHConnectionProtocol) throws -> Int { /// `sweepInterval: nil` keeps the background sweeper out of the tests; every /// case here drives `sweepIdle` itself with a synthetic instant. private func makePool( - _ backend: StubBackend, openerGate: Gate? = nil + _ backend: StubBackend, openerGate: Gate? = nil, forwardingEnabled: Bool = false ) -> AgentConnectionPool { AgentConnectionPool( configuration: { hostID in @@ -375,6 +413,7 @@ private func makePool( ) }, routeFingerprint: { try await backend.fingerprint(for: $0) }, + forwardingEnabled: { _ in forwardingEnabled }, openConnection: { _ in if let openerGate { await openerGate.wait() } return await backend.openConnection() diff --git a/TFMTests/SSHAgentForwardingRenderingTests.swift b/TFMTests/SSHAgentForwardingRenderingTests.swift new file mode 100644 index 00000000..9de68098 --- /dev/null +++ b/TFMTests/SSHAgentForwardingRenderingTests.swift @@ -0,0 +1,40 @@ +import AppKit +import SwiftUI +import TermindDomain +import TermindPresentation +import Testing + +@Suite(.requiresVisibleWindow, .serialized) @MainActor +struct SSHAgentForwardingRenderingTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func sessionSettingsRenderAtNarrowWidth(_ language: String) async throws { + let controller = NSHostingController( + rootView: Form { + HostSessionSection( + charset: .constant(.utf8), sftpInitialPath: .constant(""), + agentForwardingEnabled: .constant(true)) + }.formStyle(.grouped) + .environment(\.locale, Locale(identifier: language)) + .frame(width: 400, height: 560)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 560), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.orderFront(nil) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(400)) + controller.view.layoutSubtreeIfNeeded() + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + #expect(data.count > 5000) + Attachment.record(data, named: "agent-forwarding-mac-\(language).png") + try data.write( + to: FileManager.default.temporaryDirectory.appending(path: "agent-forwarding-mac-\(language).png")) + } +} diff --git a/TFMTests/SSHAgentForwardingWireTests.swift b/TFMTests/SSHAgentForwardingWireTests.swift new file mode 100644 index 00000000..8931bd9c --- /dev/null +++ b/TFMTests/SSHAgentForwardingWireTests.swift @@ -0,0 +1,173 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@testable import TermindRemoteInfrastructure + +/// Uses disposable localhost sshd servers and keys from the companion script. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_AGENT_FORWARDING_FIXTURE"] != nil), .serialized) +@MainActor +struct SSHAgentForwardingWireTests { + private struct Fixture: Decodable { + var port: Int + var targetPort: Int + var deniedPort: Int + var username: String + var loginPrivateKey: String + var forwardedPrivateKey: String + var forwardedPublicKey: String + var fingerprint: String + var gitPath: String + } + private func fixture() throws -> Fixture { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_AGENT_FORWARDING_FIXTURE"]) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + } + private func setup(_ fixture: Fixture) throws -> (Host, HostLibraryModel, RemoteConnectionCoordinator) { + let host = Host(name: "Agent fixture", host: "127.0.0.1", port: fixture.port, agentForwardingEnabled: true) + let material = try SSHKeyMaterial.inspect(privateKey: fixture.forwardedPrivateKey, passphrase: nil) + let key = SSHKeyRef( + label: "Forwarded fixture", algorithm: material.algorithm, + publicKey: material.publicKey, fingerprint: material.fingerprint, credentialRef: "fixture-key", + storage: .thisDevice) + let secrets = InMemorySecretStore() + try secrets.setKeyCredential( + .software(.init(privateKey: fixture.forwardedPrivateKey)), for: key.credentialRef, storage: key.storage) + let library = HostLibraryModel(hosts: [host], keys: [key], secrets: secrets) + let coordinator = RemoteConnectionCoordinator() + coordinator.updateAgentForwarding(VaultSSHAgent.factory(library: library)) + return (host, library, coordinator) + } + private func request(_ host: Host, _ fixture: Fixture, port: Int? = nil) -> SSHConnectionRequest { + SSHConnectionRequest( + configuration: .init( + hostID: host.id, host: host.host, port: port ?? host.sshPort, username: fixture.username, + credentials: [.privateKey(privateKey: fixture.loginPrivateKey, publicKey: nil, passphrase: nil)], + expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.fingerprint)])) + } + private func ssh(_ fixture: Fixture) -> String { + "/usr/bin/ssh -F /dev/null -o BatchMode=yes -o IdentityFile=/dev/null -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p \(fixture.targetPort) \(fixture.username)@127.0.0.1" + } + @Test func execListsKeysAuthenticatesSecondHopAndReadsGit() async throws { + let fixture = try fixture() + let (host, library, coordinator) = try setup(fixture) + let connection = try await coordinator.connectConnection(using: request(host, fixture)) + do { + let command = + "test -n \"$SSH_AUTH_SOCK\" && /usr/bin/ssh-add -L && \(ssh(fixture)) 'printf forwarded-ok' && \(ssh(fixture)) 'git-upload-pack \(fixture.gitPath)' /dev/null 2>&1; printf 'revoked-%s' $?\n".utf8)) + for _ in 0..<50 { + if output.text.contains("revoked-1") { break } + try await Task.sleep(for: .milliseconds(100)) + } + #expect(output.text.contains("revoked-1")) + await pty.disconnect() + } +} + +private nonisolated final class Output: @unchecked Sendable { + private let lock = NSLock() + private var bytes = Data() + func append(_ data: Data) { lock.withLock { bytes.append(data) } } + var text: String { lock.withLock { String(decoding: bytes, as: UTF8.self) } } +}