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.
416 lines
31 KiB
C#
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 })));
|
|
}
|
|
}
|