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
@@ -689,6 +689,26 @@
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
<!--
◆ THE KEY, WHICH IS THE HALF THE SENTENCE ABOVE CANNOT PROMISE. A binding resolves across
vaults, so the host goes on working here either way — but the colleagues it has just joined
hold one vault's key, and a host whose key stayed behind is one they cannot connect with.
Unticked, and it has to be: moving a key into a team's vault hands it to everybody who holds
that key. The note under it is the count, because a key twenty machines authenticate with is a
different decision from one nothing else uses, and neither is visible from here otherwise.
-->
<CheckBox IsChecked="{Binding BringsTheBindingAlong}"
IsVisible="{Binding HasABindingToBring}"
ToolTip.Tip="Moves the key or password itself into the same vault, and re-aims every host and group that used it at where it has gone.">
<TextBlock Text="{Binding BindingToBringQuestion}" Classes="hint" FontSize="12"
TextWrapping="Wrap" />
</CheckBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasABindingToBring}"
Text="{Binding BindingToBringNote}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
IsEnabled="{Binding !IsBusy}" />
@@ -297,9 +297,50 @@
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding ShowsItemActions}">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
<!--
Only where there is somewhere to move to, unlike EDIT beside it, which is the same rule the
host's MOVE follows on the phone: a button that answers with "this is the only vault you can
write to" is a button that should not have been drawn. Keys and passwords only — a tag and a
bucket are read from the active vault alone, so "another vault" is not a question they have.
-->
<Button Classes="ghost" Content="MOVE" Command="{Binding MoveSelectedItemCommand}"
IsVisible="{Binding CanMoveSelectedItem}"
ToolTip.Tip="Re-encrypts this under another vault's key, and re-aims every host and group that used it at where it has gone." />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
<!--
◆ MOVING THE ITEM TO ANOTHER VAULT, in the place those buttons were. The host's panel, over
here — see HostDrawer.axaml — and what it is for is the thing a shared vault could not do until
now: a key typed into a personal vault before the team existed was stuck there, and the only
way across was to paste the private half into a second item and delete the first.
The two sentences under the picker are the whole of the decision. The first says what a move
is; the second says what points at this key, because everything that does is re-aimed at it in
its new vault and somebody moving a key twenty machines use should see the twenty first.
-->
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMovingItem}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveItemVaultChoices}"
SelectedItem="{Binding SelectedMoveItemVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveItemCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveItemCommand}" />
</StackPanel>
</StackPanel>
<!--
The public half only, and there is no button for the other one. Installing a key means pasting
this line into a host's authorized_keys; a private key on the clipboard is a private key in