Public Access
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.
807 lines
29 KiB
C#
807 lines
29 KiB
C#
using DodoSSH.Client.Api;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// The team, directory and grant half of the fake server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The key log is real.</b> Entries are chained with <see cref="KeyLogChain.ComputeEntryHash"/> exactly
|
|
/// as the server chains them, because the client refuses to wrap a vault key to a directory answer that
|
|
/// does not appear in a log whose chain verifies — so a fake that returned a plausible-looking log would
|
|
/// make every sharing test pass against a check that was never exercised. It also means a test can break
|
|
/// the chain deliberately and watch the client refuse.
|
|
/// </para>
|
|
/// <para>
|
|
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
|
|
/// tested against the real one in <c>DodoSSH.Api.Tests</c>; what the shell needs from here is that a team
|
|
/// can be created, a member added, and a vault key wrapped and recorded.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
|
|
{
|
|
private readonly List<TeamSummary> teams = [];
|
|
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
|
|
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
|
|
/// <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 = [];
|
|
|
|
/// <summary>
|
|
/// Every account on this fake server, enrolled or not.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Kept apart from <see cref="directory"/> because the real server keeps them apart, and the gap
|
|
/// between the two is where a real bug lived: the directory omits anybody who has not published a
|
|
/// key, so a fake that had only one list could not tell an account that does not exist from one
|
|
/// that exists and has not enrolled — which is exactly the distinction the add path turns on.
|
|
/// </remarks>
|
|
private readonly List<(Guid UserId, string Email, string DisplayName)> accounts = [];
|
|
|
|
/// <inheritdoc />
|
|
public ITeamApi Teams => this;
|
|
|
|
/// <inheritdoc />
|
|
public IDirectoryApi Directory => this;
|
|
|
|
/// <inheritdoc />
|
|
public IVaultGrantApi Grants => this;
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The switch a test flips to prove the client refuses rather than shares. A fake with no way to be
|
|
/// wrong can only ever confirm the happy path.
|
|
/// </remarks>
|
|
internal bool CorruptKeyLog { get; set; }
|
|
|
|
/// <summary>Slugs this fake refuses, as the real server refuses one already in use.</summary>
|
|
/// <remarks>
|
|
/// A vault's slug is derived from its name rather than typed, so a collision is something the client
|
|
/// has to get out of on its own — and a fake that accepted every slug could not tell whether it does.
|
|
/// </remarks>
|
|
internal HashSet<string> TakenSlugs { get; } = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>How many vault creates to refuse before answering normally.</summary>
|
|
/// <remarks>
|
|
/// Creating a vault of its own is two calls, and the failure worth testing is the one between them:
|
|
/// the team is made and the vault is not. One refusal is enough to leave the client in that state and
|
|
/// let the test press CREATE again.
|
|
/// </remarks>
|
|
internal int VaultCreateFailures { get; set; }
|
|
|
|
/// <summary>How many team creates have been asked for, for a test to assert on.</summary>
|
|
internal int TeamCreates { get; private set; }
|
|
|
|
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
|
|
/// <returns>Their user id.</returns>
|
|
internal Guid AddAccount(string email, string displayName)
|
|
{
|
|
var userId = Guid.CreateVersion7();
|
|
|
|
// Real keys rather than filler: the client recomputes the fingerprint over both halves and refuses
|
|
// an entry whose fingerprint does not match, so random bytes would fail for the wrong reason.
|
|
using var bundle = UserSecretBundle.Create(DateTimeOffset.UnixEpoch);
|
|
|
|
var sequence = AppendKeyLog(
|
|
userId, bundle.EncryptionPublicKey, bundle.SigningPublicKey, new byte[64]);
|
|
|
|
directory.Add(new DirectoryEntry(
|
|
userId,
|
|
email,
|
|
displayName,
|
|
bundle.EncryptionPublicKey,
|
|
bundle.SigningPublicKey,
|
|
DshCrypto.ComputeFingerprint(bundle.EncryptionPublicKey, bundle.SigningPublicKey),
|
|
KeyGeneration: 1,
|
|
KeyLogSequence: sequence));
|
|
|
|
accounts.Add((userId, email, displayName));
|
|
|
|
return userId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers an account that has signed in here but has not enrolled a key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Normal rather than exotic: an account exists from its owner's first authenticated request and
|
|
/// stays keyless until they choose a passphrase on their own machine. It is absent from the
|
|
/// directory throughout, because a directory entry exists to be wrapped to and this one has nothing
|
|
/// to wrap. It can still be made a member — membership grants nothing readable.
|
|
/// </remarks>
|
|
/// <returns>Their user id.</returns>
|
|
internal Guid AddUnenrolledAccount(string email, string displayName)
|
|
{
|
|
var userId = Guid.CreateVersion7();
|
|
|
|
accounts.Add((userId, email, displayName));
|
|
|
|
return userId;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
|
Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamSummary> CreateTeamAsync(
|
|
CreateTeamRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
TeamCreates++;
|
|
|
|
// Idempotent on the client-chosen id, as the real one is. That is the whole of how a create whose
|
|
// second half failed is retried without leaving a second team behind, so a fake that made one
|
|
// anyway would let the bug through.
|
|
if (teams.Find(row => row.TeamId == request.TeamId) is { } existing)
|
|
{
|
|
return Task.FromResult(existing);
|
|
}
|
|
|
|
if (TakenSlugs.Contains(request.Slug))
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.Conflict,
|
|
ProblemCodes.TeamSlugTaken,
|
|
$"The slug '{request.Slug}' is already in use.");
|
|
}
|
|
|
|
var team = new TeamSummary(
|
|
request.TeamId,
|
|
request.Name,
|
|
request.Slug,
|
|
request.Description,
|
|
TeamMemberRole.Owner,
|
|
MemberCount: 1,
|
|
VaultCount: 0,
|
|
DateTimeOffset.UnixEpoch);
|
|
|
|
teams.Add(team);
|
|
|
|
members[team.TeamId] =
|
|
[
|
|
new TeamMemberSummary(
|
|
UserId,
|
|
"alice@example.com",
|
|
"Alice Example",
|
|
TeamMemberRole.Owner,
|
|
TeamMemberStatus.Active,
|
|
IsEnrolled: true,
|
|
DateTimeOffset.UnixEpoch,
|
|
DateTimeOffset.UnixEpoch),
|
|
];
|
|
|
|
return Task.FromResult(team);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamSummary> UpdateTeamAsync(
|
|
Guid teamId,
|
|
UpdateTeamRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var index = teams.FindIndex(team => team.TeamId == teamId);
|
|
|
|
if (index < 0)
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such team.");
|
|
}
|
|
|
|
// The slug is deliberately not touched, matching the server: a rename changes the display
|
|
// name only. A fake that also moved the slug would let a test assert behaviour nothing has.
|
|
teams[index] = teams[index] with
|
|
{
|
|
Name = request.Name,
|
|
Description = request.Description,
|
|
};
|
|
|
|
return Task.FromResult(teams[index]);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// The vault refusal is reproduced rather than skipped, unlike the other server rules here. It is
|
|
/// the one whose consequence the shell has to render — a status line explaining why nothing
|
|
/// happened — so a fake that always succeeded would leave that path untested.
|
|
/// </remarks>
|
|
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken)
|
|
{
|
|
var index = teams.FindIndex(team => team.TeamId == teamId);
|
|
|
|
if (index < 0)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
if (teamVaults.Values.Any(vault => vault.TeamId == teamId))
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.Conflict,
|
|
ProblemCodes.TeamNotEmpty,
|
|
"This team still owns vaults, and archiving it would take them away from everybody "
|
|
+ "holding a key — including you.");
|
|
}
|
|
|
|
teams.RemoveAt(index);
|
|
members.Remove(teamId);
|
|
invitations.Remove(teamId);
|
|
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Both rows move, because a fake that only promoted the recipient would let a test pass while
|
|
/// the team was owned twice — which is the exact failure the real service uses a transaction to
|
|
/// make impossible.
|
|
/// </remarks>
|
|
public Task TransferTeamOwnershipAsync(
|
|
Guid teamId,
|
|
TransferTeamOwnershipRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var list = members.GetValueOrDefault(teamId, []);
|
|
var incoming = list.FindIndex(member => member.UserId == request.UserId);
|
|
|
|
if (incoming < 0)
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidTeam,
|
|
"That account is not an active member of this team.");
|
|
}
|
|
|
|
var outgoing = list.FindIndex(member => member.Role == TeamMemberRole.Owner);
|
|
|
|
list[incoming] = list[incoming] with { Role = TeamMemberRole.Owner };
|
|
|
|
if (outgoing >= 0)
|
|
{
|
|
list[outgoing] = list[outgoing] with { Role = TeamMemberRole.Admin };
|
|
}
|
|
|
|
var index = teams.FindIndex(team => team.TeamId == teamId);
|
|
|
|
if (index >= 0)
|
|
{
|
|
teams[index] = teams[index] with { Role = TeamMemberRole.Admin };
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// When set, a member read waits on it before answering.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every other method here answers from memory and therefore completes before its caller's await
|
|
/// ever suspends, which hides anything the screen only gets wrong while a read is in flight — the
|
|
/// state a real server leaves it in for the length of a round trip. A test that wants that state
|
|
/// holds the gate.
|
|
/// </remarks>
|
|
internal TaskCompletionSource? MemberReadGate { get; set; }
|
|
|
|
/// <summary>How many member reads have been asked for, for a test to assert on.</summary>
|
|
internal int MemberReads { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
MemberReads++;
|
|
|
|
if (MemberReadGate is { } gate)
|
|
{
|
|
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return members.TryGetValue(teamId, out var list) ? [.. list] : [];
|
|
}
|
|
|
|
/// <summary>Adds a member, resolved by id when the caller has one and by address otherwise.</summary>
|
|
/// <remarks>
|
|
/// Resolved against <see cref="accounts"/> rather than <see cref="directory"/>, which is the whole
|
|
/// point of the two being separate here: an account with no published key is missing from the
|
|
/// directory and is still perfectly addable. <c>IsEnrolled</c> is reported from whether the
|
|
/// directory has them rather than hardcoded, so a member row can say it holds no key.
|
|
/// </remarks>
|
|
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
|
Guid teamId,
|
|
AddTeamMemberRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var account = request.UserId != Guid.Empty
|
|
? accounts.Find(candidate => candidate.UserId == request.UserId)
|
|
: accounts.Find(candidate => string.Equals(
|
|
candidate.Email, request.Email, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (account.UserId == Guid.Empty)
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.NotFound,
|
|
ProblemCodes.NoSuchAccount,
|
|
"No such account on this server.");
|
|
}
|
|
|
|
// LastActiveAt is left null: this account has been added, not seen. The owner's row carries a
|
|
// real one, so both branches of the interface's "last active / never" split are exercised.
|
|
var member = new TeamMemberSummary(
|
|
account.UserId,
|
|
account.Email,
|
|
account.DisplayName,
|
|
request.Role,
|
|
TeamMemberStatus.Active,
|
|
IsEnrolled: directory.Exists(entry => entry.UserId == account.UserId),
|
|
DateTimeOffset.UnixEpoch,
|
|
LastActiveAt: null);
|
|
|
|
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
|
|
|
|
Recount(teamId);
|
|
|
|
return Task.FromResult(member);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
|
|
invitations.TryGetValue(teamId, out var list) ? [.. list] : []);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
|
|
Guid teamId,
|
|
CreateTeamInvitationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var list = invitations.GetValueOrDefault(teamId, []);
|
|
|
|
if (list.Exists(invitation =>
|
|
invitation.State == TeamInvitationState.Pending
|
|
&& string.Equals(invitation.Email, request.Email, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidTeamInvitation,
|
|
"There is already an invitation to that address for this team.");
|
|
}
|
|
|
|
var invited = new TeamInvitationSummary(
|
|
request.InvitationId,
|
|
request.Email,
|
|
request.Role,
|
|
TeamInvitationState.Pending,
|
|
UserId,
|
|
DateTimeOffset.UnixEpoch,
|
|
DateTimeOffset.UnixEpoch.AddDays(14),
|
|
AcceptedAt: null);
|
|
|
|
invitations[teamId] = [.. list, invited];
|
|
|
|
return Task.FromResult(invited);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> RevokeTeamInvitationAsync(
|
|
Guid teamId,
|
|
Guid invitationId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var list = invitations.GetValueOrDefault(teamId, []);
|
|
var index = list.FindIndex(invitation =>
|
|
invitation.InvitationId == invitationId
|
|
&& invitation.State == TeamInvitationState.Pending);
|
|
|
|
if (index < 0)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
// Kept and marked rather than removed, as the server keeps it: the screen has to be able to
|
|
// say an invitation was withdrawn rather than letting it vanish and read as never sent.
|
|
list[index] = list[index] with { State = TeamInvitationState.Revoked };
|
|
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
|
Guid teamId,
|
|
Guid userId,
|
|
ChangeTeamMemberRoleRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var list = members.GetValueOrDefault(teamId, []);
|
|
var index = list.FindIndex(member => member.UserId == userId);
|
|
|
|
if (index < 0)
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidTeam,
|
|
"That account is not an active member of this team.");
|
|
}
|
|
|
|
list[index] = list[index] with { Role = request.Role };
|
|
|
|
return Task.FromResult(list[index]);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> RemoveTeamMemberAsync(
|
|
Guid teamId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var list = members.GetValueOrDefault(teamId, []);
|
|
var removed = list.RemoveAll(member => member.UserId == userId) > 0;
|
|
|
|
// 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.
|
|
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(key);
|
|
}
|
|
|
|
Recount(teamId);
|
|
|
|
return Task.FromResult(removed);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<VaultSummary> CreateTeamVaultAsync(
|
|
Guid teamId,
|
|
CreateTeamVaultRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (VaultCreateFailures > 0)
|
|
{
|
|
VaultCreateFailures--;
|
|
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.ServiceUnavailable,
|
|
code: null,
|
|
"The server is not answering.");
|
|
}
|
|
|
|
var vault = new VaultSummary(
|
|
request.VaultId,
|
|
request.Name,
|
|
IsPersonal: false,
|
|
TeamId: teamId,
|
|
KeyGeneration: 1,
|
|
Permissions: 31,
|
|
request.WrappedVaultKey,
|
|
RekeyRequired: false);
|
|
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// The owning team is renamed with the vault when it owns nothing else, exactly as the real service
|
|
/// does it — a fake that moved only the vault would let a test pass while the two names disagreed,
|
|
/// which is the state the server code goes out of its way to avoid.
|
|
/// </remarks>
|
|
public Task<VaultSummary> RenameVaultAsync(
|
|
Guid vaultId,
|
|
UpdateVaultRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (personalVault is { } personal && personal.VaultId == vaultId)
|
|
{
|
|
personalVault = personal with { Name = request.Name };
|
|
|
|
return Task.FromResult(personalVault);
|
|
}
|
|
|
|
if (!teamVaults.TryGetValue(vaultId, out var vault))
|
|
{
|
|
throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such vault.");
|
|
}
|
|
|
|
var renamed = vault with { Name = request.Name };
|
|
|
|
teamVaults[vaultId] = renamed;
|
|
|
|
if (renamed.TeamId is { } teamId
|
|
&& !teamVaults.Values.Any(other => other.TeamId == teamId && other.VaultId != vaultId))
|
|
{
|
|
var index = teams.FindIndex(team => team.TeamId == teamId);
|
|
|
|
if (index >= 0)
|
|
{
|
|
teams[index] = teams[index] with { Name = request.Name };
|
|
}
|
|
}
|
|
|
|
return Task.FromResult(renamed);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
|
string email,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult<IReadOnlyList<DirectoryEntry>>(
|
|
[
|
|
.. directory.Where(entry =>
|
|
string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
|
|
]);
|
|
|
|
/// <inheritdoc />
|
|
public Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
|
|
Task.FromResult(directory.Find(entry => entry.UserId == userId));
|
|
|
|
/// <inheritdoc />
|
|
public Task<KeyLogPage> ReadKeyLogAsync(
|
|
long afterSequence,
|
|
int? limit,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var page = keyLog.Where(entry => entry.Sequence > afterSequence).ToList();
|
|
|
|
if (CorruptKeyLog && page.Count > 0)
|
|
{
|
|
// One byte, in the field the chain is built from. Enough to break the link and nothing else,
|
|
// which is what a tampered log would look like.
|
|
var last = page[^1];
|
|
page[^1] = last with { EncryptionPublicKey = [.. last.EncryptionPublicKey.Reverse()] };
|
|
}
|
|
|
|
var head = keyLog.Count == 0
|
|
? KeyLogChain.CreateGenesisPreviousHash()
|
|
: keyLog[^1].Hash;
|
|
|
|
return Task.FromResult(new KeyLogPage(page, keyLog.Count, head, HasMore: false));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
|
|
Guid vaultId,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult(new VaultGrantsResponse(
|
|
vaultId,
|
|
KeyGeneration: Generation(vaultId),
|
|
RekeyRequired: false,
|
|
Grants:
|
|
[
|
|
// 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: group.Max(entry => entry.Key.KeyGeneration),
|
|
VaultGrantState.Active,
|
|
UserId,
|
|
DateTimeOffset.UnixEpoch,
|
|
null)),
|
|
]));
|
|
|
|
/// <inheritdoc />
|
|
public Task IssueVaultGrantAsync(
|
|
Guid vaultId,
|
|
IssueVaultGrantRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
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)
|
|
{
|
|
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)
|
|
{
|
|
if (directory.Exists(entry => entry.UserId == UserId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sequence = AppendKeyLog(
|
|
UserId, statement.EncryptionPublicKey, statement.SigningPublicKey, statementSignature);
|
|
|
|
directory.Add(new DirectoryEntry(
|
|
UserId,
|
|
"alice@example.com",
|
|
"Alice Example",
|
|
statement.EncryptionPublicKey,
|
|
statement.SigningPublicKey,
|
|
DshCrypto.ComputeFingerprint(statement.EncryptionPublicKey, statement.SigningPublicKey),
|
|
statement.KeyGeneration,
|
|
sequence));
|
|
}
|
|
|
|
/// <summary>Appends a key log entry, chained as the real log chains it.</summary>
|
|
private long AppendKeyLog(
|
|
Guid userId,
|
|
byte[] encryptionPublicKey,
|
|
byte[] signingPublicKey,
|
|
byte[] statementSignature)
|
|
{
|
|
var previous = keyLog.Count == 0
|
|
? KeyLogChain.CreateGenesisPreviousHash()
|
|
: keyLog[^1].Hash;
|
|
|
|
var createdAt = KeyLogChain.TruncateTimestamp(DateTimeOffset.UnixEpoch);
|
|
var sequence = keyLog.Count + 1;
|
|
|
|
var hash = KeyLogChain.ComputeEntryHash(
|
|
previous, userId, 1, encryptionPublicKey, signingPublicKey, statementSignature, createdAt);
|
|
|
|
keyLog.Add(new KeyLogRecord(
|
|
sequence,
|
|
userId,
|
|
Generation: 1,
|
|
encryptionPublicKey,
|
|
signingPublicKey,
|
|
statementSignature,
|
|
previous,
|
|
hash,
|
|
createdAt));
|
|
|
|
return sequence;
|
|
}
|
|
|
|
private void Recount(Guid teamId)
|
|
{
|
|
var index = teams.FindIndex(team => team.TeamId == teamId);
|
|
|
|
if (index < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
teams[index] = teams[index] with
|
|
{
|
|
MemberCount = members.GetValueOrDefault(teamId, []).Count,
|
|
VaultCount = teamVaults.Values.Count(vault => vault.TeamId == teamId),
|
|
};
|
|
}
|
|
}
|