Files
DodoSSH/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml
T
laurensandClaude Opus 5.5 ef365f462e 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>
2026-10-20 16:26:25 +02:00

208 lines
12 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage"
x:DataType="vm:MainWindowViewModel"
x:Name="Root">
<!--
v5c: Security, against Settings-Security.dc.html.
The end-to-end card's sentence is ADR 0001's own claim in the design's words: "The server stores
ciphertext and never holds a key" and "the relay operator ... see ciphertext only" — see docs/adr/0001-e2ee-trust-model.md.
UNLOCKING is Windows Hello's register/"Stop unlocking here" pair, moved here whole from the old
PreferencesScreen.axaml — same RegisterDeviceCommand, ForgetDeviceCommand and the no-TPM explanation
(HasNoDeviceKeyOption). One home for this fact rather than two; see the cross-reference on the
Preferences page. The design's own auto-lock link row is refused: there is nothing to link to, since
auto-lock and per-vault unlock rules do not exist anywhere in this client.
CONNECTING keeps exactly one row, Approved host keys. Strict host-key checking and the allowed-algorithm
chips are both refused — the app always asks on a changed key, and there is no algorithm allow-list
anywhere in the SSH stack. The count and the "N that no host dials" clause both come straight off
KnownHostsViewModel.Summary, the same sentence the known-hosts screen's own header prints, so this row
can never say a different number than the screen it sends you to. "Open host keys" leaves settings mode
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 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
"security" is itself a judgement call the design does not resolve — and the honest link is complete on
its own: Logs already holds the whole story, and this row says so rather than half-repeating it.
-->
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<TextBlock Classes="settingstitle" Text="Security" />
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
<!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
Margin="0,0,16,0" VerticalAlignment="Top">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE897;" FontSize="20"
Foreground="{StaticResource Live}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
Foreground="{StaticResource Text}" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
</StackPanel>
</Grid>
</Border>
<TextBlock Classes="settingssection" Text="UNLOCKING" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="{Binding DeviceUnlock.Name}" />
<TextBlock Classes="settingsrowcaption"
Text="{Binding DeviceUnlock.Offer}"
IsVisible="{Binding CanRegisterDevice}" />
<TextBlock Classes="settingsrowcaption"
Text="{Binding DeviceUnlock.Registered}"
IsVisible="{Binding CanForgetDevice}" />
<TextBlock Classes="settingsrowcaption"
Text="{Binding DeviceUnlock.Unavailable}"
IsVisible="{Binding HasNoDeviceKeyOption}" />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
Command="{Binding RegisterDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}" />
<Button Grid.Column="1" Classes="danger" Height="38" Content="STOP UNLOCKING HERE"
Command="{Binding ForgetDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanForgetDevice}"
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
</Grid>
</Border>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="CONNECTING" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="Approved host keys" />
<TextBlock Classes="settingsrowcaption" Text="{Binding KnownHostsScreen.Summary}" />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN HOST KEYS"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}" />
</Grid>
</Border>
</StackPanel>
</Border>
<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="Sign in to Spelunk" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
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"
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkSignInFormApi}"
PlaceholderText="Spelunk API address (https://api.…)">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.SignInToSpelunkCommand}" />
</TextBox.KeyBindings>
</TextBox>
<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.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 sign in to Spelunk." />
</StackPanel>
</Border>
<ItemsControl ItemsSource="{Binding Vault.SpelunkSignIns}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<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">
<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="ghost" Height="30" Content="SIGN OUT"
VerticalAlignment="Center"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.SignOutOfSpelunkCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Sign this device out of this Spelunk. Hosts reached through it stop connecting until you sign in again." />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
<Border Classes="settingscard" Margin="0,12,0,40">
<StackPanel>
<Border Classes="settingsrow last">
<Button Classes="flat" Padding="0" HorizontalAlignment="Left"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="See all activity in Logs" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource AccentText}" />
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5C8;" FontSize="14"
Foreground="{StaticResource AccentText}" />
</StackPanel>
</Button>
</Border>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>