Files
Termind/TFMTests/AgentHostAuthenticationTests.swift

1657 lines
69 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import CryptoKit
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite(.serialized)
@MainActor
struct AgentHostAuthenticationTests {
@Test func tailscaleBrowserApprovalRemainsPendingInAssistantUntilTheUserResponds() async throws {
let conversation = ConversationModel()
let interaction = WeakAgentUserInteraction(conversation).sshInteraction
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: UUID(), hostname: "server.example", port: 22, username: "root",
round: 1, name: "", instruction: "Approve at https://login.example/ssh/check", prompts: [],
isTailscaleApproval: true)
let task = Task { try await interaction.respondToChallenge(challenge, invocationID: UUID()) }
defer { task.cancel() }
try await waitForAuthentication(in: conversation)
for _ in 0..<10 { await Task.yield() }
let pending = try #require(conversation.pendingSSHAuthentication)
#expect(pending.keyboardInteractive?.isTailscaleApproval == true)
conversation.respondToSSHAuthentication(.keyboardInteractive([]), requestID: pending.id)
#expect(try await task.value.isEmpty)
#expect(conversation.pendingSSHAuthentication == nil)
}
@Test func webAuthnSigningUsesTheConversationAuthenticationForm() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
let host = Host(name: "Server", host: "server.example", identityID: identity.id)
let store = HostLibraryModel(hosts: [host], identities: [identity])
let conversation = ConversationModel()
let invocationID = UUID()
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id, invocationID: invocationID,
interaction: WeakAgentUserInteraction(conversation).sshInteraction)
let attempt = try await session.configurationAttempt()
let signing = try #require(attempt.request.securityKeySigning)
let request = signingRequest(hostID: host.id)
let task = Task { try await signing(request) }
defer { task.cancel() }
try await waitForAuthentication(in: conversation)
let prompt = try #require(conversation.pendingSSHAuthentication)
#expect(prompt.securityKey == request)
#expect(prompt.invocationID == invocationID)
let assertion = signingAssertion()
conversation.respondToSSHAuthentication(.securityKey(assertion), requestID: prompt.id)
#expect(try await task.value == assertion)
#expect(conversation.pendingSSHAuthentication == nil)
#expect(conversation.messages.isEmpty)
}
@Test func cancellingWebAuthnSigningClearsTheConversationForm() async throws {
let conversation = ConversationModel()
let interaction = WeakAgentUserInteraction(conversation).sshInteraction
let task = Task { try await interaction.signSecurityKey(signingRequest(), invocationID: UUID()) }
defer { task.cancel() }
try await waitForAuthentication(in: conversation)
task.cancel()
await #expect(throws: CancellationError.self) { try await task.value }
#expect(conversation.pendingSSHAuthentication == nil)
}
@Test func unavailableOrSilentWebAuthnSigningNeverRequestsInteraction() async throws {
await #expect(throws: SSHInteractionRequired.self) {
try await AgentSSHInteraction.unavailable.signSecurityKey(signingRequest(), invocationID: UUID())
}
let interaction = AgentSSHInteraction(
authenticate: { _ in
Issue.record("Silent signing must not request authentication")
return .cancelled
}, confirmHostKey: { _ in .deny })
var request = signingRequest()
request.allowsUserInteraction = false
await #expect(throws: SSHInteractionRequired.self) {
try await interaction.signSecurityKey(request, invocationID: UUID())
}
}
@Test(.requiresVisibleWindow)
func externalMCPWebAuthnSigningUsesTheAuthenticationWindowQueue() async throws {
let coordinator = ExternalAuthCoordinator()
let runID = UUID()
let interaction = coordinator.handler(clientName: "MCP test", runID: runID).sshInteraction
let request = signingRequest()
let task = Task { try await interaction.signSecurityKey(request, invocationID: UUID()) }
defer {
task.cancel()
coordinator.cancelAll()
}
for _ in 0..<100 {
if coordinator.current != nil { break }
await Task.yield()
}
let pending = try #require(coordinator.current)
guard case .ssh(let prompt) = pending.request else {
Issue.record("Expected SSH authentication")
return
}
#expect(prompt.securityKey == request)
#expect(pending.clientName == "MCP test" && pending.runID == runID)
let assertion = signingAssertion()
coordinator.respondSSH(.securityKey(assertion), requestID: prompt.id)
#expect(try await task.value == assertion)
#expect(coordinator.current == nil)
}
private func signingRequest(hostID: UUID = UUID()) -> SecurityKeySigningRequest {
SecurityKeySigningRequest(
hostID: hostID, hostname: "server.example",
credential: SecurityKeyCredential(
credentialID: Data([1, 2, 3]), publicKeyX963: P256.Signing.PrivateKey().publicKey.x963Representation),
challenge: Data("ssh authentication challenge".utf8))
}
private func signingAssertion() -> SecurityKeyAssertion {
SecurityKeyAssertion(
credentialID: Data([1, 2, 3]), authenticatorData: Data([1]),
clientDataJSON: Data([2]), signature: Data([3]))
}
@Test
func proxyAuthenticationUsesTheAgentInteractionWithoutChangingItsUsername() async throws {
let sshIdentity = Identity(name: "SSH", username: "deploy")
let missingProxy = Identity(
name: "Proxy", username: "identity-user",
credentialRef: "termind.password.proxy-missing")
let host = Host(
name: "Server", host: "server.example", identityID: sshIdentity.id,
proxy: HostProxy(
hostname: "proxy.example", username: "proxy-user",
identityID: missingProxy.id))
let store = HostLibraryModel(
hosts: [host], identities: [sshIdentity, missingProxy],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")],
secrets: MemoryHostSecretStore())
let interaction = AuthenticationResponder { request in
guard case .proxy(let ownerHostID) = request.mode else {
return .cancelled
}
#expect(ownerHostID == host.id)
#expect(request.selectedIdentityID == nil)
#expect(request.username == "proxy-user")
return .submit(
AgentSSHAuthenticationAnswer(
username: request.username, password: "one-use", save: false))
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction)
let configuration = try await session.configuration()
#expect(configuration.proxy?.username == "proxy-user")
#expect(try #require(configuration.proxy?.password) == "one-use")
#expect(store.host(host.id)?.proxy?.identityID == missingProxy.id)
}
@Test func usernameOnlyIdentityDoesNotRequireCredentialEntry() async throws {
let identity = Identity(name: "OTP", username: "deploy")
let host = Host(name: "Server", host: "server.example", identityID: identity.id)
let store = HostLibraryModel(hosts: [host], identities: [identity])
let resolver = AgentHostResolver(store: store)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: host.id, invocationID: UUID(), interaction: .unavailable
)
let configuration = try await session.configuration()
#expect(configuration.username == "deploy")
#expect(configuration.credentials.isEmpty)
}
@Test
func cancellingTheOnlyConfigurationWaiterClearsItsAuthenticationCard() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(hosts: [host])
let conversation = ConversationModel()
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: WeakAgentUserInteraction(conversation).sshInteraction
)
let completion = AuthenticationCompletionProbe()
let attempt = Task {
defer { Task { await completion.finish() } }
return try await session.configurationAttempt()
}
try await waitForAuthentication(in: conversation)
attempt.cancel()
let completedBeforeAnswer = await waitForCompletion(completion)
let cardCleared = conversation.pendingSSHAuthentication == nil
if !cardCleared {
conversation.respondToSSHAuthentication(.cancelled)
}
#expect(completedBeforeAnswer)
#expect(cardCleared)
await #expect(throws: CancellationError.self) {
_ = try await attempt.value
}
}
@Test
func cancellingOneSharedConfigurationWaiterKeepsTheOtherOwnerRunning() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(hosts: [host])
let conversation = ConversationModel()
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: WeakAgentUserInteraction(conversation).sshInteraction
)
let firstCompletion = AuthenticationCompletionProbe()
let first = Task {
defer { Task { await firstCompletion.finish() } }
return try await session.configurationAttempt()
}
try await waitForAuthentication(in: conversation)
let second = Task { try await session.configurationAttempt() }
for _ in 0..<100 { await Task.yield() }
first.cancel()
let firstCompletedBeforeAnswer = await waitForCompletion(firstCompletion)
let request = try #require(conversation.pendingSSHAuthentication)
conversation.respondToSSHAuthentication(
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "one-use", save: false
)),
requestID: request.id
)
#expect(firstCompletedBeforeAnswer)
await #expect(throws: CancellationError.self) {
_ = try await first.value
}
let retained = try await second.value
#expect(retained.configuration.username == "deploy")
#expect(password(in: retained.configuration, matches: "one-use"))
#expect(conversation.pendingSSHAuthentication == nil)
}
@Test func concurrentConnectionChallengesAreNeverCoalescedWithSavedCredentials() async throws {
let identity = Identity(name: "OTP", username: "deploy")
let host = Host(name: "Server", host: "server.example", identityID: identity.id)
let store = HostLibraryModel(hosts: [host], identities: [identity])
let interaction = AuthenticationResponder { request in
let challenge = request.keyboardInteractive!
return .keyboardInteractive([challenge.id.uuidString])
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id, invocationID: UUID(),
interaction: interaction.sshInteraction
)
let attempt = try await session.configurationAttempt()
let responder = try #require(attempt.request.keyboardInteractive)
let first = SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: host.id, hostname: host.host, port: 22, username: "deploy",
round: 1, name: "PAM", instruction: "", prompts: [.init(text: "OTP", echo: false)])
var second = first
second.id = UUID()
second.connectionID = UUID()
let secondChallenge = second
async let firstAnswers = responder(first)
async let secondAnswers = responder(secondChallenge)
let answers = try await (firstAnswers, secondAnswers)
#expect(answers.0 == [first.id.uuidString])
#expect(answers.1 == [second.id.uuidString])
#expect(store.identities == [identity])
}
@Test
func listingIncludesNestedAndEmptyGroupsAndHostPaths() throws {
let root = HostGroup(name: "Production", sortIndex: 0)
let child = HostGroup(name: "Web", parentID: root.id, sortIndex: 0)
let empty = HostGroup(name: "Empty", sortIndex: 1)
let grouped = Host(name: "Web 01", host: "web.example", groupID: child.id)
let ungrouped = Host(name: "Loose", host: "loose.example")
let temporary = AgentHostTarget(
id: UUID(), displayName: "Pane", address: "pane.example", tags: [],
connectionState: "connected", isTemporary: true,
groupID: UUID(), groupPath: "must be cleared"
)
let store = HostLibraryModel(
hosts: [grouped, ungrouped], groups: [empty, child, root]
)
let resolver = AgentHostResolver(store: store, temporaryTargets: { [temporary] })
let listing = resolver.listing()
#expect(listing.groups.map(\.name) == ["Production", "Empty"])
#expect(listing.groups[0].children.map(\.name) == ["Web"])
#expect(listing.groups[1].children.isEmpty)
#expect(listing.hosts.first { $0.id == grouped.id }?.groupID == child.id)
#expect(
listing.hosts.first { $0.id == grouped.id }?.groupPath
== "Production / Web"
)
#expect(listing.hosts.first { $0.id == ungrouped.id }?.groupPath == nil)
#expect(listing.hosts.first { $0.id == temporary.id }?.groupID == nil)
#expect(listing.hosts.first { $0.id == temporary.id }?.groupPath == nil)
}
@Test
func inlineIdentityCanBeUsedOnceWithoutChangingTheVault() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")]
)
let interaction = AuthenticationResponder { request in
#expect(request.hostID == host.id)
return .submit(
AgentSSHAuthenticationAnswer(
identityName: "Deploy", username: "deploy", password: "secret",
save: false
))
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let configuration = try await session.configuration()
#expect(configuration.username == "deploy")
#expect(configuration.credentials.count == 1)
#expect(store.host(host.id)?.identityID == nil)
#expect(store.identities.isEmpty)
}
@Test
func inlineIdentityCanBeCreatedAndBound() async throws {
let host = Host(name: "Server", host: "server.example")
let secrets = MemoryHostSecretStore()
let store = HostLibraryModel(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")],
secrets: secrets
)
let interaction = AuthenticationResponder { _ in
.submit(
AgentSSHAuthenticationAnswer(
identityName: "Production Deploy", username: "deploy",
password: "secret", save: true
))
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let configuration = try await session.configuration()
let saved = try #require(store.identities.first)
defer { _ = store.delete(identityID: saved.id) }
#expect(saved.name == "Production Deploy")
#expect(saved.username == "deploy")
#expect(saved.passwordStorage == .iCloud)
#expect(store.host(host.id)?.identityID == saved.id)
#expect(try store.passwordCredential(for: saved) == "secret")
#expect(configuration.username == "deploy")
}
@Test(arguments: [false, true])
func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection(save: Bool) async throws {
let keychain = MemoryHostSecretStore()
let reference = "termind.key.\(UUID().uuidString)"
let key = SSHKeyRef(
label: "Protected", publicKey: "ssh-ed25519 test",
fingerprint: "SHA256:key", credentialRef: reference,
storage: .thisDevice, hasPassphrase: true
)
try keychain.setKeyCredential(
.software(SSHPrivateKeySecret(privateKey: "encrypted")),
for: reference, storage: .thisDevice
)
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(
hosts: [host], keys: [key],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")],
secrets: keychain
)
let interaction = AuthenticationResponder { request in
switch request.mode {
case .identity:
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "fallback-password",
keyID: key.id, save: false
))
case .keyboardInteractive, .securityKey, .tailscaleUsername:
return .cancelled
case .proxy:
return .cancelled
case .passphrase(let keyID):
#expect(keyID == key.id)
return .submit(
AgentSSHAuthenticationAnswer(
username: request.username, passphrase: "one-use",
keyID: key.id, save: save
))
}
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let configuration = try await session.configuration()
guard case .privateKey(_, _, let passphrase, _) = configuration.credentials.first else {
Issue.record("Expected a private-key credential")
return
}
#expect(passphrase?.matches("one-use") == true)
guard configuration.credentials.count == 2,
case .password(let password) = configuration.credentials[1]
else {
Issue.record("Expected the password from the Identity step to remain available")
return
}
#expect(password == "fallback-password")
guard
case .software(let stored)? = try keychain.keyCredential(
for: reference, storage: .thisDevice
)
else {
Issue.record("Expected the stored software key")
return
}
#expect(stored.privateKey == "encrypted")
#expect(stored.passphrase == (save ? "one-use" : nil))
#expect(store.requiresPassphraseEntry(for: key) == !save)
#expect(store.host(host.id)?.identityID == nil)
configuration.clearSecrets()
#expect(store.key(key.id)?.requiresPassphraseEntry == true)
}
@Test(.requiresVisibleWindow)
func savedIdentityWithProtectedKeyRequestsOnlyPassphraseExternally() async throws {
let keychain = MemoryHostSecretStore()
let reference = "termind.key.\(UUID().uuidString)"
let key = SSHKeyRef(
label: "Protected", publicKey: "ssh-ed25519 test",
fingerprint: "SHA256:key", credentialRef: reference,
storage: .thisDevice, hasPassphrase: true
)
try keychain.setKeyCredential(
.software(SSHPrivateKeySecret(privateKey: "encrypted")),
for: reference, storage: .thisDevice
)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(
name: "Server", host: "server.example", identityID: identity.id
)
let store = HostLibraryModel(
hosts: [host], keys: [key], identities: [identity],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")],
secrets: keychain
)
let coordinator = ExternalAuthCoordinator()
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(),
interaction: coordinator.handler(clientName: "Codex", runID: UUID())
.sshInteraction
)
let configuration = Task { try await session.configuration() }
for _ in 0..<100 {
if case .ssh? = coordinator.current?.request { break }
await Task.yield()
}
guard case .ssh(let request)? = coordinator.current?.request else {
Issue.record("Expected a Passphrase request")
return
}
guard case .passphrase(let requestedKeyID) = request.mode else {
Issue.record("Expected Passphrase without an Identity step")
return
}
#expect(requestedKeyID == key.id)
#expect(request.selectedIdentityID == identity.id)
#expect(request.username == identity.username)
#expect(request.selectedKeyID == key.id)
coordinator.respondSSH(
.submit(
AgentSSHAuthenticationAnswer(
identityID: request.selectedIdentityID,
username: request.username, passphrase: "one-use",
keyID: request.selectedKeyID, save: false
)))
let resolved = try await configuration.value
guard case .privateKey(_, _, let passphrase, _) = resolved.credentials.first else {
Issue.record("Expected a private-key credential")
return
}
#expect(passphrase?.matches("one-use") == true)
#expect(coordinator.pending.isEmpty)
}
@Test
func sftpCollectsJumpAndDestinationAuthenticationInRouteOrder() async throws {
let jump = Host(name: "Bastion", host: "jump.example")
var destination = Host(name: "Files", host: "files.example")
destination.jumpHostID = jump.id
let store = HostLibraryModel(
hosts: [destination, jump],
knownHosts: [
KnownHostEntry(hostname: jump.host, fingerprint: "SHA256:jump"),
KnownHostEntry(hostname: destination.host, fingerprint: "SHA256:files"),
]
)
let resolver = AgentHostResolver(store: store)
let backend = AuthenticationConnectionBackend()
let pool = authenticationPool(resolver: resolver, backend: backend)
let registry = AgentSSHAuthenticationRegistry(resolver: resolver)
var requestedHosts: [TermindDomain.Host.ID] = []
let interaction = AuthenticationResponder { request in
requestedHosts.append(request.hostID)
return .submit(
AgentSSHAuthenticationAnswer(
username: request.hostID == jump.id ? "jump-user" : "files-user",
password: request.hostID == jump.id ? "jump-secret" : "files-secret",
save: false
))
}
_ = try await registry.sftpChannel(
hostID: destination.id, invocationID: UUID(),
interaction: interaction.sshInteraction, pool: pool
)
#expect(requestedHosts == [jump.id, destination.id])
let configuration = try #require(await backend.sftpConfigurations.first)
let destinationPassword = try #require(await backend.observedDestinationPasswords.first)
let jumpPasswords = try #require(await backend.observedJumpPasswords.first)
#expect(configuration.username == "files-user")
#expect(destinationPassword == "files-secret")
#expect(configuration.jumps.count == 1)
#expect(configuration.jumps[0].id == jump.id)
#expect(configuration.jumps[0].username == "jump-user")
guard case .password(let jumpPassword) = configuration.jumps[0].credentials.first
else {
Issue.record("Expected a jump-host password")
return
}
#expect(jumpPasswords == ["jump-secret"])
#expect(jumpPassword.isConsumed)
}
@Test
func sftpToolCollectsMissingIdentityBeforeListing() async throws {
let host = Host(name: "Files", host: "files.example")
let store = HostLibraryModel(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")]
)
let resolver = AgentHostResolver(store: store)
let authentication = AgentSSHAuthenticationRegistry(resolver: resolver)
let backend = AuthenticationConnectionBackend()
let pool = authenticationPool(resolver: resolver, backend: backend)
let interaction = AuthenticationResponder { _ in
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
))
}
let tool = AnyAgentTool(
SFTPListTool(
authentication: authentication, connections: pool
))
let invocation = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "sftp-list", name: "sftp_list",
arguments: .object([
"host_id": .string(host.id.uuidString), "path": .string("/srv"),
])
)
)
let result = try await tool.execute(
invocation,
context: AgentToolExecutionContext(
runID: invocation.runID, userIntent: "list files",
secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(),
services: AgentToolServices()
.setting(interaction.sshInteraction, for: .sshInteraction),
preflightMetadata: [:], authorize: { _ in .allow }
)
)
#expect(!result.isError)
#expect(await backend.sftpOpens == 1)
let configuration = try #require(await backend.sftpConfigurations.first)
let observedPassword = try #require(await backend.observedDestinationPasswords.first)
#expect(configuration.username == "deploy")
guard case .password(let password) = configuration.credentials.first else {
Issue.record("Expected the one-use password in the SFTP configuration")
return
}
#expect(observedPassword == "secret")
#expect(password.isConsumed)
}
/// Concurrent SFTP-only and mixed exec/SFTP callers both join one pending
/// authentication and one primary connection.
@Test(arguments: [false, true])
func concurrentExecAndSFTPCallersShareOnePromptAndOneConnection(includeExec: Bool) async throws {
let setup = authenticationSetup()
let interaction = GatedAuthenticationResponder()
async let first =
includeExec
? setup.registry.connection(
hostID: setup.host.id, invocationID: UUID(),
interaction: interaction.sshInteraction, pool: setup.pool)
: setup.registry.sftpChannel(
hostID: setup.host.id, invocationID: UUID(),
interaction: interaction.sshInteraction, pool: setup.pool)
async let second = setup.registry.sftpChannel(
hostID: setup.host.id, invocationID: UUID(),
interaction: interaction.sshInteraction, pool: setup.pool
)
while await interaction.requestCount == 0 { await Task.yield() }
#expect(await interaction.requestCount == 1)
await interaction.answer(
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
)))
_ = try await (first, second)
#expect(await interaction.requestCount == 1)
#expect(await setup.backend.sftpOpens == 1)
if includeExec {
#expect(await setup.backend.execOpens == 1)
#expect(await setup.pool.openConnectionCount == 1)
}
}
@Test
func cancellingSharedAuthenticationCancelsEveryWaiterBeforeSFTPOpens() async {
let setup = authenticationSetup()
let interaction = GatedAuthenticationResponder()
let first = Task {
try await setup.registry.sftpChannel(
hostID: setup.host.id, invocationID: UUID(),
interaction: interaction.sshInteraction, pool: setup.pool
)
}
let second = Task {
try await setup.registry.sftpChannel(
hostID: setup.host.id, invocationID: UUID(),
interaction: interaction.sshInteraction, pool: setup.pool
)
}
while await interaction.requestCount == 0 { await Task.yield() }
await interaction.answer(.cancelled)
await #expect(throws: (any Error).self) { _ = try await first.value }
await #expect(throws: (any Error).self) { _ = try await second.value }
#expect(await interaction.requestCount == 1)
#expect(await setup.backend.sftpOpens == 0)
#expect(await setup.backend.sftpOperations == 0)
}
@Test
func rejectedSFTPCredentialPromptsAgainAndUsesTheNewSecret() async throws {
let setup = authenticationSetup(sftpAuthenticationFailures: 1)
var requests: [AgentSSHAuthenticationRequest] = []
let interaction = AuthenticationResponder { request in
requests.append(request)
let password = requests.count == 1 ? "old-secret" : "new-secret"
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: password, save: false
))
}
_ = try await setup.registry.sftpChannel(
hostID: setup.host.id, invocationID: UUID(),
interaction: interaction.sshInteraction, pool: setup.pool
)
#expect(requests.count == 2)
#expect(requests[0].failureMessage == nil)
#expect(requests[1].failureMessage?.contains("Rejected") == true)
#expect(requests[1].username == "deploy")
#expect(await setup.backend.execOpens == 2)
#expect(await setup.backend.sftpOpens == 1)
let configurations = await setup.backend.sftpConfigurations
#expect(await setup.backend.observedDestinationPasswords == ["old-secret", "new-secret"])
#expect(
configurations.allSatisfy { configuration in
configuration.credentials.allSatisfy { credential in
guard case .password(let password) = credential else { return true }
return password.isConsumed
}
})
}
@Test
func staleFailureCannotClearANewerAuthenticationGeneration() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")]
)
let interaction = GatedAuthenticationResponder()
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let firstTask = Task { try await session.configurationAttempt() }
while await interaction.requestCount == 0 { await Task.yield() }
await interaction.answer(
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "old-secret", save: false
)))
let first = try await firstTask.value
await session.retry(
after: .authenticationFailed("Rejected old credential"),
generation: first.generation
)
let secondTask = Task { try await session.configurationAttempt() }
while await interaction.requestCount < 2 { await Task.yield() }
await interaction.answer(
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "new-secret", save: false
)))
let second = try await secondTask.value
await session.retry(
after: .authenticationFailed("Late rejection from old connection"),
generation: first.generation
)
let retained = try await session.configurationAttempt()
#expect(retained.generation == second.generation)
#expect(password(in: retained.configuration, matches: "new-secret"))
#expect(await interaction.requestCount == 2)
}
@Test
func successfulAuthenticationConsumesAgentSecretsAndPromptsForAnotherConnection() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")]
)
var requests = 0
let interaction = AuthenticationResponder { _ in
requests += 1
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "one-use-\(requests)", save: false
))
}
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let first = try await session.configurationAttempt()
guard case .password(let firstPassword) = first.configuration.credentials.first else {
Issue.record("Expected the first one-shot password")
return
}
#expect(firstPassword.matches("one-use-1"))
await session.didAuthenticate(generation: first.generation)
#expect(firstPassword.isConsumed)
let second = try await session.configurationAttempt()
guard case .password(let secondPassword) = second.configuration.credentials.first else {
Issue.record("Expected a newly entered password")
return
}
#expect(requests == 2)
#expect(secondPassword.matches("one-use-2"))
#expect(!secondPassword.isConsumed)
}
@Test
func connectionLossEvictsSFTPWithoutReplayingTheOperation() async throws {
let setup = authenticationSetup(sftpOperationFailures: 1)
var requestCount = 0
let interaction = AuthenticationResponder { _ in
requestCount += 1
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret-\(requestCount)", save: false
))
}
let tool = AnyAgentTool(
SFTPListTool(
authentication: setup.registry, connections: setup.pool
))
func invocation(_ id: String) -> AgentToolInvocation {
AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: id, name: "sftp_list",
arguments: .object([
"host_id": .string(setup.host.id.uuidString),
"path": .string("/srv"),
])
)
)
}
func context(for invocation: AgentToolInvocation) -> AgentToolExecutionContext {
AgentToolExecutionContext(
runID: invocation.runID, userIntent: "list files",
secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(),
services: AgentToolServices()
.setting(interaction.sshInteraction, for: .sshInteraction),
preflightMetadata: [:], authorize: { _ in .allow }
)
}
let first = invocation("first")
await #expect(throws: (any Error).self) {
_ = try await tool.execute(first, context: context(for: first))
}
#expect(await setup.backend.sftpOperations == 1)
let remainedConnected = await setup.pool.isConnected(hostID: setup.host.id)
#expect(!remainedConnected)
let second = invocation("second")
let result = try await tool.execute(second, context: context(for: second))
#expect(!result.isError)
#expect(await setup.backend.sftpOperations == 2)
#expect(await setup.backend.sftpOpens == 2)
#expect(requestCount == 2)
}
@Test
func firstUseHostKeyCanContinueOnceWithoutPersisting() async throws {
let host = Host(name: "Server", host: "server.example")
let observation = SSHHostKeyObservation(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:new"
)
let store = HostLibraryModel(hosts: [host])
var requests: [AgentSSHHostKeyRequest] = []
let interaction = AuthenticationResponder(
answer: { _ in
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
))
},
hostKeyAnswer: { request in
requests.append(request)
return .continueOnce
}
)
let resolver = AgentHostResolver(store: store)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let attempt = try await session.configurationAttempt()
let authorizer = try #require(attempt.request.authorizeHostKey)
let decision = try await authorizer(
SSHHostKeyChallenge(
hostID: host.id, observation: observation,
expectedHostKeys: attempt.configuration.expectedHostKeys
)
)
#expect(decision == .accept)
#expect(requests.count == 1)
#expect(requests[0].prompt.kind == .firstUse)
#expect(requests[0].allowsPersistentTrust)
#expect(attempt.configuration.expectedHostKeys.isEmpty)
#expect(store.knownHosts(for: host).isEmpty)
}
@Test
func routeCollectsLocalCredentialsBeforeStartingEachHostsHandshake() async throws {
let jump = Host(name: "Bastion", host: "jump.example")
var destination = Host(name: "Files", host: "files.example")
destination.jumpHostID = jump.id
let store = HostLibraryModel(hosts: [jump, destination])
var events: [String] = []
let interaction = AuthenticationResponder(
answer: { request in
events.append("authenticate:\(request.hostID)")
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
))
},
hostKeyAnswer: { request in
events.append("verify:\(request.hostID)")
return .continueOnce
}
)
let resolver = AgentHostResolver(store: store)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: destination.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let attempt = try await session.configurationAttempt()
let authorizer = try #require(attempt.request.authorizeHostKey)
for endpoint in [jump, destination] {
let decision = try await authorizer(
SSHHostKeyChallenge(
hostID: endpoint.id,
observation: SSHHostKeyObservation(
hostname: endpoint.host, port: endpoint.port,
algorithm: .ed25519,
fingerprint: "SHA256:\(endpoint.id)"
),
expectedHostKeys: []
)
)
#expect(decision == .accept)
}
#expect(
events == [
"authenticate:\(jump.id)",
"authenticate:\(destination.id)",
"verify:\(jump.id)",
"verify:\(destination.id)",
])
}
@Test
func directPassphraseIsPresentedBeforeAConnectionRequestIsReturned() async throws {
let keychain = MemoryHostSecretStore()
let reference = "termind.key.\(UUID().uuidString)"
let key = SSHKeyRef(
label: "Protected", publicKey: "ssh-ed25519 test",
fingerprint: "SHA256:key", credentialRef: reference,
storage: .thisDevice, hasPassphrase: true
)
try keychain.setKeyCredential(
.software(SSHPrivateKeySecret(privateKey: "encrypted")),
for: reference, storage: .thisDevice
)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "N1", host: "n1.example", identityID: identity.id)
let store = HostLibraryModel(
hosts: [host], keys: [key], identities: [identity], secrets: keychain
)
let probe = PassphraseRequestProbe()
let interaction = AuthenticationResponder { request in
guard case .passphrase(let keyID) = request.mode else {
Issue.record("Expected Passphrase before opening the connection")
return .cancelled
}
#expect(keyID == key.id)
probe.wasRequested = true
return .submit(
AgentSSHAuthenticationAnswer(
identityID: identity.id, username: identity.username,
passphrase: "one-use", keyID: key.id, save: false
))
}
let resolver = AgentHostResolver(store: store)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let attempt = try await session.configurationAttempt()
#expect(probe.wasRequested)
#expect(attempt.request.authorizeHostKey != nil)
}
@Test
func changedHostKeyCanBeTrustedWithoutRemovingTheOldKey() async throws {
let host = Host(name: "Server", host: "server.example")
let old = KnownHostEntry(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:old"
)
let observation = SSHHostKeyObservation(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:new"
)
let store = HostLibraryModel(hosts: [host], knownHosts: [old])
let interaction = AuthenticationResponder(
answer: { _ in
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
))
},
hostKeyAnswer: { request in
#expect(request.prompt.kind == .additionalKey(trustedKeyCount: 1))
return .trustAndContinue
}
)
let resolver = AgentHostResolver(store: store)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let attempt = try await session.configurationAttempt()
let authorizer = try #require(attempt.request.authorizeHostKey)
let decision = try await authorizer(
SSHHostKeyChallenge(
hostID: host.id, observation: observation,
expectedHostKeys: attempt.configuration.expectedHostKeys
)
)
#expect(decision == .accept)
#expect(attempt.configuration.expectedHostKeys == [old.identity])
#expect(Set(store.knownHosts(for: host).map(\.identity)) == [old.identity, observation.identity])
}
@Test
func denyingHostKeyRejectsTheInFlightConnection() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(hosts: [host])
let resolver = AgentHostResolver(store: store)
let interaction = AuthenticationResponder(
answer: { _ in
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
))
},
hostKeyAnswer: { _ in .deny }
)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let attempt = try await session.configurationAttempt()
let authorizer = try #require(attempt.request.authorizeHostKey)
let observation = SSHHostKeyObservation(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:denied"
)
let decision = try await authorizer(
SSHHostKeyChallenge(
hostID: host.id, observation: observation,
expectedHostKeys: []
)
)
#expect(decision == .reject)
#expect(store.knownHosts(for: host).isEmpty)
}
@Test
func concurrentConfigurationAttemptsShareOneHostKeyPrompt() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(hosts: [host])
let interaction = GatedHostKeyResponder()
let resolver = AgentHostResolver(store: store)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: host.id,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let firstAttempt = try await session.configurationAttempt()
let secondAttempt = try await session.configurationAttempt()
let firstAuthorizer = try #require(firstAttempt.request.authorizeHostKey)
let secondAuthorizer = try #require(secondAttempt.request.authorizeHostKey)
let observation = SSHHostKeyObservation(
hostname: host.host, port: host.port, algorithm: .ed25519,
fingerprint: "SHA256:shared"
)
let challenge = SSHHostKeyChallenge(
hostID: host.id, observation: observation,
expectedHostKeys: []
)
async let first = firstAuthorizer(challenge)
async let second = secondAuthorizer(challenge)
while await interaction.hostKeyRequestCount == 0 { await Task.yield() }
#expect(await interaction.hostKeyRequestCount == 1)
await interaction.answerHostKey(.continueOnce)
let decisions = try await [first, second]
#expect(decisions.allSatisfy { $0 == .accept })
#expect(await interaction.authenticationRequestCount == 1)
#expect(await interaction.hostKeyRequestCount == 1)
}
@Test
func temporaryTargetCanApproveChangedKeyOnlyForReplacementConnection() async throws {
let hostID = UUID()
let old = SSHHostKeyIdentity(
algorithm: .ed25519, fingerprint: "SHA256:old"
)
let observation = SSHHostKeyObservation(
hostname: "quick.example", port: 22, algorithm: .ed25519,
fingerprint: "SHA256:new"
)
let target = AgentHostTarget(
id: hostID, displayName: "Quick Connect", address: "quick.example",
tags: [], connectionState: "connected", isTemporary: true,
groupID: nil, groupPath: nil
)
let configuration = SSHConnectionConfiguration(
hostID: hostID, host: observation.hostname, port: observation.port,
username: "deploy", credentials: [.password("secret")],
expectedHostKeys: [old], proxy: nil
)
var requests: [AgentSSHHostKeyRequest] = []
let interaction = AuthenticationResponder(
answer: { _ in .cancelled },
hostKeyAnswer: { request in
requests.append(request)
return .continueOnce
}
)
let resolver = AgentHostResolver(
store: HostLibraryModel(), temporaryTargets: { [target] },
temporaryConfiguration: { id in id == hostID ? configuration : nil }
)
let session = AgentSSHAuthenticationContext(
resolver: resolver, hostID: hostID,
invocationID: UUID(), interaction: interaction.sshInteraction
)
let initial = try await session.configurationAttempt()
let authorizer = try #require(initial.request.authorizeHostKey)
let challenge = SSHHostKeyChallenge(
hostID: hostID, observation: observation,
expectedHostKeys: [old]
)
let decision = try await authorizer(challenge)
let repeatedDecision = try await authorizer(challenge)
#expect(decision == .accept)
#expect(repeatedDecision == .accept)
#expect(requests.count == 1)
#expect(!requests[0].allowsPersistentTrust)
#expect(initial.configuration.expectedHostKeys == [old])
}
@Test
func scratchPullAndPushUseAuthenticationAndTheSharedSFTPPool() async throws {
let source = Data("server configuration\n".utf8)
let setup = authenticationSetup(remoteFiles: ["/etc/app.conf": source])
let base = FileManager.default.temporaryDirectory
.appending(path: "termind-agent-auth-scratch/\(UUID().uuidString)")
let workspace = AgentScratchWorkspace(conversationID: UUID(), base: base)
let pull = ScratchPullTool(
workspace: workspace, pool: setup.pool, authentication: setup.registry
)
let push = ScratchPushTool(
workspace: workspace, pool: setup.pool, authentication: setup.registry
)
var requestCount = 0
let interaction = AuthenticationResponder { _ in
requestCount += 1
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret-\(requestCount)", save: false
))
}
func context(
_ invocation: AgentToolInvocation,
preflight: [String: AgentJSONValue] = [:]
) -> AgentToolExecutionContext {
AgentToolExecutionContext(
runID: invocation.runID, userIntent: "transfer configuration",
secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(),
services: AgentToolServices()
.setting(interaction.sshInteraction, for: .sshInteraction),
preflightMetadata: preflight, authorize: { _ in .allow }
)
}
let pullInvocation = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "pull", name: "scratch_pull",
arguments: .object([
"host_id": .string(setup.host.id.uuidString),
"remote_path": .string("/etc/app.conf"),
"scratch_path": .string("app.conf"),
])
)
)
_ = try await pull.execute(pullInvocation, context: context(pullInvocation))
#expect(try await workspace.data(at: "app.conf").data == source)
#expect(requestCount == 1)
// A live SFTP channel is reused without another card. Once evicted, the
// push is a new authentication wave and must not inherit Custom secrets.
_ = await setup.pool.disconnect(hostID: setup.host.id)
let pushInvocation = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "push", name: "scratch_push",
arguments: .object([
"host_id": .string(setup.host.id.uuidString),
"scratch_path": .string("app.conf"),
"remote_path": .string("/etc/copy.conf"),
])
)
)
let preflight = try await push.preflight(pushInvocation)
_ = try await push.execute(
pushInvocation,
context: context(pushInvocation, preflight: preflight.executionMetadata)
)
#expect(await setup.backend.remoteFile(at: "/etc/copy.conf") == source)
#expect(requestCount == 2)
#expect(await setup.backend.execOpens == 2)
#expect(await setup.backend.sftpOpens == 2)
}
@Test
func pooledSFTPRetriesCredentialsOnlyBeforeTheOperation() async throws {
let setup = authenticationSetup(sftpAuthenticationFailures: 1)
var prompts = 0
let interaction = AuthenticationResponder { _ in
prompts += 1
return .submit(.init(username: "deploy", password: "secret-\(prompts)", save: false))
}
let files = try await setup.registry.withSFTP(
hostID: setup.host.id, invocationID: UUID(), interaction: interaction.sshInteraction,
pool: setup.pool, cancellation: TransferCancellationToken()
) { client in try await client.listDirectory(at: "/") }
#expect(files.isEmpty)
#expect(prompts == 2)
#expect(await setup.backend.execOpens == 2)
#expect(await setup.backend.sftpOpens == 1)
#expect(await setup.backend.sftpOperations == 1)
#expect(await setup.pool.openConnectionCount == 1)
}
@Test
func pooledSFTPOperationFailureNeverRetries() async throws {
let setup = authenticationSetup(sftpOperationFailures: 1)
let interaction = AuthenticationResponder { _ in
.submit(.init(username: "deploy", password: "secret", save: false))
}
await #expect(throws: SFTPError.self) {
_ = try await setup.registry.withSFTP(
hostID: setup.host.id, invocationID: UUID(), interaction: interaction.sshInteraction,
pool: setup.pool, cancellation: TransferCancellationToken()
) { client in try await client.listDirectory(at: "/") }
}
#expect(await setup.backend.sftpOpens == 1)
#expect(await setup.backend.sftpOperations == 1)
#expect(await setup.pool.openConnectionCount == 0)
}
@Test
func sameHostLeasesFallBackToSiblingChannelsWithOneAuthenticationContext() async throws {
let setup = authenticationSetup()
var prompts = 0
let interaction = AuthenticationResponder { _ in
prompts += 1
return .submit(.init(username: "deploy", password: "secret", save: false))
}
let registry = setup.registry
let pool = setup.pool
let hostID = setup.host.id
let cancellation = TransferCancellationToken()
_ = try await registry.withSFTP(
hostID: hostID, invocationID: UUID(), interaction: interaction.sshInteraction,
pool: pool, cancellation: cancellation
) { first in
try await registry.withSFTP(
hostID: hostID, invocationID: UUID(), interaction: interaction.sshInteraction,
pool: pool, cancellation: cancellation
) { second in
_ = try await first.listDirectory(at: "/")
return try await second.listDirectory(at: "/")
}
}
#expect(prompts == 1)
#expect(await setup.backend.sftpOpens == 1)
#expect(await setup.backend.sftpSiblingChannels == 1)
#expect(await setup.pool.openConnectionCount == 1)
}
private func authenticationSetup(
sftpAuthenticationFailures: Int = 0,
sftpOperationFailures: Int = 0,
remoteFiles: [String: Data] = [:]
) -> (
host: TermindDomain.Host, registry: AgentSSHAuthenticationRegistry,
pool: AgentConnectionPool, backend: AuthenticationConnectionBackend,
resolver: AgentHostResolver
) {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")]
)
let resolver = AgentHostResolver(store: store)
let backend = AuthenticationConnectionBackend(
sftpAuthenticationFailures: sftpAuthenticationFailures,
sftpOperationFailures: sftpOperationFailures,
remoteFiles: remoteFiles
)
return (
host, AgentSSHAuthenticationRegistry(resolver: resolver),
authenticationPool(resolver: resolver, backend: backend), backend, resolver
)
}
private func authenticationPool(
resolver: AgentHostResolver, backend: AuthenticationConnectionBackend
) -> AgentConnectionPool {
AgentConnectionPool(
configuration: { try await resolver.configuration(for: $0) },
routeFingerprint: { try await resolver.routeFingerprint(for: $0) },
openConnection: { try await backend.openConnection(configuration: $0.configuration) },
openSFTP: { try await backend.openSFTP(configuration: $0.configuration) },
sweepInterval: nil
)
}
private func password(
in configuration: SSHConnectionConfiguration, matches expected: String
) -> Bool {
for credential in configuration.credentials {
if case .password(let password) = credential { return password.matches(expected) }
}
return false
}
private func waitForAuthentication(in conversation: ConversationModel) async throws {
for _ in 0..<1_000 {
if conversation.pendingSSHAuthentication != nil { return }
await Task.yield()
}
throw AuthenticationCancellationTestError.timedOut
}
private func waitForCompletion(_ completion: AuthenticationCompletionProbe) async -> Bool {
for _ in 0..<1_000 {
if await completion.isFinished { return true }
await Task.yield()
}
return await completion.isFinished
}
}
private enum AuthenticationCancellationTestError: Error { case timedOut }
private actor AuthenticationCompletionProbe {
private(set) var isFinished = false
func finish() { isFinished = true }
}
private final class MemoryHostSecretStore: SecretStoring, @unchecked Sendable {
private struct PasswordKey: Hashable {
var account: String
var storage: PasswordStorage
}
private struct KeyKey: Hashable {
var account: String
var storage: KeyStorage
}
private var passwords: [PasswordKey: String] = [:]
private var keys: [KeyKey: StoredSSHKeyCredential] = [:]
func string(for account: String, storage: PasswordStorage) throws -> String? {
passwords[PasswordKey(account: account, storage: storage)]
}
func setString(
_ value: String?, for account: String, storage: PasswordStorage
) throws {
passwords[PasswordKey(account: account, storage: storage)] = value
}
func keyCredential(
for account: String, storage: KeyStorage
) throws -> StoredSSHKeyCredential? {
keys[KeyKey(account: account, storage: storage)]
}
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws {
keys[KeyKey(account: account, storage: storage)] = value
}
func removePassword(_ account: String, storage: PasswordStorage) throws {
passwords[PasswordKey(account: account, storage: storage)] = nil
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
keys[KeyKey(account: account, storage: storage)] = nil
}
}
@MainActor
private final class PassphraseRequestProbe {
var wasRequested = false
}
private final class AuthenticationResponder: @unchecked Sendable {
private let answer: @MainActor (AgentSSHAuthenticationRequest) -> AgentSSHAuthenticationResponse
private let hostKeyAnswer: @MainActor (AgentSSHHostKeyRequest) -> AgentSSHHostKeyResponse
init(
answer: @escaping @MainActor (AgentSSHAuthenticationRequest) -> AgentSSHAuthenticationResponse,
hostKeyAnswer: @escaping @MainActor (AgentSSHHostKeyRequest) -> AgentSSHHostKeyResponse = { _ in .deny }
) {
self.answer = answer
self.hostKeyAnswer = hostKeyAnswer
}
var sshInteraction: AgentSSHInteraction {
AgentSSHInteraction(
authenticate: { [answer] in await answer($0) },
confirmHostKey: { [hostKeyAnswer] in await hostKeyAnswer($0) }
)
}
}
private actor GatedAuthenticationResponder {
private(set) var requestCount = 0
private var waiters: [CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = []
nonisolated var sshInteraction: AgentSSHInteraction {
AgentSSHInteraction(
authenticate: { [self] _ in await authenticate() },
confirmHostKey: { _ in .deny }
)
}
private func authenticate() async -> AgentSSHAuthenticationResponse {
requestCount += 1
return await withCheckedContinuation { waiters.append($0) }
}
func answer(_ response: AgentSSHAuthenticationResponse) {
let answering = waiters
waiters.removeAll()
for waiter in answering { waiter.resume(returning: response) }
}
}
private actor GatedHostKeyResponder {
private(set) var authenticationRequestCount = 0
private(set) var hostKeyRequestCount = 0
private var hostKeyWaiters: [CheckedContinuation<AgentSSHHostKeyResponse, Never>] = []
nonisolated var sshInteraction: AgentSSHInteraction {
AgentSSHInteraction(
authenticate: { [self] _ in await authenticate() },
confirmHostKey: { [self] _ in await confirmHostKey() }
)
}
private func authenticate() -> AgentSSHAuthenticationResponse {
authenticationRequestCount += 1
return .submit(
AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
))
}
private func confirmHostKey() async -> AgentSSHHostKeyResponse {
hostKeyRequestCount += 1
return await withCheckedContinuation { hostKeyWaiters.append($0) }
}
func answerHostKey(_ response: AgentSSHHostKeyResponse) {
let answering = hostKeyWaiters
hostKeyWaiters.removeAll()
for waiter in answering { waiter.resume(returning: response) }
}
}
private actor AuthenticationConnectionBackend {
private(set) var execOpens = 0
private(set) var sftpOpens = 0
private(set) var auxiliarySFTPOpens = 0
private(set) var sftpSiblingChannels = 0
private(set) var sftpOperations = 0
private(set) var sftpConfigurations: [SSHConnectionConfiguration] = []
private(set) var observedDestinationPasswords: [String?] = []
private(set) var observedJumpPasswords: [[String?]] = []
private var sftpAuthenticationFailures: Int
private var sftpOperationFailures: Int
private var remoteFiles: [String: Data]
init(
sftpAuthenticationFailures: Int = 0,
sftpOperationFailures: Int = 0,
remoteFiles: [String: Data] = [:]
) {
self.sftpAuthenticationFailures = sftpAuthenticationFailures
self.sftpOperationFailures = sftpOperationFailures
self.remoteFiles = remoteFiles
}
func openConnection(configuration: SSHConnectionConfiguration) throws -> AuthenticationSSHConnection {
execOpens += 1
observedDestinationPasswords.append(Self.passwordLabel(in: configuration.credentials))
observedJumpPasswords.append(
configuration.jumps.map { Self.passwordLabel(in: $0.credentials) }
)
sftpConfigurations.append(configuration)
if sftpAuthenticationFailures > 0 {
sftpAuthenticationFailures -= 1
throw TermindSSHError.authenticationFailed("Rejected credential")
}
return AuthenticationSSHConnection(backend: self)
}
private static func passwordLabel(in credentials: [SSHCredentials]) -> String? {
guard
case .password(let password)? = credentials.first(where: {
if case .password = $0 { return true }
return false
})
else { return nil }
return ["files-secret", "jump-secret", "secret", "old-secret", "new-secret", "fallback-password"]
.first(where: password.matches)
}
func openSFTP(
configuration: SSHConnectionConfiguration
) throws -> AuthenticationSFTPClient {
auxiliarySFTPOpens += 1
return AuthenticationSFTPClient(backend: self)
}
func openPrimarySFTP() -> AuthenticationSFTPClient {
sftpOpens += 1
return AuthenticationSFTPClient(backend: self)
}
func openSFTPSibling() -> AuthenticationSFTPClient {
sftpSiblingChannels += 1
return AuthenticationSFTPClient(backend: self)
}
func listDirectory(at path: String) throws -> [RemoteFile] {
sftpOperations += 1
if sftpOperationFailures > 0 {
sftpOperationFailures -= 1
throw SFTPError.connectionLost("Connection lost during list")
}
return []
}
func readFile(at path: String) throws -> Data {
sftpOperations += 1
guard let data = remoteFiles[path] else {
throw SFTPError.operation("Missing test file")
}
return data
}
func writeFile(_ data: Data, at path: String) {
sftpOperations += 1
remoteFiles[path] = data
}
func remoteFile(at path: String) -> Data? { remoteFiles[path] }
}
private actor AuthenticationSSHConnection: SSHConnectionProtocol {
private let backend: AuthenticationConnectionBackend
init(backend: AuthenticationConnectionBackend) { self.backend = backend }
func connect(using configuration: SSHConnectionConfiguration) async throws {}
func execute(
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
SSHExecResult(
stdout: "", stderr: "", exitCode: 0, signal: nil,
stdoutTruncated: false, stderrTruncated: false
)
}
func openSFTPChannel() async throws -> any SFTPChannelProtocol {
await backend.openPrimarySFTP()
}
func disconnect() async {}
}
private actor AuthenticationSFTPClient: SFTPClientProtocol {
private let backend: AuthenticationConnectionBackend
init(backend: AuthenticationConnectionBackend) { self.backend = backend }
func openSiblingChannel() async throws -> any SFTPChannelProtocol {
await backend.openSFTPSibling()
}
func connect(
using configuration: SSHConnectionConfiguration,
progress: SSHRouteProgressHandler?
) async throws {}
func disconnect() async {}
func homeDirectory() async throws -> String { "/home/deploy" }
func listDirectory(at path: String) async throws -> [RemoteFile] {
try await backend.listDirectory(at: path)
}
func attributes(at path: String) async throws -> RemoteFile? { nil }
func readFile(at path: String, maximumBytes: Int) async throws -> Data {
let data = try await backend.readFile(at: path)
guard data.count <= maximumBytes else {
throw SFTPError.operation("Test file exceeds limit")
}
return data
}
func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws {
await backend.writeFile(data, at: path)
}
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws {}
func rename(
from source: String, to destination: String, overwrite: Bool
) async throws {}
func removeFile(at path: String) async throws {}
func removeDirectory(at path: String) async throws {}
func uploadFile(
from localURL: URL, to remotePath: String, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
func downloadFile(
from remotePath: String, to localURL: URL, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
}