- structure mutations returned the full structure to any member who could call them (moving an item after sending a message exposed channels the member has no access to) - moving items now requires write access to the destination channel; new folders require an existing channel - account avatars are only readable by the owner or accounts sharing a space
153 lines
11 KiB
C#
153 lines
11 KiB
C#
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<WebDropException>(() => 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<WebDropException>(() => 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<WebDropException>(() => 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<WebDropException>(() => 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnUnfinishedUploadCanBeDeletedWithoutASignedEvent()
|
|
{
|
|
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) with { RequireSignedContent = true };
|
|
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
|
var member = space.Member(memberEndpoint) with { AccountName = "member", RequireSignedContent = true }; env.Service.Join(space.Id, member);
|
|
var file = TestEnv.NewId();
|
|
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = file.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
|
// 只传了第 0 帧,没有末帧:上传被打断
|
|
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(file, 0, FrameFlags.Gated, 16), null);
|
|
Assert.Throws<WebDropException>(() => env.Service.DeleteItem(space.Id, space.Member(TestEnv.Endpoint()) with { AccountName = "stranger", RequireSignedContent = true }, file));
|
|
env.Service.DeleteItem(space.Id, owner, file);
|
|
Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event);
|
|
}
|
|
|
|
[Fact]
|
|
public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites()
|
|
{
|
|
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
|
var defaultId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
|
Bootstrap(env, space, owner, defaultId);
|
|
var secret = env.Service.CreateWorkspaceChannelResult(space.Id, owner, "secret", null).Channel.Id;
|
|
Bootstrap(env, space, owner, secret);
|
|
var hidden = TestEnv.NewId().ToBase64Url();
|
|
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hidden, ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "k" });
|
|
|
|
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
|
var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
|
|
{ ChannelId = plan.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 member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
|
|
|
// 结构里没有没授权的频道;密钥包、对象登记里也没有它的任何东西
|
|
Assert.Equal(new[] { defaultId }, env.Service.GetWorkspace(space.Id, member).Channels.Select(c => c.Id));
|
|
var crypto = env.Service.ReadCrypto(space.Id, member);
|
|
Assert.DoesNotContain(crypto.Envelopes, e => e.ChannelId == secret);
|
|
Assert.DoesNotContain(crypto.Objects, o => o.ChannelId == secret);
|
|
Assert.DoesNotContain(crypto.Access, a => a.ChannelId == secret);
|
|
// 也不能往没授权的频道登记对象
|
|
Assert.Throws<WebDropException>(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "x" }));
|
|
}
|
|
|
|
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" }],
|
|
});
|
|
}
|
|
}
|