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