feat(api): add account workspaces and signed content events

This commit is contained in:
justme committed 2026-10-14 04:05:30 +08:00
1 parent 937b774a93
commit 7b8d61d1ba
63 files changed
+5407 -370

No files matched your search

+2 -1
View File
@@ -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";
}
+97 -3
View File
@@ -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>))]
+7 -2
View File
@@ -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);
}
+39 -1
View File
@@ -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();
});
}
}
+102 -4
View File
@@ -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)));
}
+49
View File
@@ -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; }
}
+2 -2
View File
@@ -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);
+22 -1
View File
@@ -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))));
}
+1
View File
@@ -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;