201 lines
15 KiB
C#
201 lines
15 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>
|
|
{
|
|
[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<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 { 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<SqliteAccountStore>();
|
|
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<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 _));
|
|
}
|
|
}
|