using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Contracts; namespace DodoSSH.Client.Sync; /// One vault item as the interface should show it. /// The item id. /// The decrypted item. /// /// The server version this is based on. Zero for an item that has never been accepted. /// /// /// Whether this reflects a local edit the server has not accepted yet. Worth showing: it is the /// difference between "saved" and "saved here". /// /// /// Whether the pending change was refused and is waiting on a person, so it will not retry on its own. /// /// /// Whether this item was written by a newer client and so must not be edited here, because re-encoding /// it would drop fields this build cannot represent. /// public sealed record VaultItem( Guid EntityId, TSecret Secret, int Version, bool HasUnsyncedChanges, bool IsBlocked, bool IsReadOnly) where TSecret : class, IVaultSecret; /// The items of one kind in a vault, and what could not be read. /// The readable items. /// /// How many items would not decrypt. Surfaced rather than swallowed: a non-zero count here after a /// rekey is the signal that new grants are needed. /// public sealed record ItemListing(IReadOnlyList> Items, int Unreadable) where TSecret : class, IVaultSecret; /// /// Reading and writing one kind of vault item, as the interface sees them. /// /// /// /// The view is the mirror of the server's state with the outbox laid over it, which is what makes the /// application feel local: an edit appears immediately and a delete disappears immediately, whether or /// not the network is there. Nothing here talks to the server; the sync engine reconciles later. /// /// /// Writes never touch the mirror. That separation is load-bearing — the mirror is the common ancestor a /// three-way merge needs, and a repository that updated it on save would destroy the very state that /// lets a conflict be merged instead of arbitrated. /// /// /// Every read and write is scoped to , which is also what /// keeps two kinds apart in storage: the item table is keyed on the type as well as the id, so a host and /// a key could share an id and never see each other's rows. /// /// internal sealed class VaultItemRepository( IItemKind kind, ItemStore items, OutboxStore outbox, VaultKeyring keyring, IActivityLogSink? activity = null) where TSecret : class, IVaultSecret { /// /// Whether writes through this repository are worth recording. /// /// /// Asked once rather than at each call site, and false for the log kinds themselves — which is the guard /// that stops the activity log producing an entry for every entry it writes, without end. See /// . /// private bool IsAudited => activity is not null && kind.IsAudited; /// Reads every item of this kind the user should see in a vault. internal async Task> ListAsync( Guid vaultId, CancellationToken cancellationToken) { if (!keyring.TryGet(vaultId, out var vaultKey, out _)) { throw new VaultUnreadableException(vaultId); } var mirrored = await items .ListAsync(vaultId, kind.EntityType, includeDeleted: true, cancellationToken) .ConfigureAwait(false); var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false); var pendingByEntity = pending .Where(operation => operation.EntityType == kind.EntityType) .ToDictionary(operation => operation.EntityId); var listed = new List>(); var unreadable = 0; foreach (var item in mirrored) { if (pendingByEntity.Remove(item.EntityId, out var local)) { AddPending(listed, ref unreadable, vaultKey, local); continue; } if (item.IsDeleted || item.Payload is null) { continue; } var opened = kind.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version); if (opened is null) { unreadable++; continue; } listed.Add(new VaultItem( item.EntityId, opened.Secret, item.Version, false, false, opened.IsReadOnly)); } // Whatever is left has no mirror row yet: items created here and not yet accepted. foreach (var local in pendingByEntity.Values) { AddPending(listed, ref unreadable, vaultKey, local); } return new ItemListing(listed, unreadable); } /// /// Adds an item, returning the id it was given. /// /// /// The id is generated here, not by the server, which is what lets an item be created with no network /// at all — the point of the whole outbox. UUIDv7 so that ids sort by creation time, which keeps /// index locality reasonable on the server side. /// internal async Task CreateAsync( Guid vaultId, TSecret secret, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(secret); Validate(secret); var (vaultKey, generation) = Key(vaultId); var entityId = Guid.CreateVersion7(); await outbox.QueueAsync( new QueuedChange( vaultId, kind.EntityType, entityId, SyncOperation.Upsert, ExpectedVersion: null, kind.Seal(secret, vaultKey.Span, entityId, generation, itemVersion: 1), kind.Fields(secret), Ancestor: null), cancellationToken).ConfigureAwait(false); // After the queue, deliberately. A crash between the two loses one advisory line; the reverse order // records an item that was never created. if (IsAudited) { activity!.Record( vaultId, kind.EntityType, entityId, secret.Label, ActivityOperation.Created, []); } return entityId; } /// /// Replaces an item's contents. /// /// /// The base is taken from the pending operation when there is one, and from the mirror otherwise. /// Reading it the other way round would seal the payload at a version that does not match the /// expectedVersion the coalesced row keeps — and because the AAD binds the item version, the /// result would encrypt cleanly and never decrypt again. /// internal async Task UpdateAsync( Guid vaultId, Guid entityId, TSecret secret, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(secret); Validate(secret); var (vaultKey, generation) = Key(vaultId); var pending = await outbox .FindAsync(vaultId, kind.EntityType, entityId, cancellationToken) .ConfigureAwait(false); var expectedVersion = pending is not null ? pending.ExpectedVersion : await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); var ancestor = pending?.Ancestor ?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); // Read before the queue overwrites it, and compared after. The version this decrypts at is the one // the payload was sealed at, which is why the pending and mirror cases differ: a pending payload // holds the version the server will assign, and a mirror row holds the one it has. var before = IsAudited ? Open(vaultKey, entityId, pending, ancestor) : null; await outbox.QueueAsync( new QueuedChange( vaultId, kind.EntityType, entityId, SyncOperation.Upsert, expectedVersion, kind.Seal( secret, vaultKey.Span, entityId, generation, SyncVersions.NextVersion(expectedVersion)), kind.Fields(secret), ancestor), cancellationToken).ConfigureAwait(false); if (IsAudited) { // An empty list when the previous version could not be read, which is why nothing may take empty // to mean "nothing changed" — it also means "we could not tell". activity!.Record( vaultId, kind.EntityType, entityId, secret.Label, ActivityOperation.Updated, before is null ? [] : kind.Changes(before, secret)); } } /// /// Deletes an item. /// /// /// /// Queued as a tombstone, never a local removal. An offline client that simply forgot the row would /// be unable to tell the server anything, and the item would come back on the next pull. /// /// /// Unless the server has never heard of the item, which is the one case where a tombstone is not only /// unnecessary but wrong — see . /// /// internal async Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) { var pending = await outbox .FindAsync(vaultId, kind.EntityType, entityId, cancellationToken) .ConfigureAwait(false); // Read before either branch, because both of them destroy it — and a delete's line is the one that // most needs a name, since the item it refers to is about to stop existing. var label = IsAudited ? await LabelAsync(vaultId, entityId, pending, cancellationToken).ConfigureAwait(false) : null; if (pending is not null && NeverReachedTheServer(pending)) { await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); // Recorded even though nothing goes to the server. Somebody created an item and then removed it, // which is two things they did — and a log that showed only the create would describe a keychain // that does not exist. Audit(vaultId, entityId, label); return; } var expectedVersion = pending is not null ? pending.ExpectedVersion : await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); var ancestor = pending?.Ancestor ?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false); await outbox.QueueAsync( new QueuedChange( vaultId, kind.EntityType, entityId, SyncOperation.Delete, expectedVersion, Payload: null, Fields: null, ancestor), cancellationToken).ConfigureAwait(false); Audit(vaultId, entityId, label); } /// Records a delete, if this kind is audited at all. private void Audit(Guid vaultId, Guid entityId, string? label) { if (IsAudited) { // The label may be null when the item could not be decrypted, which is a state worth recording // rather than skipping: an item nobody can read is still one somebody deleted. activity!.Record( vaultId, kind.EntityType, entityId, label ?? "(an item that could not be read)", ActivityOperation.Deleted, []); } } /// What an item is currently called, for a log line written as it goes away. private async Task LabelAsync( Guid vaultId, Guid entityId, PendingOperation? pending, CancellationToken cancellationToken) { if (!keyring.TryGet(vaultId, out var vaultKey, out _)) { return null; } var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken) .ConfigureAwait(false); return Open(vaultKey, entityId, pending, ancestor)?.Label; } /// /// Decrypts whichever version of an item this machine currently shows. /// /// /// The pending payload first, because that is what the user is looking at — an item edited offline twice /// should report the second edit against the first, not against what the server last accepted. The /// version each is opened at differs for the reason the sealing side differs: a queued payload is sealed /// at the version the server will assign, and a mirror row holds the one it has. /// private TSecret? Open( ReadOnlyMemory vaultKey, Guid entityId, PendingOperation? pending, StoredAncestor? ancestor) { if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued }) { return kind.TryOpen( queued, vaultKey.Span, entityId, SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret; } return ancestor is null ? null : kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret; } /// /// Whether a queued change describes an item the server cannot be holding. /// /// /// /// A null ExpectedVersion means the row is a create — including a create that has since been /// edited, because coalescing keeps the original expected version. So there is no server row and no /// mirror row, and dropping the queued change makes the item genuinely gone. Queueing a tombstone /// instead asks the server to delete something it has never seen, which it answers Invalid; the /// change is parked, and the user is left with a rejected item they already deleted and a pending count /// that never reaches zero. Add a host on a laptop with no network, change your mind, and that is the /// state — it applies to all four item types. /// /// /// The attempt count is what makes this safe rather than merely convenient. Nothing sent cannot have /// landed. A parked row cannot have landed either — parking is what the pusher does when the server has /// refused, so the refusal is the evidence. What is left is a create that went out and whose answer was /// never seen: in flight, or failed in a way that might yet have been applied. That one still gets a /// tombstone, because the server may be holding the item and a local drop would strand it there for /// ever. A refused tombstone is recoverable; an orphan on the server is not. /// /// private static bool NeverReachedTheServer(PendingOperation pending) => pending is { Operation: SyncOperation.Upsert, ExpectedVersion: null } && (pending.Attempts == 0 || pending.IsParked); private static void Validate(TSecret secret) { if (!secret.TryValidate(out var error)) { throw new ArgumentException(error, nameof(secret)); } } private void AddPending( List> listed, ref int unreadable, ReadOnlyMemory vaultKey, PendingOperation local) { if (local.Operation == SyncOperation.Delete) { // Gone as far as this machine is concerned, even before the server agrees. return; } if (local.Payload is null) { unreadable++; return; } var version = SyncVersions.NextVersion(local.ExpectedVersion); var opened = kind.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version); if (opened is null) { unreadable++; return; } listed.Add(new VaultItem( local.EntityId, opened.Secret, local.ExpectedVersion ?? 0, HasUnsyncedChanges: true, local.IsParked, opened.IsReadOnly)); } private (ReadOnlyMemory VaultKey, uint Generation) Key(Guid vaultId) => keyring.TryGet(vaultId, out var vaultKey, out var generation) ? (vaultKey, generation) : throw new VaultUnreadableException(vaultId); private async Task MirrorVersionAsync( Guid vaultId, Guid entityId, CancellationToken cancellationToken) { var item = await items .FindAsync(vaultId, kind.EntityType, entityId, cancellationToken) .ConfigureAwait(false); // A null means the server has never seen this item, which is exactly what "create" is. return item?.Version; } private async Task MirrorAncestorAsync( Guid vaultId, Guid entityId, CancellationToken cancellationToken) { var item = await items .FindAsync(vaultId, kind.EntityType, entityId, cancellationToken) .ConfigureAwait(false); return item?.Payload is null ? null : new StoredAncestor(item.Version, item.Payload, item.Fields); } }