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.
This commit is contained in:
2026-08-08 20:54:56 +02:00
parent 8209f15741
commit 9d5ff9f23a
8 changed files with 330 additions and 47 deletions
@@ -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;
}