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:
@@ -144,14 +144,19 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pending);
|
||||
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation) || pending.Payload is null)
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||
|| pending.Payload is null
|
||||
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey))
|
||||
{
|
||||
return "This item has no usable vault key.";
|
||||
}
|
||||
|
||||
// Opened under the generation it was queued at and re-sealed under the current one. Those
|
||||
// differ whenever a rotation lands between an offline edit and its push, and re-sealing is
|
||||
// the point: what goes back to the server has to be readable by everybody holding the new key.
|
||||
var local = kind.TryOpen(
|
||||
pending.Payload,
|
||||
vaultKey.Span,
|
||||
queuedKey.Span,
|
||||
pending.EntityId,
|
||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||
|
||||
@@ -253,8 +258,16 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
|
||||
var (local, remoteSecret, vaultKey, generation) = opened.Value;
|
||||
|
||||
var ancestor = kind.TryOpen(
|
||||
pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version);
|
||||
// The ancestor is the version the server last confirmed, so it carries its own generation —
|
||||
// typically the oldest of the three when a rotation has happened since.
|
||||
var ancestor =
|
||||
keyring.TryGetAt(vaultId, pending.Ancestor.Payload.KeyGeneration, out var ancestorKey)
|
||||
? kind.TryOpen(
|
||||
pending.Ancestor.Payload,
|
||||
ancestorKey.Span,
|
||||
remote.EntityId,
|
||||
pending.Ancestor.Version)
|
||||
: null;
|
||||
|
||||
if (ancestor is null)
|
||||
{
|
||||
@@ -313,10 +326,11 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
}
|
||||
|
||||
var local = pending.Payload is null
|
||||
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
|
||||
? null
|
||||
: kind.TryOpen(
|
||||
pending.Payload,
|
||||
vaultKey.Span,
|
||||
queuedKey.Span,
|
||||
remote.EntityId,
|
||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||
|
||||
@@ -419,21 +433,26 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
{
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||
|| pending.Payload is null
|
||||
|| remote.Payload is null)
|
||||
|| remote.Payload is null
|
||||
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
|
||||
|| !keyring.TryGetAt(vaultId, remote.Payload.KeyGeneration, out var remoteKey))
|
||||
{
|
||||
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Each side under its own generation. The two genuinely differ after a rotation: what the
|
||||
// server holds was sealed before it, and the queued edit after — or the other way round, for a
|
||||
// client that rotated while this one was offline.
|
||||
var local = kind.TryOpen(
|
||||
pending.Payload,
|
||||
vaultKey.Span,
|
||||
queuedKey.Span,
|
||||
remote.EntityId,
|
||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||
|
||||
var remoteSecret = kind.TryOpen(
|
||||
remote.Payload, vaultKey.Span, remote.EntityId, remote.Version);
|
||||
remote.Payload, remoteKey.Span, remote.EntityId, remote.Version);
|
||||
|
||||
if (local is null || remoteSecret is null)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@ namespace DodoSSH.Client.Sync;
|
||||
/// "the keys exist only while unlocked" a property of the code and not of everyone's discipline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A vault has a key per generation, and this holds every one it was granted.</b> A rotation does not
|
||||
/// re-encrypt what is already stored — each item keeps the generation it was sealed under — so reading a
|
||||
/// rotated vault means opening items under two or three different keys, chosen per item rather than per
|
||||
/// vault. Writing uses the newest, which is what <see cref="TryGet"/> answers; reading an item asks for
|
||||
/// the generation that item names, which is <see cref="TryGetAt"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A grant that will not open is not an error: it means the vault has been rekeyed and this client's
|
||||
/// grant has not been re-wrapped yet, or the grant was fabricated. Both leave the vault temporarily
|
||||
/// unreadable and both are reported rather than thrown, so one bad grant does not take the other vaults
|
||||
@@ -23,7 +30,7 @@ namespace DodoSSH.Client.Sync;
|
||||
/// </remarks>
|
||||
public sealed class VaultKeyring : IDisposable
|
||||
{
|
||||
private readonly Dictionary<Guid, byte[]> keys = [];
|
||||
private readonly Dictionary<Guid, Dictionary<uint, byte[]>> keys = [];
|
||||
private readonly Dictionary<Guid, uint> generations = [];
|
||||
private bool disposed;
|
||||
|
||||
@@ -51,6 +58,12 @@ public sealed class VaultKeyring : IDisposable
|
||||
{
|
||||
foreach (var vault in vaults)
|
||||
{
|
||||
// The history first, and never conditional on the current generation opening. A member
|
||||
// who has been rotated past but not yet re-wrapped can still read everything written
|
||||
// before the rotation, and dropping those keys because the newest grant is missing
|
||||
// would turn "you cannot see the last hour's changes" into "the vault is empty".
|
||||
keyring.OpenPriorWraps(bundle, vault);
|
||||
|
||||
if (vault.WrappedVaultKey is null)
|
||||
{
|
||||
// The server said so itself: a grant awaiting re-wrap after a rekey.
|
||||
@@ -70,8 +83,7 @@ public sealed class VaultKeyring : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
keyring.keys[vault.VaultId] = key;
|
||||
keyring.generations[vault.VaultId] = vault.KeyGeneration;
|
||||
keyring.Adopt(vault.VaultId, key, vault.KeyGeneration);
|
||||
}
|
||||
|
||||
keyring.Unopened = unopened;
|
||||
@@ -94,26 +106,49 @@ public sealed class VaultKeyring : IDisposable
|
||||
/// </param>
|
||||
/// <param name="keyGeneration">The generation this key is for.</param>
|
||||
/// <remarks>
|
||||
/// Creating a team vault is the only case: the client generates the key, wraps it to itself and
|
||||
/// sends the wrap, so the plaintext is already here and unwrapping the server's copy back would be
|
||||
/// a round trip to learn something this process just chose. Adopting it also means the new vault is
|
||||
/// usable immediately rather than at the next unlock, which is what somebody who just pressed
|
||||
/// "create" expects.
|
||||
/// <para>
|
||||
/// Two cases, and they are the same operation: creating a team vault, and rotating one. Both
|
||||
/// generate the key here, wrap it to this user and send the wrap, so the plaintext is already in
|
||||
/// this process and unwrapping the server's copy back would be a round trip to learn something it
|
||||
/// just chose. Adopting it also means the vault is usable immediately rather than at the next
|
||||
/// unlock, which is what somebody who has just pressed a button expects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This generation becomes the one writes are sealed under. A key for a generation the vault has
|
||||
/// moved <em>past</em> goes in through <see cref="AdoptPrior"/> instead, which is not the same
|
||||
/// operation: it makes old items readable and must not walk the write target backwards.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(vaultKey);
|
||||
|
||||
if (keys.TryGetValue(vaultId, out var previous))
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(previous);
|
||||
}
|
||||
Store(vaultId, vaultKey, keyGeneration);
|
||||
|
||||
keys[vaultId] = vaultKey;
|
||||
generations[vaultId] = keyGeneration;
|
||||
Promote(vaultId, keyGeneration);
|
||||
}
|
||||
|
||||
Unopened = [.. Unopened.Where(id => id != vaultId)];
|
||||
/// <summary>
|
||||
/// Takes a vault key for a generation the vault has already moved past.
|
||||
/// </summary>
|
||||
/// <param name="vaultId">The vault.</param>
|
||||
/// <param name="vaultKey">
|
||||
/// The plaintext key. <b>The keyring takes ownership</b>, exactly as <see cref="Adopt"/> does.
|
||||
/// </param>
|
||||
/// <param name="keyGeneration">The superseded generation this key opens.</param>
|
||||
/// <remarks>
|
||||
/// Holding one of these is what lets a rotated vault be read at all: items are not re-encrypted by a
|
||||
/// rotation, so everything written before it is still sealed under the key it was written with.
|
||||
/// Nothing is ever <em>written</em> under one, which is why this does not touch the current
|
||||
/// generation and does not make an otherwise unreadable vault readable.
|
||||
/// </remarks>
|
||||
public void AdoptPrior(Guid vaultId, byte[] vaultKey, uint keyGeneration)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(vaultKey);
|
||||
|
||||
Store(vaultId, vaultKey, keyGeneration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,13 +167,23 @@ public sealed class VaultKeyring : IDisposable
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
|
||||
// Attempted whatever happens to the current generation, and before it. A share of a vault that
|
||||
// has been rotated since it was created arrives as a current wrap plus its history, and the
|
||||
// history is not a consolation prize — without it the recipient sees a vault full of items that
|
||||
// will not decrypt.
|
||||
OpenPriorWraps(bundle, vault);
|
||||
|
||||
if (vault.WrappedVaultKey is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keys.ContainsKey(vault.VaultId) && generations[vault.VaultId] == vault.KeyGeneration)
|
||||
if (Held(vault.VaultId, vault.KeyGeneration) is not null)
|
||||
{
|
||||
// Already open at this generation. Promoted rather than returned early, because a vault
|
||||
// that was rotated and re-granted arrives here with a generation this keyring has been
|
||||
// treating as historic, and it is now the one writes belong under.
|
||||
Promote(vault.VaultId, vault.KeyGeneration);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -157,14 +202,26 @@ public sealed class VaultKeyring : IDisposable
|
||||
|
||||
/// <summary>Records that a vault cannot be read, so the interface can say so.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The counterpart of <see cref="TryAdmit"/> for the case where the grant did not open. Kept
|
||||
/// explicit rather than inferred from the absence of a key, because "no key" is also what a vault
|
||||
/// this session has never heard of looks like.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It also gives up the write target, and that is the load-bearing half.</b> The usual way to
|
||||
/// reach here is another client having rotated the vault: this session still holds the previous
|
||||
/// generation's key and it is no longer the current one. Going on treating it as current would seal
|
||||
/// new items under a superseded key — readable here, unreadable to everybody else, and with nothing
|
||||
/// to show the author that anything was wrong. The keys themselves are kept, because the items
|
||||
/// already written under them are still readable through <see cref="TryGetAt"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public void MarkUnreadable(Guid vaultId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
generations.Remove(vaultId);
|
||||
|
||||
if (!Unopened.Contains(vaultId))
|
||||
{
|
||||
Unopened = [.. Unopened, vaultId];
|
||||
@@ -172,20 +229,27 @@ public sealed class VaultKeyring : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Borrows a vault's key.
|
||||
/// Borrows a vault's current key: the one new items are sealed under.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The returned memory is the keyring's own buffer, not a copy, and is zeroed when the keyring is
|
||||
/// disposed. Callers must not retain it past the operation they borrowed it for.
|
||||
/// <para>
|
||||
/// False for a vault this session holds only the history of — one rotated past a grant that has not
|
||||
/// been re-wrapped yet. That is deliberate: writing under a superseded key would produce an item
|
||||
/// nobody else could read, and the honest answer is that the vault is not writable until the new
|
||||
/// key arrives.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool TryGet(Guid vaultId, out ReadOnlyMemory<byte> vaultKey, out uint keyGeneration)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (keys.TryGetValue(vaultId, out var key))
|
||||
if (generations.TryGetValue(vaultId, out var current)
|
||||
&& Held(vaultId, current) is { } key)
|
||||
{
|
||||
vaultKey = key;
|
||||
keyGeneration = generations[vaultId];
|
||||
keyGeneration = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -194,8 +258,52 @@ public sealed class VaultKeyring : IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Whether this vault can be read at all.</summary>
|
||||
public bool CanRead(Guid vaultId) => !disposed && keys.ContainsKey(vaultId);
|
||||
/// <summary>
|
||||
/// Borrows the key one particular generation of a vault was sealed under.
|
||||
/// </summary>
|
||||
/// <param name="vaultId">The vault.</param>
|
||||
/// <param name="keyGeneration">The generation the item names.</param>
|
||||
/// <param name="vaultKey">The key, borrowed on the same terms as <see cref="TryGet"/>.</param>
|
||||
/// <returns>Whether this session holds that generation.</returns>
|
||||
/// <remarks>
|
||||
/// What every read goes through, because an item names the generation it was sealed under and a
|
||||
/// rotated vault holds items from more than one. False means that item is unreadable here and says
|
||||
/// nothing about the rest of the vault — which is why a caller counts it rather than failing.
|
||||
/// </remarks>
|
||||
public bool TryGetAt(Guid vaultId, uint keyGeneration, out ReadOnlyMemory<byte> vaultKey)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (Held(vaultId, keyGeneration) is { } key)
|
||||
{
|
||||
vaultKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
vaultKey = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every generation of one vault's key that this session holds, oldest first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read when sharing: a recipient given only the newest key would find the vault's history
|
||||
/// undecryptable, so the sharing client wraps each of these in turn. It is the only party that can
|
||||
/// — the server holds ciphertext, and the recipient holds nothing yet.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<uint> GenerationsHeld(Guid vaultId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return keys.TryGetValue(vaultId, out var held) ? [.. held.Keys.Order()] : [];
|
||||
}
|
||||
|
||||
/// <summary>Whether this vault can be read and written at its current generation.</summary>
|
||||
public bool CanRead(Guid vaultId) =>
|
||||
!disposed
|
||||
&& generations.TryGetValue(vaultId, out var current)
|
||||
&& Held(vaultId, current) is not null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
@@ -207,14 +315,70 @@ public sealed class VaultKeyring : IDisposable
|
||||
|
||||
disposed = true;
|
||||
|
||||
foreach (var key in keys.Values)
|
||||
foreach (var held in keys.Values)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
foreach (var key in held.Values)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
keys.Clear();
|
||||
generations.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Opens whatever superseded generations this vault came with.</summary>
|
||||
/// <remarks>
|
||||
/// A wrap that will not open is skipped rather than reported. It means one historic grant is
|
||||
/// unusable — the items under that generation stay unreadable and are counted as such where they
|
||||
/// are listed — and it is not a reason to refuse the generations that did open.
|
||||
/// </remarks>
|
||||
private void OpenPriorWraps(UserSecretBundle bundle, StoredVault vault)
|
||||
{
|
||||
foreach (var wrap in vault.PriorKeyWraps ?? [])
|
||||
{
|
||||
if (Held(vault.VaultId, wrap.KeyGeneration) is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = VaultKeys.TryUnwrap(
|
||||
bundle.EncryptionKey, wrap.WrappedKey, vault.VaultId, wrap.KeyGeneration);
|
||||
|
||||
if (key is not null)
|
||||
{
|
||||
AdoptPrior(vault.VaultId, key, wrap.KeyGeneration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[]? Held(Guid vaultId, uint keyGeneration) =>
|
||||
keys.TryGetValue(vaultId, out var held) && held.TryGetValue(keyGeneration, out var key)
|
||||
? key
|
||||
: null;
|
||||
|
||||
private void Store(Guid vaultId, byte[] vaultKey, uint keyGeneration)
|
||||
{
|
||||
if (!keys.TryGetValue(vaultId, out var held))
|
||||
{
|
||||
held = [];
|
||||
keys[vaultId] = held;
|
||||
}
|
||||
|
||||
if (held.TryGetValue(keyGeneration, out var previous))
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(previous);
|
||||
}
|
||||
|
||||
held[keyGeneration] = vaultKey;
|
||||
}
|
||||
|
||||
private void Promote(Guid vaultId, uint keyGeneration)
|
||||
{
|
||||
generations[vaultId] = keyGeneration;
|
||||
|
||||
Unopened = [.. Unopened.Where(id => id != vaultId)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when an operation needs a vault key the keyring does not hold.</summary>
|
||||
|
||||
Reference in New Issue
Block a user