Files
DodoSSH/src/DodoSSH.Client.Domain/HostSecret.cs
T
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00

117 lines
4.6 KiB
C#

using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A host as the user sees it: everything the server never gets to read.
/// </summary>
/// <remarks>
/// <para>
/// The whole of this record lives inside the item's encrypted payload. In particular there is no
/// plaintext label anywhere in the system — access-control administration runs on the client, which
/// can decrypt names, so the server never needs a searchable title.
/// </para>
/// <para>
/// <see cref="Hostname"/> and <see cref="Port"/> are here <em>and</em> may additionally appear as
/// plaintext columns on the server, but only for a host the user has opted into the relay. That is
/// the one deliberate privacy concession in the design: the relay must resolve its target
/// server-side or it becomes an authenticated open TCP proxy into the operator's own network. The
/// copy in here is the authoritative one; the plaintext column is a derived duplicate the client
/// supplies only when relay is enabled. See ADR 0004.
/// </para>
/// <para>
/// Structural equality holds across every field, including the collections, which is what the merge
/// relies on to tell "unchanged" from "changed to the same thing" from "changed differently".
/// </para>
/// </remarks>
public sealed record HostSecret
{
/// <summary>The default SSH port, used when a host does not say otherwise.</summary>
public const int DefaultPort = 22;
/// <summary>Display name. The only name this host has anywhere.</summary>
public required string Label { get; init; }
/// <summary>Hostname or address to connect to.</summary>
public required string Hostname { get; init; }
/// <summary>TCP port.</summary>
public int Port { get; init; } = DefaultPort;
/// <summary>Login user, when the host pins one.</summary>
public string? Username { get; init; }
/// <summary>Free-text notes.</summary>
public string? Notes { get; init; }
/// <summary>
/// The jump chain, nearest hop first, as host item ids.
/// </summary>
/// <remarks>
/// Order is the meaning here, so this merges as a whole value rather than as a set: reordering a
/// chain changes which machine is reached through which, and a set union of two different chains
/// would produce a route neither user asked for.
/// </remarks>
public JumpChain JumpHostIds { get; init; } = JumpChain.Empty;
/// <summary>SSH directives, unique by name.</summary>
public HostOptions Options { get; init; } = HostOptions.Empty;
/// <summary>
/// Whether this host may be dialled through the server relay.
/// </summary>
/// <remarks>
/// <para>
/// Lives here, inside the encrypted payload, rather than only in the plaintext columns the server
/// keeps. It has to: it is the flag that decides whether <see cref="Hostname"/> and
/// <see cref="Port"/> are copied out into those columns, and a setting the merge cannot see is a
/// setting two clients can silently disagree about — one of them re-exposing an address the other
/// had just withdrawn.
/// </para>
/// <para>
/// The plaintext copy is derived from this, in one place, so the address can only ever leave the
/// payload as a consequence of the user turning this on. See ADR 0004.
/// </para>
/// </remarks>
public bool RelayEnabled { get; init; }
/// <summary>
/// Checks the fields that must hold before this can be stored.
/// </summary>
/// <remarks>
/// Separate from construction on purpose. A view model binds directly to these properties and
/// passes through empty and half-typed states on the way to a valid one; a constructor that threw
/// would make the editor unusable. The sync layer validates before sealing, and the codec
/// validates on decode, which are the two points where an invalid host would become durable.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? error)
{
if (string.IsNullOrWhiteSpace(Label))
{
error = "A host needs a name.";
return false;
}
if (string.IsNullOrWhiteSpace(Hostname))
{
error = "A host needs a hostname or address.";
return false;
}
if (Port is < 1 or > 65535)
{
error = $"Port must be between 1 and 65535, not {Port}.";
return false;
}
if (JumpHostIds.AsSpan().Contains(Guid.Empty))
{
error = "A jump chain cannot contain an empty host id.";
return false;
}
error = null;
return true;
}
}