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 { private sealed record TestSigner(string Username, string DeviceId, byte[] PrivateKey); private readonly Dictionary _signers = new(StringComparer.Ordinal); [Fact] public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation() { var ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 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}/seats", 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(); 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", seats = 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(); 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", seats = 2, join_policy = "open", allow_guests = true }); 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}/seats", who, token); using var response = await server.Http.SendAsync(joined); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_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("seats_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}/seats", third, unrelated)) await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full"); Assert.Equal(2, accounts.Devices(username, firstId).Count); Assert.Single(accounts.Devices(username, firstId), d => d.Current); using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken)) await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required"); 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\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!; Assert.Equal(JoinPolicy.Open, request.Settings!.JoinPolicy); Assert.True(request.Settings.AllowGuests); 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"); } } private async Task Register(string username) { var accounts = server.App.Services.GetRequiredService(); 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 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 { ["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", seats = 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)); using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest)) await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required"); using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: 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}/seats", guest, body: new { password = "channel-secret" })) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode); using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false }))) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode); using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, 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", allow_guests = false }))) 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}/seats", 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}/seats", member, account)) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode); using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member)) await AssertErrorAsync(await server.Http.SendAsync(impersonated), 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}/seats", 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(); Assert.Null(accounts.Authenticate(username, new byte[32])); using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService().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 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(); 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 }))); } }