using System.Buffers.Binary; using System.Security.Cryptography; using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Contracts; namespace DodoSSH.Client.Sync; /// /// Derives the id a resurrected item takes. /// /// /// Deterministic, from the original id and the version of the tombstone that displaced it. That matters /// because applying a pulled change is at-least-once: the cursor is saved after the changes are applied, /// so a process that dies in between re-applies them on the next start. A random id would resurrect the /// same item twice and leave the user with duplicates to sort out; this way the second attempt produces /// the same id and coalesces into the same outbox row. /// /// Not a UUIDv7, and that is fine — the server treats item ids as opaque, and the time ordering a v7 id /// carries is meaningless for a copy created to rescue content from a deletion. /// /// internal static class ResurrectionId { internal static Guid For(Guid entityId, int tombstoneVersion) { Span input = stackalloc byte[19 + 16 + sizeof(int)]; "dsh1/resurrect/v1"u8.CopyTo(input); var offset = 17; input[offset++] = 0; input[offset++] = 0; if (!entityId.TryWriteBytes(input[offset..], bigEndian: true, out _)) { throw new InvalidOperationException("Failed to write the entity id."); } offset += 16; BinaryPrimitives.WriteInt32BigEndian(input[offset..], tombstoneVersion); Span digest = stackalloc byte[32]; SHA256.HashData(input, digest); return new Guid(digest[..16], bigEndian: true); } } /// /// Reconciles one item type, with the secret type erased so the engine can hold a table of them. /// /// /// The engine never needs the concrete type — it dispatches on the entity type a change carries and lets /// the reconciler do the rest — so this interface is what it stores. The two members are the two places /// the push and pull paths need type-specific crypto. /// internal interface IItemReconciler { /// Reconciles a remote change against the operation pending for the same item. Task ReconcileAsync( Guid vaultId, SyncChange remote, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken); /// Re-seals a queued change as a create, for a server that says it has no such row. /// Null on success, or the reason the change could not be re-offered. Task ReofferAsCreateAsync( Guid vaultId, PendingOperation pending, CancellationToken cancellationToken); } /// /// Decides what happens when a remote change collides with an unpushed local one. /// /// /// /// Shared by the pull and the push paths, because both meet the same six situations and must answer them /// identically — a pull that merged one way and a push that merged the other would make the outcome /// depend on which side happened to notice first. Shared across item types for the same reason: a host /// and an SSH key meet those six situations in exactly the same way, and the only differences — /// encoding, merge, plaintext columns, what to call the thing — arrive through /// . /// /// /// The governing rule is that nothing is discarded silently. Where the two sides can be /// reconciled field by field, they are. Where they cannot, one value survives, the other is written to /// the conflict log verbatim, and the user is told. Where a deletion meets an edit, the edit survives: /// re-deleting costs a click, while a discarded edit may be the only copy of something the user cannot /// reconstruct. /// /// /// /// Takes no , which is worth noticing rather than reading as an omission: nothing /// here writes the mirror. Reconciling only ever revises the outbox and records conflicts, and the /// server's own version of an item is written by before this is called. /// internal sealed class ItemReconciler( IItemKind kind, OutboxStore outbox, ConflictStore conflicts, VaultKeyring keyring) : IItemReconciler where TSecret : class, IVaultSecret { /// Reconciles a remote change against the operation pending for the same item. public Task ReconcileAsync( Guid vaultId, SyncChange remote, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(remote); ArgumentNullException.ThrowIfNull(pending); if (remote.Operation == SyncOperation.Delete) { return pending.Operation == SyncOperation.Delete // Both sides deleted it. Nothing to arbitrate and nothing to tell the user. ? outbox.CompleteAsync(pending.Sequence, cancellationToken) : ResurrectAsync(vaultId, remote, pending, report, cancellationToken); } return pending.Operation == SyncOperation.Delete ? AbandonLocalDeleteAsync(vaultId, remote, pending, report, cancellationToken) : MergeAsync(vaultId, remote, pending, report, cancellationToken); } /// /// Re-seals a queued change as a create, for a server that says it has no such row. /// /// /// The payload has to be re-sealed rather than re-sent: it was sealed at the version this client /// predicted, and a create produces version 1, which the AAD binds. /// /// Null on success, or the reason the change could not be re-offered. public async Task ReofferAsCreateAsync( Guid vaultId, PendingOperation pending, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(pending); if (!keyring.TryGet(vaultId, out var vaultKey, out var generation) || pending.Payload is null) { return "This item has no usable vault key."; } var local = kind.TryOpen( pending.Payload, vaultKey.Span, pending.EntityId, SyncVersions.NextVersion(pending.ExpectedVersion)); if (local is null) { return "The queued change could not be decrypted, so it could not be re-offered."; } await outbox.ReviseAsync( pending.Sequence, SyncOperation.Upsert, expectedVersion: null, kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1), kind.Fields(local.Secret), ancestor: null, cancellationToken).ConfigureAwait(false); return null; } /// /// Reconciles a pending create that the server says already exists. /// /// /// In practice this means an earlier push of the same create did land and its acknowledgement was /// lost — a timeout, a dropped connection — after which the local row may also have been edited. The /// resolution adopts the server's row as the base and re-offers the local content as an update, so /// the newer local state wins and no duplicate item appears. A genuine id collision between two /// clients is the other reading, and is not achievable with UUIDv7; if it happened, the server's /// values would be in the conflict log rather than gone. /// internal async Task AdoptRemoteAsBaseAsync( Guid vaultId, SyncChange remote, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken) .ConfigureAwait(false); if (opened is null) { return; } var (local, remoteSecret, vaultKey, generation) = opened.Value; // Through the comparer, not ==. Both secrets are records with value equality, but TSecret is a // type parameter, so == would bind to reference equality at compile time and never be true — // turning "our own create coming back" into a conflict record on every single pass. if (EqualityComparer.Default.Equals(local, remoteSecret)) { // Field for field the same item: this is our own create coming back. Nothing to do but stop // trying to send it again. await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); return; } await ReviseAsUpdateAsync( vaultId, remote, pending, local, vaultKey, generation, cancellationToken) .ConfigureAwait(false); await conflicts.RecordAsync( vaultId, kind.EntityType, remote.EntityId, ConflictKind.FieldOverridden, ConflictDetails.Encode( $"An item with this id already existed on the server at version {remote.Version}. " + "The version from this machine was kept; the server's values are recorded here."), cancellationToken).ConfigureAwait(false); report.Merged++; } /// Merges two divergent edits of the same item. private async Task MergeAsync( Guid vaultId, SyncChange remote, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { if (pending.Ancestor is null) { await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken) .ConfigureAwait(false); return; } var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken) .ConfigureAwait(false); if (opened is null) { return; } var (local, remoteSecret, vaultKey, generation) = opened.Value; var ancestor = kind.TryOpen( pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version); if (ancestor is null) { // The base is unreadable, so a three-way merge is not possible. Falling back to a two-way // one would have to guess which side changed what, so the honest move is to keep the local // state as an update over the server's and record what was overridden. await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken) .ConfigureAwait(false); return; } var merged = kind.Merge(ancestor.Secret, local, remoteSecret); await ReviseAsUpdateAsync( vaultId, remote, pending, merged.Merged, vaultKey, generation, cancellationToken) .ConfigureAwait(false); if (merged.Conflicts.Count > 0) { await conflicts.RecordAsync( vaultId, kind.EntityType, remote.EntityId, ConflictKind.FieldOverridden, ConflictDetails.Encode( $"'{merged.Merged.Label}' was edited in two places at once. " + $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.", merged.Conflicts), cancellationToken).ConfigureAwait(false); } report.Merged++; } /// /// Keeps local content that a remote deletion would otherwise take with it. /// /// /// The tombstone is accepted — arguing with it would conflict for ever, since a delete beats a late /// upsert on the server — and the local content is re-offered under a fresh id, labelled so the user /// can see what happened. That is the whole of "never silently drop an item": the original goes, the /// work does not. /// private async Task ResurrectAsync( Guid vaultId, SyncChange remote, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)) { await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) .ConfigureAwait(false); return; } var local = pending.Payload is null ? null : kind.TryOpen( pending.Payload, vaultKey.Span, remote.EntityId, SyncVersions.NextVersion(pending.ExpectedVersion)); if (local is null || local.IsReadOnly) { await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) .ConfigureAwait(false); return; } var restoredId = ResurrectionId.For(remote.EntityId, remote.Version); var restored = kind.Relabel(local.Secret, $"{local.Secret.Label} (restored)"); // Queued before the original is cleared, and that order matters. These are two separate // transactions, so a process that dies between them has to fail in the direction that keeps the // work: this way the original stays pending and the next pass resurrects again — landing on the // same deterministic id, which coalesces into the row already queued. The other order would // leave the tombstone accepted and the local content gone. await outbox.QueueAsync( new QueuedChange( vaultId, kind.EntityType, restoredId, SyncOperation.Upsert, ExpectedVersion: null, kind.Seal(restored, vaultKey.Span, restoredId, generation, itemVersion: 1), kind.Fields(restored), Ancestor: null), cancellationToken).ConfigureAwait(false); // Now the tombstone can stand. await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); await conflicts.RecordAsync( vaultId, kind.EntityType, remote.EntityId, ConflictKind.RemoteDeleteResurrected, ConflictDetails.Encode( $"'{local.Secret.Label}' was deleted elsewhere while this machine had unsaved changes. " + $"The deletion stands and the local version was kept as '{restored.Label}'."), cancellationToken).ConfigureAwait(false); report.Resurrected++; } /// Drops a local deletion because the other side edited the item instead. private async Task AbandonLocalDeleteAsync( Guid vaultId, SyncChange remote, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); await conflicts.RecordAsync( vaultId, kind.EntityType, remote.EntityId, ConflictKind.LocalDeleteOverridden, ConflictDetails.Encode( $"This {kind.Noun} was edited elsewhere after it was deleted here, so the deletion was " + "not applied. Delete it again if that is still what you want."), cancellationToken).ConfigureAwait(false); report.DeletesAbandoned++; } /// Re-offers an item as an update against the server's current version. private async Task ReviseAsUpdateAsync( Guid vaultId, SyncChange remote, PendingOperation pending, TSecret secret, ReadOnlyMemory vaultKey, uint generation, CancellationToken cancellationToken) { var nextVersion = SyncVersions.NextVersion(remote.Version); await outbox.ReviseAsync( pending.Sequence, SyncOperation.Upsert, expectedVersion: remote.Version, kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion), kind.Fields(secret), new StoredAncestor(remote.Version, remote.Payload!, remote.PlaintextFields), cancellationToken).ConfigureAwait(false); } /// Opens both sides of a collision, parking the operation if either will not open. private async Task<(TSecret Local, TSecret Remote, ReadOnlyMemory VaultKey, uint Generation)?> OpenPairAsync( Guid vaultId, SyncChange remote, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { if (!keyring.TryGet(vaultId, out var vaultKey, out var generation) || pending.Payload is null || remote.Payload is null) { await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) .ConfigureAwait(false); return null; } var local = kind.TryOpen( pending.Payload, vaultKey.Span, remote.EntityId, SyncVersions.NextVersion(pending.ExpectedVersion)); var remoteSecret = kind.TryOpen( remote.Payload, vaultKey.Span, remote.EntityId, remote.Version); if (local is null || remoteSecret is null) { await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken) .ConfigureAwait(false); return null; } if (local.IsReadOnly || remoteSecret.IsReadOnly) { await ParkAsTooNewAsync(vaultId, remote.EntityId, pending, report, cancellationToken) .ConfigureAwait(false); return null; } return (local.Secret, remoteSecret.Secret, vaultKey, generation); } /// /// Leaves an item alone because a newer client wrote it. /// /// /// Re-encoding would drop fields this build cannot represent, so the item waits until this client is /// updated. Parked rather than merged-and-hoped: the dropped field could be the one that matters. /// private async Task ParkAsTooNewAsync( Guid vaultId, Guid entityId, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { await outbox.ParkAsync( pending.Sequence, $"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.", cancellationToken).ConfigureAwait(false); await conflicts.RecordAsync( vaultId, kind.EntityType, entityId, ConflictKind.TooNewToEdit, ConflictDetails.Encode( $"This {kind.Noun} was written by a newer version of DodoSSH. It can be read but not " + "merged here, because saving it would discard fields this version does not know " + "about."), cancellationToken).ConfigureAwait(false); report.Parked++; } private async Task ParkAsync( Guid vaultId, Guid entityId, PendingOperation pending, SyncReportBuilder report, CancellationToken cancellationToken) { await outbox.ParkAsync( pending.Sequence, $"The local or the server copy of this {kind.Noun} could not be decrypted.", cancellationToken).ConfigureAwait(false); await conflicts.RecordAsync( vaultId, kind.EntityType, entityId, ConflictKind.Undecryptable, ConflictDetails.Encode( $"This {kind.Noun} could not be decrypted, so the change made here could not be merged. " + "The vault key may have been rotated, or the stored payload may not belong to this " + "item."), cancellationToken).ConfigureAwait(false); report.Unreadable++; report.Parked++; } } /// Writes the server's version of an item into the local mirror. /// /// Type-agnostic on purpose, and separate from the reconcilers for that reason: mirroring copies /// ciphertext into a row and never decrypts, so there is nothing here for an item kind to decide. Making /// it a method on a reconciler would have meant picking one arbitrarily, or having the engine look one up /// for a change it can mirror without knowing anything about. /// internal static class ItemMirror { internal static Task WriteAsync( ItemStore items, Guid vaultId, SyncChange change, CancellationToken cancellationToken) => items.SaveAsync( new StoredItem( vaultId, change.EntityType, change.EntityId, change.Version, change.ChangeSequence, change.Payload, change.PlaintextFields, change.Operation == SyncOperation.Delete, change.UpdatedAt), cancellationToken); }