using System.Security.Cryptography; using DodoSSH.Client.Api; using DodoSSH.Client.Storage; using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Sync; /// What one re-sealing pass did. /// The vault. /// The generation everything was moved to. /// Items now sealed under the current key. /// /// Items left alone because a local edit is queued for them. Not a failure: a queued change is re-sealed /// as it is pushed, so these arrive at the current generation by another route. /// /// /// Items whose own generation this session holds no key for. They stay where they are — the alternative /// is discarding an item nobody can read yet, which is the one outcome that cannot be undone. /// /// /// Items somebody else wrote while this pass was running. The server refused the version, and the next /// pass picks them up against the version it left behind. /// public sealed record ResealReport( Guid VaultId, uint KeyGeneration, int Resealed, int Deferred, int Unreadable, int Contested) { /// Whether every item in the vault is now sealed under its current key. /// /// Deferred items count as finished. They are queued changes, and a queued change cannot reach the /// server under a superseded key — re-seals it on the way out. /// public bool Complete => Unreadable == 0 && Contested == 0; /// Whether anything moved. public bool MovedAnything => Resealed > 0; } /// /// Moves a rotated vault's stored items onto its current key. /// /// /// /// What a rotation on its own does not do. Advancing a vault's generation re-keys the vault and /// not its contents: every item stays sealed under the generation it was written with, readable by /// anybody holding that generation's grant. That is what keeps a rotation cheap and safe — see /// ADR 0010 — and it leaves one gap, which is this pass. Somebody who left with a copy of the old key /// could still open old ciphertext they later got hold of. Once this has run, they cannot: every item /// is sealed under a key issued after they went. /// /// /// The plaintext is never decoded. An item is opened, and the same bytes are sealed again under /// a fresh data key — no codec, no merge, no schema version. So an item written by a newer client /// survives this untouched, where re-encoding it through this build's codec would silently drop the /// fields this build has no concept of. It also means nothing here needs to know what an item *is*, /// which is why one pass covers every type including the ones added later. /// /// /// Resumable by construction, because a vault at mixed generations is readable. Each item is one /// ordinary upsert against the version the server holds, so a pass that dies half way leaves a working /// vault, and running it again picks up what is left. Nothing here is a transaction and nothing needs /// to be. /// /// public sealed class VaultResealer { private readonly ISyncApi api; private readonly ItemStore items; private readonly OutboxStore outbox; private readonly VaultKeyring keyring; private readonly TimeProvider clock; private readonly SyncOptions options; /// Creates the pass. public VaultResealer( ISyncApi api, ItemStore items, OutboxStore outbox, VaultKeyring keyring, TimeProvider clock, SyncOptions? options = null) { ArgumentNullException.ThrowIfNull(api); ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(outbox); ArgumentNullException.ThrowIfNull(keyring); ArgumentNullException.ThrowIfNull(clock); this.api = api; this.items = items; this.outbox = outbox; this.keyring = keyring; this.clock = clock; this.options = options ?? SyncOptions.Default; } /// Re-seals everything in one vault that is not already on its current key. /// The vault. /// Cancellation token. /// /// Answers with a report of zero for a vault this session cannot write to, rather than throwing. /// Being rotated past and not yet re-wrapped is the ordinary state for a member between somebody /// else's rotation and their own re-grant, and it is not this pass's business to complain about it. /// public async Task ResealAsync(Guid vaultId, CancellationToken cancellationToken) { if (!keyring.TryGet(vaultId, out _, out var generation)) { return new ResealReport(vaultId, KeyGeneration: 0, 0, 0, 0, 0); } var queued = await QueuedAsync(vaultId, cancellationToken).ConfigureAwait(false); var tally = new Tally(); var batch = new List(options.MaxOperationsPerPush); foreach (var entityType in ItemKinds.SyncedTypes) { var stored = await items .ListAsync(vaultId, entityType, includeDeleted: false, cancellationToken) .ConfigureAwait(false); foreach (var item in stored.Where(item => Behind(item, generation))) { if (queued.Contains((entityType, item.EntityId))) { tally.Deferred++; continue; } if (Move(vaultId, item) is not { } moved) { tally.Unreadable++; continue; } batch.Add(moved); if (batch.Count == options.MaxOperationsPerPush) { await SendAsync(vaultId, batch, tally, cancellationToken).ConfigureAwait(false); } } } await SendAsync(vaultId, batch, tally, cancellationToken).ConfigureAwait(false); return new ResealReport( vaultId, generation, tally.Resealed, tally.Deferred, tally.Unreadable, tally.Contested); } /// Whether an item is still sealed under a key the vault has moved past. private static bool Behind(StoredItem item, uint generation) => item.Payload is { } payload && payload.KeyGeneration < generation; /// Re-seals one item, or answers null when this session cannot open it. private Pending? Move(Guid vaultId, StoredItem item) { var version = SyncVersions.NextVersion(item.Version); var payload = PayloadReseal.TryReseal( keyring, vaultId, item.EntityType, item.EntityId, item.Payload!, item.Version, version); return payload is null ? null : new Pending(item, payload, version); } /// Sends a batch if there is one, and empties it. private async Task SendAsync( Guid vaultId, List batch, Tally tally, CancellationToken cancellationToken) { if (batch.Count == 0) { return; } var outcome = await PushAsync(vaultId, batch, cancellationToken).ConfigureAwait(false); tally.Resealed += outcome.Applied; tally.Contested += outcome.Contested; batch.Clear(); } /// The running counts, so the loop above stays one screen long. private sealed class Tally { internal int Resealed { get; set; } internal int Deferred { get; set; } internal int Unreadable { get; set; } internal int Contested { get; set; } } /// One item, re-sealed and waiting to be sent. private sealed record Pending(StoredItem Item, EncryptedPayload Payload, int Version) { internal Guid OperationId { get; } = Guid.CreateVersion7(); } /// What one batch achieved. [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] private readonly record struct PushOutcome(int Applied, int Contested); /// /// Sends one batch and mirrors what the server accepted. /// /// /// /// The mirror is written here rather than left to the next pull, so that a pass followed immediately /// by another does not re-seal everything a second time. It is the same row with the same plaintext /// under a new key, so there is nothing for a reader to notice. /// /// /// A conflict is counted and skipped. There is nothing to merge — this pass changes no content — and /// re-reading the item at the version the other client left is exactly what the next pass does. /// /// private async Task PushAsync( Guid vaultId, List batch, CancellationToken cancellationToken) { var operations = batch .Select(pending => new SyncPushOperation( pending.OperationId, pending.Item.EntityType, pending.Item.EntityId, SyncOperation.Upsert, pending.Item.Version, pending.Payload, pending.Item.Fields)) .ToList(); var response = await api .SyncPushAsync(vaultId, new SyncPushRequest(operations), cancellationToken) .ConfigureAwait(false); var applied = 0; var contested = 0; foreach (var result in response.Results) { if (batch.Find(pending => pending.OperationId == result.OperationId) is not { } sent) { continue; } if (result.Status is not (SyncOperationStatus.Applied or SyncOperationStatus.Duplicate)) { contested++; continue; } applied++; await items.SaveAsync( sent.Item with { Version = result.Version ?? sent.Version, ChangeSequence = result.ChangeSequence ?? sent.Item.ChangeSequence, Payload = sent.Payload, UpdatedAt = clock.GetUtcNow(), }, cancellationToken) .ConfigureAwait(false); } return new PushOutcome(applied, contested); } /// The items a local edit is already queued for. private async Task> QueuedAsync( Guid vaultId, CancellationToken cancellationToken) { var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false); return [.. pending.Select(operation => (operation.EntityType, operation.EntityId))]; } } /// /// Moving one payload from the key it was sealed under to the one in force now. /// /// /// Used from two places, and both of them matter: the pass above, which walks a rotated vault, and the /// push path, which cannot be allowed to send a change queued before a rotation under the key it was /// queued with. Between them they are the guarantee that nothing reaches the server under a superseded /// generation. /// internal static class PayloadReseal { /// /// Re-seals a payload under the vault's current key. /// /// The open keyring. /// The vault. /// What kind of item this is; the AAD binds its resource type. /// The item. /// The payload as it stands, sealed under an earlier generation. /// The item version is bound to. /// The version the result will be bound to. /// /// The re-sealed payload, or when this session cannot open the original — /// which means one item stays where it is, and says nothing about the rest of the vault. /// /// /// A fresh data key, not the original one re-wrapped. The two cost the same here, because the AAD /// binds the generation into the payload as well as into the key wrap and the envelope has to be /// re-made either way — and a new key per version is the rule the whole item format is built on. /// internal static EncryptedPayload? TryReseal( VaultKeyring keyring, Guid vaultId, SyncEntityType entityType, Guid entityId, EncryptedPayload payload, int openAtVersion, int sealAtVersion) { if (!keyring.TryGetAt(vaultId, payload.KeyGeneration, out var previous) || !keyring.TryGet(vaultId, out var current, out var generation)) { return null; } var resource = AadResourceTypes.For(entityType); var dataKey = ItemKeys.TryUnwrapDataKey( previous.Span, payload.WrappedDataKey, resource, entityId, payload.KeyGeneration, (uint)openAtVersion); if (dataKey is null) { return null; } try { var plaintext = ItemKeys.TryOpenPayload( dataKey, payload.Envelope, resource, entityId, payload.DataKeyId, payload.KeyGeneration, (uint)openAtVersion); if (plaintext is null) { return null; } try { return Seal( plaintext, current.Span, resource, entityId, generation, (uint)sealAtVersion); } finally { // The one place in this file that holds an item's plaintext, and it holds every kind of // item there is — a private key, a password, a snippet with a token pasted into it. CryptographicOperations.ZeroMemory(plaintext); } } finally { CryptographicOperations.ZeroMemory(dataKey); } } private static EncryptedPayload Seal( ReadOnlySpan plaintext, ReadOnlySpan vaultKey, CryptoSpec.AadResourceType resource, Guid entityId, uint keyGeneration, uint itemVersion) { var dataKey = ItemKeys.CreateDataKey(); try { var dataKeyId = Guid.CreateVersion7(); var envelope = ItemKeys.SealPayload( dataKey, plaintext, resource, entityId, dataKeyId, keyGeneration, itemVersion); var wrapped = ItemKeys.WrapDataKey( dataKey, vaultKey, resource, entityId, keyGeneration, itemVersion); return new EncryptedPayload( envelope, wrapped, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion); } finally { CryptographicOperations.ZeroMemory(dataKey); } } }