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);
}
}
}