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
@@ -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();