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:
@@ -152,6 +152,21 @@ public sealed class VaultColumnLayoutTests : IAsyncLifetime
|
||||
await MeasureAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The only section with no editor, so it has only the one shape — but the tallest rows, because each
|
||||
/// carries a full fingerprint on a wrapped monospace line rather than a one-word description.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostKeysSectionFits()
|
||||
{
|
||||
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
|
||||
vault.ShowsKnownHosts.ShouldBeTrue();
|
||||
|
||||
vault.KnownHostPins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
|
||||
|
||||
await MeasureAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The host editor is the one a third item type made taller: its authentication picker is now a ComboBox
|
||||
/// with a two-line-capable item template, and the section it sits in is the only one holding a
|
||||
@@ -236,6 +251,14 @@ public sealed class VaultColumnLayoutTests : IAsyncLifetime
|
||||
column.KeyboardTarget.ShouldBeSameAs(column.CredentialList);
|
||||
column.KeyboardTarget.Focus().ShouldBeTrue("the credentials section is showing");
|
||||
});
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
|
||||
|
||||
await OnTheColumnAsync((column, _) =>
|
||||
{
|
||||
column.KeyboardTarget.ShouldBeSameAs(column.KnownHostList);
|
||||
column.KeyboardTarget.Focus().ShouldBeTrue("the host keys section is showing");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -266,6 +289,7 @@ public sealed class VaultColumnLayoutTests : IAsyncLifetime
|
||||
await AssertOnlyVisibleAsync(VaultSection.Hosts);
|
||||
await AssertOnlyVisibleAsync(VaultSection.Keys);
|
||||
await AssertOnlyVisibleAsync(VaultSection.Credentials);
|
||||
await AssertOnlyVisibleAsync(VaultSection.KnownHosts);
|
||||
}
|
||||
|
||||
/// <summary>Shows one section and checks that it is the only one a user can see.</summary>
|
||||
@@ -280,6 +304,7 @@ public sealed class VaultColumnLayoutTests : IAsyncLifetime
|
||||
[VaultSection.Hosts] = column.HostList,
|
||||
[VaultSection.Keys] = column.KeyList,
|
||||
[VaultSection.Credentials] = column.CredentialList,
|
||||
[VaultSection.KnownHosts] = column.KnownHostList,
|
||||
};
|
||||
|
||||
foreach (var (owner, list) in lists)
|
||||
@@ -304,13 +329,24 @@ public sealed class VaultColumnLayoutTests : IAsyncLifetime
|
||||
{
|
||||
var buttons = column.SectionSelector.Children.OfType<Button>().ToList();
|
||||
|
||||
buttons.Count.ShouldBe(3, "one per section that exists");
|
||||
buttons.Count.ShouldBe(4, "one per section that exists");
|
||||
|
||||
foreach (var button in buttons)
|
||||
{
|
||||
button.Bounds.Height.ShouldBeGreaterThan(20);
|
||||
button.Bounds.Width.ShouldBeGreaterThan(40);
|
||||
}
|
||||
|
||||
// How much room a fifth section would have. The row is a horizontal StackPanel in a 340-pixel
|
||||
// column, so the four labels are close to filling it — and the fit tests above would catch an
|
||||
// overflow only as "a button falls outside the window", which reads as a mysterious layout fault
|
||||
// rather than as "the selector has run out of room". Stated as a number so it reads as itself.
|
||||
var used = buttons.Sum(button => button.Bounds.Width);
|
||||
|
||||
used.ShouldBeLessThan(
|
||||
LayoutHarness.VaultColumnWidth,
|
||||
$"the selector needs {used:0} of {LayoutHarness.VaultColumnWidth:0} pixels; a fifth section "
|
||||
+ "means shorter labels or a second row");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,6 +411,22 @@ public sealed class VaultColumnLayoutTests : IAsyncLifetime
|
||||
await vault.SaveCredentialCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// Pins come from approving a fingerprint at connect time, not from an editor, so they are seeded
|
||||
// through the store the connect path writes to. Two for one endpoint, because a host offering keys
|
||||
// of two algorithms is ordinary and the duplicate is one of the things this list has to show.
|
||||
foreach (var (host, algorithm) in new[]
|
||||
{
|
||||
("host-0.internal", "ssh-ed25519"),
|
||||
("host-0.internal", "ecdsa-sha2-nistp256"),
|
||||
("gone.internal", "ssh-ed25519"),
|
||||
})
|
||||
{
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation(
|
||||
host, 22, algorithm, $"SHA256:{algorithm}-fingerprint-0123456789abcdefghijklmnop"),
|
||||
Token);
|
||||
}
|
||||
|
||||
// Back to where the column opens, so every test starts from the state a user would see.
|
||||
vault.Section = VaultSection.Hosts;
|
||||
|
||||
|
||||
@@ -1726,6 +1726,123 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.ShowsHosts.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- Pinned host keys ----
|
||||
|
||||
/// <remarks>
|
||||
/// The list that did not exist. 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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePinnedKeyList_ShowsWhatWasApprovedAndWhatNothingUsesAnyMore()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("gone.internal", 22, "ssh-ed25519", "SHA256:another-key"), Token);
|
||||
|
||||
// Pushed, as the connect path pushes a pin the moment it is approved. Without this both rows would
|
||||
// be badged "not synced", which is true and would drown out the badge this test is about.
|
||||
await vault.SyncCommand.ExecuteAsync(null);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.KnownHostPins.Count.ShouldBe(2);
|
||||
|
||||
var dialled = vault.KnownHostPins
|
||||
.Single(pin => string.Equals(pin.Host, "db.internal", StringComparison.Ordinal));
|
||||
dialled.IsDialledByAHost.ShouldBeTrue("ReadyToConnectAsync's host is at db.internal:22");
|
||||
dialled.Fingerprint.ShouldBe("SHA256:the-key", "in full, because that is what gets compared");
|
||||
dialled.Badge.ShouldBeEmpty();
|
||||
|
||||
var orphan = vault.KnownHostPins
|
||||
.Single(pin => string.Equals(pin.Host, "gone.internal", StringComparison.Ordinal));
|
||||
orphan.IsDialledByAHost.ShouldBeFalse();
|
||||
orphan.Badge.ShouldBe("no host uses this");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingAHost_LeavesItsPinBehindAndTheListSaysSo()
|
||||
{
|
||||
// The behaviour the debt was about, now visible instead of silent. Keeping the pin is right — the
|
||||
// address may still be reached by something else, and trust is about the endpoint rather than the
|
||||
// bookmark — so the fix was never to cascade the delete. It was to stop the leftover being invisible.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
||||
"the pin outlives the host, and the list has to admit it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgettingAPinFromTheList_WithdrawsEveryKeyForThatAddress()
|
||||
{
|
||||
// One address, two algorithms, one decision. Somebody withdrawing trust from a machine has not
|
||||
// decided to keep trusting one of its keys — and a pin left behind would go on being offered at the
|
||||
// next handshake, which reads as a withdrawal that did not work.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-ed25519-key"), Token);
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ecdsa-sha2-nistp256", "SHA256:the-ecdsa-key"), Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
vault.KnownHostPins.Count.ShouldBe(2);
|
||||
|
||||
vault.SelectedKnownHost = vault.KnownHostPins[0];
|
||||
await vault.ForgetPinCommand.ExecuteAsync(null);
|
||||
|
||||
vault.KnownHostPins.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain("2 pinned key(s)");
|
||||
|
||||
// And it reached the vault, not just the snapshot: the next connection has to ask again.
|
||||
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
||||
|
||||
// Pushed straight away, as trusting is — the other machines are the ones still refusing.
|
||||
vault.PendingChanges.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgettingWithNothingSelected_DoesNothing()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
vault.SelectedKnownHost.ShouldBeNull("loading must not select a pin, because Forget acts on it");
|
||||
|
||||
await vault.ForgetPinCommand.ExecuteAsync(null);
|
||||
|
||||
vault.KnownHostPins.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinSectionIsReachableAndTakesItsTurn()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
|
||||
|
||||
vault.ShowsKnownHosts.ShouldBeTrue();
|
||||
vault.ShowsHosts.ShouldBeFalse();
|
||||
vault.ShowsKeys.ShouldBeFalse();
|
||||
vault.ShowsCredentials.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
Reference in New Issue
Block a user