Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link

A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.

A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
This commit is contained in:
jaap-jan committed 2026-09-28 22:53:51 +02:00
1 parent 07bd5c9886
commit 38ab9b431b
18 files changed
+1239 -115

No files matched your search

@@ -15,8 +15,8 @@ internal sealed partial class MainWindowViewModel
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
/// passed down the doorbell, or one the platform delivered to the running application. The link has
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never connects to a host the user has not
/// saved.
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
/// host already sends it to.
/// </para>
/// <para>
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
@@ -1158,7 +1158,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal bool SelectedHostAsksForAPassword =>
ShowsHostPicker
&& (SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|| (!SelectedHost.Host.SignsInThroughSpelunk
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword));
// ---- The remote pane ----
@@ -1796,14 +1797,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await CloseSessionAsync().ConfigureAwait(true);
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
is not { } opened)
is not ({ } opened, { } dialled))
{
return;
}
session = opened;
TypedPassword = string.Empty;
MarkHostConnected(opened, request, row, identityLabel);
MarkHostConnected(opened, dialled, row, identityLabel);
reconnectTo = (RemoteKind.Host, row.EntityId);
@@ -1818,8 +1819,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// <summary>
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
/// </summary>
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
private async Task<ISftpSession?> OpenHostSessionAsync(
/// <returns>
/// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account
/// Spelunk named rather than the one the host resolved to — or null with <see cref="Status"/> saying why
/// there is none.
/// </returns>
private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync(
VaultViewModel open,
HostRowViewModel row,
SshConnectionRequest request,
@@ -1833,9 +1838,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// The session owns the route from here, on success and on failure alike.
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
return await sftp
.OpenSftpAsync(route is null ? request : request with { Proxy = route.Proxy }, cancellationToken)
.ConfigureAwait(true);
var through = VaultViewModel.Through(request, route);
return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through);
}
catch (Exception exception)
when (route?.Failure is { } failure
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Shell.ViewModels;
@@ -16,6 +17,15 @@ namespace DodoSSH.Client.Shell.ViewModels;
/// </remarks>
internal sealed partial class VaultViewModel
{
/// <summary>The tag every host made from a Spelunk link wears, and what a link looks for.</summary>
/// <remarks>
/// Matched without regard to case, as the editor matches every tag name — see <c>AddEditorTagAsync</c>.
/// </remarks>
internal const string SpelunkTag = "spelunk";
/// <summary>What the status bar and the log name as the identity of a Spelunk sign-in.</summary>
private const string SpelunkSignInIdentity = "Spelunk sign-in";
/// <summary>What opens a tunnel when no caller supplied something else.</summary>
/// <remarks>
/// Static and shared for the reason <see cref="DefaultReachability"/> is: it holds nothing but a
@@ -32,8 +42,18 @@ internal sealed partial class VaultViewModel
/// fact what these two boxes can simply not express.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
private bool editorThroughSpelunk;
/// <summary>Whether Spelunk signs the user in to the host being edited, so it needs no login of its own.</summary>
/// <remarks>
/// Inside the Spelunk card, and meaningful only while that is ticked. See
/// <see cref="SpelunkRoute.SignInThroughSpelunk"/>.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
private bool editorSpelunkSignIn;
/// <summary>The Spelunk API address in the editor, as typed.</summary>
[ObservableProperty]
private string editorSpelunkApi = string.Empty;
@@ -54,6 +74,16 @@ internal sealed partial class VaultViewModel
[ObservableProperty]
private AuthenticationChoice? editorSelectedSpelunkKey;
/// <summary>
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
/// </summary>
/// <remarks>
/// The two controls are disabled rather than hidden while it is false, with a sentence saying why, so a
/// login somebody had chosen is still there to see — and comes back as it was if the box is unticked
/// before saving.
/// </remarks>
internal bool EditorAsksForLogin => !(EditorThroughSpelunk && EditorSpelunkSignIn);
/// <inheritdoc cref="DefaultSpelunk" />
private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk;
@@ -87,6 +117,10 @@ internal sealed partial class VaultViewModel
/// <see cref="OpenRelayAsync"/> for the relay's half of that argument; Spelunk's is stronger still,
/// because a Spelunk host's address may be nothing that answers at all.
/// </para>
/// <para>
/// The request a route is used for is made with <see cref="Through"/>, which is where a Spelunk sign-in's
/// key and account replace the ones the host resolved to.
/// </para>
/// </remarks>
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
{
@@ -102,6 +136,39 @@ internal sealed partial class VaultViewModel
: null;
}
/// <summary>
/// The request to connect with once a route is open: through its proxy, and for a Spelunk sign-in, with
/// the key the tunnel was opened with and the account Spelunk named.
/// </summary>
/// <remarks>
/// <para>
/// Both transports make their request through this, so a sign-in cannot reach one of them with the
/// stand-in credential <see cref="TryBuildAuthentication"/> gives it — which the SSH layer would refuse
/// before dialling anyway. See <see cref="SpelunkSignInPending"/>.
/// </para>
/// <para>
/// The host and port are left alone. The host key is pinned under them as for every host, and Spelunk's
/// SSH server presents the server's real host key, so a sign-in and a plain tunnel to one server are
/// checked against the same pin.
/// </para>
/// </remarks>
internal static SshConnectionRequest Through(SshConnectionRequest request, IConnectionRoute? route)
{
ArgumentNullException.ThrowIfNull(request);
return route switch
{
null => request,
SpelunkTunnelRoute { SignIn: { } signIn, User: { } user } => request with
{
Proxy = route.Proxy,
Username = user,
Credential = signIn.Credential,
},
_ => request with { Proxy = route.Proxy },
};
}
/// <summary>
/// Opens a Spelunk tunnel to a host, with the key its credential holds.
/// </summary>
@@ -115,6 +182,12 @@ internal sealed partial class VaultViewModel
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
/// server's own loopback, which is where the SSH server listens whatever address the world sees.
/// </para>
/// <para>
/// <b>A host Spelunk signs in to gets a new key pair here, every time.</b> It is made in memory for this
/// one tunnel, its public half goes with the ticket request, and the route carries it to the connection —
/// see <see cref="SpelunkSignIn"/>. A retry after a host key question, or a reconnect, comes back through
/// here and makes another.
/// </para>
/// </remarks>
private async Task<IConnectionRoute> OpenSpelunkTunnelAsync(
HostRowViewModel row,
@@ -129,7 +202,13 @@ internal sealed partial class VaultViewModel
}
return await Spelunk
.OpenAsync(route.ApiBase, route.ServerId, key.Credential.Password, row.Resolved.Port.Value, cancellationToken)
.OpenAsync(
route.ApiBase,
route.ServerId,
key.Credential.Password,
row.Resolved.Port.Value,
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
cancellationToken)
.ConfigureAwait(true);
}
@@ -137,66 +216,243 @@ internal sealed partial class VaultViewModel
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
/// </summary>
/// <returns>
/// The connection attempt when a saved host was found, so a caller that wants to can wait for it; a
/// completed task otherwise.
/// The work the link started, connection attempt included, so a caller that wants to can wait for it.
/// </returns>
/// <remarks>
/// <para>
/// <b>A saved host for that server on that Spelunk is connected to</b>, exactly as a tap on its row
/// would — a password prompt if it asks for one, a host key question if it has not been seen.
/// <b>A host for that server in the personal vault, tagged <see cref="SpelunkTag"/>, is connected to</b>,
/// exactly as a tap on its row would. Those are the hosts links make, and the only ones a link looks for:
/// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's.
/// </para>
/// <para>
/// <b>Otherwise the editor opens on a new host, and nothing more.</b> The link came from outside, and
/// anything on this machine can make one; it must never be able to make this application connect
/// somewhere, or send an API key somewhere, that the user has not looked at and saved. So the host is
/// pre-filled — the name, the address its key will be pinned under, the Spelunk and the server — and
/// the user chooses how to log in and which credential holds the key, and presses SAVE.
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
/// SSH key or password — with the key credential a saved host already sends to that same Spelunk. It is
/// saved the ordinary way and syncs like any host. Nothing leaks by this: the key goes where it already
/// goes, and the server is one that key's own organisation holds or Spelunk refuses it.
/// </para>
/// <para>
/// <b>The key is pre-chosen only when another host already sends it to the same Spelunk.</b> That is a
/// convenience that cannot leak anything — the key already goes there — whereas guessing a key for a
/// Spelunk no host uses would be offering to send it to whatever address the link named.
/// <b>The first link from a Spelunk opens the editor instead, and nothing more.</b> Anything on this
/// machine can make a link, so it must never be able to send an API key to an address no saved host
/// already sends it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
/// vault — and the user chooses the credential holding the key and saves; every later link from that
/// Spelunk connects straight away.
/// </para>
/// </remarks>
internal Task OpenSpelunkLinkAsync(SpelunkLink link)
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
{
ArgumentNullException.ThrowIfNull(link);
if (Hosts.FirstOrDefault(row => row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true) is { } saved)
var personal = PersonalVaultId();
if (personal is { } vaultId
&& Hosts.FirstOrDefault(row =>
row.VaultId == vaultId
&& row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true
&& WearsSpelunkTag(row.Host)) is { } saved)
{
return ConnectToRowCommand.ExecuteAsync(saved);
await ConnectToRowCommand.ExecuteAsync(saved).ConfigureAwait(true);
return;
}
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
if (personal is { } into && knownKey is { } key)
{
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
return;
}
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
}
/// <summary>The personal vault, when this session can read and write it.</summary>
private Guid? PersonalVaultId() =>
session.ReadableVaults.FirstOrDefault(vault => vault.IsPersonal && vault.CanWrite)?.VaultId;
/// <summary>Whether a host wears a tag called <see cref="SpelunkTag"/>, from whichever vault.</summary>
private bool WearsSpelunkTag(HostSecret host) =>
host.TagIds.Any(id => tagsById.TryGetValue(id, out var tag) && IsSpelunkTag(tag));
private static bool IsSpelunkTag(TagSecret tag) =>
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// The credential a saved host already sends to this Spelunk as its API key, or null when none does.
/// </summary>
/// <remarks>
/// The personal vault's hosts first, so the answer does not depend on the order vaults happen to load in;
/// and only a credential still in the keychain, because a dangling one would make a host that cannot
/// connect.
/// </remarks>
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
Hosts
.OrderByDescending(row => row.VaultId == personal)
.Select(row => row.Host.Spelunk)
.Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal))
.Select(route => (Guid?)route!.ApiKeyCredentialId)
.FirstOrDefault(id => Credentials.Any(credential => credential.EntityId == id));
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
{
Label = link.Name,
Hostname = link.Hostname,
Port = HostSecret.DefaultPort,
TagIds = tags,
Spelunk = new SpelunkRoute
{
ApiBase = link.ApiBase,
ServerId = link.ServerId,
ApiKeyCredentialId = apiKeyCredentialId,
SignInThroughSpelunk = true,
},
};
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId)
{
if (IsBusy)
{
// RunAsync would drop it without a word, and the user pressed a button somewhere else to get here.
Status = $"Busy — open {link.Name} from Spelunk again in a moment.";
return;
}
Guid? hostId = null;
await RunAsync(
$"Adding {link.Name} from Spelunk…",
async () =>
{
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
hostId = await session.Hosts
.CreateAsync(
vaultId,
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
CancellationToken.None)
.ConfigureAwait(true);
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
}).ConfigureAwait(true);
// A failure has said so on the status line already.
if (hostId is not { } id || Hosts.FirstOrDefault(row => row.EntityId == id) is not { } row)
{
return;
}
Status = $"Added {link.Name} from Spelunk at {link.ApiBase}.";
await ConnectToRowCommand.ExecuteAsync(row).ConfigureAwait(true);
// After the connect rather than before it: the host is saved either way, and the terminal is what the
// user is waiting for. As SaveHostAsync does, it goes to the server now rather than at the next tick.
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
}
/// <summary>
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
/// </summary>
/// <remarks>
/// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is
/// made or found afterwards and added to the host being edited; a tag made for a host that is then
/// cancelled stays, as the editor's own new-tag box leaves it.
/// </remarks>
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey)
{
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
// is not worth somebody's unsaved host.
if (AHostEditorIsInTheWay())
{
return Task.CompletedTask;
return;
}
NewHost();
if (personal is { } vaultId
&& EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId) is { } choice)
{
EditorSelectedVault = choice;
}
EditorLabel = link.Name;
EditorHostname = link.Hostname;
EditorPort = HostSecret.DefaultPort;
EditorThroughSpelunk = true;
EditorSpelunkSignIn = true;
EditorSpelunkApi = link.ApiBase;
EditorSpelunkServerId = link.ServerId.ToString();
var knownKey = Hosts
.Select(row => row.Host.Spelunk)
.FirstOrDefault(route => route is not null && string.Equals(route.ApiBase, link.ApiBase, StringComparison.Ordinal))
?.ApiKeyCredentialId;
BuildSpelunkKeyChoices(Credentials.Any(credential => credential.EntityId == knownKey) ? knownKey : null);
BuildSpelunkKeyChoices(knownKey);
IsHostPaneOpen = true;
Status = EditorSelectedSpelunkKey is null
? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in and the credential "
+ "holding your Spelunk API key, then save."
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in, then save.";
return Task.CompletedTask;
var said = EditorSelectedSpelunkKey is null
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose the credential "
+ "holding your Spelunk API key and save — Spelunk signs you in, so no SSH key is needed, and "
+ "later links from this Spelunk connect straight away."
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
Status = said;
if (personal is { } into)
{
await TagHostFromLinkAsync(into, said).ConfigureAwait(true);
}
}
/// <summary>Adds the <see cref="SpelunkTag"/> tag to the host a link opened the editor on.</summary>
/// <param name="vaultId">The personal vault, where the host is to be saved and the tag is.</param>
/// <param name="said">What the status line says about the link, put back after the reload.</param>
private async Task TagHostFromLinkAsync(Guid vaultId, string said)
{
try
{
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
// Only onto the host this link opened: the user may have cancelled it, or moved on to another, in
// the moment the tag took.
if (!IsEditing || editingEntityId is not null || editingHostVaultId != vaultId)
{
return;
}
editorTagIds = TagSet.Create([.. editorTagIds, tagId]);
if (!tagsById.ContainsKey(tagId))
{
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
}
BuildTagChoices();
Status = said;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The host can still be saved; it is the tag that could not be made, and a later link would then
// not find it. Said, rather than failing the whole link over it.
Status = $"{said} (The '{SpelunkTag}' tag could not be added: {exception.Message})";
}
}
/// <summary>The <see cref="SpelunkTag"/> tag in a vault, made there if it has none.</summary>
private async Task<Guid> EnsureSpelunkTagAsync(Guid vaultId, CancellationToken cancellationToken)
{
var listing = await session.Tags.ListAsync(vaultId, cancellationToken).ConfigureAwait(true);
if (listing.Items
.Where(tag => IsSpelunkTag(tag.Secret))
.OrderBy(tag => tag.EntityId)
.FirstOrDefault() is { } existing)
{
return existing.EntityId;
}
return await session.Tags
.CreateAsync(vaultId, new TagSecret { Label = SpelunkTag }, cancellationToken)
.ConfigureAwait(true);
}
/// <summary>
@@ -207,15 +463,20 @@ internal sealed partial class VaultViewModel
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
/// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk
/// keys are named by the host itself and never inherited, so the stored route is the whole answer there.
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
/// </remarks>
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
(row.Resolved.Binding is { } binding && binding.Kind == kind && binding.EntityId == entityId)
(!row.Host.SignsInThroughSpelunk
&& row.Resolved.Binding is { } binding
&& binding.Kind == kind
&& binding.EntityId == entityId)
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
private void LoadEditorSpelunk(SpelunkRoute? route)
{
EditorThroughSpelunk = route is not null;
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
@@ -261,6 +522,7 @@ internal sealed partial class VaultViewModel
: EditorSpelunkApi.Trim(),
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
SignInThroughSpelunk = EditorSpelunkSignIn,
};
}
}
@@ -4308,10 +4308,15 @@ internal sealed partial class VaultViewModel(
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
/// every host under a group that binds a key, and the box would do nothing.
/// </para>
/// <para>
/// False for a host Spelunk signs in to, whatever it resolves to: there is nothing to type, and a box —
/// or on the phone, a sheet — asking for a password there would be a question with no use for its answer.
/// </para>
/// </remarks>
internal bool SelectedHostAsksForAPassword =>
SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|| (!SelectedHost.Host.SignsInThroughSpelunk
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
/// <summary>
/// What the terminal column says in place of the password box, or nothing when the box is showing.
@@ -4330,11 +4335,13 @@ internal sealed partial class VaultViewModel(
/// changed.
/// </para>
/// </remarks>
internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
internal string SelectedHostAuthenticationNote => SelectedHost switch
{
{ Kind: ResolvedBindingKind.Credential } binding =>
{ Host.SignsInThroughSpelunk: true } =>
"Spelunk signs you in to this host with a one-time key; no SSH key or password is needed.",
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential } binding } =>
$"This host uses a password stored in your keychain{From(binding)}.",
{ Kind: ResolvedBindingKind.SshKey } binding =>
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey } binding } =>
$"This host authenticates with an SSH key{From(binding)}.",
_ => string.Empty,
};
@@ -4363,14 +4370,19 @@ internal sealed partial class VaultViewModel(
/// <remarks>
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
/// value that had not loaded.
/// value that had not loaded. A host Spelunk signs in to logs in as whatever Spelunk names, so it says
/// that rather than a username the connection will not use.
/// </remarks>
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
? user
: "no account set";
internal string SelectedHostUsernameLabel => SelectedHost switch
{
{ Host.SignsInThroughSpelunk: true } => "named by Spelunk",
{ Resolved.Username.Value: { Length: > 0 } user } => user,
_ => "no account set",
};
/// <inheritdoc cref="SelectedHostPortIsInherited" />
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
internal bool SelectedHostUsernameIsInherited =>
SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true };
/// <summary>
/// What the pane names in the credentials row: the key or password this host authenticates with.
@@ -4386,11 +4398,12 @@ internal sealed partial class VaultViewModel(
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
/// </para>
/// </remarks>
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
internal string SelectedHostBindingLabel => SelectedHost switch
{
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
{ Host.SignsInThroughSpelunk: true } => SpelunkSignInIdentity,
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey, EntityId: { } id } } =>
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential, EntityId: { } id } } =>
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
?? "(a password that is no longer here)",
_ => string.Empty,
@@ -11991,7 +12004,11 @@ internal sealed partial class VaultViewModel(
authentication.Username,
authentication.Credential);
var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
var (sessionId, dialled) = await OpenThroughAsync(target, request, reporter, cancellationToken)
.ConfigureAwait(true);
// The account the session actually logged in as, which a Spelunk sign-in learns only from the tunnel.
authentication = authentication with { Username = dialled.Username };
// The workspace has already opened a ticket for this session, with the address and the moment it
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
@@ -12086,7 +12103,7 @@ internal sealed partial class VaultViewModel(
/// bridge listening until its own timeout; disposing twice is safe.
/// </para>
/// </remarks>
private async Task<uint> OpenThroughAsync(
private async Task<(uint SessionId, SshConnectionRequest Request)> OpenThroughAsync(
ConnectionTarget target,
SshConnectionRequest request,
PhaseReporter reporter,
@@ -12098,15 +12115,15 @@ internal sealed partial class VaultViewModel(
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
: null;
var through = Through(request, route);
try
{
return await workspace
.OpenSessionAsync(
route is null ? request : request with { Proxy = route.Proxy },
TerminalSize.Default,
reporter,
cancellationToken)
var sessionId = await workspace
.OpenSessionAsync(through, TerminalSize.Default, reporter, cancellationToken)
.ConfigureAwait(true);
return (sessionId, through);
}
catch (Exception exception) when (route is not null)
{
@@ -12432,8 +12449,25 @@ internal sealed partial class VaultViewModel(
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
/// nothing about keys.
/// </para>
/// <para>
/// <b>A host Spelunk signs in to is answered first, whatever it or its group binds</b> — see
/// <see cref="SignedInBySpelunk"/>. It is the one host that connects with no binding, because it says so;
/// every other host goes through <see cref="TryBuildBoundAuthentication"/> exactly as before.
/// </para>
/// </remarks>
private bool TryBuildAuthentication(
HostSecret host,
ResolvedHost resolved,
string typedPassword,
[NotNullWhen(true)] out HostAuthentication? authentication,
[NotNullWhen(false)] out string? reason) =>
host.SignsInThroughSpelunk
? SignedInBySpelunk(out authentication, out reason)
: TryBuildBoundAuthentication(host, resolved, typedPassword, out authentication, out reason);
/// <summary>Turns a host's resolved binding into a key, a password or a prompt, or says why it cannot.</summary>
/// <remarks>See <see cref="TryBuildAuthentication"/>.</remarks>
private bool TryBuildBoundAuthentication(
HostSecret host,
ResolvedHost resolved,
string typedPassword,
@@ -12498,6 +12532,27 @@ internal sealed partial class VaultViewModel(
out reason);
}
/// <summary>
/// How a host Spelunk signs in to authenticates, before the tunnel that decides it is open.
/// </summary>
/// <remarks>
/// Spelunk signs the host in with a key the tunnel makes and an account Spelunk names, neither of which
/// exists yet. These stand-ins are replaced as the connection is made through the route — see
/// <see cref="Through"/> — and refused by the SSH layer if they ever reach it. It is the only way to
/// connect without a binding, and only for a host that says so; every other host still needs one.
/// </remarks>
private static bool SignedInBySpelunk(
[NotNullWhen(true)] out HostAuthentication? authentication,
out string? reason)
{
authentication = new HostAuthentication(
SpelunkSignIn.DefaultUser,
SpelunkSignInPending.Instance,
SpelunkSignInIdentity);
reason = null;
return true;
}
/// <summary>
/// The last thing every branch has to agree on: there is somebody to log in as.
/// </summary>
@@ -12570,7 +12625,9 @@ internal sealed partial class VaultViewModel(
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
// host in the vault the first time it was edited.
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
// Never beside a Spelunk sign-in, nor are the bindings below: a host has one way to log in.
AsksForPassword = EditorAsksForLogin
&& EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
&& EditorSelectedGroup?.EntityId is not null
? true
: null,
@@ -12605,7 +12662,9 @@ internal sealed partial class VaultViewModel(
/// <summary>The picker's selection, if it names something of this kind.</summary>
private Guid? Bound(AuthenticationKind kind) =>
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
EditorAsksForLogin && EditorSelectedAuthentication is { } choice && choice.Kind == kind
? choice.EntityId
: null;
/// <summary>
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.