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

+19 -24
View File
@@ -707,8 +707,8 @@
◆ THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
because a host has one route. A host ticked here is reached through the Spelunk agent already
on the server, so it needs no open SSH port at all. The fields appear only when the box is
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
and synced like every other secret. See VaultViewModel.Spelunk.cs.
ticked. No credential is chosen here: the host is reached as whoever this device signed in to
that Spelunk as, and the card says who, or offers to sign in. See VaultViewModel.Spelunk.cs.
-->
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
@@ -724,25 +724,20 @@
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
PlaceholderText="Spelunk server id" />
<!--
Saved Spelunk keys first (Settings → Security), each qualified by the one address it
goes to, then credentials whose password is a key. A deleted one keeps a placeholder, as
the authentication picker's does, so an unrelated edit cannot drop it. Typing an address
a key is saved for picks that key when nothing is picked yet.
Whether this device can reach that Spelunk, and as whom; or a button to sign in, which
opens the browser and waits there. Nothing about it is saved with the host.
-->
<TextBlock Classes="hint" FontSize="10.5" Text="Spelunk API key" />
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
SelectedItem="{Binding EditorSelectedSpelunkKey}"
HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding EditorSpelunkSignInNote}"
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding EditorSpelunkNeedsSignIn}">
<Button Classes="accent" Height="34" Content="SIGN IN TO SPELUNK"
Command="{Binding SignInToEditorSpelunkCommand}"
IsVisible="{Binding !IsSigningInToSpelunk}" />
<Button Classes="ghost" Height="34" Content="CANCEL"
Command="{Binding SignInToEditorSpelunkCancelCommand}"
IsVisible="{Binding IsSigningInToSpelunk}" />
</StackPanel>
<!--
Spelunk signs the user in: a key pair made in memory for each connection, its public
half sent with the ticket, and the agent's one-connection SSH server accepting only
@@ -754,12 +749,12 @@
</CheckBox>
</StackPanel>
<!--
What the route asks for and what it costs, said where the decision is made: an Admin-role
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
only while Spelunk and its agent are.
What the route asks for and what it costs, said where the decision is made: this device
signed in to that Spelunk, DodoSSH online to vouch for it, and a host that is reached only
while Spelunk and its agent are.
-->
<TextBlock Classes="hint" FontSize="11.5"
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save your Spelunk API key (Admin role) under Settings → Security and choose it here, or keep it as a credential's password; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
</StackPanel>
</Border>
@@ -26,13 +26,12 @@
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
SPELUNK is where a Spelunk API key is filled in: an address and a key, saved as a Spelunk key item in the
personal vault (VaultViewModel.SaveSpelunkKeyCommand) and synced like every secret. The address is the
point — a key saved here is only ever sent there, and links from that Spelunk connect straight away
because saving it is the user saying so. A second key for an address already saved replaces the first.
The key box is a password box and is emptied the moment the key is in the keychain; the list below it
shows the name, the address and how many hosts use each key, never the key. Deleting goes through the
same ConfirmDeleteCard every other deletion does, with a count of the hosts that would stop connecting.
SPELUNK is where this device signs in to a Spelunk: an address and SIGN IN, which opens the browser at
Spelunk and waits for it (VaultViewModel.SignInToSpelunkCommand, cancellable while it waits). What comes
back stays on this device — it is not a keychain item and does not sync — and links from that Spelunk
connect straight away because signing in is the user saying so. Signing in again to an address replaces
the sign-in. The list below shows who each sign-in is and how many hosts reach Spelunk through it, never
its token, and SIGN OUT ends it in Spelunk and here.
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
@@ -115,76 +114,66 @@
<TextBlock Classes="settingssection" Text="SPELUNK" />
<!--
Signing this device in to a Spelunk. Spelunk asks in the browser, however its person signs in there,
and what it gives back stays on this device: each device signs in on its own, and signing out of
DodoSSH signs out of every Spelunk too. See VaultViewModel.SpelunkSignIns.cs.
-->
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow">
<StackPanel Spacing="10">
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
<TextBlock Classes="settingsrowtitle" Text="Spelunk API key" />
<TextBlock Classes="settingsrowtitle" Text="Sign in to Spelunk" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Hosts reached through Spelunk use this key, and links from that Spelunk connect straight away. It is kept encrypted in your personal vault, syncs to your other devices, and is only ever sent to the address below. Use a key with the Admin role." />
Text="Hosts reached through Spelunk connect as you, in every organisation you are in there, and links from that Spelunk connect straight away. Spelunk asks in your browser. The sign-in stays on this device and ends when you sign out here, in Spelunk, or of DodoSSH." />
</StackPanel>
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="10"
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10"
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkKeyFormApi}"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Grid.Column="1" Height="38" PasswordChar="•" Text="{Binding Vault.SpelunkKeyFormKey}"
PlaceholderText="API key (spelunk_…)">
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkSignInFormApi}"
PlaceholderText="Spelunk API address (https://api.…)">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveSpelunkKeyCommand}" />
<KeyBinding Gesture="Enter" Command="{Binding Vault.SignInToSpelunkCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="accent" Height="38" Content="SAVE KEY"
Command="{Binding Vault.SaveSpelunkKeyCommand}"
IsEnabled="{Binding !Vault.IsBusy, FallbackValue=False}" />
<Button Grid.Column="1" Classes="accent" Height="38" Content="SIGN IN"
Command="{Binding Vault.SignInToSpelunkCommand}"
IsVisible="{Binding !Vault.IsSigningInToSpelunk, FallbackValue=True}" />
<Button Grid.Column="1" Classes="ghost" Height="38" Content="CANCEL"
Command="{Binding Vault.SignInToSpelunkCancelCommand}"
IsVisible="{Binding Vault.IsSigningInToSpelunk, FallbackValue=False}" />
</Grid>
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="{Binding Vault.SpelunkKeyFormNote}"
IsVisible="{Binding Vault.HasSpelunkKeyFormNote, FallbackValue=False}" />
Text="{Binding Vault.SpelunkSignInFormNote}"
IsVisible="{Binding Vault.HasSpelunkSignInFormNote, FallbackValue=False}" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
Text="Unlock your keychain to save a Spelunk key." />
Text="Unlock your keychain to sign in to Spelunk." />
</StackPanel>
</Border>
<!-- The delete confirmation, in place, as the tags and groups pages draw it. -->
<Border Margin="24,12" Padding="20" CornerRadius="12"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1"
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
<ItemsControl ItemsSource="{Binding Vault.SpelunkKeys}">
<ItemsControl ItemsSource="{Binding Vault.SpelunkSignIns}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SpelunkKeyRowViewModel">
<DataTemplate x:DataType="vm:SpelunkSignInRowViewModel">
<Border Classes="settingsrow" Padding="24,14">
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="{Binding Label}" />
<Border Classes="chip" IsVisible="{Binding HasVaultBadge}" VerticalAlignment="Center">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
<TextBlock Classes="hint" FontSize="11" Text="{Binding Badge}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="settingsrowtitle" Text="{Binding Who}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="{Binding ApiBase}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
Text="{Binding UsedBy}" />
<Button Grid.Column="2" Classes="paneicon danger" Width="30" Height="30" FontSize="13"
<Button Grid.Column="2" Classes="ghost" Height="30" Content="SIGN OUT"
VerticalAlignment="Center"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteSpelunkKeyRowCommand}"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.SignOutOfSpelunkCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Delete this key. Hosts that reach Spelunk with it stop connecting until they name another.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE872;" />
</Button>
ToolTip.Tip="Sign this device out of this Spelunk. Hosts reached through it stop connecting until you sign in again." />
</Grid>
</Border>
</DataTemplate>
@@ -3,7 +3,7 @@ using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys.
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and signing in to Spelunk.
/// </summary>
internal sealed partial class SettingsSecurityPage : UserControl
{