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
@@ -1,6 +1,7 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using WebDrop.Core;
|
||||
@@ -28,6 +29,14 @@ public static partial class Endpoints
|
||||
ApiEndpoint = captcha.Enabled ? options.Captcha.ApiEndpoint : null,
|
||||
},
|
||||
Transports = new TransportsBody { Http3 = options.EnableHttp3 },
|
||||
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
|
||||
Choices = new SettingChoicesBody
|
||||
{
|
||||
Seats = new[] { 2, 3, 5, 10, limits.MaxSeats }.Where(n => n >= 1 && n <= limits.MaxSeats).Distinct().Order().ToArray(),
|
||||
TtlSeconds = new[] { 3600, 86_400, 604_800, limits.MinTtlSeconds, limits.MaxTtlSeconds, CreationDefaults.Channel(limits).TtlSeconds }
|
||||
.Where(n => n >= limits.MinTtlSeconds && n <= limits.MaxTtlSeconds).Distinct().Order().ToArray(),
|
||||
MaxVisitors = new[] { 0, 2, 5, 10, 20, limits.MaxVisitors }.Where(n => n <= limits.MaxVisitors).Distinct().Order().ToArray(),
|
||||
},
|
||||
Limits = new LimitsBody
|
||||
{
|
||||
MaxPayloadBytes = limits.MaxPayloadBytes,
|
||||
@@ -35,10 +44,16 @@ public static partial class Endpoints
|
||||
MaxChannelBytes = limits.MaxChannelBytes,
|
||||
MaxSeats = limits.MaxSeats,
|
||||
MaxTtlSeconds = limits.MaxTtlSeconds,
|
||||
MinTtlSeconds = limits.MinTtlSeconds,
|
||||
MaxItems = limits.MaxItemsLimit,
|
||||
MaxVisitors = limits.MaxVisitors,
|
||||
},
|
||||
}, ApiJson.Default.ServerInfoBody));
|
||||
|
||||
MapAccounts(api, accounts);
|
||||
MapPasskeys(api, accounts);
|
||||
MapWorkspace(api, accounts);
|
||||
MapWorkspaceCrypto(api);
|
||||
|
||||
// ───────── 频道 ─────────
|
||||
|
||||
@@ -109,14 +124,15 @@ public static partial class Endpoints
|
||||
|
||||
// ───────── 帧与日志 ─────────
|
||||
|
||||
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
|
||||
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
|
||||
|
||||
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
|
||||
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct);
|
||||
var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, body);
|
||||
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct, signedEvent);
|
||||
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
|
||||
});
|
||||
|
||||
@@ -217,9 +233,12 @@ public static partial class Endpoints
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc) =>
|
||||
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
|
||||
{
|
||||
svc.DeleteItem(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), ParseId(itemId));
|
||||
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
|
||||
svc.DeleteItem(channelId, creds, parsedItemId, proof);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
@@ -355,6 +374,85 @@ public static partial class Endpoints
|
||||
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
|
||||
private static SignedContentEventProof? ParseSignedContentEvent(HttpRequest request, SqliteAccountStore accounts,
|
||||
Id16 spaceId, Credentials creds, byte[] frame)
|
||||
{
|
||||
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
|
||||
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target"];
|
||||
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
|
||||
bool any = values.Any(value => value.Length > 0);
|
||||
if (!any)
|
||||
{
|
||||
if (Envelope.TryParse(frame, out var legacyEnvelope) && legacyEnvelope.IsFinal && creds.AccountName is not null
|
||||
&& creds.AccountDeviceId is not null && accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) is not null)
|
||||
throw new WebDropException(ErrorCode.Forbidden);
|
||||
return null;
|
||||
}
|
||||
if (!Envelope.TryParse(frame, out var envelope) || !envelope.IsFinal || creds.AccountName is null || creds.AccountDeviceId is null
|
||||
|| values.Take(8).Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|
||||
|| !int.TryParse(values[4], out var epoch) || epoch < 0
|
||||
|| !int.TryParse(values[5], out var frameCount) || frameCount < 1 || frameCount > 65_536)
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
|
||||
var eventKind = values[1];
|
||||
if (domain == CryptoDomain.Chat && !eventKind.StartsWith("chat.", StringComparison.Ordinal)
|
||||
|| domain == CryptoDomain.Files && !eventKind.StartsWith("file.", StringComparison.Ordinal)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (!Codec.TryDecodeExact(values[0], 16, out var eventId)
|
||||
|| !Codec.TryDecodeExact(values[6], 32, out var manifestHash)
|
||||
|| !Codec.TryDecodeExact(values[7], 64, out var signature)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
byte[] target = [];
|
||||
if (values[8].Length > 0 && !Codec.TryDecodeExact(values[8], 16, out target)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId)
|
||||
?? throw new WebDropException(ErrorCode.Forbidden);
|
||||
var rootPublicKey = accounts.GetSigningPublicKey(creds.AccountName) ?? throw new WebDropException(ErrorCode.Forbidden);
|
||||
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey);
|
||||
var jwk = publicKey.RootElement;
|
||||
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|
||||
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), values[2], creds.AccountName,
|
||||
creds.AccountDeviceId, eventId, eventKind, domain, epoch, envelope.ItemId.ToArray(), frameCount, manifestHash, target);
|
||||
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
return new SignedContentEventProof(eventId, eventKind, creds.AccountName, values[2], domain, epoch, frameCount,
|
||||
manifestHash, creds.AccountDeviceId, signature, values[8].Length == 0 ? null : values[8],
|
||||
rootPublicKey, deviceKey.PublicKey, deviceKey.CertificateSignature);
|
||||
}
|
||||
|
||||
private static SignedContentEventProof? ParseSignedDeleteEvent(HttpRequest request, SqliteAccountStore accounts,
|
||||
ChannelService service, Id16 spaceId, Id16 itemId, Credentials creds)
|
||||
{
|
||||
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
|
||||
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature"];
|
||||
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
|
||||
if (values.All(string.IsNullOrEmpty)) return null;
|
||||
if (creds.AccountName is null || creds.AccountDeviceId is null || values.Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var obj = service.ObjectSigningContext(spaceId, creds, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var createdEvent = obj.Event ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|
||||
|| !int.TryParse(values[4], out var epoch) || !int.TryParse(values[5], out var frameCount)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
|
||||
var kind = domain == CryptoDomain.Chat ? "chat.delete" : "file.delete";
|
||||
if (values[1] != kind || obj.ChannelId != values[2] || obj.Domain != domain || obj.Epoch != epoch
|
||||
|| createdEvent.FrameCount != frameCount
|
||||
|| !Codec.TryDecodeExact(values[0], 16, out var eventId)
|
||||
|| !Codec.TryDecodeExact(values[6], 32, out var manifest)
|
||||
|| !Codec.TryDecodeExact(values[7], 64, out var signature)
|
||||
|| !Codec.TryDecodeExact(createdEvent.ManifestHash, 32, out var objectManifest)
|
||||
|| !CryptographicOperations.FixedTimeEquals(manifest, objectManifest)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) ?? throw new WebDropException(ErrorCode.Forbidden);
|
||||
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey); var jwk = publicKey.RootElement;
|
||||
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|
||||
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), obj.ChannelId, creds.AccountName,
|
||||
creds.AccountDeviceId, eventId, kind, domain, epoch, itemId.ToArray(), frameCount, manifest, []);
|
||||
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
return new SignedContentEventProof(eventId, kind, creds.AccountName, obj.ChannelId, domain, epoch, frameCount,
|
||||
manifest, creds.AccountDeviceId, signature, null, accounts.GetSigningPublicKey(creds.AccountName)!,
|
||||
deviceKey.PublicKey, deviceKey.CertificateSignature);
|
||||
}
|
||||
|
||||
private static byte[] DecodeBase64(string? value, int minLength, int maxLength)
|
||||
{
|
||||
if (value is null || value.Length > ((maxLength + 2) / 3 * 4)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
Reference in new issue
Block a user