Public Access
Let a session's transport say what it negotiated
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.
This commit is contained in:
@@ -265,6 +265,17 @@ public interface ISftpSession : IRemoteFileStore
|
|||||||
{
|
{
|
||||||
/// <summary>The host key that was accepted for this session.</summary>
|
/// <summary>The host key that was accepted for this session.</summary>
|
||||||
HostKeyPresentation HostKey { get; }
|
HostKeyPresentation HostKey { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same fact <see cref="ISshConnection.Cipher"/> is, read the same way — off SSH.NET's
|
||||||
|
/// <c>ConnectionInfo.CurrentServerEncryption</c> once the handshake this session's own connect performed
|
||||||
|
/// has finished — and for the same reason: <c>SftpClient</c> derives from <c>BaseClient</c> exactly as
|
||||||
|
/// <c>SshClient</c> does, and rekeys are no more visible here than they are there. See that member's remark.
|
||||||
|
/// </remarks>
|
||||||
|
string Cipher { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -98,6 +98,25 @@ public interface ISshConnection : IAsyncDisposable
|
|||||||
/// <summary>The host key that was accepted for this connection.</summary>
|
/// <summary>The host key that was accepted for this connection.</summary>
|
||||||
HostKeyPresentation HostKey { get; }
|
HostKeyPresentation HostKey { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Read once, immediately after the handshake, off SSH.NET's own <c>ConnectionInfo.CurrentServerEncryption</c>.
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Server-to-client, not client-to-server.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
string Cipher { get; }
|
||||||
|
|
||||||
/// <summary>Opens an interactive shell with a pseudo-terminal.</summary>
|
/// <summary>Opens an interactive shell with a pseudo-terminal.</summary>
|
||||||
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,16 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
|
||||||
|
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
|
||||||
|
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
|
||||||
|
/// types the property as a non-nullable <c>string</c>, so this reads straight through rather than coalescing
|
||||||
|
/// a null that the library's own contract says cannot occur.
|
||||||
|
/// </remarks>
|
||||||
|
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
|
||||||
|
/// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
|
||||||
|
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from.
|
||||||
|
/// </remarks>
|
||||||
|
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory { get; } = homeDirectory;
|
public string HomeDirectory { get; } = homeDirectory;
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
|
|||||||
public uint SessionId { get; } = sessionId;
|
public uint SessionId { get; } = sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated cipher and host-key algorithm for one live session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Cipher">The server-to-client encryption algorithm; see <see cref="ISshConnection.Cipher"/>.</param>
|
||||||
|
/// <param name="HostKeyAlgorithm">The host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two facts rather than the whole <see cref="ISshConnection"/>, 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 <see cref="TerminalWorkspace.GetSessionFacts"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm);
|
||||||
|
|
||||||
/// <summary>The renderer asking for a different font size.</summary>
|
/// <summary>The renderer asking for a different font size.</summary>
|
||||||
/// <param name="step">
|
/// <param name="step">
|
||||||
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
|
/// 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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The negotiated cipher and host-key algorithm for one live session, or null when the id names no
|
||||||
|
/// session this workspace still has open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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 <see cref="ISshConnection"/> itself would have handed over the shell,
|
||||||
|
/// disposal and all, to code that already goes through <see cref="CloseSessionAsync"/> for that.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// "No session this workspace still has open" covers two different absences the same way
|
||||||
|
/// <see cref="IsSessionLive"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised with the session id when a shell ends on its own.
|
/// Raised with the session id when a shell ends on its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
public HostKeyPresentation HostKey { get; } =
|
public HostKeyPresentation HostKey { get; } =
|
||||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory => $"/home/{request.Username}";
|
public string HomeDirectory => $"/home/{request.Username}";
|
||||||
|
|
||||||
@@ -139,6 +142,9 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn
|
|||||||
public HostKeyPresentation HostKey { get; } =
|
public HostKeyPresentation HostKey { get; } =
|
||||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(
|
public Task<ISshShellSession> OpenShellAsync(
|
||||||
TerminalSize size,
|
TerminalSize size,
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
|
|
||||||
connection.IsConnected.ShouldBeTrue();
|
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.
|
// 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);
|
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
|||||||
public HostKeyPresentation HostKey { get; } =
|
public HostKeyPresentation HostKey { get; } =
|
||||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <summary>The shell this connection opened, if it opened one.</summary>
|
/// <summary>The shell this connection opened, if it opened one.</summary>
|
||||||
internal FakeShellSession? Shell { get; private set; }
|
internal FakeShellSession? Shell { get; private set; }
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,31 @@ public sealed class TerminalWorkspaceTests
|
|||||||
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
|
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The shell's connect path reads these back once, right after <see cref="TerminalWorkspace.OpenSessionAsync"/>
|
||||||
|
/// returns, to fill in the status bar's cipher and host-key facts — see <c>VaultViewModel.ConnectAndAnnounceAsync</c>.
|
||||||
|
/// Asserted the same way <see cref="LivenessIsAnsweredPerSession"/> asserts liveness: per session, and
|
||||||
|
/// null rather than thrown for an id this workspace never issued.
|
||||||
|
/// </remarks>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
|
/// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ internal sealed class FakeSftpSession : ISftpSession
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Cipher { get; } = "aes256-gcm@openssh.com";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory => "/home/dodo";
|
public string HomeDirectory => "/home/dodo";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user