Stay signed in, come back online by itself, and let a machine be given up

Three things a machine that has been set up could not do. Unlock now takes
Enter, which is the gesture everybody makes after typing a password and which
did nothing until they found the button.

Signing in survives a relaunch. The refresh token is kept in the local cache,
sealed under the vault's own cache key, so a later launch resumes the session
through the refresh grant with no browser and nobody present — and because it
is sealed under that key, only an unlocked vault can resume it. A locked
client therefore cannot reach the server at all, which is a consequence worth
stating rather than working around; docs/crypto.md §3.2 records it. Every sync
pass asks the shell for a connection rather than reading one captured at
unlock, so a laptop that unlocked on a train is online within a minute of
finding a network, with nothing pressed. Unlocking itself still never waits on
a socket.

Signing out empties this machine: the profile, the cached items, the outbox
and this machine's device key, with the account's row withdrawn when the
server can be reached. It asks first and says what it costs — the outbox count
when the vault is open, an admission that it cannot be counted when it is not,
and the shells that keep running either way. The vault is on the server and is
untouched, which is what makes the same button the only honest answer to a
forgotten passphrase, so it is on the unlock screen as well as in preferences.
It cannot end the session at the identity provider, and says so.

Two defects surfaced on the way. The synchronisation pass that runs when the
vault opens never ran at all: the loop is started from inside the unlock
command, so the busy flag it yields to was raised by that command — the first
sync was a minute late on every launch. And signing in from preferences while
unlocked threw an unlock screen over an open vault whose keys were still in
memory.

The unlock card and the new confirmation live in their own controls because
MainWindow cannot be laid out headless, so markup left inside it is markup no
test can measure; both are now measured at the window's minimum size in the
shapes that grow. What is still unverified is the composed window itself.
This commit is contained in:
2026-07-31 11:07:36 +02:00
parent 94e11f5e38
commit 0b261c4d39
28 changed files with 2323 additions and 80 deletions
+22 -48
View File
@@ -252,7 +252,13 @@
<TextBlock Classes="heading" Text="Connect to your server" />
<TextBlock Classes="hint"
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
<!--
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
not replayed. See MainWindow.axaml.cs.
-->
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
PlaceholderText="https://dodossh.example" />
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
@@ -296,54 +302,22 @@
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsLocked}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Unlock your vault" />
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
<!--
Named because locking has to put the keyboard here explicitly. The terminal's native
child window keeps Win32 focus when it is collapsed, so without that this box would
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
-->
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
PlaceholderText="vault passphrase" PasswordChar="•" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
IsEnabled="{Binding !IsBusy}" />
<!--
Shown only when this machine has both a registered wrap and a keystore still willing to
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
machine that never had it invites the reading that something is broken — and the passphrase
box beside it is not a fallback, it is the ordinary way in.
-->
<Button Classes="ghost" Content="USE WINDOWS HELLO"
Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}"
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
</StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
<TextBlock Classes="hint" FontSize="11"
Text="This works with no network: the salt and the wrapped key are already on this machine." />
<!--
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
-->
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
<views:UnlockCard x:Name="UnlockPane" />
</Border>
<!--
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
while locked, so a shell left running is invisible as well as unstopped — and a screen
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
exactly the kind of half-truth this project writes down instead of implying. Visible only
when there is something to disclose, so an ordinary launch stays quiet.
-->
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="10,8"
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
<StackPanel Spacing="4">
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11"
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
</StackPanel>
</Border>
</StackPanel>
<!--
The confirmation, in place of the unlock card rather than under it: the card is already close
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
worse than one that shows one question at a time.
-->
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
<views:SignOutCard />
</Border>
</Panel>