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:
@@ -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);
|
||||
|
||||
@@ -261,6 +261,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);
|
||||
@@ -581,16 +626,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,
|
||||
@@ -603,17 +652,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)
|
||||
|
||||
@@ -217,6 +217,70 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
Queued().ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The phone's way in, and it has to obey the same rules as every other: a document chosen in the system
|
||||
/// picker is copied into the cache and the copy is queued, which is an upload with one extra property —
|
||||
/// that this application made the file and will delete it again. Everything about *what may be queued*
|
||||
/// is the same, and this says so rather than leaving a second path free to drift.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void StagedUploads_QueueUnderTheSameRulesAsAnyOther()
|
||||
{
|
||||
Connected();
|
||||
|
||||
var folder = Path.Combine(directory, "a-folder");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
transfers.QueueStagedUploads([File("picked.txt"), folder]);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("1 file");
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The phone's way out. The delivery itself — copying the finished file into the document the save
|
||||
/// picker made — needs a transfer that actually runs and a picker to have made something, so it is
|
||||
/// checked by hand in <c>docs/manual-checks.md</c> phase 14. What is worth pinning here is the pair of
|
||||
/// refusals in front of it, because both would otherwise be discovered as an empty file sitting in
|
||||
/// somebody's Downloads: the picker creates the destination the moment it is dismissed, so anything
|
||||
/// this method turns away after that point has already cost a visible artefact.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ADeliveredDownload_QueuesTheFileAndRefusesADirectory()
|
||||
{
|
||||
var delivered = 0;
|
||||
|
||||
Connected();
|
||||
|
||||
transfers.QueueDeliveredDownload(
|
||||
RemoteFile("one.log"),
|
||||
Path.Combine(directory, "staged", "one.log"),
|
||||
_ =>
|
||||
{
|
||||
delivered++;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("one.log");
|
||||
|
||||
// A directory has nothing to fetch, and the message is the same one every other path on this screen
|
||||
// gives for the same mistake.
|
||||
transfers.QueueDeliveredDownload(
|
||||
RemoteDirectory("logs"),
|
||||
Path.Combine(directory, "staged", "logs"),
|
||||
_ => Task.CompletedTask);
|
||||
|
||||
Queued().Count.ShouldBe(1);
|
||||
transfers.Status.ShouldContain("Only files");
|
||||
|
||||
// Nothing is delivered by queueing. The callback runs when the bytes are there and not before.
|
||||
delivered.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
||||
/// <remarks>
|
||||
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
||||
|
||||
@@ -104,12 +104,42 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole point of a shared vault, 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 shared vault, in one test. Adding somebody wraps the vault 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 CreatingAVaultAndSharingIt_WrapsTheKeyToTheOtherMember()
|
||||
public async Task AddingSomebody_WrapsTheVaultToThemStraightAway()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
|
||||
server.IssuedGrants.ShouldContainKey(
|
||||
(vaultId, colleague),
|
||||
"adding somebody to a vault is what shares it with them");
|
||||
|
||||
vaults.Status.ShouldContain("Platform secrets");
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
@@ -121,9 +151,6 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
vaults.Status.ShouldContain("cannot read anything yet");
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||
@@ -137,6 +164,91 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <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 RemovingSomebody_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var leaving = server.AddAccount("bob@example.com", "Bob Example");
|
||||
var staying = server.AddAccount("carol@example.com", "Carol Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
|
||||
{
|
||||
vaults.InviteEmail = address;
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
vaults.Members.Count.ShouldBe(3, vaults.Status);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == leaving);
|
||||
|
||||
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Status.ShouldContain("Rotated", customMessage: vaults.Status);
|
||||
vaults.Status.ShouldContain("Platform secrets");
|
||||
|
||||
// The last act of a rotation is moving what is already stored onto the new key. Proven by the
|
||||
// bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
|
||||
// and says which of the two guarantees the user has ended up with.
|
||||
vaults.Status.ShouldContain("re-sealed under the new key", customMessage: vaults.Status);
|
||||
|
||||
// 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 AddingSomebodyToARotatedVault_HandsThemItsHistoryAsWell()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var first = server.AddAccount("bob@example.com", "Bob Example");
|
||||
var second = server.AddAccount("carol@example.com", "Carol Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.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.
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
|
||||
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.InviteEmail = "carol@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The test this whole design exists for. A server that wants to read a shared vault only has to
|
||||
@@ -159,16 +271,24 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "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;
|
||||
|
||||
vaults.InviteEmail = "mallory@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
server.CorruptKeyLog = true;
|
||||
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
||||
vaults.Status.ShouldContain("Could not share");
|
||||
vaults.Status.ShouldContain("key log");
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
server.IssuedGrants.ShouldBeEmpty();
|
||||
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
||||
vaults.Status.ShouldContain("Did not share");
|
||||
vaults.Status.ShouldContain("key log");
|
||||
}
|
||||
@@ -409,8 +529,8 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The screen's answer to "who can actually open this". Asserted after a share rather than before,
|
||||
/// because an empty list proves nothing about whether the call was made.
|
||||
/// The screen's answer to "who can actually open this". Asserted after somebody has been added
|
||||
/// rather than before, because an empty list proves nothing about whether the call was made.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
|
||||
@@ -425,13 +545,13 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
// Two, and the creator is the other: their own self-grant is what makes a vault they just made
|
||||
// readable at all, so a list that left it out would show the one person who can certainly open
|
||||
// this vault as somebody who cannot.
|
||||
vaults.Grants.Count.ShouldBe(2, vaults.Status);
|
||||
|
||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||
var holder = vaults.Grants.Single(row => row.UserId == colleague);
|
||||
|
||||
var holder = vaults.Grants.ShouldHaveSingleItem();
|
||||
|
||||
holder.UserId.ShouldBe(colleague);
|
||||
holder.IsLive.ShouldBeTrue(vaults.Status);
|
||||
holder.State.ShouldBe("holds a key");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Crypto;
|
||||
@@ -16,6 +17,9 @@ internal sealed class SyncDevice : IDisposable
|
||||
private readonly ClientCacheFactory factory;
|
||||
private readonly LocalCacheProtector protector;
|
||||
|
||||
private readonly FakeVaultServer server;
|
||||
private readonly SyncOptions options;
|
||||
|
||||
private SyncDevice(
|
||||
string name,
|
||||
ClientCacheFactory factory,
|
||||
@@ -27,6 +31,8 @@ internal sealed class SyncDevice : IDisposable
|
||||
Name = name;
|
||||
this.factory = factory;
|
||||
this.protector = protector;
|
||||
this.server = server;
|
||||
this.options = options;
|
||||
Keyring = keyring;
|
||||
|
||||
Items = new ItemStore(factory, protector);
|
||||
@@ -96,6 +102,15 @@ internal sealed class SyncDevice : IDisposable
|
||||
internal Task<SyncReport> SyncAsync() =>
|
||||
Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
/// <summary>Moves everything this machine can see onto the vault's current key.</summary>
|
||||
/// <remarks>
|
||||
/// Built per call rather than held, as the engine is: it carries no state between passes, and one
|
||||
/// per call is what the session does.
|
||||
/// </remarks>
|
||||
internal Task<ResealReport> ResealAsync() =>
|
||||
new VaultResealer(server, Items, Outbox, Keyring, TimeProvider.System, options)
|
||||
.ResealAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
internal Task<ItemListing<HostSecret>> ListAsync() =>
|
||||
Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -274,6 +289,41 @@ internal sealed class SyncHarness : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates the vault: a new key, taken by both machines, and a server that says so.
|
||||
/// </summary>
|
||||
/// <returns>The key the vault has just moved off, so a test can prove it no longer opens anything.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Stands in for the server call the real rotation makes. What matters here is the state it leaves —
|
||||
/// a vault whose current generation is one past everything stored in it — and the grant round trip
|
||||
/// that produces that state is <c>DodoSSH.Api.Tests</c>'s subject, not this suite's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each keyring gets its own copy of the bytes, because a keyring owns what it is handed and zeroes
|
||||
/// it on disposal; sharing one array would leave the second machine holding a zeroed key at the end
|
||||
/// of a test and produce failures that look like a decryption bug.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal byte[] Rotate()
|
||||
{
|
||||
var generation = Server.KeyGeneration + 1;
|
||||
|
||||
First.Keyring.TryGetAt(VaultId, Server.KeyGeneration, out var previous).ShouldBeTrue();
|
||||
|
||||
var superseded = previous.ToArray();
|
||||
var key = VaultKeys.Create();
|
||||
|
||||
First.Keyring.Adopt(VaultId, [.. key], generation);
|
||||
Second.Keyring.Adopt(VaultId, [.. key], generation);
|
||||
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
|
||||
Server.KeyGeneration = generation;
|
||||
|
||||
return superseded;
|
||||
}
|
||||
|
||||
/// <summary>Brings both devices up to date, twice, so the result is a settled state.</summary>
|
||||
/// <remarks>
|
||||
/// Twice because one pass per device is not enough for a change made on one to be merged on the
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Moving a rotated vault's stored items onto its current key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A rotation re-keys the vault and not its contents, which is what makes it cheap and safe (ADR 0010)
|
||||
/// and what leaves this pass to be run. The claim it has to earn is narrow and testable: after it, the
|
||||
/// key the vault has moved off opens nothing. Every test here that says "resealed" also checks that,
|
||||
/// because a pass that re-wrapped everything under the same key would report exactly the same numbers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The other half is the push path. A change queued before a rotation is sealed under the old key, and
|
||||
/// sending it as it stands would put a brand-new item into the vault under the key the person who was
|
||||
/// just removed still holds — the one hole a pass over stored items cannot see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultResealTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ARotatedVault_MovesItsStoredItemsOntoTheNewKey()
|
||||
{
|
||||
using var harness = await SyncHarness.CreateAsync();
|
||||
|
||||
var web = await harness.First.CreateAsync(SyncHarness.Host("web-01"));
|
||||
var db = await harness.First.CreateAsync(SyncHarness.Host("db-01"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var superseded = harness.Rotate();
|
||||
|
||||
var report = await harness.First.ResealAsync();
|
||||
|
||||
report.Resealed.ShouldBe(2);
|
||||
report.Complete.ShouldBeTrue();
|
||||
report.KeyGeneration.ShouldBe(2u);
|
||||
|
||||
foreach (var entityId in (Guid[])[web, db])
|
||||
{
|
||||
var row = harness.Server.Find(entityId).ShouldNotBeNull();
|
||||
|
||||
row.Payload.KeyGeneration.ShouldBe(2u);
|
||||
|
||||
// The point of the whole pass: the key somebody left with opens nothing here any more.
|
||||
HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull();
|
||||
}
|
||||
|
||||
// And the vault still reads as itself — the plaintext was carried across, not re-encoded.
|
||||
var hosts = await harness.First.HostsSortedAsync();
|
||||
|
||||
hosts.Select(host => host.Label).ShouldBe(["db-01", "web-01"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The pass is run after every rotation and can be run again at any time, so "nothing left to do"
|
||||
/// has to be cheap and silent rather than a second round of writes. A pass that re-sealed on every
|
||||
/// call would churn the vault's version numbers and hand every other client a pull per item.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASecondPass_FindsNothingLeftToDo()
|
||||
{
|
||||
using var harness = await SyncHarness.CreateAsync();
|
||||
|
||||
await harness.First.CreateAsync(SyncHarness.Host("web-01"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
harness.Rotate();
|
||||
|
||||
(await harness.First.ResealAsync()).Resealed.ShouldBe(1);
|
||||
|
||||
var again = await harness.First.ResealAsync();
|
||||
|
||||
again.Resealed.ShouldBe(0);
|
||||
again.Complete.ShouldBeTrue();
|
||||
harness.Server.PushCount.ShouldBe(2, "an empty pass must not send a batch at all");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An item with an edit waiting to go is left alone by the pass and re-sealed by the push instead.
|
||||
/// Doing it here as well would overwrite the user's queued work with the version the server holds,
|
||||
/// which is the one thing a re-keying pass must never do.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AQueuedEdit_IsLeftToThePushPathAndStillLandsUnderTheNewKey()
|
||||
{
|
||||
using var harness = await SyncHarness.CreateAsync();
|
||||
|
||||
var entityId = await harness.First.CreateAsync(SyncHarness.Host("web-01"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
// Queued while the old key was current, and not yet pushed.
|
||||
await harness.First.UpdateAsync(entityId, SyncHarness.Host("web-01", notes: "moved rack"));
|
||||
|
||||
var superseded = harness.Rotate();
|
||||
|
||||
var report = await harness.First.ResealAsync();
|
||||
|
||||
report.Deferred.ShouldBe(1);
|
||||
report.Resealed.ShouldBe(0);
|
||||
report.Complete.ShouldBeTrue("a queued change is not something this pass has left undone");
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var row = harness.Server.Find(entityId).ShouldNotBeNull();
|
||||
|
||||
row.Payload.KeyGeneration.ShouldBe(2u);
|
||||
HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull();
|
||||
|
||||
// The edit itself survived the re-sealing, which is the half that would be easy to lose.
|
||||
var seen = await harness.Second.SyncAsync();
|
||||
|
||||
seen.Pulled.ShouldBeGreaterThan(0);
|
||||
(await harness.Second.FindAsync(entityId)).Secret.Notes.ShouldBe("moved rack");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Somebody else writing an item mid-pass is not a failure and not a merge — there is nothing to
|
||||
/// merge, since this pass changes no content. It is counted, left where it is, and picked up by the
|
||||
/// next pass against the version they left behind. That is the whole of the resumability claim.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnItemWrittenElsewhereMeanwhile_IsCountedAndPickedUpNextTime()
|
||||
{
|
||||
using var harness = await SyncHarness.CreateAsync();
|
||||
|
||||
var entityId = await harness.First.CreateAsync(SyncHarness.Host("web-01"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var superseded = harness.Rotate();
|
||||
|
||||
// A third machine that has not heard about the rotation yet: it writes version 2 under the key
|
||||
// it still believes is current. That is the item this pass has to find and move, and sealing it
|
||||
// by hand is the only way to produce one — every client in this harness now holds the new key.
|
||||
var held = harness.Server.Find(entityId).ShouldNotBeNull();
|
||||
|
||||
harness.Server.ExternalUpsert(
|
||||
entityId,
|
||||
HostCipher.Seal(
|
||||
SyncHarness.Host("web-01", notes: "renamed elsewhere"),
|
||||
superseded,
|
||||
entityId,
|
||||
keyGeneration: 1,
|
||||
itemVersion: held.Version + 1),
|
||||
held.Fields);
|
||||
|
||||
var contested = await harness.First.ResealAsync();
|
||||
|
||||
contested.Contested.ShouldBe(1);
|
||||
contested.Resealed.ShouldBe(0);
|
||||
contested.Complete.ShouldBeFalse();
|
||||
|
||||
// Read what they wrote, then run the pass again: nothing to recover, nothing to decide.
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var second = await harness.First.ResealAsync();
|
||||
|
||||
second.Resealed.ShouldBe(1);
|
||||
second.Complete.ShouldBeTrue();
|
||||
|
||||
var row = harness.Server.Find(entityId).ShouldNotBeNull();
|
||||
|
||||
row.Payload.KeyGeneration.ShouldBe(2u);
|
||||
HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Every synced type, not the one the tests happen to use most. The pass is written over the item
|
||||
/// store rather than over the repositories precisely so that a type added later is covered without
|
||||
/// anybody remembering to add it — and this is the test that would notice if it stopped being true.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EveryKindOfItem_MovesOntoTheNewKey()
|
||||
{
|
||||
using var harness = await SyncHarness.CreateAsync();
|
||||
|
||||
await harness.First.CreateAsync(SyncHarness.Host("web-01"));
|
||||
await harness.First.CreateKeyAsync(SyncHarness.Key("deploy"));
|
||||
await harness.First.CreateCredentialAsync(SyncHarness.Credential("registry"));
|
||||
await harness.First.CreateKnownHostAsync(SyncHarness.KnownHost("db.internal"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
harness.Rotate();
|
||||
|
||||
var report = await harness.First.ResealAsync();
|
||||
|
||||
report.Resealed.ShouldBe(4);
|
||||
report.Complete.ShouldBeTrue();
|
||||
|
||||
// Read back through the repositories, so this asserts the items are usable and not merely
|
||||
// rewritten: a pass that produced ciphertext nobody could open would pass every count above.
|
||||
(await harness.First.ListAsync()).Unreadable.ShouldBe(0);
|
||||
(await harness.First.ListKeysAsync()).Unreadable.ShouldBe(0);
|
||||
(await harness.First.ListCredentialsAsync()).Unreadable.ShouldBe(0);
|
||||
(await harness.First.ListKnownHostsAsync()).Unreadable.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A member who has been rotated past and not yet re-wrapped holds the history and no current key.
|
||||
/// They must not attempt this: there is nothing to seal under, and the honest answer is a report of
|
||||
/// zero rather than an exception on a background pass nobody asked for.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AMachineWithNoCurrentKey_DoesNothingRatherThanFailing()
|
||||
{
|
||||
using var harness = await SyncHarness.CreateAsync();
|
||||
|
||||
await harness.First.CreateAsync(SyncHarness.Host("web-01"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
// What RefreshVaultsAsync does when the server reports a generation this machine has no grant
|
||||
// for: the vault is marked unreadable and the write target goes with it.
|
||||
harness.First.Keyring.MarkUnreadable(SyncHarness.VaultId);
|
||||
|
||||
var report = await harness.First.ResealAsync();
|
||||
|
||||
report.KeyGeneration.ShouldBe(0u);
|
||||
report.Resealed.ShouldBe(0);
|
||||
harness.Server.PushCount.ShouldBe(1, "nothing was sent");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user