Public Access
Everything a shared vault needs was already here and arranged the wrong way round. A vault has to belong to a team, so creating one meant going to the teams screen, founding an organisation, and only then adding a vault to it — which the NEW VAULT button named after the team, so a team with three of them held three vaults called the same thing and nothing told them apart. Somebody who wants to share four servers with two colleagues is not asking to found anything. So the form asks for a name and nothing else. The team is derived from it, slug included, and created with this account as its owner; the vault goes inside; and the members, roles, invitations and key holders that hang off a team are all on screen the moment it exists. The tab strip's New vault entry lands there with the new vault selected, which is where the next thing anybody wants to do already is. That is two calls, and the first can succeed alone. When it does the team is kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again resends the identical create — which the server treats as the same team — and retries the vault, and the message says all of that rather than "creating the vault failed". Archiving the orphan instead would be a client deleting something on the user's behalf because a later step failed, which is the kind of tidying that eventually archives a team somebody has just been added to. A slug taken by somebody else is retried once with a disambiguated one and never in a loop; a name with no a-z or 0-9 anywhere in it falls back to the team's own id rather than to a refusal pointing at a field nobody was shown. The other half is the caret beside Vaults. Being in four teams means four teams' machines in front of you all day, and the answer is a switch per vault rather than four sign-ins. Switching one off takes its hosts, groups, keys and pins off the screens that list them and does nothing else: it still syncs, its key stays in the keyring, it stays choosable as somewhere to file a new item, and a shown host that authenticates with a key filed in it still connects. That last one is what shaped the design. TryBuildAuthentication resolves a binding out of the keychain's typed list and a cross-vault binding is legal, so filtering the reload loops — the obvious implementation — would have turned a preference about reading into an outage. Only the projections a person reads consult IsVaultShown; every Reload*Async stays whole, including the dialled-endpoint set that decides which pins are described as unused, because that is a hint which invites deleting trust. Snippets, logs and buckets needed no code and the comment says so out loud: all three read ActiveVaultId alone, and the personal vault is drawn in the menu ticked and cannot be switched off — it is the active vault, the group and tag editors' target, and the save picker's fallback, so hiding it would empty half the application rather than filter it. The preference is a column on the cache's vault row, which is what makes it survive both a relaunch and the /me refresh that runs every minute: Apply does not touch it, deliberately, because the server has never been told which vaults this machine is showing. It is in the encrypted cache rather than settings.json because it is a list of vault ids and that file's own doc comment says what may go in it. VaultSession cannot see the type at all — ReadableVaults is what the sync loop walks, and a filter reaching it would be a vault that quietly stopped syncing, found out weeks later from a host that was never there. The strip's note refusing a MenuFlyout stands and is unchanged. This flyout sidesteps the question rather than answering it: the handler selects the Vaults tab first, which collapses the renderer, so nothing native is under the popup by the time it opens — the move QuickConnect already makes. A headless test asserts that ordering, which is as far as headless can go with no native window, and manual check 1.6 is the other half. The phone is out of scope on purpose: it has no tab strip and its teams screen's vault section is read-only. The plumbing is in Client.Shell, so it can adopt this later; until then nothing there is ever hidden, which is today's behaviour. 1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth naming are the guards: a hidden vault still syncs, still holds keys that authenticate hosts on screen, still appears in the save picker, and still counts towards which pins nothing dials. Not fixed, and noted here because it is next door: VaultGrantService's team-vault create refuses a taken vault id rather than returning the existing vault, while VaultSharing's own remark claims a create whose response was lost is safe to resend. A lost 200 therefore leaves a vault whose key the client's catch already zeroed, openable by nobody.
273 lines
11 KiB
C#
273 lines
11 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Collections.Specialized;
|
|
using System.Globalization;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Sync;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>One pinned host key, as a row in the list.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
|
|
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
|
|
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
|
|
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
|
|
/// What was wrong was that nothing ever showed them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
|
|
/// of pinning one is to compare it with what they published.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class KnownHostRowViewModel(
|
|
VaultItem<KnownHostSecret> pin,
|
|
bool isDialledByAHost,
|
|
Guid vaultId,
|
|
string vaultName)
|
|
{
|
|
/// <summary>Which vault this pin lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
|
internal Guid VaultId => vaultId;
|
|
|
|
/// <summary>The vault's display name.</summary>
|
|
internal string VaultName => vaultName;
|
|
|
|
internal Guid EntityId => pin.EntityId;
|
|
|
|
internal KnownHostSecret Pin => pin.Secret;
|
|
|
|
internal string Host => pin.Secret.Host;
|
|
|
|
internal int Port => pin.Secret.Port;
|
|
|
|
internal string Algorithm => pin.Secret.Algorithm;
|
|
|
|
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
|
|
internal string Label => pin.Secret.Label;
|
|
|
|
/// <summary>The fingerprint, in full.</summary>
|
|
/// <remarks>
|
|
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
|
|
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
|
|
/// this whole mechanism exists to replace.
|
|
/// </remarks>
|
|
internal string Fingerprint => pin.Secret.Fingerprint;
|
|
|
|
/// <summary>
|
|
/// Whether any host in this vault actually dials the endpoint this pin is for.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
|
|
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
|
|
/// worth deleting on the user's behalf.
|
|
/// </remarks>
|
|
internal bool IsDialledByAHost { get; } = isDialledByAHost;
|
|
|
|
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
|
|
|
|
internal string Badge => IsDialledByAHost
|
|
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
|
|
: "no host uses this";
|
|
|
|
/// <summary>
|
|
/// When this pin was approved, as far as anything here can tell.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
|
|
/// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
|
|
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
|
|
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
|
|
/// renders as a dash rather than as a guess.
|
|
/// </remarks>
|
|
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
|
|
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
|
|
: "—";
|
|
}
|
|
|
|
/// <summary>
|
|
/// The host keys this keychain has approved, and how to withdraw one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
|
|
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
|
|
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
|
|
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
|
|
/// it produces, neither of which the vault has any use for.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
|
|
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
|
|
/// answer a question nobody is asking.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class KnownHostsViewModel : ObservableObject
|
|
{
|
|
private readonly VaultViewModel vault;
|
|
|
|
internal KnownHostsViewModel(VaultViewModel vault)
|
|
{
|
|
this.vault = vault;
|
|
|
|
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
|
|
// copy of a trust decision is the one kind of staleness that matters here.
|
|
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
|
|
|
|
Rebuild();
|
|
}
|
|
|
|
/// <summary>The pins this filter admits, in the order the vault produced them.</summary>
|
|
/// <remarks>
|
|
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
|
|
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
|
|
/// — host, then port, then algorithm — is the one worth keeping.
|
|
/// </remarks>
|
|
internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private string filter = string.Empty;
|
|
|
|
/// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
|
|
/// <remarks>
|
|
/// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
|
|
/// and there is no reason for it to learn about this screen.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private KnownHostRowViewModel? selected;
|
|
|
|
/// <summary>
|
|
/// The pins from vaults this machine is showing, before the filter box narrows them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every count and every sentence on this screen is taken from here rather than from
|
|
/// <c>vault.KnownHostPins</c>, so none of them can describe a pin the list is not drawing — a summary
|
|
/// saying "3 that no host dials" over two rows would send somebody looking for a third.
|
|
/// <para>
|
|
/// The vault's own list stays whole and this is a projection of it, which is the rule stated on
|
|
/// <c>VaultViewModel.IsVaultShown</c>: the trust the SSH handshake consults is read straight out of
|
|
/// <c>VaultKnownHostStore</c> and has never come through either list.
|
|
/// </para>
|
|
/// </remarks>
|
|
private IEnumerable<KnownHostRowViewModel> Shown =>
|
|
vault.KnownHostPins.Where(pin => vault.IsVaultShown(pin.VaultId));
|
|
|
|
internal bool HasPins => Shown.Any();
|
|
|
|
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
|
|
|
internal bool HasSelection => Selected is not null;
|
|
|
|
/// <summary>What the whole list amounts to, in one line.</summary>
|
|
/// <remarks>
|
|
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
|
|
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
|
|
/// might want to act on, and counting them is cheaper than reading a badge column.
|
|
/// </remarks>
|
|
internal string Summary
|
|
{
|
|
get
|
|
{
|
|
var total = Shown.Count();
|
|
|
|
if (total == 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var unused = Shown.Count(pin => !pin.IsDialledByAHost);
|
|
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
|
|
|
|
return unused == 0
|
|
? pins
|
|
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The hidden-vault case is its own sentence rather than falling into "nothing approved yet", which
|
|
/// would be a screen telling somebody they have never approved a host key while the keys they approved
|
|
/// sit in a vault they switched off in a menu.
|
|
/// </remarks>
|
|
internal string EmptyMessage => (HasPins, vault.KnownHostPins.Count) switch
|
|
{
|
|
(true, _) => "No approved host key matches that.",
|
|
(false, > 0) =>
|
|
"Every approved host key here is in a vault you have switched off. Press the ⌄ beside Vaults in "
|
|
+ "the tab strip to switch one back on.",
|
|
_ =>
|
|
"Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you "
|
|
+ "to check — approving it puts it here.",
|
|
};
|
|
|
|
/// <summary>Withdraws trust in the selected pin.</summary>
|
|
/// <remarks>
|
|
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
|
|
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
|
|
/// server are the other ones.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ForgetSelectedAsync()
|
|
{
|
|
if (Selected is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
|
|
}
|
|
|
|
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
|
|
|
|
partial void OnFilterChanged(string value) => Rebuild();
|
|
|
|
partial void OnSelectedChanged(KnownHostRowViewModel? value)
|
|
{
|
|
vault.SelectedKnownHost = value;
|
|
OnPropertyChanged(nameof(HasSelection));
|
|
}
|
|
|
|
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
|
|
|
|
private void Rebuild()
|
|
{
|
|
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
|
|
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
|
|
// writes that null straight back before the refill can matter.
|
|
var selectedId = Selected?.EntityId;
|
|
|
|
VisiblePins.Clear();
|
|
|
|
foreach (var pin in Shown.Where(Matches))
|
|
{
|
|
VisiblePins.Add(pin);
|
|
}
|
|
|
|
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
|
|
|
|
OnPropertyChanged(nameof(HasPins));
|
|
OnPropertyChanged(nameof(HasVisiblePins));
|
|
OnPropertyChanged(nameof(Summary));
|
|
OnPropertyChanged(nameof(EmptyMessage));
|
|
}
|
|
|
|
private bool Matches(KnownHostRowViewModel pin)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(Filter))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var needle = Filter.Trim();
|
|
|
|
return Contains(pin.Host, needle)
|
|
|| Contains(pin.Algorithm, needle)
|
|
|| Contains(pin.Fingerprint, needle)
|
|
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
|
|
}
|
|
|
|
private static bool Contains(string haystack, string needle) =>
|
|
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
|
}
|