Public Access
409 lines
18 KiB
C#
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;
|