using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace DodoSSH.Client.Domain; /// A decoded host payload, together with the schema version it was written at. /// The host. /// /// The version the writing client used. May exceed /// , which is the case this type exists to make /// visible. /// public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion) { /// /// Whether this payload was written by a newer client than the one reading it. /// /// /// /// Such an item is safe to read — 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. /// /// /// 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. /// /// public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion; } /// /// Encodes and decodes the plaintext inside a host item's encrypted payload. /// /// /// /// 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 /// prevents. /// /// /// 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. /// /// public static class HostSecretCodec { /// The first version, and the one a host with no newer field is still written at. public const int BaseSchemaVersion = 1; /// The version that introduced . public const int SshKeyIdSchemaVersion = 2; /// The version that introduced . public const int CredentialIdSchemaVersion = 3; /// The version that introduced . public const int GroupIdSchemaVersion = 4; /// /// The version that introduced inheritance: a null and /// . /// /// /// /// The one version where "read-only on an older client" understates the cost. 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 int Port reads 0, and /// refuses the host outright. The item does not appear locked on /// that machine; it does not appear at all. /// /// /// 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 /// : only a host that actually inherits its port is written here. /// /// /// 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". /// /// public const int PortInheritSchemaVersion = 5; /// The version that introduced . /// /// 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. /// public const int TagIdsSchemaVersion = 6; /// The version that introduced . /// /// One past tags rather than sharing with them, for the reason 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 a maximum over what is genuinely present rather /// than a coincidence of what shipped together. /// public const int PinnedPathsSchemaVersion = 7; /// The highest schema version this build can write. /// /// Names the highest constant above, which assumes when it takes a /// maximum. A new field added below this line has to be named here too. /// public const int CurrentSchemaVersion = PinnedPathsSchemaVersion; /// Serialises a host to the bytes that get sealed. /// The host is not valid for storage. 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(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); } /// /// The lowest schema version that can represent this host without losing anything. /// /// /// /// Not simply , 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 any 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. /// /// /// 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. /// /// /// A maximum, not a ladder, and the difference arrived with . The /// two authentication bindings are mutually exclusive — see — so /// while they were the only versioned fields, a switch 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 and 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. /// /// /// 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. /// /// 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; } /// /// Parses a decrypted payload. /// /// /// Returns 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. /// public static bool TryDecode( ReadOnlySpan 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; } } /// /// The serialised shape. Mutable and nullable because it models untrusted input. /// /// /// Deliberately separate from . 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. /// internal sealed class HostPayloadDocument { public int SchemaVersion { get; set; } public string? Label { get; set; } public string? Hostname { get; set; } /// /// 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 /// 0 there, which refuses. See /// . /// public int? Port { get; set; } public string? Username { get; set; } public string? Notes { get; set; } public Guid[]? JumpHostIds { get; set; } /// /// Sorted, so serialisation order is defined by the type rather than by insertion order — a /// plain does not guarantee enumeration order, and this /// encoding has to be reproducible. /// public SortedDictionary? Options { get; set; } public bool RelayEnabled { get; set; } /// /// 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. /// public Guid? SshKeyId { get; set; } /// public Guid? CredentialId { get; set; } /// public Guid? GroupId { get; set; } /// public bool? AsksForPassword { get; set; } /// /// Last, and it must stay last for the reason gives. Null when the host wears no /// tags, never [] — 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. /// public Guid[]? TagIds { get; set; } /// /// Last, and null rather than [], for exactly the reasons gives — it is the /// newer of the two and follows the same rule. /// 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;