Files
DodoSSH/src/DodoSSH.Client.App/Views/MainWindow.axaml
T
jaap-jan 211eba0666
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
Keep host key trust in the vault, and make it withdrawable
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.

The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.

One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.

The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.

The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.

That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.

Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.

ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.

Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.

The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.

Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.

It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.

Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.

Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.

The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.

845 tests green. Zero warnings, dotnet format clean.

Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
2026-07-30 11:00:39 +02:00

486 lines
28 KiB
XML

<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
x:Class="DodoSSH.Client.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="DodoSSH"
Width="1180"
Height="760"
MinWidth="820"
MinHeight="520"
Background="#10131a">
<Window.Styles>
<Style Selector="TextBlock.hint">
<Setter Property="Foreground" Value="#7b8394" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.heading">
<Setter Property="Foreground" Value="#e6e9f0" />
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Border.card">
<Setter Property="Background" Value="#171b24" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="24" />
<Setter Property="MaxWidth" Value="520" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
</Window.Styles>
<!--
The terminal's WebView is collapsed whenever the vault is not unlocked, and that is not a style
choice. NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window
composites above everything the parent paints — so no sibling in this visual tree can cover it,
whatever the z-order says. Layering the setup screens over it left them sliced at the WebView's left
edge, with their buttons unreachable at the window's default width.
Collapsing is safe, which the earlier version of this comment denied: NativeControlHost creates the
native control when the control is attached to the visual tree, not when it is laid out or shown, and
an assigned Source is replayed once the adapter exists. IsVisible=false therefore still starts
WebView2, still loads the page and still lets the renderer attach its socket; it only swaps
ShowInBounds for HideWithSize. Flipping it back re-pushes the bounds.
What the first connection after unlocking actually depends on is the await in
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate
is that await, never this control's visibility.
-->
<Panel>
<Grid RowDefinitions="Auto,*" ColumnDefinitions="340,*">
<!-- Account bar -->
<Border Grid.Row="0" Grid.ColumnSpan="2" Padding="12,8" Background="#171b24"
IsVisible="{Binding IsUnlocked}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="{Binding Vault.VaultName}" Foreground="#e6e9f0" FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock Text="{Binding AccountName}" Classes="hint" VerticalAlignment="Center" />
<TextBlock Text="{Binding Vault.Status}" Classes="hint" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" MaxWidth="520" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<TextBlock Text="offline" Foreground="#c8a55a" VerticalAlignment="Center"
IsVisible="{Binding !IsOnline}" />
<Button Content="Sign in" Command="{Binding SignInCommand}"
IsVisible="{Binding !IsOnline}" />
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
<!--
The tooltip carries the policy to the point of action, because the button's name implies
the opposite of what it does to a running shell.
-->
<Button Content="Lock" Command="{Binding LockCommand}"
ToolTip.Tip="Closes the vault and forgets its keys. Open shells keep running and reappear when you unlock." />
</StackPanel>
</Grid>
</Border>
<!--
The vault column: hosts above, SSH keys below.
Two lists in one column rather than a TabControl. A TabControl is the tidier layout and it was not
chosen because of what the terminal does with the keyboard: MainWindow releases focus by calling
Focus() on HostList by name, and a tabbed version would put that target behind a tab selection. This
repository already has one measured finding of that shape — Focus() on a collapsed control is a
no-op and is not replayed when it is revealed, see the note on NativeWebView below — and whether an
unselected TabItem behaves the same way here is untested. Not worth finding out by shipping it, for
a layout preference.
The host list keeps the flexible row, so it is what grows with the window; the key section takes
what it needs and no more.
-->
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto,Auto,Auto,Auto,Auto"
Background="#131722" IsVisible="{Binding IsUnlocked}">
<!-- Named because it is where keyboard focus lands when the user leaves the terminal. -->
<ListBox Grid.Row="0" x:Name="HostList" Margin="6"
ItemsSource="{Binding Vault.Hosts}"
SelectedItem="{Binding Vault.SelectedHost}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
FontFamily="ui-monospace,Consolas,monospace" />
<!--
Which of the two ways this host authenticates. In the list because the password box
below is only relevant to one of them, and an empty box on a key-authenticated host is
otherwise indistinguishable from one somebody forgot to fill in.
-->
<TextBlock Text="{Binding Authentication}" Classes="hint" FontSize="11" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
<Border Grid.Row="1" Padding="10" Background="#171b24" IsVisible="{Binding Vault.IsEditing}">
<StackPanel Spacing="6">
<TextBox Text="{Binding Vault.EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding Vault.EditorHostname}" PlaceholderText="hostname or address" />
<NumericUpDown Value="{Binding Vault.EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" />
<TextBox Text="{Binding Vault.EditorUsername}" PlaceholderText="username" />
<TextBox Text="{Binding Vault.EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="60" TextWrapping="Wrap" />
<!--
Which key this host authenticates with, or a password. Part of the host rather than of the
connection, so it follows the host to every machine; a host bound to a key that has since been
deleted keeps a placeholder entry here, so that editing the port cannot quietly turn it back
into a password host.
-->
<ComboBox ItemsSource="{Binding Vault.EditorKeyChoices}"
SelectedItem="{Binding Vault.EditorSelectedKey}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SshKeyChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding Vault.EditorRelayEnabled}"
Content="Allow connecting through the server relay" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to resolve
the target, or it becomes an authenticated open proxy into the operator's network.
-->
<TextBlock Classes="hint" FontSize="11"
Text="Enabling the relay stores this host's address on the server in plain text. Everything else about the host stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding Vault.SaveHostCommand}" />
<Button Content="Cancel" Command="{Binding Vault.CancelEditCommand}" />
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
not a field of the host.
Added to this row rather than as a row of its own on purpose: this column's editors already
only just fit at the window's minimum height, which is why only one may be open at a time.
-->
<Button Content="Forget host key" Command="{Binding Vault.ForgetHostKeyCommand}"
IsVisible="{Binding Vault.CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
IsVisible="{Binding !Vault.IsEditing}">
<Button Content="Add" Command="{Binding Vault.NewHostCommand}" />
<Button Content="Edit" Command="{Binding Vault.EditSelectedHostCommand}" />
<Button Content="Delete" Command="{Binding Vault.DeleteHostCommand}" />
</StackPanel>
<Border Grid.Row="3" Padding="8,6" Background="#10141d">
<TextBlock Text="SSH keys" Foreground="#9aa4b6" FontSize="11" FontWeight="SemiBold" />
</Border>
<!--
Bounded rather than flexible, and hidden while its editor is open. The key editor is the tallest
thing in this column — a private key needs a real text area — and at the window's minimum height
there is not room for both. Browsing the list and editing one of its rows are not things anyone
needs to do at the same moment.
-->
<ListBox Grid.Row="4" x:Name="KeyList" Margin="6" MaxHeight="170"
ItemsSource="{Binding Vault.Keys}"
SelectedItem="{Binding Vault.SelectedKey}"
Background="Transparent"
IsVisible="{Binding !Vault.IsEditingKey}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SshKeyRowViewModel">
<StackPanel Spacing="2" Margin="2,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<!--
What is known about the key, never the key. Binding the material here would put a private
key into a list item's visual tree, where a tooltip or a screen reader could read it out.
-->
<TextBlock Text="{Binding Description}" Classes="hint" FontSize="11" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="5" Padding="10" Background="#171b24"
IsVisible="{Binding Vault.IsEditingKey}">
<StackPanel Spacing="6">
<TextBox Text="{Binding Vault.KeyEditorLabel}" PlaceholderText="name" />
<!--
Not a password box. The armour has to be visible to be pasted and checked — a masked
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this actually
prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
-->
<TextBox Text="{Binding Vault.KeyEditorPrivateKey}"
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
FontFamily="ui-monospace,Consolas,monospace" FontSize="11" />
<TextBox Text="{Binding Vault.KeyEditorPassphrase}"
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
<TextBox Text="{Binding Vault.KeyEditorPublicKey}"
PlaceholderText="public half (optional)" FontSize="11" />
<TextBox Text="{Binding Vault.KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Save" Command="{Binding Vault.SaveKeyCommand}" />
<Button Content="Cancel" Command="{Binding Vault.CancelKeyEditCommand}" />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Grid.Row="6" Orientation="Horizontal" Spacing="6" Margin="8,4,8,8"
IsVisible="{Binding !Vault.IsEditingKey}">
<Button Content="Add key" Command="{Binding Vault.NewKeyCommand}" />
<Button Content="Edit" Command="{Binding Vault.EditSelectedKeyCommand}" />
<Button Content="Delete" Command="{Binding Vault.DeleteKeyCommand}" />
</StackPanel>
</Grid>
<!-- Terminal column -->
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
<StackPanel Orientation="Horizontal" Spacing="8">
<!--
Typed per connection. Credentials do sync, but nothing in this interface can create one, so
the vault genuinely does not hold this — saying so beats a password box that looks like it
should have been remembered. Disabled rather than hidden when a key is being used, so it
stays visible that a password is what the other choice means.
-->
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored yet)"
PasswordChar="•" Width="220" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostUsesAKey}" />
<!--
Hidden rather than disabled for the key case, unlike most of this window. A disabled password
box invites the reading that a password is wanted and unavailable; the honest statement for a
key-authenticated host is that nothing needs typing, and an absent box says that better than a
greyed-out one.
-->
<TextBlock Text="This host authenticates with its SSH key." Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostUsesAKey}" />
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="11" VerticalAlignment="Center"
Text="Keys are in the vault; passwords are not yet."
IsVisible="{Binding !Vault.SelectedHostUsesAKey}" />
</StackPanel>
</Border>
<StackPanel Grid.Row="1" IsVisible="{Binding IsUnlocked}">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
to click through the one warning that matters.
-->
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding Vault.HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="#e8dcb0" TextWrapping="Wrap" />
<SelectableTextBlock Text="{Binding Vault.PendingHostKey.Fingerprint}"
FontFamily="ui-monospace,Consolas,monospace"
Foreground="#f4ecd0" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Trust and connect" Command="{Binding Vault.TrustHostKeyCommand}" />
<Button Content="Cancel" Command="{Binding Vault.RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding Vault.HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="#f3c9cd" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
Foreground="#f3c9cd" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="#d59aa1" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode is
kept and shown; without this panel it would be last-writer-wins with a longer explanation.
-->
<Border Padding="10,8" Background="#1b2432" IsVisible="{Binding Vault.HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="#bcd2ea" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="#141b26" CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="#dfe6f0" TextWrapping="Wrap" />
<SelectableTextBlock Text="{Binding Detail}" Classes="hint" FontSize="11"
FontFamily="ui-monospace,Consolas,monospace"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Dismiss all" Command="{Binding Vault.AcknowledgeAllConflictsCommand}"
HorizontalAlignment="Left" />
</StackPanel>
</Border>
</StackPanel>
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser process
tree, so twenty tabs would cost twenty of them.
IsVisible is load-bearing rather than cosmetic — see the note on the root Panel. Without it the
native child window paints over the setup and unlock screens and swallows their input.
It must stay IsVisible on this control specifically, and two nearby alternatives are wrong.
Removing the control from the tree instead — conditional content, a template swap — detaches it,
and detaching destroys the native control and the whole WebView2 process tree, so every unlock
would pay a cold start. Hoisting the binding to an ancestor looks tidier and is unverified:
NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push fires
only for its own IsVisible.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible then
falls back to its default of true, and the occlusion comes back silently. Not reachable at
runtime — the DataContext is set before the window is shown — but it is what the previewer does.
-->
<NativeWebView Grid.Row="2" x:Name="Terminal"
IsVisible="{Binding IsUnlocked, FallbackValue=False}" />
</Grid>
</Grid>
<!--
Setup and unlock. Last in the Panel, so it is above the app content in Avalonia's z-order — which
covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered.
-->
<Border Background="#10131a" IsVisible="{Binding !IsUnlocked}">
<Panel>
<Border Classes="card" IsVisible="{Binding IsStarting}">
<StackPanel Spacing="10">
<TextBlock Classes="heading" Text="DodoSSH" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
<StackPanel Spacing="12">
<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" />
<Button Content="Sign in with your browser" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
<TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
<Button Content="Create my vault" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<!--
Shown once and impossible to skip. This is the only moment the code exists, and losing it
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
design.
-->
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Write this recovery code down" />
<TextBlock Classes="hint"
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
<Border Background="#0c0f15" CornerRadius="6" Padding="14">
<SelectableTextBlock Text="{Binding RecoveryCode}"
FontFamily="ui-monospace,Consolas,monospace"
FontSize="16" Foreground="#9ee6b4" TextWrapping="Wrap" />
</Border>
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
Content="I have written it down somewhere safe" />
<Button Content="Continue" Command="{Binding ConfirmRecoveryCodeCommand}"
HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsLocked}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Unlock your vault" />
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
<!--
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="•" />
<Button Content="Unlock" Command="{Binding UnlockCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<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." />
<!--
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="#1b2432" CornerRadius="4" Padding="10,8"
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
<StackPanel Spacing="4">
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="#bcd2ea"
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>
</Border>
</Panel>
</Border>
</Panel>
</Window>