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

@@ -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,
};
}
}