Let a key move to another vault, and ask whether it goes with the host

Keys sync and keys are shared: SshKey is in the sync registry on both sides, the
material rides in the sealed payload, and every generation of the vault key is
wrapped to a new member. What was missing was the way in. Hosts and groups could
move between vaults and keychain items could not, so a key typed into a personal
vault before the team existed stayed there for good — and moving a host into the
team's vault left it authenticating with something nobody else in that vault can
read. The code said so and could do nothing about it: "the answer is usually to
put a copy of that key in the destination vault", which meant pasting the private
half into a second item and deleting the first. A private key on a clipboard, and
two items nobody can tell apart afterwards.

MoveAsync already existed on the generic repository and is now exposed for keys
and passwords as it is for hosts and groups. What had to be built around it is the
re-aim. An item re-sealed under another vault's key lands with an id of that
vault's making, so every host bound to the old one and every group lending it as a
default is left naming a tombstone — and a host bound to something its vault no
longer holds refuses to connect rather than falling back to a typed password. A
move without the re-aim would look like a success and break every machine on that
key. It runs over every vault this session can write to, because a binding
resolves across all of them, and it counts what it could not rewrite: an item from
a newer client, or one in a vault this account may only read. Those are said in
the sentence afterwards rather than swallowed.

The host's move asks the question rather than deciding it. A binding resolves
across vaults, so the moved host goes on working for the person who moved it
whichever way this is answered; it is the colleagues they have just joined who
hold one vault's key and cannot connect with a host whose key stayed behind.
Unticked, and it stays that way on purpose: moving a key into a team's vault hands
it to everybody holding that key, and this design does not default anybody into a
disclosure. Under the box is the count of everything else that authenticates with
that key, because a key twenty machines use is a different decision from one
nothing else touches, and neither number is visible from the panel otherwise. The
question is answered against the vault in the picker, so choosing a different
destination re-asks it and a key already in the destination offers nothing.

One thing fixed on the way. A host that inherited its key from its group arrived
in the destination naming nothing at all — the group belongs to the vault it left
— so a machine that connected before the move refused after it, with no sentence
anywhere saying why. The resolved binding is now written onto the host as it
crosses, and the stranded-binding warning reads the resolved binding too, which is
the case where somebody is least likely to know a key is involved.

MOVE is on both heads, for keys and passwords only: a tag, a bucket and a pin are
read from the active vault alone, so "another vault" is not a question any of them
has. Four tests cover the move and its re-aim, the host's move with the key
brought and without it, and the inherited binding.
This commit is contained in:
2026-08-06 07:39:15 +02:00
parent 174ef7c420
commit 185790fb14
10 changed files with 1223 additions and 32 deletions
@@ -647,6 +647,236 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("only vault you can write to");
}
/// <remarks>
/// <para>
/// The gap the host's move kept running into. A key typed into a personal vault before the team existed
/// is the key the team's machines authenticate with, and until this existed there was no way to get it
/// across: the keychain could create and delete, so "moving" a key meant pasting the private half into a
/// second item and deleting the first.
/// </para>
/// <para>
/// <b>The re-aim is the half worth the test.</b> An item re-sealed into another vault lands with a new
/// id, so without it every host bound to the key would be left naming a tombstone — and a host bound to
/// something its vault no longer holds refuses to connect rather than falling back to a typed password.
/// A move that did only the first half would look like a success and break two machines.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAKeyToAnotherVault_ReSealsItThereAndReAimsTheHostsThatUsedIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
key.VaultId.ShouldNotBe(sharedVaultId, "it was typed into the personal vault");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == key.EntityId);
vault.CanMoveSelectedItem.ShouldBeTrue("there is a second vault this session can write to");
vault.MoveSelectedItemCommand.Execute(null);
vault.IsMovingItem.ShouldBeTrue(vault.Status);
vault.ShowsItemActions.ShouldBeFalse("the panel takes the place of EDIT and DELETE");
vault.MoveItemVaultChoices.ShouldNotContain(choice => choice.VaultId == key.VaultId);
// The count, before the move rather than after it. Two machines stop connecting if this is wrong.
vault.MovingItemUsage.ShouldContain("2 hosts");
vault.SelectedMoveItemVault =
vault.MoveItemVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// As the host's move does: the pass that follows every write is made to fail, so the sentence the
// move itself wrote is still on the status line to be read.
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveItemCommand.ExecuteAsync(null);
var moved = vault.Keys.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.EntityId.ShouldNotBe(key.EntityId, "an id belongs to one vault");
moved.Key.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"), "the material crossed intact");
vault.Hosts.Count.ShouldBe(2);
vault.Hosts.ShouldAllBe(host => host.Host.SshKeyId == moved.EntityId);
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("2 hosts");
}
/// <remarks>
/// The question this whole panel exists to ask. A binding resolves across vaults, so the moved host goes
/// on working for the person who moved it either way — and for the colleagues it has just joined, a host
/// whose key stayed behind is one they cannot connect with. Ticked, the key goes too and the host lands
/// naming it by the id it landed with.
/// </remarks>
[Fact]
public async Task MovingAHostWithItsKeyBrought_TakesTheKeyAcrossAndKeepsTheBinding()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue(vault.Status);
vault.BindingToBringQuestion.ShouldContain("deploy");
vault.BindingToBringNote.ShouldContain("Nothing else", Case.Insensitive);
vault.BringsTheBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
vault.BringsTheBindingAlong = true;
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var movedKey = vault.Keys.ShouldHaveSingleItem();
var movedHost = vault.Hosts.ShouldHaveSingleItem();
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
vault.Status.ShouldContain("came with it");
vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
}
/// <remarks>
/// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
/// and the sentence afterwards says what that means for everybody else in the destination. It is also
/// what happens to anybody who presses MOVE without reading, which is why it is the unticked state.
/// </remarks>
[Fact]
public async Task MovingAHostWithoutItsKey_LeavesTheKeyBehindAndSaysWhatThatCosts()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
vault.Keys.ShouldHaveSingleItem().VaultId.ShouldBe(key.VaultId, "the key was not asked for");
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.SshKeyId.ShouldBe(key.EntityId, "the binding is kept — it resolves across vaults");
vault.Status.ShouldContain("another vault");
}
/// <remarks>
/// <para>
/// The case where a host stops connecting without naming anything. A group lends its default key to
/// everything filed under it, and a group belongs to the vault it is in — so the group stays behind, and
/// a host that only inherited its key used to arrive naming nothing at all.
/// </para>
/// <para>
/// The binding is written onto the host on the way across instead, which is the same key it
/// authenticated with before the move. The move is also asked about it: the tick box reads the resolved
/// binding, so an inherited key can be brought too.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAHostThatInheritsItsGroupsKey_WritesThatBindingOntoIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices
.Single(choice => choice.EntityId == key.EntityId);
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
var before = vault.Hosts.ShouldHaveSingleItem();
before.Host.SshKeyId.ShouldBeNull("the host names nothing; the group lends it");
before.Authentication.ShouldBe("key");
vault.SelectedHost = before;
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue("an inherited key is still a key that can come along");
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
moved.Host.SshKeyId.ShouldBe(key.EntityId, "what it inherited is written onto it");
moved.Authentication.ShouldBe("key", "it authenticates with what it did before the move");
}
/// <remarks>
/// <para>
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
@@ -1461,6 +1691,41 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// Through the form rather than straight at the command, because the name is what the form is for —
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
/// </remarks>
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
private static string PrivateKey(string body) =>
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
/// <summary>Puts one key in whatever vault the keychain is filing into, and hands back its row.</summary>
private static async Task<SshKeyRowViewModel> AddKeyAsync(VaultViewModel vault, string label)
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = label;
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.IsEditingKey.ShouldBeFalse(vault.Status);
return vault.Keys.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
}
/// <summary>Creates a host that authenticates with one key, by choosing it in the editor.</summary>
private static async Task AddHostBoundToKeyAsync(VaultViewModel vault, string label, Guid keyId)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.EntityId == keyId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
}
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{
await vaults.LoadAsync(Token);