Files
DodoSSH/src/DodoSSH.Client.Domain/HostSecretCodec.cs
T
jaap-jan 38ab9b431b Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.

A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
2026-09-28 22:53:51 +02:00

602 lines
25 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded host payload, together with the schema version it was written at.</summary>
/// <param name="Host">The host.</param>
/// <param name="SchemaVersion">
/// The version the writing client used. May exceed
/// <see cref="HostSecretCodec.CurrentSchemaVersion"/>, which is the case this type exists to make
/// visible.
/// </param>
public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion)
{
/// <summary>
/// Whether this payload was written by a newer client than the one reading it.
/// </summary>
/// <remarks>
/// <para>
/// Such an item is safe to <em>read</em> — every field this build knows about decodes normally —
/// but must not be re-encoded, because fields added by the newer schema are not represented here
/// and would be dropped on write. Silently losing a field a colleague filled in is exactly the
/// class of bug that makes people stop trusting a synced vault.
/// </para>
/// <para>
/// So the rule is: display it, refuse to edit it, and tell the user to update. Preserving unknown
/// fields through a round trip was the alternative and it is worse — it means carrying opaque
/// JSON inside the domain model, which then has no usable structural equality and so breaks the
/// merge.
/// </para>
/// </remarks>
public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a host item's encrypted payload.
/// </summary>
/// <remarks>
/// <para>
/// JSON rather than the fixed binary layouts used elsewhere in the specification. The reasoning
/// differs because the constraints differ: those layouts are hashed or signed, so canonicality is
/// load-bearing, whereas this is only ever encrypted. What matters here instead is that the format
/// grows a field without a migration — and the one thing that must not happen is an old client
/// quietly dropping a field a new one wrote, which is what <see cref="HostSecretDocument.IsReadOnly"/>
/// prevents.
/// </para>
/// <para>
/// Encoding is deterministic: property order is fixed by declaration, and directives are held in a
/// sorted map. That matters because the sync engine decides whether to push by comparing values, and
/// a codec that produced different bytes for the same host would make every pass look like a change.
/// </para>
/// </remarks>
public static class HostSecretCodec
{
/// <summary>The first version, and the one a host with no newer field is still written at.</summary>
public const int BaseSchemaVersion = 1;
/// <summary>The version that introduced <see cref="HostSecret.SshKeyId"/>.</summary>
public const int SshKeyIdSchemaVersion = 2;
/// <summary>The version that introduced <see cref="HostSecret.CredentialId"/>.</summary>
public const int CredentialIdSchemaVersion = 3;
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
public const int GroupIdSchemaVersion = 4;
/// <summary>
/// The version that introduced inheritance: a null <see cref="HostSecret.Port"/> and
/// <see cref="HostSecret.AsksForPassword"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The one version where "read-only on an older client" understates the cost.</b> Every field before
/// this one is additive: an older build decodes the host, shows it, and refuses to save it. A null port
/// is subtractive — the property is omitted, an older build's <c>int Port</c> reads 0, and
/// <see cref="HostSecret.TryValidate"/> refuses the host outright. The item does not appear locked on
/// that machine; it does not appear at all.
/// </para>
/// <para>
/// Accepted rather than worked around, because the two workarounds are worse. Writing 22 into every host
/// makes inheritance a lie the moment a group's default changes, and it is what inheritance exists to
/// stop. Keeping a second non-inheriting port field beside this one would mean two ports per host and a
/// rule about which wins, on every screen and on the wire. What confines the cost is
/// <see cref="SchemaVersionFor"/>: only a host that actually inherits its port is written here.
/// </para>
/// <para>
/// <see cref="HostSecret.AsksForPassword"/> shares this version because it arrives in the same build and
/// answers the same question — it is what a host says instead of naming a binding, once naming nothing
/// has come to mean "ask the group".
/// </para>
/// </remarks>
public const int PortInheritSchemaVersion = 5;
/// <summary>The version that introduced <see cref="HostSecret.TagIds"/>.</summary>
/// <remarks>
/// One past inheritance rather than sharing with it, because the two are independent: a host can wear
/// tags without inheriting anything, and such a host stays decodable on a build that knows 5. That
/// distinction is unobservable today — both shipped together — and it is stated anyway, because the rule
/// this file follows is one version per field and the exception would have to be re-justified by whoever
/// adds the seventh.
/// </remarks>
public const int TagIdsSchemaVersion = 6;
/// <summary>The version that introduced <see cref="HostSecret.PinnedPaths"/>.</summary>
/// <remarks>
/// One past tags rather than sharing with them, for the reason <see cref="TagIdsSchemaVersion"/> gives
/// for sharing with inheritance instead of standing alone: the two fields are independent, a host can
/// pin paths without wearing a single tag, and stating the version such a host would actually need
/// keeps the rule in <see cref="SchemaVersionFor"/> a maximum over what is genuinely present rather
/// than a coincidence of what shipped together.
/// </remarks>
public const int PinnedPathsSchemaVersion = 7;
/// <summary>The version that introduced <see cref="HostSecret.PortForwards"/>.</summary>
/// <remarks>
/// Its own version for the reason <see cref="PinnedPathsSchemaVersion"/> gives: a host can carry
/// forwards without pinning anything, and should stay editable on a build that knows 7 until it does.
/// </remarks>
public const int PortForwardsSchemaVersion = 8;
/// <summary>The version that introduced <see cref="HostSecret.Spelunk"/>.</summary>
/// <remarks>
/// <para>
/// Its own version, by the rule this file follows, and it matters more here than for a tag. An older
/// build that decoded a Spelunk host as editable would drop the route on the next save — and the host
/// would then be dialled directly, at an address that exists only so its key has a name to be pinned
/// under. Written at this version, the older build shows the host, refuses to edit it, and says to update.
/// </para>
/// <para>
/// Additive, like every version but <see cref="PortInheritSchemaVersion"/>: the route is one more
/// property, and a build that does not know it skips it and still reads a valid host. It follows
/// <see cref="PortForwardsSchemaVersion"/>, whose forwards reached main first.
/// </para>
/// </remarks>
public const int SpelunkSchemaVersion = 9;
/// <summary>The version that introduced <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</summary>
/// <remarks>
/// <para>
/// Its own version, and nested in the existing <c>spelunk</c> object rather than beside it, because it
/// is part of that one decision. A build that knows 9 skips the unknown member and reads a valid host —
/// a Spelunk host with no login of its own — and it must not save that back: the flag would be dropped
/// and the host would start asking for a password it never had. Written at this version, that build
/// shows the host, refuses to edit it, and says to update.
/// </para>
/// <para>
/// Only a route that signs in is written here. Every other Spelunk host stays at
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
/// </para>
/// </remarks>
public const int SpelunkSignInSchemaVersion = 10;
/// <summary>The highest schema version this build can write.</summary>
/// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
/// maximum. A new field added below this line has to be named here too.
/// </remarks>
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
public static byte[] Encode(HostSecret host)
{
ArgumentNullException.ThrowIfNull(host);
if (!host.TryValidate(out var error))
{
throw new ArgumentException(error, nameof(host));
}
var options = new SortedDictionary<string, string>(HostOption.NameComparer);
foreach (var option in host.Options)
{
options[option.Name] = option.Value;
}
var document = new HostPayloadDocument
{
SchemaVersion = SchemaVersionFor(host),
Label = host.Label,
Hostname = host.Hostname,
Port = host.Port,
Username = host.Username,
Notes = host.Notes,
JumpHostIds = [.. host.JumpHostIds],
Options = options,
RelayEnabled = host.RelayEnabled,
SshKeyId = host.SshKeyId,
CredentialId = host.CredentialId,
GroupId = host.GroupId,
// Null rather than false, so a host that never said anything about this encodes exactly as it
// did before the field existed. See HostSecret.AsksForPassword.
AsksForPassword = host.AsksForPassword is true ? true : null,
// Null rather than an empty array, for the same reason and with a wider blast radius: an empty
// [] here would land in every host in every vault and make the first sync after the upgrade
// read as though every one of them had changed.
TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
// as it did before this field existed.
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
// Last, and null when empty, for the reason both fields above give.
PortForwards = host.PortForwards.Count == 0
? null
: [.. host.PortForwards.Select(PortForwardDocument.From)],
// Last of all, and absent for a host that is dialled, so every host without a route encodes
// exactly as it did before routes existed.
Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, HostPayloadJsonContext.Default.HostPayloadDocument);
}
/// <summary>
/// The lowest schema version that can represent this host without losing anything.
/// </summary>
/// <remarks>
/// <para>
/// Not simply <see cref="CurrentSchemaVersion"/>, and in a shared vault the difference is the whole
/// point. The version is what makes an older client treat an item as read-only, so stamping the newest
/// one unconditionally would mean that upgrading a single machine and then touching <em>any</em> host —
/// renaming it, changing a port — made that host uneditable on every machine that had not been upgraded
/// yet. Emitting the lowest version that loses nothing confines that cost to the hosts which actually
/// use the newer field.
/// </para>
/// <para>
/// The rule generalises, and every field added since has followed it: a host is written at the version
/// that introduced the newest field it actually carries. It also means the bytes for a host that binds
/// nothing are identical to what this codec produced before either binding existed, so adding the fields
/// did not make every host in every vault look like a change to the sync engine.
/// </para>
/// <para>
/// <b>A maximum, not a ladder, and the difference arrived with <see cref="HostSecret.GroupId"/>.</b> The
/// two authentication bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so
/// while they were the only versioned fields, a <c>switch</c> that returned the first match was
/// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
/// a credential <em>and</em> a group, and the ladder would have answered 3 for it, writing a version that
/// cannot represent the group it just wrote. An older client would then decode that host as editable and
/// drop the field on the next save.
/// </para>
/// <para>
/// Written as a maximum over the fields actually present, which is the general form of the same rule and
/// stays correct however the next field relates to these.
/// </para>
/// </remarks>
private static int SchemaVersionFor(HostSecret host)
{
var version = BaseSchemaVersion;
if (host.SshKeyId is not null)
{
version = Math.Max(version, SshKeyIdSchemaVersion);
}
if (host.CredentialId is not null)
{
version = Math.Max(version, CredentialIdSchemaVersion);
}
if (host.GroupId is not null)
{
version = Math.Max(version, GroupIdSchemaVersion);
}
// Both halves of inheritance, and this is the branch that loses an item rather than locking one if
// it is forgotten — see PortInheritSchemaVersion. A host stamped at 4 with its port omitted is a
// host an older client deletes from its own view.
if (host.Port is null || host.AsksForPassword is true)
{
version = Math.Max(version, PortInheritSchemaVersion);
}
if (host.TagIds.Count > 0)
{
version = Math.Max(version, TagIdsSchemaVersion);
}
if (host.PinnedPaths.Count > 0)
{
version = Math.Max(version, PinnedPathsSchemaVersion);
}
if (host.PortForwards.Count > 0)
{
version = Math.Max(version, PortForwardsSchemaVersion);
}
if (host.Spelunk is not null)
{
version = Math.Max(version, SpelunkSchemaVersion);
}
if (host.SignsInThroughSpelunk)
{
version = Math.Max(version, SpelunkSignInSchemaVersion);
}
return version;
}
/// <summary>
/// Parses a decrypted payload.
/// </summary>
/// <remarks>
/// Returns <see langword="false"/> rather than throwing on anything malformed. These bytes
/// authenticated under a key only vault members hold, so a failure here is not an attack — it is
/// a bug in some client, or a truncated write. Either way it must degrade to one unreadable item
/// rather than an exception that aborts the whole sync pass and strands every other change.
/// </remarks>
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out HostSecretDocument? document)
{
document = null;
HostPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, HostPayloadJsonContext.Default.HostPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
if (!TryBuild(parsed, out var host))
{
return false;
}
document = new HostSecretDocument(host, parsed.SchemaVersion);
return true;
}
private static bool TryBuild(
HostPayloadDocument parsed,
[NotNullWhen(true)] out HostSecret? host)
{
host = null;
var directives = (parsed.Options ?? [])
.Select(entry => new HostOption(entry.Key, entry.Value));
if (!HostOptions.TryCreate(directives, out var options, out _))
{
return false;
}
if (!TryBuildForwards(parsed.PortForwards, out var forwards))
{
return false;
}
var candidate = new HostSecret
{
Label = parsed.Label ?? string.Empty,
Hostname = parsed.Hostname ?? string.Empty,
Port = parsed.Port,
Username = parsed.Username,
Notes = parsed.Notes,
JumpHostIds = JumpChain.Create(parsed.JumpHostIds ?? []),
Options = options,
RelayEnabled = parsed.RelayEnabled,
SshKeyId = parsed.SshKeyId,
CredentialId = parsed.CredentialId,
GroupId = parsed.GroupId,
// False folds back to null: they say the same thing, and letting both onto the record would
// give the merge two spellings of one state to report as a change nobody made.
AsksForPassword = parsed.AsksForPassword is true ? true : null,
TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
PortForwards = forwards,
Spelunk = RouteFrom(parsed.Spelunk),
};
if (!candidate.TryValidate(out _))
{
return false;
}
host = candidate;
return true;
}
/// <remarks>
/// A missing id, kind or port is a refusal rather than a default. Each would be invented here — a fresh
/// id the other machines do not have, a <c>Local</c> nobody chose — and an invented forward is one the
/// next save writes back as though somebody had asked for it.
/// </remarks>
private static bool TryBuildForwards(
PortForwardDocument?[]? parsed,
[NotNullWhen(true)] out PortForwardList? forwards)
{
forwards = PortForwardList.Empty;
if (parsed is null)
{
return true;
}
var rules = new List<PortForwardRule>(parsed.Length);
foreach (var entry in parsed)
{
if (entry?.Id is not { } id || entry.Kind is not { } kind || entry.BindPort is not { } bindPort)
{
return false;
}
rules.Add(new PortForwardRule
{
Id = id,
Kind = (PortForwardKind)kind,
BindPort = bindPort,
TargetHost = entry.TargetHost,
TargetPort = entry.TargetPort,
});
}
forwards = PortForwardList.Create(rules);
return true;
}
/// <summary>The route as stored, or null when there is none.</summary>
/// <remarks>
/// Missing parts come through as empty values rather than making the route vanish, so that
/// <see cref="HostSecret.TryValidate"/> refuses the host. Reading a half-written route as "no route"
/// would turn a Spelunk host into one that is dialled directly, which is the one reading of a damaged
/// payload that does something the user never asked for.
/// </remarks>
private static SpelunkRoute? RouteFrom(SpelunkPayloadDocument? parsed) =>
parsed is null
? null
: new SpelunkRoute
{
ApiBase = parsed.Api ?? string.Empty,
ServerId = parsed.ServerId ?? Guid.Empty,
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
// False folds to the default, as AsksForPassword's does: one state, one spelling.
SignInThroughSpelunk = parsed.SignIn is true,
};
}
/// <summary>
/// The serialised shape. Mutable and nullable because it models untrusted input.
/// </summary>
/// <remarks>
/// Deliberately separate from <see cref="HostSecret"/>. A single type would force the domain model to
/// carry the serialiser's requirements — a parameterless constructor, settable properties, nullable
/// everything — and would let a decode failure produce a half-built host that looks valid to
/// everything downstream.
/// </remarks>
internal sealed class HostPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? Hostname { get; set; }
/// <remarks>
/// Nullable, which is what removes the key from the JSON for a host that inherits its port. It is also
/// the one property here whose absence an older build cannot survive: it deserialises as
/// <see langword="int"/> 0 there, which <see cref="HostSecret.TryValidate"/> refuses. See
/// <see cref="HostSecretCodec.PortInheritSchemaVersion"/>.
/// </remarks>
public int? Port { get; set; }
public string? Username { get; set; }
public string? Notes { get; set; }
public Guid[]? JumpHostIds { get; set; }
/// <remarks>
/// Sorted, so serialisation order is defined by the type rather than by insertion order — a
/// plain <see cref="Dictionary{TKey,TValue}"/> does not guarantee enumeration order, and this
/// encoding has to be reproducible.
/// </remarks>
public SortedDictionary<string, string>? Options { get; set; }
public bool RelayEnabled { get; set; }
/// <remarks>
/// Last, deliberately. Property order is the serialisation order, so appending keeps the bytes for every
/// field that existed before this one byte-identical — and a null is omitted entirely, which is what
/// makes a host with no key encode exactly as it did before the field existed.
/// </remarks>
public Guid? SshKeyId { get; set; }
/// <inheritdoc cref="SshKeyId" />
public Guid? CredentialId { get; set; }
/// <inheritdoc cref="SshKeyId" />
public Guid? GroupId { get; set; }
/// <inheritdoc cref="SshKeyId" />
public bool? AsksForPassword { get; set; }
/// <remarks>
/// Last, and it must stay last for the reason <see cref="SshKeyId"/> gives. Null when the host wears no
/// tags, never <c>[]</c> — an empty array would be a new key in the JSON of every host in every vault,
/// which the sync engine would read as every host having changed.
/// </remarks>
public Guid[]? TagIds { get; set; }
/// <remarks>
/// Last, and null rather than <c>[]</c>, for exactly the reasons <see cref="TagIds"/> gives — it is the
/// newer of the two and follows the same rule.
/// </remarks>
public string[]? PinnedPaths { get; set; }
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
public PortForwardDocument?[]? PortForwards { get; set; }
/// <remarks>
/// Last, and null for a host that is dialled, for the reason <see cref="TagIds"/> gives. One nested object
/// rather than three loose properties, because the three are one decision — a server id without its
/// Spelunk means nothing — and an object makes "all or none" the shape of the JSON.
/// </remarks>
public SpelunkPayloadDocument? Spelunk { get; set; }
}
/// <summary>The serialised shape of a <see cref="SpelunkRoute"/>. Nullable because it models untrusted input.</summary>
internal sealed class SpelunkPayloadDocument
{
public string? Api { get; set; }
public Guid? ServerId { get; set; }
public Guid? ApiKeyCredentialId { get; set; }
/// <remarks>
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
/// </remarks>
public bool? SignIn { get; set; }
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
{
Api = route.ApiBase,
ServerId = route.ServerId,
ApiKeyCredentialId = route.ApiKeyCredentialId,
// Null rather than false, so a route that does not sign in encodes exactly as it did before the
// field existed — the rule AsksForPassword follows.
SignIn = route.SignInThroughSpelunk ? true : null,
};
}
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
/// <remarks>
/// The kind is written as its number rather than its name, so renaming a member of
/// <see cref="PortForwardKind"/> can never change what is on the wire.
/// </remarks>
internal sealed class PortForwardDocument
{
public Guid? Id { get; set; }
public int? Kind { get; set; }
public int? BindPort { get; set; }
public string? TargetHost { get; set; }
public int? TargetPort { get; set; }
public static PortForwardDocument From(PortForwardRule rule) => new()
{
Id = rule.Id,
Kind = (int)rule.Kind,
BindPort = rule.BindPort,
TargetHost = rule.TargetHost,
TargetPort = rule.TargetPort,
};
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// An unknown member means a newer client wrote a field this build has no concept of. Skipping it
// is right; the guard against losing it lives in HostSecretDocument.IsReadOnly.
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HostPayloadDocument))]
internal sealed partial class HostPayloadJsonContext : JsonSerializerContext;