Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
ci / build and test (pull_request) Failing after 3m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 5m24s

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.
This commit is contained in:
jaap-jan committed 2026-09-29 10:34:38 +02:00
1 parent ee423b25ce
commit b08f2c58e3
37 files changed
+3907 -64

No files matched your search

@@ -62,11 +62,11 @@ internal sealed partial class VaultViewModel
[ObservableProperty]
private string editorSpelunkServerId = string.Empty;
/// <summary>The credentials the editor offers as the Spelunk API key.</summary>
/// <summary>What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials.</summary>
/// <remarks>
/// Credentials only, and no "none" entry: a route with no key cannot be opened, and nothing selected is
/// how a host made from a link says it has not been chosen yet. A key the host names that has since been
/// deleted keeps a placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
/// No "none" entry: a route with no key cannot be opened, and nothing selected is how a host made from a
/// link says it has not been chosen yet. A key the host names that has since been deleted keeps a
/// placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
/// </remarks>
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
@@ -170,13 +170,13 @@ internal sealed partial class VaultViewModel
}
/// <summary>
/// Opens a Spelunk tunnel to a host, with the key its credential holds.
/// Opens a Spelunk tunnel to a host, with the key it names.
/// </summary>
/// <remarks>
/// <para>
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
/// path uses, and goes only to the API address the host names. A credential that has gone is refused in
/// words, exactly as a missing SSH credential is — see <see cref="TryBuildAuthentication"/>.
/// path uses, and goes only to the API address the host names — and for a saved Spelunk key, only when
/// that is the address it was saved for. See <see cref="SpelunkApiKeyFor"/>.
/// </para>
/// <para>
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
@@ -194,18 +194,13 @@ internal sealed partial class VaultViewModel
SpelunkRoute route,
CancellationToken cancellationToken)
{
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is not { } key)
{
throw new SpelunkTunnelException(
$"'{row.Label}' is reached through Spelunk with an API key credential that is not in this "
+ "keychain any more. Edit the host to choose another one.");
}
var apiKey = SpelunkApiKeyFor(row, route);
return await Spelunk
.OpenAsync(
route.ApiBase,
route.ServerId,
key.Credential.Password,
apiKey,
row.Resolved.Port.Value,
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
cancellationToken)
@@ -227,16 +222,17 @@ internal sealed partial class VaultViewModel
/// <para>
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
/// SSH key or password — with the key credential a saved host already sends to that same Spelunk. It is
/// saved the ordinary way and syncs like any host. Nothing leaks by this: the key goes where it already
/// goes, and the server is one that key's own organisation holds or Spelunk refuses it.
/// SSH key or password — with the key saved for that Spelunk under Settings → Security, or else the key
/// credential a saved host already sends there. It is saved the ordinary way and syncs like any host.
/// Nothing leaks by this: the key goes where the user already said it goes, and the server is one that
/// key's own organisation holds or Spelunk refuses it.
/// </para>
/// <para>
/// <b>The first link from a Spelunk opens the editor instead, and nothing more.</b> Anything on this
/// machine can make a link, so it must never be able to send an API key to an address no saved host
/// already sends it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
/// vault — and the user chooses the credential holding the key and saves; every later link from that
/// Spelunk connects straight away.
/// <b>The first link from a Spelunk with no key opens the editor instead, and nothing more.</b> Anything
/// on this machine can make a link, so it must never be able to send an API key to an address the user
/// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
/// vault — and the user chooses the key and saves; every later link from that Spelunk connects straight
/// away.
/// </para>
/// </remarks>
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
@@ -278,20 +274,29 @@ internal sealed partial class VaultViewModel
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// The credential a saved host already sends to this Spelunk as its API key, or null when none does.
/// The key the user has already agreed may go to this Spelunk, or null when there is none.
/// </summary>
/// <remarks>
/// The personal vault's hosts first, so the answer does not depend on the order vaults happen to load in;
/// and only a credential still in the keychain, because a dangling one would make a host that cannot
/// connect.
/// <para>
/// A Spelunk key saved for that address first — it is the one thing that says so in as many words — and
/// otherwise the key a saved host already sends there, which is how the answer was reached before saved
/// keys existed and is still true of every host set up that way.
/// </para>
/// <para>
/// The personal vault's first, so the answer does not depend on the order vaults happen to load in; and
/// only a key still in the keychain, because a dangling one would make a host that cannot connect.
/// </para>
/// </remarks>
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
Hosts
SavedSpelunkKeyFor(apiBase, personal)?.EntityId
?? Hosts
.OrderByDescending(row => row.VaultId == personal)
.Select(row => row.Host.Spelunk)
.Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal))
.Select(route => (Guid?)route!.ApiKeyCredentialId)
.FirstOrDefault(id => Credentials.Any(credential => credential.EntityId == id));
.FirstOrDefault(id =>
Credentials.Any(credential => credential.EntityId == id)
|| SpelunkKeys.Any(key => key.EntityId == id && string.Equals(key.ApiBase, apiBase, StringComparison.Ordinal)));
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
@@ -390,9 +395,9 @@ internal sealed partial class VaultViewModel
IsHostPaneOpen = true;
var said = EditorSelectedSpelunkKey is null
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose the credential "
+ "holding your Spelunk API key and save — Spelunk signs you in, so no SSH key is needed, and "
+ "later links from this Spelunk connect straight away."
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose your Spelunk API "
+ "key and save — Spelunk signs you in, so no SSH key is needed, and later links from this "
+ "Spelunk connect straight away. Saving the key under Settings → Security does the same."
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
Status = said;
@@ -484,10 +489,20 @@ internal sealed partial class VaultViewModel
}
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
/// <remarks>
/// Saved Spelunk keys first, named for where they go, because that is the choice the Security page made
/// for exactly this; then every credential, for a key kept as a password before saved keys existed.
/// </remarks>
private void BuildSpelunkKeyChoices(Guid? selectedId)
{
EditorSpelunkKeyChoices.Clear();
foreach (var key in SpelunkKeys)
{
EditorSpelunkKeyChoices.Add(new AuthenticationChoice(
AuthenticationKind.Credential, key.EntityId, key.Label, $"Spelunk key · {key.ApiBase}"));
}
foreach (var credential in Credentials)
{
EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
@@ -0,0 +1,374 @@
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.";
}
}
@@ -1343,6 +1343,9 @@ internal enum DeletionTarget
/// <summary>A tag, from the vault screen.</summary>
Tag,
/// <summary>A saved Spelunk key, from the Security page.</summary>
SpelunkKey,
}
/// <summary>
@@ -4548,6 +4551,7 @@ internal sealed partial class VaultViewModel(
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadSpelunkKeysAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
// Last, because it reads the host list to work out which pins nothing dials any more.
@@ -11023,6 +11027,10 @@ internal sealed partial class VaultViewModel(
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
break;
case DeletionTarget.SpelunkKey:
await DeleteSpelunkKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
break;
default:
break;
}