Move the keys when a membership changes, not just the flag

Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.

The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.

The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.

What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.

Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
This commit is contained in:
2026-08-03 23:05:40 +02:00
parent e82a25c912
commit d5b1a73182
35 changed files with 2838 additions and 173 deletions
+25
View File
@@ -159,6 +159,31 @@ internal sealed class CachedVaultRow
public DateTimeOffset UpdatedAtUtc { get; set; }
}
/// <summary>
/// A vault key this user holds for a generation the vault has moved past.
/// </summary>
/// <remarks>
/// <para>
/// A table rather than a column, because there is one of these per rotation and the vault row has one
/// of everything else. The current generation's wrap stays on <see cref="CachedVaultRow"/>: it is what
/// unlocking needs, and burying it in a child table would make the common case the awkward one.
/// </para>
/// <para>
/// Cached for the reason the current wrap is. An item keeps the generation it was sealed under, so a
/// machine that came back from a rotation with only the newest key would read everything written
/// before it as corrupt — offline, with no way to ask for the rest.
/// </para>
/// </remarks>
internal sealed class CachedVaultKeyWrapRow
{
public Guid VaultId { get; set; }
public uint KeyGeneration { get; set; }
/// <summary>The vault key at this generation, sealed to this user's X25519 key.</summary>
public byte[] WrappedKey { get; set; } = [];
}
/// <summary>
/// The last state of an item that the server confirmed.
/// </summary>
@@ -123,7 +123,8 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
entity.Property(row => row.SealedRefreshToken).IsRequired();
});
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
private static void ConfigureVaults(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CachedVaultRow>(entity =>
{
entity.ToTable("vault");
@@ -132,6 +133,18 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
entity.Property(row => row.Name).IsRequired();
});
// No foreign key to the vault row, deliberately. The two are written by the same store in the
// same call, and a cascade would make "which of these two tables is authoritative" a question
// the schema answers rather than the code — while buying nothing, since a wrap for a vault this
// machine can no longer see is removed by the same pass that removes the vault.
modelBuilder.Entity<CachedVaultKeyWrapRow>(entity =>
{
entity.ToTable("vault_key_wrap");
entity.HasKey(row => new { row.VaultId, row.KeyGeneration });
entity.Property(row => row.WrappedKey).IsRequired();
});
}
private static void ConfigureItems(ModelBuilder modelBuilder) =>
modelBuilder.Entity<CachedItemRow>(entity =>
{
@@ -0,0 +1,465 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20260803202241_AddVaultKeyWrapHistory")]
partial class AddVaultKeyWrapHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("Hidden")
.HasColumnType("INTEGER")
.HasColumnName("hidden");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<Guid?>("DeviceId")
.HasColumnType("TEXT")
.HasColumnName("device_id");
b.Property<byte[]>("DeviceWrappedPrivateKey")
.HasColumnType("BLOB")
.HasColumnName("device_wrapped_private_key");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,35 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddVaultKeyWrapHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "vault_key_wrap",
columns: table => new
{
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
wrapped_key = table.Column<byte[]>(type: "BLOB", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_vault_key_wrap", x => new { x.vault_id, x.key_generation });
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "vault_key_wrap");
}
}
}
@@ -83,6 +83,27 @@ namespace DodoSSH.Client.Storage.Migrations
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
+7 -1
View File
@@ -71,6 +71,11 @@ public sealed record StoredUnlockMaterial(
/// <param name="Permissions">Effective permissions, as a flags value.</param>
/// <param name="WrappedVaultKey">The vault key sealed to this user. Null while awaiting re-wrap.</param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
/// <param name="PriorKeyWraps">
/// The same key at every generation before <paramref name="KeyGeneration"/> that this user still holds
/// a grant for. Empty for a vault that has never been rotated, and what makes one that has readable
/// back to its first item.
/// </param>
public sealed record StoredVault(
Guid VaultId,
string Name,
@@ -79,7 +84,8 @@ public sealed record StoredVault(
uint KeyGeneration,
int Permissions,
byte[]? WrappedVaultKey,
bool RekeyRequired)
bool RekeyRequired,
IReadOnlyList<VaultKeyWrap>? PriorKeyWraps = null)
{
/// <summary>
/// The <c>Write</c> bit of <see cref="Permissions"/>.
+91 -4
View File
@@ -1,3 +1,4 @@
using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
@@ -25,7 +26,9 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
var wraps = await ReadWrapsAsync(context, cancellationToken).ConfigureAwait(false);
return [.. rows.Select(row => ToStored(row, wraps.GetValueOrDefault(row.VaultId, [])))];
}
/// <summary>Reads one vault.</summary>
@@ -39,7 +42,19 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
if (row is null)
{
return null;
}
var wraps = await context.Set<CachedVaultKeyWrapRow>()
.AsNoTracking()
.Where(w => w.VaultId == vaultId)
.OrderBy(w => w.KeyGeneration)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return ToStored(row, [.. wraps.Select(ToWrap)]);
}
/// <summary>
@@ -81,10 +96,20 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
}
Apply(row, vault, now);
await ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);
}
context.RemoveRange(existing.Values);
// The wraps of a vault that is gone from the list go with it. They are keys to something this
// machine can no longer fetch, and keeping them would be keeping key material for a vault the
// user has been told they no longer have.
foreach (var dropped in existing.Keys)
{
await RemoveWrapsAsync(context, dropped, cancellationToken).ConfigureAwait(false);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
@@ -116,6 +141,8 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
Apply(row, vault, clock.GetUtcNow());
await ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
@@ -182,7 +209,66 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
row.UpdatedAtUtc = now;
}
private static StoredVault ToStored(CachedVaultRow row) =>
/// <summary>
/// Replaces one vault's earlier-generation wraps with what the server reported.
/// </summary>
/// <remarks>
/// Deleted and re-inserted rather than merged. There are a handful of these per vault at most, the
/// server's list is authoritative, and a merge would have to decide what a wrap present here and
/// absent there means — which is "that grant was revoked", and the answer to that is to drop it.
/// </remarks>
private static async Task ApplyWrapsAsync(
ClientCacheContext context,
StoredVault vault,
CancellationToken cancellationToken)
{
await RemoveWrapsAsync(context, vault.VaultId, cancellationToken).ConfigureAwait(false);
foreach (var wrap in vault.PriorKeyWraps ?? [])
{
context.Add(new CachedVaultKeyWrapRow
{
VaultId = vault.VaultId,
KeyGeneration = wrap.KeyGeneration,
WrappedKey = wrap.WrappedKey,
});
}
}
private static async Task RemoveWrapsAsync(
ClientCacheContext context,
Guid vaultId,
CancellationToken cancellationToken)
{
var stale = await context.Set<CachedVaultKeyWrapRow>()
.Where(w => w.VaultId == vaultId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
context.RemoveRange(stale);
}
private static async Task<Dictionary<Guid, IReadOnlyList<VaultKeyWrap>>> ReadWrapsAsync(
ClientCacheContext context,
CancellationToken cancellationToken)
{
var rows = await context.Set<CachedVaultKeyWrapRow>()
.AsNoTracking()
.OrderBy(row => row.KeyGeneration)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows
.GroupBy(row => row.VaultId)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<VaultKeyWrap>)[.. group.Select(ToWrap)]);
}
private static VaultKeyWrap ToWrap(CachedVaultKeyWrapRow row) =>
new(row.KeyGeneration, row.WrappedKey);
private static StoredVault ToStored(CachedVaultRow row, IReadOnlyList<VaultKeyWrap> priorWraps) =>
new(
row.VaultId,
row.Name,
@@ -191,5 +277,6 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
row.KeyGeneration,
row.Permissions,
row.WrappedVaultKey,
row.RekeyRequired);
row.RekeyRequired,
priorWraps);
}