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

+1 -1
View File
@@ -42,7 +42,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用
| `Captcha:ApiEndpoint` | 空 | 浏览器里 Cap widget 连接的地址,形如 `https://cap.example.com/<site_key>/`,经 `/info` 下发 |
| `Captcha:SiteverifyUrl` | 空 | 本服务调用 Cap 校验 token 的地址,形如 `https://cap.example.com/<site_key>/siteverify`,可以是内网地址 |
| `Captcha:Secret` | 空 | Cap 站点密钥的 secret,只在服务端使用 |
| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、席位、TTL 上限等 |
| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、成员人数上限、TTL 上限等 |
## 约束
+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;
+6
View File
@@ -91,6 +91,11 @@ public interface IResourceTx
// 频道
Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel);
WorkspaceState? GetWorkspace(Id16 id);
CryptoState GetWorkspaceCrypto(Id16 id);
void PutWorkspaceCrypto(Id16 id, CryptoState state);
IReadOnlyList<Id16> ListAccountSpaces(string username, long now);
void PutWorkspace(Id16 id, WorkspaceState state);
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt);
bool HasInvitation(Id16 id, string username);
@@ -122,6 +127,7 @@ public interface IResourceTx
// 席位
Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
int CountSeats(Id16 channelId);
int CountMembers(Id16 channelId);
void InsertSeat(Id16 channelId, Seat seat);
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label);
+11
View File
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class AccountProfile
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
}
+6 -16
View File
@@ -19,7 +19,7 @@ public sealed partial class ChannelService
bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName);
if (invited) return;
if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return;
if (channel.Settings.JoinPolicy == "invite") throw new WebDropException(ErrorCode.InvitationRequired);
if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired);
if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
if (channel.JoinPasswordHash is null) return;
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
@@ -28,21 +28,6 @@ public sealed partial class ChannelService
if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
}
public void InviteUser(Id16 id, Credentials creds, string username, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.PutKeyPackage(id, username, payload, clock.NowMs);
tx.AllowAccount(id, username);
return 0;
});
}
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
@@ -63,6 +48,11 @@ public sealed partial class ChannelService
tx.DeleteKeyPackage(id, username);
tx.DeleteAccountSeats(id, username);
tx.DeleteChannelMember(id, username);
var crypto = tx.GetWorkspaceCrypto(id);
crypto.Access.RemoveAll(a => a.Username == username);
crypto.Envelopes.RemoveAll(e => e.Username == username);
crypto.Revision++;
tx.PutWorkspaceCrypto(id, crypto);
tx.DenyAccount(id, username);
return 0;
});
+212
View File
@@ -0,0 +1,212 @@
namespace WebDrop.Core;
public sealed partial class ChannelService
{
public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict);
var batches = new List<CryptoGrantBatch>();
if (space.Mode == ChannelMode.Enc)
{
var crypto = tx.GetWorkspaceCrypto(id);
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files })
{
var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict);
var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory;
if (history)
{
var epochs = crypto.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount)
.Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList();
if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
foreach (var epoch in epochs) batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = epoch, Recipients = [username], Rotate = false });
}
else
{
var recipients = crypto.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().Order(StringComparer.Ordinal).ToList();
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = currentEpoch + 1, Recipients = recipients, Rotate = true });
}
}
}
return new InvitationCryptoPlan { ChannelId = channelId, Username = username, Batches = batches };
});
public void InviteUser(Id16 id, Credentials creds, string username, string payload, IReadOnlyList<DomainEnvelope> envelopes)
{
if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict);
var state = tx.GetWorkspaceCrypto(id);
var expected = new List<(CryptoDomain Domain, int Epoch, string Username)>();
if (space.Mode == ChannelMode.Enc)
{
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files })
{
var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict);
var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory;
if (history)
{
var epochs = state.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount)
.Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList();
if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
expected.AddRange(epochs.Select(epoch => (domain, epoch, username)));
}
else
{
var recipients = state.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().ToHashSet(StringComparer.Ordinal);
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
expected.AddRange(recipients.Select(recipient => (domain, currentEpoch + 1, recipient)));
}
}
}
var actual = envelopes.Select(e => (e.Domain, e.Epoch, e.Username)).ToList();
if (actual.Count != expected.Count || actual.Distinct().Count() != actual.Count || !actual.ToHashSet().SetEquals(expected)) throw new WebDropException(ErrorCode.BadRequest);
foreach (var envelope in envelopes)
{
if (envelope.ChannelId != channelId || envelope.Payload is null || envelope.Payload.Length is < 1 or > 16_384
|| !Enum.IsDefined(envelope.Domain) || envelope.Epoch < 1) throw new WebDropException(ErrorCode.BadRequest);
var previous = state.Envelopes.Find(e => e.ChannelId == channelId && e.Domain == envelope.Domain && e.Epoch == envelope.Epoch && e.Username == envelope.Username);
if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict);
if (previous is null) state.Envelopes.Add(envelope);
if (envelope.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, envelope.Epoch);
else channel.FilesEpoch = Math.Max(channel.FilesEpoch, envelope.Epoch);
}
state.Access.RemoveAll(a => a.ChannelId == channelId && a.Username == username);
state.Access.Add(new ChannelAccess
{
ChannelId = channelId, Username = username, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true,
ChatFromRevision = channel.ChatHistory ? 0 : state.Revision + 1,
FilesFromRevision = channel.FilesHistory ? 0 : state.Revision + 1,
});
state.Revision++;
workspace.Revision++;
tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state);
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
tx.PutKeyPackage(id, username, payload, clock.NowMs); tx.AllowAccount(id, username);
return 0;
});
events.SettingsChanged(id);
}
private static bool CanReadObject(IResourceTx tx, Channel space, Principal principal, Id16 itemId)
{
var crypto = tx.GetWorkspaceCrypto(space.Id);
if (crypto.Access.Count == 0 && crypto.Envelopes.Count == 0 && space.Mode == ChannelMode.Raw) return true;
if (crypto.Envelopes.Count == 0 && crypto.Objects.Count == 0 && space.Mode == ChannelMode.Enc) return true;
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
if (item is null) return false;
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return true;
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
if (access is null || (item.Domain == CryptoDomain.Chat ? !access.ChatRead || item.CreatedRevision < access.ChatFromRevision : !access.FilesRead || item.CreatedRevision < access.FilesFromRevision)) return false;
if (space.Mode == ChannelMode.Raw) return true;
return crypto.Envelopes.Exists(e => e.ChannelId == item.ChannelId && e.Domain == item.Domain && e.Epoch == item.Epoch && e.Username == principal.AccountName);
}
private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Envelope envelope)
{
var crypto = tx.GetWorkspaceCrypto(space.Id);
if (space.Mode == ChannelMode.Enc && crypto.Envelopes.Count == 0) return;
if (space.Mode == ChannelMode.Raw && crypto.Access.Count == 0 && crypto.Objects.Count == 0) return;
var item = crypto.Objects.Find(o => o.ItemId == envelope.ItemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest);
if (item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
var channel = tx.GetWorkspace(space.Id)?.Channels.Find(c => c.Id == item.ChannelId);
if (space.Mode == ChannelMode.Enc && channel is not null && item.Epoch != (item.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
if (principal.Role != Role.Owner)
{
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
if (access is null && (space.Mode == ChannelMode.Enc || crypto.Access.Count > 0)
|| access is not null && (item.Domain == CryptoDomain.Chat ? !access.ChatWrite : !access.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden);
}
}
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var state = tx.GetWorkspaceCrypto(id);
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state;
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
var allowed = access.ToDictionary(a => a.ChannelId);
bool CanRead(string channelId, CryptoDomain domain) => allowed.TryGetValue(channelId, out var grant)
&& (domain == CryptoDomain.Chat ? grant.ChatRead : grant.FilesRead);
var envelopes = state.Envelopes.Where(e => e.Username == principal.AccountName && CanRead(e.ChannelId, e.Domain)).ToList();
var epochs = envelopes.Select(e => (e.ChannelId, e.Domain, e.Epoch)).ToHashSet();
return new CryptoState { Envelopes = envelopes,
Objects = state.Objects.Where(o => CanRead(o.ChannelId, o.Domain)
&& (space.Mode == ChannelMode.Raw || epochs.Contains((o.ChannelId, o.Domain, o.Epoch)))).ToList(),
Access = access };
});
public void PublishDomain(Id16 id, Credentials creds, DomainPublishRequest req)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest);
var epoch = req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
if (epoch > 1) throw new WebDropException(ErrorCode.Conflict);
if (req.Envelopes[0].Username != principal.AccountName || req.Envelopes[0].ChannelId != req.ChannelId || req.Envelopes[0].Domain != req.Domain || req.Envelopes[0].Epoch != 1
|| req.Envelopes[0].Payload is null || req.Envelopes[0].Payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
var state = tx.GetWorkspaceCrypto(id);
var envelope = req.Envelopes[0];
var previous = state.Envelopes.Find(e => e.ChannelId == envelope.ChannelId && e.Domain == envelope.Domain && e.Epoch == 1 && e.Username == envelope.Username);
if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict);
if (previous is null) state.Envelopes.Add(envelope);
var ownerAccess = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
if (ownerAccess is null) state.Access.Add(new ChannelAccess { ChannelId = req.ChannelId, Username = principal.AccountName!, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true });
if (req.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, req.Epoch); else channel.FilesEpoch = Math.Max(channel.FilesEpoch, req.Epoch);
state.Revision++;
workspace.Revision++; tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state); return 0;
});
events.SettingsChanged(id);
}
public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
var state = tx.GetWorkspaceCrypto(id);
if (principal.Role != Role.Owner && (space.Mode == ChannelMode.Enc || state.Access.Count > 0))
{
var access = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
if (access is null || req.Domain == CryptoDomain.Chat && !access.ChatWrite || req.Domain == CryptoDomain.Files && !access.FilesUpload) throw new WebDropException(ErrorCode.Forbidden);
}
if (space.Mode == ChannelMode.Enc && req.Epoch != (req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
var existing = state.Objects.Find(o => o.ItemId == req.ItemId);
if (existing is not null)
{
if (existing.AuthorAccount != principal.AccountName || existing.ChannelId != req.ChannelId || existing.Domain != req.Domain || existing.Epoch != req.Epoch || existing.KeyEnvelope != req.KeyEnvelope) throw new WebDropException(ErrorCode.Conflict);
return existing;
}
if (state.Objects.Count >= 20_000) throw new WebDropException(ErrorCode.TooLarge);
req.AuthorAccount = principal.AccountName; req.CreatedRevision = state.Revision + 1; state.Revision++; state.Objects.Add(req); tx.PutWorkspaceCrypto(id, state); return req;
});
public void SetChannelHistory(Id16 id, Credentials creds, string channelId, bool chat, bool files)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound);
channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0;
});
events.SettingsChanged(id);
}
}
+115 -17
View File
@@ -24,7 +24,9 @@ public sealed partial class ChannelService(
if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest);
if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest);
var settings = req.Settings ?? new ChannelSettings();
var settings = req.Settings ?? CreationDefaults.Channel(limits);
if (string.IsNullOrWhiteSpace(settings.Name)) throw new WebDropException(ErrorCode.SpaceNameRequired);
settings.Name = settings.Name.Trim();
settings.Validate(limits);
if (!Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
@@ -54,6 +56,7 @@ public sealed partial class ChannelService(
{
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
tx.InsertChannel(channel);
tx.PutWorkspace(id, NewWorkspace(creatorAccount, now));
tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now));
if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now);
tx.InsertSeat(id, new Seat(endpointHash, now, null, creatorAccount, accountDeviceId));
@@ -64,8 +67,8 @@ public sealed partial class ChannelService(
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
{
var (channel, _) = Authenticate(tx, id, creds, SeatRule.None);
return ToInfo(channel, tx.CountSeats(id));
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
return ToInfo(channel, tx.CountMembers(id), principal);
});
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
@@ -88,7 +91,7 @@ public sealed partial class ChannelService(
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id));
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id));
});
events.SettingsChanged(id);
return info;
@@ -117,21 +120,23 @@ public sealed partial class ChannelService(
var existing = tx.GetSeat(id, principal.EndpointHash);
if (existing?.AccountName is not null && existing.AccountName != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired);
if (existing?.AccountDeviceId is not null && existing.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
int used = tx.CountSeats(id);
int membersUsed = tx.CountMembers(id);
bool accountAlreadyMember = creds.AccountName is not null && tx.GetChannelMember(id, creds.AccountName) is not null;
if (existing is not null)
{
if (existing.AccountName is not null && existing.AccountDeviceId is null && creds.AccountDeviceId is not null)
tx.BindSeatDevice(id, principal.EndpointHash, creds.AccountDeviceId);
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false);
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = membersUsed, SeatsMax = channel.Settings.Seats }, false);
}
CheckAdmission(tx, channel, creds, password);
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
bool newMember = creds.AccountName is null || !accountAlreadyMember;
if (newMember && membersUsed >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
long now = clock.NowMs;
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null, creds.AccountName, creds.AccountDeviceId));
if (creds.AccountName is not null) tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", now));
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true);
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = membersUsed + (newMember ? 1 : 0), SeatsMax = channel.Settings.Seats }, true);
});
if (isNew) events.SeatsChanged(id);
return joined;
@@ -199,7 +204,7 @@ public sealed partial class ChannelService(
public async Task<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
long? maxDownloads, CancellationToken ct = default)
long? maxDownloads, CancellationToken ct = default, SignedContentEventProof? signedEvent = null)
{
if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest);
if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge);
@@ -212,6 +217,7 @@ public sealed partial class ChannelService(
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, channel, principal, env);
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
});
if (existing is not null) return Resolve(existing, digest);
@@ -228,7 +234,7 @@ public sealed partial class ChannelService(
AppendOutcome outcome;
try
{
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads));
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, signedEvent));
}
catch
{
@@ -251,10 +257,11 @@ public sealed partial class ChannelService(
private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
byte[] digest, string? blobKey, long? maxDownloads)
byte[] digest, string? blobKey, long? maxDownloads, SignedContentEventProof? signedEvent)
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, channel, principal, env);
// 并发下预检之后可能已有同一帧
if (tx.GetFrame(channelId, env.ItemId, env.FrameIndex) is { } dup)
@@ -263,12 +270,54 @@ public sealed partial class ChannelService(
long now = clock.NowMs;
long ttlMs = channel.Settings.TtlSeconds * 1000L;
var item = tx.GetItem(channelId, env.ItemId);
var crypto = tx.GetWorkspaceCrypto(channelId);
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == env.ItemId.ToBase64Url());
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (objectEnvelope is not null && env.IsFinal)
{
if (signedEvent is null)
{
if (creds.RequireSignedContent) throw new WebDropException(ErrorCode.Forbidden);
}
else
{
if (objectEnvelope.AuthorAccount != principal.AccountName || signedEvent.Username != principal.AccountName || creds.AccountDeviceId != signedEvent.DeviceId
|| objectEnvelope.ChannelId != signedEvent.ChannelId || objectEnvelope.Domain != signedEvent.Domain
|| objectEnvelope.Epoch != signedEvent.Epoch || signedEvent.FrameCount != env.FrameIndex + 1
|| signedEvent.EventIdBytes.Length != 16 || signedEvent.ManifestHash.Length != 32 || signedEvent.Signature.Length != 64
|| objectEnvelope.Event is not null
|| signedEvent.Domain == CryptoDomain.Chat && signedEvent.EventKind is not ("chat.create" or "chat.edit" or "chat.snapshot")
|| signedEvent.Domain == CryptoDomain.Files && signedEvent.EventKind is not ("file.publish" or "file.catalog"))
throw new WebDropException(ErrorCode.Forbidden);
if (signedEvent.EventKind == "chat.edit")
{
if (signedEvent.TargetItemId is null || !Codec.TryDecodeExact(signedEvent.TargetItemId, 16, out var targetId)) throw new WebDropException(ErrorCode.BadRequest);
var targetItem = tx.GetItem(channelId, Id16.FromBytes(targetId));
var targetObject = crypto.Objects.Find(o => o.ItemId == signedEvent.TargetItemId);
if (targetItem is null || targetObject?.Domain != CryptoDomain.Chat || !ChannelPolicy.CanDeleteItem(channel, targetItem, principal))
throw new WebDropException(ErrorCode.Forbidden);
}
else if (signedEvent.TargetItemId is not null) throw new WebDropException(ErrorCode.BadRequest);
var manifestFrames = tx.ListFrames(channelId, env.ItemId, 0, limits.MaxFramesPerItem);
if (manifestFrames.Count != env.FrameIndex) throw new WebDropException(ErrorCode.Conflict);
var digests = new List<byte[]>(manifestFrames.Count + 1);
for (int i = 0; i < manifestFrames.Count; i++)
{
if (manifestFrames[i].Index != i) throw new WebDropException(ErrorCode.Conflict);
digests.Add(manifestFrames[i].Digest);
}
digests.Add(digest);
if (!CryptographicOperations.FixedTimeEquals(SignatureCodec.FrameManifestHash(digests), signedEvent.ManifestHash))
throw new WebDropException(ErrorCode.Conflict);
}
}
if (env.FrameIndex == 0)
{
if (env.IsControl && tx.CountControlItems(channelId) >= 4096) throw new WebDropException(ErrorCode.TooLarge);
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
long max = env.IsGated ? Math.Clamp(maxDownloads ?? channel.Settings.FileMaxDownloads, 0, 1_000_000) : 0;
long max = env.IsGated ? maxDownloads ?? channel.Settings.FileMaxDownloads : 0;
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(max, channel.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
tx.InsertItem(item);
@@ -293,6 +342,13 @@ public sealed partial class ChannelService(
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest,
blobKey is null ? body.ToArray() : null, blobKey));
if (signedEvent is not null && objectEnvelope is not null)
{
objectEnvelope.Event = ToSignedContentEventRecord(signedEvent);
crypto.Revision++;
tx.PutWorkspaceCrypto(channelId, crypto);
}
if (!env.IsGated)
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Frame, env.ItemId, env.FrameIndex, 0, now));
else if (env.FrameIndex == 0)
@@ -386,6 +442,7 @@ public sealed partial class ChannelService(
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
foreach (var e in tx.ReadLog(id, from, limit))
{
if (e.ItemId != default && !CanReadObject(tx, channel, principal, e.ItemId)) continue;
switch (e.Kind)
{
case LogKind.Frame:
@@ -420,9 +477,10 @@ public sealed partial class ChannelService(
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
long now = clock.NowMs;
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
@@ -454,9 +512,10 @@ public sealed partial class ChannelService(
maxFrames = Math.Clamp(maxFrames, 1, 64);
var (frames, next, complete) = store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
@@ -488,7 +547,13 @@ public sealed partial class ChannelService(
// ───────────────────────── 删除 ─────────────────────────
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId)
public ObjectEnvelope? ObjectSigningContext(Id16 id, Credentials creds, Id16 itemId) => store.Read(tx =>
{
_ = Authenticate(tx, id, creds, SeatRule.Required);
return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url());
});
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId, SignedContentEventProof? signedEvent = null)
{
var (seq, prefixes) = store.Write(tx =>
{
@@ -496,6 +561,26 @@ public sealed partial class ChannelService(
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
var crypto = tx.GetWorkspaceCrypto(id);
var obj = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
if (creds.RequireSignedContent && obj is not null)
{
if (signedEvent is null || obj?.Event is null || signedEvent.Username != principal.AccountName
|| signedEvent.DeviceId != creds.AccountDeviceId || signedEvent.ChannelId != obj.ChannelId
|| signedEvent.Domain != obj.Domain || signedEvent.Epoch != obj.Epoch
|| signedEvent.FrameCount != obj.Event.FrameCount || signedEvent.ManifestHash.Length != 32
|| !Codec.TryDecodeExact(obj.Event.ManifestHash, 32, out var oldManifest)
|| !CryptographicOperations.FixedTimeEquals(signedEvent.ManifestHash, oldManifest)
|| signedEvent.EventKind != (obj.Domain == CryptoDomain.Chat ? "chat.delete" : "file.delete")
|| signedEvent.TargetItemId is not null || signedEvent.EventIdBytes.Length != 16 || signedEvent.Signature.Length != 64
|| obj.DeleteEvent is not null)
throw new WebDropException(ErrorCode.Forbidden);
obj.DeleteEvent = ToSignedContentEventRecord(signedEvent);
crypto.Revision++;
tx.PutWorkspaceCrypto(id, crypto);
}
else if (signedEvent is not null) throw new WebDropException(ErrorCode.BadRequest);
var list = new List<string>();
long s = DeleteItemCore(tx, id, item, TombstoneReason.Deleted, list);
return (s, list);
@@ -514,6 +599,17 @@ public sealed partial class ChannelService(
return seq;
}
private static SignedContentEventRecord ToSignedContentEventRecord(SignedContentEventProof signedEvent) => new()
{
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
SignerDeviceId = signedEvent.DeviceId,
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
TargetItemId = signedEvent.TargetItemId,
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
DeviceCertificateSignature = Convert.ToBase64String(signedEvent.DeviceCertificateSignature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
};
// ───────────────────────── 后台清理 ─────────────────────────
/// <summary>清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。</summary>
@@ -618,14 +714,16 @@ public sealed partial class ChannelService(
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
}
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
{
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, [], channel.CreatorAccount)),
CanManageStructure = principal is null || principal.Role == Role.Owner,
Id = channel.Id.ToBase64Url(),
CreatorAccount = channel.CreatorAccount,
PasswordRequired = channel.JoinPasswordHash is not null,
Mode = channel.Mode,
Settings = channel.Settings,
SeatsUsed = seatsUsed,
SeatsUsed = membersUsed,
OldestSeq = channel.CompactedThrough + 1,
LastSeq = channel.LastSeq,
CreatedAt = channel.CreatedAt,
+139
View File
@@ -0,0 +1,139 @@
namespace WebDrop.Core;
public sealed partial class ChannelService
{
public void AccountProfileChanged(string username)
{
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
}
public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
{
var channel = tx.GetChannel(id)!;
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
return ToInfo(channel, tx.CountMembers(id), new Principal(role, [], username));
}).ToList());
private static WorkspaceState NewWorkspace(string username, long now)
{
var id = Guid.NewGuid().ToString();
return new() { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] };
}
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
if (principal.AccountName != space.CreatorAccount)
{
var crypto = tx.GetWorkspaceCrypto(id);
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
}
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
return state;
});
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true)
{
var state = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (requireAccount && principal.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden);
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
change(tx, current, principal);
ValidateWorkspace(current);
current.Revision++;
tx.PutWorkspace(id, current);
return current;
});
events.SettingsChanged(id);
return state;
}
public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId)
{
WorkspaceChannel? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, principal) =>
{
created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName! };
workspace.Channels.Add(created);
});
return (state, created!);
}
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).Name = WorkspaceName(name));
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).GroupId = groupId);
public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name)
{
WorkspaceGroup? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, _) =>
{
created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs };
workspace.Groups.Add(created);
});
return (state, created!);
}
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) =>
{
if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest);
foreach (var itemId in itemIds)
{
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
if (principal.Role != Role.Owner && (item.AuthorAccount is not null ? item.AuthorAccount != principal.AccountName : !item.AuthorHash.AsSpan().SequenceEqual(principal.EndpointHash))) throw new WebDropException(ErrorCode.Forbidden);
state.Locations.RemoveAll(l => l.ItemId == itemId);
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
}
}, requireAccount: false);
private static WorkspaceChannel FindChannel(WorkspaceState state, string id) => state.Channels.Find(c => c.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
private static string WorkspaceName(string name)
{
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 80 || name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
return name.Trim();
}
private static void ValidateWorkspace(WorkspaceState state)
{
if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null
|| state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000)
throw new WebDropException(ErrorCode.BadRequest);
if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest);
var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).ToArray();
if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest);
foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name);
foreach (var channel in state.Channels)
{
channel.Name = WorkspaceName(channel.Name);
if (channel.GroupId is not null && !state.Groups.Any(g => g.Id == channel.GroupId)) throw new WebDropException(ErrorCode.BadRequest);
}
foreach (var folder in state.Folders)
{
folder.Name = WorkspaceName(folder.Name);
_ = FindChannel(state, folder.ThreadId);
var visited = new HashSet<string> { folder.Id };
for (var parentId = folder.ParentId; parentId is not null;)
{
var parent = state.Folders.Find(f => f.Id == parentId && f.ThreadId == folder.ThreadId);
if (parent is null || !visited.Add(parent.Id)) throw new WebDropException(ErrorCode.BadRequest);
parentId = parent.ParentId;
}
}
if (state.Locations.Select(l => l.ItemId).Distinct().Count() != state.Locations.Count) throw new WebDropException(ErrorCode.BadRequest);
foreach (var location in state.Locations)
{
_ = FindChannel(state, location.ThreadId);
if (location.FolderId is not null && !state.Folders.Any(f => f.Id == location.FolderId && f.ThreadId == location.ThreadId)) throw new WebDropException(ErrorCode.BadRequest);
}
}
}
+4
View File
@@ -52,11 +52,14 @@ public sealed class ShareItemInfo
public sealed class ChannelInfo
{
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
/// <summary>从该 seq 起的续传有效;0 &lt; from &lt; oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
@@ -71,6 +74,7 @@ public sealed class SeatJoined
{
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
/// <summary>加入后的成员人数,同一账号的其他终端不增加此值。</summary>
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
[JsonPropertyName("seats_max")] public required int SeatsMax { get; init; }
}
+14
View File
@@ -9,6 +9,20 @@ public enum ChannelMode : byte
[JsonStringEnumMemberName("raw")] Raw = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<CryptoDomain>))]
public enum CryptoDomain : byte
{
[JsonStringEnumMemberName("chat")] Chat = 1,
[JsonStringEnumMemberName("files")] Files = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<JoinPolicy>))]
public enum JoinPolicy : byte
{
[JsonStringEnumMemberName("open")] Open = 1,
[JsonStringEnumMemberName("invite")] Invite = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<DeletePolicy>))]
public enum DeletePolicy : byte
{
+3
View File
@@ -21,6 +21,7 @@ public enum ErrorCode
InvitationRequired,
ChannelPasswordRequired,
ChannelPasswordInvalid,
SpaceNameRequired,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -61,6 +62,7 @@ public static class ErrorCodes
ErrorCode.InvitationRequired => "invitation_required",
ErrorCode.ChannelPasswordRequired => "channel_password_required",
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
ErrorCode.SpaceNameRequired => "space_name_required",
_ => "bad_request",
};
@@ -72,6 +74,7 @@ public static class ErrorCodes
ErrorCode.NotFound => 404,
ErrorCode.Conflict => 409,
ErrorCode.Sealed => 409,
ErrorCode.SpaceNameRequired => 400,
ErrorCode.Gone => 410,
ErrorCode.TooLarge => 413,
ErrorCode.CaptchaRequired => 428,
+2 -1
View File
@@ -104,4 +104,5 @@ public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameInd
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
/// <summary>请求携带的凭据,尚未验证。</summary>
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null);
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null,
bool RequireSignedContent = false);
+33 -4
View File
@@ -8,7 +8,9 @@ public sealed class ServerLimits
public int MaxPayloadBytes { get; set; } = (1 << 20) + 16;
public int MaxFramesPerItem { get; set; } = 65_536;
public long MaxChannelBytes { get; set; } = 1L << 30;
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
public int MaxSeats { get; set; } = 64;
public int MaxVisitors { get; set; } = 10_000;
public int MinTtlSeconds { get; set; } = 60;
public int MaxTtlSeconds { get; set; } = 604_800;
public int MaxItemsLimit { get; set; } = 10_000;
@@ -18,11 +20,19 @@ public sealed class ServerLimits
public int MaxSeatLabelBytes { get; set; } = 256;
}
public static class ServerSettingChoices
{
public static readonly int[] FileMaxDownloads = [0, 1, 3, 5, 10, 20];
public static bool IsFileMaxDownloadsAllowed(long value, int currentDefault) => value == currentDefault
|| value is >= int.MinValue and <= int.MaxValue && Array.IndexOf(FileMaxDownloads, (int)value) >= 0;
}
public sealed class ChannelSettings
{
[JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open";
[JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open;
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
[JsonPropertyName("name")] public string? Name { get; set; }
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
[JsonPropertyName("seats")] public int Seats { get; set; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own;
@@ -33,7 +43,7 @@ public sealed class ChannelSettings
public void Validate(ServerLimits limits)
{
if (JoinPolicy is not ("open" or "invite")) throw new WebDropException(ErrorCode.BadRequest);
if (!Enum.IsDefined(JoinPolicy)) throw new WebDropException(ErrorCode.BadRequest);
if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest);
if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest);
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest);
@@ -44,7 +54,7 @@ public sealed class ChannelSettings
public sealed class ChannelSettingsPatch
{
[JsonPropertyName("join_policy")] public string? JoinPolicy { get; init; }
[JsonPropertyName("join_policy")] public JoinPolicy? JoinPolicy { get; init; }
[JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
@@ -80,7 +90,7 @@ public sealed class ShareSettings
public void Validate(ServerLimits limits)
{
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds
|| MaxVisitors < 0 || MaxVisitors > 10_000
|| MaxVisitors < 0 || MaxVisitors > limits.MaxVisitors
|| FileMaxDownloads < 0)
throw new WebDropException(ErrorCode.BadRequest);
}
@@ -99,3 +109,22 @@ public sealed class ShareSettingsPatch
FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads,
};
}
/// <summary>创建接口与 /info 使用同一个配置来源;旧存储对象的字段默认值不作迁移。</summary>
public static class CreationDefaults
{
public const ChannelMode ChannelMode = WebDrop.Core.ChannelMode.Enc;
public static ChannelSettings Channel(ServerLimits limits) => new()
{
JoinPolicy = JoinPolicy.Invite, AllowGuests = false,
Seats = Math.Min(10, limits.MaxSeats),
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
};
public static ShareSettings Share(ServerLimits limits) => new()
{
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
MaxVisitors = Math.Min(10, limits.MaxVisitors),
};
}
+2 -1
View File
@@ -18,7 +18,7 @@ public sealed class ShareService(
|| !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
throw new WebDropException(ErrorCode.BadRequest);
var settings = req.Settings ?? new ShareSettings();
var settings = req.Settings ?? CreationDefaults.Share(limits);
settings.Validate(limits);
long now = clock.NowMs;
var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now,
@@ -102,6 +102,7 @@ public sealed class ShareService(
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
if (tx.CountShareItems(id) >= limits.MaxItemsLimit) throw new WebDropException(ErrorCode.TooLarge);
long cap = env.IsGated ? maxDownloads ?? share.Settings.FileMaxDownloads : 0;
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(cap, share.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
item = new ShareItem(id, env.ItemId, env.ItemFlags, cap, 0, 0, -1, null,
0, now, share.ExpiresAt, null);
tx.InsertShareItem(item);
+122
View File
@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using System.Text;
using System.Buffers.Binary;
namespace WebDrop.Core;
/// <summary>Protocol signature framing. Payload encodings are defined by their signed statement type.</summary>
public static class SignatureCodec
{
private static ReadOnlySpan<byte> Magic => [0x57, 0x44, 0x53, 0x01]; // WDS\x01
private const int MaxKindBytes = 64;
private const int MaxPayloadBytes = 1_048_576;
public static byte[] DeviceCertificatePayload(string username, string deviceId, ReadOnlySpan<byte> x, ReadOnlySpan<byte> y)
{
if (string.IsNullOrEmpty(username) || username.Length > 32 || username.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-'))
|| !Guid.TryParseExact(deviceId, "D", out _) || x.Length != 32 || y.Length != 32)
throw new ArgumentException("Invalid device certificate fields.");
var user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
var device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
var payload = new byte[1 + user.Length + 1 + device.Length + 64];
int offset = 0;
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
x.CopyTo(payload.AsSpan(offset)); y.CopyTo(payload.AsSpan(offset + 32));
return payload;
}
public static byte[] ContentEventPayload(ReadOnlySpan<byte> spaceId, string channelId, string username, string deviceId,
ReadOnlySpan<byte> eventId, string eventKind, CryptoDomain domain, int epoch, ReadOnlySpan<byte> itemId,
int frameCount, ReadOnlySpan<byte> manifestHash, ReadOnlySpan<byte> targetItemId)
{
if (spaceId.Length != 16 || eventId.Length != 16 || itemId.Length != 16 || manifestHash.Length != 32
|| targetItemId.Length is not (0 or 16) || epoch < 0 || frameCount < 1
|| !Guid.TryParseExact(channelId, "D", out _) || !Enum.IsDefined(domain)
|| !IsAsciiIdentity(username, 32) || !Guid.TryParseExact(deviceId, "D", out _)
|| eventKind.Length is < 1 or > 32 || eventKind.Any(c => c is not (>= 'a' and <= 'z') and not '.' and not '-'))
throw new ArgumentException("Invalid signed content event fields.");
byte[] channel = Encoding.ASCII.GetBytes(channelId.ToLowerInvariant());
byte[] user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
byte[] device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
byte[] kind = Encoding.ASCII.GetBytes(eventKind);
var payload = new byte[16 + 1 + channel.Length + 1 + user.Length + 1 + device.Length + 16 + 1 + kind.Length + 1 + 4 + 16 + 4 + 32 + 1 + 16];
int offset = 0;
spaceId.CopyTo(payload.AsSpan(offset)); offset += 16;
payload[offset++] = (byte)channel.Length; channel.CopyTo(payload, offset); offset += channel.Length;
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
eventId.CopyTo(payload.AsSpan(offset)); offset += 16;
payload[offset++] = (byte)kind.Length; kind.CopyTo(payload, offset); offset += kind.Length;
payload[offset++] = (byte)domain;
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)epoch); offset += 4;
itemId.CopyTo(payload.AsSpan(offset)); offset += 16;
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)frameCount); offset += 4;
manifestHash.CopyTo(payload.AsSpan(offset)); offset += 32;
payload[offset++] = targetItemId.IsEmpty ? (byte)0 : (byte)1;
if (!targetItemId.IsEmpty) targetItemId.CopyTo(payload.AsSpan(offset));
return payload;
}
public static byte[] FrameManifestHash(IReadOnlyList<byte[]> frameDigests)
{
if (frameDigests.Count is < 1 or > 65_536 || frameDigests.Any(d => d.Length != 32)) throw new ArgumentException("Invalid frame manifest.");
var data = new byte[8 + frameDigests.Count * 36];
"WD1M"u8.CopyTo(data);
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(4), (uint)frameDigests.Count);
int offset = 8;
for (int i = 0; i < frameDigests.Count; i++)
{
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(offset), (uint)i); offset += 4;
frameDigests[i].CopyTo(data, offset); offset += 32;
}
return SHA256.HashData(data);
}
private static bool IsAsciiIdentity(string value, int maxLength) => value.Length is >= 3 && value.Length <= maxLength
&& value.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-');
public static byte[] BuildMessage(string kind, ReadOnlySpan<byte> payload)
{
if (string.IsNullOrEmpty(kind) || kind.Length > MaxKindBytes
|| kind[0] is < 'a' or > 'z'
|| kind.Any(c => c is not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not '.' and not '_' and not '-'))
throw new ArgumentException("Invalid signature kind.", nameof(kind));
if (payload.Length > MaxPayloadBytes) throw new ArgumentOutOfRangeException(nameof(payload));
var kindBytes = Encoding.ASCII.GetBytes(kind);
var message = new byte[Magic.Length + 2 + kindBytes.Length + 4 + payload.Length];
int offset = 0;
Magic.CopyTo(message.AsSpan(offset)); offset += Magic.Length;
message[offset++] = (byte)(kindBytes.Length >> 8);
message[offset++] = (byte)kindBytes.Length;
kindBytes.CopyTo(message, offset); offset += kindBytes.Length;
uint length = (uint)payload.Length;
message[offset++] = (byte)(length >> 24);
message[offset++] = (byte)(length >> 16);
message[offset++] = (byte)(length >> 8);
message[offset++] = (byte)length;
payload.CopyTo(message.AsSpan(offset));
return message;
}
/// <summary>Verifies WebCrypto ECDSA P-256/SHA-256 signatures in fixed-width IEEE P1363 form.</summary>
public static bool VerifyP256(ReadOnlySpan<byte> x, ReadOnlySpan<byte> y, string kind,
ReadOnlySpan<byte> payload, ReadOnlySpan<byte> signature)
{
if (x.Length != 32 || y.Length != 32 || signature.Length != 64) return false;
try
{
using var key = ECDsa.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = x.ToArray(), Y = y.ToArray() },
});
return key.VerifyData(BuildMessage(kind, payload), signature, HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (ArgumentException) { return false; }
catch (CryptographicException) { return false; }
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class WorkspaceChannel
{
[JsonPropertyName("chatEpoch")] public int ChatEpoch { get; set; }
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
[JsonPropertyName("createdBy")] public required string CreatedBy { get; init; }
[JsonPropertyName("groupId")] public string? GroupId { get; set; }
}
public sealed class WorkspaceGroup
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
}
public sealed class WorkspaceFolder
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
}
public sealed class WorkspaceLocation
{
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
}
public sealed class WorkspaceState
{
[JsonPropertyName("defaultChannelId")] public string? DefaultChannelId { get; init; }
[JsonPropertyName("revision")] public long Revision { get; set; }
[JsonPropertyName("channels")] public List<WorkspaceChannel> Channels { get; init; } = [];
[JsonPropertyName("groups")] public List<WorkspaceGroup> Groups { get; init; } = [];
[JsonPropertyName("folders")] public List<WorkspaceFolder> Folders { get; init; } = [];
[JsonPropertyName("locations")] public List<WorkspaceLocation> Locations { get; set; } = [];
}
[JsonSerializable(typeof(WorkspaceState))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class WorkspaceJson : JsonSerializerContext;
@@ -0,0 +1,83 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class DomainEnvelope
{
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
}
public sealed class ObjectEnvelope
{
[JsonPropertyName("createdRevision")] public long CreatedRevision { get; set; }
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("keyEnvelope")] public required string KeyEnvelope { get; init; }
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; set; }
[JsonPropertyName("event")] public SignedContentEventRecord? Event { get; set; }
[JsonPropertyName("deleteEvent")] public SignedContentEventRecord? DeleteEvent { get; set; }
}
public sealed class SignedContentEventRecord
{
[JsonPropertyName("eventId")] public required string EventId { get; init; }
[JsonPropertyName("kind")] public required string Kind { get; init; }
[JsonPropertyName("frameCount")] public required int FrameCount { get; init; }
[JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; }
[JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; }
[JsonPropertyName("signature")] public required string Signature { get; init; }
[JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; }
[JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; }
[JsonPropertyName("signerDevicePublicKey")] public required string SignerDevicePublicKey { get; init; }
[JsonPropertyName("deviceCertificateSignature")] public required string DeviceCertificateSignature { get; init; }
}
public sealed record SignedContentEventProof(byte[] EventIdBytes, string EventKind, string Username, string ChannelId, CryptoDomain Domain, int Epoch,
int FrameCount, byte[] ManifestHash, string DeviceId, byte[] Signature, string? TargetItemId,
string RootPublicKey, string DevicePublicKey, byte[] DeviceCertificateSignature);
public sealed class ChannelAccess
{
[JsonPropertyName("chatFromRevision")] public long ChatFromRevision { get; set; }
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("filesFromRevision")] public long FilesFromRevision { get; set; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("chatRead")] public bool ChatRead { get; set; } = true;
[JsonPropertyName("chatWrite")] public bool ChatWrite { get; set; } = true;
[JsonPropertyName("filesRead")] public bool FilesRead { get; set; } = true;
[JsonPropertyName("filesUpload")] public bool FilesUpload { get; set; } = true;
}
public sealed class CryptoGrantBatch
{
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("recipients")] public required List<string> Recipients { get; init; }
[JsonPropertyName("rotate")] public required bool Rotate { get; init; }
}
public sealed class InvitationCryptoPlan
{
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("batches")] public required List<CryptoGrantBatch> Batches { get; init; }
}
public sealed class CryptoState
{
[JsonPropertyName("revision")] public long Revision { get; set; }
[JsonPropertyName("envelopes")] public List<DomainEnvelope> Envelopes { get; init; } = [];
[JsonPropertyName("objects")] public List<ObjectEnvelope> Objects { get; init; } = [];
[JsonPropertyName("access")] public List<ChannelAccess> Access { get; init; } = [];
}
public sealed class DomainPublishRequest
{
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("envelopes")] public required List<DomainEnvelope> Envelopes { get; init; }
}
[JsonSerializable(typeof(CryptoState))]
public sealed partial class WorkspaceCryptoJson : JsonSerializerContext;
@@ -0,0 +1,41 @@
using WebDrop.Core;
namespace WebDrop.Storage;
public sealed partial class SqliteAccountStore
{
public AccountProfile Profile(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT display_name,avatar,modified_at FROM account_profiles WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username);
using var reader = cmd.ExecuteReader();
return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), ModifiedAt = reader.GetInt64(2) }
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
}
}
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
{
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
if (avatar is not null)
{
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
if (prefix is null || avatar.Length > 350_000 || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
try { _ = Convert.FromBase64String(avatar[prefix.Length..]); }
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
}
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,modified_at) VALUES($u,$n,$a,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)";
cmd.Parameters.AddWithValue("$u", username);
cmd.Parameters.AddWithValue("$n", displayName.Trim());
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
cmd.ExecuteNonQuery();
return Profile(username);
}
}
}
@@ -0,0 +1,44 @@
using Microsoft.Data.Sqlite;
namespace WebDrop.Storage;
public sealed record AccountDeviceSigningKey(string PublicKey, byte[] CertificateSignature);
public sealed partial class SqliteAccountStore
{
public AccountDeviceSigningKey? DeviceSigningKey(string username, string deviceId)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT public_key,certificate_signature FROM account_device_signing_keys WHERE username=$u AND device_id=$d";
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
using var reader = cmd.ExecuteReader();
return reader.Read() ? new(reader.GetString(0), (byte[])reader[1]) : null;
}
}
public bool SaveDeviceSigningKey(string username, string deviceId, string publicKey, byte[] signature)
{
lock (_gate)
{
using var device = _connection.CreateCommand();
device.CommandText = "SELECT 1 FROM account_devices WHERE username=$u AND id=$d";
device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId);
if (device.ExecuteScalar() is null) return false;
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO account_device_signing_keys(username,device_id,public_key,certificate_signature,modified_at)
VALUES($u,$d,$p,$s,$m)
ON CONFLICT(username,device_id) DO UPDATE SET public_key=excluded.public_key,
certificate_signature=excluded.certificate_signature,modified_at=excluded.modified_at
""";
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$s", signature);
cmd.Parameters.AddWithValue("$m", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
return cmd.ExecuteNonQuery() == 1;
}
}
}
@@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite;
namespace WebDrop.Storage;
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
public sealed class SqliteAccountStore : IDisposable
public sealed partial class SqliteAccountStore : IDisposable
{
private readonly SqliteConnection _connection;
private readonly object _gate = new();
@@ -20,8 +20,25 @@ public sealed class SqliteAccountStore : IDisposable
CREATE TABLE IF NOT EXISTS account_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS account_devices (username TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY(username,id));
CREATE TABLE IF NOT EXISTS account_device_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, device_id TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS account_device_signing_keys (username TEXT NOT NULL, device_id TEXT NOT NULL, public_key TEXT NOT NULL, certificate_signature BLOB NOT NULL, modified_at INTEGER NOT NULL, PRIMARY KEY(username,device_id));
CREATE TABLE IF NOT EXISTS account_profiles (username TEXT PRIMARY KEY COLLATE NOCASE, display_name TEXT NOT NULL, avatar TEXT, modified_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS account_passkeys (id TEXT PRIMARY KEY, username TEXT NOT NULL, name TEXT NOT NULL, data TEXT NOT NULL, prf_salt TEXT NOT NULL, key_envelope TEXT, created_at INTEGER NOT NULL);
""";
cmd.ExecuteNonQuery();
using (var columns = _connection.CreateCommand())
{
columns.CommandText = "PRAGMA table_info(accounts)";
using var reader = columns.ExecuteReader();
bool hasSigningKey = false;
while (reader.Read()) if (reader.GetString(1) == "signing_public_key") hasSigningKey = true;
reader.Close();
if (!hasSigningKey)
{
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE accounts ADD COLUMN signing_public_key TEXT";
alter.ExecuteNonQuery();
}
}
}
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
@@ -45,7 +62,7 @@ public sealed class SqliteAccountStore : IDisposable
}
}
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey)
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey, string? signingPublicKey = null)
{
lock (_gate)
{
@@ -53,10 +70,11 @@ public sealed class SqliteAccountStore : IDisposable
{
using var cmd = _connection.CreateCommand();
var verifierSalt = RandomNumberGenerator.GetBytes(16);
cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt) VALUES($u,$s,$v,$b,1,$p,$vs)";
cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt,signing_public_key) VALUES($u,$s,$v,$b,1,$p,$vs,$sp)";
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$s", salt);
cmd.Parameters.AddWithValue("$v", HashVerifier(verifier, verifierSalt)); cmd.Parameters.AddWithValue("$b", vault);
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$vs", verifierSalt);
cmd.Parameters.AddWithValue("$sp", (object?)signingPublicKey ?? DBNull.Value);
cmd.ExecuteNonQuery(); return true;
}
catch (SqliteException e) when (e.SqliteErrorCode == 19) { return false; }
@@ -77,6 +95,26 @@ public sealed class SqliteAccountStore : IDisposable
r.Close();
var expected = HashVerifier(suppliedVerifier, verifierSalt);
if (!CryptographicOperations.FixedTimeEquals(verifier, expected) || !found) return null;
return CreateDeviceSession(username, deviceId, deviceName);
}
}
public bool VerifyLogin(string username, byte[] verifier)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT verifier,verifier_salt FROM accounts WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader();
var found = reader.Read(); var expected = found ? (byte[])reader[0] : new byte[32]; var salt = found ? (byte[])reader[1] : new byte[16];
return CryptographicOperations.FixedTimeEquals(expected, HashVerifier(verifier, salt)) && found;
}
}
public string? CreateDeviceSession(string username, string? deviceId, string? deviceName)
{
lock (_gate)
{
using var exists = _connection.CreateCommand(); exists.CommandText = "SELECT 1 FROM accounts WHERE username=$u";
exists.Parameters.AddWithValue("$u", username); if (exists.ExecuteScalar() is null) return null;
if (deviceId is not null && (!Guid.TryParseExact(deviceId, "D", out _) || deviceName is null || deviceName.Length is < 1 or > 80 || deviceName.Any(char.IsControl))) return null;
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
using var transaction = _connection.BeginTransaction();
@@ -231,6 +269,16 @@ public sealed class SqliteAccountStore : IDisposable
}
}
public string? GetSigningPublicKey(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT signing_public_key FROM accounts WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username);
return cmd.ExecuteScalar() as string;
}
}
}
public sealed record AccountDevice(string Id, string Name, long CreatedAt, long LastSeen, bool Current);
@@ -44,6 +44,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
""");
}
@@ -253,6 +255,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
{
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceJson.Default.WorkspaceState)!, ("@id", id.ToArray()));
public CryptoState GetWorkspaceCrypto(Id16 id) => One("SELECT state FROM workspace_crypto WHERE space_id=@id",
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceCryptoJson.Default.CryptoState)!, ("@id", id.ToArray())) ?? new CryptoState();
public void PutWorkspaceCrypto(Id16 id, CryptoState state) => Run(
"INSERT INTO workspace_crypto(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceCryptoJson.Default.CryptoState)));
public IReadOnlyList<Id16> ListAccountSpaces(string username, long now) => Many(
"SELECT c.id FROM channels c JOIN channel_members m ON m.channel_id=c.id WHERE m.username=@u AND c.expires_at>@n ORDER BY c.created_at DESC",
r => Id16.FromBytes((byte[])r[0]), ("@u", username), ("@n", now));
public void PutWorkspace(Id16 id, WorkspaceState state) => Run(
"INSERT INTO workspace_structures(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceJson.Default.WorkspaceState)));
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, author_account";
private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline";
@@ -487,6 +502,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key));
Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key));
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
}
@@ -505,6 +522,9 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
public int CountMembers(Id16 channelId) =>
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run(
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
@@ -0,0 +1,61 @@
using WebDrop.Core;
namespace WebDrop.Storage;
public sealed record StoredPasskey(string Id, string Username, string Name, string Data, string PrfSalt, string? KeyEnvelope, long CreatedAt);
public sealed partial class SqliteAccountStore
{
public IReadOnlyList<StoredPasskey> Passkeys(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT id,username,name,data,prf_salt,key_envelope,created_at FROM account_passkeys WHERE username=$u ORDER BY created_at";
cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader(); var rows = new List<StoredPasskey>();
while (reader.Read()) rows.Add(new(reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4), reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetInt64(6)));
return rows;
}
}
public StoredPasskey? Passkey(string id)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT username FROM account_passkeys WHERE id=$i"; cmd.Parameters.AddWithValue("$i", id);
return cmd.ExecuteScalar() is string username ? Passkeys(username).FirstOrDefault(p => p.Id == id) : null;
}
}
public void AddPasskey(StoredPasskey passkey)
{
lock (_gate)
{
if (Passkeys(passkey.Username).Count >= 16 || Passkey(passkey.Id) is not null) throw new WebDropException(ErrorCode.Conflict);
using var cmd = _connection.CreateCommand(); cmd.CommandText = "INSERT INTO account_passkeys(id,username,name,data,prf_salt,key_envelope,created_at) VALUES($i,$u,$n,$d,$s,$k,$t)";
cmd.Parameters.AddWithValue("$i", passkey.Id); cmd.Parameters.AddWithValue("$u", passkey.Username); cmd.Parameters.AddWithValue("$n", passkey.Name);
cmd.Parameters.AddWithValue("$d", passkey.Data); cmd.Parameters.AddWithValue("$s", passkey.PrfSalt); cmd.Parameters.AddWithValue("$k", (object?)passkey.KeyEnvelope ?? DBNull.Value); cmd.Parameters.AddWithValue("$t", passkey.CreatedAt); cmd.ExecuteNonQuery();
}
}
public bool UpdatePasskeyData(string id, string username, string oldData, string newData)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET data=$n WHERE id=$i AND username=$u AND data=$old";
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$n", newData); cmd.Parameters.AddWithValue("$old", oldData); return cmd.ExecuteNonQuery() == 1;
}
}
public void UpdatePasskeyEnvelope(string id, string username, string envelope)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET key_envelope=$k WHERE id=$i AND username=$u";
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$k", envelope);
if (cmd.ExecuteNonQuery() != 1) throw new WebDropException(ErrorCode.NotFound);
}
}
public void RemovePasskey(string id, string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "DELETE FROM account_passkeys WHERE id=$i AND username=$u";
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.ExecuteNonQuery();
}
}
}
@@ -12,11 +12,14 @@ namespace WebDrop.Api.Tests;
public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<ServerFixture>
{
private sealed record TestSigner(string Username, string DeviceId, byte[] PrivateKey);
private readonly Dictionary<string, TestSigner> _signers = new(StringComparer.Ordinal);
[Fact]
public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation()
{
var ch = await server.CreateAsync(settings: new { seats = 8 });
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = Register(username);
var ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 8 });
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await Register(username);
var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(ownerPackage)).StatusCode);
@@ -25,8 +28,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
var rows = await ReadJsonAsync(await server.Http.SendAsync(ownerList));
Assert.Contains(rows.EnumerateArray(), row => row.GetProperty("channel_id").GetString() == ch.Id && row.GetProperty("role").GetString() == "owner");
}
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-member-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
using (var invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray());
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token))
@@ -46,6 +48,111 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
await AssertErrorAsync(await server.Http.SendAsync(anonymous), 401, "account_required");
}
[Fact]
public async Task DeviceSigningKeyRequiresAndStoresAnAccountRootCertificate()
{
const string username = "signing_device";
var verifier = RandomNumberGenerator.GetBytes(32); var salt = RandomNumberGenerator.GetBytes(16);
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var invitationPoint = invitation.ExportParameters(false).Q;
var rootPoint = root.ExportParameters(false).Q;
string invitePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitationPoint.X!), y = B64(invitationPoint.Y!) });
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
var vault = new byte[28];
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.True(accounts.Register(username, salt, verifier, vault, invitePublic, rootPublic));
var deviceId = Guid.NewGuid().ToString("D");
var token = accounts.Authenticate(username, verifier, deviceId, "Desktop")!;
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var devicePoint = device.ExportParameters(false).Q;
byte[] x = devicePoint.X!; byte[] y = devicePoint.Y!;
var certificate = SignatureCodec.DeviceCertificatePayload(username, deviceId, x, y);
var signature = root.SignData(SignatureCodec.BuildMessage("device.certificate", certificate), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
var requestBody = new { device_id = deviceId, public_key = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(x), y = B64(y) }), signature = Convert.ToBase64String(signature) };
using var register = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key") { Content = JsonBody(requestBody) };
register.Headers.Add("X-WD-Account", token);
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(register)).StatusCode);
signature[0] ^= 1;
using var forged = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
{ Content = JsonBody(requestBody with { signature = Convert.ToBase64String(signature) }) };
forged.Headers.Add("X-WD-Account", token);
await AssertErrorAsync(await server.Http.SendAsync(forged), 403, "forbidden");
using var publicKeyRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/users/{username}/key");
publicKeyRequest.Headers.Add("X-WD-Account", token);
var identity = await ReadJsonAsync(await server.Http.SendAsync(publicKeyRequest));
Assert.Equal(rootPublic, identity.GetProperty("signing_public_key").GetString());
byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32);
var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId();
using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token,
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", seats = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode);
string channelId;
using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token))
using (var response = await server.Http.SendAsync(structure))
channelId = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
string itemId = NewId();
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{spaceId}/crypto/objects", owner, token,
new { itemId, channelId, domain = "chat", epoch = 0, keyEnvelope = "" }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
byte[] frame = Frame(itemId, 0, 1, 16);
Codec.TryDecodeExact(spaceId, 16, out var spaceBytes); Codec.TryDecodeExact(itemId, 16, out var itemBytes);
byte[] eventId = RandomNumberGenerator.GetBytes(16); byte[] manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
byte[] eventPayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, eventId,
"chat.create", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
byte[] eventSignature = device.SignData(SignatureCodec.BuildMessage("content.event", eventPayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using (var unsigned = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
{
unsigned.Content = Bytes(frame);
await AssertErrorAsync(await server.Http.SendAsync(unsigned), 403, "forbidden");
}
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
{
upload.Content = Bytes(frame);
upload.Headers.Add("X-WD-Event-Id", B64(eventId)); upload.Headers.Add("X-WD-Event-Kind", "chat.create");
upload.Headers.Add("X-WD-Event-Channel", channelId); upload.Headers.Add("X-WD-Event-Domain", "chat");
upload.Headers.Add("X-WD-Event-Epoch", "0"); upload.Headers.Add("X-WD-Event-Frames", "1");
upload.Headers.Add("X-WD-Event-Manifest", B64(manifest)); upload.Headers.Add("X-WD-Event-Signature", B64(eventSignature));
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
}
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
{
var objects = (await ReadJsonAsync(cryptoResponse)).GetProperty("objects");
var signedObject = Assert.Single(objects.EnumerateArray());
Assert.Equal("chat.create", signedObject.GetProperty("event").GetProperty("kind").GetString());
Assert.Equal(B64(manifest), signedObject.GetProperty("event").GetProperty("manifestHash").GetString());
}
using (var unsignedDelete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
await AssertErrorAsync(await server.Http.SendAsync(unsignedDelete), 403, "forbidden");
byte[] deleteEventId = RandomNumberGenerator.GetBytes(16);
byte[] deletePayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, deleteEventId,
"chat.delete", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
byte[] deleteSignature = device.SignData(SignatureCodec.BuildMessage("content.event", deletePayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using (var delete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
{
delete.Headers.Add("X-WD-Event-Id", B64(deleteEventId)); delete.Headers.Add("X-WD-Event-Kind", "chat.delete");
delete.Headers.Add("X-WD-Event-Channel", channelId); delete.Headers.Add("X-WD-Event-Domain", "chat");
delete.Headers.Add("X-WD-Event-Epoch", "0"); delete.Headers.Add("X-WD-Event-Frames", "1");
delete.Headers.Add("X-WD-Event-Manifest", B64(manifest)); delete.Headers.Add("X-WD-Event-Signature", B64(deleteSignature));
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(delete)).StatusCode);
}
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
{
var signedObject = Assert.Single((await ReadJsonAsync(cryptoResponse)).GetProperty("objects").EnumerateArray());
Assert.Equal("chat.delete", signedObject.GetProperty("deleteEvent").GetProperty("kind").GetString());
}
}
[Fact]
public async Task RevokingOneAccountDevicePreservesOtherDevicesAndChannelMembership()
{
@@ -56,15 +163,22 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString();
var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!;
var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!;
var ch = await server.CreateAsync(settings: new { seats = 8 });
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-key-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
var ch = await server.CreateAsync(settings: new { name = "Device admission space", seats = 2, join_policy = "open", allow_guests = true });
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) };
foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) })
{
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token);
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(joined)).StatusCode);
using var response = await server.Http.SendAsync(joined);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_used").GetInt32());
}
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner))
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("seats_used").GetInt32());
var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]);
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", third, unrelated))
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full");
Assert.Equal(2, accounts.Devices(username, firstId).Count);
Assert.Single(accounts.Devices(username, firstId), d => d.Current);
using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
@@ -82,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
public void PartialSettingsPreserveAdmissionDefaults()
{
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
Assert.Equal("open", request.Settings!.JoinPolicy);
Assert.Equal(JoinPolicy.Open, request.Settings!.JoinPolicy);
Assert.True(request.Settings.AllowGuests);
request.Settings.Validate(new ServerLimits());
var share = JsonSerializer.Deserialize("{\"settings\":{\"max_visitors\":1}}", ApiJson.Default.CreateShareRequest)!;
@@ -98,15 +212,28 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
await AssertErrorAsync(response, 400, "bad_request");
}
}
private string Register(string username)
private async Task<string> Register(string username)
{
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
var verifier = RandomNumberGenerator.GetBytes(32);
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var point = key.ExportParameters(false).Q;
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28],
JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })));
return accounts.Authenticate(username, verifier)!;
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var invitePoint = invitation.ExportParameters(false).Q; var rootPoint = root.ExportParameters(false).Q; var devicePoint = device.ExportParameters(false).Q;
string invitationPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitePoint.X!), y = B64(invitePoint.Y!) });
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
string devicePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(devicePoint.X!), y = B64(devicePoint.Y!) });
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], invitationPublic, rootPublic));
string deviceId = Guid.NewGuid().ToString("D"); var token = accounts.Authenticate(username, verifier, deviceId, "Test device")!;
var certPayload = SignatureCodec.DeviceCertificatePayload(username, deviceId, devicePoint.X!, devicePoint.Y!);
var cert = root.SignData(SignatureCodec.BuildMessage("device.certificate", certPayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
{ Content = JsonBody(new { device_id = deviceId, public_key = devicePublic, signature = Convert.ToBase64String(cert) }) };
request.Headers.Add("X-WD-Account", token);
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(request)).StatusCode);
_signers[token] = new TestSigner(username, deviceId, device.ExportPkcs8PrivateKey());
return token;
}
private HttpRequestMessage Request(HttpMethod method, string path, Who who, string? account = null, object? body = null)
{
@@ -116,6 +243,29 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
return request;
}
private Dictionary<string, string> SignedFrameHeaders(string token, string spaceId, string channelId, string itemId,
CryptoDomain domain, int epoch, string kind, byte[] frame)
{
var signer = _signers[token];
Assert.True(Codec.TryDecodeExact(spaceId, 16, out var space));
Assert.True(Codec.TryDecodeExact(itemId, 16, out var item));
const int frameCount = 1;
var manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
var eventId = RandomNumberGenerator.GetBytes(16);
var payload = SignatureCodec.ContentEventPayload(space, channelId, signer.Username, signer.DeviceId, eventId,
kind, domain, epoch, item, frameCount, manifest, []);
using var device = ECDsa.Create(); device.ImportPkcs8PrivateKey(signer.PrivateKey, out _);
var signature = device.SignData(SignatureCodec.BuildMessage("content.event", payload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return new Dictionary<string, string>
{
["X-WD-Event-Id"] = B64(eventId), ["X-WD-Event-Kind"] = kind,
["X-WD-Event-Channel"] = channelId, ["X-WD-Event-Domain"] = domain == CryptoDomain.Chat ? "chat" : "files",
["X-WD-Event-Epoch"] = epoch.ToString(), ["X-WD-Event-Frames"] = frameCount.ToString(),
["X-WD-Event-Manifest"] = B64(manifest), ["X-WD-Event-Signature"] = B64(signature),
};
}
[Fact]
public async Task CreationAndOwnerActionsRequireAccountIdentity()
{
@@ -130,7 +280,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
using var membersResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner));
var members = await ReadJsonAsync(membersResponse);
Assert.Equal("owner", members[0].GetProperty("role").GetString());
var unrelated = Register("unrelated_" + Guid.NewGuid().ToString("N")[..8]);
var unrelated = await Register("unrelated_" + Guid.NewGuid().ToString("N")[..8]);
using var copied = Request(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, unrelated, new { name = "stolen" });
await AssertErrorAsync(await server.Http.SendAsync(copied), 401, "account_required");
}
@@ -140,7 +290,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
[InlineData(ChannelMode.Raw)]
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
{
var ch = await server.CreateAsync(mode, new { seats = 8 });
var ch = await server.CreateAsync(mode, new { name = "Password space", seats = 8 });
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
@@ -157,20 +307,30 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
var account = Register(username);
var account = await Register(username);
var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required");
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "client-encrypted-payload" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
using (var invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required");
var writer = member with { Role = "write", Token = ch.WriteToken };
string channelId;
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
using (var structureResponse = await server.Http.SendAsync(structureRequest))
channelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
var itemId = NewId();
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/crypto/objects", writer, account,
new { itemId, channelId, domain = "files", epoch = 1, keyEnvelope = "opaque-object-key" }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
byte[] frame = Frame(itemId, 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16);
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", writer, account))
{
upload.Content = Bytes(Frame(NewId(), 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16));
upload.Content = Bytes(frame);
foreach (var (name, value) in SignedFrameHeaders(account, ch.Id, channelId, itemId, CryptoDomain.Files, 1, "file.publish", frame))
upload.Headers.Add(name, value);
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
}
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
@@ -183,7 +343,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional()
{
var username = "digest_" + Guid.NewGuid().ToString("N")[..8];
var token = Register(username);
var token = await Register(username);
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.Null(accounts.Authenticate(username, new byte[32]));
using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService<WebDropOptions>().DataDir, "v2", "webdrop.db") }.ToString());
@@ -197,4 +357,34 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
Assert.Equal(vault.Revision + 1, accounts.PutVault(username, new byte[28], vault.Revision));
accounts.RevokeSession(token); Assert.False(accounts.HasSession(token, out _));
}
private async Task<HttpResponseMessage> InviteAsync(Chan channel, string username, string payload)
{
string? defaultChannelId = null;
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{channel.Id}/structure", channel.Owner))
using (var structureResponse = await server.Http.SendAsync(structureRequest))
{
Assert.Equal(HttpStatusCode.OK, structureResponse.StatusCode);
defaultChannelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString();
}
if (channel.Mode == ChannelMode.Enc)
{
foreach (var domain in new[] { "chat", "files" })
{
using var bootstrap = server.Req(HttpMethod.Put, $"/api/v1/spaces/{channel.Id}/crypto/domains", channel.Owner,
JsonBody(new { channelId = defaultChannelId, domain, epoch = 1, envelopes = new[] { new { channelId = defaultChannelId, domain, epoch = 1, username = "fixture_owner", payload = $"owner-{domain}" } } }));
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(bootstrap)).StatusCode);
}
}
using var planRequest = server.Req(HttpMethod.Get, $"/api/v1/channels/{channel.Id}/invitations/{username}/crypto-plan", channel.Owner);
using var planResponse = await server.Http.SendAsync(planRequest); Assert.Equal(HttpStatusCode.OK, planResponse.StatusCode);
var plan = await ReadJsonAsync(planResponse);
var targetChannelId = plan.GetProperty("channelId").GetString();
var envelopes = new List<object>();
foreach (var batch in plan.GetProperty("batches").EnumerateArray())
foreach (var recipient in batch.GetProperty("recipients").EnumerateArray())
envelopes.Add(new { channelId = targetChannelId, domain = batch.GetProperty("domain").GetString(), epoch = batch.GetProperty("epoch").GetInt32(), username = recipient.GetString(), payload = "opaque-domain-key" });
return await server.Http.SendAsync(server.Req(HttpMethod.Post, $"/api/v1/channels/{channel.Id}/invitations", channel.Owner,
JsonBody(new { username, payload, domain_envelopes = envelopes })));
}
}
@@ -0,0 +1,14 @@
using System.Text.Json;
using WebDrop.Api;
namespace WebDrop.Api.Tests;
public sealed class ChannelAccessContractTests
{
[Fact]
public void GeneratedJsonDefaultsDomainEnvelopesForUnencryptedInvitations()
{
var request = JsonSerializer.Deserialize("""{"username":"member","payload":"opaque-grant"}""", ApiJson.Default.InvitationRequest)!;
Assert.Null(request.DomainEnvelopes);
}
}
@@ -0,0 +1,20 @@
using WebDrop.Api;
using WebDrop.Core;
namespace WebDrop.Api.Tests;
public sealed class PasskeyCeremonyTests
{
[Fact]
public void CeremoniesAreOneTimeAndBoundToTheAuthenticatedAccountAndSession()
{
var ceremonies = new PasskeyCeremonies();
PasskeyCeremony state = new("alice", "register", "server-state", "salt", PasskeyCeremonies.Hash("token-a"), DateTimeOffset.UtcNow.AddMinutes(1));
var id = ceremonies.Put(state); Assert.Equal(state, ceremonies.Take(id, "alice", "register", "token-a"));
Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "bob", "register", "token-a"));
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-b"));
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "login", "token-a"));
id = ceremonies.Put(state with { Expires = DateTimeOffset.UtcNow.AddSeconds(-1) }); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
}
}
+20 -1
View File
@@ -53,6 +53,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
var body = new Dictionary<string, object?>
{
["id"] = ch.Id, ["mode"] = "enc",
["settings"] = new { name = "Duplicate" },
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)),
};
@@ -69,6 +70,24 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
await AssertErrorAsync(await s.Http.SendAsync(badJson), 400, "bad_request");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task CreateRequiresSpaceName(string? name)
{
var ch = new Chan(NewId(), ChannelMode.Raw);
var body = new Dictionary<string, object?>
{
["id"] = ch.Id,
["mode"] = "raw",
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
};
if (name is not null) body["settings"] = new { name };
using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
await AssertErrorAsync(await s.Http.SendAsync(req), 400, "space_name_required");
}
[Fact]
public async Task ErrorsUseStableCodes()
{
@@ -367,7 +386,7 @@ public class RateLimitTests
{
var ch = new Chan(NewId(), ChannelMode.Raw);
using var req = server.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner,
JsonBody(new { id = ch.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
JsonBody(new { id = ch.Id, mode = "raw", settings = new { name = "Rate limit space" }, owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
using var r = await server.Http.SendAsync(req);
statuses.Add((int)r.StatusCode);
}
@@ -117,7 +117,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)),
};
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
if (settings is not null) body["settings"] = settings;
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
+2 -2
View File
@@ -105,14 +105,14 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixtu
byte[] channelOwnerToken = RandomNumberGenerator.GetBytes(32);
var channelOwner = new Who("owner", channelOwnerToken, RandomNumberGenerator.GetBytes(32));
using (var collision = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", channelOwner,
JsonBody(new { id = share.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
JsonBody(new { id = share.Id, mode = "raw", settings = new { name = "Collision" }, owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
await AssertErrorAsync(collision, 409, "conflict");
string channelId = NewId();
byte[] secondOwnerToken = RandomNumberGenerator.GetBytes(32);
var secondOwner = new Who("owner", secondOwnerToken, RandomNumberGenerator.GetBytes(32));
using (var channel = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", secondOwner,
JsonBody(new { id = channelId, mode = "raw", owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
JsonBody(new { id = channelId, mode = "raw", settings = new { name = "Second space" }, owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
Assert.Equal(HttpStatusCode.Created, channel.StatusCode);
using var shareCollision = await CreateShare(new ShareClient(channelId));
@@ -0,0 +1,64 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using WebDrop.Core;
using WebDrop.Storage;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<ServerFixture>
{
[Fact]
public async Task AccountProfileIsServerOwnedAndDoesNotChangeTheEncryptedVault()
{
var store = server.App.Services.GetRequiredService<SqliteAccountStore>();
var revision = store.GetVault("fixture_owner")!.Value.Revision;
using var patch = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "资料测试", avatar = "data:image/png;base64,AA==" }) };
patch.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var response = await server.Http.SendAsync(patch);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response); Assert.Equal("资料测试", body.GetProperty("displayName").GetString());
Assert.Equal(revision, store.GetVault("fixture_owner")!.Value.Revision);
using var read = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/profile"); read.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var loaded = await server.Http.SendAsync(read);
Assert.Equal("资料测试", (await ReadJsonAsync(loaded)).GetProperty("displayName").GetString());
using var bad = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "test", avatar = "https://tracker.test/avatar.png" }) };
bad.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var rejected = await server.Http.SendAsync(bad); Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
using var anonymous = await server.Http.GetAsync("/api/v1/accounts/profile"); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task StructureEndpointsPersistAndRejectCrossSpaceGroups()
{
var a = await server.CreateAsync(); var b = await server.CreateAsync();
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{b.Id}/groups", b.Owner, JsonBody(new { name = "另一空间" }));
using var groupResponse = await server.Http.SendAsync(createGroup); Assert.Equal(HttpStatusCode.Created, groupResponse.StatusCode);
var groupBody = await ReadJsonAsync(groupResponse);
var groupId = groupBody.GetProperty("group").GetProperty("id").GetString();
using var wrong = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "频道", groupId }));
using var wrongResponse = await server.Http.SendAsync(wrong); Assert.Equal(HttpStatusCode.BadRequest, wrongResponse.StatusCode);
using var valid = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "讨论" }));
using var response = await server.Http.SendAsync(valid); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var createdBody = await ReadJsonAsync(response);
Assert.Equal("讨论", createdBody.GetProperty("channel").GetProperty("name").GetString());
using var read = server.Req(HttpMethod.Get, $"/api/v1/spaces/{a.Id}/structure", a.Owner);
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
}
[Fact]
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
{
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
await isolated.InitializeAsync();
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
var defaults = info.GetProperty("defaults").GetProperty("channel");
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
Assert.Equal(3, defaults.GetProperty("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
var ch = new Chan(NewId(), ChannelMode.Enc);
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", seats = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
Assert.Equal(defaults.GetProperty("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
}
}
@@ -0,0 +1,109 @@
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public sealed class ChannelCryptoTests
{
[Fact]
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
{
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
Bootstrap(env, space, owner, channelId);
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2,
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, Username = "owner", Payload = "forbidden-rotation" }] }));
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1,
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "owner", Payload = "owner-key" },
new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "member", Payload = "unauthorized-grant" }] }));
}
[Fact]
public void ServerPlansAndAtomicallyCommitsNoHistoryRotationAndDefaultAccess()
{
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
Bootstrap(env, space, owner, channelId);
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
Assert.Equal(channelId, plan.ChannelId);
Assert.Equal(new[] { CryptoDomain.Chat, CryptoDomain.Files }, plan.Batches.Select(b => b.Domain).Order());
Assert.All(plan.Batches, batch => { Assert.Equal(2, batch.Epoch); Assert.Equal(new[] { "member", "owner" }, batch.Recipients.Order(StringComparer.Ordinal)); });
var incomplete = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).Skip(1).ToList();
Assert.Throws<WebDropException>(() => env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", incomplete));
Assert.Empty(env.Service.Invitations(space.Id, owner));
var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList();
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
var updatedChannel = env.Service.GetWorkspace(space.Id, owner).Channels.Single();
Assert.Equal(2, updatedChannel.ChatEpoch); Assert.Equal(2, updatedChannel.FilesEpoch);
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
var received = env.Service.ReadCrypto(space.Id, member);
Assert.Equal(2, received.Envelopes.Count);
Assert.All(received.Envelopes, e => Assert.Equal(2, e.Epoch));
Assert.All(received.Access, a => { Assert.True(a.ChatRead); Assert.True(a.ChatWrite); Assert.True(a.FilesRead); Assert.True(a.FilesUpload); });
Assert.Throws<WebDropException>(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "old" }));
var objectId = TestEnv.NewId().ToBase64Url();
Assert.Equal("member", env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = objectId, ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, KeyEnvelope = "current" }).AuthorAccount);
}
[Fact]
public void HistoryEnabledPlanGrantsExistingEpochsOnlyToTheNewRecipient()
{
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
Bootstrap(env, space, owner, channelId);
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
Assert.Equal(2, plan.Batches.Count);
Assert.All(plan.Batches, batch => { Assert.Equal(1, batch.Epoch); Assert.Equal(new[] { "member" }, batch.Recipients); });
var packages = plan.Batches.Select(batch => new DomainEnvelope { ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = "member", Payload = $"wrapped-{batch.Domain}" }).ToList();
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
var state = env.Service.ReadCrypto(space.Id, member);
Assert.Equal(2, state.Envelopes.Count);
Assert.All(state.Envelopes, e => Assert.Equal("member", e.Username));
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().ChatEpoch);
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().FilesEpoch);
}
[Fact]
public async Task UnencryptedWorkspaceStillUsesServerHistoryCutoffsAndAccessRules()
{
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
var oldFile = TestEnv.NewId();
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null);
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
Assert.Empty(plan.Batches);
env.Service.InviteUser(space.Id, owner, "member", "account-grant", []);
var member = space.Member(memberEndpoint) with { AccountName = "member" }; env.Service.Join(space.Id, member);
Assert.Empty(env.Service.GetWorkspace(space.Id, member).Locations);
Assert.Empty((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
var newMessage = TestEnv.NewId();
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = newMessage.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(newMessage, 0, FrameFlags.Final, 16), null);
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
}
private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId)
{
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest
{
ChannelId = channelId, Domain = domain, Epoch = 1,
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = domain, Epoch = 1, Username = "owner", Payload = $"owner-{domain.ToString().ToLowerInvariant()}-key" }],
});
}
}
@@ -41,6 +41,7 @@ public class ChannelServiceTests : IDisposable
var dup = new CreateChannelRequest
{
Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc,
Settings = new ChannelSettings { Name = "Duplicate" },
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
};
@@ -51,9 +52,9 @@ public class ChannelServiceTests : IDisposable
public void CreateEnforcesModeSpecificHashes()
{
string h() => Codec.Encode(TokenHash.Compute(TestEnv.Random(32)));
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, ReadHash = h(), OwnerHash = h() };
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "Raw space" }, ReadHash = h(), OwnerHash = h() };
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, Settings = new ChannelSettings { Name = "Enc space" }, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "No owner space" } };
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
@@ -106,6 +107,32 @@ public class ChannelServiceTests : IDisposable
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
}
[Fact]
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
{
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 2 });
var memberOne = TestEnv.Endpoint();
var memberTwo = TestEnv.Endpoint();
var ownerTwo = TestEnv.Endpoint();
Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device);
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).SeatsUsed);
Assert.Equal(4, Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Count); // 设备仍单独登记,但不计入成员人数
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob));
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), memberOne);
Assert.Equal(2, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed); // 移除一台设备不会释放账号成员名额
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }));
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).SeatsUsed);
}
[Fact]
public async Task DataOperationsRequireASeat()
{
@@ -114,6 +141,28 @@ public class ChannelServiceTests : IDisposable
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CreateRequiresSpaceName(string? name)
{
var request = new CreateChannelRequest
{
Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw,
Settings = new ChannelSettings { Name = name },
OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))),
};
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, _ownerEp, "owner"));
}
[Fact]
public void CreateTrimsSpaceName()
{
var channel = _env.Create(_ownerEp, settings: new ChannelSettings { Name = " Team space " });
Assert.Equal("Team space", Svc.GetChannel(channel.Id, channel.Owner(_ownerEp)).Settings.Name);
}
[Fact]
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
{
@@ -68,6 +68,8 @@ public sealed class TestEnv : IDisposable
var write = Random(32);
var owner = Random(32);
settings ??= new ChannelSettings();
settings.Name ??= "Test space";
Service.CreateChannel(new CreateChannelRequest
{
Id = id.ToBase64Url(),
@@ -67,6 +67,37 @@ public class VectorTests
Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id"))));
}
[Fact]
public void SignatureFramingAndEcdsaMatchTheVectors()
{
var vector = V.GetProperty("signatures");
static byte[] B64Url(string value) => Convert.FromBase64String(value.Replace('-', '+').Replace('_', '/') + new string('=', (4 - value.Length % 4) % 4));
var x = B64Url(vector.GetProperty("x").GetString()!);
var y = B64Url(vector.GetProperty("y").GetString()!);
var payload = H(vector.GetProperty("payload"));
var kind = vector.GetProperty("kind").GetString()!;
var signature = H(vector.GetProperty("signature"));
Assert.Equal(H(vector.GetProperty("message")), SignatureCodec.BuildMessage(kind, payload));
Assert.True(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
Assert.False(SignatureCodec.VerifyP256(x, y, "chat.edit", payload, signature));
payload[0] ^= 1;
Assert.False(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
}
[Fact]
public void ContentEventPayloadAndFrameManifestMatchTheVectors()
{
var vector = V.GetProperty("content_event");
var digests = vector.GetProperty("frame_digests").EnumerateArray().Select(H).ToList();
var manifest = SignatureCodec.FrameManifestHash(digests);
Assert.Equal(H(vector.GetProperty("manifest_hash")), manifest);
byte[] id(string property) => H(vector.GetProperty(property));
Assert.Equal(H(vector.GetProperty("payload")), SignatureCodec.ContentEventPayload(
id("space_id"), vector.GetProperty("channel_id").GetString()!, vector.GetProperty("username").GetString()!,
vector.GetProperty("device_id").GetString()!, id("event_id"), vector.GetProperty("kind").GetString()!, CryptoDomain.Chat,
vector.GetProperty("epoch").GetInt32(), id("item_id"), vector.GetProperty("frame_count").GetInt32(), manifest, id("target_item_id")));
}
[Fact]
public void ServerTokenHashMatchesTheStoredHashes()
{
@@ -0,0 +1,71 @@
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public sealed class WorkspaceStructureTests
{
[Fact]
public void NewSpaceHasAnAtomicInitialChannelAndChangesPersist()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
var state = env.Service.GetWorkspace(ch.Id, owner);
Assert.Equal("常规", Assert.Single(state.Channels).Name);
var group = env.Service.CreateWorkspaceGroup(ch.Id, owner, "项目").Groups.Single();
var changed = env.Service.CreateWorkspaceChannel(ch.Id, owner, "讨论", group.Id);
Assert.Equal(group.Id, changed.Channels.Last().GroupId);
var reloaded = env.Service.GetWorkspace(ch.Id, owner);
Assert.Equal(changed.Revision, reloaded.Revision); Assert.Equal(2, reloaded.Channels.Count);
}
[Fact]
public void InvalidAndCrossSpaceAssociationsRollback()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var a = env.Create(endpoint); var b = env.Create(endpoint);
var group = env.Service.CreateWorkspaceGroup(b.Id, b.Owner(endpoint), "其他空间").Groups.Single();
var previous = env.Service.GetWorkspace(a.Id, a.Owner(endpoint));
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "讨论", group.Id));
Assert.Equal(previous.Revision, env.Service.GetWorkspace(a.Id, a.Owner(endpoint)).Revision);
var first = previous.Channels.Single().Id;
var second = env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "频道2", null).Channels.Last().Id;
var folder = env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), first, null, "目录").Folders.Single();
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), second, folder.Id, "跨频道目录"));
}
[Fact]
public async Task MembersCannotMoveAnotherAccountsItemsAndTheServerSuppliesTheAuthor()
{
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
var member = ch.Member(memberEp) with { AccountName = "member" };
env.Service.Join(ch.Id, member);
var item = TestEnv.NewId();
await env.Service.AppendFrameAsync(ch.Id, ch.Owner(ownerEp), TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
var thread = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, member, [item.ToBase64Url()], thread, null));
var moved = env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [item.ToBase64Url()], thread, null);
Assert.Equal("owner", moved.Locations.Single().AuthorAccount);
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [TestEnv.NewId().ToBase64Url()], thread, null));
}
[Fact]
public void AnExistingSpaceWithoutBackendStructureIsNotAutomaticallyMigrated()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint);
using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={Path.Combine(env.Dir, "test.db")}");
connection.Open();
using var delete = connection.CreateCommand(); delete.CommandText = "DELETE FROM workspace_structures WHERE space_id=$id";
delete.Parameters.AddWithValue("$id", ch.Id.ToArray()); delete.ExecuteNonQuery();
var empty = env.Service.GetWorkspace(ch.Id, ch.Owner(endpoint));
Assert.Empty(empty.Channels); Assert.Equal(0, empty.Revision);
var added = env.Service.CreateWorkspaceChannel(ch.Id, ch.Owner(endpoint), "新频道", null);
Assert.Equal("新频道", added.Channels.Single().Name); Assert.Equal(1, added.Revision);
}
[Fact]
public async Task AllowedGuestsCanPlaceTheirUploadsButCannotCreateBusinessStructure()
{
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var guestEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
var guest = ch.Member(guestEp); env.Service.Join(ch.Id, guest);
var item = TestEnv.NewId();
await env.Service.AppendFrameAsync(ch.Id, guest, TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
var channelId = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
Assert.Single(env.Service.MoveWorkspaceItems(ch.Id, guest, [item.ToBase64Url()], channelId, null).Locations);
Assert.Equal(ErrorCode.AccountRequired, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "访客分组")).Code);
}
}