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; } /// 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); } /// 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), }; } }