Take a rotated vault's contents onto the new key as well

Rotating a vault re-keyed the vault and not its contents, which was the deal
struck last time: everything already stored stayed sealed under the generation it
was written with, every remaining member kept the older keys, and the guarantee
was narrowed to "nothing written from now on". That left one gap worth closing —
somebody who walked off with the old key could still open old ciphertext they
later got hold of — and the reason it was safe to defer is the reason it was
cheap to add. A vault at mixed generations reads perfectly well, so the pass that
moves items across can stop half way and be run again.

VaultResealer walks the vault and rewrites each item as an ordinary upsert
against the version the server holds. It never decodes the plaintext: an item is
opened and the same bytes are sealed again under a fresh data key, so an item
written by a newer client crosses a rotation untouched rather than being
re-encoded through this build's codec and quietly losing the fields this build
has no concept of. It also means nothing in the pass knows what an item is, which
is why one loop covers every type including the ones added after it. A conflict
is counted and skipped rather than merged — there is nothing to merge, since no
content changes — and the next pass picks the item up at the version the other
client left.

The half that a pass over stored items cannot see is a change queued before the
rotation and pushed after it, which would put a brand-new item into the vault
under the key the person who just left still holds. So the push path re-seals a
stale payload as it dispatches it, writing the revision back to the outbox first
so that a retry sends the same bytes rather than a fresh envelope. Between the
two, nothing reaches the server under a superseded generation at all. Queued
items are therefore deliberately left alone by the pass: rewriting one there
would overwrite the user's unpushed work with the version the server holds, which
is the one thing a re-keying pass must never do.

Removal runs it last, after a sync — a mirror that is behind produces a batch of
conflicts instead of a re-sealed vault — and the status line distinguishes the two
guarantees, because they are not the same: a vault fully re-sealed is closed to
the person who left, and one with items outstanding is closed only to what
happens next.

Six tests, and three mutations run against them: making the re-seal return the
payload unchanged fails five of the six, making the push path skip re-sealing
fails the queued-edit test and only that one, and counting conflicts as applied
fails the write-elsewhere test. One of the six was wrong before it was right — it
modelled a third-party write by re-pushing an existing payload at a bumped
version, which no real client would do, and it took reading the AAD to see that
the test was lying rather than the code.
This commit is contained in:
2026-08-04 10:18:14 +02:00
parent d5b1a73182
commit 5d447da532
12 changed files with 923 additions and 68 deletions
+66 -1
View File
@@ -350,13 +350,16 @@ public sealed class SyncEngine
foreach (var operation in pending)
{
var payload = await CurrentAsync(vaultId, operation, cancellationToken)
.ConfigureAwait(false);
operations.Add(new SyncPushOperation(
operation.OperationId,
operation.EntityType,
operation.EntityId,
operation.Operation,
operation.ExpectedVersion,
operation.Payload,
payload,
operation.Fields));
byOperationId[operation.OperationId] = operation;
@@ -393,6 +396,68 @@ public sealed class SyncEngine
pending.Count, conflicted, pending.Count == options.MaxOperationsPerPush);
}
/// <summary>
/// The payload to send, re-sealed under the current key if it was queued before a rotation.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing may reach the server under a superseded generation, and this is where that is
/// enforced.</b> A change queued offline is sealed under whatever key was current when it was made,
/// and a rotation can land in between — so sending it as it stands would store a brand-new item
/// under the key the person who was just removed still holds. The vault's own re-sealing pass cannot
/// help: it runs over what the server holds, and this has not been sent yet.
/// </para>
/// <para>
/// The revised payload is written back to the outbox before it goes, so a push whose answer is lost
/// is retried as the same bytes. Re-sealing on each attempt instead would produce a different
/// envelope every time, which is harmless on the wire and would leave the queued row disagreeing
/// with what the server may already have accepted.
/// </para>
/// </remarks>
private async Task<EncryptedPayload?> CurrentAsync(
Guid vaultId,
PendingOperation operation,
CancellationToken cancellationToken)
{
if (operation.Payload is not { } payload
|| !keyring.TryGet(vaultId, out _, out var generation)
|| payload.KeyGeneration >= generation)
{
return operation.Payload;
}
var version = SyncVersions.NextVersion(operation.ExpectedVersion);
var resealed = PayloadReseal.TryReseal(
keyring,
vaultId,
operation.EntityType,
operation.EntityId,
payload,
openAtVersion: version,
sealAtVersion: version);
if (resealed is null)
{
// The generation this change was queued under is one this session no longer holds. It goes
// as it stands: the server takes it either way, and a queued edit that cannot be re-sealed
// is still the user's work.
return payload;
}
await outbox.ReviseAsync(
operation.Sequence,
operation.Operation,
operation.ExpectedVersion,
resealed,
operation.Fields,
operation.Ancestor,
cancellationToken)
.ConfigureAwait(false);
return resealed;
}
/// <returns>Whether this answer warrants another push round.</returns>
private async Task<bool> HandleAsync(
Guid vaultId,