Files
WebDrop/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs
T
justme c858bd2a09 feat(server)!: require a logged-in account for every channel request
Drop unauthenticated guests from channels: the allow_guests setting, the
/guests endpoints and the guest seat table are gone, and requests without an
account session get 401 account_required. Share links are unaffected.
Contract version is now 8.0.0.
2026-10-16 06:46:15 +08:00

416 lines
31 KiB
C#

using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using WebDrop.Core;
using WebDrop.Storage;
using WebDrop.Api;
using Microsoft.Data.Sqlite;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<ServerFixture>
{
private sealed record TestSigner(string Username, string DeviceId, byte[] PrivateKey);
private readonly Dictionary<string, TestSigner> _signers = new(StringComparer.Ordinal);
[Fact]
public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation()
{
var ch = await server.CreateAsync(settings: new { name = "Key package space", max_members = 8 });
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await Register(username);
var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(ownerPackage)).StatusCode);
using (var ownerList = server.Req(HttpMethod.Get, "/api/v1/accounts/channels", ch.Owner))
{
var rows = await ReadJsonAsync(await server.Http.SendAsync(ownerList));
Assert.Contains(rows.EnumerateArray(), row => row.GetProperty("channel_id").GetString() == ch.Id && row.GetProperty("role").GetString() == "owner");
}
using (var invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray());
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, token))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode);
using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
{
var rows = await ReadJsonAsync(await server.Http.SendAsync(delivered));
Assert.Equal("opaque-member-package", rows[0].GetProperty("payload").GetString()); Assert.Equal("member", rows[0].GetProperty("role").GetString());
}
using (var stealOwner = Request(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, token, new { payload = "replaced" }))
await AssertErrorAsync(await server.Http.SendAsync(stealOwner), 401, "account_required");
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
using (var removed = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(removed))).EnumerateArray());
using (var anonymous = Request(HttpMethod.Get, "/api/v1/accounts/channels", member))
await AssertErrorAsync(await server.Http.SendAsync(anonymous), 401, "account_required");
}
[Fact]
public async Task DeviceSigningKeyRequiresAndStoresAnAccountRootCertificate()
{
const string username = "signing_device";
var verifier = RandomNumberGenerator.GetBytes(32); var salt = RandomNumberGenerator.GetBytes(16);
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var invitationPoint = invitation.ExportParameters(false).Q;
var rootPoint = root.ExportParameters(false).Q;
string invitePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitationPoint.X!), y = B64(invitationPoint.Y!) });
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
var vault = new byte[28];
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.True(accounts.Register(username, salt, verifier, vault, invitePublic, rootPublic));
var deviceId = Guid.NewGuid().ToString("D");
var token = accounts.Authenticate(username, verifier, deviceId, "Desktop")!;
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var devicePoint = device.ExportParameters(false).Q;
byte[] x = devicePoint.X!; byte[] y = devicePoint.Y!;
var certificate = SignatureCodec.DeviceCertificatePayload(username, deviceId, x, y);
var signature = root.SignData(SignatureCodec.BuildMessage("device.certificate", certificate), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
var requestBody = new { device_id = deviceId, public_key = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(x), y = B64(y) }), signature = Convert.ToBase64String(signature) };
using var register = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key") { Content = JsonBody(requestBody) };
register.Headers.Add("X-WD-Account", token);
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(register)).StatusCode);
signature[0] ^= 1;
using var forged = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
{ Content = JsonBody(requestBody with { signature = Convert.ToBase64String(signature) }) };
forged.Headers.Add("X-WD-Account", token);
await AssertErrorAsync(await server.Http.SendAsync(forged), 403, "forbidden");
using var publicKeyRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/users/{username}/key");
publicKeyRequest.Headers.Add("X-WD-Account", token);
var identity = await ReadJsonAsync(await server.Http.SendAsync(publicKeyRequest));
Assert.Equal(rootPublic, identity.GetProperty("signing_public_key").GetString());
byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32);
var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId();
using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token,
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", max_members = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode);
string channelId;
using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token))
using (var response = await server.Http.SendAsync(structure))
channelId = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
string itemId = NewId();
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{spaceId}/crypto/objects", owner, token,
new { itemId, channelId, domain = "chat", epoch = 0, keyEnvelope = "" }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
byte[] frame = Frame(itemId, 0, 1, 16);
Codec.TryDecodeExact(spaceId, 16, out var spaceBytes); Codec.TryDecodeExact(itemId, 16, out var itemBytes);
byte[] eventId = RandomNumberGenerator.GetBytes(16); byte[] manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
byte[] eventPayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, eventId,
"chat.create", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
byte[] eventSignature = device.SignData(SignatureCodec.BuildMessage("content.event", eventPayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using (var unsigned = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
{
unsigned.Content = Bytes(frame);
await AssertErrorAsync(await server.Http.SendAsync(unsigned), 403, "forbidden");
}
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
{
upload.Content = Bytes(frame);
upload.Headers.Add("X-WD-Event-Id", B64(eventId)); upload.Headers.Add("X-WD-Event-Kind", "chat.create");
upload.Headers.Add("X-WD-Event-Channel", channelId); upload.Headers.Add("X-WD-Event-Domain", "chat");
upload.Headers.Add("X-WD-Event-Epoch", "0"); upload.Headers.Add("X-WD-Event-Frames", "1");
upload.Headers.Add("X-WD-Event-Manifest", B64(manifest)); upload.Headers.Add("X-WD-Event-Signature", B64(eventSignature));
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
}
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
{
var objects = (await ReadJsonAsync(cryptoResponse)).GetProperty("objects");
var signedObject = Assert.Single(objects.EnumerateArray());
Assert.Equal("chat.create", signedObject.GetProperty("event").GetProperty("kind").GetString());
Assert.Equal(B64(manifest), signedObject.GetProperty("event").GetProperty("manifestHash").GetString());
}
using (var unsignedDelete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
await AssertErrorAsync(await server.Http.SendAsync(unsignedDelete), 403, "forbidden");
byte[] deleteEventId = RandomNumberGenerator.GetBytes(16);
byte[] deletePayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, deleteEventId,
"chat.delete", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
byte[] deleteSignature = device.SignData(SignatureCodec.BuildMessage("content.event", deletePayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using (var delete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
{
delete.Headers.Add("X-WD-Event-Id", B64(deleteEventId)); delete.Headers.Add("X-WD-Event-Kind", "chat.delete");
delete.Headers.Add("X-WD-Event-Channel", channelId); delete.Headers.Add("X-WD-Event-Domain", "chat");
delete.Headers.Add("X-WD-Event-Epoch", "0"); delete.Headers.Add("X-WD-Event-Frames", "1");
delete.Headers.Add("X-WD-Event-Manifest", B64(manifest)); delete.Headers.Add("X-WD-Event-Signature", B64(deleteSignature));
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(delete)).StatusCode);
}
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
{
var signedObject = Assert.Single((await ReadJsonAsync(cryptoResponse)).GetProperty("objects").EnumerateArray());
Assert.Equal("chat.delete", signedObject.GetProperty("deleteEvent").GetProperty("kind").GetString());
}
}
[Fact]
public async Task RevokingOneAccountDevicePreservesOtherDevicesAndChannelMembership()
{
var username = "devices_" + Guid.NewGuid().ToString("N")[..8];
var verifier = RandomNumberGenerator.GetBytes(32); var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); var point = key.ExportParameters(false).Q;
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })));
var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString();
var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!;
var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!;
var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open" });
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) };
foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) })
{
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, token);
using var response = await server.Http.SendAsync(joined);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("members_used").GetInt32());
}
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner))
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("members_used").GetInt32());
var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]);
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", third, unrelated))
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "members_full");
Assert.Equal(2, accounts.Devices(username, firstId).Count);
Assert.Single(accounts.Devices(username, firstId), d => d.Current);
// 账号成员只凭账号会话鉴权,不再绑定到某个终端:同一账号的任一设备会话都能读取
using (var anyDevice = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(anyDevice)).StatusCode);
using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _));
Assert.Single(accounts.Devices(username, firstId));
using (var read = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", first, firstToken))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(read)).StatusCode);
using (var packages = Request(HttpMethod.Get, "/api/v1/accounts/channels", first, firstToken))
Assert.Single((await ReadJsonAsync(await server.Http.SendAsync(packages))).EnumerateArray());
}
[Fact]
public void PartialSettingsPreserveAdmissionDefaults()
{
var request = JsonSerializer.Deserialize("{\"settings\":{\"max_members\":8}}", ApiJson.Default.CreateChannelRequest)!;
Assert.Equal(JoinPolicy.Open, request.Settings!.JoinPolicy);
request.Settings.Validate(new ServerLimits());
var share = JsonSerializer.Deserialize("{\"settings\":{\"max_visitors\":1}}", ApiJson.Default.CreateShareRequest)!;
Assert.Equal(86400, share.Settings!.TtlSeconds); share.Settings.Validate(new ServerLimits());
}
[Fact]
public async Task NullCredentialsAreRejectedWith400InsteadOf500()
{
foreach (var path in new[] { "/api/v1/accounts/salt", "/api/v1/accounts/session" })
{
using var response = await server.Http.PostAsync(path, JsonBody(new { username = (string?)null, verifier = (string?)null }));
await AssertErrorAsync(response, 400, "bad_request");
}
}
[Fact]
public async Task AccountMembersNeedNoTerminalKey()
{
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite" });
var username = "member_" + Guid.NewGuid().ToString("N")[..8];
var account = await Register(username);
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
var who = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var join = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(join)).StatusCode);
// 登录账号不带终端密钥也能鉴权
using (var log = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", who, account))
{
log.Headers.Remove("X-WD-Endpoint");
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(log)).StatusCode);
}
// 成员清单里有它,设备数不小于在线设备数
using var members = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner);
var list = await ReadJsonAsync(await server.Http.SendAsync(members));
var mine = list.EnumerateArray().Single(m => m.GetProperty("username").GetString() == username);
Assert.True(mine.GetProperty("devices").GetInt32() >= mine.GetProperty("online_devices").GetInt32());
}
private async Task<string> Register(string username)
{
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
var verifier = RandomNumberGenerator.GetBytes(32);
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var invitePoint = invitation.ExportParameters(false).Q; var rootPoint = root.ExportParameters(false).Q; var devicePoint = device.ExportParameters(false).Q;
string invitationPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitePoint.X!), y = B64(invitePoint.Y!) });
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
string devicePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(devicePoint.X!), y = B64(devicePoint.Y!) });
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], invitationPublic, rootPublic));
string deviceId = Guid.NewGuid().ToString("D"); var token = accounts.Authenticate(username, verifier, deviceId, "Test device")!;
var certPayload = SignatureCodec.DeviceCertificatePayload(username, deviceId, devicePoint.X!, devicePoint.Y!);
var cert = root.SignData(SignatureCodec.BuildMessage("device.certificate", certPayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
{ Content = JsonBody(new { device_id = deviceId, public_key = devicePublic, signature = Convert.ToBase64String(cert) }) };
request.Headers.Add("X-WD-Account", token);
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(request)).StatusCode);
_signers[token] = new TestSigner(username, deviceId, device.ExportPkcs8PrivateKey());
return token;
}
private HttpRequestMessage Request(HttpMethod method, string path, Who who, string? account = null, object? body = null)
{
var request = server.Req(method, path, who, body is null ? null : JsonBody(body));
request.Headers.Remove("X-WD-Account");
if (account is not null) request.Headers.Add("X-WD-Account", account);
return request;
}
private Dictionary<string, string> SignedFrameHeaders(string token, string spaceId, string channelId, string itemId,
CryptoDomain domain, int epoch, string kind, byte[] frame)
{
var signer = _signers[token];
Assert.True(Codec.TryDecodeExact(spaceId, 16, out var space));
Assert.True(Codec.TryDecodeExact(itemId, 16, out var item));
const int frameCount = 1;
var manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
var eventId = RandomNumberGenerator.GetBytes(16);
var payload = SignatureCodec.ContentEventPayload(space, channelId, signer.Username, signer.DeviceId, eventId,
kind, domain, epoch, item, frameCount, manifest, []);
using var device = ECDsa.Create(); device.ImportPkcs8PrivateKey(signer.PrivateKey, out _);
var signature = device.SignData(SignatureCodec.BuildMessage("content.event", payload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return new Dictionary<string, string>
{
["X-WD-Event-Id"] = B64(eventId), ["X-WD-Event-Kind"] = kind,
["X-WD-Event-Channel"] = channelId, ["X-WD-Event-Domain"] = domain == CryptoDomain.Chat ? "chat" : "files",
["X-WD-Event-Epoch"] = epoch.ToString(), ["X-WD-Event-Frames"] = frameCount.ToString(),
["X-WD-Event-Manifest"] = B64(manifest), ["X-WD-Event-Signature"] = B64(signature),
};
}
[Fact]
public async Task CreationAndOwnerActionsRequireAccountIdentity()
{
var ch = await server.CreateAsync();
foreach (var path in new[] { "/api/v1/channels", "/api/v1/shares" })
{
using var request = Request(HttpMethod.Post, path, ch.Owner, body: new { });
await AssertErrorAsync(await server.Http.SendAsync(request), 401, "account_required");
}
using var infoResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner));
Assert.Equal("fixture_owner", (await ReadJsonAsync(infoResponse)).GetProperty("creator_account").GetString());
using var membersResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner));
var members = await ReadJsonAsync(membersResponse);
Assert.Equal("owner", members[0].GetProperty("role").GetString());
var unrelated = await Register("unrelated_" + Guid.NewGuid().ToString("N")[..8]);
using var copied = Request(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, unrelated, new { name = "stolen" });
await AssertErrorAsync(await server.Http.SendAsync(copied), 401, "account_required");
}
[Theory]
[InlineData(ChannelMode.Enc)]
[InlineData(ChannelMode.Raw)]
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
{
var ch = await server.CreateAsync(mode, new { name = "Password space", max_members = 8 });
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
var joiner = await Register("joiner_" + Guid.NewGuid().ToString("N")[..8]);
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner))
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, new { password = "wrong" }))
await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid");
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, new { password = "channel-secret" }))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode);
// 没有账号会话的请求一律被拒绝,哪怕链接令牌和密码都对
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "channel-secret" }))
await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required");
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite" })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
var account = await Register(username);
var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required");
using (var invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
// 只有链接令牌与终端密钥、没有账号会话的请求没有任何身份
using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 401, "account_required");
var writer = member with { Role = "write", Token = ch.WriteToken };
string channelId;
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
using (var structureResponse = await server.Http.SendAsync(structureRequest))
channelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
var itemId = NewId();
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/crypto/objects", writer, account,
new { itemId, channelId, domain = "files", epoch = 1, keyEnvelope = "opaque-object-key" }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
byte[] frame = Frame(itemId, 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16);
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", writer, account))
{
upload.Content = Bytes(frame);
foreach (var (name, value) in SignedFrameHeaders(account, ch.Id, channelId, itemId, CryptoDomain.Files, 1, "file.publish", frame))
upload.Headers.Add(name, value);
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
}
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden");
}
[Fact]
public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional()
{
var username = "digest_" + Guid.NewGuid().ToString("N")[..8];
var token = await Register(username);
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.Null(accounts.Authenticate(username, new byte[32]));
using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService<WebDropOptions>().DataDir, "v2", "webdrop.db") }.ToString());
database.Open();
using var query = database.CreateCommand(); query.CommandText = "SELECT verifier FROM accounts WHERE username=$u"; query.Parameters.AddWithValue("$u", username);
var storedDigest = (byte[])query.ExecuteScalar()!;
Assert.Null(accounts.Authenticate(username, storedDigest));
Assert.True(accounts.HasSession(token, out var actual)); Assert.Equal(username, actual);
var vault = accounts.GetVault(username)!.Value;
Assert.Null(accounts.PutVault(username, new byte[28], vault.Revision + 1));
Assert.Equal(vault.Revision + 1, accounts.PutVault(username, new byte[28], vault.Revision));
accounts.RevokeSession(token); Assert.False(accounts.HasSession(token, out _));
}
private async Task<HttpResponseMessage> InviteAsync(Chan channel, string username, string payload)
{
string? defaultChannelId = null;
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{channel.Id}/structure", channel.Owner))
using (var structureResponse = await server.Http.SendAsync(structureRequest))
{
Assert.Equal(HttpStatusCode.OK, structureResponse.StatusCode);
defaultChannelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString();
}
if (channel.Mode == ChannelMode.Enc)
{
foreach (var domain in new[] { "chat", "files" })
{
using var bootstrap = server.Req(HttpMethod.Put, $"/api/v1/spaces/{channel.Id}/crypto/domains", channel.Owner,
JsonBody(new { channelId = defaultChannelId, domain, epoch = 1, envelopes = new[] { new { channelId = defaultChannelId, domain, epoch = 1, username = "fixture_owner", payload = $"owner-{domain}" } } }));
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(bootstrap)).StatusCode);
}
}
using var planRequest = server.Req(HttpMethod.Get, $"/api/v1/channels/{channel.Id}/invitations/{username}/crypto-plan", channel.Owner);
using var planResponse = await server.Http.SendAsync(planRequest); Assert.Equal(HttpStatusCode.OK, planResponse.StatusCode);
var plan = await ReadJsonAsync(planResponse);
var targetChannelId = plan.GetProperty("channelId").GetString();
var envelopes = new List<object>();
foreach (var batch in plan.GetProperty("batches").EnumerateArray())
foreach (var recipient in batch.GetProperty("recipients").EnumerateArray())
envelopes.Add(new { channelId = targetChannelId, domain = batch.GetProperty("domain").GetString(), epoch = batch.GetProperty("epoch").GetInt32(), username = recipient.GetString(), payload = "opaque-domain-key" });
return await server.Http.SendAsync(server.Req(HttpMethod.Post, $"/api/v1/channels/{channel.Id}/invitations", channel.Owner,
JsonBody(new { username, payload, domain_envelopes = envelopes })));
}
}