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:
2026-08-04 13:58:56 +02:00
51 changed files with 4785 additions and 275 deletions
@@ -108,6 +108,10 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
// Gated on Share inside the handler as the two writes above are, and additionally on holding the
// current key — which no policy could express, since it is a row in vault_key_grant.
"POST /api/v1/vaults/{vaultId:guid}/rekey name=RekeyVault tags=Vaults policies=Enrolled anon=False",
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
// MapHealthChecks constrains no verb, hence ANY.
@@ -1127,6 +1127,198 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
/// <remarks>
/// The rotation itself: the generation advances, the caller holds the new key, and the flag a
/// removal set is cleared because the rotation it recorded has happened. What the server cannot do
/// is any part of the cryptography — the wrap arrives sealed and is stored as bytes.
/// </remarks>
[Fact]
public async Task RekeyingAVault_AdvancesTheGenerationAndWrapsItToTheCaller()
{
var owner = await EnrolledClientAsync("rotate-owner", "rotowner@example.com");
await EnrolledClientAsync("rotate-member", "rotmember@example.com");
var team = await CreateTeamAsync(owner, "Rotations");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "rotmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
var rotated = await RekeyAsync(owner, vaultId, generation: 2);
rotated.KeyGeneration.ShouldBe(2u);
rotated.RekeyRequired.ShouldBeFalse();
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
grants.KeyGeneration.ShouldBe(2u);
grants.RekeyRequired.ShouldBeFalse();
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
summary.KeyGeneration.ShouldBe(2u);
summary.WrappedVaultKey.ShouldNotBeNull();
}
/// <remarks>
/// The reason a rotation does not have to re-encrypt anything to be safe, and the reason it cannot
/// throw the old grants away: every item still carries the generation it was sealed under, so the
/// caller has to go on holding every key they were given or the vault's history becomes unreadable
/// to the people who are still in the team.
/// </remarks>
[Fact]
public async Task ARotatedVault_StillServesTheCallerTheGenerationsItHasMovedPast()
{
var owner = await EnrolledClientAsync("history-owner", "histowner@example.com");
var team = await CreateTeamAsync(owner, "History");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
await RekeyAsync(owner, vaultId, generation: 2);
await RekeyAsync(owner, vaultId, generation: 3);
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
summary.KeyGeneration.ShouldBe(3u);
summary.PriorKeyWraps.ShouldNotBeNull();
summary.PriorKeyWraps.Select(wrap => wrap.KeyGeneration).ShouldBe([1u, 2u]);
}
/// <remarks>
/// The sharing list answers "who can open this", so a member appears once however many generations
/// they hold — and the generation on their row is the best key they have, which is what makes a row
/// below the vault's own generation mean "still owed the new key".
/// </remarks>
[Fact]
public async Task TheGrantListing_ShowsAMemberOnceWithTheBestKeyTheyHold()
{
var owner = await EnrolledClientAsync("listing-owner", "listowner@example.com");
await EnrolledClientAsync("listing-member", "listmember@example.com");
var team = await CreateTeamAsync(owner, "Listings");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "listmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var issued = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
issued.EnsureSuccessStatusCode();
await RekeyAsync(owner, vaultId, generation: 2);
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
grants.KeyGeneration.ShouldBe(2u);
grants.Grants.Count.ShouldBe(2);
// The rotating owner holds both generations and is listed at the newer one.
grants.Grants.Single(g => g.RecipientUserId != entry.UserId).KeyGeneration.ShouldBe(2u);
// The member has not been re-wrapped, so their row says so by generation rather than by state.
var stale = grants.Grants.Single(g => g.RecipientUserId == entry.UserId);
stale.KeyGeneration.ShouldBe(1u);
stale.State.ShouldBe(VaultGrantState.Active);
}
/// <remarks>
/// Two admins rotating at once must not both succeed, or one of them ends up holding a key nobody
/// else has and every item they write is unreadable to the rest of the team. The generation is what
/// makes that decidable: the second request is no longer one past the current, and is refused with a
/// message that says to read the vault again.
/// </remarks>
[Fact]
public async Task ARekeyFromASupersededGeneration_IsRefused()
{
var owner = await EnrolledClientAsync("race-owner", "raceowner@example.com");
var team = await CreateTeamAsync(owner, "Races");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
await RekeyAsync(owner, vaultId, generation: 2);
var stale = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2));
await ShouldBeProblemAsync(stale, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
/// A member with no key to the current generation cannot rotate. They could not have wrapped the
/// new key from the old one, so the request is either a mistake or a way to strand everybody else
/// behind a key nobody holds.
/// </remarks>
[Fact]
public async Task ARekeyByAMemberWhoHoldsNoKey_IsRefused()
{
var owner = await EnrolledClientAsync("keyless-owner", "klowner@example.com");
var member = await EnrolledClientAsync("keyless-member", "klmember@example.com");
var team = await CreateTeamAsync(owner, "Keyless");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "klmember@example.com");
// Admin, so permission is not what stops them: what stops them is holding no key.
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
var response = await member.PostContractAsync(
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2));
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
/// Sharing a rotated vault means handing over its history too, so a grant for a generation the vault
/// has moved past is accepted. One for a generation it has not reached is not: nothing is sealed
/// under it, and accepting it would let a client move the vault forward outside the one transaction
/// that is allowed to.
/// </remarks>
[Fact]
public async Task AGrantForAnEarlierGeneration_IsAcceptedAndOneForALaterOneIsNot()
{
var owner = await EnrolledClientAsync("gen-owner", "genowner@example.com");
var member = await EnrolledClientAsync("gen-member", "genmember@example.com");
var team = await CreateTeamAsync(owner, "Generations");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "genmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await RekeyAsync(owner, vaultId, generation: 2);
foreach (var generation in (uint[])[1, 2])
{
var accepted = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation));
accepted.StatusCode.ShouldBe(HttpStatusCode.NoContent);
}
var ahead = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 3));
await ShouldBeProblemAsync(ahead, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
// Both grants are live at once, which is what lets the recipient read the vault's history and
// its present. A single row per recipient would have made one of them overwrite the other.
var me = await ReadAsync<MeResponse>(member, MeUrl);
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
summary.KeyGeneration.ShouldBe(2u);
summary.PriorKeyWraps.ShouldNotBeNull().ShouldHaveSingleItem().KeyGeneration.ShouldBe(1u);
}
// ---- The directory and the key log ----
/// <remarks>
@@ -1301,6 +1493,40 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
return vault.VaultId;
}
/// <remarks>
/// The wrap is the right shape and nothing more, for the reason <see cref="CreateVaultAsync"/> gives:
/// the server stores it opaquely, so a real seal here would be exercising the crypto library.
/// </remarks>
private static RekeyVaultRequest RekeyRequest(uint generation) =>
new(
KeyGeneration: generation,
WrappedVaultKey: new byte[110],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch);
private static IssueVaultGrantRequest GrantRequest(DirectoryEntry entry, uint generation) =>
new(
entry.UserId,
entry.Fingerprint,
KeyGeneration: generation,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch);
private static async Task<VaultSummary> RekeyAsync(
HttpClient client,
Guid vaultId,
uint generation)
{
var response = await client.PostContractAsync(
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation));
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<VaultSummary>())!;
}
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
{
var address = addresses.GetValueOrDefault(email, email);