Public Access
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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user