From 8209f157417209134186364677f681b9cd818201 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sat, 8 Aug 2026 20:54:14 +0200 Subject: [PATCH] Let a session's transport say what it negotiated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISshConnection and ISftpSession both carry Cipher now — the server-to-client algorithm off SSH.NET's own ConnectionInfo, captured once because a rekey is not an event that library raises — and TerminalWorkspace.GetSessionFacts hands that plus the host key's algorithm back per session, without ever handing over the connection itself. Nothing reads either yet; the status bar that will is the next commit. --- src/DodoSSH.Client.Ssh/SftpSession.cs | 11 +++++ src/DodoSSH.Client.Ssh/SshConnection.cs | 19 ++++++++ .../SshNetConnectionFactory.cs | 10 +++++ src/DodoSSH.Client.Ssh/SshNetSftpSession.cs | 8 ++++ .../TerminalWorkspace.cs | 43 +++++++++++++++++++ tests/DodoSSH.Client.App.Tests/FakeSsh.cs | 6 +++ .../KeyAuthenticationTests.cs | 6 +++ .../FakeShellSession.cs | 3 ++ .../TerminalWorkspaceTests.cs | 25 +++++++++++ .../FakeSftpSession.cs | 3 ++ 10 files changed, 134 insertions(+) diff --git a/src/DodoSSH.Client.Ssh/SftpSession.cs b/src/DodoSSH.Client.Ssh/SftpSession.cs index 8dd61a5..eb0a6cf 100644 --- a/src/DodoSSH.Client.Ssh/SftpSession.cs +++ b/src/DodoSSH.Client.Ssh/SftpSession.cs @@ -265,6 +265,17 @@ public interface ISftpSession : IRemoteFileStore { /// The host key that was accepted for this session. HostKeyPresentation HostKey { get; } + + /// + /// The negotiated server-to-client encryption algorithm, e.g. aes256-gcm@openssh.com. + /// + /// + /// The same fact is, read the same way — off SSH.NET's + /// ConnectionInfo.CurrentServerEncryption once the handshake this session's own connect performed + /// has finished — and for the same reason: SftpClient derives from BaseClient exactly as + /// SshClient does, and rekeys are no more visible here than they are there. See that member's remark. + /// + string Cipher { get; } } /// diff --git a/src/DodoSSH.Client.Ssh/SshConnection.cs b/src/DodoSSH.Client.Ssh/SshConnection.cs index a81a9a3..3939d2e 100644 --- a/src/DodoSSH.Client.Ssh/SshConnection.cs +++ b/src/DodoSSH.Client.Ssh/SshConnection.cs @@ -98,6 +98,25 @@ public interface ISshConnection : IAsyncDisposable /// The host key that was accepted for this connection. HostKeyPresentation HostKey { get; } + /// + /// The negotiated server-to-client encryption algorithm, e.g. aes256-gcm@openssh.com. + /// + /// + /// + /// Read once, immediately after the handshake, off SSH.NET's own ConnectionInfo.CurrentServerEncryption. + /// The only event that could make this stale is a rekey, and SSH.NET raises no event for one and exposes no + /// way to ask again — there is nothing behind this property to go and re-read. A captured value is therefore + /// not a snapshot that might drift; it is the only value there has ever been a moment to observe. + /// + /// + /// Server-to-client, not client-to-server. SSH negotiates the two directions independently and a + /// server is free to choose differently for each, so the two can in principle disagree. This is the + /// direction the bytes drawn on a terminal pane travelled in, which is the fact a status bar showing what + /// the screen is made of should be naming. + /// + /// + string Cipher { get; } + /// Opens an interactive shell with a pseudo-terminal. Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken); } diff --git a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs index 54778ed..ed768d9 100644 --- a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs +++ b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs @@ -274,6 +274,16 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos /// public HostKeyPresentation HostKey { get; } = hostKey; + /// + /// + /// Read at construction rather than lazily: by the time an exists, + /// has already awaited client.ConnectAsync, so + /// ConnectionInfo is already populated and there is no earlier moment reading it would race. SSH.NET + /// types the property as a non-nullable string, so this reads straight through rather than coalescing + /// a null that the library's own contract says cannot occur. + /// + public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption; + /// public Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken) { diff --git a/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs b/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs index bb28263..d41b64e 100644 --- a/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs +++ b/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs @@ -20,6 +20,14 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h /// public HostKeyPresentation HostKey { get; } = hostKey; + /// + /// + /// Read at construction, the same way and for the same reason as SshNetConnection.Cipher: this type + /// is only ever built after SshNetConnectionFactory.OpenSftpAsync has awaited client.ConnectAsync, + /// so ConnectionInfo is already populated by the time there is a session to read it from. + /// + public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption; + /// public string HomeDirectory { get; } = homeDirectory; diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs index c8190cf..79a6dc9 100644 --- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs +++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs @@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs public uint SessionId { get; } = sessionId; } +/// +/// The negotiated cipher and host-key algorithm for one live session. +/// +/// The server-to-client encryption algorithm; see . +/// The host key's algorithm, e.g. ssh-ed25519. +/// +/// Two facts rather than the whole , because that is all a caller outside this +/// assembly has any business reading off a session it does not own — everything else on the connection +/// (disposal, the shell) belongs to the workspace alone. See . +/// +public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm); + /// The renderer asking for a different font size. /// /// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a @@ -180,6 +192,37 @@ public sealed class TerminalWorkspace : IAsyncDisposable } } + /// + /// The negotiated cipher and host-key algorithm for one live session, or null when the id names no + /// session this workspace still has open. + /// + /// + /// + /// Additive, and deliberately narrow: the caller this exists for — the shell's own connect path, which + /// wants these two facts for its status bar — has no other business with a session it does not own, and a + /// method that handed back the itself would have handed over the shell, + /// disposal and all, to code that already goes through for that. + /// + /// + /// "No session this workspace still has open" covers two different absences the same way + /// already does: an id this workspace never issued, and one whose shell has + /// already ended but whose entry has not been removed yet. Both are "nothing to report" to a caller + /// asking what a session's transport looks like right now. + /// + /// + public SessionFacts? GetSessionFacts(uint sessionId) + { + lock (sessionGate) + { + if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted) + { + return null; + } + + return new SessionFacts(session.Connection.Cipher, session.Connection.HostKey.Algorithm); + } + } + /// /// Raised with the session id when a shell ends on its own. /// diff --git a/tests/DodoSSH.Client.App.Tests/FakeSsh.cs b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs index 72d0bf1..36f9d12 100644 --- a/tests/DodoSSH.Client.App.Tests/FakeSsh.cs +++ b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs @@ -83,6 +83,9 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi public HostKeyPresentation HostKey { get; } = new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake"); + /// + public string Cipher { get; } = "aes256-gcm@openssh.com"; + /// public string HomeDirectory => $"/home/{request.Username}"; @@ -139,6 +142,9 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn public HostKeyPresentation HostKey { get; } = new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake"); + /// + public string Cipher { get; } = "aes256-gcm@openssh.com"; + /// public Task OpenShellAsync( TerminalSize size, diff --git a/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs b/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs index ddfbd1f..8ed48ef 100644 --- a/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs +++ b/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs @@ -36,6 +36,12 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture) connection.IsConnected.ShouldBeTrue(); + // The one place this suite checks Cipher against a real handshake rather than a fake's fixed string. + // SSH.NET negotiates whatever the container's sshd offers first from its own preference list, so the + // exact algorithm is not pinned here — only that ConnectionInfo.CurrentServerEncryption came back as + // something rather than the empty string a stalled or pre-handshake read would produce. + connection.Cipher.ShouldNotBeNullOrEmpty(); + // Authenticated is not the same as usable: a channel has to open on the connection too. await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token); diff --git a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs index 003f655..abbbbf0 100644 --- a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs +++ b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs @@ -136,6 +136,9 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer public HostKeyPresentation HostKey { get; } = new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake"); + /// + public string Cipher { get; } = "aes256-gcm@openssh.com"; + /// The shell this connection opened, if it opened one. internal FakeShellSession? Shell { get; private set; } diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs index c15d086..feac62b 100644 --- a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs +++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs @@ -149,6 +149,31 @@ public sealed class TerminalWorkspaceTests workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another"); } + /// + /// The shell's connect path reads these back once, right after + /// returns, to fill in the status bar's cipher and host-key facts — see VaultViewModel.ConnectAndAnnounceAsync. + /// Asserted the same way asserts liveness: per session, and + /// null rather than thrown for an id this workspace never issued. + /// + [Fact] + public async Task SessionFactsAreReadPerSession() + { + var connections = new FakeConnectionFactory(); + + await using var workspace = CreateWorkspace(connections); + + var sessionId = await workspace.OpenSessionAsync( + Request(), TerminalSize.Default, TestContext.Current.CancellationToken); + + var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull(); + var connection = connections.Connections.ShouldHaveSingleItem(); + + facts.Cipher.ShouldBe(connection.Cipher); + facts.HostKeyAlgorithm.ShouldBe(connection.HostKey.Algorithm); + + workspace.GetSessionFacts(9999).ShouldBeNull("this workspace never issued that id"); + } + /// /// /// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops diff --git a/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs b/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs index f246782..b54292e 100644 --- a/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs +++ b/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs @@ -23,6 +23,9 @@ internal sealed class FakeSftpSession : ISftpSession /// public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake"); + /// + public string Cipher { get; } = "aes256-gcm@openssh.com"; + /// public string HomeDirectory => "/home/dodo";