Public Access
Merge branch 'claude/vault-key-sync-sharing-d098aa'
This commit is contained in:
@@ -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
|
||||
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
|
||||
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
|
||||
to" picker on the Keychain screen and cannot be moved yet.
|
||||
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
|
||||
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
|
||||
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
||||
|
||||
@@ -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
|
||||
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**
|
||||
|
||||
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>
|
||||
<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." />
|
||||
|
||||
<!--
|
||||
◆ 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,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
@@ -149,8 +149,19 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE"
|
||||
<!--
|
||||
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>
|
||||
</Border>
|
||||
|
||||
@@ -180,6 +191,47 @@
|
||||
</StackPanel>
|
||||
</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 ============ -->
|
||||
<Panel Grid.Row="4">
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -2053,6 +2054,41 @@ internal sealed partial class VaultViewModel(
|
||||
SelectedHost is { IsReadOnly: false } row
|
||||
&& 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>
|
||||
/// Whether the panel asking which vault to move the group to is up.
|
||||
/// </summary>
|
||||
@@ -2098,6 +2134,86 @@ internal sealed partial class VaultViewModel(
|
||||
// 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.
|
||||
|
||||
/// <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>
|
||||
/// What the drawer's header says it is about.
|
||||
/// </summary>
|
||||
@@ -2829,7 +2945,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
||||
/// <inheritdoc cref="ShowsHostActions" />
|
||||
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
|
||||
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion && !IsMovingItem;
|
||||
|
||||
// ---- Connecting ----
|
||||
|
||||
@@ -6171,19 +6287,35 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
var name = target.Name;
|
||||
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;
|
||||
movingHostId = null;
|
||||
MoveVaultChoices.Clear();
|
||||
SelectedMoveVault = null;
|
||||
BringsTheBindingAlong = false;
|
||||
|
||||
await RunAsync(
|
||||
"Moving…",
|
||||
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
|
||||
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
@@ -6192,7 +6324,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
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);
|
||||
|
||||
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
|
||||
@@ -6210,25 +6342,134 @@ internal sealed partial class VaultViewModel(
|
||||
_ => 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>
|
||||
/// The warning about a key or password that is not in the vault the host has moved to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Named rather than counted, because which one it is decides what to do about it — and the answer is
|
||||
/// usually to put a copy of that key in the destination vault, which needs to know which key.
|
||||
/// <para>
|
||||
/// 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>
|
||||
private string BindingOutside(HostSecret host, Guid vaultId)
|
||||
private string BindingOutside(HostRowViewModel row, Guid vaultId)
|
||||
{
|
||||
if (host.SshKeyId is { } keyId
|
||||
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
|
||||
&& key.VaultId != vaultId)
|
||||
if (row.Resolved.Binding is not { EntityId: { } entityId } binding)
|
||||
{
|
||||
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.";
|
||||
}
|
||||
|
||||
if (host.CredentialId is { } credentialId
|
||||
&& Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential
|
||||
if (binding.Kind is ResolvedBindingKind.Credential
|
||||
&& Credentials.FirstOrDefault(stored => stored.EntityId == entityId) is { } credential
|
||||
&& credential.VaultId != vaultId)
|
||||
{
|
||||
return $" It still authenticates with the password '{credential.Label}', which is in another "
|
||||
@@ -6617,6 +6858,452 @@ internal sealed partial class VaultViewModel(
|
||||
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>
|
||||
/// <remarks>
|
||||
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
||||
@@ -9392,6 +10079,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>
|
||||
/// Refills the vault table from the typed lists.
|
||||
/// </summary>
|
||||
@@ -9443,21 +10156,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
AddTagRows();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
AddBucketRows();
|
||||
|
||||
// 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.
|
||||
@@ -9467,6 +10166,11 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(HasVaultItems));
|
||||
OnPropertyChanged(nameof(TotalItemCount));
|
||||
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>
|
||||
@@ -9508,6 +10212,18 @@ internal sealed partial class VaultViewModel(
|
||||
default:
|
||||
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>
|
||||
|
||||
@@ -48,6 +48,16 @@ public sealed class CredentialRepository(
|
||||
CancellationToken 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" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
credentials.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
|
||||
@@ -47,6 +47,29 @@ public sealed class SshKeyRepository(
|
||||
CancellationToken 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" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
keys.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user