using DodoSSH.Client.Api;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
///
/// The team, directory and grant half of the fake server.
///
///
///
/// The key log is real. Entries are chained with 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.
///
///
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
/// tested against the real one in DodoSSH.Api.Tests; what the shell needs from here is that a team
/// can be created, a member added, and a vault key wrapped and recorded.
///
///
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
{
private readonly List teams = [];
private readonly Dictionary> members = [];
private readonly Dictionary teamVaults = [];
///
/// 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.
///
private readonly Dictionary<(Guid VaultId, Guid UserId, uint KeyGeneration), IssueVaultGrantRequest>
grants = [];
private readonly List keyLog = [];
private readonly List directory = [];
private readonly Dictionary> invitations = [];
///
/// Every account on this fake server, enrolled or not.
///
///
/// Kept apart from 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.
///
private readonly List<(Guid UserId, string Email, string DisplayName)> accounts = [];
///
public ITeamApi Teams => this;
///
public IDirectoryApi Directory => this;
///
public IVaultGrantApi Grants => this;
///
/// Grants this fake has been asked to record, newest generation per recipient.
///
///
/// Flattened to one entry per recipient because that is the question most tests are asking — can
/// this person open the vault as it stands. is for the ones asking
/// whether they were also given its history.
///
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);
/// Which generations of one vault's key a recipient has been wrapped, oldest first.
internal IReadOnlyList GenerationsGranted(Guid vaultId, Guid userId) =>
[
.. grants.Keys
.Where(key => key.VaultId == vaultId && key.UserId == userId)
.Select(key => key.KeyGeneration)
.Order(),
];
///
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
///
///
/// 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.
///
internal bool CorruptKeyLog { get; set; }
/// Slugs this fake refuses, as the real server refuses one already in use.
///
/// 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.
///
internal HashSet TakenSlugs { get; } = new(StringComparer.Ordinal);
/// How many vault creates to refuse before answering normally.
///
/// 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.
///
internal int VaultCreateFailures { get; set; }
/// How many team creates have been asked for, for a test to assert on.
internal int TeamCreates { get; private set; }
/// Registers another account, as though they had signed in and enrolled here.
/// Their user id.
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;
}
///
/// Registers an account that has signed in here but has not enrolled a key.
///
///
/// 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.
///
/// Their user id.
internal Guid AddUnenrolledAccount(string email, string displayName)
{
var userId = Guid.CreateVersion7();
accounts.Add((userId, email, displayName));
return userId;
}
///
/// Puts a vault somebody else made, and shared with this account, on the server.
///
///
///
/// The other half of sharing, which no test can otherwise reach: every vault in this suite is one
/// this client made, and a vault this client made is one it already holds the key to. What arrives
/// on the machine somebody shared with is different — a vault that appears in /me
/// out of nowhere, with a key wrapped to this account by a client this one never spoke to.
///
///
/// The wrap is real, made against the encryption key this account enrolled, so the keyring opens it
/// exactly as it opens one from a real colleague. A helper that filled the field with bytes would
/// let a vault appear in the list and never prove it could be read.
///
///
/// What the vault is called.
/// The account that made it, from .
/// The vault's id.
internal Guid ShareVaultWithMe(string name, Guid sharedBy)
{
if (statement is not { } enrolled)
{
throw new InvalidOperationException(
"Nothing can be wrapped to this account until it has enrolled a key.");
}
var teamId = Guid.CreateVersion7();
var vaultId = Guid.CreateVersion7();
var vaultKey = VaultKeys.Create();
var wrapped = VaultKeys.WrapTo(vaultKey, enrolled.EncryptionPublicKey, vaultId, 1);
teams.Add(new TeamSummary(
teamId,
name,
name.ToLowerInvariant().Replace(' ', '-'),
Description: null,
// A member rather than an owner: somebody else made this and this account was added to it,
// which is what decides whether the screen offers to rename or remove it.
TeamMemberRole.Member,
MemberCount: 2,
VaultCount: 1,
DateTimeOffset.UnixEpoch));
var sharer = accounts.Find(account => account.UserId == sharedBy);
members[teamId] =
[
Member(sharedBy, sharer.Email, sharer.DisplayName, TeamMemberRole.Owner),
Member(UserId, "alice@example.com", "Alice Example", TeamMemberRole.Member),
];
teamVaults[vaultId] = new VaultSummary(
vaultId,
name,
IsPersonal: false,
TeamId: teamId,
KeyGeneration: 1,
Permissions: 31,
wrapped,
RekeyRequired: false);
return vaultId;
}
/// One active, enrolled member, which is the only kind this helper makes.
private static TeamMemberSummary Member(
Guid userId,
string email,
string displayName,
TeamMemberRole role) =>
new(
userId,
email,
displayName,
role,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch);
///
public Task> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult>([.. teams]);
///
public Task 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);
}
///
public Task 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]);
}
///
///
/// 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.
///
public Task 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);
}
///
///
/// 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.
///
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;
}
///
/// When set, a member read waits on it before answering.
///
///
/// 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.
///
internal TaskCompletionSource? MemberReadGate { get; set; }
/// How many member reads have been asked for, for a test to assert on.
internal int MemberReads { get; private set; }
///
public async Task> 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] : [];
}
/// Adds a member, resolved by id when the caller has one and by address otherwise.
///
/// Resolved against rather than , 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. IsEnrolled is reported from whether the
/// directory has them rather than hardcoded, so a member row can say it holds no key.
///
public Task 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);
}
///
public Task> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult>(
invitations.TryGetValue(teamId, out var list) ? [.. list] : []);
///
public Task 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);
}
///
public Task 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);
}
///
public Task 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]);
}
///
public Task 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);
}
///
public Task 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);
}
///
///
/// 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.
///
public Task 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);
}
///
///
/// Every grant to the vault goes with it, as the real service withdraws them in the same write, and the
/// team behind it is archived when it owns nothing else — the second half of what the endpoint does.
/// A fake that kept either would let a test assert a deletion that had left the vault readable, or
/// leave the vaults screen listing a membership list with no vault under it.
///
public Task DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken)
{
if (personalVault is { } personal && personal.VaultId == vaultId)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidVaultGrant,
"A personal vault cannot be deleted.");
}
if (!teamVaults.Remove(vaultId, out var vault))
{
return Task.FromResult(false);
}
foreach (var key in grants.Keys.Where(key => key.VaultId == vaultId).ToList())
{
grants.Remove(key);
}
if (vault.TeamId is { } teamId && !teamVaults.Values.Any(other => other.TeamId == teamId))
{
teams.RemoveAll(team => team.TeamId == teamId);
members.Remove(teamId);
invitations.Remove(teamId);
}
return Task.FromResult(true);
}
///
public Task> LookupByEmailAsync(
string email,
CancellationToken cancellationToken) =>
Task.FromResult>(
[
.. directory.Where(entry =>
string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
]);
///
public Task LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
Task.FromResult(directory.Find(entry => entry.UserId == userId));
///
public Task 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));
}
///
public Task 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)),
]));
///
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
grants[(vaultId, request.RecipientUserId, request.KeyGeneration)] = request;
return Task.CompletedTask;
}
///
///
/// 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.
///
public Task 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);
}
///
public Task 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);
}
/// The generation a vault currently stands at.
private uint Generation(Guid vaultId) =>
teamVaults.TryGetValue(vaultId, out var vault) ? vault.KeyGeneration : 1;
/// Publishes the enrolling account's own key, in the directory and the key log.
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));
}
/// Appends a key log entry, chained as the real log chains it.
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),
};
}
}