Public Access
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:
@@ -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;
|
||||
@@ -2032,6 +2033,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>
|
||||
@@ -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
|
||||
// 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>
|
||||
@@ -2808,7 +2924,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 ----
|
||||
|
||||
@@ -6034,19 +6150,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);
|
||||
@@ -6055,7 +6187,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
|
||||
@@ -6073,25 +6205,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 "
|
||||
@@ -6480,6 +6721,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
|
||||
@@ -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>
|
||||
/// Refills the vault table from the typed lists.
|
||||
/// </summary>
|
||||
@@ -9306,21 +10019,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.
|
||||
@@ -9330,6 +10029,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>
|
||||
@@ -9371,6 +10075,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>
|
||||
|
||||
Reference in New Issue
Block a user