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
+22 -2
View File
@@ -424,8 +424,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port. vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
What a move cannot do is reach a machine that has already synced the host, which is the same limit What a move cannot do is reach a machine that has already synced the host, which is the same limit
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
to" picker on the Keychain screen and cannot be moved yet. Keychain screen and cannot be moved yet.
**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
every group lending it as a default, across every vault you can write to. Without that the move would be a
deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
afterwards. The panel says what points at the key before you press it, not after.
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group **A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
+25
View File
@@ -446,6 +446,31 @@ target lands. Anything still in the source vault is a partial move, which is sur
not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves
hosts behind and never a shelf with nothing on it. hosts behind and never a shelf with nothing on it.
### 3.3b Moving a key, and moving a host with its key · **needs a second vault**
In your personal vault: add an SSH key, then two hosts that both authenticate with it. On the Keychain
screen select the key and press **MOVE**, and read the panel before choosing the shared vault.
**Pass:** the panel says what uses the key — "Used by 2 hosts…" — before anything happens. Afterwards the key
carries the destination's badge under an id it did not have a moment ago, *and both hosts still say `key`
under their names* and still connect. The sentence names the vault and the two hosts that followed it.
Then the other direction: with a key back in your personal vault and a host bound to it, choose **Move to
another vault…** on the host and pick the shared vault. The tick box under the picker offers to bring the
key, unticked, with the count of what else uses it underneath. Leave it and press MOVE; then move the host
back, tick it, and press MOVE again.
**Pass:** unticked, the host lands in the shared vault and the status line says the key it authenticates with
is in another vault and will not resolve for anybody else there. Ticked, the key lands in the shared vault
too and the host still says `key`. Choosing a *different* vault in the picker re-asks the question, and the
box disappears when the destination is the vault the key is already in.
**Failure means:** a host that says `password` after either move is the re-aim not having happened — the item
takes a new id in the destination, so every host bound to it and every group lending it has to be rewritten
as it lands, and a host bound to something its vault no longer holds refuses to connect rather than falling
back to a typed password. A tick box that is ticked when the panel opens is worse than a bug: it moves a
private key into a shared vault on the strength of a decision nobody made.
### 3.4 A group deleted on another machine · **needs two machines** ### 3.4 A group deleted on another machine · **needs two machines**
Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync
@@ -712,6 +712,25 @@
</ComboBox> </ComboBox>
<TextBlock Classes="body" <TextBlock Classes="body"
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." /> 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. A binding resolves across vaults, so the host keeps working here whichever way this is
answered — but the vault it has just joined holds one key, and a host whose own key stayed behind
is one its new colleagues cannot connect with.
Unticked, because moving a key into a team's vault hands it to everybody who holds that vault's
key: a disclosure is chosen, never defaulted into. The line under it is the count of what else
uses that key, which is the difference between an obvious yes and an obvious no.
-->
<CheckBox IsChecked="{Binding BringsTheBindingAlong}" MinHeight="44"
IsVisible="{Binding HasABindingToBring}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding BindingToBringQuestion}" />
</CheckBox>
<TextBlock Classes="body" TextWrapping="Wrap"
IsVisible="{Binding HasABindingToBring}"
Text="{Binding BindingToBringNote}" />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE" <Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" />
@@ -149,8 +149,19 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE" <!--
Command="{Binding DeleteSelectedItemCommand}" /> MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
this head, for the reason a phone has: there is no room to draw a button that answers with a
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
moving it back.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
IsVisible="{Binding CanMoveSelectedItem}"
Command="{Binding MoveSelectedItemCommand}" />
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -180,6 +191,47 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- ============ ◆ moving it to another vault ============ -->
<!--
The desktop's panel, in the place the deletion question uses and never at the same time as it: MOVE
disarms a pending deletion on the way in, and the buttons that ask either question are hidden while
one is up.
Both sentences are here rather than only in the status line afterwards, which on a phone is one line at
the bottom of a screen somebody has already navigated away from. The second one is the count of what
points at this key — every one of them is re-aimed at the vault it moves to, and that is the part
nobody can see from a keychain row.
-->
<Border Grid.Row="3" IsVisible="{Binding IsMovingItem}" Margin="12,4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding MovingItemLabel}" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveItemVaultChoices}"
SelectedItem="{Binding SelectedMoveItemVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body" 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="body" TextWrapping="Wrap"
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveItemCommand}" />
</Grid>
</StackPanel>
</Border>
<!-- ============ the items ============ --> <!-- ============ the items ============ -->
<Panel Grid.Row="4"> <Panel Grid.Row="4">
@@ -689,6 +689,26 @@
</ComboBox> </ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" <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." /> 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"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}" <Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
@@ -297,9 +297,50 @@
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0" <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding ShowsItemActions}"> IsVisible="{Binding ShowsItemActions}">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" /> <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}" /> <Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel> </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 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 this line into a host's authorized_keys; a private key on the clipboard is a private key in
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -2032,6 +2033,41 @@ internal sealed partial class VaultViewModel(
SelectedHost is { IsReadOnly: false } row SelectedHost is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId); && session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
/// <summary>
/// Whether the open move panel has a key or password it could bring with the host.
/// </summary>
/// <remarks>
/// <para>
/// Read against the vault in the picker rather than against the host, so choosing a different
/// destination re-asks the question: a key already sitting in the vault the host is going to has nothing
/// to move, and offering to move it there would be offering to do nothing.
/// </para>
/// <para>
/// The binding is the <em>resolved</em> one, so a key the host only inherits from its group counts. That
/// is the case this question matters most in — the group stays behind, so a host that inherited its key
/// arrives naming nothing at all unless the move writes the binding onto it.
/// </para>
/// </remarks>
internal bool HasABindingToBring => BindingOfTheMovingHost() is not null;
/// <summary>What the tick box beside the move picker says.</summary>
internal string BindingToBringQuestion => BindingOfTheMovingHost() is { } binding
? $"Bring the {binding.Noun} '{binding.Label}' too"
: string.Empty;
/// <summary>
/// What bringing it would do to everything else that uses it, and what leaving it would do to the host.
/// </summary>
/// <remarks>
/// Both halves, because both are decisions. The hosts that also authenticate with it are re-aimed at the
/// key's new vault and go on working for whoever can read both — but for the members of the vault it
/// left, it is gone; and a host that arrives without its key is a host its new colleagues cannot connect
/// with. Neither is the wrong answer, which is why this is a question rather than a rule.
/// </remarks>
internal string BindingToBringNote => BindingOfTheMovingHost() is { } binding
? WhatElseUses(binding.Kind, binding.EntityId, binding.Label, besidesHost: movingHostId)
: string.Empty;
/// <summary> /// <summary>
/// Whether the panel asking which vault to move the group to is up. /// Whether the panel asking which vault to move the group to is up.
/// </summary> /// </summary>
@@ -2077,6 +2113,86 @@ internal sealed partial class VaultViewModel(
// opened and its entries do not move. The one place the question decides anything is MoveGroup, which // opened and its entries do not move. The one place the question decides anything is MoveGroup, which
// asks it by building the picker and saying so when it comes back empty. // asks it by building the picker and saying so when it comes back empty.
/// <summary>
/// Whether the host's move panel is offering to bring the key or password it authenticates with.
/// </summary>
/// <remarks>
/// <para>
/// <b>Off unless it is ticked</b>, and that is not a default chosen for tidiness. Moving a key into a
/// team's vault hands it to everybody who holds that vault's key — it is a disclosure, and the same rule
/// <see cref="TargetVaultId"/> follows applies: filing something where other people can read it is
/// chosen, never defaulted into. Leaving it off is also the state that was there before this question
/// existed, so somebody pressing MOVE without reading gets what they used to get.
/// </para>
/// <para>
/// The alternative — moving the host and quietly copying the key — was rejected for the reason the
/// keychain has one item per key: two items holding the same private half cannot be told apart
/// afterwards, and rotating the key means finding both.
/// </para>
/// </remarks>
[ObservableProperty]
private bool bringsTheBindingAlong;
/// <summary>
/// Whether the panel asking which vault a keychain item should move to is up.
/// </summary>
/// <remarks>
/// The host's panel — see <see cref="IsMovingHost"/> — over on the keychain, where until now a key was
/// stuck in the vault it was typed into for ever. It takes the place of that pane's EDIT and DELETE
/// while it is open, as the deletion question does, so the pane asks one thing at a time.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsItemActions))]
private bool isMovingItem;
/// <summary>Which keychain item the open move panel is about. Null when it is closed.</summary>
/// <inheritdoc cref="movingHostId" path="/remarks" />
private Guid? movingItemId;
/// <summary>Which kind of item that id belongs to, so the confirmation knows which repository to ask.</summary>
private VaultItemKind movingItemKind;
/// <summary>Where that item lives now. Held for the same reason its id is.</summary>
private Guid movingItemVaultId;
/// <summary>Where the selected keychain item could go: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveItemVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveItemVault;
/// <summary>The item the open move panel is about, by name.</summary>
/// <inheritdoc cref="MovingGroupLabel" path="/remarks" />
[ObservableProperty]
private string movingItemLabel = string.Empty;
/// <summary>
/// What else points at the item about to move, said before the move rather than after it.
/// </summary>
/// <remarks>
/// The count is the whole of what makes this decidable. A key is the one item in this vault that other
/// items name, so moving one is never only about the key: every host bound to it and every group lending
/// it is re-aimed at the new id, and somebody about to move a key twenty machines authenticate with
/// should see the twenty before they press it, not read about them in the sentence afterwards.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMovingItemUsage))]
private string movingItemUsage = string.Empty;
/// <summary>Whether anything at all points at the item the move panel is about.</summary>
internal bool HasMovingItemUsage => MovingItemUsage.Length > 0;
/// <summary>
/// Whether the selected keychain item can be moved to another vault.
/// </summary>
/// <remarks>
/// 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 — and a key is the item this exists for: it is the one thing
/// on this screen that other vaults' hosts genuinely authenticate with.
/// </remarks>
internal bool CanMoveSelectedItem =>
MovableRow() is { IsReadOnly: false } item && CanLeaveItsVault(item.VaultId);
/// <summary> /// <summary>
/// What the drawer's header says it is about. /// What the drawer's header says it is about.
/// </summary> /// </summary>
@@ -2808,7 +2924,7 @@ internal sealed partial class VaultViewModel(
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary> /// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
/// <inheritdoc cref="ShowsHostActions" /> /// <inheritdoc cref="ShowsHostActions" />
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion; internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion && !IsMovingItem;
// ---- Connecting ---- // ---- Connecting ----
@@ -6034,19 +6150,35 @@ internal sealed partial class VaultViewModel(
var name = target.Name; var name = target.Name;
var dropped = WhatWasLeftBehind(row.Host); var dropped = WhatWasLeftBehind(row.Host);
var stranded = BindingOutside(row.Host, target.VaultId);
var moved = row.Host with { GroupId = null, TagIds = TagSet.Empty }; // Read before the panel is folded away, because all three of these are answered against it.
var bringing = BringsTheBindingAlong ? BindingOfTheMovingHost() : null;
var stranded = bringing is null ? BindingOutside(row, target.VaultId) : string.Empty;
var moved = Detached(row.Host, row.Resolved.Binding);
IsMovingHost = false; IsMovingHost = false;
movingHostId = null; movingHostId = null;
MoveVaultChoices.Clear(); MoveVaultChoices.Clear();
SelectedMoveVault = null; SelectedMoveVault = null;
BringsTheBindingAlong = false;
await RunAsync( await RunAsync(
"Moving…", "Moving…",
async () => async () =>
{ {
var carried = string.Empty;
// The binding first, so the host can be written naming the id it landed with. An
// interruption between the two leaves the key in the destination and the host still in the
// vault it started in, pointing at a tombstone — visible, and repaired by moving it again.
if (bringing is { } bring)
{
(moved, carried) = await CarriedAlongAsync(
bring, moved, row.EntityId, target.VaultId, cancellationToken)
.ConfigureAwait(true);
}
var entityId = await session.Hosts var entityId = await session.Hosts
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken) .MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
@@ -6055,7 +6187,7 @@ internal sealed partial class VaultViewModel(
SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId); SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}"; Status = $"Moved '{row.Label}' to {name}.{dropped}{carried}{stranded}";
}).ConfigureAwait(true); }).ConfigureAwait(true);
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of // As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
@@ -6073,25 +6205,134 @@ internal sealed partial class VaultViewModel(
_ => string.Empty, _ => string.Empty,
}; };
/// <summary>
/// The host as it will be written on the other side: no group, no tags, and its binding spelled out.
/// </summary>
/// <remarks>
/// <para>
/// The group and the tags go for the reason <see cref="ConfirmMoveHostAsync"/> gives. <b>The binding is
/// written onto the host when it came from a group</b>, and that is the half this used to lose: the
/// group stays behind, so a host that inherited its key arrived in the destination naming nothing at all
/// and authenticating with nothing — a machine that connected before the move and refused after it, with
/// no sentence anywhere saying why.
/// </para>
/// <para>
/// Only the inherited case writes anything. A host that names its own key already carries it, and one
/// that types its password says so with <c>AsksForPassword</c>, which is an answer rather than a gap.
/// </para>
/// </remarks>
private static HostSecret Detached(HostSecret host, ResolvedBinding binding)
{
var moved = host with { GroupId = null, TagIds = TagSet.Empty };
if (!binding.IsInherited || binding.EntityId is not { } entityId)
{
return moved;
}
return binding.Kind is ResolvedBindingKind.SshKey
? moved with { SshKeyId = entityId }
: moved with { CredentialId = entityId };
}
/// <summary>
/// Takes the host's key or password across with it, and re-aims everything else that named it.
/// </summary>
/// <returns>The host as it should now be written, and what to say about what came with it.</returns>
/// <remarks>
/// The moving host is left out of the re-aim and given the new id directly, because it is about to be
/// written into another vault anyway: re-aiming it would be a save in the vault it is leaving, followed
/// immediately by a tombstone for the row that save had just amended.
/// </remarks>
private async Task<(HostSecret Host, string Note)> CarriedAlongAsync(
MovableBinding bring,
HostSecret moved,
Guid movingHostId,
Guid vaultId,
CancellationToken cancellationToken)
{
var (hosts, groups) = PointingAt(bring.Kind, bring.EntityId);
if (await MoveTheBindingAsync(bring, vaultId, cancellationToken).ConfigureAwait(true)
is not { } landed)
{
return (moved, string.Empty);
}
var reaimed = await ReAimAtAsync(
bring.Kind,
landed,
hosts.Where(host => host.EntityId != movingHostId),
groups,
cancellationToken)
.ConfigureAwait(true);
return (
bring.Kind is ResolvedBindingKind.SshKey
? moved with { SshKeyId = landed }
: moved with { CredentialId = landed },
$" The {bring.Noun} '{bring.Label}' came with it.{WhatFollowedIt(reaimed)}");
}
/// <summary>Re-seals one key or password into another vault, or null when its row has gone.</summary>
/// <remarks>
/// Null rather than a throw, because the row is read from a list a background sync can replace: the
/// honest outcome is a host that moves and keeps naming the key where it was, which is exactly what
/// leaving the tick box alone would have done.
/// </remarks>
private async Task<Guid?> MoveTheBindingAsync(
MovableBinding binding,
Guid vaultId,
CancellationToken cancellationToken)
{
if (binding.Kind is ResolvedBindingKind.SshKey)
{
return Keys.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } key
? null
: await session.SshKeys
.MoveAsync(binding.VaultId, vaultId, binding.EntityId, key.Key, cancellationToken)
.ConfigureAwait(true);
}
return Credentials.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } credential
? null
: await session.Credentials
.MoveAsync(
binding.VaultId, vaultId, binding.EntityId, credential.Credential, cancellationToken)
.ConfigureAwait(true);
}
/// <summary> /// <summary>
/// The warning about a key or password that is not in the vault the host has moved to. /// The warning about a key or password that is not in the vault the host has moved to.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Named rather than counted, because which one it is decides what to do about it — and the answer is /// <para>
/// usually to put a copy of that key in the destination vault, which needs to know which key. /// Named rather than counted, because which one it is decides what to do about it — and the answer is to
/// bring that key across, which is the tick box beside the picker and needs to know which key.
/// </para>
/// <para>
/// Read from the resolved binding, so a key the host only inherits is warned about too. It is written
/// onto the host by <see cref="Detached"/> on the way over, so it is genuinely what the moved host
/// authenticates with — and it is the case where somebody is least likely to know a key is involved.
/// </para>
/// </remarks> /// </remarks>
private string BindingOutside(HostSecret host, Guid vaultId) private string BindingOutside(HostRowViewModel row, Guid vaultId)
{ {
if (host.SshKeyId is { } keyId if (row.Resolved.Binding is not { EntityId: { } entityId } binding)
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
&& key.VaultId != vaultId)
{ {
return $" It still authenticates with the key '{key.Label}', which is in another vault — " return string.Empty;
}
if (binding.Kind is ResolvedBindingKind.SshKey
&& Keys.FirstOrDefault(key => key.EntityId == entityId) is { } stored
&& stored.VaultId != vaultId)
{
return $" It still authenticates with the key '{stored.Label}', which is in another vault — "
+ "everybody else in this one will find that binding unresolvable."; + "everybody else in this one will find that binding unresolvable.";
} }
if (host.CredentialId is { } credentialId if (binding.Kind is ResolvedBindingKind.Credential
&& Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential && Credentials.FirstOrDefault(stored => stored.EntityId == entityId) is { } credential
&& credential.VaultId != vaultId) && credential.VaultId != vaultId)
{ {
return $" It still authenticates with the password '{credential.Label}', which is in another " return $" It still authenticates with the password '{credential.Label}', which is in another "
@@ -6480,6 +6721,452 @@ internal sealed partial class VaultViewModel(
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault(); SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
} }
/// <summary>A keychain item that could be moved, with what the panel needs to say about it.</summary>
/// <param name="Kind">Which list it came from, so the confirmation knows which repository to ask.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Label">What it is called.</param>
/// <param name="VaultId">The vault it is in now.</param>
/// <param name="IsReadOnly">Whether this build can re-encode it. A move re-encodes.</param>
private sealed record MovableItem(
VaultItemKind Kind,
Guid EntityId,
string Label,
Guid VaultId,
bool IsReadOnly);
/// <summary>A key or password a host's move could carry, resolved to the row that holds it.</summary>
/// <param name="Kind">Key or password.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Label">What it is called.</param>
/// <param name="VaultId">The vault it is in now, which is not the one the host is going to.</param>
private sealed record MovableBinding(
ResolvedBindingKind Kind,
Guid EntityId,
string Label,
Guid VaultId)
{
/// <summary>What to call it in a sentence a person reads.</summary>
internal string Noun => Kind is ResolvedBindingKind.SshKey ? "key" : "password";
}
/// <summary>How many things a move re-aimed, and how many it could not.</summary>
/// <param name="Hosts">Hosts whose own binding now names the item's new id.</param>
/// <param name="Groups">Groups whose default now names it.</param>
/// <param name="Refused">
/// Things left naming the old id, because this build cannot re-encode them or this account cannot
/// write to the vault they are in. Counted rather than swallowed: each one is a host that will refuse
/// to connect, and the sentence afterwards says how many.
/// </param>
[StructLayout(LayoutKind.Auto)]
private readonly record struct ReAimed(int Hosts, int Groups, int Refused);
/// <summary>The selected keychain row, when it is one of the kinds a vault can hand to another.</summary>
/// <inheritdoc cref="CanMoveSelectedItem" path="/remarks" />
private MovableItem? MovableRow() => SelectedVaultItem?.Kind switch
{
VaultItemKind.Key when SelectedKey is { } key =>
new MovableItem(VaultItemKind.Key, key.EntityId, key.Label, key.VaultId, key.IsReadOnly),
VaultItemKind.Credential when SelectedCredential is { } credential => new MovableItem(
VaultItemKind.Credential,
credential.EntityId,
credential.Label,
credential.VaultId,
credential.IsReadOnly),
_ => null,
};
/// <summary>Whether there is a vault to move something out of this one into.</summary>
private bool CanLeaveItsVault(Guid vaultId) =>
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != vaultId);
/// <summary>Whether this account may write to one vault at all.</summary>
/// <remarks>
/// Asked before every re-aim. A viewer of a team vault can read the hosts in it and cannot save one, so
/// a key move that tried would queue an operation the server refuses — and the honest answer is to leave
/// that host naming the old id and say so, rather than to fail the move that had already happened.
/// </remarks>
private bool CanWriteTo(Guid vaultId) =>
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId == vaultId);
/// <summary>The binding kind that goes with a keychain row's kind.</summary>
private static ResolvedBindingKind BindingKindOf(VaultItemKind kind) =>
kind is VaultItemKind.Key ? ResolvedBindingKind.SshKey : ResolvedBindingKind.Credential;
/// <summary>
/// Everything that names one key or password by id: the hosts that bind it and the groups that lend it.
/// </summary>
/// <remarks>
/// The hosts' <em>own</em> ids rather than their resolved bindings, which is the opposite of what
/// <see cref="HostsBoundTo"/> reads and is right for the opposite reason. That one warns a person, so it
/// counts everybody who would stop connecting, inherited or not. This one drives writes: a host that
/// inherits its key names nothing, so rewriting it would put a binding on a host that never had one —
/// the group it inherits from is in this list and is the one thing that has to change.
/// </remarks>
private (List<HostRowViewModel> Hosts, List<HostGroupRowViewModel> Groups) PointingAt(
ResolvedBindingKind kind,
Guid entityId)
{
var hosts = Hosts
.Where(row => OwnBinding(row.Host, kind) == entityId)
.ToList();
var groups = Groups
.Where(row => DefaultBinding(row.Group, kind) == entityId)
.ToList();
return (hosts, groups);
}
private static Guid? OwnBinding(HostSecret host, ResolvedBindingKind kind) =>
kind is ResolvedBindingKind.SshKey ? host.SshKeyId : host.CredentialId;
private static Guid? DefaultBinding(HostGroupSecret group, ResolvedBindingKind kind) =>
kind is ResolvedBindingKind.SshKey ? group.DefaultSshKeyId : group.DefaultCredentialId;
/// <summary>
/// Points everything that named a moved key or password at the id it landed with.
/// </summary>
/// <remarks>
/// <para>
/// <b>Without this a move is a deletion with extra steps.</b> An item re-sealed into another vault takes
/// a new id — see <c>VaultItemRepository.MoveAsync</c> — so every host bound to the old one would be
/// left naming a tombstone and would refuse to connect rather than fall back to a typed password. The
/// bindings themselves cross vaults perfectly well; it is only the id that changes.
/// </para>
/// <para>
/// A host this build cannot re-encode, or one in a vault this account cannot write to, is skipped and
/// counted. Failing the whole move instead would be worse: the item has already landed, and the
/// alternative to a partial re-aim is none at all.
/// </para>
/// </remarks>
private async Task<ReAimed> ReAimAtAsync(
ResolvedBindingKind kind,
Guid landedId,
IEnumerable<HostRowViewModel> hosts,
IEnumerable<HostGroupRowViewModel> groups,
CancellationToken cancellationToken)
{
var rebound = 0;
var relent = 0;
var refused = 0;
foreach (var host in hosts)
{
if (host.IsReadOnly || !CanWriteTo(host.VaultId))
{
refused++;
continue;
}
await session.Hosts
.UpdateAsync(
host.VaultId,
host.EntityId,
kind is ResolvedBindingKind.SshKey
? host.Host with { SshKeyId = landedId }
: host.Host with { CredentialId = landedId },
cancellationToken)
.ConfigureAwait(true);
rebound++;
}
foreach (var group in groups)
{
if (group.IsReadOnly || !CanWriteTo(group.VaultId))
{
refused++;
continue;
}
await session.HostGroups
.UpdateAsync(
group.VaultId,
group.EntityId,
kind is ResolvedBindingKind.SshKey
? group.Group with { DefaultSshKeyId = landedId }
: group.Group with { DefaultCredentialId = landedId },
cancellationToken)
.ConfigureAwait(true);
relent++;
}
return new ReAimed(rebound, relent, refused);
}
/// <summary>
/// The key or password the open host move panel could carry, or null when there is nothing to carry.
/// </summary>
/// <remarks>
/// Null in four cases, and each is a case where the tick box would be a lie: the host authenticates with
/// a typed password, the binding dangles already, the item is in the vault the host is going to, or it is
/// one this build cannot re-encode.
/// </remarks>
private MovableBinding? BindingOfTheMovingHost()
{
if (!IsMovingHost
|| movingHostId is not { } hostId
|| Hosts.FirstOrDefault(row => row.EntityId == hostId) is not { } host
|| SelectedMoveVault is not { } target
|| host.Resolved.Binding is not { EntityId: { } entityId } binding)
{
return null;
}
return binding.Kind switch
{
ResolvedBindingKind.SshKey =>
Keys.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } key
&& key.VaultId != target.VaultId
&& CanWriteTo(key.VaultId)
? new MovableBinding(binding.Kind, entityId, key.Label, key.VaultId)
: null,
ResolvedBindingKind.Credential =>
Credentials.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } stored
&& stored.VaultId != target.VaultId
&& CanWriteTo(stored.VaultId)
? new MovableBinding(binding.Kind, entityId, stored.Label, stored.VaultId)
: null,
_ => null,
};
}
/// <summary>Re-asks the binding question, which is answered against the vault in the picker.</summary>
private void TheBindingQuestionChanged()
{
OnPropertyChanged(nameof(HasABindingToBring));
OnPropertyChanged(nameof(BindingToBringQuestion));
OnPropertyChanged(nameof(BindingToBringNote));
}
partial void OnSelectedMoveVaultChanged(VaultChoiceViewModel? value) => TheBindingQuestionChanged();
partial void OnIsMovingHostChanged(bool value) => TheBindingQuestionChanged();
/// <summary>What else authenticates with one item, for the tick box beside the host's picker.</summary>
private string WhatElseUses(ResolvedBindingKind kind, Guid entityId, string label, Guid? besidesHost)
{
var (hosts, groups) = PointingAt(kind, entityId);
var others = hosts.Count(row => row.EntityId != besidesHost);
return Users(others, groups.Count, "other host") is not { Length: > 0 } phrase
? $"Nothing else authenticates with '{label}', so nothing is left behind by bringing it."
: $"Also used by {phrase}, which will be re-aimed at it in its new vault — and for anybody else "
+ "in the vault it leaves, it is gone.";
}
/// <summary>What uses one item, for the keychain's own move panel.</summary>
private string WhatUses(ResolvedBindingKind kind, Guid entityId)
{
var (hosts, groups) = PointingAt(kind, entityId);
return Users(hosts.Count, groups.Count, "host") is not { Length: > 0 } phrase
? string.Empty
: $"Used by {phrase}, which will be re-aimed at it in the vault it moves to.";
}
/// <summary>The hosts and groups that name something, counted into a phrase.</summary>
/// <remarks>
/// Empty when nothing does, so each caller can say its own sentence about nothing rather than being
/// handed "0 hosts" to put in the middle of one.
/// </remarks>
private static string Users(int hosts, int groups, string hostNoun)
{
var machines = hosts switch
{
0 => string.Empty,
1 => $"one {hostNoun}",
_ => $"{hosts} {hostNoun}s",
};
var shelves = groups switch
{
0 => string.Empty,
1 => "one group",
_ => $"{groups} groups",
};
return (machines, shelves) switch
{
("", "") => string.Empty,
("", _) => shelves,
(_, "") => machines,
_ => $"{machines} and {shelves}",
};
}
/// <summary>
/// Opens the panel that asks which vault the selected key or password should move to.
/// </summary>
/// <remarks>
/// <para>
/// The host's panel again — see <see cref="MoveHost"/> — and the gap it closes is the one the host's
/// move kept running into: moving a machine into a team's vault left the key it authenticates with in
/// the vault it came from, where the team cannot read it. Until now the only remedy was to paste the
/// private half into a second item, which is a private key on a clipboard and two items nobody can tell
/// apart afterwards.
/// </para>
/// <para>
/// Refused for an item written by a newer client, exactly as editing one is: the move re-encodes the
/// payload, so a field this build cannot represent would be dropped on the way across.
/// </para>
/// </remarks>
[RelayCommand]
private void MoveSelectedItem()
{
if (MovableRow() is not { } item || AVaultEditorIsInTheWay())
{
return;
}
if (item.IsReadOnly)
{
Status = "This was written by a newer version of DodoSSH. Moving it would re-encode it here and "
+ "lose what this build cannot read. Update first.";
return;
}
BuildMoveItemVaultChoices(item.VaultId);
if (MoveItemVaultChoices.Count == 0)
{
Status = $"There is nowhere to move '{item.Label}' to: this is the only vault you can write to.";
return;
}
// As the host's panel disarms a deletion aimed at the same host: two questions about one item, one
// of which destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null;
movingItemId = item.EntityId;
movingItemKind = item.Kind;
movingItemVaultId = item.VaultId;
MovingItemLabel = item.Label;
MovingItemUsage = WhatUses(BindingKindOf(item.Kind), item.EntityId);
IsMovingItem = true;
Status = string.Empty;
}
/// <summary>Abandons the keychain's move panel.</summary>
[RelayCommand]
private void CancelMoveItem()
{
if (!IsMovingItem)
{
return;
}
IsMovingItem = false;
movingItemId = null;
MovingItemLabel = string.Empty;
MovingItemUsage = string.Empty;
MoveItemVaultChoices.Clear();
SelectedMoveItemVault = null;
Status = string.Empty;
}
/// <summary>
/// Moves the key or password into the chosen vault, and re-aims everything that named it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The item first, the re-aims after</b>, because each of those has to name the id it landed with.
/// What an interruption between them leaves is a key in its new vault and some hosts still naming the
/// old one, which is visible — those hosts say they cannot resolve their binding — and repaired by
/// binding them again. The other order cannot be written at all.
/// </para>
/// <para>
/// <b>The hosts are re-aimed across every vault they are in, not only the one the key came from.</b> A
/// binding resolves over everything this session can read, which is the arrangement one key on twenty
/// hosts in three vaults exists for — so a re-aim scoped to one vault would quietly break the other two.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveItemAsync(CancellationToken cancellationToken)
{
if (movingItemId is not { } entityId
|| SelectedMoveItemVault is not { } target
|| MovableRow() is not { IsReadOnly: false })
{
return;
}
var kind = movingItemKind;
var from = movingItemVaultId;
var label = MovingItemLabel;
var bindingKind = BindingKindOf(kind);
var (hosts, groups) = PointingAt(bindingKind, entityId);
var name = target.Name;
var key = Keys.FirstOrDefault(row => row.EntityId == entityId);
var credential = Credentials.FirstOrDefault(row => row.EntityId == entityId);
CancelMoveItemCommand.Execute(null);
await RunAsync(
"Moving…",
async () =>
{
var landed = kind is VaultItemKind.Key
? await session.SshKeys
.MoveAsync(from, target.VaultId, entityId, key!.Key, cancellationToken)
.ConfigureAwait(true)
: await session.Credentials
.MoveAsync(from, target.VaultId, entityId, credential!.Credential, cancellationToken)
.ConfigureAwait(true);
var reaimed = await ReAimAtAsync(
bindingKind, landed, hosts, groups, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// By its new id, as a moved host's pane is: leaving the pane on the row it came from would
// read as the item having been deleted rather than moved.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == landed);
Status = $"Moved '{label}' to {name}.{WhatFollowedIt(reaimed)}";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>What the re-aim achieved, said as the counts somebody can check against the cards.</summary>
private static string WhatFollowedIt(ReAimed reaimed)
{
var followed = Users(reaimed.Hosts, reaimed.Groups, "host") is { Length: > 0 } phrase
? $" {phrase} now point at it there."
: string.Empty;
var left = reaimed.Refused switch
{
0 => string.Empty,
1 => " One thing that used it could not be rewritten here and still names the old item; it will "
+ "refuse to connect until it is bound again.",
_ => $" {reaimed.Refused} things that used it could not be rewritten here and still name the old "
+ "item; they will refuse to connect until they are bound again.",
};
return followed + left;
}
/// <summary>Fills the keychain move panel's picker with every vault this session can write to but that one.</summary>
private void BuildMoveItemVaultChoices(Guid vaultId)
{
MoveItemVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveItemVaultChoices.Add(choice);
}
SelectedMoveItemVault = MoveItemVaultChoices.FirstOrDefault();
}
/// <summary>Asks whether the selected host should go.</summary> /// <summary>Asks whether the selected host should go.</summary>
/// <remarks> /// <remarks>
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does /// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
@@ -9255,6 +9942,32 @@ internal sealed partial class VaultViewModel(
} }
} }
/// <summary>Adds the bucket rows to the table, when the table is showing them.</summary>
/// <remarks>
/// Out of <see cref="RebuildVaultItems"/> for the reason <see cref="AddTagRows"/> is — length — and this
/// is the arm that left rather than the newest one, because a rebuild that also has to say whether the
/// selected row can be moved has one line more than it can hold.
/// </remarks>
private void AddBucketRows()
{
if (Section is not (VaultSection.All or VaultSection.Buckets))
{
return;
}
foreach (var store in ObjectStores)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.ObjectStore,
store.EntityId,
store.Label,
"BUCKET",
store.Description,
store.Badge,
store.HasUnsyncedChanges));
}
}
/// <summary> /// <summary>
/// Refills the vault table from the typed lists. /// Refills the vault table from the typed lists.
/// </summary> /// </summary>
@@ -9306,21 +10019,7 @@ internal sealed partial class VaultViewModel(
} }
AddTagRows(); AddTagRows();
AddBucketRows();
if (Section is VaultSection.All or VaultSection.Buckets)
{
foreach (var store in ObjectStores)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.ObjectStore,
store.EntityId,
store.Label,
"BUCKET",
store.Description,
store.Badge,
store.HasUnsyncedChanges));
}
}
// The selection survives a reload, as every other list's does, and for the same reason: a background // The selection survives a reload, as every other list's does, and for the same reason: a background
// sync every minute would otherwise move the detail pane out from under whoever was reading it. // sync every minute would otherwise move the detail pane out from under whoever was reading it.
@@ -9330,6 +10029,11 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(HasVaultItems)); OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(TotalItemCount)); OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage)); OnPropertyChanged(nameof(EmptySectionMessage));
// A reload replaces every row object, and the selection is restored by id — so the setter above may
// not have fired even though the row this answers about is a different instance. Asked again here,
// because the answer decides whether the pane draws MOVE at all.
OnPropertyChanged(nameof(CanMoveSelectedItem));
} }
/// <remarks> /// <remarks>
@@ -9371,6 +10075,18 @@ internal sealed partial class VaultViewModel(
default: default:
break; break;
} }
// After the switch, not with the three above it: this one is answered from the typed selection the
// switch has just made, so asking before it would answer about the row that was selected before.
OnPropertyChanged(nameof(CanMoveSelectedItem));
// The move panel names one item and its picker is built from that item's vault, so a selection that
// has gone elsewhere has left it aimed at something nobody is looking at. The deletion question
// above is disarmed the same way and for the same reason.
if (IsMovingItem && movingItemId != value?.EntityId)
{
CancelMoveItemCommand.Execute(null);
}
} }
/// <remarks> /// <remarks>
@@ -48,6 +48,16 @@ public sealed class CredentialRepository(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken); credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <inheritdoc cref="SshKeyRepository.MoveAsync" path="/remarks" />
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
CredentialSecret credential,
CancellationToken cancellationToken) =>
credentials.MoveAsync(fromVaultId, toVaultId, entityId, credential, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" /> /// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
credentials.DeleteAsync(vaultId, entityId, cancellationToken); credentials.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -47,6 +47,29 @@ public sealed class SshKeyRepository(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
keys.UpdateAsync(vaultId, entityId, key, cancellationToken); keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <remarks>
/// <para>
/// The same two writes a host's move is, and the reason a key needs one at all is what a vault is for:
/// a key created in a personal vault before a team existed is the key the team's hosts authenticate
/// with, and until this existed the only way to get it across was to paste the private half into a
/// second item and delete the first — which is a private key on a clipboard, and two items nobody can
/// tell apart afterwards.
/// </para>
/// <para>
/// <b>It lands with a new id</b>, as everything moved does, so every host and group default naming the
/// old one is left pointing at a tombstone. Re-aiming them is the caller's, because only the caller
/// knows which of them it is allowed to rewrite — see <c>VaultViewModel.ReAimAtAsync</c>.
/// </para>
/// </remarks>
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
SshKeySecret key,
CancellationToken cancellationToken) =>
keys.MoveAsync(fromVaultId, toVaultId, entityId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" /> /// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
keys.DeleteAsync(vaultId, entityId, cancellationToken); keys.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -647,6 +647,236 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("only vault you can write to"); 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> /// <remarks>
/// <para> /// <para>
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created, /// 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 — /// 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. /// and because the form is now the only way in: there is no separate "make a team" step behind it.
/// </remarks> /// </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) private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{ {
await vaults.LoadAsync(Token); await vaults.LoadAsync(Token);