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 = []; private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = []; private readonly List keyLog = []; private readonly List directory = []; /// public ITeamApi Teams => this; /// public IDirectoryApi Directory => this; /// public IVaultGrantApi Grants => this; /// Grants this fake has been asked to record, for a test to assert on. internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants; /// /// 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; } /// 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)); return userId; } /// public Task> ListTeamsAsync(CancellationToken cancellationToken) => Task.FromResult>([.. teams]); /// public Task 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), ]; return Task.FromResult(team); } /// public Task> ListTeamMembersAsync( Guid teamId, CancellationToken cancellationToken) => Task.FromResult>( members.TryGetValue(teamId, out var list) ? [.. list] : []); /// public Task 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."); var member = new TeamMemberSummary( entry.UserId, entry.Email, entry.DisplayName, request.Role, TeamMemberStatus.Active, IsEnrolled: true, DateTimeOffset.UnixEpoch); members[teamId] = [.. members.GetValueOrDefault(teamId, []), member]; Recount(teamId); return Task.FromResult(member); } /// 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. 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); } /// public Task 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); } /// 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: 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)), ])); /// public Task IssueVaultGrantAsync( Guid vaultId, IssueVaultGrantRequest request, CancellationToken cancellationToken) { grants[(vaultId, request.RecipientUserId)] = request; return Task.CompletedTask; } /// public Task RevokeVaultGrantAsync( Guid vaultId, Guid userId, CancellationToken cancellationToken) => Task.FromResult(grants.Remove((vaultId, userId))); /// 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), }; } }