Public Access
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.
DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.
Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.
Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.
--- Two things found on the way ---
Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.
And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.
--- Reachable at all ---
ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.
No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.
Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.
The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.
Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.
930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
338 lines
20 KiB
XML
338 lines
20 KiB
XML
<Window xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
|
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
|
x:DataType="vm:MainWindowViewModel"
|
|
Title="DodoSSH"
|
|
Width="1180"
|
|
Height="760"
|
|
MinWidth="820"
|
|
MinHeight="520"
|
|
Background="#10131a">
|
|
|
|
<!-- The shared classes these views use are in App.axaml, so a control laid out on its own keeps them. -->
|
|
|
|
<!--
|
|
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}" />
|
|
<!--
|
|
Offered only on a machine that can keep a device key and has not already registered one, so it
|
|
disappears once used and never appears where it could not work. Its own button rather than a
|
|
setting, because it is a one-time decision with a consent dialog attached — and because the
|
|
honest place to ask "may this machine unlock itself?" is right after somebody proved they can.
|
|
-->
|
|
<Button Content="Use Windows Hello here" Command="{Binding RegisterDeviceCommand}"
|
|
IsEnabled="{Binding !IsBusy}"
|
|
IsVisible="{Binding CanRegisterDevice}"
|
|
ToolTip.Tip="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
|
<!--
|
|
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one:
|
|
a machine with no TPM and a machine that is already registered are both "cannot register", and
|
|
only the second has anything to take back.
|
|
-->
|
|
<Button 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." />
|
|
<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, in its own control. VaultColumn.axaml carries the reasoning about its shape; what
|
|
matters here is that it is the part of this window a test can lay out, because this window as a
|
|
whole cannot be shown off the UI thread that owns WebView2.
|
|
|
|
Wrapped in a Panel rather than bound directly. The column's data context is the vault, and this
|
|
element's visibility is the shell's business — put both on one element and IsVisible would resolve
|
|
against the vault as well, where IsUnlocked does not exist.
|
|
-->
|
|
<Panel Grid.Row="1" Grid.Column="0" IsVisible="{Binding IsUnlocked}">
|
|
<views:VaultColumn x:Name="VaultPane" DataContext="{Binding Vault}" />
|
|
</Panel>
|
|
|
|
<!-- 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, and now only for a host that asks to be. A host bound to a stored
|
|
credential wants nothing typed here; so does one bound to a key. "not stored" rather than "not
|
|
stored yet", because the vault can hold this now — a host on a typed password is a host nobody
|
|
has bound a credential to, which is a choice rather than a missing feature.
|
|
|
|
The rest of that explanation is a tooltip and not a sentence in this row, which was measured the
|
|
hard way: at the window's 820px minimum this column gets 480, and a 220px box plus Connect plus
|
|
any sentence does not fit. The row shipped clipped for as long as it has had a hint in it. This
|
|
is also the one strip of the window no test can see — MainWindow cannot be laid out headlessly at
|
|
all, because WebView2 refuses the headless dispatcher's MTA thread. Extracting this header into
|
|
its own control, as the vault column was extracted for exactly this reason, is what would make
|
|
it measurable.
|
|
-->
|
|
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
|
PasswordChar="•" Width="220" VerticalAlignment="Center"
|
|
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
|
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Passwords and bind this host to it in the host's own editor." />
|
|
<!--
|
|
Hidden rather than disabled, unlike most of this window. A disabled password box invites the
|
|
reading that a password is wanted and unavailable; the honest statement for a host that carries
|
|
its own secret is that nothing needs typing, and an absent box says that better than a
|
|
greyed-out one. Which secret it carries is in the sentence, because a stored password can be
|
|
wrong and re-typed here and a key cannot.
|
|
-->
|
|
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint" FontSize="11"
|
|
VerticalAlignment="Center"
|
|
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
|
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
|
|
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
|
|
</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 "Forget host key" 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="•" />
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<Button 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 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." />
|
|
|
|
<!--
|
|
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>
|