Public Access
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
375 lines
15 KiB
C#
375 lines
15 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Sync;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>One saved Spelunk key, as a row on the Security page.</summary>
|
|
/// <remarks>
|
|
/// Carries the decrypted key so the connect path needs no second decryption, and — as the credential row does
|
|
/// — exposes nothing of it to the view: the XAML binds the name, the address and a count.
|
|
/// </remarks>
|
|
internal sealed class SpelunkKeyRowViewModel(VaultItem<SpelunkKeySecret> key, Guid vaultId, string vaultName)
|
|
{
|
|
internal Guid EntityId => key.EntityId;
|
|
|
|
internal Guid VaultId { get; } = vaultId;
|
|
|
|
internal string VaultName { get; } = vaultName;
|
|
|
|
internal SpelunkKeySecret Key => key.Secret;
|
|
|
|
internal string Label => key.Secret.Label;
|
|
|
|
/// <summary>The one address this key is ever sent to.</summary>
|
|
internal string ApiBase => key.Secret.ApiBase;
|
|
|
|
/// <summary>The vault's name, when there is more than one to tell apart; empty otherwise.</summary>
|
|
internal string VaultBadge { get; init; } = string.Empty;
|
|
|
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
|
|
|
/// <summary>How many hosts reach Spelunk with this key, in words.</summary>
|
|
internal string UsedBy { get; init; } = string.Empty;
|
|
|
|
internal bool IsReadOnly => key.IsReadOnly;
|
|
|
|
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A saved key says where it may go.</b> It is an API key and the one Spelunk address it belongs to, kept
|
|
/// in the keychain and synced like any secret. A host can name it where it would name a credential, and it is
|
|
/// then sent to that address and never to another — see <see cref="SpelunkApiKeyFor"/>. A link from that
|
|
/// Spelunk connects straight away, because saving the key is the user saying so — see
|
|
/// <see cref="KnownSpelunkKey"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// The form saves into the personal vault, and a second key for an address the personal vault already has a
|
|
/// key for replaces that key rather than adding a rival: one Spelunk, one key, which is also what makes the
|
|
/// answer to "which key does a link use?" not depend on anything the user cannot see.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class VaultViewModel
|
|
{
|
|
/// <summary>Every Spelunk key this session can read, the personal vault's first.</summary>
|
|
internal ObservableCollection<SpelunkKeyRowViewModel> SpelunkKeys { get; } = [];
|
|
|
|
/// <summary>Whether there is any saved key to list.</summary>
|
|
internal bool HasSpelunkKeys => SpelunkKeys.Count > 0;
|
|
|
|
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
|
|
[ObservableProperty]
|
|
private string spelunkKeyFormApi = string.Empty;
|
|
|
|
/// <summary>The API key in the Security page's form, as pasted. Cleared the moment it is saved.</summary>
|
|
[ObservableProperty]
|
|
private string spelunkKeyFormKey = string.Empty;
|
|
|
|
/// <summary>What the form has to say about the last save, or empty.</summary>
|
|
/// <remarks>
|
|
/// Beside the form rather than only on the status line, because the Security page is a long way from the
|
|
/// status bar and a refusal nobody sees reads as a button that does nothing.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string spelunkKeyFormNote = string.Empty;
|
|
|
|
/// <summary>Whether <see cref="SpelunkKeyFormNote"/> has anything in it.</summary>
|
|
internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0;
|
|
|
|
partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote));
|
|
|
|
/// <summary>
|
|
/// Picks the saved key for the address being typed, when nothing is chosen yet.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the
|
|
/// address would be choosing for them.
|
|
/// </remarks>
|
|
partial void OnEditorSpelunkApiChanged(string value)
|
|
{
|
|
if (EditorSelectedSpelunkKey is not null
|
|
|| !SpelunkRoute.TryNormalizeApiBase(value, out var apiBase, out _)
|
|
|| SavedSpelunkKeyFor(apiBase, PersonalVaultId()) is not { } saved)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == saved.EntityId);
|
|
}
|
|
|
|
/// <summary>Saves the key in the form for the address in the form.</summary>
|
|
[RelayCommand]
|
|
private async Task SaveSpelunkKeyAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!TryReadSpelunkKeyForm(out var vaultId, out var existing, out var key))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var apiBase = key.ApiBase;
|
|
|
|
SpelunkKeyFormNote = string.Empty;
|
|
|
|
await RunAsync(
|
|
"Saving the Spelunk key…",
|
|
async () =>
|
|
{
|
|
if (existing is not null)
|
|
{
|
|
await session.SpelunkKeys
|
|
.UpdateAsync(vaultId, existing.EntityId, key, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
await session.SpelunkKeys.CreateAsync(vaultId, key, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
// The key leaves the form as soon as it is in the keychain; the address stays, as a reminder
|
|
// of what was just saved.
|
|
SpelunkKeyFormKey = string.Empty;
|
|
SpelunkKeyFormApi = apiBase;
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
var said = existing is null
|
|
? $"Saved the Spelunk key for {apiBase}. Links from that Spelunk now connect straight away."
|
|
: $"Replaced the Spelunk key for {apiBase}. Every host using it connects with the new one.";
|
|
|
|
Status = connection() is null ? $"{said} It will sync when you are online." : said;
|
|
SpelunkKeyFormNote = said;
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the form into the key it would save, and where; or says on the form why it cannot.
|
|
/// </summary>
|
|
/// <param name="vaultId">The vault the key goes into: the personal one, when there is one.</param>
|
|
/// <param name="existing">The key already saved there for that address, which this one replaces.</param>
|
|
/// <param name="key">The key to save, keeping the existing one's name.</param>
|
|
private bool TryReadSpelunkKeyForm(
|
|
out Guid vaultId,
|
|
out SpelunkKeyRowViewModel? existing,
|
|
[NotNullWhen(true)] out SpelunkKeySecret? key)
|
|
{
|
|
vaultId = Guid.Empty;
|
|
existing = null;
|
|
key = null;
|
|
|
|
if (!SpelunkRoute.TryNormalizeApiBase(SpelunkKeyFormApi, out var apiBase, out var reason))
|
|
{
|
|
SpelunkKeyFormNote = reason;
|
|
return false;
|
|
}
|
|
|
|
if ((PersonalVaultId() ?? WritableActiveVault()) is not { } target)
|
|
{
|
|
SpelunkKeyFormNote = "There is no vault here this account can write to, so the key cannot be saved.";
|
|
return false;
|
|
}
|
|
|
|
vaultId = target;
|
|
existing = SpelunkKeys.FirstOrDefault(row =>
|
|
row.VaultId == target && string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal));
|
|
|
|
var candidate = new SpelunkKeySecret
|
|
{
|
|
Label = existing?.Label ?? SpelunkKeyLabel(apiBase),
|
|
ApiBase = apiBase,
|
|
ApiKey = SpelunkKeyFormKey.Trim(),
|
|
};
|
|
|
|
if (!candidate.TryValidate(out reason))
|
|
{
|
|
SpelunkKeyFormNote = reason;
|
|
return false;
|
|
}
|
|
|
|
key = candidate;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Asks whether a saved Spelunk key should go.</summary>
|
|
[RelayCommand]
|
|
private void DeleteSpelunkKeyRow(SpelunkKeyRowViewModel? row)
|
|
{
|
|
if (row is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
DeletionTarget.SpelunkKey,
|
|
row.EntityId,
|
|
$"Delete the Spelunk key for {row.ApiBase}?",
|
|
HowFarADeletionGoes("The key"),
|
|
HostsReachingSpelunkWith(row.EntityId));
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for the Spelunk key that was agreed to.</summary>
|
|
private async Task DeleteSpelunkKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (SpelunkKeys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
Status = "That Spelunk key is no longer here, so nothing was deleted.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Deleting…",
|
|
async () =>
|
|
{
|
|
await session.SpelunkKeys
|
|
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
Status = $"Deleted the Spelunk key for {row.ApiBase}.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <returns>How many Spelunk keys would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// Read after the hosts, because each row says how many of them use it.
|
|
/// </remarks>
|
|
private async Task<int> ReloadSpelunkKeysAsync(CancellationToken cancellationToken)
|
|
{
|
|
var unreadable = 0;
|
|
var rows = new List<SpelunkKeyRowViewModel>();
|
|
|
|
var readableVaults = session.ReadableVaults.ToList();
|
|
var several = readableVaults.Count > 1;
|
|
|
|
foreach (var vault in readableVaults)
|
|
{
|
|
var listing = await session.SpelunkKeys
|
|
.ListAsync(vault.VaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
rows.AddRange(listing.Items.Select(item => new SpelunkKeyRowViewModel(item, vault.VaultId, vault.Name)
|
|
{
|
|
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
|
UsedBy = UsedBySummary(Hosts.Count(host => host.Host.Spelunk?.ApiKeyCredentialId == item.EntityId)),
|
|
}));
|
|
}
|
|
|
|
var personal = PersonalVaultId();
|
|
|
|
SpelunkKeys.Clear();
|
|
|
|
foreach (var row in rows
|
|
.OrderByDescending(row => row.VaultId == personal)
|
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
|
.ThenBy(row => row.ApiBase, StringComparer.Ordinal))
|
|
{
|
|
SpelunkKeys.Add(row);
|
|
}
|
|
|
|
OnPropertyChanged(nameof(HasSpelunkKeys));
|
|
|
|
return unreadable;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A saved Spelunk key goes to its own address or nowhere.</b> A host naming one with a different
|
|
/// address — edited by hand, or merged from two devices — is refused in words rather than sending the key
|
|
/// to a Spelunk it was never saved for. A credential carries no address, so its password goes where the
|
|
/// host says, as it always has.
|
|
/// </para>
|
|
/// <para>
|
|
/// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential
|
|
/// is — see <see cref="TryBuildAuthentication"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <exception cref="SpelunkTunnelException">The key cannot be sent to this host's Spelunk.</exception>
|
|
private string SpelunkApiKeyFor(HostRowViewModel row, SpelunkRoute route)
|
|
{
|
|
if (SpelunkKeys.FirstOrDefault(key => key.EntityId == route.ApiKeyCredentialId) is { } saved)
|
|
{
|
|
if (!string.Equals(saved.ApiBase, route.ApiBase, StringComparison.Ordinal))
|
|
{
|
|
throw new SpelunkTunnelException(
|
|
$"'{row.Label}' is reached through {route.ApiBase} with the Spelunk key saved for "
|
|
+ $"{saved.ApiBase}, and that key is never sent anywhere else. Edit the host to choose "
|
|
+ "the key for its own Spelunk.");
|
|
}
|
|
|
|
return saved.Key.ApiKey;
|
|
}
|
|
|
|
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is { } credential)
|
|
{
|
|
return credential.Credential.Password;
|
|
}
|
|
|
|
throw new SpelunkTunnelException(
|
|
$"'{row.Label}' is reached through Spelunk with an API key that is not in this keychain any more. "
|
|
+ "Edit the host to choose another one, or save the key under Settings → Security.");
|
|
}
|
|
|
|
/// <summary>The saved key for a Spelunk, the given vault's first, or null when none is saved.</summary>
|
|
private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) =>
|
|
SpelunkKeys
|
|
.Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal))
|
|
.OrderByDescending(row => row.VaultId == preferredVault)
|
|
.FirstOrDefault();
|
|
|
|
/// <summary>The active vault when this session may write to it, for a session with no personal vault.</summary>
|
|
private Guid? WritableActiveVault() =>
|
|
session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite)
|
|
?.VaultId;
|
|
|
|
/// <summary>The name a key saved from the form is given: where it goes.</summary>
|
|
private static string SpelunkKeyLabel(string apiBase) =>
|
|
Uri.TryCreate(apiBase, UriKind.Absolute, out var uri) ? $"Spelunk at {uri.Authority}" : "Spelunk";
|
|
|
|
private static string UsedBySummary(int hosts) => hosts switch
|
|
{
|
|
0 => "No host uses it yet",
|
|
1 => "1 host uses it",
|
|
_ => $"{hosts} hosts use it",
|
|
};
|
|
|
|
/// <summary>What the hosts that reach Spelunk with a key would be left with, or nothing when none do.</summary>
|
|
private string HostsReachingSpelunkWith(Guid entityId)
|
|
{
|
|
var named = Hosts
|
|
.Where(row => row.Host.Spelunk?.ApiKeyCredentialId == entityId)
|
|
.Select(row => row.Label)
|
|
.ToArray();
|
|
|
|
if (named.Length == 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var list = named.Length <= 3
|
|
? string.Join(", ", named)
|
|
: $"{string.Join(", ", named.Take(3))} and {named.Length - 3} more";
|
|
|
|
return named.Length == 1
|
|
? $"{list} reaches Spelunk with it, and will refuse to connect until it names another key."
|
|
: $"{named.Length} hosts reach Spelunk with it — {list} — and will refuse to connect until they "
|
|
+ "name another key.";
|
|
}
|
|
}
|