Public Access
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted rather than removed — removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
532 lines
18 KiB
C#
532 lines
18 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 = [];
|
|
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
|
|
private readonly List<KeyLogRecord> keyLog = [];
|
|
private readonly List<DirectoryEntry> directory = [];
|
|
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
|
|
|
|
/// <inheritdoc />
|
|
public ITeamApi Teams => this;
|
|
|
|
/// <inheritdoc />
|
|
public IDirectoryApi Directory => this;
|
|
|
|
/// <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>
|
|
/// 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>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));
|
|
|
|
return userId;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
|
Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamSummary> CreateTeamAsync(
|
|
CreateTeamRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
|
|
members.TryGetValue(teamId, out var list) ? [.. list] : []);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
|
Guid teamId,
|
|
AddTeamMemberRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var entry = directory.Find(candidate => candidate.UserId == request.UserId)
|
|
?? throw new DodoSshApiException(
|
|
System.Net.HttpStatusCode.BadRequest,
|
|
ProblemCodes.InvalidTeam,
|
|
"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(
|
|
entry.UserId,
|
|
entry.Email,
|
|
entry.DisplayName,
|
|
request.Role,
|
|
TeamMemberStatus.Active,
|
|
IsEnrolled: true,
|
|
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.
|
|
foreach (var vaultId in teamVaults.Values
|
|
.Where(vault => vault.TeamId == teamId)
|
|
.Select(vault => vault.VaultId))
|
|
{
|
|
grants.Remove((vaultId, userId));
|
|
}
|
|
|
|
Recount(teamId);
|
|
|
|
return Task.FromResult(removed);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<VaultSummary> CreateTeamVaultAsync(
|
|
Guid teamId,
|
|
CreateTeamVaultRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var vault = new VaultSummary(
|
|
request.VaultId,
|
|
request.Name,
|
|
IsPersonal: false,
|
|
TeamId: teamId,
|
|
KeyGeneration: 1,
|
|
Permissions: 31,
|
|
request.WrappedVaultKey,
|
|
RekeyRequired: false);
|
|
|
|
teamVaults[vault.VaultId] = vault;
|
|
|
|
Recount(teamId);
|
|
|
|
return Task.FromResult(vault);
|
|
}
|
|
|
|
/// <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: 1,
|
|
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,
|
|
null,
|
|
KeyGeneration: 1,
|
|
VaultGrantState.Active,
|
|
UserId,
|
|
DateTimeOffset.UnixEpoch,
|
|
null)),
|
|
]));
|
|
|
|
/// <inheritdoc />
|
|
public Task IssueVaultGrantAsync(
|
|
Guid vaultId,
|
|
IssueVaultGrantRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
grants[(vaultId, request.RecipientUserId)] = request;
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> RevokeVaultGrantAsync(
|
|
Guid vaultId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult(grants.Remove((vaultId, userId)));
|
|
|
|
/// <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),
|
|
};
|
|
}
|
|
}
|