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 { [Fact] public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation() { var ch = await server.CreateAsync(settings: new { seats = 8 }); var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = 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 = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-member-package" }))) Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(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 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 { seats = 8 }); using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-key-package" }))) Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(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); Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(joined)).StatusCode); } 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("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 string Register(string username) { var accounts = server.App.Services.GetRequiredService(); var verifier = RandomNumberGenerator.GetBytes(32); 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!) }))); return accounts.Authenticate(username, verifier)!; } 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; } [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 = 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 { 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 = 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 = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "client-encrypted-payload" }))) Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(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 }; using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", writer, account)) { upload.Content = Bytes(Frame(NewId(), 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16)); 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 = 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 _)); } }