Public Access
Merge branch 'claude/team-key-share-rotate-4b2619'
Two conflicts, and the second is worth recording. main's M4 bullet gained the Android signing decision while this branch rewrote the M5 line either side of it; both are kept. The other is an ADR number collision: two sessions each took 0010, one for vault key rotation and one for Android distribution, and both are now on main. ADR numbers are identifiers — "see ADR 0010" appears in code comments as well as in prose — so leaving two would make every such reference ambiguous. The rotation ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009 and the sync code; the Android one is referenced from README and android-port.md. So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011, with its title and both references updated. Nothing about either decision changes.
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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user