Keep additional SSH credentials reference-only
This commit is contained in:
1 parent
f09c57e332
commit
5f519eaa6c
28 files changed
+808
-183
No files matched your search
@@ -35,9 +35,11 @@ struct SSHInteractionPolicyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func additionalConnectionRefreshesDurableTrustWithoutReusingOnceOnlyApproval() {
|
||||
@Test func additionalSnapshotRetainsRouteAndIDsWithoutCredentialMaterial() {
|
||||
let host = Host(name: "Server", host: "example.invalid")
|
||||
let jump = Host(name: "Jump", host: "jump.invalid")
|
||||
let identityID = UUID()
|
||||
let keyID = UUID()
|
||||
let trusted = KnownHostEntry(hostname: host.host, port: 22, algorithm: .ed25519, fingerprint: "saved")
|
||||
let store = HostLibraryModel(hosts: [host, jump], knownHosts: [trusted])
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
@@ -47,17 +49,180 @@ struct SSHInteractionPolicyTests {
|
||||
SSHRouteHopConfiguration(
|
||||
id: jump.id, name: jump.name, host: jump.host, port: 22, username: "jump-user", credentials: [],
|
||||
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: "once-only")])
|
||||
])
|
||||
let additional = store.configurationForAdditionalConnection(from: configuration)
|
||||
#expect(additional.expectedHostKeys == [trusted.identity])
|
||||
],
|
||||
authenticationReference: SSHAuthenticationReference(
|
||||
identityID: identityID, fallbackKeyID: keyID,
|
||||
username: "temporary-user"
|
||||
))
|
||||
var withReference = configuration
|
||||
withReference.jumps[0].authenticationReference = SSHAuthenticationReference(
|
||||
fallbackKeyID: keyID, username: "jump-user", permitsNone: false
|
||||
)
|
||||
|
||||
let additional = store.additionalConnectionSnapshot(from: withReference)
|
||||
#expect(additional.target.expectedHostKeys == [trusted.identity])
|
||||
#expect(additional.jumps[0].expectedHostKeys.isEmpty)
|
||||
#expect(additional.host == host.host)
|
||||
#expect(additional.username == "temporary-user")
|
||||
#expect(additional.jumps[0].username == "jump-user")
|
||||
if case .password(let value) = additional.credentials.first {
|
||||
#expect(value == "temporary-password")
|
||||
} else {
|
||||
Issue.record("The live credential snapshot must be retained")
|
||||
#expect(additional.target.host == host.host)
|
||||
#expect(additional.target.authentication.username == "temporary-user")
|
||||
#expect(additional.target.authentication.identityID == identityID)
|
||||
#expect(additional.target.authentication.fallbackKeyID == keyID)
|
||||
#expect(additional.jumps[0].authentication.username == "jump-user")
|
||||
#expect(additional.jumps[0].authentication.fallbackKeyID == keyID)
|
||||
}
|
||||
|
||||
@Test func forbiddenPolicyReplacesProtectedKeysButKeepsPasswordFallback() {
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
hostID: UUID(), host: "example.invalid", port: 22, username: "user",
|
||||
credentials: [
|
||||
.secureEnclave(
|
||||
credential: SecureEnclaveKeyCredential(dataRepresentation: Data("handle".utf8)),
|
||||
publicKey: "public", requiresUserPresence: true
|
||||
),
|
||||
.privateKey(
|
||||
privateKey: "encrypted", publicKey: "public", passphrase: "one-time",
|
||||
requiresPassphrase: true
|
||||
),
|
||||
.password("saved-password"),
|
||||
], expectedHostKeys: [])
|
||||
|
||||
let request = SSHConnectionRequest(
|
||||
configuration: configuration, interactionPolicy: .forbidden
|
||||
).enforcingInteractionPolicy()
|
||||
#expect(request.configuration.credentials.count == 3)
|
||||
guard case .interactionRequired = request.configuration.credentials[0] else {
|
||||
Issue.record("Touch ID key must become an interaction marker")
|
||||
return
|
||||
}
|
||||
guard case .interactionRequired = request.configuration.credentials[1] else {
|
||||
Issue.record("Passphrase key must become an interaction marker")
|
||||
return
|
||||
}
|
||||
guard case .password(let password) = request.configuration.credentials[2] else {
|
||||
Issue.record("Password fallback must remain available")
|
||||
return
|
||||
}
|
||||
#expect(password == "saved-password")
|
||||
}
|
||||
|
||||
@Test func additionalResolutionUsesCurrentIdentityAndSkipsProtectedKeyReads() throws {
|
||||
let secrets = CountingSecretStore()
|
||||
let protectedKey = SSHKeyRef(
|
||||
label: "Touch ID", publicKey: "ecdsa-sha2-nistp256 public",
|
||||
credentialRef: "key.touch-id", storage: .secureEnclave,
|
||||
requiresUserPresence: true
|
||||
)
|
||||
let fallbackKey = SSHKeyRef(
|
||||
label: "Fallback", publicKey: "ssh-ed25519 fallback",
|
||||
credentialRef: "key.fallback"
|
||||
)
|
||||
let identity = Identity(
|
||||
name: "Current", username: "current-user",
|
||||
credentialRef: "password.current", keyID: protectedKey.id
|
||||
)
|
||||
try secrets.setString("current-password", for: identity.credentialRef, storage: identity.passwordStorage)
|
||||
let store = HostLibraryModel(
|
||||
keys: [protectedKey, fallbackKey], identities: [identity], secrets: secrets
|
||||
)
|
||||
let node = SSHAdditionalConnectionSnapshot.Node(
|
||||
id: UUID(), name: "Server", host: "example.invalid", port: 22,
|
||||
authentication: SSHAuthenticationReference(
|
||||
identityID: identity.id, fallbackKeyID: fallbackKey.id,
|
||||
username: "stale-user"
|
||||
),
|
||||
expectedHostKeys: [], proxy: nil
|
||||
)
|
||||
|
||||
let request = try store.additionalConnectionRequest(
|
||||
from: SSHAdditionalConnectionSnapshot(target: node)
|
||||
)
|
||||
#expect(request.configuration.username == "current-user")
|
||||
#expect(secrets.keyReads == 0)
|
||||
#expect(secrets.stringReads == 1)
|
||||
guard case .interactionRequired = request.configuration.credentials[0] else {
|
||||
Issue.record("The current Identity key must take priority and remain unread")
|
||||
return
|
||||
}
|
||||
guard case .password(let password) = request.configuration.credentials[1] else {
|
||||
Issue.record("The current Identity password must remain available")
|
||||
return
|
||||
}
|
||||
#expect(password == "current-password")
|
||||
}
|
||||
|
||||
@Test func additionalResolutionSkipsPassphraseKeyAndUsesIdentityPassword() throws {
|
||||
let secrets = CountingSecretStore()
|
||||
let protectedKey = SSHKeyRef(
|
||||
label: "Encrypted", publicKey: "ssh-ed25519 public",
|
||||
credentialRef: "key.encrypted", hasPassphrase: true
|
||||
)
|
||||
let identity = Identity(
|
||||
name: "Deploy", username: "deploy",
|
||||
credentialRef: "password.deploy", keyID: protectedKey.id
|
||||
)
|
||||
try secrets.setString("saved-password", for: identity.credentialRef, storage: identity.passwordStorage)
|
||||
let store = HostLibraryModel(
|
||||
keys: [protectedKey], identities: [identity], secrets: secrets
|
||||
)
|
||||
let node = SSHAdditionalConnectionSnapshot.Node(
|
||||
id: UUID(), name: "Server", host: "example.invalid", port: 22,
|
||||
authentication: SSHAuthenticationReference(
|
||||
identityID: identity.id, username: "stale"
|
||||
),
|
||||
expectedHostKeys: [], proxy: nil
|
||||
)
|
||||
|
||||
let request = try store.additionalConnectionRequest(
|
||||
from: SSHAdditionalConnectionSnapshot(target: node)
|
||||
)
|
||||
#expect(secrets.keyReads == 0)
|
||||
guard case .interactionRequired = request.configuration.credentials[0] else {
|
||||
Issue.record("Passphrase key must remain unread")
|
||||
return
|
||||
}
|
||||
guard case .password(let password) = request.configuration.credentials[1] else {
|
||||
Issue.record("Identity password must remain available")
|
||||
return
|
||||
}
|
||||
#expect(password == "saved-password")
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated final class CountingSecretStore: SecretStoring, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var strings: [String: String] = [:]
|
||||
private var keys: [String: StoredSSHKeyCredential] = [:]
|
||||
private(set) var stringReads = 0
|
||||
private(set) var keyReads = 0
|
||||
|
||||
func string(for account: String, storage: PasswordStorage) throws -> String? {
|
||||
lock.withLock {
|
||||
stringReads += 1
|
||||
return strings[account]
|
||||
}
|
||||
}
|
||||
|
||||
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
|
||||
lock.withLock { strings[account] = value }
|
||||
}
|
||||
|
||||
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
|
||||
lock.withLock {
|
||||
keyReads += 1
|
||||
return keys[account]
|
||||
}
|
||||
}
|
||||
|
||||
func setKeyCredential(
|
||||
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
|
||||
) throws {
|
||||
lock.withLock { keys[account] = value }
|
||||
}
|
||||
|
||||
func removePassword(_ account: String, storage: PasswordStorage) throws {
|
||||
lock.withLock { strings[account] = nil }
|
||||
}
|
||||
|
||||
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
|
||||
lock.withLock { keys[account] = nil }
|
||||
}
|
||||
}
|
||||
@@ -104,22 +104,159 @@ extension RemoteSessionHostStore {
|
||||
return updated
|
||||
}
|
||||
|
||||
/// Refresh only durable trust after a successful handshake. The connection may
|
||||
/// have prompted before its configuration was captured; once-only approval is
|
||||
/// deliberately not promoted into permission for additional connections.
|
||||
public func configurationForAdditionalConnection(
|
||||
/// Captures the authenticated route without retaining password, key, or
|
||||
/// passphrase material. Once-only trust is deliberately not promoted into
|
||||
/// permission for additional connections.
|
||||
public func additionalConnectionSnapshot(
|
||||
from configuration: SSHConnectionConfiguration
|
||||
) -> SSHConnectionConfiguration {
|
||||
) -> SSHAdditionalConnectionSnapshot {
|
||||
func keys(id: UUID, hostname: String, port: Int) -> Set<SSHHostKeyIdentity> {
|
||||
Set(knownHosts(for: Host(id: id, name: hostname, host: hostname, port: port)).map(\.identity))
|
||||
}
|
||||
var result = configuration
|
||||
result.expectedHostKeys = keys(id: result.hostID, hostname: result.host, port: result.port)
|
||||
for index in result.jumps.indices {
|
||||
let hop = result.jumps[index]
|
||||
result.jumps[index].expectedHostKeys = keys(id: hop.id, hostname: hop.host, port: hop.port)
|
||||
|
||||
func proxy(_ value: SSHTransportProxy?) -> SSHAdditionalConnectionSnapshot.Proxy? {
|
||||
value.map {
|
||||
SSHAdditionalConnectionSnapshot.Proxy(
|
||||
kind: $0.kind, hostname: $0.hostname, port: $0.port,
|
||||
identityID: $0.identityID
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
let target = SSHAdditionalConnectionSnapshot.Node(
|
||||
id: configuration.hostID, name: configuration.host,
|
||||
host: configuration.host, port: configuration.port,
|
||||
authentication: configuration.authenticationReference,
|
||||
expectedHostKeys: keys(
|
||||
id: configuration.hostID, hostname: configuration.host,
|
||||
port: configuration.port
|
||||
),
|
||||
proxy: proxy(configuration.proxy)
|
||||
)
|
||||
let jumps = configuration.jumps.map { hop in
|
||||
SSHAdditionalConnectionSnapshot.Node(
|
||||
id: hop.id, name: hop.name, host: hop.host, port: hop.port,
|
||||
authentication: hop.authenticationReference,
|
||||
expectedHostKeys: keys(id: hop.id, hostname: hop.host, port: hop.port),
|
||||
proxy: proxy(hop.proxy)
|
||||
)
|
||||
}
|
||||
return SSHAdditionalConnectionSnapshot(target: target, jumps: jumps)
|
||||
}
|
||||
|
||||
/// Resolves current Identity/Key contents immediately before a silent open.
|
||||
/// The returned request is one-shot and must not be retained as session state.
|
||||
public func additionalConnectionRequest(
|
||||
from snapshot: SSHAdditionalConnectionSnapshot
|
||||
) throws -> SSHConnectionRequest {
|
||||
func proxy(
|
||||
_ value: SSHAdditionalConnectionSnapshot.Proxy?
|
||||
) throws -> SSHTransportProxy? {
|
||||
guard let value else { return nil }
|
||||
guard let identityID = value.identityID else {
|
||||
return SSHTransportProxy(
|
||||
kind: value.kind, hostname: value.hostname, port: value.port,
|
||||
username: ""
|
||||
)
|
||||
}
|
||||
guard let identity = identity(identityID) else {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
let password = try connectionPasswordCredential(for: identity)
|
||||
guard let password else { throw TermindSSHError.missingCredential }
|
||||
return SSHTransportProxy(
|
||||
kind: value.kind, hostname: value.hostname, port: value.port,
|
||||
username: identity.username, password: password,
|
||||
identityID: identity.id
|
||||
)
|
||||
}
|
||||
|
||||
func authentication(
|
||||
_ node: SSHAdditionalConnectionSnapshot.Node
|
||||
) throws -> (username: String, credentials: [SSHCredentials]) {
|
||||
let reference = node.authentication
|
||||
let resolvedIdentity = reference.identityID.flatMap(identity)
|
||||
if reference.identityID != nil, resolvedIdentity == nil {
|
||||
throw TermindSSHError.missingCredential
|
||||
}
|
||||
|
||||
let username = (resolvedIdentity?.username ?? reference.username)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
|
||||
|
||||
var credentials: [SSHCredentials] = []
|
||||
var failures: [any Error] = []
|
||||
let keyID = resolvedIdentity?.keyID ?? reference.fallbackKeyID
|
||||
if let keyID {
|
||||
if let key = key(keyID) {
|
||||
if key.requiresPassphraseEntry || key.requiresUserPresence {
|
||||
credentials.append(.interactionRequired)
|
||||
} else {
|
||||
do {
|
||||
if let stored = try connectionKeyCredential(
|
||||
for: key, identityID: resolvedIdentity?.id
|
||||
) {
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey,
|
||||
certificate: key.certificate,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
)
|
||||
)
|
||||
} else {
|
||||
failures.append(TermindSSHError.missingCredential)
|
||||
}
|
||||
} catch {
|
||||
failures.append(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
failures.append(TermindSSHError.missingCredential)
|
||||
}
|
||||
}
|
||||
|
||||
if let resolvedIdentity, resolvedIdentity.usesPassword {
|
||||
do {
|
||||
if let password = try connectionPasswordCredential(for: resolvedIdentity) {
|
||||
credentials.append(.password(password))
|
||||
} else {
|
||||
failures.append(TermindSSHError.missingCredential)
|
||||
}
|
||||
} catch {
|
||||
failures.append(error)
|
||||
}
|
||||
}
|
||||
|
||||
if credentials.isEmpty {
|
||||
if reference.hasEphemeralSecret { throw SSHInteractionRequired() }
|
||||
if !reference.permitsNone { throw failures.first ?? TermindSSHError.missingCredential }
|
||||
}
|
||||
return (username, credentials)
|
||||
}
|
||||
|
||||
let targetAuthentication = try authentication(snapshot.target)
|
||||
let jumps = try snapshot.jumps.map { node in
|
||||
let resolved = try authentication(node)
|
||||
return SSHRouteHopConfiguration(
|
||||
id: node.id, name: node.name, host: node.host, port: node.port,
|
||||
username: resolved.username, credentials: resolved.credentials,
|
||||
expectedHostKeys: node.expectedHostKeys,
|
||||
proxy: try proxy(node.proxy),
|
||||
authenticationReference: node.authentication
|
||||
)
|
||||
}
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
hostID: snapshot.target.id, host: snapshot.target.host,
|
||||
port: snapshot.target.port, username: targetAuthentication.username,
|
||||
credentials: targetAuthentication.credentials,
|
||||
expectedHostKeys: snapshot.target.expectedHostKeys,
|
||||
proxy: try proxy(snapshot.target.proxy), jumps: jumps,
|
||||
authenticationReference: snapshot.target.authentication
|
||||
)
|
||||
return SSHConnectionRequest(
|
||||
configuration: configuration, interactionPolicy: .forbidden
|
||||
).enforcingInteractionPolicy()
|
||||
}
|
||||
|
||||
public func connectionPasswordCredential(for identity: Identity) throws -> String? {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// A reusable SSH route with no authentication material. Network topology is
|
||||
/// frozen for the live session; credentials are resolved from Identity/Key IDs
|
||||
/// immediately before each additional connection opens.
|
||||
public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
|
||||
public struct Proxy: Equatable, Sendable {
|
||||
public var kind: HostProxy.ProtocolKind
|
||||
public var hostname: String
|
||||
public var port: Int
|
||||
public var identityID: Identity.ID?
|
||||
|
||||
public init(
|
||||
kind: HostProxy.ProtocolKind, hostname: String, port: Int,
|
||||
identityID: Identity.ID?
|
||||
) {
|
||||
self.kind = kind
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.identityID = identityID
|
||||
}
|
||||
}
|
||||
|
||||
public struct Node: Equatable, Sendable {
|
||||
public var id: Host.ID
|
||||
public var name: String
|
||||
public var host: String
|
||||
public var port: Int
|
||||
public var authentication: SSHAuthenticationReference
|
||||
public var expectedHostKeys: Set<SSHHostKeyIdentity>
|
||||
public var proxy: Proxy?
|
||||
|
||||
public init(
|
||||
id: Host.ID, name: String, host: String, port: Int,
|
||||
authentication: SSHAuthenticationReference,
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: Proxy?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.authentication = authentication
|
||||
self.expectedHostKeys = expectedHostKeys
|
||||
self.proxy = proxy
|
||||
}
|
||||
}
|
||||
|
||||
public var target: Node
|
||||
public var jumps: [Node]
|
||||
|
||||
public init(target: Node, jumps: [Node] = []) {
|
||||
self.target = target
|
||||
self.jumps = jumps
|
||||
}
|
||||
|
||||
/// Drops all resolved authentication material while retaining the route's
|
||||
/// explicit Identity/Key provenance. Durable trust may be refreshed by the
|
||||
/// host store before this value is retained for additional connections.
|
||||
public init(configuration: SSHConnectionConfiguration) {
|
||||
func proxy(_ value: SSHTransportProxy?) -> Proxy? {
|
||||
value.map {
|
||||
Proxy(
|
||||
kind: $0.kind, hostname: $0.hostname, port: $0.port,
|
||||
identityID: $0.identityID
|
||||
)
|
||||
}
|
||||
}
|
||||
target = Node(
|
||||
id: configuration.hostID, name: configuration.host,
|
||||
host: configuration.host, port: configuration.port,
|
||||
authentication: configuration.authenticationReference,
|
||||
expectedHostKeys: configuration.expectedHostKeys,
|
||||
proxy: proxy(configuration.proxy)
|
||||
)
|
||||
jumps = configuration.jumps.map {
|
||||
Node(
|
||||
id: $0.id, name: $0.name, host: $0.host, port: $0.port,
|
||||
authentication: $0.authenticationReference,
|
||||
expectedHostKeys: $0.expectedHostKeys, proxy: proxy($0.proxy)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public var hostID: Host.ID { target.id }
|
||||
}
|
||||
@@ -76,7 +76,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port,
|
||||
username: material.0, credentials: material.1,
|
||||
expectedHostKeys: node.trustedKeys,
|
||||
proxy: try proxy(node, accessedIdentityIDs: &accessedIdentityIDs)
|
||||
proxy: try proxy(node, accessedIdentityIDs: &accessedIdentityIDs),
|
||||
authenticationReference: authenticationReference(for: node)
|
||||
))
|
||||
} catch {
|
||||
if error is CancellationError { throw error }
|
||||
@@ -100,7 +101,25 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
return SSHConnectionConfiguration(
|
||||
hostID: target.host.id, host: target.host.host, port: target.host.port,
|
||||
username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys,
|
||||
proxy: try proxy(target, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps
|
||||
proxy: try proxy(target, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps,
|
||||
authenticationReference: authenticationReference(for: target)
|
||||
)
|
||||
}
|
||||
|
||||
private func authenticationReference(for node: Node) -> SSHAuthenticationReference {
|
||||
if let identity = connectionIdentity(for: node) {
|
||||
return SSHAuthenticationReference(
|
||||
identityID: identity.id,
|
||||
username: identity.username,
|
||||
permitsNone: !identity.hasSecret,
|
||||
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
|
||||
)
|
||||
}
|
||||
return SSHAuthenticationReference(
|
||||
fallbackKeyID: node.submission?.keyID ?? node.key?.id,
|
||||
username: node.submission?.username ?? "",
|
||||
permitsNone: node.key == nil && node.submission?.password == nil,
|
||||
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,7 +149,9 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
result.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: node.submission?.passphrase
|
||||
passphraseOverride: node.submission?.passphrase,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -179,7 +200,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
|
||||
}
|
||||
return SSHTransportProxy(
|
||||
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
|
||||
username: proxy.username, password: password
|
||||
username: proxy.username, password: password,
|
||||
identityID: proxy.identityID
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -480,6 +480,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: trustPolicy,
|
||||
accessedIdentityIDs: &accessedIdentityIDs
|
||||
),
|
||||
authenticationReference: additionalAuthenticationReference(
|
||||
host: host, submission: submissions[host.id]
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -595,6 +598,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
expectedHostKeys: expectedHostKeys,
|
||||
proxy: try transportProxy(
|
||||
for: host, accessedIdentityIDs: &accessedIdentityIDs
|
||||
),
|
||||
authenticationReference: additionalAuthenticationReference(
|
||||
host: host, submission: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -621,10 +627,35 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
expectedHostKeys: expectedHostKeys,
|
||||
proxy: try transportProxy(
|
||||
for: host, accessedIdentityIDs: &accessedIdentityIDs
|
||||
),
|
||||
authenticationReference: additionalAuthenticationReference(
|
||||
host: host, submission: submission
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func additionalAuthenticationReference(
|
||||
host: Host, submission: SSHAuthenticationSubmission?
|
||||
) -> SSHAuthenticationReference {
|
||||
let resolvedIdentity =
|
||||
submission?.identityID.flatMap(identity)
|
||||
?? (submission == nil ? identity(for: host) : nil)
|
||||
if let resolvedIdentity {
|
||||
return SSHAuthenticationReference(
|
||||
identityID: resolvedIdentity.id,
|
||||
username: resolvedIdentity.username,
|
||||
permitsNone: !resolvedIdentity.hasSecret,
|
||||
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
|
||||
)
|
||||
}
|
||||
return SSHAuthenticationReference(
|
||||
fallbackKeyID: submission?.keyID,
|
||||
username: submission?.username ?? "",
|
||||
permitsNone: submission?.keyID == nil && submission?.password == nil,
|
||||
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
|
||||
)
|
||||
}
|
||||
|
||||
private func sshCredentials(
|
||||
for submission: SSHAuthenticationSubmission,
|
||||
accessedIdentityIDs: inout Set<Identity.ID>
|
||||
@@ -643,7 +674,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: submission.passphrase
|
||||
passphraseOverride: submission.passphrase,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -680,7 +713,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
}
|
||||
return SSHTransportProxy(
|
||||
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
|
||||
username: proxy.username, password: password
|
||||
username: proxy.username, password: password,
|
||||
identityID: proxy.identityID
|
||||
)
|
||||
}
|
||||
|
||||
@@ -711,7 +745,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
} else {
|
||||
credentials.append(
|
||||
credential.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,13 +42,15 @@ public nonisolated enum SSHHostKeyDecision: Equatable, Sendable {
|
||||
}
|
||||
|
||||
public typealias SSHHostKeyAuthorizer = @Sendable (SSHHostKeyChallenge) async throws -> SSHHostKeyDecision
|
||||
public typealias SSHAdditionalConnectionRequestResolver = @MainActor @Sendable () throws -> SSHConnectionRequest
|
||||
|
||||
public nonisolated enum SSHHostTrustPolicy: Equatable, Sendable {
|
||||
case requireKnownHost
|
||||
case allowChallenge
|
||||
}
|
||||
|
||||
/// Controls Termind authentication forms. System key authorization (Touch ID) remains allowed.
|
||||
/// Controls every authentication action that may require user presence,
|
||||
/// including Termind forms, Touch ID, and device-password authorization.
|
||||
public nonisolated enum SSHInteractionPolicy: Equatable, Sendable {
|
||||
case allowed
|
||||
case forbidden
|
||||
@@ -95,6 +97,11 @@ public nonisolated struct SSHConnectionRequest: Sendable {
|
||||
public func enforcingInteractionPolicy() -> Self {
|
||||
guard interactionPolicy == .forbidden else { return self }
|
||||
var request = self
|
||||
request.configuration.credentials = request.configuration.credentials.map { $0.withoutInteraction }
|
||||
for index in request.configuration.jumps.indices {
|
||||
request.configuration.jumps[index].credentials =
|
||||
request.configuration.jumps[index].credentials.map { $0.withoutInteraction }
|
||||
}
|
||||
request.authorizeHostKey = { _ in throw SSHInteractionRequired() }
|
||||
request.keyboardInteractive = { challenge in
|
||||
guard challenge.prompts.isEmpty else { throw SSHInteractionRequired() }
|
||||
@@ -104,6 +111,18 @@ public nonisolated struct SSHConnectionRequest: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension SSHCredentials {
|
||||
fileprivate var withoutInteraction: SSHCredentials {
|
||||
switch self {
|
||||
case .privateKey(_, _, _, requiresPassphrase: true),
|
||||
.secureEnclave(_, _, requiresUserPresence: true):
|
||||
.interactionRequired
|
||||
default:
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated struct SSHHandshakeFailure: Hashable, Sendable {
|
||||
public enum Kind: String, Hashable, Sendable {
|
||||
case peerClosed
|
||||
@@ -197,13 +216,23 @@ public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable {
|
||||
|
||||
public nonisolated enum SSHCredentials: Sendable {
|
||||
case password(String)
|
||||
case privateKey(privateKey: String, publicKey: String?, passphrase: String?)
|
||||
case secureEnclave(credential: SecureEnclaveKeyCredential, publicKey: String)
|
||||
case privateKey(
|
||||
privateKey: String, publicKey: String?, passphrase: String?,
|
||||
requiresPassphrase: Bool = false
|
||||
)
|
||||
case secureEnclave(
|
||||
credential: SecureEnclaveKeyCredential, publicKey: String,
|
||||
requiresUserPresence: Bool = false
|
||||
)
|
||||
/// A public-key credential exists, but the current policy may not obtain it
|
||||
/// without presenting Touch ID, a device-password prompt, or a passphrase form.
|
||||
case interactionRequired
|
||||
}
|
||||
|
||||
nonisolated extension StoredSSHKeyCredential {
|
||||
public func sshCredential(
|
||||
publicKey: String, certificate: String = "", passphraseOverride: String? = nil
|
||||
publicKey: String, certificate: String = "", passphraseOverride: String? = nil,
|
||||
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
|
||||
) -> SSHCredentials {
|
||||
switch self {
|
||||
case .software(let secret):
|
||||
@@ -212,14 +241,44 @@ nonisolated extension StoredSSHKeyCredential {
|
||||
publicKey: certificate.isEmpty
|
||||
? (publicKey.isEmpty ? nil : publicKey)
|
||||
: certificate,
|
||||
passphrase: passphraseOverride
|
||||
passphrase: passphraseOverride,
|
||||
requiresPassphrase: requiresPassphrase
|
||||
)
|
||||
case .secureEnclave(let credential):
|
||||
.secureEnclave(credential: credential, publicKey: publicKey)
|
||||
.secureEnclave(
|
||||
credential: credential, publicKey: publicKey,
|
||||
requiresUserPresence: requiresUserPresence
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-secret provenance retained by a live connection so its reusable
|
||||
/// additional-connection snapshot can resolve current credentials by ID.
|
||||
public nonisolated struct SSHAuthenticationReference: Hashable, Sendable {
|
||||
public var identityID: Identity.ID?
|
||||
/// Used only when no Identity owns this login. An Identity always resolves
|
||||
/// its current Key, so changing the Identity updates later connections.
|
||||
public var fallbackKeyID: SSHKeyRef.ID?
|
||||
/// Used only when there is no Identity. Identity-owned logins always use
|
||||
/// the Identity's current username.
|
||||
public var username: String
|
||||
public var permitsNone: Bool
|
||||
public var hasEphemeralSecret: Bool
|
||||
|
||||
public init(
|
||||
identityID: Identity.ID? = nil, fallbackKeyID: SSHKeyRef.ID? = nil,
|
||||
username: String = "",
|
||||
permitsNone: Bool = false, hasEphemeralSecret: Bool = false
|
||||
) {
|
||||
self.identityID = identityID
|
||||
self.fallbackKeyID = fallbackKeyID
|
||||
self.username = username
|
||||
self.permitsNone = permitsNone
|
||||
self.hasEphemeralSecret = hasEphemeralSecret
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated struct SSHConnectionConfiguration: Sendable {
|
||||
public var hostID: Host.ID
|
||||
public var host: String
|
||||
@@ -229,10 +288,12 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
|
||||
public var expectedHostKeys: Set<SSHHostKeyIdentity>
|
||||
public var proxy: SSHTransportProxy?
|
||||
public var jumps: [SSHRouteHopConfiguration] = []
|
||||
public var authenticationReference: SSHAuthenticationReference
|
||||
public init(
|
||||
hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials],
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: SSHTransportProxy? = nil,
|
||||
jumps: [SSHRouteHopConfiguration] = []
|
||||
jumps: [SSHRouteHopConfiguration] = [],
|
||||
authenticationReference: SSHAuthenticationReference? = nil
|
||||
) {
|
||||
self.hostID = hostID
|
||||
self.host = host
|
||||
@@ -242,6 +303,13 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
|
||||
self.expectedHostKeys = expectedHostKeys
|
||||
self.proxy = proxy
|
||||
self.jumps = jumps
|
||||
self.authenticationReference =
|
||||
authenticationReference
|
||||
?? SSHAuthenticationReference(
|
||||
username: username,
|
||||
permitsNone: credentials.isEmpty,
|
||||
hasEphemeralSecret: !credentials.isEmpty
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable {
|
||||
public var credentials: [SSHCredentials]
|
||||
public var expectedHostKeys: Set<SSHHostKeyIdentity>
|
||||
public var proxy: SSHTransportProxy?
|
||||
public var authenticationReference: SSHAuthenticationReference
|
||||
public init(
|
||||
id: Host.ID, name: String, host: String, port: Int, username: String, credentials: [SSHCredentials],
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: SSHTransportProxy? = nil
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: SSHTransportProxy? = nil,
|
||||
authenticationReference: SSHAuthenticationReference? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -24,6 +26,13 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable {
|
||||
self.credentials = credentials
|
||||
self.expectedHostKeys = expectedHostKeys
|
||||
self.proxy = proxy
|
||||
self.authenticationReference =
|
||||
authenticationReference
|
||||
?? SSHAuthenticationReference(
|
||||
username: username,
|
||||
permitsNone: credentials.isEmpty,
|
||||
hasEphemeralSecret: !credentials.isEmpty
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,12 +9,17 @@ public nonisolated struct SSHTransportProxy: Sendable {
|
||||
public var port: Int
|
||||
public var username: String
|
||||
public var password: String?
|
||||
public init(kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String, password: String? = nil) {
|
||||
public var identityID: Identity.ID?
|
||||
public init(
|
||||
kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String,
|
||||
password: String? = nil, identityID: Identity.ID? = nil
|
||||
) {
|
||||
self.kind = kind
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.identityID = identityID
|
||||
}
|
||||
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public final class PortForwardingConnection {
|
||||
fileprivate var connected = false
|
||||
fileprivate var ready = false
|
||||
fileprivate var silentAttempt = false
|
||||
fileprivate var configuration: SSHConnectionConfiguration?
|
||||
fileprivate var additionalSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:]
|
||||
fileprivate let maximumConnections: Int
|
||||
|
||||
@@ -361,8 +361,8 @@ public final class PortForwardingController {
|
||||
try Task.checkCancellation()
|
||||
let configuration: SSHConnectionConfiguration
|
||||
if silently {
|
||||
guard let snapshot = context.configuration else { throw TermindSSHError.missingCredential }
|
||||
configuration = snapshot
|
||||
guard let snapshot = context.additionalSnapshot else { throw TermindSSHError.missingCredential }
|
||||
configuration = try hosts.additionalConnectionRequest(from: snapshot).configuration
|
||||
} else {
|
||||
let preparation = try hosts.prepareSSHConnection(
|
||||
for: host, submissions: context.submissions,
|
||||
@@ -400,12 +400,13 @@ public final class PortForwardingController {
|
||||
context.task = nil
|
||||
context.connected = true
|
||||
if !silently {
|
||||
context.configuration = hosts.configurationForAdditionalConnection(from: configuration)
|
||||
context.additionalSnapshot = hosts.additionalConnectionSnapshot(from: configuration)
|
||||
}
|
||||
if let snapshot = context.configuration {
|
||||
await forwardingConnection.configureAdditionalConnections(
|
||||
SSHConnectionRequest(configuration: snapshot, interactionPolicy: .forbidden)
|
||||
.enforcingInteractionPolicy())
|
||||
if let snapshot = context.additionalSnapshot {
|
||||
await forwardingConnection.configureAdditionalConnections { [weak self] in
|
||||
guard let self else { throw CancellationError() }
|
||||
return try self.hosts.additionalConnectionRequest(from: snapshot)
|
||||
}
|
||||
}
|
||||
context.submissions.removeAll()
|
||||
context.connection.temporaryHostKeys.removeAll()
|
||||
@@ -551,7 +552,7 @@ public final class PortForwardingController {
|
||||
}
|
||||
|
||||
private func recover(_ context: PortForwardingConnection, message: String) {
|
||||
guard context.connection.hasConnectedOnce, context.configuration != nil else {
|
||||
guard context.connection.hasConnectedOnce, context.additionalSnapshot != nil else {
|
||||
fail(context, message: message)
|
||||
return
|
||||
}
|
||||
@@ -599,7 +600,7 @@ public final class PortForwardingController {
|
||||
context.setPrompt(nil)
|
||||
context.hostKeyRequestID = nil
|
||||
context.submissions.removeAll()
|
||||
context.configuration = nil
|
||||
context.additionalSnapshot = nil
|
||||
context.connection.disconnect()
|
||||
if connections[context.hostID] === context { connections[context.hostID] = nil }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public nonisolated struct SSHForwardingPoolStatus: Hashable, Sendable {
|
||||
|
||||
public protocol SSHForwardingConnectionProtocol: Actor {
|
||||
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
|
||||
func configureAdditionalConnections(_ request: SSHConnectionRequest) async
|
||||
func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async
|
||||
func connectionPoolStatus() async -> SSHForwardingPoolStatus
|
||||
func start(_ rule: PortForwardingRule, generation: UUID) async throws
|
||||
func stop(_ id: PortForwardingRule.ID) async
|
||||
@@ -36,7 +36,7 @@ public protocol SSHForwardingConnectionProtocol: Actor {
|
||||
}
|
||||
|
||||
extension SSHForwardingConnectionProtocol {
|
||||
public func configureAdditionalConnections(_ request: SSHConnectionRequest) async {}
|
||||
public func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async {}
|
||||
public func connectionPoolStatus() async -> SSHForwardingPoolStatus { .single }
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
private var primaryID: UUID?
|
||||
private var registrations: [PortForwardingRule.ID: RuleRegistration] = [:]
|
||||
private var counts: [UUID: [PortForwardingRule.ID: Int]] = [:]
|
||||
private var additionalRequest: SSHConnectionRequest?
|
||||
private var additionalRequest: SSHAdditionalConnectionRequestResolver?
|
||||
private var opening: [UUID: Slot] = [:]
|
||||
private var installing: [UUID: Slot] = [:]
|
||||
private var retryTask: Task<Void, Never>?
|
||||
@@ -78,8 +78,8 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func configureAdditionalConnections(_ request: SSHConnectionRequest) async {
|
||||
additionalRequest = request.enforcingInteractionPolicy()
|
||||
func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async {
|
||||
additionalRequest = resolver
|
||||
fillPool()
|
||||
}
|
||||
|
||||
@@ -172,9 +172,10 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
let id = UUID()
|
||||
let slot = makeSlot(id: id)
|
||||
opening[id] = slot
|
||||
let request = additionalRequest!
|
||||
let resolver = additionalRequest!
|
||||
Task { [weak self] in
|
||||
do {
|
||||
let request = try await resolver()
|
||||
try await slot.connection.connect(request, progress: nil)
|
||||
await self?.opened(slot)
|
||||
} catch {
|
||||
|
||||
@@ -1037,12 +1037,14 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
var secureEnclaveError: (any Error)?
|
||||
var remaining = credentials
|
||||
var attemptedInteractive = false
|
||||
var skippedInteractiveCredential = false
|
||||
while true {
|
||||
guard
|
||||
let index = remaining.firstIndex(where: { credential in
|
||||
switch credential {
|
||||
case .password: methods.contains("password")
|
||||
case .privateKey, .secureEnclave: methods.contains("publickey")
|
||||
case .privateKey, .secureEnclave, .interactionRequired:
|
||||
methods.contains("publickey")
|
||||
}
|
||||
})
|
||||
else {
|
||||
@@ -1079,7 +1081,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
if result == 0 { return lastMethod }
|
||||
}
|
||||
case .privateKey(let privateKey, let publicKey, let passphrase):
|
||||
case .privateKey(let privateKey, let publicKey, let passphrase, _):
|
||||
guard methods.contains("publickey") else { continue }
|
||||
attempted = true
|
||||
lastMethod = "public_key"
|
||||
@@ -1105,7 +1107,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
if result == 0 { return lastMethod }
|
||||
case .secureEnclave(let credential, let publicKey):
|
||||
case .secureEnclave(let credential, let publicKey, _):
|
||||
guard methods.contains("publickey") else { continue }
|
||||
guard let publicBlob = SecureEnclaveKey.publicBlob(from: publicKey) else {
|
||||
secureEnclaveError = TermindSSHError.library(
|
||||
@@ -1131,10 +1133,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
if result == 0 { return lastMethod }
|
||||
secureEnclaveError = signer.error
|
||||
case .interactionRequired:
|
||||
skippedInteractiveCredential = true
|
||||
}
|
||||
methods = try authenticationMethods(username: username, session: session)
|
||||
if libssh2_userauth_authenticated(session) == 1 { return lastMethod }
|
||||
}
|
||||
if skippedInteractiveCredential { throw SSHInteractionRequired() }
|
||||
if let secureEnclaveError {
|
||||
throw TermindSSHError.authenticationFailed(secureEnclaveError.localizedDescription)
|
||||
}
|
||||
|
||||
@@ -24,20 +24,22 @@ public final class SFTPEditService {
|
||||
}
|
||||
let hops: [Hop]
|
||||
|
||||
init(_ configuration: SSHConnectionConfiguration) {
|
||||
func proxy(_ value: SSHTransportProxy?) -> [String] {
|
||||
init(_ snapshot: SSHAdditionalConnectionSnapshot) {
|
||||
func proxy(_ value: SSHAdditionalConnectionSnapshot.Proxy?) -> [String] {
|
||||
guard let value else { return [] }
|
||||
return [value.kind.rawValue, value.hostname, String(value.port), value.username]
|
||||
return [value.kind.rawValue, value.hostname, String(value.port)]
|
||||
}
|
||||
hops =
|
||||
configuration.jumps.map {
|
||||
snapshot.jumps.map {
|
||||
Hop(
|
||||
host: $0.host, port: $0.port, username: $0.username, keys: $0.expectedHostKeys,
|
||||
host: $0.host, port: $0.port, username: $0.authentication.username,
|
||||
keys: $0.expectedHostKeys,
|
||||
proxy: proxy($0.proxy))
|
||||
} + [
|
||||
Hop(
|
||||
host: configuration.host, port: configuration.port, username: configuration.username,
|
||||
keys: configuration.expectedHostKeys, proxy: proxy(configuration.proxy))
|
||||
host: snapshot.target.host, port: snapshot.target.port,
|
||||
username: snapshot.target.authentication.username,
|
||||
keys: snapshot.target.expectedHostKeys, proxy: proxy(snapshot.target.proxy))
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -64,12 +66,12 @@ public final class SFTPEditService {
|
||||
public func open(
|
||||
_ file: RemoteFile, hostID: Host.ID, connection: SFTPTransferConnection,
|
||||
client: any SFTPChannelProtocol, homeDirectory: String? = nil,
|
||||
configuration: SSHConnectionConfiguration? = nil
|
||||
snapshot: SSHAdditionalConnectionSnapshot? = nil
|
||||
) async throws -> URL {
|
||||
validateHosts()
|
||||
if let existing = model.file(hostID: hostID, path: file.path) {
|
||||
guard !existing.isInvalidated,
|
||||
matchesDestination(existing.id, connection: connection, configuration: configuration)
|
||||
matchesDestination(existing.id, connection: connection, snapshot: snapshot)
|
||||
else { throw SFTPEditFailure.unavailable }
|
||||
return existing.localURL
|
||||
}
|
||||
@@ -77,7 +79,7 @@ public final class SFTPEditService {
|
||||
if let task = opening[key] {
|
||||
let url = try await task.value
|
||||
if let existing = model.file(hostID: hostID, path: file.path),
|
||||
!matchesDestination(existing.id, connection: connection, configuration: configuration)
|
||||
!matchesDestination(existing.id, connection: connection, snapshot: snapshot)
|
||||
{
|
||||
throw SFTPEditFailure.unavailable
|
||||
}
|
||||
@@ -126,7 +128,7 @@ public final class SFTPEditService {
|
||||
guard generations[hostID, default: 0] == generation else { throw SFTPEditFailure.unavailable }
|
||||
if let originalHost { originalHosts[hostID] = originalHost }
|
||||
let id = model.register(hostID: hostID, file: original, localURL: url, digest: digest)
|
||||
destinations[id] = destination(connection, configuration: configuration)
|
||||
destinations[id] = destination(connection, snapshot: snapshot)
|
||||
validateHosts()
|
||||
startMonitoring()
|
||||
}
|
||||
@@ -235,14 +237,14 @@ public final class SFTPEditService {
|
||||
public func upload(
|
||||
_ id: UUID, connection: SFTPTransferConnection, client: any SFTPChannelProtocol,
|
||||
homeDirectory: String? = nil, replacing: Bool = false,
|
||||
configuration: SSHConnectionConfiguration? = nil,
|
||||
snapshot: SSHAdditionalConnectionSnapshot? = nil,
|
||||
didUpload: @escaping () -> Void = {}
|
||||
) async -> Bool {
|
||||
validateHosts()
|
||||
guard let entry = model.file(id), !entry.isPreparing, entry.transferID == nil, !entry.isInvalidated else {
|
||||
return false
|
||||
}
|
||||
guard matchesDestination(id, connection: connection, configuration: configuration) else { return false }
|
||||
guard matchesDestination(id, connection: connection, snapshot: snapshot) else { return false }
|
||||
model.update(id) {
|
||||
$0.isPreparing = true
|
||||
$0.error = nil
|
||||
@@ -301,18 +303,18 @@ public final class SFTPEditService {
|
||||
}
|
||||
|
||||
private func destination(
|
||||
_ connection: SFTPTransferConnection, configuration: SSHConnectionConfiguration?
|
||||
_ connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot?
|
||||
) -> Destination? {
|
||||
if let configuration { return Destination(configuration) }
|
||||
if let snapshot { return Destination(snapshot) }
|
||||
if case .saved(let saved) = connection { return Destination(saved) }
|
||||
if case .context(let session) = connection { return Destination(session.configuration) }
|
||||
if case .context(let session) = connection { return Destination(session.snapshot) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func matchesDestination(
|
||||
_ id: UUID, connection: SFTPTransferConnection, configuration: SSHConnectionConfiguration?
|
||||
_ id: UUID, connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot?
|
||||
) -> Bool {
|
||||
guard destinations[id] == destination(connection, configuration: configuration) else {
|
||||
guard destinations[id] == destination(connection, snapshot: snapshot) else {
|
||||
model.update(id) {
|
||||
$0.isInvalidated = true
|
||||
$0.error = SFTPEditFailure.unavailable.localizedDescription
|
||||
|
||||
@@ -16,7 +16,8 @@ public nonisolated enum SFTPTransferPrimaryRole: Hashable, Sendable {
|
||||
@MainActor
|
||||
public final class SFTPTransferContext {
|
||||
public let id = UUID()
|
||||
public let configuration: SSHConnectionConfiguration
|
||||
public let snapshot: SSHAdditionalConnectionSnapshot
|
||||
private weak var hostStore: (any RemoteSessionHostStore)?
|
||||
private var clientFactory: SFTPTransferCoordinator.ClientFactory
|
||||
private var primary: (any SFTPChannelProtocol)?
|
||||
private var ownerAttached = true
|
||||
@@ -31,11 +32,13 @@ public final class SFTPTransferContext {
|
||||
var availabilityChanged: (() -> Void)?
|
||||
|
||||
public init(
|
||||
configuration: SSHConnectionConfiguration, channel: any SFTPChannelProtocol,
|
||||
snapshot: SSHAdditionalConnectionSnapshot, hostStore: (any RemoteSessionHostStore)? = nil,
|
||||
channel: any SFTPChannelProtocol,
|
||||
primaryRole: SFTPTransferPrimaryRole = .control,
|
||||
clientFactory: @escaping SFTPTransferCoordinator.ClientFactory = { SFTPClient() }
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.snapshot = snapshot
|
||||
self.hostStore = hostStore
|
||||
self.primary = channel
|
||||
self.clientFactory = clientFactory
|
||||
self.primaryRole = primaryRole
|
||||
@@ -54,6 +57,10 @@ public final class SFTPTransferContext {
|
||||
clientFactory = factory
|
||||
}
|
||||
|
||||
public func configureHostStore(_ store: any RemoteSessionHostStore) {
|
||||
hostStore = store
|
||||
}
|
||||
|
||||
func suspendTransfer(_ id: UUID) {
|
||||
transfers.remove(id)
|
||||
Task { await closeIfUnused() }
|
||||
@@ -203,9 +210,9 @@ public final class SFTPTransferContext {
|
||||
}
|
||||
|
||||
private func openSilently(cancellation: TransferCancellationToken?) async throws -> any SFTPChannelProtocol {
|
||||
guard let hostStore else { throw SFTPError.notConnected }
|
||||
let client = clientFactory()
|
||||
let request = SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)
|
||||
.enforcingInteractionPolicy()
|
||||
let request = try hostStore.additionalConnectionRequest(from: snapshot)
|
||||
let opening = Task {
|
||||
try await client.connect(using: request, progress: nil, connectionFailure: nil)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
public nonisolated enum SFTPTransferConnection: Sendable {
|
||||
case saved(SSHConnectionConfiguration)
|
||||
case saved(SSHAdditionalConnectionSnapshot)
|
||||
case context(SFTPTransferContext)
|
||||
case borrowed(id: UUID, channel: any SFTPChannelProtocol)
|
||||
|
||||
@@ -40,7 +40,11 @@ public nonisolated struct SFTPRemoteCopySource: Sendable {
|
||||
}
|
||||
|
||||
public init(hostID: Host.ID, configuration: SSHConnectionConfiguration, files: [RemoteFile]) {
|
||||
self.init(hostID: hostID, connection: .saved(configuration), files: files)
|
||||
self.init(
|
||||
hostID: hostID,
|
||||
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
|
||||
files: files
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +280,7 @@ public final class SFTPTransferCoordinator {
|
||||
enqueue(
|
||||
direction: direction, batchID: batchID, localURL: localURL,
|
||||
remotePath: remotePath, byteCount: byteCount, hostID: hostID,
|
||||
connection: .saved(configuration), scopedRoot: scopedRoot,
|
||||
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)), scopedRoot: scopedRoot,
|
||||
overwrite: overwrite, homeDirectory: homeDirectory, completion: completion
|
||||
)
|
||||
}
|
||||
@@ -329,9 +333,9 @@ public final class SFTPTransferCoordinator {
|
||||
) {
|
||||
enqueueRemoteCopy(
|
||||
batchID: batchID, sourcePath: sourcePath, sourceHostID: sourceHostID,
|
||||
sourceConnection: .saved(sourceConfiguration),
|
||||
sourceConnection: .saved(SSHAdditionalConnectionSnapshot(configuration: sourceConfiguration)),
|
||||
destinationPath: destinationPath, destinationHostID: destinationHostID,
|
||||
destinationConnection: .saved(destinationConfiguration),
|
||||
destinationConnection: .saved(SSHAdditionalConnectionSnapshot(configuration: destinationConfiguration)),
|
||||
byteCount: byteCount, overwrite: overwrite, completion: completion
|
||||
)
|
||||
}
|
||||
@@ -722,13 +726,10 @@ public final class SFTPTransferCoordinator {
|
||||
switch connection {
|
||||
case .context(let context):
|
||||
return try await context.acquire(mode: mode, cancellation: cancellation)
|
||||
case .saved(let configuration):
|
||||
case .saved(let snapshot):
|
||||
let client = clientFactory()
|
||||
do {
|
||||
let request = SSHConnectionRequest(
|
||||
configuration: configuration,
|
||||
interactionPolicy: .forbidden
|
||||
).enforcingInteractionPolicy()
|
||||
let request = try store.additionalConnectionRequest(from: snapshot)
|
||||
let openingRequest = request
|
||||
let opening = Task {
|
||||
try await client.connect(using: openingRequest, progress: nil, connectionFailure: nil)
|
||||
|
||||
@@ -104,11 +104,14 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
|
||||
- User sessions own challenge queues; Agents use interaction queues. Desktop external auth separates app/MCP origins;
|
||||
stopping MCP preserves app requests. Mobile has no app auth sheet. Forwarding/Metrics own Journey challenges, never
|
||||
external-window fallback.
|
||||
- Terminal drops/additional transfer connections forbid app password/passphrase/MFA/trust forms throughout the route,
|
||||
but permit system key authorization. Username-only/SSH `none` remain eligible: actual connection success, not saved
|
||||
credential presence, decides. Cancellation interrupts opening. A terminal drop always tries its independent SFTP
|
||||
preflight first; any non-cancellation connection failure, including interaction-required authentication, falls back to
|
||||
SFTP Channels on the already authenticated connection belonging to the PTY Channel without presenting a form.
|
||||
- Terminal drops/additional transfer and forwarding connections forbid password/passphrase/MFA/trust forms and system
|
||||
user-presence authorization (Touch ID or device password) throughout the route. Reusable snapshots freeze target,
|
||||
Jump Host and proxy network data but retain authentication only as Identity IDs, plus a Key ID only when no Identity
|
||||
exists. Each silent open resolves the Identity's current username/password/Key from Vault and Keychain. Protected Keys
|
||||
are skipped before their secret is read; password and other noninteractive methods remain eligible. Passphrases and
|
||||
temporary passwords never enter reusable snapshots. Username-only/SSH `none` remain eligible. Cancellation interrupts
|
||||
opening. A terminal drop tries an independent SFTP preflight when durable references are available; any non-cancellation
|
||||
failure falls back to SFTP Channels on the already authenticated PTY connection without presenting a form.
|
||||
- Username-only Identities try `none`, then advertised methods. Device-local `lastUsedAt` tracks successful saved-secret
|
||||
reads, not authentication. Batch route reads; missing/availability/edit reads do not count. First access per Identity/
|
||||
process writes directly without Vault journal/refresh; sync strips this field and preserves local values on apply.
|
||||
|
||||
@@ -81,7 +81,7 @@ final class MobileWorkspace: Identifiable {
|
||||
let sftpEdits: SFTPEditService?
|
||||
let transfers: SFTPTransferCoordinator?
|
||||
private var transferContext: SFTPTransferContext?
|
||||
private var transferConfiguration: SSHConnectionConfiguration?
|
||||
private var transferSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
private(set) var uploadNotice: WorkspaceUploadNotice?
|
||||
private var pendingUpload: MobilePendingUpload?
|
||||
private var uploadPreparationTask: Task<Void, Never>?
|
||||
@@ -184,12 +184,12 @@ final class MobileWorkspace: Identifiable {
|
||||
case .ssh:
|
||||
return TerminalUploadAvailability.problem(
|
||||
isConnected: connected,
|
||||
hasTransferConfiguration: transferConfiguration != nil,
|
||||
hasTransferConfiguration: transferSnapshot != nil,
|
||||
isWorkingDirectoryPending: workingDirectory == nil,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
case .sftp:
|
||||
guard connected, transferConfiguration != nil, sftp != nil else {
|
||||
guard connected, transferSnapshot != nil, sftp != nil else {
|
||||
return String(localized: "The SFTP channel is not connected.")
|
||||
}
|
||||
return nil
|
||||
@@ -638,10 +638,11 @@ final class MobileWorkspace: Identifiable {
|
||||
journey.receive(.connected(host.id))
|
||||
}
|
||||
guard generation == token, !Task.isCancelled else { return }
|
||||
let additionalConfiguration = hosts.configurationForAdditionalConnection(from: config)
|
||||
transferConfiguration = additionalConfiguration
|
||||
let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config)
|
||||
transferSnapshot = additionalSnapshot
|
||||
if let sftp {
|
||||
transferContext = SFTPTransferContext(configuration: additionalConfiguration, channel: sftp)
|
||||
transferContext = SFTPTransferContext(
|
||||
snapshot: additionalSnapshot, hostStore: hosts, channel: sftp)
|
||||
}
|
||||
submissions.removeAll()
|
||||
let wasReconnecting = connection.markConnected()
|
||||
@@ -768,7 +769,7 @@ final class MobileWorkspace: Identifiable {
|
||||
sftpChannel.detach()
|
||||
busy = false
|
||||
connected = false
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
status = String(localized: "Disconnected")
|
||||
connectionFailure = reconnectState.lastError
|
||||
journey.fail(reconnectState.lastError)
|
||||
@@ -797,7 +798,7 @@ final class MobileWorkspace: Identifiable {
|
||||
sftpChannel.detach()
|
||||
busy = false
|
||||
connected = false
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
activeHistoryID = nil
|
||||
authenticationRequired = false
|
||||
authenticationPrompt = nil
|
||||
@@ -831,7 +832,7 @@ final class MobileWorkspace: Identifiable {
|
||||
busy = false
|
||||
connected = false
|
||||
ssh = nil
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
switch result {
|
||||
case .success:
|
||||
connectionResumeIntent = .none
|
||||
@@ -865,7 +866,7 @@ final class MobileWorkspace: Identifiable {
|
||||
sftpChannel.detach()
|
||||
busy = false
|
||||
connected = false
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
status = String(localized: "Reconnecting")
|
||||
if announcesLoss, descriptor.kind == .ssh {
|
||||
appendTerminalNotice(String(localized: "Connection lost; reconnecting…"))
|
||||
@@ -914,7 +915,7 @@ final class MobileWorkspace: Identifiable {
|
||||
generation = UUID()
|
||||
attempt?.cancel()
|
||||
attempt = nil
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
let oldSSH = ssh
|
||||
let oldSFTP = sftp
|
||||
let oldTransferContext = transferContext
|
||||
@@ -1008,7 +1009,7 @@ final class MobileWorkspace: Identifiable {
|
||||
let urls = urls.filter(\.isFileURL)
|
||||
guard !urls.isEmpty else { return false }
|
||||
cancelPendingUpload()
|
||||
guard transfers != nil, let configuration = transferConfiguration,
|
||||
guard transfers != nil, let snapshot = transferSnapshot,
|
||||
let destination = uploadDestination
|
||||
else {
|
||||
showUploadNotice(
|
||||
@@ -1040,11 +1041,7 @@ final class MobileWorkspace: Identifiable {
|
||||
client = sftp
|
||||
} else {
|
||||
client = try await connectionServices.connectSFTP(
|
||||
SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)
|
||||
.enforcingInteractionPolicy(),
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
hosts.additionalConnectionRequest(from: snapshot), nil, nil)
|
||||
ownsClient = true
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
@@ -1069,7 +1066,8 @@ final class MobileWorkspace: Identifiable {
|
||||
let context: SFTPTransferContext
|
||||
if ownsClient {
|
||||
context = SFTPTransferContext(
|
||||
configuration: configuration, channel: client, primaryRole: .donatedConnection)
|
||||
snapshot: snapshot, hostStore: hosts,
|
||||
channel: client, primaryRole: .donatedConnection)
|
||||
} else {
|
||||
guard let existing = transferContext else { throw SFTPError.notConnected }
|
||||
context = existing
|
||||
@@ -1111,7 +1109,7 @@ final class MobileWorkspace: Identifiable {
|
||||
}
|
||||
|
||||
func prepareRemoteCopy(_ payload: SFTPRemoteItemsPayload) {
|
||||
guard let transfers, transferConfiguration != nil,
|
||||
guard let transfers, transferSnapshot != nil,
|
||||
let sftp, connected
|
||||
else {
|
||||
operationError = String(localized: "The SFTP connection is not available.")
|
||||
@@ -1152,7 +1150,7 @@ final class MobileWorkspace: Identifiable {
|
||||
}
|
||||
|
||||
func prepareDownload(_ file: RemoteFile, to destination: URL) {
|
||||
guard transfers != nil, transferConfiguration != nil,
|
||||
guard transfers != nil, transferSnapshot != nil,
|
||||
let sftp, connected
|
||||
else {
|
||||
operationError = String(localized: "The SFTP connection is not available.")
|
||||
|
||||
@@ -997,7 +997,7 @@ struct MobileUploadBatchTests {
|
||||
batch,
|
||||
policy: .replace,
|
||||
hostID: hostID,
|
||||
connection: .saved(configuration),
|
||||
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
|
||||
client: client,
|
||||
coordinator: coordinator
|
||||
)
|
||||
@@ -1033,7 +1033,8 @@ struct MobileUploadBatchTests {
|
||||
|
||||
await #expect(throws: (any Error).self) {
|
||||
try await SFTPLocalUploadPlanner.enqueue(
|
||||
batch, policy: .replace, hostID: hostID, connection: .saved(configuration),
|
||||
batch, policy: .replace, hostID: hostID,
|
||||
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
|
||||
client: client, coordinator: coordinator)
|
||||
}
|
||||
#expect(await client.listedDirectories == [target.path])
|
||||
@@ -1056,7 +1057,8 @@ struct MobileUploadBatchTests {
|
||||
let task = Task { @MainActor in
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await SFTPLocalUploadPlanner.enqueue(
|
||||
batch, policy: .replace, hostID: hostID, connection: .saved(configuration),
|
||||
batch, policy: .replace, hostID: hostID,
|
||||
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
|
||||
client: client, coordinator: coordinator)
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
|
||||
private var client: (any SFTPChannelProtocol)?
|
||||
private var transferContext: SFTPTransferContext?
|
||||
private var configuration: SSHConnectionConfiguration?
|
||||
private var additionalSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
private var usesEphemeralCredentials = false
|
||||
private var connectionTask: Task<Void, Never>?
|
||||
private var startsOnMount = true
|
||||
@@ -178,6 +178,8 @@ final class SFTPChannelModel: Identifiable {
|
||||
initialPath: String = "",
|
||||
client: (any SFTPChannelProtocol)? = nil,
|
||||
configuration: SSHConnectionConfiguration? = nil,
|
||||
additionalSnapshot: SSHAdditionalConnectionSnapshot? = nil,
|
||||
hostStore: (any RemoteSessionHostStore)? = nil,
|
||||
primaryRole: SFTPTransferPrimaryRole = .control,
|
||||
homeResolution: RemoteHomeResolutionState = .pending,
|
||||
automaticRefreshInterval: Duration = .seconds(15)
|
||||
@@ -190,10 +192,13 @@ final class SFTPChannelModel: Identifiable {
|
||||
self.pathText = path
|
||||
self.client = client
|
||||
startsOnMount = client == nil
|
||||
self.configuration = configuration
|
||||
if let client, let configuration {
|
||||
let retainedSnapshot =
|
||||
additionalSnapshot ?? configuration.map(SSHAdditionalConnectionSnapshot.init(configuration:))
|
||||
self.additionalSnapshot = retainedSnapshot
|
||||
if let client, let retainedSnapshot {
|
||||
transferContext = SFTPTransferContext(
|
||||
configuration: configuration, channel: client, primaryRole: primaryRole)
|
||||
snapshot: retainedSnapshot, hostStore: hostStore,
|
||||
channel: client, primaryRole: primaryRole)
|
||||
}
|
||||
self.homeResolution = homeResolution
|
||||
self.automaticRefreshInterval = automaticRefreshInterval
|
||||
@@ -350,7 +355,9 @@ final class SFTPChannelModel: Identifiable {
|
||||
credentials.append(
|
||||
stored.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: suppliedAuthentication.passphrase
|
||||
passphraseOverride: suppliedAuthentication.passphrase,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
))
|
||||
}
|
||||
if let password = suppliedAuthentication.password {
|
||||
@@ -366,6 +373,16 @@ final class SFTPChannelModel: Identifiable {
|
||||
for: host, submissions: suppliedAuthentications,
|
||||
temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: .allowChallenge
|
||||
),
|
||||
authenticationReference: SSHAuthenticationReference(
|
||||
identityID: suppliedAuthentication.identityID,
|
||||
fallbackKeyID: suppliedAuthentication.identityID == nil
|
||||
? suppliedAuthentication.keyID : nil,
|
||||
username: suppliedAuthentication.username,
|
||||
permitsNone: suppliedAuthentication.keyID == nil
|
||||
&& suppliedAuthentication.password == nil,
|
||||
hasEphemeralSecret: suppliedAuthentication.password != nil
|
||||
|| suppliedAuthentication.passphrase != nil
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -415,9 +432,10 @@ final class SFTPChannelModel: Identifiable {
|
||||
}
|
||||
self.connectionJourney.receive(.connected(host.id))
|
||||
self.client = client
|
||||
let transferConfiguration = store.configurationForAdditionalConnection(from: configuration)
|
||||
self.transferContext = SFTPTransferContext(configuration: transferConfiguration, channel: client)
|
||||
self.configuration = transferConfiguration
|
||||
let additionalSnapshot = store.additionalConnectionSnapshot(from: configuration)
|
||||
self.transferContext = SFTPTransferContext(
|
||||
snapshot: additionalSnapshot, hostStore: store, channel: client)
|
||||
self.additionalSnapshot = additionalSnapshot
|
||||
self.suppliedAuthentications.removeAll()
|
||||
self.temporaryHostKeys.removeAll()
|
||||
self.pendingAuthentication = nil
|
||||
@@ -483,6 +501,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
|
||||
func bindStatus(to store: any RemoteSessionHostStore) {
|
||||
statusStore = store
|
||||
transferContext?.configureHostStore(store)
|
||||
store.setConnectionStatus(status, connectionID: id, hostID: hostID)
|
||||
}
|
||||
|
||||
@@ -507,7 +526,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
let transferContext = transferContext
|
||||
self.transferContext = nil
|
||||
self.client = nil
|
||||
configuration = nil
|
||||
additionalSnapshot = nil
|
||||
usesEphemeralCredentials = false
|
||||
status = .disconnected
|
||||
homeResolution = .unavailable
|
||||
@@ -533,7 +552,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
let transferContext = transferContext
|
||||
self.transferContext = nil
|
||||
self.client = nil
|
||||
configuration = nil
|
||||
additionalSnapshot = nil
|
||||
status = .failed(reconnectState.lastError)
|
||||
homeResolution = .unavailable
|
||||
hint = reconnectState.lastError
|
||||
@@ -851,7 +870,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
do {
|
||||
let url = try await edits.open(
|
||||
file, hostID: hostID, connection: connection, client: client,
|
||||
homeDirectory: homeResolution.resolvedDirectory, configuration: configuration)
|
||||
homeDirectory: homeResolution.resolvedDirectory, snapshot: additionalSnapshot)
|
||||
completion(url)
|
||||
} catch { operationError = error.localizedDescription }
|
||||
}
|
||||
@@ -881,7 +900,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
guard status == .connected, let connection = transferConnection, let client else { return false }
|
||||
return await edits.upload(
|
||||
id, connection: connection, client: client,
|
||||
homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, configuration: configuration
|
||||
homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, snapshot: additionalSnapshot
|
||||
) { [weak self] in self?.scheduleSilentRefresh() }
|
||||
}
|
||||
|
||||
@@ -1068,7 +1087,7 @@ final class SFTPChannelModel: Identifiable {
|
||||
let transferContext = transferContext
|
||||
self.transferContext = nil
|
||||
self.client = nil
|
||||
configuration = nil
|
||||
additionalSnapshot = nil
|
||||
status = .reconnecting
|
||||
hint = String(localized: "Reconnecting")
|
||||
let host = store.host(hostID)
|
||||
|
||||
@@ -228,10 +228,10 @@ final class SSHSessionModel: Identifiable {
|
||||
private var reconnectController: SessionReconnectController { connection.reconnect }
|
||||
private var workingDirectoryObservation: AnyCancellable?
|
||||
private var remoteWindowTitleObservation: AnyCancellable?
|
||||
/// The exact authenticated route used by the live terminal, retained only
|
||||
/// in memory so a drop can open an independent SFTP connection without
|
||||
/// asking for credentials a second time. Cleared with the SSH connection.
|
||||
private(set) var transferConfiguration: SSHConnectionConfiguration?
|
||||
/// The live terminal's frozen route and credential IDs. Password, Key, and
|
||||
/// passphrase material are resolved only for each attempted transfer
|
||||
/// connection and never retained here. Cleared with the SSH connection.
|
||||
private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24)
|
||||
var connectionRoute: SSHConnectionRoute?
|
||||
private var transientHost: Host?
|
||||
@@ -371,7 +371,17 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
|
||||
var agentConnectionConfiguration: SSHConnectionConfiguration? {
|
||||
transferConfiguration
|
||||
guard let transferSnapshot, let statusStore else { return nil }
|
||||
return try? statusStore.additionalConnectionRequest(from: transferSnapshot).configuration
|
||||
}
|
||||
|
||||
var transferHostStore: (any RemoteSessionHostStore)? { statusStore }
|
||||
|
||||
func additionalConnectionRequest(
|
||||
from snapshot: SSHAdditionalConnectionSnapshot
|
||||
) throws -> SSHConnectionRequest {
|
||||
guard let statusStore else { throw TermindSSHError.missingCredential }
|
||||
return try statusStore.additionalConnectionRequest(from: snapshot)
|
||||
}
|
||||
|
||||
func requestTerminalFocus() {
|
||||
@@ -450,7 +460,7 @@ final class SSHSessionModel: Identifiable {
|
||||
homeResolution = .unavailable
|
||||
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
|
||||
workingDirectory = nil
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
commandExcludedFromHistory = nil
|
||||
statusStore = store
|
||||
let attemptID = UUID()
|
||||
@@ -523,7 +533,9 @@ final class SSHSessionModel: Identifiable {
|
||||
credentials.append(
|
||||
credential.sshCredential(
|
||||
publicKey: key.publicKey, certificate: key.certificate,
|
||||
passphraseOverride: suppliedAuthentication.passphrase
|
||||
passphraseOverride: suppliedAuthentication.passphrase,
|
||||
requiresPassphrase: key.requiresPassphraseEntry,
|
||||
requiresUserPresence: key.requiresUserPresence
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -621,13 +633,31 @@ final class SSHSessionModel: Identifiable {
|
||||
connection.clearHostKeyPrompt()
|
||||
connectionTask?.cancel()
|
||||
status = reconnecting ? .reconnecting : .connecting
|
||||
let authenticationReference: SSHAuthenticationReference
|
||||
if let identityID = suppliedAuthentication?.identityID ?? (suppliedAuthentication == nil ? identity?.id : nil) {
|
||||
authenticationReference = SSHAuthenticationReference(
|
||||
identityID: identityID,
|
||||
username: username,
|
||||
permitsNone: identity?.hasSecret == false,
|
||||
hasEphemeralSecret: suppliedAuthentication?.password != nil
|
||||
|| suppliedAuthentication?.passphrase != nil
|
||||
)
|
||||
} else {
|
||||
authenticationReference = SSHAuthenticationReference(
|
||||
fallbackKeyID: selectedKeyID,
|
||||
username: username,
|
||||
permitsNone: selectedKeyID == nil && suppliedAuthentication?.password == nil,
|
||||
hasEphemeralSecret: suppliedAuthentication?.password != nil
|
||||
|| suppliedAuthentication?.passphrase != nil
|
||||
)
|
||||
}
|
||||
connectionTask = Task { [weak self, weak store] in
|
||||
guard let self, let store else { return }
|
||||
do {
|
||||
try await openSSH(
|
||||
host: host, username: username, credentials: credentials,
|
||||
expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps,
|
||||
attemptID: attemptID,
|
||||
authenticationReference: authenticationReference, attemptID: attemptID,
|
||||
authorizeHostKey: { [weak self, weak store] challenge in
|
||||
guard let self, let store else { throw CancellationError() }
|
||||
return await self.connection.authorizeHostKey(
|
||||
@@ -760,7 +790,7 @@ final class SSHSessionModel: Identifiable {
|
||||
remoteWindowTitle = nil
|
||||
workingDirectory = nil
|
||||
homeResolution = .unavailable
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
pendingInitialCommand = nil
|
||||
commandExcludedFromHistory = nil
|
||||
pendingAuthentication = nil
|
||||
@@ -795,7 +825,7 @@ final class SSHSessionModel: Identifiable {
|
||||
host: Host, username: String, credentials: [SSHCredentials],
|
||||
expectedHostKeys: Set<SSHHostKeyIdentity>,
|
||||
proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration],
|
||||
attemptID: UUID,
|
||||
authenticationReference: SSHAuthenticationReference, attemptID: UUID,
|
||||
authorizeHostKey: @escaping SSHHostKeyAuthorizer
|
||||
) async throws {
|
||||
let wasReconnecting = connection.isRecovering
|
||||
@@ -803,7 +833,8 @@ final class SSHSessionModel: Identifiable {
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
hostID: host.id, host: host.host, port: host.port, username: username,
|
||||
credentials: credentials, expectedHostKeys: expectedHostKeys,
|
||||
proxy: proxy, jumps: jumps
|
||||
proxy: proxy, jumps: jumps,
|
||||
authenticationReference: authenticationReference
|
||||
)
|
||||
let backend = terminalBackend
|
||||
let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel(
|
||||
@@ -838,9 +869,9 @@ final class SSHSessionModel: Identifiable {
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard connectionAttemptID == attemptID else { throw CancellationError() }
|
||||
transferConfiguration =
|
||||
statusStore?.configurationForAdditionalConnection(from: configuration)
|
||||
?? configuration
|
||||
transferSnapshot =
|
||||
statusStore?.additionalConnectionSnapshot(from: configuration)
|
||||
?? SSHAdditionalConnectionSnapshot(configuration: configuration)
|
||||
suppliedAuthentications.removeAll()
|
||||
temporaryHostKeys.removeAll()
|
||||
_ = connection.markConnected()
|
||||
@@ -876,7 +907,7 @@ final class SSHSessionModel: Identifiable {
|
||||
remoteWindowTitle = nil
|
||||
workingDirectory = nil
|
||||
homeResolution = .unavailable
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
connectionAttemptID = nil
|
||||
ssh = nil
|
||||
switch result {
|
||||
@@ -921,7 +952,7 @@ final class SSHSessionModel: Identifiable {
|
||||
connectionTask = nil
|
||||
if let ssh { Task { await ssh.disconnect() } }
|
||||
ssh = nil
|
||||
transferConfiguration = nil
|
||||
transferSnapshot = nil
|
||||
status = .reconnecting
|
||||
let host = connectionHost(hostID, using: store)
|
||||
if let connectionRoute, let host {
|
||||
|
||||
@@ -180,7 +180,7 @@ struct TerminalPlaceholderView: View {
|
||||
private var uploadTargetProblem: String? {
|
||||
TerminalUploadAvailability.problem(
|
||||
isConnected: sshSession.status == .connected,
|
||||
hasTransferConfiguration: sshSession.transferConfiguration != nil,
|
||||
hasTransferConfiguration: sshSession.transferSnapshot != nil,
|
||||
isWorkingDirectoryPending: sshSession.homeResolution.isPending,
|
||||
workingDirectory: sshSession.workingDirectory
|
||||
)
|
||||
@@ -201,7 +201,7 @@ struct TerminalPlaceholderView: View {
|
||||
guard !files.isEmpty else { return false }
|
||||
cancelPendingUpload()
|
||||
guard uploadTargetProblem == nil,
|
||||
let configuration = sshSession.transferConfiguration,
|
||||
let snapshot = sshSession.transferSnapshot,
|
||||
let workingDirectory = sshSession.workingDirectory
|
||||
else {
|
||||
showUploadNotice(
|
||||
@@ -232,9 +232,9 @@ struct TerminalPlaceholderView: View {
|
||||
let resolved = try await TerminalUploadConnectionResolver.resolve(
|
||||
isCurrent: { sshSession.connectionAttemptID == generation },
|
||||
connectIndependent: {
|
||||
try await app.connections.connectSFTP(
|
||||
using: SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden),
|
||||
progress: nil
|
||||
let request = try sshSession.additionalConnectionRequest(from: snapshot)
|
||||
return try await app.connections.connectSFTP(
|
||||
using: request, progress: nil
|
||||
)
|
||||
},
|
||||
openPTYSFTPChannel: {
|
||||
@@ -248,7 +248,8 @@ struct TerminalPlaceholderView: View {
|
||||
hostID: sshSession.hostID,
|
||||
initialPath: destination,
|
||||
client: client,
|
||||
configuration: configuration,
|
||||
additionalSnapshot: snapshot,
|
||||
hostStore: sshSession.transferHostStore,
|
||||
primaryRole: resolved.primaryRole,
|
||||
homeResolution: sshSession.homeResolution
|
||||
)
|
||||
|
||||
@@ -536,7 +536,7 @@ private struct PaneView: View {
|
||||
case .sshSession(let sshSession):
|
||||
if let problem = TerminalUploadAvailability.problem(
|
||||
isConnected: sshSession.status == .connected,
|
||||
hasTransferConfiguration: sshSession.transferConfiguration != nil,
|
||||
hasTransferConfiguration: sshSession.transferSnapshot != nil,
|
||||
isWorkingDirectoryPending: sshSession.homeResolution.isPending,
|
||||
workingDirectory: sshSession.workingDirectory
|
||||
) {
|
||||
|
||||
@@ -195,7 +195,7 @@ struct AgentHostAuthenticationTests {
|
||||
|
||||
let configuration = try await session.configuration()
|
||||
|
||||
guard case .privateKey(_, _, let passphrase) = configuration.credentials.first else {
|
||||
guard case .privateKey(_, _, let passphrase, _) = configuration.credentials.first else {
|
||||
Issue.record("Expected a private-key credential")
|
||||
return
|
||||
}
|
||||
@@ -276,7 +276,7 @@ struct AgentHostAuthenticationTests {
|
||||
)))
|
||||
|
||||
let resolved = try await configuration.value
|
||||
guard case .privateKey(_, _, let passphrase) = resolved.credentials.first else {
|
||||
guard case .privateKey(_, _, let passphrase, _) = resolved.credentials.first else {
|
||||
Issue.record("Expected a private-key credential")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,6 +43,24 @@ struct ArchitectureBoundaryTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func additionalConnectionOwnersRetainReferencesInsteadOfCredentials() throws {
|
||||
let paths = [
|
||||
"../Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift",
|
||||
"Features/Terminal/SSHSessionModel.swift",
|
||||
"../Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift",
|
||||
]
|
||||
for path in paths {
|
||||
let source = try String(contentsOf: sourceRoot.appending(path: path), encoding: .utf8)
|
||||
#expect(containsToken("SSHAdditionalConnectionSnapshot", in: source))
|
||||
}
|
||||
let transferContext = try String(
|
||||
contentsOf: sourceRoot.appending(
|
||||
path: "../Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift"),
|
||||
encoding: .utf8)
|
||||
#expect(!containsToken("SSHConnectionConfiguration", in: transferContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
func mobileLayersKeepUIAndInfrastructureSeparate() throws {
|
||||
try assertNoImports(
|
||||
|
||||
@@ -169,7 +169,7 @@ struct PortForwardingReconnectTests {
|
||||
#expect(!context.journey.isPresented)
|
||||
}
|
||||
|
||||
@Test func temporaryPasswordAndKeyPassphraseSurviveOnlyInTheConnectionSnapshot() async throws {
|
||||
@Test func temporaryPasswordAndKeyPassphraseAreAbsentFromRecoverySnapshot() async throws {
|
||||
let fixture = ReconnectFixture()
|
||||
defer { fixture.stop() }
|
||||
let material = try SSHKeyMaterial.generate(.ed25519, comment: "recovery", passphrase: "temporary-passphrase")
|
||||
@@ -192,15 +192,11 @@ struct PortForwardingReconnectTests {
|
||||
try await fixture.waitForRunning([0])
|
||||
let request = try #require(await fixture.sessions.last!.requests.first)
|
||||
#expect(request.interactionPolicy == .forbidden)
|
||||
#expect(request.configuration.credentials.count == 2)
|
||||
guard case .privateKey(_, _, let passphrase) = request.configuration.credentials[0],
|
||||
case .password(let password) = request.configuration.credentials[1]
|
||||
else {
|
||||
Issue.record("Expected the authenticated credential snapshot")
|
||||
#expect(request.configuration.credentials.count == 1)
|
||||
guard case .interactionRequired = request.configuration.credentials[0] else {
|
||||
Issue.record("Expected a reference-only interaction marker")
|
||||
return
|
||||
}
|
||||
#expect(passphrase == "temporary-passphrase")
|
||||
#expect(password == "temporary-password")
|
||||
#expect(fixture.library.identity(identity.id)?.usesPassword == false)
|
||||
#expect(fixture.context?.journey.plainTextLog.contains("temporary-") == false)
|
||||
}
|
||||
|
||||
@@ -186,19 +186,20 @@ struct SFTPEditTests {
|
||||
|
||||
@Test func reconnectingWithADifferentAccountCannotUploadAnOldCopy() async throws {
|
||||
let fixture = Fixture()
|
||||
var configuration = SSHConnectionConfiguration(
|
||||
hostID: fixture.hostID, host: "test.invalid", port: 22, username: "first", credentials: [],
|
||||
expectedHostKeys: [])
|
||||
var snapshot = SSHAdditionalConnectionSnapshot(
|
||||
configuration: SSHConnectionConfiguration(
|
||||
hostID: fixture.hostID, host: "test.invalid", port: 22,
|
||||
username: "first", credentials: [], expectedHostKeys: []))
|
||||
let url = try await fixture.service.open(
|
||||
fixture.client.file, hostID: fixture.hostID, connection: fixture.connection,
|
||||
client: fixture.client, configuration: configuration)
|
||||
client: fixture.client, snapshot: snapshot)
|
||||
defer { fixture.clean(url) }
|
||||
try Data([9]).write(to: url)
|
||||
let id = try #require(fixture.service.model.files.first?.id)
|
||||
configuration.username = "second"
|
||||
snapshot.target.authentication.username = "second"
|
||||
#expect(
|
||||
await fixture.service.upload(
|
||||
id, connection: fixture.connection, client: fixture.client, configuration: configuration) == false)
|
||||
id, connection: fixture.connection, client: fixture.client, snapshot: snapshot) == false)
|
||||
#expect(fixture.service.model.file(id)?.isInvalidated == true)
|
||||
#expect(await fixture.client.uploads == 0)
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ struct SFTPTransferContextTests {
|
||||
let primary = TransferChannelClient(harness: harness, primary: true)
|
||||
let (context, coordinator) = fixture(primary: primary, harness: harness)
|
||||
coordinator.enqueueRemoteCopy(
|
||||
batchID: UUID(), sourcePath: "/source", sourceHostID: context.configuration.hostID,
|
||||
batchID: UUID(), sourcePath: "/source", sourceHostID: context.snapshot.hostID,
|
||||
sourceConnection: .context(context), destinationPath: "/copy",
|
||||
destinationHostID: context.configuration.hostID, destinationConnection: .context(context),
|
||||
destinationHostID: context.snapshot.hostID, destinationConnection: .context(context),
|
||||
byteCount: 1, overwrite: false)
|
||||
try await eventually { await harness.started == 1 }
|
||||
await context.releaseOwner()
|
||||
@@ -290,12 +290,14 @@ struct SFTPTransferContextTests {
|
||||
let primary = TransferChannelClient(harness: harness, primary: true)
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
hostID: UUID(), host: "terminal", port: 22, username: "user", credentials: [], expectedHostKeys: [])
|
||||
let store = HostLibraryModel()
|
||||
let coordinator = SFTPTransferCoordinator(
|
||||
store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) },
|
||||
store: store, clientFactory: { TransferChannelClient(harness: harness) },
|
||||
globalLimit: 15, perHostLimit: 5, strategy: .connectionsPreferred)
|
||||
let upload = SFTPChannelModel(
|
||||
hostID: configuration.hostID, initialPath: "/destination", client: primary,
|
||||
configuration: configuration, primaryRole: .donatedConnection)
|
||||
additionalSnapshot: SSHAdditionalConnectionSnapshot(configuration: configuration),
|
||||
hostStore: store, primaryRole: .donatedConnection)
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
@@ -325,14 +327,16 @@ struct SFTPTransferContextTests {
|
||||
|
||||
@Test func twoTransferContextsForOneHostShareTheSameFiveTransferLimit() async throws {
|
||||
let harness = TransferChannelHarness(behaviors: [])
|
||||
let store = HostLibraryModel()
|
||||
let coordinator = SFTPTransferCoordinator(
|
||||
store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) },
|
||||
store: store, clientFactory: { TransferChannelClient(harness: harness) },
|
||||
globalLimit: 15, perHostLimit: 5, strategy: .connectionsPreferred)
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
hostID: UUID(), host: "same-host", port: 22, username: "user", credentials: [], expectedHostKeys: [])
|
||||
let contexts = (0..<2).map { _ in
|
||||
SFTPTransferContext(
|
||||
configuration: configuration, channel: TransferChannelClient(harness: harness),
|
||||
snapshot: SSHAdditionalConnectionSnapshot(configuration: configuration), hostStore: store,
|
||||
channel: TransferChannelClient(harness: harness),
|
||||
primaryRole: .donatedConnection)
|
||||
}
|
||||
for context in contexts { enqueue(4, context: context, coordinator: coordinator) }
|
||||
@@ -352,13 +356,15 @@ struct SFTPTransferContextTests {
|
||||
primaryRole: SFTPTransferPrimaryRole = .control,
|
||||
strategy: SFTPConcurrencyStrategy = .connectionsPreferred
|
||||
) -> (SFTPTransferContext, SFTPTransferCoordinator) {
|
||||
let store = HostLibraryModel()
|
||||
let configuration = SSHConnectionConfiguration(
|
||||
hostID: UUID(), host: "fixture", port: 22, username: "user",
|
||||
credentials: [], expectedHostKeys: [])
|
||||
let context = SFTPTransferContext(
|
||||
configuration: SSHConnectionConfiguration(
|
||||
hostID: UUID(), host: "fixture", port: 22, username: "user",
|
||||
credentials: [], expectedHostKeys: []),
|
||||
snapshot: SSHAdditionalConnectionSnapshot(configuration: configuration), hostStore: store,
|
||||
channel: primary, primaryRole: primaryRole)
|
||||
let coordinator = SFTPTransferCoordinator(
|
||||
store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) },
|
||||
store: store, clientFactory: { TransferChannelClient(harness: harness) },
|
||||
globalLimit: limit, perHostLimit: limit, strategy: strategy)
|
||||
return (context, coordinator)
|
||||
}
|
||||
@@ -376,7 +382,7 @@ struct SFTPTransferContextTests {
|
||||
coordinator.enqueue(
|
||||
direction: .upload, batchID: UUID(),
|
||||
localURL: URL(fileURLWithPath: "/tmp/termind-test-\(index)"), remotePath: "/file-\(index)",
|
||||
byteCount: byteCount, hostID: context.configuration.hostID, connection: .context(context),
|
||||
byteCount: byteCount, hostID: context.snapshot.hostID, connection: .context(context),
|
||||
scopedRoot: URL(fileURLWithPath: "/tmp"), overwrite: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,11 @@ nonisolated struct SSHForwardingWireTests {
|
||||
]
|
||||
primary.authorizeHostKey = nil
|
||||
try await pool.connect(primary, progress: nil)
|
||||
await pool.configureAdditionalConnections(
|
||||
SSHConnectionRequest(configuration: primary.configuration, interactionPolicy: .forbidden))
|
||||
await pool.configureAdditionalConnections {
|
||||
SSHConnectionRequest(
|
||||
configuration: primary.configuration, interactionPolicy: .forbidden
|
||||
).enforcingInteractionPolicy()
|
||||
}
|
||||
let local = try rule(.local, fixture: fixture)
|
||||
try await pool.start(local, generation: UUID())
|
||||
defer { Task { await pool.disconnect() } }
|
||||
|
||||
Reference in new issue
Block a user