Public Access
Show the host keys this vault has approved
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:
@@ -212,6 +212,56 @@ internal sealed class CredentialRowViewModel(VaultItem<CredentialSecret> credent
|
||||
ItemBadge.For(credential.IsBlocked, credential.IsReadOnly, credential.HasUnsyncedChanges);
|
||||
}
|
||||
|
||||
/// <summary>One pinned host key, as a row in the list.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
|
||||
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
|
||||
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
|
||||
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
|
||||
/// What was wrong was that nothing ever showed them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
|
||||
/// of pinning one is to compare it with what they published.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class KnownHostRowViewModel(VaultItem<KnownHostSecret> pin, bool isDialledByAHost)
|
||||
{
|
||||
internal Guid EntityId => pin.EntityId;
|
||||
|
||||
internal KnownHostSecret Pin => pin.Secret;
|
||||
|
||||
internal string Host => pin.Secret.Host;
|
||||
|
||||
internal int Port => pin.Secret.Port;
|
||||
|
||||
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
|
||||
internal string Label => pin.Secret.Label;
|
||||
|
||||
/// <summary>The fingerprint, in full.</summary>
|
||||
/// <remarks>
|
||||
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
|
||||
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
|
||||
/// this whole mechanism exists to replace.
|
||||
/// </remarks>
|
||||
internal string Fingerprint => pin.Secret.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any host in this vault actually dials the endpoint this pin is for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
|
||||
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
|
||||
/// worth deleting on the user's behalf.
|
||||
/// </remarks>
|
||||
internal bool IsDialledByAHost { get; } = isDialledByAHost;
|
||||
|
||||
internal string Badge => IsDialledByAHost
|
||||
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
|
||||
: "no host uses this";
|
||||
}
|
||||
|
||||
/// <summary>The one-word marker a row shows for its sync state.</summary>
|
||||
/// <remarks>
|
||||
/// Shared by both row types rather than written twice, because the three states mean the same thing for
|
||||
@@ -278,6 +328,9 @@ internal enum VaultSection
|
||||
|
||||
/// <summary>The usernames and passwords they authenticate with instead.</summary>
|
||||
Credentials,
|
||||
|
||||
/// <summary>The host keys this user has approved.</summary>
|
||||
KnownHosts,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -350,6 +403,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// <summary>The stored credentials to show, unpushed local state included.</summary>
|
||||
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
||||
|
||||
/// <summary>The host keys this user has approved.</summary>
|
||||
internal ObservableCollection<KnownHostRowViewModel> KnownHostPins { get; } = [];
|
||||
|
||||
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
|
||||
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
|
||||
|
||||
@@ -365,6 +421,9 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private CredentialRowViewModel? selectedCredential;
|
||||
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selectedKnownHost;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
@@ -398,6 +457,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// <inheritdoc cref="ShowsHosts" />
|
||||
internal bool ShowsCredentials => Section is VaultSection.Credentials;
|
||||
|
||||
/// <inheritdoc cref="ShowsHosts" />
|
||||
internal bool ShowsKnownHosts => Section is VaultSection.KnownHosts;
|
||||
|
||||
// ---- The editor ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -627,6 +689,9 @@ internal sealed partial class VaultViewModel(
|
||||
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
|
||||
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Last, because it reads the host list to work out which pins nothing dials any more.
|
||||
unreadable += await ReloadKnownHostsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
UnreadableItems = unreadable;
|
||||
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -710,6 +775,50 @@ internal sealed partial class VaultViewModel(
|
||||
return listing.Unreadable;
|
||||
}
|
||||
|
||||
/// <returns>How many pins would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// Read through the repository rather than through <c>VaultKnownHostStore</c>, which holds a snapshot
|
||||
/// shaped for the SSH handshake — one pin per endpoint, deduplicated, and with no entity ids. This list
|
||||
/// has to show duplicates, because a duplicate is one of the things worth seeing.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.KnownHosts
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var selectedId = SelectedKnownHost?.EntityId;
|
||||
|
||||
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
|
||||
// hundred scans of the host list on every background sync.
|
||||
var dialled = Hosts
|
||||
.Select(host => Endpoint(host.Host.Hostname, host.Host.Port))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
KnownHostPins.Clear();
|
||||
|
||||
foreach (var pin in listing.Items
|
||||
.OrderBy(pin => pin.Secret.Host, StringComparer.CurrentCulture)
|
||||
.ThenBy(pin => pin.Secret.Port)
|
||||
.ThenBy(pin => pin.Secret.Algorithm, StringComparer.Ordinal))
|
||||
{
|
||||
KnownHostPins.Add(new KnownHostRowViewModel(
|
||||
pin, dialled.Contains(Endpoint(pin.Secret.Host, pin.Secret.Port))));
|
||||
}
|
||||
|
||||
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
return listing.Unreadable;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Case-insensitively, because a host name is, and <c>KnownHostIdentity</c> keys the store the same way.
|
||||
/// A pin written as <c>DB.internal</c> and a host saved as <c>db.internal</c> are the same machine, and a
|
||||
/// list that called one of them unused would be inviting somebody to delete trust they rely on.
|
||||
/// </remarks>
|
||||
private static string Endpoint(string host, int port) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");
|
||||
|
||||
/// <summary>Runs a synchronisation pass, if there is a server to talk to.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||||
@@ -1266,6 +1375,54 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws trust from the selected pin's endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Goes through the same <c>ForgetAsync</c> as the host editor's button, which withdraws every pin for
|
||||
/// the endpoint rather than the one row that was selected. That is deliberate and not a shortcut: trust
|
||||
/// is about an address, a second pin for the same address under another algorithm would go on being
|
||||
/// offered at the next handshake, and a user who has decided to stop trusting a machine has not decided
|
||||
/// to stop trusting one of its keys. The status line says how many went.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No confirmation. Withdrawing trust costs one fingerprint check on the next connection, and it is the
|
||||
/// safe direction to be wrong in — the dangerous button is the one that adds trust, and that one is the
|
||||
/// prompt at connect time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ForgetPinAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedKnownHost is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
$"Forgetting the pinned host key for {row.Host}…",
|
||||
async () =>
|
||||
{
|
||||
var forgotten = await knownHosts
|
||||
.ForgetAsync(row.Host, row.Port, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// A mismatch the user was staring at is about a pin that may have just gone.
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = forgotten == 1
|
||||
? $"Forgot the pinned key for {row.Host}:{row.Port}."
|
||||
: $"Forgot {forgotten} pinned key(s) for {row.Host}:{row.Port}.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
// Pushed straight away, as trusting is: the other machines are the ones still refusing to connect to
|
||||
// a server that has been rebuilt.
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens a terminal on the selected host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
@@ -1910,6 +2067,7 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(ShowsHosts));
|
||||
OnPropertyChanged(nameof(ShowsKeys));
|
||||
OnPropertyChanged(nameof(ShowsCredentials));
|
||||
OnPropertyChanged(nameof(ShowsKnownHosts));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user