Public Access
Merge branch 'main' into the vaults screen, and let it rotate keys too
Main built vault key rotation while this branch was reshaping the screen that would drive it, so the two met in the same three files. Every other conflict was textual and resolved by taking both; these are the ones where a decision had to be made. **The view model.** Main taught TeamsViewModel three things and this branch had renamed and rewritten it into VaultsViewModel. All three are ported rather than dropped, because each is a behaviour rather than wording: adding somebody now wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed, removing somebody rotates the vault and hands the new key to whoever is left, and a share reports how many generations were wrapped. The session calls they reach — ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list rather than to one vault, and they are called that way here rather than narrowed: adding somebody is a change to the list, so every vault the list carries is one they can now fetch. This screen makes lists that carry one vault, so the sentences name one; where a list carries several, naming them all is the honest report, and the members section already says the list is shared. AddMemberAsync ran two lines over the length limit once the sharing was in it, so the calls behind it moved to AddOrInviteAsync and the three-way refusal to WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was before the sharing arrived. **The tests.** Main's four new cases are ported to the vault-first API, including the one that matters most: the tampered key log is corrupted *before* the add, because the add is now a route to a wrap and a test that corrupted it afterwards would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey now expects two holders rather than one — main's fake records the creator's own self-grant, and a key-holder list that omitted it would show the one person who can certainly open a new vault as somebody who cannot. **The README.** The limits list is six rather than four or five: main's rotation entries and this branch's "a vault cannot be deleted" describe different things and both are true. "The rekey is flagged, never performed" is gone, since it is now performed, and M3 reads *Done* rather than *Done, except rekey*. One thing worth writing down that neither side had. An invitation claimed at sign-in still leaves the key owed, where an add does not: at the moment an invitation is issued there is no account and no published key to wrap to, and the claim happens on the invitee's machine, which holds nothing. Manual check 12.1 says so, because a reader who knows adding shares would otherwise read that step as stale. 1561 tests pass.
This commit is contained in:
@@ -81,15 +81,25 @@ internal sealed class IdentityService(DodoDbContext database, IVaultAccessServic
|
||||
{
|
||||
var vault = access.Vault!;
|
||||
|
||||
// The grant must match both the current key generation and the exact identity key it
|
||||
// was wrapped to. A grant left over from a superseded key is not merely stale — the
|
||||
// client's current private key cannot open it, so offering it would produce a tag
|
||||
// failure the user reads as data corruption.
|
||||
var grant = grants.Find(g =>
|
||||
g.VaultId == vault.Id
|
||||
&& g.KeyGeneration == vault.KeyGeneration
|
||||
&& key is not null
|
||||
&& g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256));
|
||||
// The grant must match the exact identity key it was wrapped to. One left over from a
|
||||
// superseded identity key is not merely stale — the client's current private key cannot
|
||||
// open it, so offering it would produce a tag failure the user reads as data corruption.
|
||||
var mine = grants
|
||||
.Where(g => g.VaultId == vault.Id
|
||||
&& key is not null
|
||||
&& g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256))
|
||||
.ToList();
|
||||
|
||||
var grant = mine.Find(g => g.KeyGeneration == vault.KeyGeneration);
|
||||
|
||||
// Everything older, oldest first. A rotation does not re-encrypt what is already stored —
|
||||
// each item keeps the generation it was sealed under — so a client holding only the
|
||||
// current key would read the vault's whole history as corrupt. See RekeyVaultRequest.
|
||||
var prior = mine
|
||||
.Where(g => g.KeyGeneration < vault.KeyGeneration)
|
||||
.OrderBy(g => g.KeyGeneration)
|
||||
.Select(g => new VaultKeyWrap((uint)g.KeyGeneration, g.WrappedKey))
|
||||
.ToArray();
|
||||
|
||||
summaries.Add(new VaultSummary(
|
||||
VaultId: vault.Id,
|
||||
@@ -103,7 +113,8 @@ internal sealed class IdentityService(DodoDbContext database, IVaultAccessServic
|
||||
// re-wrap it; the client has to say so rather than showing an empty vault.
|
||||
WrappedVaultKey: grant?.WrappedKey,
|
||||
|
||||
RekeyRequired: vault.RekeyRequired));
|
||||
RekeyRequired: vault.RekeyRequired,
|
||||
PriorKeyWraps: prior));
|
||||
}
|
||||
|
||||
return summaries;
|
||||
|
||||
@@ -76,6 +76,21 @@ internal static partial class TeamLog
|
||||
internal static partial void GrantRevoked(
|
||||
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
|
||||
|
||||
/// <remarks>
|
||||
/// Warning, because a rotation is the one operation that changes what every other member's key is
|
||||
/// worth: until each of them is wrapped the new generation, they hold the vault's history and
|
||||
/// cannot read anything written since. An operator seeing members report an unreadable vault needs
|
||||
/// this line and its timestamp to explain it.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2115,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Rotated the key of vault {VaultId} to generation {KeyGeneration}, by {ActorId}. "
|
||||
+ "Earlier grants are kept so stored items stay readable; every other member needs the new "
|
||||
+ "generation wrapped to them before they can read anything written from now on.")]
|
||||
internal static partial void VaultRekeyed(
|
||||
ILogger logger, Guid vaultId, int keyGeneration, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2108,
|
||||
Level = LogLevel.Information,
|
||||
|
||||
@@ -186,6 +186,70 @@ internal sealed class IssueVaultGrantEndpoint(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves this vault to a fresh key.</summary>
|
||||
/// <remarks>
|
||||
/// Gated on Share rather than on a rotation permission of its own. Rotating decides who can read what
|
||||
/// is written next, which is the same question sharing and withdrawing answer, and a fourth permission
|
||||
/// would be a distinction nobody administering a team would be able to explain.
|
||||
/// </remarks>
|
||||
internal sealed class RekeyVaultEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
VaultGrantService grants)
|
||||
: Endpoint<RekeyVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/vaults/{vaultId:guid}/rekey");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("RekeyVault")
|
||||
.WithSummary("Advances this vault's key generation, wrapped to the caller.")
|
||||
.WithTags("Vaults"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
RekeyVaultRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var access = await vaultAccess
|
||||
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.Permissions.HasFlag(PermissionFlags.Share))
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"You do not have permission to share this vault, so you cannot rotate its key.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var summary = await grants
|
||||
.RekeyAsync(user, access.Vault!, (int)access.Permissions, req, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(summary);
|
||||
}
|
||||
catch (VaultGrantInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Withdraws a member's key to this vault.</summary>
|
||||
/// <remarks>
|
||||
/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device
|
||||
|
||||
@@ -217,6 +217,13 @@ internal sealed class VaultGrantService(
|
||||
}
|
||||
|
||||
/// <summary>Lists who can open a vault.</summary>
|
||||
/// <remarks>
|
||||
/// One row per holder, not one per grant. A rotated vault holds several grants per member — one per
|
||||
/// generation, which is what lets them read its history — and a listing that showed each of them
|
||||
/// would answer "who can open this" with the same person three times. The row carries the best key
|
||||
/// they hold: the live grant at the highest generation, or, for somebody whose access has been
|
||||
/// withdrawn, the most recent grant they had, so the withdrawal is still visible.
|
||||
/// </remarks>
|
||||
internal async Task<VaultGrantsResponse> ListGrantsAsync(
|
||||
Vault vault,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -228,13 +235,25 @@ internal sealed class VaultGrantService(
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var holders = grants
|
||||
.GroupBy(g => g.RecipientUserId!.Value)
|
||||
.Select(group => group
|
||||
.OrderByDescending(g => g.RevokedAtUtc is null)
|
||||
.ThenByDescending(g => g.KeyGeneration)
|
||||
.First())
|
||||
|
||||
// The order the first grant of each holder was made in, so the list reads as the vault was
|
||||
// shared rather than reshuffling itself every time somebody is re-wrapped.
|
||||
.OrderBy(g => grants.Find(first => first.RecipientUserId == g.RecipientUserId)!.CreatedAtUtc)
|
||||
.ToList();
|
||||
|
||||
return new VaultGrantsResponse(
|
||||
VaultId: vault.Id,
|
||||
KeyGeneration: (uint)vault.KeyGeneration,
|
||||
RekeyRequired: vault.RekeyRequired,
|
||||
Grants:
|
||||
[
|
||||
.. grants.Select(g => new VaultGrantSummary(
|
||||
.. holders.Select(g => new VaultGrantSummary(
|
||||
g.RecipientUserId!.Value,
|
||||
g.RecipientUser?.Email,
|
||||
g.RecipientUser?.DisplayName,
|
||||
@@ -248,10 +267,18 @@ internal sealed class VaultGrantService(
|
||||
|
||||
/// <summary>Wraps a vault key to another member.</summary>
|
||||
/// <remarks>
|
||||
/// Re-issuing to a recipient who already holds a live grant replaces it in place rather than
|
||||
/// inserting a second row, because the unique index permits exactly one live grant per recipient
|
||||
/// per generation — and because the operation somebody is actually performing when they do this
|
||||
/// is "wrap it again", after a rotation or a botched first attempt.
|
||||
/// <para>
|
||||
/// Re-issuing to a recipient who already holds a live grant <em>for that generation</em> replaces it
|
||||
/// in place rather than inserting a second row, because the unique index permits exactly one live
|
||||
/// grant per recipient per generation — and because the operation somebody is actually performing
|
||||
/// when they do this is "wrap it again", after a botched first attempt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A recipient may hold one grant per generation at once, and after a rotation they need to: an item
|
||||
/// is sealed under whatever generation was current when it was written, so somebody given only the
|
||||
/// newest key would find everything older unreadable. Which generations get wrapped is the sharing
|
||||
/// client's decision — it is the only party that can tell which ones it holds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task IssueGrantAsync(
|
||||
UserAccount actor,
|
||||
@@ -264,10 +291,12 @@ internal sealed class VaultGrantService(
|
||||
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var generation = (int)request.KeyGeneration;
|
||||
|
||||
var existing = await database.VaultKeyGrants
|
||||
.SingleOrDefaultAsync(
|
||||
g => g.VaultId == vault.Id
|
||||
&& g.KeyGeneration == vault.KeyGeneration
|
||||
&& g.KeyGeneration == generation
|
||||
&& g.RecipientUserId == request.RecipientUserId
|
||||
&& g.RevokedAtUtc == null,
|
||||
cancellationToken)
|
||||
@@ -277,7 +306,7 @@ internal sealed class VaultGrantService(
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = vault.Id,
|
||||
KeyGeneration = vault.KeyGeneration,
|
||||
KeyGeneration = generation,
|
||||
Kind = GrantKind.Member,
|
||||
RecipientUserId = request.RecipientUserId,
|
||||
CreatedAtUtc = clock.GetUtcNow(),
|
||||
@@ -304,7 +333,7 @@ internal sealed class VaultGrantService(
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.GrantIssued(
|
||||
logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id);
|
||||
logger, vault.Id, generation, request.RecipientUserId, actor.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -335,7 +364,11 @@ internal sealed class VaultGrantService(
|
||||
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
|
||||
RequireDigest(request.KeyLogHead, "key log head");
|
||||
|
||||
if (request.KeyGeneration != (uint)vault.KeyGeneration)
|
||||
// Any generation the vault has actually reached, not only the current one — sharing a rotated
|
||||
// vault means handing over its history as well as its present. A generation ahead of the
|
||||
// current one is refused: nothing is sealed under it, so the grant would open nothing, and
|
||||
// accepting it would let a client move the vault forward without the transaction that does so.
|
||||
if (request.KeyGeneration is 0 || request.KeyGeneration > (uint)vault.KeyGeneration)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
|
||||
@@ -374,6 +407,170 @@ internal sealed class VaultGrantService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a vault to a fresh key generation, wrapped to the caller.
|
||||
/// </summary>
|
||||
/// <returns>The vault as the caller now sees it, at the generation this call created.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What the server contributes is the moment, not the key.</b> It cannot generate a vault key, tell
|
||||
/// that the one it is handed differs from the old one, or check that the caller held the old one at
|
||||
/// all. What it can do — and what nothing else can — is advance the generation exactly once, so two
|
||||
/// admins rotating the same vault at the same time do not both walk away believing they succeeded.
|
||||
/// The stale one's generation is no longer one past the current, and it is refused.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Earlier grants are left standing.</b> They are what the remaining members read the vault's
|
||||
/// history with: an item carries the generation it was sealed under, and nothing here re-encrypts
|
||||
/// items — only a client holding both keys could. The departed member is cut off by the revocation
|
||||
/// that removal already performed, which takes every generation they held.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rekey flag is cleared here rather than when the last member is re-wrapped, because it records
|
||||
/// that a membership change left the vault owing a rotation, and the rotation is this. Who still
|
||||
/// needs the new key is a different question, and the grant list answers it by generation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<VaultSummary> RekeyAsync(
|
||||
UserAccount actor,
|
||||
Vault vault,
|
||||
int permissions,
|
||||
RekeyVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var key = await RequireRotatableAsync(actor, vault, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var generation = (int)request.KeyGeneration;
|
||||
|
||||
AddSelfGrant(actor, vault, key, generation, request, now);
|
||||
|
||||
vault.KeyGeneration = generation;
|
||||
vault.RekeyRequired = false;
|
||||
vault.RekeyReason = RekeyReason.None;
|
||||
vault.UpdatedAtUtc = now;
|
||||
|
||||
try
|
||||
{
|
||||
// One SaveChanges, so the row and the grant land together. The vault's xmin concurrency
|
||||
// token is what makes the generation check above binding rather than advisory: a second
|
||||
// rotation that read the same generation fails here instead of overwriting this one.
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
// Reported as the same refusal the pre-check gives, because it is the same situation seen a
|
||||
// moment later — and a 500 about a concurrency token would tell the user nothing they could
|
||||
// act on. Retrying is safe: the caller generates a fresh key and reads the generation again.
|
||||
throw new VaultGrantInvalidException(
|
||||
"Somebody else rotated this vault while this rotation was being recorded. Read it again "
|
||||
+ "and rotate from the generation they left behind.");
|
||||
}
|
||||
|
||||
TeamLog.VaultRekeyed(logger, vault.Id, generation, actor.Id);
|
||||
|
||||
var prior = await database.VaultKeyGrants
|
||||
.Where(g => g.VaultId == vault.Id
|
||||
&& g.RecipientUserId == actor.Id
|
||||
&& g.KeyGeneration < generation
|
||||
&& g.State == GrantState.Active
|
||||
&& g.RevokedAtUtc == null)
|
||||
.OrderBy(g => g.KeyGeneration)
|
||||
.Select(g => new VaultKeyWrap((uint)g.KeyGeneration, g.WrappedKey))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new VaultSummary(
|
||||
VaultId: vault.Id,
|
||||
Name: vault.Name,
|
||||
IsPersonal: false,
|
||||
TeamId: vault.TeamId,
|
||||
KeyGeneration: request.KeyGeneration,
|
||||
Permissions: permissions,
|
||||
WrappedVaultKey: request.WrappedVaultKey,
|
||||
RekeyRequired: false,
|
||||
PriorKeyWraps: prior);
|
||||
}
|
||||
|
||||
/// <summary>Records the rotating client's grant for the generation it has just created.</summary>
|
||||
private void AddSelfGrant(
|
||||
UserAccount actor,
|
||||
Vault vault,
|
||||
UserKey key,
|
||||
int generation,
|
||||
RekeyVaultRequest request,
|
||||
DateTimeOffset now) =>
|
||||
database.VaultKeyGrants.Add(new VaultKeyGrant
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = vault.Id,
|
||||
KeyGeneration = generation,
|
||||
Kind = GrantKind.Member,
|
||||
RecipientUserId = actor.Id,
|
||||
RecipientKeyFingerprint = key.FingerprintSha256,
|
||||
WrappedKey = request.WrappedVaultKey,
|
||||
GranterUserId = actor.Id,
|
||||
GranterKeyFingerprint = key.FingerprintSha256,
|
||||
|
||||
// No key log head, as every self-grant carries none: there is no third party whose key
|
||||
// could have been substituted when you wrap something to yourself.
|
||||
KeyLogHead = null,
|
||||
Signature = request.GrantSignature,
|
||||
State = GrantState.Active,
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Everything that can be checked about a rotation before it is recorded.
|
||||
/// </summary>
|
||||
/// <returns>The caller's current identity key, which the new grant is filed against.</returns>
|
||||
private async Task<UserKey> RequireRotatableAsync(
|
||||
UserAccount actor,
|
||||
Vault vault,
|
||||
RekeyVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is null)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"Only a team vault can be rotated. A personal vault has one reader, so a rotation "
|
||||
+ "would re-wrap a key to the same person and change nothing about who can read it.");
|
||||
}
|
||||
|
||||
RequireWrappedKey(request.WrappedVaultKey);
|
||||
RequireSignature(request.GrantSignature);
|
||||
|
||||
if (request.KeyGeneration != (uint)vault.KeyGeneration + 1)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
$"This vault is at key generation {vault.KeyGeneration}, so the next one is "
|
||||
+ $"{vault.KeyGeneration + 1} and not {request.KeyGeneration}. Read the vault again — "
|
||||
+ "somebody else has rotated it since you last looked.");
|
||||
}
|
||||
|
||||
var key = await RequireCurrentKeyAsync(actor.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Held now, not merely permitted. The new key has to be wrapped from the old one, and an
|
||||
// account that cannot open the current generation cannot have done that — so a request from
|
||||
// one is either a mistake or an attempt to strand every other member behind a key nobody has.
|
||||
var holdsCurrent = await database.VaultKeyGrants
|
||||
.AnyAsync(
|
||||
g => g.VaultId == vault.Id
|
||||
&& g.KeyGeneration == vault.KeyGeneration
|
||||
&& g.RecipientUserId == actor.Id
|
||||
&& g.State == GrantState.Active
|
||||
&& g.RevokedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return holdsCurrent
|
||||
? key
|
||||
: throw new VaultGrantInvalidException(
|
||||
"You hold no key to this vault at its current generation, so you cannot rotate it. Ask "
|
||||
+ "a member who does.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a member's key grant.
|
||||
/// </summary>
|
||||
|
||||
@@ -60,10 +60,11 @@ internal static class EndpointRegistration
|
||||
typeof(ListVaultGrantsEndpoint),
|
||||
typeof(IssueVaultGrantEndpoint),
|
||||
typeof(RevokeVaultGrantEndpoint),
|
||||
typeof(RekeyVaultEndpoint),
|
||||
|
||||
// Registered as each feature lands:
|
||||
// Identity — key rotation, passphrase change
|
||||
// Vaults — rekey, per-item ACLs
|
||||
// Vaults — per-item ACLs
|
||||
// Relay — tickets and the WebSocket
|
||||
// Audit, Admin
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user