Files
Termind/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift
T

473 lines
22 KiB
Swift

import Foundation
import TermindDomain
import struct TermindDomain.Host
/// The host, trust, and credential capabilities required to establish a live
/// SSH-backed session. Session features depend on this port instead of gaining
/// the complete Vault mutation surface from `HostLibraryModel`.
@MainActor
public protocol RemoteSessionHostStore: AnyObject, Sendable {
func host(_ id: Host.ID) -> Host?
func snippet(_ id: Snippet.ID) -> Snippet?
func identity(_ id: Identity.ID) -> Identity?
func identity(for host: Host) -> Identity?
func identity(for host: Host, protocol connectionProtocol: HostTerminalProtocol) -> Identity?
func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity?
var customPasswordStorage: PasswordStorage { get }
func key(_ id: SSHKeyRef.ID) -> SSHKeyRef?
func knownHosts(for host: Host) -> [KnownHostEntry]
func route(for host: Host) -> [Host]
func expectedHostKeys(
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> Set<SSHHostKeyIdentity>
func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation
func telnetConnectionConfiguration(for host: Host) throws -> TelnetConnectionConfiguration
func sshConnectionConfiguration(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> SSHConnectionConfiguration
func sshJumpConfigurations(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration]
func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
) throws -> SSHConnectionPreparation
func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration]
func saveConnectionRoute(
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission?,
authenticationHostID: Host.ID?, suppliedPassword: String?
) throws -> SSHConnectionRoute
func transportProxy(for host: Host) throws -> SSHTransportProxy?
func transportProxy(
for host: Host, submission: SSHProxyAuthenticationSubmission?
) throws -> SSHTransportProxy?
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
func passwordSecret(for identity: Identity) throws -> SSHSecret?
func passwordAvailability(for identity: Identity) -> SecretAvailability
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential?
func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID)
func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID)
func trust(_ entry: KnownHostEntry)
@discardableResult func upsert(_ identity: Identity) -> Bool
func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws
func upsert(_ host: Host)
}
extension RemoteSessionHostStore {
public func snippet(_ id: Snippet.ID) -> Snippet? { nil }
public func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? { nil }
public var customPasswordStorage: PasswordStorage { .iCloud }
public func identity(for host: Host, protocol connectionProtocol: HostTerminalProtocol) -> Identity? {
host.identityID(for: connectionProtocol).flatMap(identity)
}
public func telnetConnectionConfiguration(for host: Host) throws -> TelnetConnectionConfiguration {
guard host.telnetEnabled else { throw HostProtocolUnavailableError(useCase: .telnetTerminal) }
let identity = identity(for: host, protocol: .telnet)
let username = identity?.username.trimmingCharacters(in: .whitespacesAndNewlines)
let password: SSHSecret?
if let identity, identity.usesPassword {
password = try connectionPasswordSecret(for: identity)
guard password != nil else { throw TermindSSHError.missingCredential }
} else {
password = nil
}
return TelnetConnectionConfiguration(
hostID: host.id, host: host.host, port: host.telnetPort,
username: username?.isEmpty == false ? username : nil,
password: password, charset: host.telnetCharset)
}
public func prepareSSHConnection(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try prepareSSHConnection(for: host, submissions: submissions, proxySubmissions: [:])
}
public func sshConnectionConfiguration(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> SSHConnectionConfiguration {
try sshConnectionConfiguration(
for: host, submissions: submissions, proxySubmissions: [:],
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy)
}
public func sshJumpConfigurations(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try sshJumpConfigurations(
for: host, submissions: submissions, proxySubmissions: [:],
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy)
}
public func prepareSSHConnection(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission]
) throws -> SSHConnectionPreparation {
try prepareSSHConnection(for: route, submissions: submissions, proxySubmissions: [:])
}
public func sshJumpConfigurations(
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
) throws -> [SSHRouteHopConfiguration] {
try sshJumpConfigurations(
for: route, submissions: submissions, proxySubmissions: [:],
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy)
}
/// Captures a desktop authentication form without saving its input. A
/// selected Identity stays reference-only so its saved password is read
/// directly into a one-shot buffer only when the connection is resolved.
public func authenticationSubmission(
username: String, password: String?, passphrase: String?, keyID: SSHKeyRef.ID?,
selectedIdentity: Identity?
) throws -> SSHAuthenticationSubmission {
SSHAuthenticationSubmission(
username: username,
password: password,
passphrase: passphrase, keyID: keyID, identityID: selectedIdentity?.id
)
}
/// Saves the optional form edit before associating its Host. A selected
/// Identity changes only for an explicitly supplied password. Custom input
/// reuses the Host's Identity, preserving the existing save boundaries.
public func saveAuthentication(
_ submission: SSHAuthenticationSubmission, for host: Host,
selectedIdentity: Identity?, suppliedPassword: String?
) throws -> Host {
let identity: Identity
if let selectedIdentity {
if let suppliedPassword {
switch passwordAvailability(for: selectedIdentity) {
case .missing, .failed:
throw TermindSSHError.library(
String(localized: "The selected Identity cannot be updated on this device."))
case .notConfigured, .available:
break
}
try upsert(
selectedIdentity,
passwordUpdate: suppliedPassword.isEmpty ? .remove : .replace(suppliedPassword))
}
identity = selectedIdentity
} else {
let candidate = customIdentity(for: host.id, purpose: .ssh)
let existing: Identity?
if let candidate {
switch passwordAvailability(for: candidate) {
case .missing, .failed: existing = nil
case .notConfigured, .available: existing = candidate
}
} else {
existing = nil
}
identity = Identity(
id: existing?.id ?? UUID(), name: existing?.name ?? submission.username,
username: submission.username, credentialRef: existing?.credentialRef ?? "",
passwordStorage: existing?.passwordStorage ?? customPasswordStorage,
keyID: submission.keyID, notes: existing?.notes ?? "",
colorTag: existing?.colorTag ?? .none,
ownerHostID: host.id, customPurpose: .ssh
)
try upsert(
identity,
passwordUpdate: suppliedPassword.map {
$0.isEmpty ? IdentityPasswordUpdate.remove : .replace($0)
}
?? (existing == nil ? .remove : .keep)
)
}
var updated = host
updated.identityID = identity.id
upsert(updated)
return updated
}
public func saveProxyAuthentication(
_ submission: SSHProxyAuthenticationSubmission, for host: Host,
selectedIdentity: Identity?, suppliedPassword: String?
) throws -> Host {
guard var proxy = host.proxy else {
throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete."))
}
let identity: Identity
if let selectedIdentity {
if let suppliedPassword {
switch passwordAvailability(for: selectedIdentity) {
case .missing, .failed:
throw TermindSSHError.library(
String(localized: "The selected Identity cannot be updated on this device."))
case .notConfigured, .available:
break
}
try upsert(
selectedIdentity,
passwordUpdate: suppliedPassword.isEmpty ? .remove : .replace(suppliedPassword))
}
identity = selectedIdentity
} else {
let existing = customIdentity(for: host.id, purpose: .proxy)
identity = Identity(
id: existing?.id ?? UUID(), name: existing?.name ?? submission.username,
username: submission.username, credentialRef: existing?.credentialRef ?? "",
passwordStorage: existing?.passwordStorage ?? customPasswordStorage,
notes: existing?.notes ?? "",
colorTag: existing?.colorTag ?? .none, ownerHostID: host.id, customPurpose: .proxy
)
try upsert(
identity,
passwordUpdate: suppliedPassword.map {
$0.isEmpty ? IdentityPasswordUpdate.remove : .replace($0)
} ?? .remove)
}
proxy.username = submission.username
proxy.identityID = identity.id
var updated = host
updated.proxy = proxy
upsert(updated)
return updated
}
/// 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
) -> 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))
}
func proxy(_ value: SSHTransportProxy?) -> SSHAdditionalConnectionSnapshot.Proxy? {
value.map {
SSHAdditionalConnectionSnapshot.Proxy(
kind: $0.kind, hostname: $0.hostname, port: $0.port,
username: $0.username,
identityID: $0.identityID
)
}
}
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?, ownerHostID: Host.ID
) 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: value.username, ownerHostID: ownerHostID
)
}
guard let identity = identity(identityID) else {
throw TermindSSHError.missingCredential
}
let password = try connectionPasswordSecret(for: identity)
guard let password else { throw TermindSSHError.missingCredential }
return SSHTransportProxy(
kind: value.kind, hostname: value.hostname, port: value.port,
username: value.username, password: password,
identityID: identity.id, ownerHostID: ownerHostID
)
}
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 connectionKeySecretCredential(for: key) {
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 connectionPasswordSecret(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, ownerHostID: node.id),
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, ownerHostID: snapshot.target.id), jumps: jumps,
charset: snapshot.target.charset,
authenticationReference: snapshot.target.authentication
)
return SSHConnectionRequest(
configuration: configuration, interactionPolicy: .forbidden
).enforcingInteractionPolicy()
}
public func connectionPasswordSecret(for identity: Identity) throws -> SSHSecret? {
try passwordSecret(for: identity)
}
public func connectionKeySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
try keySecretCredential(for: key)
}
public func expectedHostKeys(
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> Set<SSHHostKeyIdentity> {
try expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys,
trustPolicy: .requireKnownHost
)
}
public func sshConnectionConfiguration(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionConfiguration {
try sshConnectionConfiguration(
for: host, submissions: submissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .requireKnownHost
)
}
public func sshJumpConfigurations(
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> [SSHRouteHopConfiguration] {
try sshJumpConfigurations(
for: host, submissions: submissions,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .requireKnownHost
)
}
public func prepareSSHConnection(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionPreparation {
var preparation = try prepareSSHConnection(for: host, submissions: submissions)
for index in preparation.nodes.indices {
if let key = temporaryHostKeys[preparation.nodes[index].host.id] {
preparation.nodes[index].trustedKeys.insert(key)
}
}
return preparation
}
public func prepareSSHConnection(
for host: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity]
) throws -> SSHConnectionPreparation {
var preparation = try prepareSSHConnection(
for: host, submissions: submissions, proxySubmissions: proxySubmissions)
for index in preparation.nodes.indices {
if let key = temporaryHostKeys[preparation.nodes[index].host.id] {
preparation.nodes[index].trustedKeys.insert(key)
}
}
return preparation
}
}