Show one kind of vault item at a time, and let the vault hold passwords

Outstanding items #8 and #9, in one commit rather than two. They are separable
as work and were built in that order, but not as a diff: the section enum has
three members, the one-editor guard has three arms, and the picker offers keys
and credentials from the same list. Reconstructing an #8-only state would mean
hand-writing an intermediate version of VaultViewModel that never existed and
that no test has ever run. One honest commit beats two invented ones.

--- #8, the type selector ---

The column showed two lists and two editors stacked in 340 pixels, and only
just: the key list needed a MaxHeight and had to hide itself whenever its editor
opened, both to stop the host list above it pushing the buttons off the bottom
edge. Credentials would not have fitted at all. It now shows one kind at a time,
chosen by a selector at the top, and both workarounds are gone because a section
owns the whole column.

Three departures from the plan, each with a reason found while building it.

The selector is plain Buttons and a parameterised command, not a TabControl, a
TabStrip or a ListBox. All three of those hold the selection themselves, so a
click moves the highlight before the view model can refuse it — and this column
does refuse, while an editor is open. A selector lit on a section the column is
not showing is worse than the refusal it would be hiding. Buttons carry no state
and cannot disagree with the vault.

The one-editor-at-a-time rule survives with its justification replaced. That
rule was a workaround for the sizing problem above, and sections dissolved it:
the editors are in different sections and only one section is ever laid out.
BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is now
BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut — the same test, inverted,
because its own comment said that if it ever started passing the rule had become
unnecessary. It has. The rule stays for a better reason: an open key editor
holds a pasted private key in a bound string, and letting the column move on
would leave key material in a form nobody can see, with nothing on screen to say
it is there. A sizing hack became a rule about not hiding a secret from the
person holding it. KeyEditorIsInTheWay and HostEditorIsInTheWay are one
AnEditorIsInTheWay, called by the section switch and by every editor-opening
command.

And releasing the keyboard from the terminal has never worked. MainWindow takes
Win32 focus off the WebView's child window and then calls Focus() on
VaultColumn.KeyboardTarget — and a ListBox is not focusable by default in
Avalonia, which leaves focus to its items. So the call returned false, the
window ended up with nothing focused, and the keystrokes went nowhere: exactly
the state that method's own comment says its second half exists to prevent.
Found by writing the test to assert focus was taken rather than that the right
control was named — the cheap assertion was already passing. Fixed with
Focusable="True" on every list.

--- #9, credentials ---

Credentials have synced since they were added and could not be created. They can
now, and the sync layer needed no change at all: fourth item type, same result,
which is the item-kind seam working as intended.

One picker for all three ways a host authenticates, which is what makes the
illegal combination unrepresentable rather than merely invalid. SshKeyChoice
became AuthenticationChoice carrying an AuthenticationKind, and BuildHost reads
both SshKeyId and CredentialId off that single selection, so a host naming a key
and a credential — which HostSecret.TryValidate refuses — cannot be expressed.
Two pickers would have expressed it and then rejected it at save time. The kind
travels with the id in three places and none is padding: Missing takes it, the
placeholder lookup matches on kind as well as id, and Bound(kind) returns null
unless the selection is that kind. Drop any one and a dangling credential comes
back as a dangling key, which saves as a key binding to an id no key has.

A credential's username had to reach the SSH request, not just its password.
TryBuildCredential returned only the secret and the connect path read the
username off the host, so a stored credential would have gone out under the
wrong account — wrong in a way a server only reports as "authentication failed".
It is now TryBuildAuthentication returning a (Username, Credential) pair.

The no-username refusal moved, and had to. It ran before anything looked at the
binding, which made a credential's username unreachable in the one case it is
most useful: a host somebody never filled a username in for. It is now the last
thing every branch agrees on, so such a host is perfectly usable through a
credential that carries one, and a host with neither still refuses and now says
where to put one.

--- What the measurements cost ---

Ten mutations, all caught. Two are worth naming.

Removing a section's IsVisible is caught by OnlyOneSectionIsOnScreenAtOnce and
by nothing else: two visible sections overlap in the row they share rather than
clip, so every fit test still passes while the column shows one list through
another.

Defaulting the credential selection to the first row is caught by
ReloadingKeepsACredentialSelectionButNeverInventsOne, and the property is a
safety one rather than tidiness — Delete acts on the selection, so a list that
picked a row on every background sync would aim a one-click password deletion at
something nobody chose. The key list has the same property, and its comment
cited a method that has not existed for some time; both now name the delete
command they actually protect.

One test of mine could not fail, and the mutation pass is what found it.
AHostBoundToACredential_SendsItsPasswordAndItsUsername gave the credential and
the host the same username, so it passed whichever one the code read. An
override is only tested when the two values differ.

Two shipped statements went false and were corrected rather than left: the class
remark saying passwords were "not yet" in the vault, and the terminal column's
"Keys are in the vault; passwords are not yet."

That column's hint is now a tooltip on the password box rather than a sentence
in the row, which was measured the hard way — by looking. At the window's 820px
minimum the column gets 480, and a 220px box plus Connect plus any sentence does
not fit; the row has shipped clipped for as long as it has had a hint in it.
That strip is the one part of the window nothing can measure, because MainWindow
cannot be laid out headlessly at all. Extracting it into its own control, as the
vault column was extracted for exactly this reason, is what would fix that, and
is not done here.

911 tests green, 30 of them new. Zero warnings, dotnet format clean.

Seen by a person, which is how the two defects above were found. Still open from
that pass: unlocking with the device key raises its consent dialog and then
never returns, while registering one works — the difference is which thread the
CNG call lands on, and diagnosing it properly is its own change.
This commit is contained in:
2026-07-30 17:08:59 +02:00
parent 573f5d5668
commit da7462e41f
6 changed files with 1835 additions and 341 deletions
+22 -15
View File
@@ -90,28 +90,35 @@
<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.
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 yet)"
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="220" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostUsesAKey}" />
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 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.
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="This host authenticates with its SSH key." Classes="hint" FontSize="11"
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostUsesAKey}" />
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<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>