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; /// One saved Spelunk key, as a row on the Security page. /// /// 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. /// internal sealed class SpelunkKeyRowViewModel(VaultItem 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; /// The one address this key is ever sent to. internal string ApiBase => key.Secret.ApiBase; /// The vault's name, when there is more than one to tell apart; empty otherwise. internal string VaultBadge { get; init; } = string.Empty; internal bool HasVaultBadge => VaultBadge.Length > 0; /// How many hosts reach Spelunk with this key, in words. internal string UsedBy { get; init; } = string.Empty; internal bool IsReadOnly => key.IsReadOnly; internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges); } /// /// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do. /// /// /// /// A saved key says where it may go. 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 . A link from that /// Spelunk connects straight away, because saving the key is the user saying so — see /// . /// /// /// 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. /// /// internal sealed partial class VaultViewModel { /// Every Spelunk key this session can read, the personal vault's first. internal ObservableCollection SpelunkKeys { get; } = []; /// Whether there is any saved key to list. internal bool HasSpelunkKeys => SpelunkKeys.Count > 0; /// The Spelunk API address in the Security page's form, as typed. [ObservableProperty] private string spelunkKeyFormApi = string.Empty; /// The API key in the Security page's form, as pasted. Cleared the moment it is saved. [ObservableProperty] private string spelunkKeyFormKey = string.Empty; /// What the form has to say about the last save, or empty. /// /// 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. /// [ObservableProperty] private string spelunkKeyFormNote = string.Empty; /// Whether has anything in it. internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0; partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote)); /// /// Picks the saved key for the address being typed, when nothing is chosen yet. /// /// /// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the /// address would be choosing for them. /// 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); } /// Saves the key in the form for the address in the form. [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); } /// /// Reads the form into the key it would save, and where; or says on the form why it cannot. /// /// The vault the key goes into: the personal one, when there is one. /// The key already saved there for that address, which this one replaces. /// The key to save, keeping the existing one's name. 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; } /// Asks whether a saved Spelunk key should go. [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)); } /// Queues a tombstone for the Spelunk key that was agreed to. 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); } /// How many Spelunk keys would not decrypt. /// /// Read after the hosts, because each row says how many of them use it. /// private async Task ReloadSpelunkKeysAsync(CancellationToken cancellationToken) { var unreadable = 0; var rows = new List(); 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; } /// /// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password. /// /// /// /// A saved Spelunk key goes to its own address or nowhere. 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. /// /// /// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential /// is — see . /// /// /// The key cannot be sent to this host's Spelunk. 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."); } /// The saved key for a Spelunk, the given vault's first, or null when none is saved. private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) => SpelunkKeys .Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal)) .OrderByDescending(row => row.VaultId == preferredVault) .FirstOrDefault(); /// The active vault when this session may write to it, for a session with no personal vault. private Guid? WritableActiveVault() => session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite) ?.VaultId; /// The name a key saved from the form is given: where it goes. 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", }; /// What the hosts that reach Spelunk with a key would be left with, or nothing when none do. 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."; } }