using System.Text; using Renci.SshNet; using Renci.SshNet.Common; namespace DodoSSH.Client.Ssh; /// /// Opens SSH connections with SSH.NET, checking host key trust during the handshake. /// /// /// The pinned fingerprint is looked up before connecting, so the comparison inside SSH.NET's /// synchronous HostKeyReceived event is a pure equality check with no I/O and no chance of /// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an /// exception the caller resolves asynchronously. /// public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory { private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15); /// public async Task ConnectAsync( SshConnectionRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var client = new SshClient(BuildConnectionInfo(request)); var gate = new HostKeyGate(knownHosts, request, cancellationToken); client.HostKeyReceived += gate.OnHostKeyReceived; try { await client.ConnectAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is SshConnectionException or SshAuthenticationException) { client.Dispose(); // Translate a refusal we caused ourselves into something the caller can act on. Without // this the user sees "connection lost" for what is really "do you trust this key?". throw gate.TranslateFailure() ?? exception; } catch { client.Dispose(); throw; } return new SshNetConnection(client, gate.Presented!); } /// /// Decides host key trust during the handshake, and remembers enough to explain a refusal. /// /// /// Separate from the connect method because the decision is the security-relevant part and reads /// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch, /// unpinned refuses as unknown. There is no fourth branch, and there is no prompt. /// private sealed class HostKeyGate( IKnownHostStore knownHosts, SshConnectionRequest request, CancellationToken cancellationToken) { /// What the server offered, once the handshake has reached that point. public HostKeyPresentation? Presented { get; private set; } private string? pinned; private bool mismatch; public void OnHostKeyReceived(object? sender, HostKeyEventArgs e) { var presentation = new HostKeyPresentation( request.Host, request.Port, e.HostKeyName, SshHostKeyFingerprint.Format(e.HostKey)); Presented = presentation; // Looked up here rather than before connecting, because the negotiated algorithm is only // known now and a server may choose a different one than it did last time. // // This is the one place the design cannot stay asynchronous: SSH.NET raises host key // verification synchronously. It is a local store read rather than a UI round trip, and // making the store synchronous instead would rule out a vault-backed implementation. pinned = knownHosts .FindAsync(request.Host, request.Port, e.HostKeyName, cancellationToken) .AsTask() .GetAwaiter() .GetResult(); if (pinned is null) { // Refused, not prompted. The caller decides, off the handshake thread. e.CanTrust = false; return; } var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint); mismatch = !matches; e.CanTrust = matches; } /// The specific exception for a refusal this gate caused, or null if it did not. public Exception? TranslateFailure() { if (Presented is not { } presentation) { return null; } if (mismatch && pinned is { } pin) { return new SshHostKeyMismatchException(presentation, pin); } return pinned is null ? new SshHostKeyUnknownException(presentation) : null; } } /// /// The known-host lookup inside the synchronous event is the one place this design cannot avoid /// blocking. It is a local store read rather than a UI round trip, and the alternative — making /// the store synchronous — would rule out the encrypted vault-backed implementation entirely. /// private static ConnectionInfo BuildConnectionInfo(SshConnectionRequest request) { AuthenticationMethod method = request.Credential switch { SshPasswordCredential password => new PasswordAuthenticationMethod(request.Username, password.Password), SshPrivateKeyCredential key => new PrivateKeyAuthenticationMethod( request.Username, CreatePrivateKeyFile(key)), _ => throw new NotSupportedException( $"Credential type {request.Credential.GetType().Name} is not supported."), }; return new ConnectionInfo(request.Host, request.Port, request.Username, method) { Timeout = request.ConnectTimeout ?? DefaultConnectTimeout, }; } private static PrivateKeyFile CreatePrivateKeyFile(SshPrivateKeyCredential credential) { using var stream = new MemoryStream(credential.PrivateKeyPem, writable: false); return credential.Passphrase is null ? new PrivateKeyFile(stream) : new PrivateKeyFile(stream, credential.Passphrase); } } /// An SSH.NET-backed connection. internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hostKey) : ISshConnection { /// public bool IsConnected => client.IsConnected; /// public HostKeyPresentation HostKey { get; } = hostKey; /// public Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var effective = size.IsUsable ? size : TerminalSize.Default; // 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here // only delays the first byte reaching the screen. var shell = client.CreateShellStream( "xterm-256color", effective.Columns, effective.Rows, effective.PixelWidth, effective.PixelHeight, 4096); return Task.FromResult(new SshNetShellSession(shell)); } /// public ValueTask DisposeAsync() { client.Dispose(); return ValueTask.CompletedTask; } } /// An SSH.NET-backed shell session. internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession { /// public bool IsOpen => shell.CanRead; /// /// /// ShellStream does not override ReadAsync, so the base /// implementation runs the blocking read on a thread-pool thread. Every idle session therefore /// parks one thread; see docs/platform-flags.md. /// public ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) => shell.ReadAsync(buffer, cancellationToken); /// public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => shell.WriteAsync(data, cancellationToken); /// public void Resize(TerminalSize size) { if (!size.IsUsable) { // A collapsed pane or a minimised window produces these. Forwarding one leaves the // remote's idea of the terminal nonsensical until the next resize arrives. return; } shell.ChangeWindowSize(size.Columns, size.Rows, size.PixelWidth, size.PixelHeight); } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync().ConfigureAwait(false); } } /// Convenience helpers over a shell session. public static class SshShellSessionExtensions { /// Writes UTF-8 text to the remote. public static ValueTask WriteTextAsync( this ISshShellSession session, string text, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(session); return session.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken); } }