Public Access
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:
@@ -82,7 +82,10 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out _))
|
||||
// TryGet rather than CanRead, which answers false for a disposed keyring where this has to
|
||||
// throw: a locked session being read from is a caller holding something it should have let go
|
||||
// of, and the exception is what says so.
|
||||
if (!keyring.TryGet(vaultId, out _, out _))
|
||||
{
|
||||
throw new VaultUnreadableException(vaultId);
|
||||
}
|
||||
@@ -104,31 +107,17 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
{
|
||||
if (pendingByEntity.Remove(item.EntityId, out var local))
|
||||
{
|
||||
AddPending(listed, ref unreadable, vaultKey, local);
|
||||
AddPending(listed, ref unreadable, vaultId, local);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.IsDeleted || item.Payload is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var opened = kind.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version);
|
||||
|
||||
if (opened is null)
|
||||
{
|
||||
unreadable++;
|
||||
continue;
|
||||
}
|
||||
|
||||
listed.Add(new VaultItem<TSecret>(
|
||||
item.EntityId, opened.Secret, item.Version, false, false, opened.IsReadOnly));
|
||||
AddMirrored(listed, ref unreadable, vaultId, item);
|
||||
}
|
||||
|
||||
// Whatever is left has no mirror row yet: items created here and not yet accepted.
|
||||
foreach (var local in pendingByEntity.Values)
|
||||
{
|
||||
AddPending(listed, ref unreadable, vaultKey, local);
|
||||
AddPending(listed, ref unreadable, vaultId, local);
|
||||
}
|
||||
|
||||
return new ItemListing<TSecret>(listed, unreadable);
|
||||
@@ -211,7 +200,7 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
// the payload was sealed at, which is why the pending and mirror cases differ: a pending payload
|
||||
// holds the version the server will assign, and a mirror row holds the one it has.
|
||||
var before = IsAudited
|
||||
? Open(vaultKey, entityId, pending, ancestor)
|
||||
? Open(vaultId, entityId, pending, ancestor)
|
||||
: null;
|
||||
|
||||
await outbox.QueueAsync(
|
||||
@@ -327,15 +316,10 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
PendingOperation? pending,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Open(vaultKey, entityId, pending, ancestor)?.Label;
|
||||
return Open(vaultId, entityId, pending, ancestor)?.Label;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -348,23 +332,29 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
/// at the version the server <em>will</em> assign, and a mirror row holds the one it has.
|
||||
/// </remarks>
|
||||
private TSecret? Open(
|
||||
ReadOnlyMemory<byte> vaultKey,
|
||||
Guid vaultId,
|
||||
Guid entityId,
|
||||
PendingOperation? pending,
|
||||
StoredAncestor? ancestor)
|
||||
{
|
||||
if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued })
|
||||
{
|
||||
return kind.TryOpen(
|
||||
queued,
|
||||
vaultKey.Span,
|
||||
entityId,
|
||||
SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret;
|
||||
return keyring.TryGetAt(vaultId, queued.KeyGeneration, out var queuedKey)
|
||||
? kind.TryOpen(
|
||||
queued,
|
||||
queuedKey.Span,
|
||||
entityId,
|
||||
SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret
|
||||
: null;
|
||||
}
|
||||
|
||||
return ancestor is null
|
||||
? null
|
||||
: kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
|
||||
if (ancestor is null
|
||||
|| !keyring.TryGetAt(vaultId, ancestor.Payload.KeyGeneration, out var vaultKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -401,10 +391,43 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds one row of the server's mirror to a listing, or counts it as unreadable.</summary>
|
||||
private void AddMirrored(
|
||||
List<VaultItem<TSecret>> listed,
|
||||
ref int unreadable,
|
||||
Guid vaultId,
|
||||
StoredItem item)
|
||||
{
|
||||
if (item.IsDeleted || item.Payload is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The generation the item names, not the vault's current one. A rotated vault holds items
|
||||
// written under two or three keys at once, and a list that assumed the newest would report
|
||||
// everything older as unreadable.
|
||||
if (!keyring.TryGetAt(vaultId, item.Payload.KeyGeneration, out var vaultKey))
|
||||
{
|
||||
unreadable++;
|
||||
return;
|
||||
}
|
||||
|
||||
var opened = kind.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version);
|
||||
|
||||
if (opened is null)
|
||||
{
|
||||
unreadable++;
|
||||
return;
|
||||
}
|
||||
|
||||
listed.Add(new VaultItem<TSecret>(
|
||||
item.EntityId, opened.Secret, item.Version, false, false, opened.IsReadOnly));
|
||||
}
|
||||
|
||||
private void AddPending(
|
||||
List<VaultItem<TSecret>> listed,
|
||||
ref int unreadable,
|
||||
ReadOnlyMemory<byte> vaultKey,
|
||||
Guid vaultId,
|
||||
PendingOperation local)
|
||||
{
|
||||
if (local.Operation == SyncOperation.Delete)
|
||||
@@ -419,6 +442,14 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
return;
|
||||
}
|
||||
|
||||
// A queued change is sealed under whatever generation was current when it was queued, which is
|
||||
// not necessarily the current one: a rotation can land between an offline edit and its push.
|
||||
if (!keyring.TryGetAt(vaultId, local.Payload.KeyGeneration, out var vaultKey))
|
||||
{
|
||||
unreadable++;
|
||||
return;
|
||||
}
|
||||
|
||||
var version = SyncVersions.NextVersion(local.ExpectedVersion);
|
||||
var opened = kind.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user