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
@@ -295,6 +295,31 @@ public sealed partial class VaultSession : IAsyncDisposable
return engine.SyncAsync(vaultId, cancellationToken);
}
/// <summary>
/// Moves one vault's stored items onto its current key.
/// </summary>
/// <param name="api">The transport. Supplied per call, as <see cref="SyncAsync"/> takes its own.</param>
/// <param name="vaultId">The vault to re-seal.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// What a rotation leaves to be finished. Rotating re-keys the vault and not its contents, so until
/// this has run the items already stored are still sealed under keys a departed member may hold. It
/// is resumable, so a pass that fails part way is re-run rather than recovered — see
/// <see cref="VaultResealer"/>.
/// </remarks>
public Task<ResealReport> ResealVaultAsync(
ISyncApi api,
Guid vaultId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var resealer = new VaultResealer(api, Items, Outbox, keyring, clock, options);
return resealer.ResealAsync(vaultId, cancellationToken);
}
/// <summary>
/// Runs one synchronisation pass over every vault this session can read.
/// </summary>
+43 -13
View File
@@ -55,16 +55,25 @@ public sealed record VaultShareReport(
/// to discover.
/// </param>
/// <param name="Failure">Why the rotation itself did not happen, when it did not.</param>
/// <param name="Reseal">
/// What moving the vault's stored items onto the new key achieved, or null when the rotation did not
/// get that far. A rotation without this has re-keyed the vault and not its contents, which is a
/// different guarantee — see <see cref="VaultResealer"/>.
/// </param>
public sealed record VaultRekeyReport(
Guid VaultId,
string Name,
uint KeyGeneration,
IReadOnlyList<Guid> Shared,
IReadOnlyList<(Guid UserId, string Reason)> NotShared,
Exception? Failure)
Exception? Failure,
ResealReport? Reseal = null)
{
/// <summary>Whether the vault moved to a new key.</summary>
public bool Rotated => Failure is null && KeyGeneration > 0;
/// <summary>Whether everything in the vault is now sealed under that new key.</summary>
public bool Sealed => Reseal is { Complete: true };
}
/// <summary>
@@ -226,6 +235,9 @@ public sealed partial class VaultSession
/// </summary>
/// <param name="grants">The grant calls.</param>
/// <param name="directory">The directory and the key log that makes it checkable.</param>
/// <param name="sync">
/// The synchronisation calls, for the last step: moving what is already stored onto the new key.
/// </param>
/// <param name="vaultId">The vault to rotate.</param>
/// <param name="recipients">
/// Who should hold the new key. The caller's own id may be in here and is ignored: this session
@@ -234,23 +246,30 @@ public sealed partial class VaultSession
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// <para>
/// <b>Two acts, and only the first is atomic.</b> The generation advances in one server transaction,
/// so there is no moment at which two clients disagree about which key is current. Wrapping it to
/// each remaining member is a separate call per member, each verified against the key log the same
/// way an ordinary share is — and any of them can fail. A member who was missed holds the vault's
/// history and cannot read anything written since, which the report says so the interface can too.
/// <b>Three acts, and only the first is atomic.</b> The generation advances in one server
/// transaction, so there is no moment at which two clients disagree about which key is current.
/// Wrapping it to each remaining member is a separate call per member, each verified against the key
/// log the same way an ordinary share is — and any of them can fail. A member who was missed holds
/// the vault's history and cannot read anything written since, which the report says so the
/// interface can too.
/// </para>
/// <para>
/// <b>What a rotation is worth, stated honestly.</b> Nothing already stored is re-encrypted — only a
/// client holding both keys could, and that is deferred work. So this does not take back what the
/// departed member already has, and it does not re-seal the vault's history against the key they may
/// have kept. What it does is make everything written from now on unreadable to them. Retroactive
/// revocation is not achievable; rotate the credentials themselves. See ADR 0001.
/// <b>The third act is re-sealing what is already there</b>, and it is what makes the rotation worth
/// the name: until it has run, the vault's stored items are still sealed under keys the departed
/// member may have kept. It runs last for a reason — it needs the new key, and it is the only step
/// that can be interrupted without leaving anything broken, because a vault at mixed generations
/// stays readable to everybody holding the grants. A pass that stops half way is re-run.
/// </para>
/// <para>
/// <b>What none of it can do</b> is take back what the departed member already pulled onto their own
/// machine. Retroactive revocation is not achievable; rotate the credentials themselves. See
/// ADR 0001.
/// </para>
/// </remarks>
public async Task<VaultRekeyReport> RekeyVaultAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
ISyncApi sync,
Guid vaultId,
IReadOnlyList<Guid> recipients,
CancellationToken cancellationToken)
@@ -258,6 +277,7 @@ public sealed partial class VaultSession
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(sync);
ArgumentNullException.ThrowIfNull(recipients);
if (!keyring.TryGet(vaultId, out _, out var keyGeneration))
@@ -297,8 +317,16 @@ public sealed partial class VaultSession
}
}
// Synced before re-sealing, and it is not tidiness. The pass rewrites each item against the
// version the server holds, so a mirror that is behind produces a batch of conflicts instead of
// a re-sealed vault — and the sync also carries out anything queued here, which the push path
// re-seals on the way rather than leaving to be found later.
await SyncAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);
var resealed = await ResealVaultAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);
return new VaultRekeyReport(
vaultId, name, summary.KeyGeneration, shared, missed, Failure: null);
vaultId, name, summary.KeyGeneration, shared, missed, Failure: null, resealed);
}
/// <summary>Generates the next vault key, records it, and takes it into the keyring.</summary>
@@ -423,6 +451,7 @@ public sealed partial class VaultSession
public async Task<IReadOnlyList<VaultRekeyReport>> RekeyTeamVaultsAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
ISyncApi sync,
Guid teamId,
IReadOnlyList<Guid> recipients,
CancellationToken cancellationToken)
@@ -430,6 +459,7 @@ public sealed partial class VaultSession
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(sync);
ArgumentNullException.ThrowIfNull(recipients);
var reports = new List<VaultRekeyReport>();
@@ -440,7 +470,7 @@ public sealed partial class VaultSession
{
reports.Add(
await RekeyVaultAsync(
grants, directory, vault.VaultId, recipients, cancellationToken)
grants, directory, sync, vault.VaultId, recipients, cancellationToken)
.ConfigureAwait(false));
}
catch (Exception exception) when (exception is not OperationCanceledException)
@@ -1086,7 +1086,7 @@ internal sealed partial class TeamsViewModel(
var reports = await open
.RekeyTeamVaultsAsync(
server.Grants, server.Directory, team.TeamId, remaining, cancellationToken)
server.Grants, server.Directory, server.Sync, team.TeamId, remaining, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
@@ -1102,29 +1102,7 @@ internal sealed partial class TeamsViewModel(
if (rotated.Count > 0)
{
// Says what a rotation is and is not worth, because the word promises more than it can
// deliver: from here on they cannot read this vault, and what is already in it was sealed
// under the key they used to hold.
sentences.Add(
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
+ "written from now on is readable to them.");
// The members who did not get the new key. They are still in the team and can still write,
// but until somebody wraps it to them they will find the vault stops updating.
// Distinct by id rather than by name, because two accounts can share a display name and
// collapsing them would tell somebody one person is owed a key when two are.
var missed = rotated
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
.Distinct()
.Select(Name)
.ToList();
if (missed.Count > 0)
{
sentences.Add(
$"The new key did not reach {Join(missed)} — press SHARE KEY for them, or they "
+ "will stop seeing changes.");
}
sentences.AddRange(Describe(rotated));
}
if (failed.Count > 0)
@@ -1136,6 +1114,42 @@ internal sealed partial class TeamsViewModel(
return string.Join(" ", sentences);
}
/// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
private IEnumerable<string> Describe(List<VaultRekeyReport> rotated)
{
yield return
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
+ "written from now on is readable to them.";
// Two different promises, so two different sentences. A vault whose items were all moved onto
// the new key is closed to them completely; one where some were left is closed to what happens
// next, and the difference is not the interface's to blur.
var sealedUp = rotated.Count(report => report.Sealed);
yield return sealedUp == rotated.Count
? "Everything already stored was re-sealed under the new key too, so their old key opens "
+ "nothing."
: $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new "
+ "key; the rest still hold items under the old one and will be picked up next time. "
+ "Rotate the credentials that mattered either way.";
// The members who did not get the new key. They are still in the team and can still write, but
// until somebody wraps it to them they will find the vault stops updating. Distinct by id
// rather than by name, because two accounts can share a display name and collapsing them would
// tell somebody one person is owed a key when two are.
var missed = rotated
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
.Distinct()
.Select(Name)
.ToList();
if (missed.Count > 0)
{
yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or "
+ "they will stop seeing changes.";
}
}
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
private string Name(Guid userId) =>
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
+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,
+414
View File
@@ -0,0 +1,414 @@
using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>What one re-sealing pass did.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="KeyGeneration">The generation everything was moved to.</param>
/// <param name="Resealed">Items now sealed under the current key.</param>
/// <param name="Deferred">
/// 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.
/// </param>
/// <param name="Unreadable">
/// 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.
/// </param>
/// <param name="Contested">
/// 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.
/// </param>
public sealed record ResealReport(
Guid VaultId,
uint KeyGeneration,
int Resealed,
int Deferred,
int Unreadable,
int Contested)
{
/// <summary>Whether every item in the vault is now sealed under its current key.</summary>
/// <remarks>
/// Deferred items count as finished. They are queued changes, and a queued change cannot reach the
/// server under a superseded key — <see cref="SyncEngine"/> re-seals it on the way out.
/// </remarks>
public bool Complete => Unreadable == 0 && Contested == 0;
/// <summary>Whether anything moved.</summary>
public bool MovedAnything => Resealed > 0;
}
/// <summary>
/// Moves a rotated vault's stored items onto its current key.
/// </summary>
/// <remarks>
/// <para>
/// <b>What a rotation on its own does not do.</b> 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.
/// </para>
/// <para>
/// <b>The plaintext is never decoded.</b> 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.
/// </para>
/// <para>
/// <b>Resumable by construction, because a vault at mixed generations is readable.</b> 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.
/// </para>
/// </remarks>
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;
/// <summary>Creates the pass.</summary>
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;
}
/// <summary>Re-seals everything in one vault that is not already on its current key.</summary>
/// <param name="vaultId">The vault.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// 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.
/// </remarks>
public async Task<ResealReport> 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<Pending>(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);
}
/// <summary>Whether an item is still sealed under a key the vault has moved past.</summary>
private static bool Behind(StoredItem item, uint generation) =>
item.Payload is { } payload && payload.KeyGeneration < generation;
/// <summary>Re-seals one item, or answers null when this session cannot open it.</summary>
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);
}
/// <summary>Sends a batch if there is one, and empties it.</summary>
private async Task SendAsync(
Guid vaultId,
List<Pending> 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();
}
/// <summary>The running counts, so the loop above stays one screen long.</summary>
private sealed class Tally
{
internal int Resealed { get; set; }
internal int Deferred { get; set; }
internal int Unreadable { get; set; }
internal int Contested { get; set; }
}
/// <summary>One item, re-sealed and waiting to be sent.</summary>
private sealed record Pending(StoredItem Item, EncryptedPayload Payload, int Version)
{
internal Guid OperationId { get; } = Guid.CreateVersion7();
}
/// <summary>What one batch achieved.</summary>
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
private readonly record struct PushOutcome(int Applied, int Contested);
/// <summary>
/// Sends one batch and mirrors what the server accepted.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
private async Task<PushOutcome> PushAsync(
Guid vaultId,
List<Pending> 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);
}
/// <summary>The items a local edit is already queued for.</summary>
private async Task<HashSet<(SyncEntityType Type, Guid EntityId)>> QueuedAsync(
Guid vaultId,
CancellationToken cancellationToken)
{
var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false);
return [.. pending.Select(operation => (operation.EntityType, operation.EntityId))];
}
}
/// <summary>
/// Moving one payload from the key it was sealed under to the one in force now.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class PayloadReseal
{
/// <summary>
/// Re-seals a payload under the vault's current key.
/// </summary>
/// <param name="keyring">The open keyring.</param>
/// <param name="vaultId">The vault.</param>
/// <param name="entityType">What kind of item this is; the AAD binds its resource type.</param>
/// <param name="entityId">The item.</param>
/// <param name="payload">The payload as it stands, sealed under an earlier generation.</param>
/// <param name="openAtVersion">The item version <paramref name="payload"/> is bound to.</param>
/// <param name="sealAtVersion">The version the result will be bound to.</param>
/// <returns>
/// The re-sealed payload, or <see langword="null"/> when this session cannot open the original —
/// which means one item stays where it is, and says nothing about the rest of the vault.
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
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<byte> plaintext,
ReadOnlySpan<byte> 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);
}
}
}