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
@@ -2,6 +2,7 @@ namespace WebDrop.Api;
|
||||
|
||||
public static class ApiContract
|
||||
{
|
||||
// v5 changes channel capacity from per-endpoint devices to account members.
|
||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||
public const string Version = "2.1.0";
|
||||
public const string Version = "6.0.0";
|
||||
}
|
||||
@@ -21,6 +21,22 @@ public sealed class ServerInfoBody
|
||||
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
|
||||
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
|
||||
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
|
||||
[JsonPropertyName("defaults")] public required DefaultsBody Defaults { get; init; }
|
||||
[JsonPropertyName("choices")] public required SettingChoicesBody Choices { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DefaultsBody
|
||||
{
|
||||
[JsonPropertyName("channel_mode")] public ChannelMode ChannelMode { get; init; } = CreationDefaults.ChannelMode;
|
||||
[JsonPropertyName("channel")] public required ChannelSettings Channel { get; init; }
|
||||
[JsonPropertyName("share")] public required ShareSettings Share { get; init; }
|
||||
}
|
||||
public sealed class SettingChoicesBody
|
||||
{
|
||||
[JsonPropertyName("seats")] public required int[] Seats { get; init; }
|
||||
[JsonPropertyName("ttl_seconds")] public required int[] TtlSeconds { get; init; }
|
||||
[JsonPropertyName("file_max_downloads")] public int[] FileMaxDownloads { get; init; } = ServerSettingChoices.FileMaxDownloads;
|
||||
[JsonPropertyName("max_visitors")] public required int[] MaxVisitors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。</summary>
|
||||
@@ -45,11 +61,15 @@ public sealed class LimitsBody
|
||||
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
|
||||
[JsonPropertyName("max_seats")] public required int MaxSeats { get; init; }
|
||||
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
||||
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
|
||||
[JsonPropertyName("max_items")] public required int MaxItems { get; init; }
|
||||
[JsonPropertyName("max_visitors")] public required int MaxVisitors { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountRegisterRequest
|
||||
{
|
||||
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
|
||||
[JsonPropertyName("signing_public_key")] public required string SigningPublicKey { get; init; }
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("salt")] public required string Salt { get; init; }
|
||||
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
|
||||
@@ -83,11 +103,16 @@ public sealed class AccountVaultBody
|
||||
[JsonPropertyName("vault")] public required string Vault { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PublicKeyBody { [JsonPropertyName("public_key")] public required string PublicKey { get; init; } }
|
||||
public sealed class PublicKeyBody
|
||||
{
|
||||
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
|
||||
[JsonPropertyName("signing_public_key")] public string? SigningPublicKey { get; init; }
|
||||
}
|
||||
public sealed class InvitationRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
||||
[JsonPropertyName("domain_envelopes")] public List<DomainEnvelope>? DomainEnvelopes { get; init; }
|
||||
}
|
||||
public sealed class InvitationBody
|
||||
{
|
||||
@@ -99,6 +124,12 @@ public sealed class InvitationBody
|
||||
public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } }
|
||||
public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } }
|
||||
public sealed class AccountDeviceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
|
||||
public sealed class AccountDeviceSigningRequest
|
||||
{
|
||||
[JsonPropertyName("device_id")] public required string DeviceId { get; init; }
|
||||
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
|
||||
[JsonPropertyName("signature")] public required string Signature { get; init; }
|
||||
}
|
||||
public sealed class AccountDeviceBody
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
@@ -121,10 +152,73 @@ public sealed class MemberBody
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountProfileRequest
|
||||
{
|
||||
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
|
||||
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
|
||||
public sealed class WorkspaceChannelRequest
|
||||
{
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("groupId")] public string? GroupId { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceGroupRequest { [JsonPropertyName("groupId")] public string? GroupId { get; init; } }
|
||||
public sealed class WorkspaceFolderRequest
|
||||
{
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
|
||||
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceMoveRequest
|
||||
{
|
||||
[JsonPropertyName("itemIds")] public required string[] ItemIds { get; init; }
|
||||
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
|
||||
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceBody
|
||||
{
|
||||
[JsonPropertyName("structure")] public required WorkspaceState Structure { get; init; }
|
||||
[JsonPropertyName("people")] public required List<AccountProfile> People { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceChannelCreatedBody
|
||||
{
|
||||
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
|
||||
[JsonPropertyName("channel")] public required WorkspaceChannel Channel { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceGroupCreatedBody
|
||||
{
|
||||
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
|
||||
[JsonPropertyName("group")] public required WorkspaceGroup Group { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(ErrorBody))]
|
||||
[JsonSerializable(typeof(CryptoState))]
|
||||
[JsonSerializable(typeof(DomainPublishRequest))]
|
||||
[JsonSerializable(typeof(ObjectEnvelope))]
|
||||
[JsonSerializable(typeof(ChannelHistoryRequest))]
|
||||
[JsonSerializable(typeof(PasskeyBeginRequest))]
|
||||
[JsonSerializable(typeof(PasskeyOptionsBody))]
|
||||
[JsonSerializable(typeof(PasskeyFinishRequest))]
|
||||
[JsonSerializable(typeof(PasskeyInfoBody))]
|
||||
[JsonSerializable(typeof(List<PasskeyInfoBody>))]
|
||||
[JsonSerializable(typeof(PasskeySessionBody))]
|
||||
[JsonSerializable(typeof(PasskeyEnvelopeRequest))]
|
||||
[JsonSerializable(typeof(AccountProfile))]
|
||||
[JsonSerializable(typeof(AccountProfileRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceState))]
|
||||
[JsonSerializable(typeof(WorkspaceBody))]
|
||||
[JsonSerializable(typeof(WorkspaceChannelCreatedBody))]
|
||||
[JsonSerializable(typeof(WorkspaceGroupCreatedBody))]
|
||||
[JsonSerializable(typeof(WorkspaceNameRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceChannelRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceGroupRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceFolderRequest))]
|
||||
[JsonSerializable(typeof(WorkspaceMoveRequest))]
|
||||
[JsonSerializable(typeof(GoneLogBody))]
|
||||
[JsonSerializable(typeof(ServerInfoBody))]
|
||||
[JsonSerializable(typeof(ChannelInfo))]
|
||||
[JsonSerializable(typeof(List<ChannelInfo>))]
|
||||
[JsonSerializable(typeof(ShareInfo))]
|
||||
[JsonSerializable(typeof(CreateShareRequest))]
|
||||
[JsonSerializable(typeof(ShareSettingsPatch))]
|
||||
@@ -134,9 +228,8 @@ public sealed class MemberBody
|
||||
[JsonSerializable(typeof(SeatJoined))]
|
||||
[JsonSerializable(typeof(List<SeatInfo>))]
|
||||
[JsonSerializable(typeof(AppendResult))]
|
||||
[JsonSerializable(typeof(SiteverifyRequest))]
|
||||
[JsonSerializable(typeof(SiteverifyResponse))]
|
||||
[JsonSerializable(typeof(AccountRegisterRequest))]
|
||||
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
|
||||
[JsonSerializable(typeof(AccountSaltRequest))]
|
||||
[JsonSerializable(typeof(AccountSessionRequest))]
|
||||
[JsonSerializable(typeof(AccountVaultRequest))]
|
||||
@@ -145,6 +238,7 @@ public sealed class MemberBody
|
||||
[JsonSerializable(typeof(AccountVaultBody))]
|
||||
[JsonSerializable(typeof(PublicKeyBody))]
|
||||
[JsonSerializable(typeof(InvitationRequest))]
|
||||
[JsonSerializable(typeof(InvitationCryptoPlan))]
|
||||
[JsonSerializable(typeof(List<InvitationBody>))]
|
||||
[JsonSerializable(typeof(JoinRequest))]
|
||||
[JsonSerializable(typeof(List<MemberBody>))]
|
||||
|
||||
@@ -16,6 +16,11 @@ public sealed class SiteverifyResponse
|
||||
[JsonPropertyName("success")] public bool Success { get; init; }
|
||||
}
|
||||
|
||||
// Backend-to-Cap DTOs are not part of the browser API contract.
|
||||
[JsonSerializable(typeof(SiteverifyRequest))]
|
||||
[JsonSerializable(typeof(SiteverifyResponse))]
|
||||
public sealed partial class CaptchaJson : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// 用 Cap(https://github.com/tiagozip/cap)校验创建频道的验证码。
|
||||
/// 工作量证明的出题、求解、验证全部由 Cap 完成,本项目不实现任何 PoW。
|
||||
@@ -36,7 +41,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
|
||||
throw new WebDropException(ErrorCode.CaptchaRequired);
|
||||
|
||||
var body = JsonSerializer.Serialize(
|
||||
new SiteverifyRequest { Secret = options.Secret, Response = token }, ApiJson.Default.SiteverifyRequest);
|
||||
new SiteverifyRequest { Secret = options.Secret, Response = token }, CaptchaJson.Default.SiteverifyRequest);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -55,7 +60,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
|
||||
|
||||
var result = await JsonSerializer.DeserializeAsync(
|
||||
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
|
||||
ApiJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
|
||||
CaptchaJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
|
||||
|
||||
if (result is not { Success: true }) throw new WebDropException(ErrorCode.CaptchaRequired);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,19 @@ public static partial class Endpoints
|
||||
{
|
||||
private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
api.MapGet("/accounts/profile", (HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(accounts.Profile(username), ApiJson.Default.AccountProfile);
|
||||
});
|
||||
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountProfileRequest, ct);
|
||||
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar);
|
||||
svc.AccountProfileChanged(username);
|
||||
return Json(profile, ApiJson.Default.AccountProfile);
|
||||
});
|
||||
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct);
|
||||
@@ -15,7 +28,8 @@ public static partial class Endpoints
|
||||
var verifier = DecodeBase64(req.Verifier, 32, 32);
|
||||
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
|
||||
ValidatePublicKey(req.PublicKey);
|
||||
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict);
|
||||
ValidatePublicKey(req.SigningPublicKey);
|
||||
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey, req.SigningPublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict);
|
||||
return Results.NoContent();
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
|
||||
@@ -59,6 +73,30 @@ public static partial class Endpoints
|
||||
if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return Results.NoContent();
|
||||
});
|
||||
api.MapPut("/accounts/devices/signing-key", async (HttpContext ctx, SqliteAccountStore accountStore, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accountStore, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceSigningRequest, ct);
|
||||
var token = ctx.Request.Headers["X-WD-Account"].ToString();
|
||||
var deviceId = accountStore.SessionDevice(token);
|
||||
if (deviceId is null || !string.Equals(deviceId, req.DeviceId, StringComparison.OrdinalIgnoreCase)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
ValidatePublicKey(req.PublicKey);
|
||||
var rootKey = accountStore.GetSigningPublicKey(username);
|
||||
if (rootKey is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
using var root = System.Text.Json.JsonDocument.Parse(rootKey);
|
||||
var rootObject = root.RootElement;
|
||||
if (!Codec.TryDecodeExact(rootObject.GetProperty("x").GetString(), 32, out var rootX)
|
||||
|| !Codec.TryDecodeExact(rootObject.GetProperty("y").GetString(), 32, out var rootY)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
using var device = System.Text.Json.JsonDocument.Parse(req.PublicKey);
|
||||
var deviceObject = device.RootElement;
|
||||
if (!Codec.TryDecodeExact(deviceObject.GetProperty("x").GetString(), 32, out var deviceX)
|
||||
|| !Codec.TryDecodeExact(deviceObject.GetProperty("y").GetString(), 32, out var deviceY)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var payload = SignatureCodec.DeviceCertificatePayload(username, deviceId, deviceX, deviceY);
|
||||
var signature = DecodeBase64(req.Signature, 64, 64);
|
||||
if (!SignatureCodec.VerifyP256(rootX, rootY, "device.certificate", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!accountStore.SaveDeviceSigningKey(username, deviceId, req.PublicKey, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
return Results.NoContent();
|
||||
});
|
||||
api.MapDelete("/accounts/devices/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
|
||||
@@ -7,6 +7,11 @@ public static partial class Endpoints
|
||||
{
|
||||
private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
api.MapGet("/accounts/spaces", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(svc.AccountSpaces(username).ToList(), ApiJson.Default.ListChannelInfo);
|
||||
});
|
||||
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
@@ -27,7 +32,7 @@ public static partial class Endpoints
|
||||
if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var key = accounts.GetPublicKey(NormalizeUsername(username));
|
||||
if (key is null) throw new WebDropException(ErrorCode.NotFound);
|
||||
return Json(new PublicKeyBody { PublicKey = key }, ApiJson.Default.PublicKeyBody);
|
||||
return Json(new PublicKeyBody { PublicKey = key, SigningPublicKey = accounts.GetSigningPublicKey(NormalizeUsername(username)) }, ApiJson.Default.PublicKeyBody);
|
||||
});
|
||||
api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
@@ -36,6 +41,12 @@ public static partial class Endpoints
|
||||
});
|
||||
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody));
|
||||
api.MapGet("/channels/{id}/invitations/{username}/crypto-plan", (string id, string username, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
|
||||
{
|
||||
username = NormalizeUsername(username);
|
||||
if (accountStore.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
|
||||
return Json(svc.InvitationPlan(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username), ApiJson.Default.InvitationCryptoPlan);
|
||||
});
|
||||
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(svc.Members(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)).Select(m => new MemberBody { Username = m.Username, Role = m.Role, JoinedAt = m.JoinedAt }).ToList(), ApiJson.Default.ListMemberBody));
|
||||
api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
@@ -43,7 +54,7 @@ public static partial class Endpoints
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct);
|
||||
var username = NormalizeUsername(req.Username);
|
||||
if (accounts.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
|
||||
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload); return Results.NoContent();
|
||||
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload, req.DomainEnvelopes ?? []); return Results.NoContent();
|
||||
});
|
||||
api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static PasskeyInfoBody PasskeyInfo(StoredPasskey p) => new() { Id = p.Id, Name = p.Name, PrfSalt = p.PrfSalt, CanRecover = p.KeyEnvelope is not null, CreatedAt = p.CreatedAt };
|
||||
private static void ValidateRecoveryEnvelope(string? value)
|
||||
{
|
||||
if (value is not null) _ = DecodeBase64(value, 28, 512);
|
||||
}
|
||||
private static void MapPasskeys(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
api.MapGet("/accounts/passkeys", (HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(accounts.Passkeys(username).Select(PasskeyInfo).ToList(), ApiJson.Default.ListPasskeyInfoBody);
|
||||
});
|
||||
api.MapDelete("/accounts/passkeys/{id}", (string id, HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
accounts.RemovePasskey(id, username); return Results.NoContent();
|
||||
});
|
||||
api.MapPost("/accounts/passkeys/register/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyBeginRequest, ct);
|
||||
if (NormalizeUsername(req.Username) != username || req.Verifier is null || !accounts.VerifyLogin(username, DecodeBase64(req.Verifier, 32, 32))) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var result = await handler.MakeCreationOptionsAsync(new() { Id = username, Name = username, DisplayName = accounts.Profile(username).DisplayName }, ctx);
|
||||
var salt = Codec.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
var id = ceremonies.Put(new(username, "register", result.AttestationState ?? throw new WebDropException(ErrorCode.BadRequest), salt, PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
|
||||
return Json(new PasskeyOptionsBody { Id = id, Options = result.CreationOptionsJson, PrfSalt = salt }, ApiJson.Default.PasskeyOptionsBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/register", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct);
|
||||
var state = ceremonies.Take(req.Id, username, "register", ctx.Request.Headers["X-WD-Account"].ToString());
|
||||
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000 || req.Name is null || req.Name.Trim().Length is < 1 or > 80 || req.Name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
ValidateRecoveryEnvelope(req.KeyEnvelope);
|
||||
var result = await handler.PerformAttestationAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AttestationState = state.State });
|
||||
if (!result.Succeeded || result.UserEntity?.Id != username || result.Passkey is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
result.Passkey.Name = req.Name.Trim();
|
||||
var row = new StoredPasskey(Codec.Encode(result.Passkey.CredentialId), username, result.Passkey.Name,
|
||||
JsonSerializer.Serialize(result.Passkey, PasskeyStorageJson.Default.UserPasskeyInfo), state.PrfSalt, req.KeyEnvelope, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
accounts.AddPasskey(row); return Json(PasskeyInfo(row), ApiJson.Default.PasskeyInfoBody, 201);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/login/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct); var username = NormalizeUsername(req.Username);
|
||||
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
|
||||
var id = ceremonies.Put(new(username, "login", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", null, DateTimeOffset.UtcNow.AddMinutes(2)));
|
||||
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/login", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct); var username = NormalizeUsername(req.Username);
|
||||
var state = ceremonies.Take(req.Id, username, "login", null);
|
||||
if (req.Credential.Length > 40_000 || req.DeviceId is null || req.DeviceName is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
|
||||
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var id = Codec.Encode(result.Passkey.CredentialId); var row = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var updated = await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey);
|
||||
if (!updated.Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var token = accounts.CreateDeviceSession(username, req.DeviceId, req.DeviceName) ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||
return Json(new PasskeySessionBody { Username = username, Token = token, CredentialId = id, PrfSalt = row.PrfSalt, KeyEnvelope = row.KeyEnvelope }, ApiJson.Default.PasskeySessionBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPost("/accounts/passkeys/recovery/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
|
||||
var id = ceremonies.Put(new(username, "recovery", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
|
||||
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
api.MapPut("/accounts/passkeys/recovery", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyEnvelopeRequest, ct);
|
||||
var state = ceremonies.Take(req.Id, username, "recovery", ctx.Request.Headers["X-WD-Account"].ToString());
|
||||
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000) throw new WebDropException(ErrorCode.BadRequest);
|
||||
ValidateRecoveryEnvelope(req.KeyEnvelope);
|
||||
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
|
||||
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
if (!(await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey)).Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
accounts.UpdatePasskeyEnvelope(Codec.Encode(result.Passkey.CredentialId), username, req.KeyEnvelope);
|
||||
return Results.NoContent();
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||
{
|
||||
WorkspaceBody View(WorkspaceState state) => new()
|
||||
{
|
||||
Structure = state,
|
||||
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Distinct()
|
||||
.Select(u => accounts.Profile(u!)).ToList(),
|
||||
};
|
||||
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true))), ApiJson.Default.WorkspaceBody));
|
||||
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
|
||||
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name, req.GroupId);
|
||||
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
|
||||
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name);
|
||||
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
||||
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
|
||||
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed class ChannelHistoryRequest
|
||||
{
|
||||
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; init; }
|
||||
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; init; }
|
||||
}
|
||||
public static partial class Endpoints
|
||||
{
|
||||
private static void MapWorkspaceCrypto(RouteGroupBuilder api)
|
||||
{
|
||||
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)), ApiJson.Default.CryptoState));
|
||||
api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct);
|
||||
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req); return Results.NoContent();
|
||||
});
|
||||
api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct);
|
||||
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req), ApiJson.Default.ObjectEnvelope, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct);
|
||||
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed record PasskeyCeremony(string Username, string Purpose, string State, string PrfSalt, string? SessionHash, DateTimeOffset Expires);
|
||||
public sealed class PasskeyCeremonies
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PasskeyCeremony> _pending = new();
|
||||
public string Put(PasskeyCeremony ceremony)
|
||||
{
|
||||
foreach (var entry in _pending) if (entry.Value.Expires <= DateTimeOffset.UtcNow) _pending.TryRemove(entry.Key, out _);
|
||||
if (_pending.Count >= 1000) throw new WebDropException(ErrorCode.RateLimited);
|
||||
var id = Codec.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
_pending[id] = ceremony; return id;
|
||||
}
|
||||
public PasskeyCeremony Take(string id, string username, string purpose, string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || id.Length > 100 || !_pending.TryRemove(id, out var ceremony) || ceremony.Expires <= DateTimeOffset.UtcNow
|
||||
|| ceremony.Username != username || ceremony.Purpose != purpose || ceremony.SessionHash != Hash(token)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return ceremony;
|
||||
}
|
||||
public static string? Hash(string? token) => token is null ? null : Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed class PasskeyBeginRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("verifier")] public string? Verifier { get; init; }
|
||||
}
|
||||
public sealed class PasskeyOptionsBody
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("options")] public required string Options { get; init; }
|
||||
[JsonPropertyName("credentials")] public List<PasskeyInfoBody> Credentials { get; init; } = [];
|
||||
[JsonPropertyName("prf_salt")] public string? PrfSalt { get; init; }
|
||||
}
|
||||
public sealed class PasskeyFinishRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("credential")] public required string Credential { get; init; }
|
||||
[JsonPropertyName("name")] public string? Name { get; init; }
|
||||
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
|
||||
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
|
||||
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
|
||||
}
|
||||
public sealed class PasskeyInfoBody
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
|
||||
[JsonPropertyName("can_recover")] public bool CanRecover { get; init; }
|
||||
[JsonPropertyName("created_at")] public long CreatedAt { get; init; }
|
||||
}
|
||||
public sealed class PasskeySessionBody
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("token")] public required string Token { get; init; }
|
||||
[JsonPropertyName("credential_id")] public required string CredentialId { get; init; }
|
||||
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
|
||||
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
|
||||
}
|
||||
public sealed class PasskeyEnvelopeRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("credential")] public required string Credential { get; init; }
|
||||
[JsonPropertyName("key_envelope")] public required string KeyEnvelope { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed record PasskeyAccount(string Username);
|
||||
|
||||
[JsonSerializable(typeof(UserPasskeyInfo))]
|
||||
public sealed partial class PasskeyStorageJson : JsonSerializerContext;
|
||||
|
||||
/// <summary>Reuse the framework's WebAuthn verifier without replacing the existing account/session system.</summary>
|
||||
public sealed class PasskeyUserStore(SqliteAccountStore accounts) : IUserPasskeyStore<PasskeyAccount>
|
||||
{
|
||||
private readonly Dictionary<string, string> _readVersions = [];
|
||||
public void Dispose() { }
|
||||
public Task<string> GetUserIdAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(user.Username);
|
||||
public Task<string?> GetUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username);
|
||||
public Task<string?> GetNormalizedUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username.ToUpperInvariant());
|
||||
public Task SetUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => name == user.Username ? Task.CompletedTask : throw new NotSupportedException();
|
||||
public Task SetNormalizedUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<IdentityResult> CreateAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<IdentityResult> DeleteAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<IdentityResult> UpdateAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(IdentityResult.Success);
|
||||
public Task<PasskeyAccount?> FindByIdAsync(string id, CancellationToken ct) => Task.FromResult(accounts.GetPublicKey(id) is null ? null : new PasskeyAccount(id.ToLowerInvariant()));
|
||||
public Task<PasskeyAccount?> FindByNameAsync(string name, CancellationToken ct) => FindByIdAsync(name, ct);
|
||||
public Task<PasskeyAccount?> FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => Task.FromResult(accounts.Passkey(Codec.Encode(id)) is { } record ? new PasskeyAccount(record.Username) : null);
|
||||
public Task<UserPasskeyInfo?> FindPasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct)
|
||||
{
|
||||
var record = accounts.Passkey(Codec.Encode(id));
|
||||
return Task.FromResult(record?.Username == user.Username ? Read(record) : null);
|
||||
}
|
||||
public Task<IList<UserPasskeyInfo>> GetPasskeysAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<IList<UserPasskeyInfo>>(accounts.Passkeys(user.Username).Select(Read).ToList());
|
||||
private UserPasskeyInfo Read(StoredPasskey record)
|
||||
{
|
||||
_readVersions[record.Id] = record.Data;
|
||||
return JsonSerializer.Deserialize(record.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
|
||||
}
|
||||
public Task AddOrUpdatePasskeyAsync(PasskeyAccount user, UserPasskeyInfo passkey, CancellationToken ct)
|
||||
{
|
||||
var id = Codec.Encode(passkey.CredentialId);
|
||||
var latest = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
|
||||
var previous = JsonSerializer.Deserialize(latest.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
|
||||
if ((previous.SignCount != 0 || passkey.SignCount != 0) && passkey.SignCount <= previous.SignCount) throw new WebDropException(ErrorCode.Unauthorized);
|
||||
if (!_readVersions.TryGetValue(id, out var old) || !accounts.UpdatePasskeyData(id, user.Username, old, JsonSerializer.Serialize(passkey, PasskeyStorageJson.Default.UserPasskeyInfo))) throw new WebDropException(ErrorCode.Conflict);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task RemovePasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct) { accounts.RemovePasskey(Codec.Encode(id), user.Username); return Task.CompletedTask; }
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public static class RequestAuth
|
||||
deviceId = accounts.SessionDevice(accountToken);
|
||||
}
|
||||
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId);
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,7 +68,7 @@ public static class RequestAuth
|
||||
}
|
||||
var parsedRole = ParseRole(role);
|
||||
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Frontend;
|
||||
using WebDrop.Storage;
|
||||
@@ -27,6 +29,23 @@ public static class WebDropApp
|
||||
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs")));
|
||||
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
|
||||
builder.Services.AddSingleton<SqliteAccountStore>(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
|
||||
builder.Services.AddSingleton<PasskeyCeremonies>();
|
||||
builder.Services.AddScoped<PasskeyUserStore>();
|
||||
builder.Services.AddScoped<UserManager<PasskeyAccount>>(sp => new(
|
||||
sp.GetRequiredService<PasskeyUserStore>(), Options.Create(new IdentityOptions()), new PasswordHasher<PasskeyAccount>(), [], [],
|
||||
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), sp, sp.GetRequiredService<ILogger<UserManager<PasskeyAccount>>>()));
|
||||
builder.Services.Configure<IdentityPasskeyOptions>(o =>
|
||||
{
|
||||
o.ServerDomain = options.PasskeyRpId;
|
||||
o.UserVerificationRequirement = "required"; o.ResidentKeyRequirement = "required";
|
||||
o.AttestationConveyancePreference = "none"; o.AuthenticatorTimeout = TimeSpan.FromMinutes(2);
|
||||
o.IsAllowedAlgorithm = algorithm => algorithm == -7;
|
||||
o.ValidateOrigin = context => ValueTask.FromResult(!context.CrossOrigin && context.TopOrigin is null
|
||||
&& Uri.TryCreate(context.Origin, UriKind.Absolute, out var origin)
|
||||
&& (options.CorsOrigins.Contains(origin.GetLeftPart(UriPartial.Authority), StringComparer.Ordinal)
|
||||
|| origin.GetLeftPart(UriPartial.Authority) == $"{context.HttpContext.Request.Scheme}://{context.HttpContext.Request.Host}"));
|
||||
});
|
||||
builder.Services.AddScoped<IPasskeyHandler<PasskeyAccount>, PasskeyHandler<PasskeyAccount>>();
|
||||
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
|
||||
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
|
||||
builder.Services.AddSingleton<ChannelHub>();
|
||||
@@ -43,7 +62,9 @@ public static class WebDropApp
|
||||
.WithOrigins(options.CorsOrigins)
|
||||
.AllowAnyMethod()
|
||||
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
|
||||
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account")
|
||||
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account",
|
||||
"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")
|
||||
.WithExposedHeaders("X-WD-Last-Seq")
|
||||
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class WebDropOptions
|
||||
/// 服务端进程本身不内置 QUIC:CreateSlimBuilder 不含 HTTP/3,需要时再接入。
|
||||
/// </summary>
|
||||
public bool EnableHttp3 { get; set; }
|
||||
public string? PasskeyRpId { get; set; }
|
||||
|
||||
public int RequestsPerMinute { get; set; } = 600;
|
||||
public int ChannelCreatesPerMinute { get; set; } = 30;
|
||||
|
||||
Reference in new issue
Block a user