Files
DodoSSH/src/DodoSSH.Client.Domain/HostSecretCodec.cs
T

409 lines
18 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 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 = PinnedPathsSchemaVersion;
/// <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],
};
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);
}
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;
}
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 ?? []),
};
if (!candidate.TryValidate(out _))
{
return false;
}
host = candidate;
return 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; }
}
[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;