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) { // TryGet rather than CanRead, which answers false for a disposed keyring where this has to // throw: a locked session being read from is a caller holding something it should have let go // of, and the exception is what says so. if (!keyring.TryGet(vaultId, out _, 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, vaultId, local); continue; } AddMirrored(listed, ref unreadable, vaultId, item); } // 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, vaultId, 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(vaultId, 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)); } } /// /// Moves an item into another vault. /// /// The vault it is in. /// The vault it should be in. /// The item. /// /// What to write into the destination. The caller's, rather than read from here, because moving is the /// one operation where the item does not arrive unchanged: references to things that live in the vault /// it is leaving are the mover's to resolve, and this layer has no way to know which those are. /// /// Cancellation token. /// The id the item has in its new vault. /// /// /// A copy and a tombstone, and it cannot be anything else. An item's payload is sealed under /// its vault's key and its AAD binds the vault, the entity id and the item version — so there is no /// edit that moves one, and no server call that could: the server holds ciphertext it cannot read. /// What crosses is the plaintext, in this process, between an unwrap under one key and a seal under /// another. /// /// /// A new id, deliberately. Keeping it would put one entity id in two vaults, and the item table /// is keyed on the type and the id rather than on the vault — so the destination's row and the /// source's tombstone would be the same row, and the move would delete what it had just written. /// Callers holding the old id have to take the new one back. /// /// /// The write comes first and the tombstone second, which decides what an interruption leaves /// behind: a copy in both vaults, which is visible and can be deleted, rather than a tombstone with /// nothing on the other side, which is the host gone. Both are queued rather than sent, so the window /// is a crash between two local writes — narrow, and worth choosing the survivable side of anyway. /// /// /// Two activity lines, not one: a create in the destination and a delete in the source, which is what /// the vaults actually record. A single "moved" line would have to be written to one of them and would /// be missing from the other's history. /// /// internal async Task MoveAsync( Guid fromVaultId, Guid toVaultId, Guid entityId, TSecret secret, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(secret); if (fromVaultId == toVaultId) { throw new ArgumentException( "That item is already in that vault.", nameof(toVaultId)); } // Both keys before either write, so a destination this session cannot write to is refused with // nothing having happened rather than after the source item has gone. _ = Key(fromVaultId); _ = Key(toVaultId); var moved = await CreateAsync(toVaultId, secret, cancellationToken).ConfigureAwait(false); await DeleteAsync(fromVaultId, entityId, cancellationToken).ConfigureAwait(false); return moved; } /// /// 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) { var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken) .ConfigureAwait(false); return Open(vaultId, 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( Guid vaultId, Guid entityId, PendingOperation? pending, StoredAncestor? ancestor) { if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued }) { return keyring.TryGetAt(vaultId, queued.KeyGeneration, out var queuedKey) ? kind.TryOpen( queued, queuedKey.Span, entityId, SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret : null; } if (ancestor is null || !keyring.TryGetAt(vaultId, ancestor.Payload.KeyGeneration, out var vaultKey)) { return null; } return 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)); } } /// Adds one row of the server's mirror to a listing, or counts it as unreadable. private void AddMirrored( List> listed, ref int unreadable, Guid vaultId, StoredItem item) { if (item.IsDeleted || item.Payload is null) { return; } // The generation the item names, not the vault's current one. A rotated vault holds items // written under two or three keys at once, and a list that assumed the newest would report // everything older as unreadable. if (!keyring.TryGetAt(vaultId, item.Payload.KeyGeneration, out var vaultKey)) { unreadable++; return; } var opened = kind.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version); if (opened is null) { unreadable++; return; } listed.Add(new VaultItem( item.EntityId, opened.Secret, item.Version, false, false, opened.IsReadOnly)); } private void AddPending( List> listed, ref int unreadable, Guid vaultId, 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; } // A queued change is sealed under whatever generation was current when it was queued, which is // not necessarily the current one: a rotation can land between an offline edit and its push. if (!keyring.TryGetAt(vaultId, local.Payload.KeyGeneration, out var vaultKey)) { 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); } }