diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 159563a..96416a6 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -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 there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the session shell's own host header shows the account and endpoint actually dialled (v3–v4: 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 (v3–v4: 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. | diff --git a/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml index 3a967cc..ff88db4 100644 --- a/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml +++ b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml @@ -8,36 +8,56 @@ - - + + + - - + + + + + + diff --git a/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs index d37bb0e..6b09816 100644 --- a/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs +++ b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs @@ -4,8 +4,9 @@ using Avalonia.Controls; namespace DodoSSH.Client.App.Views; /// -/// 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. /// internal sealed partial class SessionStatusBar : UserControl { diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index 0af025e..d3a6371 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -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, }; + /// + /// 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. + /// + /// + /// One property reading whichever surface is showing, for the same reason + /// is: SessionStatusBar.axaml is the same markup on both surfaces. The terminal's is + /// ; SFTP's is . + /// + internal string? SessionCipher => Surface switch + { + ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher), + _ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher), + _ => null, + }; + + /// The accepted host key's algorithm, e.g. ssh-ed25519 — printed as negotiated, not shortened. + /// See ; resolved the same way, off or . + internal string? SessionHostKeyAlgorithm => Surface switch + { + ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm), + _ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm), + _ => null, + }; + + /// + /// The display name of the key or credential that authenticated, or null when a typed password did or + /// nothing is connected. + /// + /// See ; resolved the same way, off or . + internal string? SessionIdentityLabel => Surface switch + { + ShellSurface.Terminal => SelectedTab?.IdentityLabel, + _ when IsTransfersShowing => Transfers.ConnectedIdentityLabel, + _ => null, + }; + + /// + /// The status bar's one run of text for the host key and the identity that authenticated — the design's + /// ed25519 · acme-deploy-key — or null while is. + /// + /// + /// Composed here rather than in the view, so SessionStatusBar.axaml binds one TextBlock 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 — ssh-ed25519, not the design's shortened + /// ed25519 — 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 · — there being nothing after the dot would be a punctuation mark standing in for the fact + /// that was never real. + /// + internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm + ? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm + : null; + + /// Null for an empty string, unchanged otherwise. + /// + /// and are non-nullable + /// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely + /// could not be read back (see TerminalWorkspace.GetSessionFacts) hands over + /// rather than null, and the status bar's collapse bindings only know how to ask about null. + /// + private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; + /// Whether the session the header and status bar are describing is actually open. /// /// Not the same question as 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(); } diff --git a/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs index 8439eb8..4b6d07a 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs @@ -28,10 +28,11 @@ internal enum TerminalTabState /// /// /// -/// 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 TerminalWorkspace, 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 TerminalWorkspace, 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 starts before its session does. Connecting is a network round trip that can take as long as @@ -102,6 +103,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject /// internal DateTimeOffset? StartedAt { get; set; } + /// + /// The negotiated server-to-client cipher, for the status bar — or null while there is no session. + /// + /// + /// Set alongside , 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. + /// + internal string? Cipher { get; set; } + + /// The accepted host key's algorithm, e.g. ssh-ed25519 — or null while there is no session. + /// See ; set and kept the same way, for the same reason. + internal string? HostKeyAlgorithm { get; set; } + + /// + /// The display name of the key or credential that authenticated, or null when a typed password did, or + /// null while there is no session. + /// + /// + /// 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. + /// + internal string? IdentityLabel { get; set; } + /// [ObservableProperty] private TerminalTabState state; diff --git a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs index ec90475..aa072c8 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs @@ -606,6 +606,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo [ObservableProperty] private string? connectedTo; + /// + /// 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. + /// + /// + /// A bucket is IRemoteFileStore with no SSH underneath it at all, so it has no cipher, no host key + /// and no identity to name — 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 ISftpSession at connect time, the same moment is + /// set, because itself is typed as IRemoteFileStore and does not carry it. + /// + [ObservableProperty] + private string? connectedCipher; + + /// The accepted host key's algorithm, e.g. ssh-ed25519. See . + [ObservableProperty] + private string? connectedHostKeyAlgorithm; + + /// + /// The display name of the key or credential that authenticated, or null when a typed password did, or + /// null while nothing is connected. + /// + /// + /// Threaded from '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. + /// + [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); } + /// + /// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the + /// disconnect log closes out later. + /// + /// + /// Split out of for length rather than for reuse — + /// 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 rather than , which is typed + /// as IRemoteFileStore and does not carry either. + /// + 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()); + } + /// /// Closes the file-transfer session. /// @@ -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(); diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index cf5634b..48fc4d7 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -888,11 +888,19 @@ internal static class ItemBadge /// Identifies the session to the renderer and to the workspace. /// The host's name, as the vault has it. /// The account and endpoint actually dialled. +/// The negotiated server-to-client cipher; see . +/// The accepted host key's algorithm, e.g. ssh-ed25519. +/// +/// The display name of the key or credential that authenticated, or null when a typed password did. +/// internal sealed class TerminalSessionEventArgs( Guid attemptId, uint sessionId, string label, - string address) : EventArgs + string address, + string cipher, + string hostKeyAlgorithm, + string? identityLabel) : EventArgs { /// Which attempt this session came out of. /// @@ -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; } /// A connection that has been asked for, and has not answered yet. @@ -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( /// /// The account to log in as, after any credential has had its say. /// What proves it. + /// + /// The display name of the keychain item that authenticates — a key's or a credential's own + /// Label — 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 or once the connection has moved on. + /// /// /// 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". /// - private sealed record HostAuthentication(string Username, SshCredential Credential); + private sealed record HostAuthentication(string Username, SshCredential Credential, string? IdentityLabel = null); /// /// 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; } diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index 7d1aac5..88887b7 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -6221,7 +6221,9 @@ public sealed class ShellFlowTests : IAsyncLifetime /// /// The status bar's facts, read off the selected terminal tab. /// is real, not fabricated: StartedAt 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 — ConnectedHostWithAPinAsync binds + /// "deploy" — composed with the real (unshortened) algorithm string. /// [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"); } /// @@ -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(); + } + + /// + /// 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 + /// 's own remark promises rather than a placeholder + /// standing in for the item that was never there. + /// + [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"); + } + + /// + /// 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 TerminalTabViewModel.Cipher's remark). + /// + [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]