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:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user