Files
DodoSSH/src/DodoSSH.Contracts/Sync.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

157 lines
7.3 KiB
C#

namespace DodoSSH.Contracts;
/// <summary>
/// Plaintext columns that the server needs in order to function, supplied alongside a payload.
/// </summary>
/// <remarks>
/// <para>
/// Kept deliberately minimal. There is no plaintext label or name: ACL administration happens
/// in the client, which can decrypt, so the server never needs a searchable title.
/// </para>
/// <para>
/// <see cref="Hostname"/> and <see cref="Port"/> may only be set when <see cref="RelayEnabled"/>
/// is true, and the database enforces that with a CHECK constraint. The relay must resolve its
/// target server-side or it becomes an authenticated open TCP proxy into the operator's own
/// network; see ADR 0004. Everything else about a host — username, notes, jump chain, options —
/// stays inside the encrypted payload.
/// </para>
/// </remarks>
/// <param name="RelayEnabled">Whether this host may be dialled through the server relay.</param>
/// <param name="Hostname">Target hostname. Permitted only when relay is enabled.</param>
/// <param name="Port">Target port. Permitted only when relay is enabled.</param>
/// <param name="GroupId">Owning group, for tree placement.</param>
/// <param name="ParentId">Parent entity, for associations and nested groups.</param>
/// <param name="RelatedId">The second side of an association row.</param>
/// <param name="Kind">Discriminator for entity types that have one, such as credential kind.</param>
/// <param name="PublicKeyFingerprint">
/// Fingerprint of a public key. Deliberately plaintext: public keys are not secret, and this
/// enables "which hosts trust this key" without weakening the threat model.
/// </param>
public sealed record SyncPlaintextFields(
bool RelayEnabled = false,
string? Hostname = null,
int? Port = null,
Guid? GroupId = null,
Guid? ParentId = null,
Guid? RelatedId = null,
int? Kind = null,
string? PublicKeyFingerprint = null);
/// <summary>
/// One change a client wants to apply.
/// </summary>
/// <param name="OperationId">
/// Client-generated idempotency key for this single operation. A retried push with the same id
/// returns <see cref="SyncOperationStatus.Duplicate"/> rather than applying twice.
/// </param>
/// <param name="EntityType">Kind of item.</param>
/// <param name="EntityId">
/// Identifier, generated by the client with UUIDv7 so items can be created offline.
/// </param>
/// <param name="Operation">Upsert or delete.</param>
/// <param name="ExpectedVersion">
/// The version the client believes the server holds; <see langword="null"/> means create. A
/// mismatch yields <see cref="SyncOperationStatus.Conflict"/> — never last-writer-wins.
/// </param>
/// <param name="Payload">Ciphertext. Required for an upsert, omitted for a delete.</param>
/// <param name="PlaintextFields">Plaintext columns the server needs.</param>
public sealed record SyncPushOperation(
Guid OperationId,
SyncEntityType EntityType,
Guid EntityId,
SyncOperation Operation,
int? ExpectedVersion,
EncryptedPayload? Payload,
SyncPlaintextFields? PlaintextFields);
/// <summary>A batch of changes to apply to one vault.</summary>
/// <param name="Operations">
/// The batch. Capped by the server; see <see cref="MetaResponse.MaxOperationsPerPush"/>.
/// </param>
public sealed record SyncPushRequest(IReadOnlyList<SyncPushOperation> Operations);
/// <summary>Outcome of one operation in a push.</summary>
/// <param name="OperationId">Echoes the request's operation id.</param>
/// <param name="Status">What happened.</param>
/// <param name="Version">The stored version after a successful apply.</param>
/// <param name="ChangeSequence">The change-log sequence assigned, for cursor comparison.</param>
/// <param name="ServerEntity">
/// The server's current state, present only on <see cref="SyncOperationStatus.Conflict"/> so
/// the client can perform a three-way merge and re-push.
/// </param>
/// <param name="Detail">Human-readable explanation for an invalid operation. Never a secret.</param>
public sealed record SyncPushResult(
Guid OperationId,
SyncOperationStatus Status,
int? Version,
long? ChangeSequence,
SyncChange? ServerEntity,
string? Detail);
/// <summary>Per-operation outcomes for a push.</summary>
/// <param name="Results">One entry per submitted operation, in request order.</param>
/// <param name="Cursor">
/// A cursor positioned after every change this push produced.
/// <para>
/// <b>Adopting this is only safe if the client had already pulled to the log head.</b> The cursor is
/// a sequence position, so if another client committed at sequence 10 while this push took 11,
/// jumping to 11 skips 10 permanently. The per-vault advisory lock guarantees that sequence order
/// matches commit order; it cannot tell this client about a write it never read. A client that
/// keeps its own cursor and re-reads its own writes — which is idempotent, since applying a change
/// is a blind overwrite of a local mirror — is strictly safer, and that is what
/// <c>DodoSSH.Client.Sync</c> does.
/// </para>
/// </param>
public sealed record SyncPushResponse(
IReadOnlyList<SyncPushResult> Results,
string Cursor);
/// <summary>A request for changes since a cursor.</summary>
/// <param name="Cursor">
/// An opaque, integrity-tagged cursor from a previous response, or <see langword="null"/> to
/// start from the beginning. Clients must never construct or modify one.
/// </param>
/// <param name="Limit">Maximum changes to return. The server clamps this.</param>
/// <param name="EntityTypes">Optional filter. Empty or null means all types.</param>
public sealed record SyncPullRequest(
string? Cursor,
int? Limit,
IReadOnlyList<SyncEntityType>? EntityTypes);
/// <summary>One change as returned by a pull.</summary>
/// <param name="EntityType">Kind of item.</param>
/// <param name="EntityId">Identifier.</param>
/// <param name="Operation">Upsert or delete. A delete carries no payload.</param>
/// <param name="Version">Version after the change.</param>
/// <param name="ChangeSequence">Position in the vault's change log.</param>
/// <param name="Payload">Ciphertext, absent for a delete.</param>
/// <param name="PlaintextFields">Plaintext columns, absent for a delete.</param>
/// <param name="UpdatedAt">When the change was recorded.</param>
public sealed record SyncChange(
SyncEntityType EntityType,
Guid EntityId,
SyncOperation Operation,
int Version,
long ChangeSequence,
EncryptedPayload? Payload,
SyncPlaintextFields? PlaintextFields,
DateTimeOffset UpdatedAt);
/// <summary>A page of changes.</summary>
/// <param name="Changes">Changes in ascending change-sequence order.</param>
/// <param name="NextCursor">Cursor to pass to the following pull.</param>
/// <param name="HasMore">Whether more changes are immediately available.</param>
/// <param name="ServerTime">
/// The server's clock, so a client can detect its own skew rather than mis-ordering local edits.
/// </param>
/// <param name="CurrentKeyGeneration">
/// The vault's current key generation. A client seeing a generation ahead of its own knows a
/// rekey happened and that it must fetch new grants.
/// </param>
public sealed record SyncPullResponse(
IReadOnlyList<SyncChange> Changes,
string NextCursor,
bool HasMore,
DateTimeOffset ServerTime,
uint CurrentKeyGeneration);