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.
193 lines
9.0 KiB
C#
193 lines
9.0 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
|
|
namespace DodoSSH.Client.Domain;
|
|
|
|
/// <summary>
|
|
/// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Spelunk manages servers through an agent that runs on each of them and keeps a connection open to
|
|
/// Spelunk. A host carrying one of these is never dialled: a connection to it asks that Spelunk for a
|
|
/// ticket, opens a WebSocket, and Spelunk hands the bytes to the agent, which connects to the SSH server on
|
|
/// the machine's own loopback. That reaches a server with no public SSH port at all — which is the case
|
|
/// this exists for — and it is why nothing here names an address to dial.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Inside the host's encrypted payload, and nowhere else.</b> Unlike the relay, nothing about this has to
|
|
/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, with a key only
|
|
/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names the keychain item
|
|
/// holding it, for the reason <see cref="HostSecret.CredentialId"/> gives: one Spelunk key is the key for
|
|
/// every server in its organisation, and a copy per host is twenty places to rotate it and one to forget.
|
|
/// That item is a <see cref="SpelunkKeySecret"/>, which also says the one address the key may go to, or —
|
|
/// for a host set up before those existed — an ordinary credential whose password is the key.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed record SpelunkRoute
|
|
{
|
|
/// <summary>The longest API address stored.</summary>
|
|
/// <remarks>
|
|
/// Far beyond any real address, and there for the reason <see cref="HostSecret.MaxPinnedPathLength"/>
|
|
/// is: this is rendered by every device that decrypts the host, and an unbounded string is the shape a
|
|
/// corrupted payload takes.
|
|
/// </remarks>
|
|
public const int MaxApiBaseLength = 512;
|
|
|
|
/// <summary>
|
|
/// The Spelunk API's base address, as <see cref="TryNormalizeApiBase"/> writes it — for example
|
|
/// <c>https://api.spelunk.example.com</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A string in its normalised form rather than a <see cref="Uri"/>, because this is compared: two hosts
|
|
/// on the same Spelunk must say so in the same bytes, or a link from Spelunk would not find the host
|
|
/// already saved for it and the merge would see a change nobody made.
|
|
/// </remarks>
|
|
public required string ApiBase { get; init; }
|
|
|
|
/// <summary>The server's id in Spelunk.</summary>
|
|
public required Guid ServerId { get; init; }
|
|
|
|
/// <summary>
|
|
/// The keychain item holding the Spelunk API key: a <see cref="SpelunkKeySecret"/>, or a credential whose
|
|
/// password is the key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Named for the credential it always was, and kept so on the wire: item ids are unique across kinds, so
|
|
/// the one id says which, and a build that predates Spelunk keys reads a host naming one as a host whose
|
|
/// key credential has gone — refused at connect, and kept as it is by an edit — rather than as damage.
|
|
/// </para>
|
|
/// <para>
|
|
/// Required rather than nullable. A route with no key can never be opened, and a host saved that way
|
|
/// would refuse every connection with a sentence about something the editor let through. The reference
|
|
/// can still dangle — the credential may be deleted on another machine — and that is refused where it
|
|
/// is noticed, at connect, exactly as a missing SSH credential is.
|
|
/// </para>
|
|
/// </remarks>
|
|
public required Guid ApiKeyCredentialId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// For each connection this client makes a throwaway key pair in memory and sends the public half with
|
|
/// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on
|
|
/// the machine whose only authorised key is that one, and says which account it logs in as. It is real
|
|
/// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host,
|
|
/// and the key is gone when the connection is.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>What the host says about logging in is then not asked.</b> Its username, key, credential and
|
|
/// typed-password choice — its own or a group's — are left alone rather than refused, because they are
|
|
/// the host's answer for the day this is unticked, and refusing a host that carries them would make a
|
|
/// merge of two honest edits undecodable. The editor stores no binding beside this, and the connect path
|
|
/// never reads one for a host that has it. See <c>VaultViewModel.TryBuildAuthentication</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Written from <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>, and only when true, so a route
|
|
/// without it encodes exactly as it did before it existed.
|
|
/// </para>
|
|
/// </remarks>
|
|
public bool SignInThroughSpelunk { get; init; }
|
|
|
|
/// <summary>Whether this is the route to the given server on the given Spelunk.</summary>
|
|
/// <param name="apiBase">A normalised API base, as <see cref="TryNormalizeApiBase"/> writes it.</param>
|
|
/// <param name="serverId">The server's id in Spelunk.</param>
|
|
public bool IsFor(string apiBase, Guid serverId) =>
|
|
ServerId == serverId && string.Equals(ApiBase, apiBase, StringComparison.Ordinal);
|
|
|
|
/// <summary>Checks that this route can be stored and opened.</summary>
|
|
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
|
{
|
|
if (!TryNormalizeApiBase(ApiBase, out var normalized, out reason))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Stored normalised or not at all, for the comparison the remarks on ApiBase depend on.
|
|
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
|
|
{
|
|
reason = "The Spelunk API address is not in its normal form.";
|
|
return false;
|
|
}
|
|
|
|
if (ServerId == Guid.Empty)
|
|
{
|
|
reason = "A host reached through Spelunk needs the id of its Spelunk server.";
|
|
return false;
|
|
}
|
|
|
|
if (ApiKeyCredentialId == Guid.Empty)
|
|
{
|
|
reason = "A host reached through Spelunk needs its Spelunk API key chosen.";
|
|
return false;
|
|
}
|
|
|
|
reason = null;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a Spelunk API address as a user or a link gives it, into the one form it is stored in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>HTTPS, or plain HTTP to this machine only.</b> The API key travels in a header on every tunnel, so
|
|
/// an address that sends it in clear across a network is refused rather than stored. A Spelunk running
|
|
/// on the developer's own box is the one place plain HTTP is harmless, and it is allowed there so the
|
|
/// feature can be tried against one.
|
|
/// </para>
|
|
/// <para>
|
|
/// No user name, password, query or fragment: none of them means anything to the API, and the first two
|
|
/// are the classic way to make an address read as one host while naming another. What is left is the
|
|
/// scheme, the authority and any path prefix, lower-cased where URLs are case-insensitive and without a
|
|
/// trailing slash — so the same Spelunk typed twice is stored the same way twice.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static bool TryNormalizeApiBase(
|
|
string? text,
|
|
[NotNullWhen(true)] out string? apiBase,
|
|
[NotNullWhen(false)] out string? reason)
|
|
{
|
|
apiBase = null;
|
|
|
|
var trimmed = text?.Trim() ?? string.Empty;
|
|
|
|
if (trimmed.Length == 0)
|
|
{
|
|
reason = "A host reached through Spelunk needs the address of the Spelunk API.";
|
|
return false;
|
|
}
|
|
|
|
if (trimmed.Length > MaxApiBaseLength
|
|
|| !Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
|
|| uri.Scheme is not ("https" or "http")
|
|
|| uri.HostNameType is UriHostNameType.Unknown
|
|
|| string.IsNullOrEmpty(uri.Host))
|
|
{
|
|
reason = "The Spelunk API address must be a web address, like https://api.spelunk.example.com.";
|
|
return false;
|
|
}
|
|
|
|
if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback)
|
|
{
|
|
reason = "The Spelunk API address must use https, because the API key is sent with every connection.";
|
|
return false;
|
|
}
|
|
|
|
if (uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0)
|
|
{
|
|
reason = "The Spelunk API address cannot carry a user name, a query or a fragment.";
|
|
return false;
|
|
}
|
|
|
|
apiBase = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
|
reason = null;
|
|
return true;
|
|
}
|
|
}
|