Files
DodoSSH/src/DodoSSH.Client.Domain/SpelunkKeySecret.cs
T
jaap-jan b08f2c58e3
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
Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
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.
2026-09-29 10:34:38 +02:00

93 lines
3.5 KiB
C#

using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A Spelunk API key and the one Spelunk it may be sent to, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b>The address is what makes this an item of its own.</b> A key kept as a credential's password can be
/// sent anywhere a host names, and a link from Spelunk can be sent by anything on the machine — so a link
/// that is not already backed by a saved host has to stop and ask. A key stored with its address says, once
/// and in the keychain, where the user agreed it may go: a link from that Spelunk connects straight away, and
/// a host naming this key with any other address is refused at connect rather than sending it there. See
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
/// </para>
/// <para>
/// <b><see cref="ApiKey"/> is a password, and everything this codebase does about passwords applies to
/// it</b>, as it does to <see cref="ObjectStoreSecret.SecretAccessKey"/>: it is inside the encrypted payload,
/// the activity log records that it changed and never what it was, and the merge reports that it differed
/// rather than either value.
/// </para>
/// </remarks>
public sealed record SpelunkKeySecret : IVaultSecret
{
/// <summary>The longest API key stored.</summary>
/// <remarks>
/// Far beyond any real key, for the reason <see cref="SpelunkRoute.MaxApiBaseLength"/> exists: an
/// unbounded string is the shape a corrupted payload takes, and this one goes into a request header.
/// </remarks>
public const int MaxApiKeyLength = 4096;
/// <summary>What the user calls this key.</summary>
public required string Label { get; init; }
/// <summary>
/// The Spelunk API this key is for, as <see cref="SpelunkRoute.TryNormalizeApiBase"/> writes it, and the
/// only address it is ever sent to.
/// </summary>
/// <remarks>
/// Normalised for the reason <see cref="SpelunkRoute.ApiBase"/> is: it is compared, byte for byte, with
/// the address a host or a link names.
/// </remarks>
public required string ApiBase { get; init; }
/// <summary>The API key itself, as Spelunk issued it.</summary>
public required string ApiKey { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A Spelunk key needs a name.";
return false;
}
if (string.IsNullOrWhiteSpace(ApiBase))
{
reason = "A Spelunk key needs the address of the Spelunk it is for.";
return false;
}
if (!SpelunkRoute.TryNormalizeApiBase(ApiBase, out var normalized, out reason))
{
return false;
}
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
{
reason = "The Spelunk API address is not in its normal form.";
return false;
}
if (string.IsNullOrWhiteSpace(ApiKey))
{
reason = "A Spelunk key needs the key.";
return false;
}
// A header value cannot hold a line break, and a key with one would fail at the first connect with an
// HTTP error that names neither the key nor this item.
if (ApiKey.Length > MaxApiKeyLength || ApiKey.Any(char.IsControl))
{
reason = "That does not look like a Spelunk API key.";
return false;
}
reason = null;
return true;
}
}