feat(ssh): forward vault keys to remote sessions

This commit is contained in:
Kaede Akino committed 2026-09-28 20:04:07 +08:00
1 parent b58c8beb5e
commit ac645aa7b7
50 files changed
+1970 -51

No files matched your search

@@ -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
@@ -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 })
}
@@ -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<PersistentHost>()).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<PersistentHost>()).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))
}
}
@@ -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<UInt8>?
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 {}
}
@@ -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<HostV2>([
let decoder = VersionedRecordDecoder<HostV3>([
"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)
@@ -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" : {
@@ -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
}
@@ -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)
@@ -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
}
}
@@ -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] = []
@@ -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 {
+7 -2
View File
@@ -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)
@@ -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.
@@ -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
@@ -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")
@@ -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
}
}
}
@@ -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<VaultEnvelope>(
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<VaultEnvelope> = {
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)
@@ -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))
@@ -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)
}
}
}
@@ -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<RemoteCharset>,
sftpInitialPath: Binding<String>,
startupSnippetID: Binding<Snippet.ID?> = .constant(nil),
agentForwardingEnabled: Binding<Bool> = .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()
@@ -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"
@@ -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<any SSHConnectionProtocol, any Error>]] = [:]
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: [:])
@@ -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
@@ -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)
}
@@ -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<Void, Never>?
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<UnsafeMutableRawPointer?>?
) -> Void = { _, channel, abstract in
guard let channel, let opaque = abstract?.pointee else { return }
let buffer = Unmanaged<SSHConnectionBuffer>.fromOpaque(opaque).takeUnretainedValue()
buffer.agentChannels?.accept(channel)
}
}
@@ -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<CChar>?) -> 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<UInt8>?
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<OpaquePointer?>, _ session: OpaquePointer, _ bytes: UnsafePointer<CChar>, _ count: Int,
_ passphrase: UnsafePointer<CChar>?
) -> Int32
@_silgen_name("_libssh2_ecdsa_new_private_frommemory")
private nonisolated func parseECDSA(
_ key: UnsafeMutablePointer<OpaquePointer?>, _ session: OpaquePointer, _ bytes: UnsafePointer<CChar>, _ count: Int,
_ passphrase: UnsafePointer<CChar>?
) -> Int32
@_silgen_name("_libssh2_ed25519_new_private_frommemory")
private nonisolated func parseEd25519(
_ key: UnsafeMutablePointer<OpaquePointer?>, _ session: OpaquePointer, _ bytes: UnsafePointer<CChar>, _ count: Int,
_ passphrase: UnsafePointer<CChar>?
) -> Int32
@_silgen_name("_libssh2_rsa_sha1_sign")
private nonisolated func signRSA1(
_ session: OpaquePointer, _ key: OpaquePointer, _ hash: UnsafePointer<UInt8>?, _ count: Int,
_ signature: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>, _ length: UnsafeMutablePointer<Int>
) -> Int32
@_silgen_name("_libssh2_rsa_sha2_sign")
private nonisolated func signRSA2(
_ session: OpaquePointer, _ key: OpaquePointer, _ hash: UnsafePointer<UInt8>?, _ count: Int,
_ signature: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>, _ length: UnsafeMutablePointer<Int>
) -> Int32
@_silgen_name("_libssh2_ecdsa_sign")
private nonisolated func signECDSA(
_ session: OpaquePointer, _ key: OpaquePointer, _ hash: UnsafePointer<UInt8>?, _ count: Int,
_ signature: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>, _ length: UnsafeMutablePointer<Int>
) -> Int32
@_silgen_name("_libssh2_ed25519_sign")
private nonisolated func signEd25519(
_ key: OpaquePointer, _ session: OpaquePointer, _ signature: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>,
_ length: UnsafeMutablePointer<Int>, _ bytes: UnsafePointer<UInt8>?, _ count: Int
) -> Int32
@@ -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..<offset + 4].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
mutating func field() throws -> Data {
let count = Int(try uint32())
guard count <= data.count - offset else { throw Failure.invalid }
defer { offset += count }
return data.subdata(in: offset..<offset + count)
}
}
}
@@ -20,11 +20,14 @@ public nonisolated final class SSHAuthenticatedConnection: @unchecked Sendable {
self.libssh2Session = libssh2Session
}
func installBuffer(tuning: SSHForwardingTuning = .current) -> 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
}
@@ -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)
}
@@ -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")
@@ -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()
@@ -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() }
@@ -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
@@ -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<Data>()
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]))
}
}
@@ -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,
+115
View File
@@ -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()
+5
View File
@@ -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(
+13
View File
@@ -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)
}
+2
View File
@@ -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? {
@@ -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,
@@ -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])
}
}
@@ -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
@@ -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"])
}
}
@@ -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)
}
@@ -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()
+1
View File
@@ -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
@@ -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,
+40 -1
View File
@@ -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()
@@ -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"))
}
}
+173
View File
@@ -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"
let result = try await connection.execute(
command, workingDirectory: nil, stdin: nil, timeout: 15, outputLimit: 8192, onOutput: nil)
#expect(result.stdout.contains("forwarded-ok"))
#expect(result.stdout.contains(library.keys[0].publicKey.split(separator: " ")[1]))
// git-upload-pack without a request can return nonzero after advertising;
// ls-remote is the complete Git protocol check below.
let git = try await connection.execute(
"GIT_SSH_COMMAND='/usr/bin/ssh -F /dev/null -o BatchMode=yes -o IdentityFile=/dev/null -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no' /usr/bin/git ls-remote ssh://\(fixture.username)@127.0.0.1:\(fixture.targetPort)\(fixture.gitPath)",
workingDirectory: nil, stdin: nil, timeout: 15, outputLimit: 4096, onOutput: nil)
#expect(git.exitCode == 0)
} catch {
await connection.disconnect()
throw error
}
await connection.disconnect()
}
@Test func forwardingWorksThroughAJumpHost() async throws {
let fixture = try fixture()
let (host, _, coordinator) = try setup(fixture)
var routed = request(host, fixture, port: fixture.targetPort)
routed.configuration.jumps = [
.init(
id: UUID(), name: "Jump fixture", host: "127.0.0.1", port: fixture.port,
username: fixture.username,
credentials: [.privateKey(privateKey: fixture.loginPrivateKey, publicKey: nil, passphrase: nil)],
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.fingerprint)])
]
let connection = try await coordinator.connectConnection(using: routed)
do {
let result = try await connection.execute(
"test -n \"$SSH_AUTH_SOCK\" && /usr/bin/ssh-add -L",
workingDirectory: nil, stdin: nil, timeout: 10, outputLimit: 4096, onOutput: nil)
#expect(result.exitCode == 0)
#expect(result.stdout.contains("ssh-ed25519"))
} catch {
await connection.disconnect()
throw error
}
await connection.disconnect()
}
@Test func assistantAndMCPSharedServicesForwardWithinTheirExecLease() async throws {
let fixture = try fixture()
let (initialHost, library, coordinator) = try setup(fixture)
let material = try SSHKeyMaterial.inspect(privateKey: fixture.loginPrivateKey, passphrase: nil)
let key = SSHKeyRef(
label: "Login fixture", algorithm: material.algorithm,
publicKey: material.publicKey, fingerprint: material.fingerprint,
credentialRef: "fixture-login", storage: .thisDevice)
try library.upsert(key, credential: .software(.init(privateKey: fixture.loginPrivateKey)))
let identity = Identity(name: "Fixture", username: fixture.username, keyID: key.id)
_ = library.upsert(identity)
var host = initialHost
host.identityID = identity.id
library.upsert(host)
library.saveKnownHost(
KnownHostEntry(
hostname: host.host, port: host.sshPort, fingerprint: fixture.fingerprint))
let services = AgentHostServices.live(store: library, coordinator: coordinator)
let context = try await services.authentication.connectionContext(
hostID: host.id, invocationID: UUID(), interaction: .unavailable, pool: services.connections)
let connection = context.connection
let command = "test -n \"$SSH_AUTH_SOCK\" && \(ssh(fixture)) 'printf shared-services-ok'"
let result = try await services.connections.withExecLease(hostID: host.id, connection: connection) {
try await connection.execute(
command, workingDirectory: nil, stdin: nil, timeout: 15, outputLimit: 4096, onOutput: nil)
}
#expect(result.exitCode == 0)
#expect(result.stdout.contains("shared-services-ok"))
}
@Test func serverDenialFailsBeforeExecutingCommand() async throws {
let fixture = try fixture()
let (host, _, coordinator) = try setup(fixture)
let connection = try await coordinator.connectConnection(
using: request(host, fixture, port: fixture.deniedPort))
await #expect(throws: (any Error).self) {
try await connection.execute(
"printf must-not-run", workingDirectory: nil, stdin: nil, timeout: 5, outputLimit: 1024, onOutput: nil)
}
await connection.disconnect()
}
@Test func terminalProvidesAgentAndRevocationStopsSigning() async throws {
let fixture = try fixture()
let (initialHost, library, coordinator) = try setup(fixture)
var host = initialHost
let output = Output()
let pty = coordinator.makePTYChannel(output: { output.append($0) }, exit: { _ in })
try await pty.connect(using: request(host, fixture), shellIntegrationEnabled: false, columns: 80, rows: 24)
await pty.write(Data("\(ssh(fixture)) 'printf wire-pty-%s ok'\n".utf8))
for _ in 0..<150 {
if output.text.contains("wire-pty-ok") { break }
try await Task.sleep(for: .milliseconds(100))
}
#expect(output.text.contains("wire-pty-ok"))
host.agentForwardingEnabled = false
library.upsert(host)
await pty.write(Data("/usr/bin/ssh-add -L >/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) } }
}