Show the host keys this vault has approved
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s

Trust was created by the connect prompt and withdrawn from one host's editor, so
a pin for a host that had since been deleted or re-addressed was unreachable
from the interface entirely. It went on refusing connections and nothing in the
application would admit it was there. Two of the four recorded debts were really
this one: leftover pins, and no list to see them in.

A fourth section in the vault column, and the first that adding one has been
cheap for — three edits and two layout tests, which is what #8 and #9 were for.

No editor and no Add, which makes it the only section with neither. A pin is not
something anybody writes: it appears when somebody approves a fingerprint at the
moment of connecting, which is the one place a person can actually check it
against what the operator published. A form for typing one in would be a form
for pasting whatever a man in the middle just offered. So the section exists to
show and to withdraw, which is exactly what was missing.

The fingerprint is shown in full, wrapped, in a monospace line. The only thing
anybody does with one is compare it against a fingerprint an operator published,
and half of one cannot be compared — it can only be glanced at, which is the
habit pinning exists to replace. Nothing here is secret; a host key fingerprint
is published on purpose.

A pin no host in this vault dials is badged rather than hidden or deleted. That
is the leftover the debt was about, and keeping it is still right: the address
may be reached by something without a bookmark, and trust is about the endpoint
rather than the bookmark. The badge is a hint and not a verdict, which is why
nothing acts on it. Matched case-insensitively, because a host name is, and
because a list that called DB.internal unused next to a host saved as
db.internal would be inviting somebody to delete trust they rely on.

Forgetting goes through the same ForgetAsync as the host editor's button, which
withdraws every pin for the address rather than the selected row. Deliberate:
somebody who has stopped trusting a machine has not decided to keep trusting one
of its keys, and a second pin under another algorithm would go on being offered
at the next handshake — which reads as a withdrawal that did not work. The
status line says how many went, and the change is pushed immediately, because
the other machines are the ones still refusing to connect to a rebuilt server.

The list is read through the repository rather than through VaultKnownHostStore,
whose snapshot is shaped for the SSH handshake: one pin per endpoint,
deduplicated, no entity ids. This list has to show duplicates, because a
duplicate is one of the things worth seeing.

Two mutations, both caught: calling every pin dialled (3 tests), and defaulting
the selection to the first row (1) — the same hazard as the credential list,
since Forget acts on the selection.

The selector now holds four buttons in 340 pixels, and TheSelectorIsBigEnoughToClick
measures how much of that they use rather than leaving a fifth section to
discover it as "a button falls outside the window".

936 tests green across 16 projects, 6 of them new. Zero warnings, format clean.

Not verified: how the section looks. It joins the list in outstanding item #7.
This commit is contained in:
2026-07-30 17:44:33 +02:00
parent f86791e817
commit d162271a45
5 changed files with 384 additions and 1 deletions
@@ -72,6 +72,10 @@
Classes.active="{Binding ShowsCredentials}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Credentials}" />
<Button Content="Host keys" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
Classes.active="{Binding ShowsKnownHosts}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.KnownHosts}" />
</StackPanel>
</Border>
@@ -329,6 +333,57 @@
</Grid>
<!--
Pinned host keys.
No editor, and no Add — the only section without either. A pin is not something anybody writes: it
appears when somebody approves a fingerprint at the moment of connecting, which is the one place a
person can actually check it against what the operator published. A form for typing one in would be a
form for pasting whatever a man in the middle just offered.
So this section exists to show and to withdraw, which is exactly what was missing: pins outlive the
hosts they were approved for, and nothing surfaced them.
-->
<Grid Grid.Row="1" RowDefinitions="*,Auto" IsVisible="{Binding ShowsKnownHosts}">
<ListBox Grid.Row="0" x:Name="KnownHostList" Margin="6" Focusable="True"
ItemsSource="{Binding KnownHostPins}"
SelectedItem="{Binding SelectedKnownHost}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
<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>
<!--
The fingerprint in full, wrapped rather than trimmed. The only thing anybody does with one is
compare it against a fingerprint an operator published, and half of one cannot be compared —
it can only be glanced at, which is the habit pinning exists to replace. Nothing here is
secret: a host key fingerprint is published on purpose.
-->
<TextBlock Text="{Binding Fingerprint}" Classes="hint" FontSize="11"
FontFamily="ui-monospace,Consolas,monospace" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1" Spacing="6" Margin="8,4,8,8">
<TextBlock Classes="hint" FontSize="11"
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
<Button Content="Forget this host key" Command="{Binding ForgetPinCommand}"
HorizontalAlignment="Left"
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
</StackPanel>
</Grid>
</Grid>
</UserControl>
@@ -42,6 +42,7 @@ internal sealed partial class VaultColumn : UserControl
{
VaultViewModel { ShowsKeys: true } => KeyList,
VaultViewModel { ShowsCredentials: true } => CredentialList,
VaultViewModel { ShowsKnownHosts: true } => KnownHostList,
_ => HostList,
};
}