feat(api): add account workspaces and signed content events
This commit is contained in:
1 parent
937b774a93
commit
7b8d61d1ba
63 files changed
+5407
-370
No files matched your search
@@ -12,11 +12,14 @@ 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 { seats = 8 });
|
||||
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = Register(username);
|
||||
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);
|
||||
@@ -25,8 +28,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 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))
|
||||
@@ -46,6 +48,111 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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", 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()
|
||||
{
|
||||
@@ -56,15 +163,22 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 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);
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(joined)).StatusCode);
|
||||
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))
|
||||
@@ -82,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
public void PartialSettingsPreserveAdmissionDefaults()
|
||||
{
|
||||
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
|
||||
Assert.Equal("open", request.Settings!.JoinPolicy);
|
||||
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)!;
|
||||
@@ -98,15 +212,28 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
await AssertErrorAsync(response, 400, "bad_request");
|
||||
}
|
||||
}
|
||||
private string Register(string username)
|
||||
private async Task<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)!;
|
||||
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)
|
||||
{
|
||||
@@ -116,6 +243,29 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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()
|
||||
{
|
||||
@@ -130,7 +280,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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]);
|
||||
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");
|
||||
}
|
||||
@@ -140,7 +290,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[InlineData(ChannelMode.Raw)]
|
||||
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
|
||||
{
|
||||
var ch = await server.CreateAsync(mode, new { seats = 8 });
|
||||
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));
|
||||
@@ -157,20 +307,30 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 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 = 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 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(NewId(), 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16));
|
||||
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))
|
||||
@@ -183,7 +343,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional()
|
||||
{
|
||||
var username = "digest_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var token = Register(username);
|
||||
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());
|
||||
@@ -197,4 +357,34 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using WebDrop.Api;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class ChannelAccessContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void GeneratedJsonDefaultsDomainEnvelopesForUnencryptedInvitations()
|
||||
{
|
||||
var request = JsonSerializer.Deserialize("""{"username":"member","payload":"opaque-grant"}""", ApiJson.Default.InvitationRequest)!;
|
||||
Assert.Null(request.DomainEnvelopes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using WebDrop.Api;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class PasskeyCeremonyTests
|
||||
{
|
||||
[Fact]
|
||||
public void CeremoniesAreOneTimeAndBoundToTheAuthenticatedAccountAndSession()
|
||||
{
|
||||
var ceremonies = new PasskeyCeremonies();
|
||||
PasskeyCeremony state = new("alice", "register", "server-state", "salt", PasskeyCeremonies.Hash("token-a"), DateTimeOffset.UtcNow.AddMinutes(1));
|
||||
var id = ceremonies.Put(state); Assert.Equal(state, ceremonies.Take(id, "alice", "register", "token-a"));
|
||||
Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
|
||||
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "bob", "register", "token-a"));
|
||||
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-b"));
|
||||
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "login", "token-a"));
|
||||
id = ceremonies.Put(state with { Expires = DateTimeOffset.UtcNow.AddSeconds(-1) }); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = ch.Id, ["mode"] = "enc",
|
||||
["settings"] = new { name = "Duplicate" },
|
||||
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
|
||||
["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)),
|
||||
};
|
||||
@@ -69,6 +70,24 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
await AssertErrorAsync(await s.Http.SendAsync(badJson), 400, "bad_request");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateRequiresSpaceName(string? name)
|
||||
{
|
||||
var ch = new Chan(NewId(), ChannelMode.Raw);
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = ch.Id,
|
||||
["mode"] = "raw",
|
||||
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
|
||||
};
|
||||
if (name is not null) body["settings"] = new { name };
|
||||
using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
|
||||
await AssertErrorAsync(await s.Http.SendAsync(req), 400, "space_name_required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorsUseStableCodes()
|
||||
{
|
||||
@@ -367,7 +386,7 @@ public class RateLimitTests
|
||||
{
|
||||
var ch = new Chan(NewId(), ChannelMode.Raw);
|
||||
using var req = server.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner,
|
||||
JsonBody(new { id = ch.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
|
||||
JsonBody(new { id = ch.Id, mode = "raw", settings = new { name = "Rate limit space" }, owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
|
||||
using var r = await server.Http.SendAsync(req);
|
||||
statuses.Add((int)r.StatusCode);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)),
|
||||
};
|
||||
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
|
||||
if (settings is not null) body["settings"] = settings;
|
||||
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
|
||||
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
|
||||
|
||||
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
|
||||
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
|
||||
|
||||
@@ -105,14 +105,14 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixtu
|
||||
byte[] channelOwnerToken = RandomNumberGenerator.GetBytes(32);
|
||||
var channelOwner = new Who("owner", channelOwnerToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var collision = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", channelOwner,
|
||||
JsonBody(new { id = share.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
|
||||
JsonBody(new { id = share.Id, mode = "raw", settings = new { name = "Collision" }, owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
|
||||
await AssertErrorAsync(collision, 409, "conflict");
|
||||
|
||||
string channelId = NewId();
|
||||
byte[] secondOwnerToken = RandomNumberGenerator.GetBytes(32);
|
||||
var secondOwner = new Who("owner", secondOwnerToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var channel = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", secondOwner,
|
||||
JsonBody(new { id = channelId, mode = "raw", owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
|
||||
JsonBody(new { id = channelId, mode = "raw", settings = new { name = "Second space" }, owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
|
||||
Assert.Equal(HttpStatusCode.Created, channel.StatusCode);
|
||||
|
||||
using var shareCollision = await CreateShare(new ShareClient(channelId));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
using static WebDrop.Api.Tests.ServerFixture;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<ServerFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task AccountProfileIsServerOwnedAndDoesNotChangeTheEncryptedVault()
|
||||
{
|
||||
var store = server.App.Services.GetRequiredService<SqliteAccountStore>();
|
||||
var revision = store.GetVault("fixture_owner")!.Value.Revision;
|
||||
using var patch = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "资料测试", avatar = "data:image/png;base64,AA==" }) };
|
||||
patch.Headers.Add("X-WD-Account", server.OwnerAccountToken);
|
||||
using var response = await server.Http.SendAsync(patch);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await ReadJsonAsync(response); Assert.Equal("资料测试", body.GetProperty("displayName").GetString());
|
||||
Assert.Equal(revision, store.GetVault("fixture_owner")!.Value.Revision);
|
||||
using var read = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/profile"); read.Headers.Add("X-WD-Account", server.OwnerAccountToken);
|
||||
using var loaded = await server.Http.SendAsync(read);
|
||||
Assert.Equal("资料测试", (await ReadJsonAsync(loaded)).GetProperty("displayName").GetString());
|
||||
using var bad = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "test", avatar = "https://tracker.test/avatar.png" }) };
|
||||
bad.Headers.Add("X-WD-Account", server.OwnerAccountToken);
|
||||
using var rejected = await server.Http.SendAsync(bad); Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
|
||||
using var anonymous = await server.Http.GetAsync("/api/v1/accounts/profile"); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task StructureEndpointsPersistAndRejectCrossSpaceGroups()
|
||||
{
|
||||
var a = await server.CreateAsync(); var b = await server.CreateAsync();
|
||||
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{b.Id}/groups", b.Owner, JsonBody(new { name = "另一空间" }));
|
||||
using var groupResponse = await server.Http.SendAsync(createGroup); Assert.Equal(HttpStatusCode.Created, groupResponse.StatusCode);
|
||||
var groupBody = await ReadJsonAsync(groupResponse);
|
||||
var groupId = groupBody.GetProperty("group").GetProperty("id").GetString();
|
||||
using var wrong = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "频道", groupId }));
|
||||
using var wrongResponse = await server.Http.SendAsync(wrong); Assert.Equal(HttpStatusCode.BadRequest, wrongResponse.StatusCode);
|
||||
using var valid = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "讨论" }));
|
||||
using var response = await server.Http.SendAsync(valid); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var createdBody = await ReadJsonAsync(response);
|
||||
Assert.Equal("讨论", createdBody.GetProperty("channel").GetProperty("name").GetString());
|
||||
using var read = server.Req(HttpMethod.Get, $"/api/v1/spaces/{a.Id}/structure", a.Owner);
|
||||
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
|
||||
}
|
||||
[Fact]
|
||||
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
|
||||
{
|
||||
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
||||
await isolated.InitializeAsync();
|
||||
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
|
||||
var defaults = info.GetProperty("defaults").GetProperty("channel");
|
||||
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
|
||||
Assert.Equal(3, defaults.GetProperty("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
|
||||
var ch = new Chan(NewId(), ChannelMode.Enc);
|
||||
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", seats = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
|
||||
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
|
||||
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
|
||||
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
|
||||
Assert.Equal(defaults.GetProperty("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
|
||||
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user