2 Commits
Author SHA1 Message Date
jaap-jan 9d5ff9f23a Draw what authenticated, and over what, on the session status bar
ci / build and test (push) Failing after 2m20s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m27s
The v5b design's own row: the negotiated cipher, then the host key's algorithm
and the name of the key or credential that authenticated, as one mono run
beside CONNECTED — on both surfaces, off MainWindowViewModel's surface-aware
SessionCipher and SessionIdentityText, the same shape SessionAddress set.

The identity's name comes out of TryBuildAuthentication, the one resolution
point that always had it in scope and always threw it away; it rides
HostAuthentication to the tab and to the SFTP connect alike. Three deviations,
recorded in the gaps doc: the algorithm prints as negotiated rather than
shortened, the run is plain text because no pin-details modal exists for an
open session, and a typed password shows the algorithm alone — there is no
item behind the dot. A dead terminal tab keeps its facts for the scrollback
still on screen; an SFTP disconnect, with no scrollback, clears them.
2026-08-08 20:54:56 +02:00
jaap-jan 8209f15741 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.
2026-08-08 20:54:14 +02:00
18 changed files with 464 additions and 47 deletions
+2 -2
View File
@@ -297,7 +297,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | Omitted, and recorded rather than silently dropped. None of the three is reachable from this shell today: `SshNetConnection` holds the negotiated algorithms but nothing reads them off `ISshConnection`; `HostKeyPresentation` exists only for the trust questions a connection asks on the way in, not for a session already open; and a key's display name is never threaded past the connect call into anything the session keeps. What is real and bound is CONNECTED plus a dot, the session's own elapsed timer, and — terminal surface only — `UTF-8`, worded as a fact about this client's own renderer rather than a claim the remote agreed to. Candidates for future plumbing, not a defect closed here. |
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
@@ -537,7 +537,7 @@ caption buttons and window title drawn on top of the application's own — two s
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right thereit just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the session shell's own host header shows the account and endpoint actually dialled (v3v4: the tab strip did; v5b moved the fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
@@ -8,26 +8,38 @@
<!--
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
surface share. The design's own row also carries a negotiated cipher, a host-key algorithm and a
key/credential name; none of the three is reachable from this shell today — see the wave B report this
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
them is drawn, omitted rather than faked, per this project's honesty rule.
surface share. The design's own row also carries a negotiated cipher and a host-key-plus-identity run
(`ed25519 · acme-deploy-key`, clickable to a pin-details modal); both are now real and bound, plumbed all
the way from SSH.NET through TerminalWorkspace.GetSessionFacts and TransfersViewModel to
MainWindowViewModel's own surface-aware properties — see SessionCipher, SessionHostKeyAlgorithm,
SessionIdentityLabel and the composed SessionIdentityText.
Three honest deviations from the mock. The algorithm prints exactly as negotiated — "ssh-ed25519", not
the design's shortened "ed25519" — because trimming it would be a cosmetic edit to a string this client
did not choose. The run is plain text, not a button: this application has no pin-details modal for a
session that is already open, and drawing a click target for a screen that does not exist would be the
fabrication this project's honesty rule forbids, not an honest omission of one. And a typed-password
session — nothing filed in the keychain to name — shows the algorithm alone, with no " · " after it,
because there is no item behind the dot.
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
the same state the header answers with its own empty-state sentence; the elapsed timer, off
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
to it.
the same state the header answers with its own empty-state sentence; the cipher and the host-key/identity
run, each collapsed rather than shown empty or fabricated when the fact behind it is not there — a bucket
connection has neither, and a session whose facts could not be read back at the instant they were asked
for has neither either; the elapsed timer, off SessionElapsedText, which is null and therefore absent
whenever there is nothing timed; and, only on the terminal surface — see <see cref="ShowsEncoding"/> —
"UTF-8", which is a true fact about this client's own renderer and write path (see TerminalWorkspace's
terminal.js and SshShellSessionExtensions.WriteTextAsync) rather than a negotiated session parameter, and
is worded plainly rather than as a claim the remote agreed to it.
-->
<Border Height="37" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
VerticalAlignment="Center" />
@@ -40,6 +52,14 @@
IsVisible="{Binding !IsSessionConnected}" />
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SessionCipher}"
IsVisible="{Binding SessionCipher, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SessionIdentityText}"
IsVisible="{Binding SessionIdentityText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
@@ -4,8 +4,9 @@ using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
/// The v5b session shell's status bar: CONNECTED and its dot, the negotiated cipher, the host key's algorithm
/// and — where one authenticated — the identity that did, the elapsed timer, and — only where it is true —
/// UTF-8. See the remark at the top of SessionStatusBar.axaml.
/// </summary>
internal sealed partial class SessionStatusBar : UserControl
{
@@ -3400,7 +3400,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// No tab was opened for this attempt, which means the user closed the connecting tab while the
// handshake was still running. The session is real and has to be adopted rather than dropped:
// dropping it would leave a shell running with nothing in the window naming it.
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
{
StartedAt = clock.GetUtcNow(),
Cipher = NullIfEmpty(e.Cipher),
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
IdentityLabel = e.IdentityLabel,
};
AdoptTab(adopted);
RefreshConnectedHosts();
return;
@@ -3411,6 +3417,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// From this moment, not from when the tab first appeared — connecting is not open, and the session
// shell's elapsed timer is about a shell that is actually running.
tab.StartedAt = clock.GetUtcNow();
tab.Cipher = NullIfEmpty(e.Cipher);
tab.HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm);
tab.IdentityLabel = e.IdentityLabel;
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
@@ -3914,6 +3923,69 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ => null,
};
/// <summary>
/// The negotiated server-to-client cipher the status bar shows beside CONNECTED, or null when neither
/// surface has a session — or when the one it has has nothing to report.
/// </summary>
/// <remarks>
/// One property reading whichever surface is showing, for the same reason <see cref="SessionAddress"/>
/// is: <c>SessionStatusBar.axaml</c> is the same markup on both surfaces. The terminal's is
/// <see cref="TerminalTabViewModel.Cipher"/>; SFTP's is <see cref="TransfersViewModel.ConnectedCipher"/>.
/// </remarks>
internal string? SessionCipher => Surface switch
{
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher),
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher),
_ => null,
};
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — printed as negotiated, not shortened.</summary>
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.HostKeyAlgorithm"/> or <see cref="TransfersViewModel.ConnectedHostKeyAlgorithm"/>.</remarks>
internal string? SessionHostKeyAlgorithm => Surface switch
{
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm),
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm),
_ => null,
};
/// <summary>
/// The display name of the key or credential that authenticated, or null when a typed password did or
/// nothing is connected.
/// </summary>
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.IdentityLabel"/> or <see cref="TransfersViewModel.ConnectedIdentityLabel"/>.</remarks>
internal string? SessionIdentityLabel => Surface switch
{
ShellSurface.Terminal => SelectedTab?.IdentityLabel,
_ when IsTransfersShowing => Transfers.ConnectedIdentityLabel,
_ => null,
};
/// <summary>
/// The status bar's one run of text for the host key and the identity that authenticated — the design's
/// <c>ed25519 · acme-deploy-key</c> — or null while <see cref="SessionHostKeyAlgorithm"/> is.
/// </summary>
/// <remarks>
/// Composed here rather than in the view, so <c>SessionStatusBar.axaml</c> binds one <c>TextBlock</c> to
/// one string instead of assembling a separator between two bindings that can each be absent on their own.
/// The algorithm prints exactly as negotiated — <c>ssh-ed25519</c>, not the design's shortened
/// <c>ed25519</c> — because trimming it would be a cosmetic claim about a string this shell has no
/// business editing. A typed-password session has no item to name, so it shows the algorithm alone with
/// no <c>·</c> — there being nothing after the dot would be a punctuation mark standing in for the fact
/// that was never real.
/// </remarks>
internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm
? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm
: null;
/// <summary>Null for an empty string, unchanged otherwise.</summary>
/// <remarks>
/// <see cref="ISshConnection.Cipher"/> and <see cref="HostKeyPresentation.Algorithm"/> are non-nullable
/// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely
/// could not be read back (see <c>TerminalWorkspace.GetSessionFacts</c>) hands over <see cref="string.Empty"/>
/// rather than null, and the status bar's collapse bindings only know how to ask about null.
/// </remarks>
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
/// <remarks>
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
@@ -3981,6 +4053,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(SessionAddress));
OnPropertyChanged(nameof(IsSessionConnected));
OnPropertyChanged(nameof(SessionElapsedText));
OnPropertyChanged(nameof(SessionCipher));
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
OnPropertyChanged(nameof(SessionIdentityLabel));
OnPropertyChanged(nameof(SessionIdentityText));
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
}
@@ -3996,7 +4072,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
or nameof(TransfersViewModel.IsConnected)
or nameof(TransfersViewModel.Remote)
or nameof(TransfersViewModel.ConnectedTo))
or nameof(TransfersViewModel.ConnectedTo)
or nameof(TransfersViewModel.ConnectedCipher)
or nameof(TransfersViewModel.ConnectedHostKeyAlgorithm)
or nameof(TransfersViewModel.ConnectedIdentityLabel))
{
RaiseSessionState();
}
@@ -28,10 +28,11 @@ internal enum TerminalTabState
/// </summary>
/// <remarks>
/// <para>
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
/// is the WebView, and there is one of those however many tabs are open.
/// A tab is a session id and a handful of strings — the address, and now the three connection facts the
/// status bar draws beside CONNECTED. It holds no terminal and owns nothing: the pane, its scrollback and the
/// shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is one frame
/// telling the page which pane to show. That is what makes tabs cheap here — the expensive object is the
/// WebView, and there is one of those however many tabs are open.
/// </para>
/// <para>
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
@@ -102,6 +103,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// </remarks>
internal DateTimeOffset? StartedAt { get; set; }
/// <summary>
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
/// </summary>
/// <remarks>
/// Set alongside <see cref="StartedAt"/>, from the same event, and kept for the same reason: a dead tab's
/// pane still holds the scrollback of a session that really did negotiate this cipher, and clearing the
/// fact when the shell ends would not make it less true of what is on screen.
/// </remarks>
internal string? Cipher { get; set; }
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — or null while there is no session.</summary>
/// <remarks>See <see cref="Cipher"/>; set and kept the same way, for the same reason.</remarks>
internal string? HostKeyAlgorithm { get; set; }
/// <summary>
/// The display name of the key or credential that authenticated, or null when a typed password did, or
/// null while there is no session.
/// </summary>
/// <remarks>
/// Three different reasons collapse to the same null, and that is deliberate: nothing downstream needs to
/// tell a session with no identity to name apart from a tab that has not opened one yet — both mean the
/// status bar shows the host-key algorithm alone, with no ` · name` after it.
/// </remarks>
internal string? IdentityLabel { get; set; }
/// <inheritdoc cref="TerminalTabState" />
[ObservableProperty]
private TerminalTabState state;
@@ -606,6 +606,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private string? connectedTo;
/// <summary>
/// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null
/// while nothing is connected, or while what is connected is a bucket rather than a host.
/// </summary>
/// <remarks>
/// A bucket is <c>IRemoteFileStore</c> with no SSH underneath it at all, so it has no cipher, no host key
/// and no identity to name — <see cref="OpenBucketAsync"/> leaves all three null rather than each reading
/// as "not yet known", which is the meaning null already carries for a host that has not connected yet.
/// Read off the concrete <c>ISftpSession</c> at connect time, the same moment <see cref="ConnectedTo"/> is
/// set, because <see cref="session"/> itself is typed as <c>IRemoteFileStore</c> and does not carry it.
/// </remarks>
[ObservableProperty]
private string? connectedCipher;
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
[ObservableProperty]
private string? connectedHostKeyAlgorithm;
/// <summary>
/// The display name of the key or credential that authenticated, or null when a typed password did, or
/// null while nothing is connected.
/// </summary>
/// <remarks>
/// Threaded from <see cref="VaultViewModel.TryBuildConnectionRequest"/>'s own out parameter rather than
/// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own
/// to climb, and a second lookup would be a second place for a stale binding to answer differently than
/// the one that actually authenticated.
/// </remarks>
[ObservableProperty]
private string? connectedIdentityLabel;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
@@ -874,6 +905,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
ConnectedTo = string.Create(
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
// No SSH underneath a bucket, so none of the three has an honest value — see
// ConnectedCipher's own remark.
ConnectedCipher = null;
ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null;
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
@@ -891,7 +928,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return;
}
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
if (!open.TryBuildConnectionRequest(
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
{
Status = refusal;
return;
@@ -906,9 +944,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
{
await CloseSessionAsync().ConfigureAwait(true);
ISftpSession opened;
try
{
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
}
catch (SshHostKeyUnknownException exception)
{
@@ -926,17 +966,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return;
}
session = opened;
TypedPassword = string.Empty;
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
// would disagree with the host's — and anybody comparing the two would be right to believe
// the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
MarkHostConnected(opened, request, row, identityLabel);
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
@@ -944,6 +976,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}).ConfigureAwait(true);
}
/// <summary>
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
/// disconnect log closes out later.
/// </summary>
/// <remarks>
/// Split out of <see cref="ConnectToHostAsync"/> for length rather than for reuse — <see cref="OpenBucketAsync"/>
/// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads
/// the cipher and host key off <paramref name="opened"/> rather than <see cref="session"/>, which is typed
/// as <c>IRemoteFileStore</c> and does not carry either.
/// </remarks>
private void MarkHostConnected(
ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel)
{
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
ConnectedCipher = opened.Cipher;
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
ConnectedIdentityLabel = identityLabel;
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
// the host's — and anybody comparing the two would be right to believe the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
}
/// <summary>
/// Closes the file-transfer session.
/// </summary>
@@ -1713,6 +1772,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
IsConnected = false;
ConnectedTo = null;
ConnectedCipher = null;
ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null;
RemotePath = string.Empty;
RemoteEntries.Clear();
RemoteTrail.Clear();
@@ -888,11 +888,19 @@ internal static class ItemBadge
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">The account and endpoint actually dialled.</param>
/// <param name="cipher">The negotiated server-to-client cipher; see <see cref="ISshConnection.Cipher"/>.</param>
/// <param name="hostKeyAlgorithm">The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
/// <param name="identityLabel">
/// The display name of the key or credential that authenticated, or null when a typed password did.
/// </param>
internal sealed class TerminalSessionEventArgs(
Guid attemptId,
uint sessionId,
string label,
string address) : EventArgs
string address,
string cipher,
string hostKeyAlgorithm,
string? identityLabel) : EventArgs
{
/// <summary>Which attempt this session came out of.</summary>
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
@@ -903,6 +911,12 @@ internal sealed class TerminalSessionEventArgs(
internal string Label { get; } = label;
internal string Address { get; } = address;
internal string Cipher { get; } = cipher;
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
internal string? IdentityLabel { get; } = identityLabel;
}
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
@@ -11085,6 +11099,13 @@ internal sealed partial class VaultViewModel(
Status = $"Connected to {target.Label}.";
// The session the workspace just opened is the only place the negotiated cipher and host-key
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
// read back here rather than carried forward from somewhere upstream that never had them. Absent only
// for a session that has already ended in the instant between opening it and asking, which the empty
// string collapses to on the status bar exactly as a session with nothing to report would.
var facts = workspace.GetSessionFacts(sessionId);
// Only now, and only on success. The page's own term.focus() focuses the textarea inside the
// document, which does nothing while the window's keyboard focus is still on the Connect button — so
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
@@ -11094,7 +11115,10 @@ internal sealed partial class VaultViewModel(
attempt.AttemptId,
sessionId,
target.Label,
Dialled(target, authentication)));
Dialled(target, authentication),
facts?.Cipher ?? string.Empty,
facts?.HostKeyAlgorithm ?? string.Empty,
authentication.IdentityLabel));
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear.
@@ -11279,13 +11303,19 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
/// <param name="Credential">What proves it.</param>
/// <param name="IdentityLabel">
/// The display name of the keychain item that authenticates — a key's or a credential's own
/// <c>Label</c> — or null when the password was typed rather than filed. Carried alongside the credential
/// so the session shell's status bar can name what actually authenticated without a second lookup back
/// into <see cref="Keys"/> or <see cref="Credentials"/> once the connection has moved on.
/// </param>
/// <remarks>
/// The two travel together because a credential can change both. Returning only the secret and reading the
/// username off the host separately is what the connect path used to do, and it would have sent a stored
/// credential's password under the host's username — which is the one combination that is wrong in a way
/// the server reports as "authentication failed".
/// </remarks>
private sealed record HostAuthentication(string Username, SshCredential Credential);
private sealed record HostAuthentication(string Username, SshCredential Credential, string? IdentityLabel = null);
/// <summary>
/// The machine a connection is being made to, however it was named.
@@ -11359,6 +11389,7 @@ internal sealed partial class VaultViewModel(
HostSecret host,
string typedPassword,
[NotNullWhen(true)] out SshConnectionRequest? request,
out string? identityLabel,
[NotNullWhen(false)] out string? reason)
{
ArgumentNullException.ThrowIfNull(host);
@@ -11368,6 +11399,7 @@ internal sealed partial class VaultViewModel(
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
{
request = null;
identityLabel = null;
return false;
}
@@ -11377,6 +11409,8 @@ internal sealed partial class VaultViewModel(
authentication.Username,
authentication.Credential);
identityLabel = authentication.IdentityLabel;
return true;
}
@@ -11428,6 +11462,7 @@ internal sealed partial class VaultViewModel(
return Complete(
credential.Credential.Username ?? resolved.Username.Value,
new SshPasswordCredential(credential.Credential.Password),
credential.Label,
out authentication,
out reason);
}
@@ -11446,13 +11481,18 @@ internal sealed partial class VaultViewModel(
resolved.Username.Value,
new SshPrivateKeyCredential(
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
key.Label,
out authentication,
out reason);
}
// No item to name: a typed password has nothing filed in the keychain, and IdentityLabel stays null —
// the default Complete's own signature gives it — rather than the placeholder text a display-only
// fallback would tempt someone to invent.
return Complete(
resolved.Username.Value,
new SshPasswordCredential(typedPassword),
identityLabel: null,
out authentication,
out reason);
}
@@ -11476,6 +11516,7 @@ internal sealed partial class VaultViewModel(
private static bool Complete(
string? username,
SshCredential credential,
string? identityLabel,
out HostAuthentication? authentication,
[NotNullWhen(false)] out string? reason)
{
@@ -11487,7 +11528,7 @@ internal sealed partial class VaultViewModel(
out reason);
}
authentication = new HostAuthentication(username, credential);
authentication = new HostAuthentication(username, credential, identityLabel);
reason = null;
return true;
}
+11
View File
@@ -265,6 +265,17 @@ public interface ISftpSession : IRemoteFileStore
{
/// <summary>The host key that was accepted for this session.</summary>
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>
+19
View File
@@ -98,6 +98,25 @@ public interface ISshConnection : IAsyncDisposable
/// <summary>The host key that was accepted for this connection.</summary>
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>
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
}
@@ -274,6 +274,16 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
/// <inheritdoc />
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 />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
@@ -20,6 +20,14 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
/// <inheritdoc />
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 />
public string HomeDirectory { get; } = homeDirectory;
@@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
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>
/// <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
@@ -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>
/// Raised with the session id when a shell ends on its own.
/// </summary>
@@ -83,6 +83,9 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
public HostKeyPresentation HostKey { get; } =
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
/// <inheritdoc />
public string Cipher { get; } = "aes256-gcm@openssh.com";
/// <inheritdoc />
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");
/// <inheritdoc />
public string Cipher { get; } = "aes256-gcm@openssh.com";
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(
TerminalSize size,
@@ -6221,7 +6221,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
/// opens, and this reads it back through the same clock.
/// opens, and this reads it back through the same clock. The cipher and host-key algorithm are the
/// fake's own, and the identity text is the bound key's label — <c>ConnectedHostWithAPinAsync</c> binds
/// "deploy" — composed with the real (unshortened) algorithm string.
/// </remarks>
[Fact]
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
@@ -6231,6 +6233,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsSessionConnected.ShouldBeTrue();
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
}
/// <remarks>
@@ -6245,6 +6250,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsSessionConnected.ShouldBeFalse();
shell.SessionAddress.ShouldBeNull();
shell.SessionElapsedText.ShouldBeNull();
shell.SessionCipher.ShouldBeNull();
shell.SessionHostKeyAlgorithm.ShouldBeNull();
shell.SessionIdentityText.ShouldBeNull();
}
/// <remarks>
/// A typed password has nothing filed in the keychain to name, so the status bar's identity run is the
/// host-key algorithm alone — no " · " and nothing after it, which is what
/// <see cref="MainWindowViewModel.SessionIdentityText"/>'s own remark promises rather than a placeholder
/// standing in for the item that was never there.
/// </remarks>
[Fact]
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "deploy@build.internal:2222";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
shell.IsSessionConnected.ShouldBeTrue();
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
shell.SessionIdentityText.ShouldBe("ssh-ed25519", "a typed password names no keychain item");
}
/// <remarks>
/// The same facts on the other surface, and one asymmetry worth pinning: the SFTP session is its own
/// login through the same resolution ladder, so the status bar names that session's cipher, host key and
/// identity — and stops naming them on disconnect, where a terminal tab keeps its facts for the
/// scrollback still on screen (see <c>TerminalTabViewModel.Cipher</c>'s remark).
/// </remarks>
[Fact]
public async Task SessionFacts_FollowTheSftpSurfacesOwnSession()
{
await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.IsTransfersShowing.ShouldBeTrue();
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
shell.SessionCipher.ShouldBeNull("the SFTP surface has no scrollback for a dead session's facts to describe");
shell.SessionIdentityText.ShouldBeNull();
}
[Fact]
@@ -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);
@@ -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");
/// <inheritdoc />
public string Cipher { get; } = "aes256-gcm@openssh.com";
/// <summary>The shell this connection opened, if it opened one.</summary>
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");
}
/// <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>
/// <para>
/// 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 />
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
/// <inheritdoc />
public string Cipher { get; } = "aes256-gcm@openssh.com";
/// <inheritdoc />
public string HomeDirectory => "/home/dodo";