Files

495 lines
24 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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 connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck?
func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws
func resolveHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) async throws
func requireSavingHosts(_ proposed: [Host]) throws
func connectionWakeAction(for hostIDs: [UUID]) -> ConnectionWakeAction?
func host(_ id: Host.ID) -> Host?
func networkConfigurationStamp(for host: Host) -> Data
func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)?
func savedTailscaleUsername(for host: Host) -> String?
func tailscaleUsername(for host: Host) -> String
@discardableResult func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool
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 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, submission: SSHProxyAuthenticationSubmission?
) throws -> SSHTransportProxy?
func sshCredentials(for identity: Identity) -> SSHCredentialResolution
func passwordSecret(for identity: Identity) throws -> SSHSecret?
func passwordAvailability(for identity: Identity) -> SecretAvailability
func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws
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)
func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws
func upsert(_ host: Host)
}
extension RemoteSessionHostStore {
public func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck? { nil }
// Explicit overloads preserve witness dispatch; default arguments on a fallback can bypass it.
public func requireHostAccess(_ host: Host) throws { try requireHostAccess(host, protocol: .ssh) }
public func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws {}
public func resolveHostAccess(_ host: Host) async throws { try await resolveHostAccess(host, protocol: .ssh) }
public func resolveHostAccess(
_ host: Host, protocol connectionProtocol: HostTerminalProtocol
) async throws {
try requireHostAccess(host, protocol: connectionProtocol)
}
public func resolveHostAccess(_ host: Host, route: SSHConnectionRoute?) async throws {
try await resolveHostAccess(host)
for node in route?.hosts ?? [] { try await resolveHostAccess(node) }
for id in route.map({ Array($0.sourceHostIDs.values) }) ?? [] {
if let saved = self.host(id) { try await resolveHostAccess(saved) }
}
}
public func requireSavingHosts(_ proposed: [Host]) throws {}
public func connectionWakeAction(for hostIDs: [UUID]) -> ConnectionWakeAction? { nil }
public func savedTailscaleUsername(for host: Host) -> String? { nil }
public func tailscaleUsername(for host: Host) -> String { "root" }
public func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool { false }
public func networkConfigurationStamp(for host: Host) -> Data { Data() }
public func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
guard !host.isTailscaleManaged else { throw TailscaleConfigurationError.unavailable }
return nil
}
public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws {
throw TermindSSHError.missingCredential
}
public func passphraseSubmission(
for prompt: SSHAuthenticationPrompt, existing: SSHAuthenticationSubmission?,
passphrase: String?, save: Bool
) throws -> SSHAuthenticationSubmission {
guard let passphrase, !passphrase.isEmpty else { throw TermindSSHError.keyPassphraseRequired }
var submission =
existing
?? SSHAuthenticationSubmission(
username: prompt.username, keyID: prompt.selectedKeyID, identityID: prompt.selectedIdentityID)
guard let keyID = submission.keyID else { throw TermindSSHError.missingCredential }
if save && prompt.allowsSaving { try saveKeyPassphrase(passphrase, for: keyID) }
submission.passphrase = passphrase
return submission
}
/// Query the local Keychain rather than relying on synchronized metadata.
/// Every probe releases its private key and passphrase immediately.
public func requiresPassphraseEntry(for key: SSHKeyRef) -> Bool {
guard key.hasPassphrase else { return false }
do {
guard let credential = try keySecretCredential(for: key) else { return true }
defer { credential.clearSecrets() }
return !credential.hasSavedPassphrase
} catch { return true }
}
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 {
try requireHostAccess(host, protocol: .telnet)
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
}
var configuration = TelnetConnectionConfiguration(
host: host.host, port: host.telnetPort,
username: username?.isEmpty == false ? username : nil,
password: password, charset: host.telnetCharset)
configuration.accessCheck = connectionAccessCheck(for: [host.id])
configuration.beforeConnect = connectionWakeAction(for: [host.id])
return configuration
}
/// 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 {
try requireSavingHosts([host])
if host.isTailscaleManaged {
guard saveTailscaleUsername(submission.username, hostID: host.id) else {
throw TailscaleConfigurationError.unavailable
}
return 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 {
try requireSavingHosts([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: configuration.tailscaleNodeID == nil
? 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)
)
}
var result = SSHAdditionalConnectionSnapshot(target: target, jumps: jumps)
result.accessHostIDs = configuration.accessHostIDs
result.tailscaleNodeID = configuration.tailscaleNodeID
result.requiresTailscale = configuration.network != nil
return result
}
/// 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 {
for id in snapshot.accessHostIDs {
if let saved = host(id) { try requireHostAccess(saved) }
}
for node in snapshot.jumps + [snapshot.target] {
if let saved = host(node.id) { try requireHostAccess(saved) }
}
var completed = false
var resolvedCredentials: [SSHCredentials] = []
var resolvedProxies: [SSHSecret] = []
defer {
if !completed {
for credential in resolvedCredentials { credential.clearSecrets() }
for secret in resolvedProxies { secret.clear() }
}
}
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 }
resolvedProxies.append(password)
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.requiresUserPresence || requiresPassphraseEntry(for: key) {
credentials.append(.interactionRequired)
} else {
do {
if let stored = try connectionKeySecretCredential(for: key) {
credentials.append(
stored.sshCredential(
for: key
)
)
} 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 }
}
resolvedCredentials += credentials
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
)
}
var 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
)
configuration.accessHostIDs = snapshot.accessHostIDs
configuration.accessCheck = connectionAccessCheck(
for: snapshot.accessHostIDs + snapshot.jumps.map(\.id) + [snapshot.target.id])
configuration.beforeConnect = connectionWakeAction(for: snapshot.jumps.map(\.id) + [snapshot.target.id])
configuration.tailscaleNodeID = snapshot.tailscaleNodeID
if let first = host(snapshot.jumps.first?.id ?? snapshot.target.id) {
configuration.network = try tailscaleNetwork(for: first)
}
if snapshot.requiresTailscale && configuration.network == nil {
throw TailscaleConfigurationError.unavailable
}
completed = true
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 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
}
}