feat(ssh): forward vault keys to remote sessions
This commit is contained in:
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
Reference in new issue
Block a user