Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault

A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was
refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings ->
Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a
native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's
/api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs
in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at
/api/app-sign-in/token for an app session token. That token opens terminals in every organisation its
person is in, with the role they have on each server at the time; tunnel tickets send no organisation.

The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type
18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came
from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of
DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in
still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of
DodoSSH and of every Spelunk on the device.

A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows
read-only and asks to be updated for, and a payload that still names a key credential decodes with the
id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17
are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its
cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a
Spelunk this device is not signed in to it opens the editor, which offers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
laurensandClaude Opus 5.5 committed 2026-10-20 16:26:25 +02:00
1 parent 902761d37b
commit ef365f462e
62 files changed
+4327 -1945

No files matched your search

@@ -1,4 +1,3 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
@@ -62,18 +61,6 @@ internal sealed partial class VaultViewModel
[ObservableProperty]
private string editorSpelunkServerId = string.Empty;
/// <summary>What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials.</summary>
/// <remarks>
/// No "none" entry: a route with no key cannot be opened, and nothing selected is how a host made from a
/// link says it has not been chosen yet. A key the host names that has since been deleted keeps a
/// placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
/// </remarks>
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
/// <summary>The credential chosen to hold the Spelunk API key.</summary>
[ObservableProperty]
private AuthenticationChoice? editorSelectedSpelunkKey;
/// <summary>
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
/// </summary>
@@ -119,7 +106,7 @@ internal sealed partial class VaultViewModel
/// </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.
/// throwaway key and account replace the ones the host resolved to.
/// </para>
/// </remarks>
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
@@ -170,13 +157,13 @@ internal sealed partial class VaultViewModel
}
/// <summary>
/// Opens a Spelunk tunnel to a host, with the key it names.
/// Opens a Spelunk tunnel to a host, as whoever this device is signed in to that Spelunk as.
/// </summary>
/// <remarks>
/// <para>
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
/// path uses, and goes only to the API address the host names — and for a saved Spelunk key, only when
/// that is the address it was saved for. See <see cref="SpelunkApiKeyFor"/>.
/// DodoSSH's own account is checked first and the sign-in read at the moment of connecting — see
/// <see cref="SpelunkSignInForAsync"/>. The sign-in only ever goes to the Spelunk it came from, since it is
/// found by that address. One Spelunk refuses (a 401) is forgotten, so the next attempt asks to sign in.
/// </para>
/// <para>
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
@@ -194,17 +181,25 @@ internal sealed partial class VaultViewModel
SpelunkRoute route,
CancellationToken cancellationToken)
{
var apiKey = SpelunkApiKeyFor(row, route);
var signIn = await SpelunkSignInForAsync(row, route, cancellationToken).ConfigureAwait(true);
return await Spelunk
.OpenAsync(
route.ApiBase,
route.ServerId,
apiKey,
row.Resolved.Port.Value,
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
cancellationToken)
.ConfigureAwait(true);
try
{
return await Spelunk
.OpenAsync(
route.ApiBase,
route.ServerId,
signIn.Token,
row.Resolved.Port.Value,
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
cancellationToken)
.ConfigureAwait(true);
}
catch (SpelunkTunnelException refusal)
{
await ForgetRefusedSpelunkSignInAsync(route.ApiBase, refusal).ConfigureAwait(true);
throw;
}
}
/// <summary>
@@ -220,19 +215,17 @@ internal sealed partial class VaultViewModel
/// 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, 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 saved for that Spelunk under Settings → Security, or else the key
/// credential a saved host already sends there. It is saved the ordinary way and syncs like any host.
/// Nothing leaks by this: the key goes where the user already said it goes, and the server is one that
/// key's own organisation holds or Spelunk refuses it.
/// <b>Otherwise, when this device is signed in to that Spelunk, 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. It is saved the ordinary way and syncs like any host. Nothing leaks by this: the
/// sign-in goes back to the Spelunk the user signed in to, and the server is one they may reach or Spelunk
/// refuses it.
/// </para>
/// <para>
/// <b>The first link from a Spelunk with no key 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 the user
/// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
/// vault — and the user chooses the key and saves; every later link from that Spelunk connects straight
/// away.
/// <b>A link from a Spelunk this device is not signed in to opens the editor instead, and nothing
/// more.</b> Anything on this machine can make a link, so it must never be able to start a sign-in by
/// itself. The host is pre-filled — signing in through Spelunk, tagged, in the personal vault — and the
/// editor offers to sign in; every later link from that Spelunk connects straight away.
/// </para>
/// </remarks>
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
@@ -251,15 +244,15 @@ internal sealed partial class VaultViewModel
return;
}
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
var signedIn = await session.SpelunkSignIns.FindAsync(link.ApiBase, CancellationToken.None).ConfigureAwait(true) is not null;
if (personal is { } into && knownKey is { } key)
if (personal is { } into && signedIn)
{
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
await AddFromLinkAndConnectAsync(link, into).ConfigureAwait(true);
return;
}
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
await OpenLinkInEditorAsync(link, personal).ConfigureAwait(true);
}
/// <summary>The personal vault, when this session can read and write it.</summary>
@@ -273,33 +266,8 @@ internal sealed partial class VaultViewModel
private static bool IsSpelunkTag(TagSecret tag) =>
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// The key the user has already agreed may go to this Spelunk, or null when there is none.
/// </summary>
/// <remarks>
/// <para>
/// A Spelunk key saved for that address first — it is the one thing that says so in as many words — and
/// otherwise the key a saved host already sends there, which is how the answer was reached before saved
/// keys existed and is still true of every host set up that way.
/// </para>
/// <para>
/// The personal vault's first, so the answer does not depend on the order vaults happen to load in; and
/// only a key still in the keychain, because a dangling one would make a host that cannot connect.
/// </para>
/// </remarks>
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
SavedSpelunkKeyFor(apiBase, personal)?.EntityId
?? 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)
|| SpelunkKeys.Any(key => key.EntityId == id && string.Equals(key.ApiBase, apiBase, StringComparison.Ordinal)));
/// <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()
/// <summary>The host a link makes: what the link says, signing in through Spelunk.</summary>
private static HostSecret HostFromLink(SpelunkLink link, TagSet tags) => new()
{
Label = link.Name,
Hostname = link.Hostname,
@@ -309,13 +277,12 @@ internal sealed partial class VaultViewModel
{
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)
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId)
{
if (IsBusy)
{
@@ -335,7 +302,7 @@ internal sealed partial class VaultViewModel
hostId = await session.Hosts
.CreateAsync(
vaultId,
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
HostFromLink(link, TagSet.Create([tagId])),
CancellationToken.None)
.ConfigureAwait(true);
@@ -358,14 +325,14 @@ internal sealed partial class VaultViewModel
}
/// <summary>
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
/// Opens the editor on a new host from a link, for a link from a Spelunk this device is not signed in 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)
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal)
{
// 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.
@@ -390,14 +357,12 @@ internal sealed partial class VaultViewModel
EditorSpelunkApi = link.ApiBase;
EditorSpelunkServerId = link.ServerId.ToString();
BuildSpelunkKeyChoices(knownKey);
IsHostPaneOpen = true;
var said = EditorSelectedSpelunkKey is null
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose 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. Saving the key under Settings → Security does the same."
var said = EditorSpelunkNeedsSignIn
? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Sign in to that Spelunk from the editor and "
+ "save — Spelunk signs you in to the server, 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;
@@ -460,22 +425,16 @@ internal sealed partial class VaultViewModel
.ConfigureAwait(true);
}
/// <summary>
/// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential
/// holding its Spelunk API key.
/// </summary>
/// <summary>Whether a host needs this key or credential to connect, as its resolved binding.</summary>
/// <remarks>
/// 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.
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask.
/// 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.Host.SignsInThroughSpelunk
&& row.Resolved.Binding is { } binding
&& binding.Kind == kind
&& binding.EntityId == entityId)
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
!row.Host.SignsInThroughSpelunk
&& row.Resolved.Binding is { } binding
&& binding.Kind == kind
&& binding.EntityId == 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)
@@ -484,36 +443,9 @@ internal sealed partial class VaultViewModel
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
EditorSpelunkSignInNote = string.Empty;
BuildSpelunkKeyChoices(route?.ApiKeyCredentialId);
}
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
/// <remarks>
/// Saved Spelunk keys first, named for where they go, because that is the choice the Security page made
/// for exactly this; then every credential, for a key kept as a password before saved keys existed.
/// </remarks>
private void BuildSpelunkKeyChoices(Guid? selectedId)
{
EditorSpelunkKeyChoices.Clear();
foreach (var key in SpelunkKeys)
{
EditorSpelunkKeyChoices.Add(new AuthenticationChoice(
AuthenticationKind.Credential, key.EntityId, key.Label, $"Spelunk key · {key.ApiBase}"));
}
foreach (var credential in Credentials)
{
EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
}
if (selectedId is { } missing && EditorSpelunkKeyChoices.All(choice => choice.EntityId != missing))
{
EditorSpelunkKeyChoices.Add(AuthenticationChoice.Missing(AuthenticationKind.Credential, missing));
}
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == selectedId);
UpdateEditorSpelunkSignIn();
}
/// <summary>The editor's route, or null when the box is not ticked.</summary>
@@ -536,7 +468,6 @@ internal sealed partial class VaultViewModel
? normalized
: EditorSpelunkApi.Trim(),
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
SignInThroughSpelunk = EditorSpelunkSignIn,
};
}