using WebDrop.Core; namespace WebDrop.Core.Tests; public sealed class ChannelCryptoTests { [Fact] public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess() { using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep); var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; Bootstrap(env, space, owner, channelId); Assert.Throws(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, Username = "owner", Payload = "forbidden-rotation" }] })); Assert.Throws(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "owner", Payload = "owner-key" }, new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "member", Payload = "unauthorized-grant" }] })); } [Fact] public void ServerPlansAndAtomicallyCommitsNoHistoryRotationAndDefaultAccess() { using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep); var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; Bootstrap(env, space, owner, channelId); env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false); var plan = env.Service.InvitationPlan(space.Id, owner, "member"); Assert.Equal(channelId, plan.ChannelId); Assert.Equal(new[] { CryptoDomain.Chat, CryptoDomain.Files }, plan.Batches.Select(b => b.Domain).Order()); Assert.All(plan.Batches, batch => { Assert.Equal(2, batch.Epoch); Assert.Equal(new[] { "member", "owner" }, batch.Recipients.Order(StringComparer.Ordinal)); }); var incomplete = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope { ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).Skip(1).ToList(); Assert.Throws(() => env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", incomplete)); Assert.Empty(env.Service.Invitations(space.Id, owner)); var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope { ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList(); env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages); var updatedChannel = env.Service.GetWorkspace(space.Id, owner).Channels.Single(); Assert.Equal(2, updatedChannel.ChatEpoch); Assert.Equal(2, updatedChannel.FilesEpoch); var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member); var received = env.Service.ReadCrypto(space.Id, member); Assert.Equal(2, received.Envelopes.Count); Assert.All(received.Envelopes, e => Assert.Equal(2, e.Epoch)); Assert.All(received.Access, a => { Assert.True(a.ChatRead); Assert.True(a.ChatWrite); Assert.True(a.FilesRead); Assert.True(a.FilesUpload); }); Assert.Throws(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "old" })); var objectId = TestEnv.NewId().ToBase64Url(); Assert.Equal("member", env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = objectId, ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, KeyEnvelope = "current" }).AuthorAccount); } [Fact] public void HistoryEnabledPlanGrantsExistingEpochsOnlyToTheNewRecipient() { using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep); var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; Bootstrap(env, space, owner, channelId); var plan = env.Service.InvitationPlan(space.Id, owner, "member"); Assert.Equal(2, plan.Batches.Count); Assert.All(plan.Batches, batch => { Assert.Equal(1, batch.Epoch); Assert.Equal(new[] { "member" }, batch.Recipients); }); var packages = plan.Batches.Select(batch => new DomainEnvelope { ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = "member", Payload = $"wrapped-{batch.Domain}" }).ToList(); env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages); var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member); var state = env.Service.ReadCrypto(space.Id, member); Assert.Equal(2, state.Envelopes.Count); Assert.All(state.Envelopes, e => Assert.Equal("member", e.Username)); Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().ChatEpoch); Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().FilesEpoch); } [Fact] public async Task UnencryptedWorkspaceStillUsesServerHistoryCutoffsAndAccessRules() { using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint(); var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint); var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; var oldFile = TestEnv.NewId(); env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" }); await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null); env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null); env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false); var plan = env.Service.InvitationPlan(space.Id, owner, "member"); Assert.Empty(plan.Batches); env.Service.InviteUser(space.Id, owner, "member", "account-grant", []); var member = space.Member(memberEndpoint) with { AccountName = "member" }; env.Service.Join(space.Id, member); Assert.Empty(env.Service.GetWorkspace(space.Id, member).Locations); Assert.Empty((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records); var newMessage = TestEnv.NewId(); env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = newMessage.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" }); await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(newMessage, 0, FrameFlags.Final, 16), null); Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records); } private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId) { foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = domain, Epoch = 1, Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = domain, Epoch = 1, Username = "owner", Payload = $"owner-{domain.ToString().ToLowerInvariant()}-key" }], }); } }