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
@@ -103,6 +103,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.
@@ -999,6 +999,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>
@@ -1171,6 +1363,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);
@@ -227,6 +227,12 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public void Dispose()
{
@@ -26,7 +26,14 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
private readonly List<TeamSummary> teams = [];
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
/// <remarks>
/// Keyed by generation as well as by recipient, because the real table is: a rotation leaves a
/// member holding one grant per generation, and a fake that kept one per person would quietly model
/// sharing the history as overwriting it — which is the bug this half of the feature exists to
/// avoid.
/// </remarks>
private readonly Dictionary<(Guid VaultId, Guid UserId, uint KeyGeneration), IssueVaultGrantRequest>
grants = [];
private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = [];
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
@@ -51,8 +58,29 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
/// <inheritdoc />
public IVaultGrantApi Grants => this;
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
/// <summary>
/// Grants this fake has been asked to record, newest generation per recipient.
/// </summary>
/// <remarks>
/// Flattened to one entry per recipient because that is the question most tests are asking — can
/// this person open the vault as it stands. <see cref="GenerationsGranted"/> is for the ones asking
/// whether they were also given its history.
/// </remarks>
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants =>
grants
.GroupBy(entry => (entry.Key.VaultId, entry.Key.UserId))
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(entry => entry.Key.KeyGeneration).First().Value);
/// <summary>Which generations of one vault's key a recipient has been wrapped, oldest first.</summary>
internal IReadOnlyList<uint> GenerationsGranted(Guid vaultId, Guid userId) =>
[
.. grants.Keys
.Where(key => key.VaultId == vaultId && key.UserId == userId)
.Select(key => key.KeyGeneration)
.Order(),
];
/// <summary>
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
@@ -451,11 +479,17 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
// Every grant they held from this team goes with them, as the real service revokes them in the
// same transaction. A fake that removed the membership and left the grants would let a test
// "prove" a revocation that had not happened.
foreach (var vaultId in teamVaults.Values
.Where(vault => vault.TeamId == teamId)
.Select(vault => vault.VaultId))
var theirs = grants.Keys
.Where(key => key.UserId == userId
&& teamVaults.TryGetValue(key.VaultId, out var vault)
&& vault.TeamId == teamId)
.ToList();
// Every generation, not only the newest. A revocation that left the history behind would let
// them go on reading everything written before the rotation that follows.
foreach (var key in theirs)
{
grants.Remove((vaultId, userId));
grants.Remove(key);
}
Recount(teamId);
@@ -491,6 +525,17 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
teamVaults[vault.VaultId] = vault;
// The creator's own grant, as the real create records it in the same transaction. Without it a
// rotation here would report no earlier wraps and the vault's first generation would vanish.
grants[(vault.VaultId, UserId, 1)] = new IssueVaultGrantRequest(
UserId,
RecipientKeyFingerprint: new byte[32],
KeyGeneration: 1,
request.WrappedVaultKey,
KeyLogHead: new byte[32],
request.GrantSignature,
request.GrantedAt);
Recount(teamId);
return Task.FromResult(vault);
@@ -539,16 +584,20 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
CancellationToken cancellationToken) =>
Task.FromResult(new VaultGrantsResponse(
vaultId,
KeyGeneration: 1,
KeyGeneration: Generation(vaultId),
RekeyRequired: false,
Grants:
[
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
new VaultGrantSummary(
entry.Key.UserId,
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
// One row per holder rather than per grant, as the real listing shows a member once
// and lets the generation say whether their key is current.
.. grants
.Where(entry => entry.Key.VaultId == vaultId)
.GroupBy(entry => entry.Key.UserId)
.Select(group => new VaultGrantSummary(
group.Key,
directory.Find(candidate => candidate.UserId == group.Key)?.Email,
null,
KeyGeneration: 1,
KeyGeneration: group.Max(entry => entry.Key.KeyGeneration),
VaultGrantState.Active,
UserId,
DateTimeOffset.UnixEpoch,
@@ -561,17 +610,88 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
grants[(vaultId, request.RecipientUserId)] = request;
grants[(vaultId, request.RecipientUserId, request.KeyGeneration)] = request;
return Task.CompletedTask;
}
/// <inheritdoc />
/// <remarks>
/// Models the one part of a rotation that is the server's: the generation advances, the caller's own
/// grant for it is recorded, and everything older is left standing so the vault's stored items go on
/// opening. What comes back is what the real endpoint returns — the vault at its new generation,
/// with the caller's earlier wraps attached.
/// </remarks>
public Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken)
{
if (!teamVaults.TryGetValue(vaultId, out var vault))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.NotFound, code: null, "No such vault.");
}
if (request.KeyGeneration != vault.KeyGeneration + 1)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidVaultGrant,
$"This vault is at key generation {vault.KeyGeneration}.");
}
grants[(vaultId, UserId, request.KeyGeneration)] = new IssueVaultGrantRequest(
UserId,
RecipientKeyFingerprint: new byte[32],
request.KeyGeneration,
request.WrappedVaultKey,
KeyLogHead: new byte[32],
request.GrantSignature,
request.GrantedAt);
var prior = grants
.Where(entry => entry.Key.VaultId == vaultId
&& entry.Key.UserId == UserId
&& entry.Key.KeyGeneration < request.KeyGeneration)
.OrderBy(entry => entry.Key.KeyGeneration)
.Select(entry => new VaultKeyWrap(entry.Key.KeyGeneration, entry.Value.WrappedVaultKey))
.ToList();
var rotated = vault with
{
KeyGeneration = request.KeyGeneration,
WrappedVaultKey = request.WrappedVaultKey,
RekeyRequired = false,
PriorKeyWraps = prior,
};
teamVaults[vaultId] = rotated;
return Task.FromResult(rotated);
}
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) =>
Task.FromResult(grants.Remove((vaultId, userId)));
CancellationToken cancellationToken)
{
var theirs = grants.Keys
.Where(key => key.VaultId == vaultId && key.UserId == userId)
.ToList();
foreach (var key in theirs)
{
grants.Remove(key);
}
return Task.FromResult(theirs.Count > 0);
}
/// <summary>The generation a vault currently stands at.</summary>
private uint Generation(Guid vaultId) =>
teamVaults.TryGetValue(vaultId, out var vault) ? vault.KeyGeneration : 1;
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
@@ -98,12 +98,12 @@ public sealed class TeamSharingTests : IAsyncLifetime
}
/// <remarks>
/// The whole point of a team, in one test. Note what the status line says after the add and before
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
/// than let a user believe the credential is already with their colleague.
/// The whole point of a team, in one test. Adding somebody wraps every team vault this machine can
/// open to them, so the status line names what they were given rather than what is still owed —
/// and the grant is on the server before the add has finished reporting.
/// </remarks>
[Fact]
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
public async Task AddingAMember_WrapsEveryTeamVaultThisMachineHoldsToThem()
{
await UnlockedAsync();
@@ -115,11 +115,121 @@ public sealed class TeamSharingTests : IAsyncLifetime
await CreateVaultAsync(teams, "Platform secrets");
teams.Vaults.Count.ShouldBe(1, teams.Status);
var vaultId = teams.Vaults[0].VaultId;
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.Count.ShouldBe(2, teams.Status);
teams.Status.ShouldContain("cannot read anything yet");
server.IssuedGrants.ShouldContainKey(
(vaultId, colleague),
"adding somebody to a team is what shares its vaults with them");
teams.Status.ShouldContain("Platform secrets");
}
/// <remarks>
/// <para>
/// The other half of the same idea. Removing somebody withdraws their grants — which only blocks
/// future reads — so the vault is rotated in the same breath and the new key goes to the people who
/// are left. From that moment nothing written is readable to the person who went.
/// </para>
/// <para>
/// The remaining member is given the earlier generation as well as the new one, which is what keeps
/// the vault's existing items readable to them: a rotation re-keys the vault, not its contents.
/// </para>
/// </remarks>
[Fact]
public async Task RemovingAMember_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
{
await UnlockedAsync();
var teams = shell.Teams;
var leaving = server.AddAccount("bob@example.com", "Bob Example");
var staying = server.AddAccount("carol@example.com", "Carol Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
var vaultId = teams.Vaults[0].VaultId;
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
{
teams.InviteEmail = address;
await teams.AddMemberCommand.ExecuteAsync(null);
}
teams.Members.Count.ShouldBe(3, teams.Status);
teams.SelectedMember = teams.Members.Single(member => member.UserId == leaving);
await teams.RemoveMemberCommand.ExecuteAsync(null);
teams.Status.ShouldContain("Rotated", customMessage: teams.Status);
teams.Status.ShouldContain("Platform secrets");
// Gone entirely, at every generation. A revocation that left the history behind would leave them
// able to read everything written before they went, from a copy of the ciphertext.
server.GenerationsGranted(vaultId, leaving).ShouldBeEmpty();
// And the member who stayed holds both: the new key for what comes next, the old one for what
// is already stored under it.
server.GenerationsGranted(vaultId, staying).ShouldBe([1u, 2u]);
}
/// <remarks>
/// Somebody added after a rotation is given every generation the sharing machine holds, not only the
/// newest. A vault shared as one key would open to a list of items that will not decrypt, which
/// reads as corruption rather than as the missing grant it is.
/// </remarks>
[Fact]
public async Task AddingAMemberToARotatedVault_HandsThemItsHistoryAsWell()
{
await UnlockedAsync();
var teams = shell.Teams;
var first = server.AddAccount("bob@example.com", "Bob Example");
var second = server.AddAccount("carol@example.com", "Carol Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
var vaultId = teams.Vaults[0].VaultId;
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
// with a history rather than one that has only ever had a single key.
teams.SelectedMember = teams.Members.Single(member => member.UserId == first);
await teams.RemoveMemberCommand.ExecuteAsync(null);
teams.InviteEmail = "carol@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], teams.Status);
}
/// <remarks>
/// The manual path still works and is still worth having: a vault whose key this machine did not
/// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
/// somebody who already holds the key is the same call, and the server replaces the row rather than
/// adding a second one.
/// </remarks>
[Fact]
public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
@@ -158,17 +268,25 @@ public sealed class TeamSharingTests : IAsyncLifetime
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
// Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
// that corrupted the log afterwards would be asserting about the second one only.
server.CorruptKeyLog = true;
teams.InviteEmail = "mallory@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
var vaultId = teams.Vaults[0].VaultId;
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
teams.Status.ShouldContain("Could not share");
teams.Status.ShouldContain("key log");
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
server.CorruptKeyLog = true;
await teams.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldBeEmpty();
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
teams.Status.ShouldContain("Did not share");
teams.Status.ShouldContain("key log");
}
@@ -303,9 +421,13 @@ public sealed class TeamSharingTests : IAsyncLifetime
teams.SelectedVault = null;
teams.SelectedVault = teams.Vaults[0];
var holder = teams.Grants.ShouldHaveSingleItem();
// Two, and the second one matters: the creator's own grant is recorded when the vault is made,
// so a list that showed only the people it was shared with would be describing a vault its
// owner cannot open.
teams.Grants.Count.ShouldBe(2, teams.Status);
var holder = teams.Grants.Single(row => row.UserId == colleague);
holder.UserId.ShouldBe(colleague);
holder.IsLive.ShouldBeTrue(teams.Status);
holder.State.ShouldBe("holds a key");
}
@@ -0,0 +1,238 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Holding more than one generation of a vault's key at once.
/// </summary>
/// <remarks>
/// A rotation does not re-encrypt what is already stored, so a rotated vault holds items sealed under
/// two or three different keys and every read has to choose the one the item names. These are the tests
/// that say so: the alternative — one key per vault — reads a rotated vault's whole history as corrupt,
/// which is a data-loss bug that looks exactly like a decryption failure.
/// </remarks>
public sealed class VaultKeyringTests : IDisposable
{
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid HostId = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f");
private readonly UserSecretBundle bundle =
UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));
/// <inheritdoc />
public void Dispose() => bundle.Dispose();
[Fact]
public void AVaultWithNoHistory_HoldsExactlyOneGeneration()
{
var (vault, _) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.GenerationsHeld(VaultId).ShouldBe([1u]);
keyring.CanRead(VaultId).ShouldBeTrue();
keyring.Unopened.ShouldBeEmpty();
}
[Fact]
public void ARotatedVault_OpensEveryGenerationItWasGranted()
{
var (vault, keys) = Rotated(currentGeneration: 3);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u, 3u]);
foreach (var (generation, key) in keys)
{
keyring.TryGetAt(VaultId, generation, out var held).ShouldBeTrue();
held.ToArray().ShouldBe(key);
}
}
/// <remarks>
/// Writes go under the newest key, always. Sealing a new item under a superseded one would produce
/// an item that nobody who joined after the rotation can read, and the author would have no way to
/// tell — their own keyring still holds the old key.
/// </remarks>
[Fact]
public void TheCurrentGeneration_IsTheNewestOneAndNotTheOldest()
{
var (vault, keys) = Rotated(currentGeneration: 3);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.TryGet(VaultId, out var current, out var generation).ShouldBeTrue();
generation.ShouldBe(3u);
current.ToArray().ShouldBe(keys[3u]);
}
/// <remarks>
/// The state a member is left in between somebody rotating a vault and somebody wrapping the new key
/// to them. They can still read what was there — their old grants stand — and they must not be able
/// to write, because anything they wrote would be sealed under a key the vault has moved past.
/// </remarks>
[Fact]
public void AMemberAwaitingTheNewKey_ReadsTheHistoryAndCannotWrite()
{
var (vault, keys) = Rotated(currentGeneration: 2);
var awaiting = vault with { WrappedVaultKey = null };
using var keyring = VaultKeyring.Open(bundle, [awaiting]);
keyring.CanRead(VaultId).ShouldBeFalse();
keyring.TryGet(VaultId, out _, out _).ShouldBeFalse();
keyring.Unopened.ShouldBe([VaultId]);
keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
first.ToArray().ShouldBe(keys[1u]);
}
/// <remarks>
/// What the rotating client itself does: it generates the next key, the server accepts it, and the
/// keyring takes it without losing the one the vault's existing items are sealed under.
/// </remarks>
[Fact]
public void AdoptingANewGeneration_KeepsTheOneBeforeIt()
{
var (vault, keys) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
var next = VaultKeys.Create();
keyring.Adopt(VaultId, next, keyGeneration: 2);
keyring.TryGet(VaultId, out _, out var generation).ShouldBeTrue();
generation.ShouldBe(2u);
keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u]);
keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
first.ToArray().ShouldBe(keys[1u]);
}
/// <remarks>
/// The whole point, at the layer that pays for it: an item written before a rotation still opens
/// after one. Sealed and opened through the real cipher, so the AAD's generation binding is
/// exercised rather than assumed.
/// </remarks>
[Fact]
public void AnItemSealedBeforeARotation_StillOpensAfterIt()
{
var (vault, _) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue();
var host = new HostSecret { Label = "web-01", Hostname = "web-01.example", Username = "ops" };
var payload = HostCipher.Seal(host, vaultKey.Span, HostId, generation, itemVersion: 1);
keyring.Adopt(VaultId, VaultKeys.Create(), keyGeneration: 2);
// Chosen by the payload's own generation, which is what every read path does.
keyring.TryGetAt(VaultId, payload.KeyGeneration, out var itemKey).ShouldBeTrue();
HostCipher.TryOpen(payload, itemKey.Span, HostId, itemVersion: 1)
.ShouldNotBeNull()
.Host.Label.ShouldBe("web-01");
// And the current key does not open it, which is why holding only that one would be a loss.
keyring.TryGet(VaultId, out var newest, out _).ShouldBeTrue();
HostCipher.TryOpen(payload, newest.Span, HostId, itemVersion: 1).ShouldBeNull();
}
/// <remarks>
/// What another client rotating the vault looks like from here: the key this session holds is
/// suddenly the previous generation. It goes on opening what it wrote, and it must stop being the
/// one new items are sealed under — an item written under a superseded key is readable to its
/// author and to nobody else, with nothing to show that anything went wrong.
/// </remarks>
[Fact]
public void AVaultRotatedElsewhere_StopsBeingWritableAndStaysReadable()
{
var (vault, keys) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.CanRead(VaultId).ShouldBeTrue();
// What RefreshVaultsAsync does when the server reports a generation this session has no grant
// for: the admit fails, and the vault is marked unreadable.
keyring.MarkUnreadable(VaultId);
keyring.CanRead(VaultId).ShouldBeFalse();
keyring.TryGet(VaultId, out _, out _).ShouldBeFalse();
keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
first.ToArray().ShouldBe(keys[1u]);
}
/// <remarks>
/// A wrap that will not open is one unusable grant, not a broken vault. Skipping it leaves the
/// generations that did open readable; refusing them all would take the whole vault down over one
/// bad row.
/// </remarks>
[Fact]
public void AnUnopenableHistoricWrap_IsSkippedRatherThanFatal()
{
var (vault, _) = Rotated(currentGeneration: 2);
var corrupted = vault with
{
PriorKeyWraps = [new VaultKeyWrap(1, new byte[110])],
};
using var keyring = VaultKeyring.Open(bundle, [corrupted]);
keyring.CanRead(VaultId).ShouldBeTrue();
keyring.GenerationsHeld(VaultId).ShouldBe([2u]);
keyring.TryGetAt(VaultId, 1, out _).ShouldBeFalse();
}
/// <summary>
/// A vault at <paramref name="currentGeneration"/>, with a distinct key wrapped for every generation
/// up to it.
/// </summary>
private (StoredVault Vault, Dictionary<uint, byte[]> Keys) Rotated(uint currentGeneration)
{
var keys = new Dictionary<uint, byte[]>();
var prior = new List<VaultKeyWrap>();
byte[]? current = null;
for (var generation = 1u; generation <= currentGeneration; generation++)
{
var key = VaultKeys.Create();
var wrapped = VaultKeys.WrapTo(key, bundle.EncryptionPublicKey, VaultId, generation);
keys[generation] = key;
if (generation == currentGeneration)
{
current = wrapped;
}
else
{
prior.Add(new VaultKeyWrap(generation, wrapped));
}
}
var vault = new StoredVault(
VaultId,
"Platform secrets",
IsPersonal: false,
TeamId: Guid.CreateVersion7(),
currentGeneration,
Permissions: 31,
current,
RekeyRequired: false,
prior);
return (vault, keys);
}
}