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";