From 7b8d61d1ba1daee13ee03f20af49640ad59bb184 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 14 Oct 2026 04:05:30 +0800 Subject: [PATCH] feat(api): add account workspaces and signed content events --- contracts/api-contract.json | 2 +- contracts/api.schemas.json | 2034 +++++++++++++++++ docs/api.md | 51 +- docs/openapi.yaml | 689 ++++-- docs/protocol.md | 68 +- scripts/check-api-contract.mjs | 7 + server/README.md | 2 +- server/src/WebDrop.Api/ApiContract.cs | 3 +- server/src/WebDrop.Api/ApiJson.cs | 100 +- server/src/WebDrop.Api/CapCaptchaVerifier.cs | 9 +- server/src/WebDrop.Api/Endpoints.Accounts.cs | 40 +- .../WebDrop.Api/Endpoints.ChannelAccess.cs | 15 +- server/src/WebDrop.Api/Endpoints.Passkeys.cs | 93 + server/src/WebDrop.Api/Endpoints.Workspace.cs | 62 + .../WebDrop.Api/Endpoints.WorkspaceCrypto.cs | 32 + server/src/WebDrop.Api/Endpoints.cs | 106 +- server/src/WebDrop.Api/PasskeyCeremonies.cs | 25 + server/src/WebDrop.Api/PasskeyDtos.cs | 49 + server/src/WebDrop.Api/PasskeyUserStore.cs | 51 + server/src/WebDrop.Api/RequestAuth.cs | 4 +- server/src/WebDrop.Api/WebDropApp.cs | 23 +- server/src/WebDrop.Api/WebDropOptions.cs | 1 + server/src/WebDrop.Core/Abstractions.cs | 6 + server/src/WebDrop.Core/AccountProfile.cs | 11 + server/src/WebDrop.Core/ChannelAdmission.cs | 22 +- server/src/WebDrop.Core/ChannelCrypto.cs | 212 ++ server/src/WebDrop.Core/ChannelService.cs | 132 +- server/src/WebDrop.Core/ChannelWorkspace.cs | 139 ++ server/src/WebDrop.Core/Dtos.cs | 4 + server/src/WebDrop.Core/Enums.cs | 14 + server/src/WebDrop.Core/Errors.cs | 3 + server/src/WebDrop.Core/Models.cs | 3 +- server/src/WebDrop.Core/Settings.cs | 37 +- server/src/WebDrop.Core/ShareService.cs | 3 +- server/src/WebDrop.Core/SignatureCodec.cs | 122 + server/src/WebDrop.Core/Workspace.cs | 49 + server/src/WebDrop.Core/WorkspaceCrypto.cs | 83 + .../WebDrop.Storage/SqliteAccountProfiles.cs | 41 + .../WebDrop.Storage/SqliteAccountSigning.cs | 44 + .../src/WebDrop.Storage/SqliteAccountStore.cs | 54 +- .../src/WebDrop.Storage/SqliteChannelStore.cs | 20 + server/src/WebDrop.Storage/SqlitePasskeys.cs | 61 + .../AccountAdmissionTests.cs | 234 +- .../ChannelAccessContractTests.cs | 14 + .../WebDrop.Api.Tests/PasskeyCeremonyTests.cs | 20 + server/tests/WebDrop.Api.Tests/RestTests.cs | 21 +- .../tests/WebDrop.Api.Tests/ServerFixture.cs | 3 +- server/tests/WebDrop.Api.Tests/ShareTests.cs | 4 +- .../WebDrop.Api.Tests/WorkspaceApiTests.cs | 64 + .../WebDrop.Core.Tests/ChannelCryptoTests.cs | 109 + .../WebDrop.Core.Tests/ChannelServiceTests.cs | 55 +- server/tests/WebDrop.Core.Tests/TestEnv.cs | 2 + .../tests/WebDrop.Core.Tests/VectorTests.cs | 31 + .../WorkspaceStructureTests.cs | 71 + test-vectors/generate.mjs | 43 +- test-vectors/vectors.json | 27 + .../ContractGenerator.csproj | 11 + tools/ContractGenerator/Program.cs | 92 + tools/ContractGenerator/README.md | 13 + web/src/core-bridge/contract.test.ts | 9 +- web/src/core-bridge/contract.ts | 2 +- web/src/core-bridge/generated/api.ts | 422 ++++ web/src/core-bridge/worker/http.test.ts | 4 +- 63 files changed, 5407 insertions(+), 370 deletions(-) create mode 100644 contracts/api.schemas.json create mode 100644 server/src/WebDrop.Api/Endpoints.Passkeys.cs create mode 100644 server/src/WebDrop.Api/Endpoints.Workspace.cs create mode 100644 server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs create mode 100644 server/src/WebDrop.Api/PasskeyCeremonies.cs create mode 100644 server/src/WebDrop.Api/PasskeyDtos.cs create mode 100644 server/src/WebDrop.Api/PasskeyUserStore.cs create mode 100644 server/src/WebDrop.Core/AccountProfile.cs create mode 100644 server/src/WebDrop.Core/ChannelCrypto.cs create mode 100644 server/src/WebDrop.Core/ChannelWorkspace.cs create mode 100644 server/src/WebDrop.Core/SignatureCodec.cs create mode 100644 server/src/WebDrop.Core/Workspace.cs create mode 100644 server/src/WebDrop.Core/WorkspaceCrypto.cs create mode 100644 server/src/WebDrop.Storage/SqliteAccountProfiles.cs create mode 100644 server/src/WebDrop.Storage/SqliteAccountSigning.cs create mode 100644 server/src/WebDrop.Storage/SqlitePasskeys.cs create mode 100644 server/tests/WebDrop.Api.Tests/ChannelAccessContractTests.cs create mode 100644 server/tests/WebDrop.Api.Tests/PasskeyCeremonyTests.cs create mode 100644 server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs create mode 100644 server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs create mode 100644 server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs create mode 100644 tools/ContractGenerator/ContractGenerator.csproj create mode 100644 tools/ContractGenerator/Program.cs create mode 100644 tools/ContractGenerator/README.md create mode 100644 web/src/core-bridge/generated/api.ts diff --git a/contracts/api-contract.json b/contracts/api-contract.json index 87032da..c06fc47 100644 --- a/contracts/api-contract.json +++ b/contracts/api-contract.json @@ -1,3 +1,3 @@ { - "version": "2.1.0" + "version": "6.0.0" } diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json new file mode 100644 index 0000000..d5402ee --- /dev/null +++ b/contracts/api.schemas.json @@ -0,0 +1,2034 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "x-backend-contract-sha256": "654ec761e3481b423296b402497a97f4092ce167e7a2ca51a47d25f0fca4466e", + "$defs": { + "AccountChannelBody": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "required": [ + "channel_id", + "created_at", + "payload", + "role" + ] + }, + "AccountDeviceBody": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "current": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "last_seen": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "created_at", + "current", + "id", + "last_seen", + "name" + ] + }, + "AccountDeviceNameRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "AccountDeviceSigningRequest": { + "type": "object", + "properties": { + "device_id": { + "type": "string" + }, + "public_key": { + "type": "string" + }, + "signature": { + "type": "string" + } + }, + "required": [ + "device_id", + "public_key", + "signature" + ] + }, + "AccountProfile": { + "type": "object", + "properties": { + "avatar": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "displayName": { + "type": "string" + }, + "modifiedAt": { + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "displayName", + "modifiedAt", + "username" + ] + }, + "AccountProfileRequest": { + "type": "object", + "properties": { + "avatar": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "displayName": { + "type": "string" + } + }, + "required": [ + "displayName" + ] + }, + "AccountRegisterRequest": { + "type": "object", + "properties": { + "public_key": { + "type": "string" + }, + "salt": { + "type": "string" + }, + "signing_public_key": { + "type": "string" + }, + "username": { + "type": "string" + }, + "vault": { + "type": "string" + }, + "verifier": { + "type": "string" + } + }, + "required": [ + "public_key", + "salt", + "signing_public_key", + "username", + "vault", + "verifier" + ] + }, + "AccountSaltBody": { + "type": "object", + "properties": { + "salt": { + "type": "string" + } + }, + "required": [ + "salt" + ] + }, + "AccountSaltRequest": { + "type": "object", + "properties": { + "username": { + "type": "string" + } + }, + "required": [ + "username" + ] + }, + "AccountSessionBody": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "token", + "username" + ] + }, + "AccountSessionRequest": { + "type": "object", + "properties": { + "device_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "device_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" + }, + "verifier": { + "type": "string" + } + }, + "required": [ + "username", + "verifier" + ] + }, + "AccountVaultBody": { + "type": "object", + "properties": { + "revision": { + "type": "integer" + }, + "vault": { + "type": "string" + } + }, + "required": [ + "revision", + "vault" + ] + }, + "AccountVaultRequest": { + "type": "object", + "properties": { + "revision": { + "type": "integer" + }, + "vault": { + "type": "string" + } + }, + "required": [ + "revision", + "vault" + ] + }, + "AppendResult": { + "type": "object", + "properties": { + "seq": { + "type": "integer" + } + }, + "required": [ + "seq" + ] + }, + "ChannelHistoryRequest": { + "type": "object", + "properties": { + "chatHistory": { + "type": "boolean" + }, + "filesHistory": { + "type": "boolean" + } + }, + "required": [ + "chatHistory", + "filesHistory" + ] + }, + "ChannelInfo": { + "type": "object", + "properties": { + "can_manage_structure": { + "type": "boolean" + }, + "can_upload": { + "type": "boolean" + }, + "created_at": { + "type": "integer" + }, + "creator_account": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "last_seq": { + "type": "integer" + }, + "mode": { + "$ref": "#/$defs/ChannelMode" + }, + "oldest_seq": { + "type": "integer" + }, + "password_required": { + "type": "boolean" + }, + "seats_used": { + "type": "integer" + }, + "settings": { + "$ref": "#/$defs/ChannelSettings" + } + }, + "required": [ + "can_manage_structure", + "can_upload", + "created_at", + "expires_at", + "id", + "last_seq", + "mode", + "oldest_seq", + "password_required", + "seats_used", + "settings" + ] + }, + "ChannelMode": { + "type": "string", + "enum": [ + "enc", + "raw" + ] + }, + "ChannelSettings": { + "type": "object", + "properties": { + "allow_guests": { + "type": "boolean" + }, + "delete_policy": { + "$ref": "#/$defs/DeletePolicy" + }, + "file_max_downloads": { + "type": "integer" + }, + "join_policy": { + "$ref": "#/$defs/JoinPolicy" + }, + "max_items": { + "type": "integer" + }, + "members_can_upload": { + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "seats": { + "type": "integer" + }, + "ttl_seconds": { + "type": "integer" + }, + "ui_restrict_copy_download": { + "type": "boolean" + } + }, + "required": [ + "allow_guests", + "delete_policy", + "file_max_downloads", + "join_policy", + "max_items", + "members_can_upload", + "seats", + "ttl_seconds", + "ui_restrict_copy_download" + ] + }, + "ChannelSettingsPatch": { + "type": "object", + "properties": { + "allow_guests": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "delete_policy": { + "anyOf": [ + { + "$ref": "#/$defs/DeletePolicy" + }, + { + "type": "null" + } + ] + }, + "file_max_downloads": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "join_password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "join_policy": { + "anyOf": [ + { + "$ref": "#/$defs/JoinPolicy" + }, + { + "type": "null" + } + ] + }, + "max_items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "members_can_upload": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "seats": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "ttl_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "ui_restrict_copy_download": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "CreateChannelRequest": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "join_password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "key_package": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mode": { + "anyOf": [ + { + "$ref": "#/$defs/ChannelMode" + }, + { + "type": "null" + } + ] + }, + "owner_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "read_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "settings": { + "anyOf": [ + { + "$ref": "#/$defs/ChannelSettings" + }, + { + "type": "null" + } + ] + }, + "write_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "CreateShareRequest": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "owner_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "read_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "settings": { + "anyOf": [ + { + "$ref": "#/$defs/ShareSettings" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "CryptoState": { + "type": "object", + "properties": { + "access": { + "type": "array", + "items": { + "$ref": "#/$defs/ChannelAccess" + } + }, + "envelopes": { + "type": "array", + "items": { + "$ref": "#/$defs/DomainEnvelope" + } + }, + "objects": { + "type": "array", + "items": { + "$ref": "#/$defs/ObjectEnvelope" + } + }, + "revision": { + "type": "integer" + } + }, + "required": [ + "access", + "envelopes", + "objects", + "revision" + ] + }, + "ChannelAccess": { + "type": "object", + "properties": { + "channelId": { + "type": "string" + }, + "chatFromRevision": { + "type": "integer" + }, + "chatRead": { + "type": "boolean" + }, + "chatWrite": { + "type": "boolean" + }, + "filesFromRevision": { + "type": "integer" + }, + "filesRead": { + "type": "boolean" + }, + "filesUpload": { + "type": "boolean" + }, + "username": { + "type": "string" + } + }, + "required": [ + "channelId", + "chatFromRevision", + "chatRead", + "chatWrite", + "filesFromRevision", + "filesRead", + "filesUpload", + "username" + ] + }, + "DeletePolicy": { + "type": "string", + "enum": [ + "owner_only", + "own", + "anyone" + ] + }, + "DomainEnvelope": { + "type": "object", + "properties": { + "channelId": { + "type": "string" + }, + "domain": { + "$ref": "#/$defs/CryptoDomain" + }, + "epoch": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "channelId", + "domain", + "epoch", + "payload", + "username" + ] + }, + "CryptoDomain": { + "type": "string", + "enum": [ + "chat", + "files" + ] + }, + "DomainPublishRequest": { + "type": "object", + "properties": { + "channelId": { + "type": "string" + }, + "domain": { + "$ref": "#/$defs/CryptoDomain" + }, + "envelopes": { + "type": "array", + "items": { + "$ref": "#/$defs/DomainEnvelope" + } + }, + "epoch": { + "type": "integer" + } + }, + "required": [ + "channelId", + "domain", + "envelopes", + "epoch" + ] + }, + "ErrorBody": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, + "FinishShareRequest": { + "type": "object", + "properties": { + "manifest_item_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "GoneLogBody": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "oldest_seq": { + "type": "integer" + } + }, + "required": [ + "error", + "oldest_seq" + ] + }, + "InvitationBody": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "channel_id", + "created_at", + "payload", + "username" + ] + }, + "InvitationCryptoPlan": { + "type": "object", + "properties": { + "batches": { + "type": "array", + "items": { + "$ref": "#/$defs/CryptoGrantBatch" + } + }, + "channelId": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "batches", + "channelId", + "username" + ] + }, + "CryptoGrantBatch": { + "type": "object", + "properties": { + "domain": { + "$ref": "#/$defs/CryptoDomain" + }, + "epoch": { + "type": "integer" + }, + "recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "rotate": { + "type": "boolean" + } + }, + "required": [ + "domain", + "epoch", + "recipients", + "rotate" + ] + }, + "InvitationRequest": { + "type": "object", + "properties": { + "domain_envelopes": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/DomainEnvelope" + } + }, + { + "type": "null" + } + ] + }, + "payload": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "payload", + "username" + ] + }, + "JoinPolicy": { + "type": "string", + "enum": [ + "open", + "invite" + ] + }, + "JoinRequest": { + "type": "object", + "properties": { + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "KeyPackageRequest": { + "type": "object", + "properties": { + "payload": { + "type": "string" + } + }, + "required": [ + "payload" + ] + }, + "MemberBody": { + "type": "object", + "properties": { + "joined_at": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "joined_at", + "role", + "username" + ] + }, + "ObjectEnvelope": { + "type": "object", + "properties": { + "authorAccount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "channelId": { + "type": "string" + }, + "createdRevision": { + "type": "integer" + }, + "deleteEvent": { + "anyOf": [ + { + "$ref": "#/$defs/SignedContentEventRecord" + }, + { + "type": "null" + } + ] + }, + "domain": { + "$ref": "#/$defs/CryptoDomain" + }, + "epoch": { + "type": "integer" + }, + "event": { + "anyOf": [ + { + "$ref": "#/$defs/SignedContentEventRecord" + }, + { + "type": "null" + } + ] + }, + "itemId": { + "type": "string" + }, + "keyEnvelope": { + "type": "string" + } + }, + "required": [ + "channelId", + "createdRevision", + "domain", + "epoch", + "itemId", + "keyEnvelope" + ] + }, + "PasskeyBeginRequest": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "verifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "username" + ] + }, + "PasskeyEnvelopeRequest": { + "type": "object", + "properties": { + "credential": { + "type": "string" + }, + "id": { + "type": "string" + }, + "key_envelope": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "credential", + "id", + "key_envelope", + "username" + ] + }, + "PasskeyFinishRequest": { + "type": "object", + "properties": { + "credential": { + "type": "string" + }, + "device_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "device_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "key_envelope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" + } + }, + "required": [ + "credential", + "id", + "username" + ] + }, + "PasskeyInfoBody": { + "type": "object", + "properties": { + "can_recover": { + "type": "boolean" + }, + "created_at": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "prf_salt": { + "type": "string" + } + }, + "required": [ + "can_recover", + "created_at", + "id", + "name", + "prf_salt" + ] + }, + "PasskeyOptionsBody": { + "type": "object", + "properties": { + "credentials": { + "type": "array", + "items": { + "$ref": "#/$defs/PasskeyInfoBody" + } + }, + "id": { + "type": "string" + }, + "options": { + "type": "string" + }, + "prf_salt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "credentials", + "id", + "options" + ] + }, + "PasskeySessionBody": { + "type": "object", + "properties": { + "credential_id": { + "type": "string" + }, + "key_envelope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prf_salt": { + "type": "string" + }, + "token": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "credential_id", + "prf_salt", + "token", + "username" + ] + }, + "PublicKeyBody": { + "type": "object", + "properties": { + "public_key": { + "type": "string" + }, + "signing_public_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "public_key" + ] + }, + "SeatInfo": { + "type": "object", + "properties": { + "account_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "endpoint_hash": { + "type": "string" + }, + "joined_at": { + "type": "integer" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "endpoint_hash", + "joined_at" + ] + }, + "SeatJoined": { + "type": "object", + "properties": { + "endpoint_hash": { + "type": "string" + }, + "joined_at": { + "type": "integer" + }, + "seats_max": { + "type": "integer" + }, + "seats_used": { + "type": "integer" + } + }, + "required": [ + "endpoint_hash", + "joined_at", + "seats_max", + "seats_used" + ] + }, + "ServerInfoBody": { + "type": "object", + "properties": { + "captcha": { + "$ref": "#/$defs/CaptchaBody" + }, + "choices": { + "$ref": "#/$defs/SettingChoicesBody" + }, + "contract_version": { + "type": "string" + }, + "defaults": { + "$ref": "#/$defs/DefaultsBody" + }, + "limits": { + "$ref": "#/$defs/LimitsBody" + }, + "protocol": { + "type": "integer" + }, + "transports": { + "$ref": "#/$defs/TransportsBody" + } + }, + "required": [ + "captcha", + "choices", + "contract_version", + "defaults", + "limits", + "protocol", + "transports" + ] + }, + "CaptchaBody": { + "type": "object", + "properties": { + "api_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": "boolean" + }, + "provider": { + "type": "string" + } + }, + "required": [ + "enabled", + "provider" + ] + }, + "DefaultsBody": { + "type": "object", + "properties": { + "channel": { + "$ref": "#/$defs/ChannelSettings" + }, + "channel_mode": { + "$ref": "#/$defs/ChannelMode" + }, + "share": { + "$ref": "#/$defs/ShareSettings" + } + }, + "required": [ + "channel", + "channel_mode", + "share" + ] + }, + "LimitsBody": { + "type": "object", + "properties": { + "max_channel_bytes": { + "type": "integer" + }, + "max_frames_per_item": { + "type": "integer" + }, + "max_items": { + "type": "integer" + }, + "max_payload_bytes": { + "type": "integer" + }, + "max_seats": { + "type": "integer" + }, + "max_ttl_seconds": { + "type": "integer" + }, + "max_visitors": { + "type": "integer" + }, + "min_ttl_seconds": { + "type": "integer" + } + }, + "required": [ + "max_channel_bytes", + "max_frames_per_item", + "max_items", + "max_payload_bytes", + "max_seats", + "max_ttl_seconds", + "max_visitors", + "min_ttl_seconds" + ] + }, + "SettingChoicesBody": { + "type": "object", + "properties": { + "file_max_downloads": { + "type": "array", + "items": { + "type": "integer" + } + }, + "max_visitors": { + "type": "array", + "items": { + "type": "integer" + } + }, + "seats": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ttl_seconds": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": [ + "file_max_downloads", + "max_visitors", + "seats", + "ttl_seconds" + ] + }, + "ShareInfo": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/ShareItemInfo" + } + }, + "manifest_item_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sealed_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "settings": { + "$ref": "#/$defs/ShareSettings" + }, + "state": { + "type": "string" + } + }, + "required": [ + "created_at", + "expires_at", + "id", + "items", + "settings", + "state" + ] + }, + "ShareItemInfo": { + "type": "object", + "properties": { + "bytes": { + "type": "integer" + }, + "complete": { + "type": "boolean" + }, + "downloads_left": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "frames": { + "type": "integer" + }, + "item_id": { + "type": "string" + }, + "max_downloads": { + "type": "integer" + } + }, + "required": [ + "bytes", + "complete", + "downloads_left", + "frames", + "item_id", + "max_downloads" + ] + }, + "ShareSettings": { + "type": "object", + "properties": { + "file_max_downloads": { + "type": "integer" + }, + "max_visitors": { + "type": "integer" + }, + "ttl_seconds": { + "type": "integer" + } + }, + "required": [ + "file_max_downloads", + "max_visitors", + "ttl_seconds" + ] + }, + "ShareSettingsPatch": { + "type": "object", + "properties": { + "file_max_downloads": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "max_visitors": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "ttl_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "SignedContentEventRecord": { + "type": "object", + "properties": { + "deviceCertificateSignature": { + "type": "string" + }, + "eventId": { + "type": "string" + }, + "frameCount": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "manifestHash": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "signerDeviceId": { + "type": "string" + }, + "signerDevicePublicKey": { + "type": "string" + }, + "signerRootPublicKey": { + "type": "string" + }, + "targetItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "deviceCertificateSignature", + "eventId", + "frameCount", + "kind", + "manifestHash", + "signature", + "signerDeviceId", + "signerDevicePublicKey", + "signerRootPublicKey" + ] + }, + "TransportsBody": { + "type": "object", + "properties": { + "http3": { + "type": "boolean" + }, + "sse": { + "type": "boolean" + }, + "websocket": { + "type": "boolean" + } + }, + "required": [ + "http3", + "sse", + "websocket" + ] + }, + "WorkspaceBody": { + "type": "object", + "properties": { + "people": { + "type": "array", + "items": { + "$ref": "#/$defs/AccountProfile" + } + }, + "structure": { + "$ref": "#/$defs/WorkspaceState" + } + }, + "required": [ + "people", + "structure" + ] + }, + "WorkspaceChannelCreatedBody": { + "type": "object", + "properties": { + "channel": { + "$ref": "#/$defs/WorkspaceChannel" + }, + "workspace": { + "$ref": "#/$defs/WorkspaceBody" + } + }, + "required": [ + "channel", + "workspace" + ] + }, + "WorkspaceChannel": { + "type": "object", + "properties": { + "chatEpoch": { + "type": "integer" + }, + "chatHistory": { + "type": "boolean" + }, + "createdAt": { + "type": "integer" + }, + "createdBy": { + "type": "string" + }, + "filesEpoch": { + "type": "integer" + }, + "filesHistory": { + "type": "boolean" + }, + "groupId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "chatEpoch", + "chatHistory", + "createdAt", + "createdBy", + "filesEpoch", + "filesHistory", + "id", + "name" + ] + }, + "WorkspaceChannelRequest": { + "type": "object", + "properties": { + "groupId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "WorkspaceFolderRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "parentId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "name", + "threadId" + ] + }, + "WorkspaceGroupCreatedBody": { + "type": "object", + "properties": { + "group": { + "$ref": "#/$defs/WorkspaceGroup" + }, + "workspace": { + "$ref": "#/$defs/WorkspaceBody" + } + }, + "required": [ + "group", + "workspace" + ] + }, + "WorkspaceGroup": { + "type": "object", + "properties": { + "createdAt": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "createdAt", + "id", + "name" + ] + }, + "WorkspaceGroupRequest": { + "type": "object", + "properties": { + "groupId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "WorkspaceMoveRequest": { + "type": "object", + "properties": { + "folderId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "itemIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "itemIds", + "threadId" + ] + }, + "WorkspaceNameRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "WorkspaceState": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "$ref": "#/$defs/WorkspaceChannel" + } + }, + "defaultChannelId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "folders": { + "type": "array", + "items": { + "$ref": "#/$defs/WorkspaceFolder" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/$defs/WorkspaceGroup" + } + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/$defs/WorkspaceLocation" + } + }, + "revision": { + "type": "integer" + } + }, + "required": [ + "channels", + "folders", + "groups", + "locations", + "revision" + ] + }, + "WorkspaceFolder": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parentId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "threadId" + ] + }, + "WorkspaceLocation": { + "type": "object", + "properties": { + "authorAccount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "folderId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "itemId", + "threadId" + ] + } + } +} diff --git a/docs/api.md b/docs/api.md index f97145e..3ad48ad 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,12 @@ -# API 契约 2.1 +# API 契约 6.0 -URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主版本客户端会被契约预检拒绝。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。 +账户资料使用 `GET/PATCH /accounts/profile`;头像为 PNG/JPEG/WebP data URI,禁止远程 URL 和 SVG。不提供旧资料导入。 +空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。 +频道、分组和目录写操作需要账号会话和空间写权限;允许上传的访客可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。 + +URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `6.0.0`。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。 + +`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.seats` 与 `limits.max_seats` 表示成员人数范围,不代表设备数。频道设置 JSON 字段 `seats` 为保持已有频道配置可读而保留其名称;v5 起它限制的是成员数。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。 基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。 @@ -14,6 +20,7 @@ URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主 | `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) | | `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 | | `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 | +| `X-WD-Event-*` | 已登记签名设备提交频道对象 FINAL 帧时必填,绑定对象 ID、域/epoch、事件类型与完整密文帧 manifest,见 protocol §21 | raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。 @@ -34,22 +41,26 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w 用户名为 3–32 位 ASCII 字母、数字、`_` 或 `-`,大小写不敏感。客户端 PBKDF2-SHA256(600,000 次)派生口令根,再分别派生登录凭据和 AES-GCM 保险库密钥。登录凭据仅经 HTTPS 发送;服务端再用随机 salt 和 PBKDF2-SHA256(210,000 次)存储慢哈希,数据库里的哈希不能直接用于登录。服务端不接收口令根或保险库密钥。二进制字段使用标准 Base64,保险库密文最大 1.3 MB。 -- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key}`。public_key 为 P-256 ECDH 公钥 JWK 的 JSON 字符串;私钥只在客户端加密保险库中保存。 +- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key,signing_public_key}`。`public_key` 是 P-256 ECDH 公钥,`signing_public_key` 是 P-256 ECDSA 账号根公钥;对应私钥只保存在客户端加密保险库中。API v6 不兼容旧账号保险库协议,不自动迁移旧签名身份。 - `POST /api/v1/accounts/salt` 请求 `{username}`,返回 `{salt}`,供客户端派生登录凭据。 - `POST /api/v1/accounts/session` 请求 `{username,verifier,device_id?,device_name?}`。成功返回 `{username,token}`;session token 随机生成,只保存 SHA-256,期限 30 天。 - `GET /api/v1/accounts/vault` 使用 `Authorization: Bearer ` 读取 `{revision,vault}`。 - `PUT /api/v1/accounts/vault` 使用同一鉴权,发送 `{revision,vault}`。revision 乐观并发控制;冲突返回 `409 vault_conflict`,客户端必须先读取并合并,不能静默覆盖。 - `DELETE /api/v1/accounts/session` 撤销当前 bearer session;退出登录时调用。 -- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询收件账号公开邀请密钥。 +- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询 ECDH 邀请公钥和可选的账号签名根公钥。 +- `PUT /api/v1/accounts/devices/signing-key` 当前设备提交 `{device_id,public_key,signature}`。服务端核对账号 session 绑定的 device_id,并验证账号根密钥对 P-256 设备公钥证书的签名后保存。 +- `GET /api/v1/accounts/users/{username}/key` 返回账号 ECDH 公钥与签名根公钥;签名对象携带设备公钥和根签设备证书,客户端首次 TOFU 固定根公钥后离线验设备证书。 - `GET /api/v1/accounts/invitations` 返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。 没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。 +签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。 + ### `GET /api/v1/info` -返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。 +返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。 ### `POST /api/v1/channels` -创建者必须登录。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入该频道的 owner 成员记录并占一个设备席位。服务端启用验证码时还需要 `X-WD-Cap`。 +创建者必须登录。创建请求必须提供 `settings.name`,服务端拒绝缺失、空白或纯空格名称并返回 `400 space_name_required`,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 `X-WD-Cap`。 ```json { @@ -66,32 +77,32 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。 ### `GET /api/v1/channels/{id}` -返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。 +返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`seats_used`)、`oldest_seq`、`last_seq`、时间。`seats_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。 ### `POST /api/v1/channels/{id}/seats` -加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。 +为一个终端加入频道。每个设备终端各自建立席位以便认证、重命名和撤销,但席位不再受人数上限约束。账号第一次加入时占一个成员名额;同一账号的更多设备不增加名额。同一设备重复调用幂等。成员名额已满时,新的账号成员或访客终端返回 `403 seats_full`。匿名访客无法跨设备识别,每个访客终端各计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。 -可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备身份独立:同一账号可占多个独立设备席位;账号成员的读写请求必须出示对应账号 session 和该设备 S。 +可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备终端身份独立:同一账号可拥有不限数量的设备席位,但只占一个成员名额;账号成员的读写请求必须出示对应账号 session 和该设备 S。 -`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。`GET/POST /channels/{id}/invitations` 由频道主查询/发送 `{username,payload}` 邀请;payload 只含客户端加密的成员链接。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。 +`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权、域密钥包与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。 -频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员/席位与写入权限,不能靠客户端隐藏按钮绕过。WebSocket 账号 session 经 `wd.account.` 子协议传递,并在推送循环中重新检查有效性。 +频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员、设备席位与写入权限,不能靠客户端隐藏按钮绕过。移除单台设备只撤销该终端,不释放账号成员名额;移除账号成员才会释放名额。WebSocket 账号 session 经 `wd.account.` 子协议传递,并在推送循环中重新检查有效性。 ### `PATCH /api/v1/channels/{id}/settings` -所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`ttl_seconds` 的修改从现在起算。 +所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`seats` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。 ### `DELETE /api/v1/channels/{id}` 所有者删除整个频道和全部密文。 ### `POST /api/v1/channels/{id}/frames` -追加一帧。请求体是完整封装(`application/octet-stream`)。 +追加一帧。请求体是完整封装(`application/octet-stream`)。当账号设备已登记签名证书且对象在 `/spaces/{id}/crypto/objects` 声明时,最终帧必须携带 `X-WD-Event-Id/Kind/Channel/Domain/Epoch/Frames/Manifest/Signature`(编辑可带 `X-WD-Event-Target`)。服务端验签并核对此前帧摘要与当前 FINAL 帧的清单;缺少或无效签名拒绝提交。签名随对象元数据进入 `CryptoState`,成员读取时仍按数据域权限过滤。 | 响应 | 含义 | |---|---| | `201 {"seq"}` | 新追加 | | `200 {"seq"}` | 幂等重发,返回原 seq | | `409` | 同一 `(item_id, frame_index)` 内容不同 | -| `403` | 无权、席位已满、模式或 ENC 标志不符 | +| `403` | 无权、成员人数已满、模式或 ENC 标志不符 | | `413` | 超出限制 | | `429` | 限流 | @@ -115,13 +126,13 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。 ### `DELETE /api/v1/channels/{id}/items/{item_id}` -按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。 +按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。 -### 席位 -- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。 +### 设备终端席位 +- `GET /api/v1/channels/{id}/seats`:所有者查看已加入设备(终端哈希、加密设备名、加入时间)。设备清单不等于成员人数;一个账号可有多个设备席位。 - `PUT /api/v1/channels/{id}/seats/me`:终端上传设备名(≤256 B);enc 模式加密,raw 模式明文。 - `PUT /api/v1/channels/{id}/seats/{endpoint_hash}/label`:所有者为指定设备更新名称;enc 模式的密文绑定目标 endpoint hash,raw 模式明文。设备也可用 `/seats/me` 更新自己的名称。 -- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝。 +- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝;账号成员身份和人数名额保留。撤销账号邀请/成员会移除该账号全部设备并释放成员名额。 ## 4. 便捷分享接口 @@ -204,7 +215,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 | `not_joined` | 403 | 终端未入席,或席位已被所有者释放 | | `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 | | `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 | -| `seats_full` | 403 | 席位已满 | +| `seats_full` | 403 | 成员人数已满 | | `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 | | `not_found` | 404 | 频道或条目不存在,或已过期 | | `conflict` | 409 | 幂等冲突 | @@ -219,7 +230,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 - `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占席位。 - `PUT /api/v1/channels/{id}/key-package`:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的 `{payload}`,用于补齐已创建频道的账号密钥包。 - 创建频道可携带 `key_package`(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。 -- 发送邀请时原子保存收件账号的密钥包;接受后仍可跨设备领取。撤销邀请/成员同时删除密钥包,删除频道清理全部密钥包。 +- 发送邀请时原子保存收件账号的空间密钥包、频道数据域 envelopes、默认权限和 epoch 更新;接受后仍可跨设备领取。撤销邀请/成员会删除服务端密钥包与访问记录,但无法收回对方此前保存的密钥,成员移除后的 epoch 轮换仍未实现。删除空间时清理全部密钥包。 ### 账号设备与资料 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9420ec1..10db092 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: WebDrop API - version: 2.1.0 + version: 6.0.0 servers: - url: /api/v1 @@ -15,6 +15,388 @@ tags: - name: account paths: + /accounts/profile: + get: + tags: [account] + parameters: + - name: X-WD-Account + in: header + required: true + schema: { type: string } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/AccountProfile' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + patch: + tags: [account] + parameters: + - name: X-WD-Account + in: header + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/AccountProfileRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/AccountProfile' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /accounts/spaces: + get: + tags: [account] + parameters: + - name: X-WD-Account + in: header + required: true + schema: { type: string } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + type: array + items: { $ref: '../contracts/api.schemas.json#/$defs/ChannelInfo' } + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/structure: + get: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: X-WD-Account + in: header + required: false + schema: { type: string } + - $ref: '#/components/parameters/Role' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/channels: + post: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceChannelRequest' } + responses: + '201': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceChannelCreatedBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/channels/{channelId}: + patch: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: channelId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/channels/{channelId}/group: + put: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: channelId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceGroupRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/groups: + post: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' } + responses: + '201': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceGroupCreatedBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/groups/{groupId}: + patch: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: groupId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/folders: + post: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceFolderRequest' } + responses: + '201': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/folders/{folderId}: + patch: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: folderId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /spaces/{id}/locations: + put: + tags: [channel] + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Endpoint' + - name: Authorization + in: header + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceMoveRequest' } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '400': { $ref: '#/components/responses/Error' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } /accounts/channels: get: tags: [account] @@ -244,7 +626,7 @@ paths: post: tags: [channel] security: [{ bearer: [], accountBearer: [] }] - summary: 邀请已注册用户 + summary: 原子提交账号邀请及服务端授权计划要求的域密钥包 requestBody: required: true content: @@ -253,6 +635,26 @@ paths: responses: '204': { description: 已邀请 } '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + /channels/{id}/invitations/{username}/crypto-plan: + get: + tags: [channel] + security: [{ bearer: [], accountBearer: [] }] + summary: 获取新成员域密钥封装计划 + description: 返回由服务端历史策略和当前成员权限计算的 epoch 与收件人集合;提交时服务端会重新计算并校验。 + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/RoleOwner' } + - { name: username, in: path, required: true, schema: { type: string } } + responses: + '200': + description: 服务端计算的加密授权计划 + content: + application/json: + schema: { $ref: '#/components/schemas/InvitationCryptoPlan' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } /channels/{id}/invitations/{username}: delete: tags: [channel] @@ -299,8 +701,8 @@ paths: - { $ref: '#/components/parameters/ChannelId' } get: tags: [channel] - summary: 读取频道信息(不占席位) - description: 任意角色可调用。用于加入前预览席位占用和核对模式。 + summary: 读取频道信息(不占成员名额) + description: 任意角色可调用。`seats_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。 security: [{ bearer: [] }] parameters: - { $ref: '#/components/parameters/Role' } @@ -355,11 +757,11 @@ paths: - { $ref: '#/components/parameters/ChannelId' } post: tags: [seat] - summary: 加入频道(占用席位) + summary: 登记当前设备并加入频道 description: | - 幂等:同一终端重复调用返回同一席位,不重复占用。 - 席位已满返回 403 `seats_full`。 - 读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则 403 `not_joined`。 + 幂等:同一终端重复调用返回同一设备席位,不重复登记。 + 同一账号的设备不限量,账号首次加入时才占一个成员名额;成员名额已满返回 403 `seats_full`。 + 未登录访客每个终端单独计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、设备席位/设备名等操作要求终端已入席,否则 403 `not_joined`。 security: [{ bearer: [] }] parameters: - { $ref: '#/components/parameters/Role' } @@ -375,7 +777,7 @@ paths: '404': { $ref: '#/components/responses/Error' } get: tags: [seat] - summary: 查看席位列表(所有者) + summary: 查看已加入设备列表(所有者) description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。 security: [{ bearer: [] }] parameters: @@ -437,8 +839,8 @@ paths: /channels/{id}/seats/{endpointHash}: delete: tags: [seat] - summary: 释放席位(所有者) - description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。被释放终端后续请求返回 `not_joined`;向订阅者推送 `{"t":"seats"}`。 + summary: 撤销单台设备(所有者) + description: 有效的 owner 凭据即可调用,不要求 owner 终端占用设备席位。被释放终端后续请求返回 `not_joined`;账号成员身份及人数名额保留;向订阅者推送 `{"t":"seats"}`。 security: [{ bearer: [] }] parameters: - { $ref: '#/components/parameters/ChannelId' } @@ -917,87 +1319,43 @@ components: schemas: AccountChannel: - type: object - required: [channel_id, role, payload, created_at] - properties: - channel_id: { $ref: '#/components/schemas/Base64Url' } - role: { type: string, enum: [owner, member] } - payload: { type: string, minLength: 1, maxLength: 16384 } - created_at: { type: integer, format: int64 } + $ref: '../contracts/api.schemas.json#/$defs/AccountChannelBody' + AccountDevice: - type: object - required: [id, name, created_at, last_seen, current] - properties: - id: { type: string, format: uuid } - name: { type: string, minLength: 1, maxLength: 80 } - created_at: { type: integer, format: int64 } - last_seen: { type: integer, format: int64 } - current: { type: boolean } + $ref: '../contracts/api.schemas.json#/$defs/AccountDeviceBody' + PublicKey: - type: object - required: [public_key] - properties: - public_key: { type: string, maxLength: 1024 } + $ref: '../contracts/api.schemas.json#/$defs/PublicKeyBody' + ChannelInvitation: - type: object - required: [channel_id, username, payload, created_at] - properties: - channel_id: { $ref: '#/components/schemas/Base64Url' } - username: { type: string } - payload: { type: string, maxLength: 16384, description: 客户端加密的成员链接 } - created_at: { type: integer, format: int64 } + $ref: '../contracts/api.schemas.json#/$defs/InvitationBody' + InvitationRequest: - type: object - required: [username, payload] - properties: - username: { type: string } - payload: { type: string, maxLength: 16384 } + $ref: '../contracts/api.schemas.json#/$defs/InvitationRequest' + InvitationCryptoPlan: + $ref: '../contracts/api.schemas.json#/$defs/InvitationCryptoPlan' + ChannelMember: - type: object - required: [username, role, joined_at] - properties: - username: { type: string } - role: { type: string, enum: [owner, member] } - joined_at: { type: integer, format: int64 } + $ref: '../contracts/api.schemas.json#/$defs/MemberBody' + AccountRegister: - type: object - required: [username, salt, verifier, vault, public_key] - properties: - username: { type: string, minLength: 3, maxLength: 32, pattern: '^[A-Za-z0-9_-]+$' } - salt: { type: string, contentEncoding: base64, minLength: 24, maxLength: 24 } - verifier: { type: string, contentEncoding: base64, minLength: 44, maxLength: 44 } - vault: { type: string, contentEncoding: base64, maxLength: 1733336 } - public_key: { type: string, maxLength: 1024, description: P-256 ECDH 公钥 JWK 的 JSON 字符串 } + $ref: '../contracts/api.schemas.json#/$defs/AccountRegisterRequest' + AccountSaltRequest: - type: object - required: [username] - properties: - username: { type: string, minLength: 3, maxLength: 32 } + $ref: '../contracts/api.schemas.json#/$defs/AccountSaltRequest' + AccountSalt: - type: object - required: [salt] - properties: - salt: { type: string, contentEncoding: base64 } + $ref: '../contracts/api.schemas.json#/$defs/AccountSaltBody' + AccountSessionRequest: - type: object - required: [username, verifier] - properties: - device_id: { type: string, format: uuid } - device_name: { type: string, minLength: 1, maxLength: 80 } - username: { type: string } - verifier: { type: string, contentEncoding: base64 } + $ref: '../contracts/api.schemas.json#/$defs/AccountSessionRequest' + AccountSession: - type: object - required: [username, token] - properties: - username: { type: string } - token: { type: string, contentEncoding: base64 } + $ref: '../contracts/api.schemas.json#/$defs/AccountSessionBody' + AccountVault: - type: object - required: [revision, vault] - properties: - revision: { type: integer, format: int64, minimum: 0 } - vault: { type: string, contentEncoding: base64, maxLength: 1733336 } + $ref: '../contracts/api.schemas.json#/$defs/AccountVaultBody' + Base64Url: type: string pattern: '^[A-Za-z0-9_-]+$' @@ -1043,186 +1401,49 @@ components: oldest_seq: { type: integer, format: int64, description: "清理水位 + 1;0 < from < oldest_seq 的续传需要从 0 全量同步" } ServerInfo: - type: object - required: [protocol, contract_version, limits, captcha, transports] - properties: - protocol: { type: integer, const: 1 } - contract_version: { type: string, example: 2.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 } - captcha: - type: object - required: [provider, enabled] - properties: - provider: { type: string, enum: [none, cap] } - enabled: { type: boolean } - api_endpoint: - type: string - description: Cap widget 要连接的地址(data-cap-api-endpoint),仅在启用时出现 - transports: - type: object - properties: - websocket: { type: boolean } - sse: { type: boolean } - http3: { type: boolean } - limits: - type: object - properties: - max_payload_bytes: { type: integer } - max_frames_per_item: { type: integer } - max_channel_bytes: { type: integer, format: int64 } - max_seats: { type: integer } - max_ttl_seconds: { type: integer } + $ref: '../contracts/api.schemas.json#/$defs/ServerInfoBody' ChannelMode: - type: string - enum: [enc, raw] + $ref: '../contracts/api.schemas.json#/$defs/ChannelMode' DeletePolicy: - type: string - enum: [owner_only, own, anyone] + $ref: '../contracts/api.schemas.json#/$defs/DeletePolicy' ChannelSettings: - type: object - required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download] - properties: - join_policy: { type: string, enum: [open, invite], default: open } - allow_guests: { type: boolean, default: true } - name: { type: string, maxLength: 48, description: "共享频道名,所有成员和服务端可见" } - seats: { type: integer, minimum: 1, default: 2 } - members_can_upload: { type: boolean, default: true } - delete_policy: { $ref: '#/components/schemas/DeletePolicy' } - ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 } - max_items: { type: integer, minimum: 1, default: 1000 } - file_max_downloads: { type: integer, minimum: 0, default: 10, description: "0 为不限,仅对 GATED 条目生效" } - ui_restrict_copy_download: { type: boolean, default: false, description: "仅供前端展示,服务端不强制" } + $ref: '../contracts/api.schemas.json#/$defs/ChannelSettings' ChannelSettingsPatch: - type: object - description: ChannelSettings 的全部字段均可选 - properties: - join_policy: { type: string, enum: [open, invite] } - allow_guests: { type: boolean } - join_password: { type: string, maxLength: 256, description: 至少 8 字符;空字符串清除;省略保持原值 } - name: { type: string, maxLength: 48, description: "更新共享频道名;空字符串清除名称" } - seats: { type: integer, minimum: 1 } - members_can_upload: { type: boolean } - delete_policy: { $ref: '#/components/schemas/DeletePolicy' } - ttl_seconds: { type: integer, minimum: 60, maximum: 604800 } - max_items: { type: integer, minimum: 1 } - file_max_downloads: { type: integer, minimum: 0 } - ui_restrict_copy_download: { type: boolean } + $ref: '../contracts/api.schemas.json#/$defs/ChannelSettingsPatch' CreateChannelRequest: - type: object - required: [id, mode, settings, owner_hash] - properties: - key_package: { type: string, minLength: 1, maxLength: 16384, description: 用创建者账号公钥封装的 owner 凭据 } - join_password: { type: string, minLength: 8, maxLength: 256 } - id: { $ref: '#/components/schemas/Base64Url' } - mode: { $ref: '#/components/schemas/ChannelMode' } - settings: { $ref: '#/components/schemas/ChannelSettings' } - read_hash: - $ref: '#/components/schemas/Base64Url' - description: SHA-256(T_read),enc 模式必填,raw 模式省略 - write_hash: - $ref: '#/components/schemas/Base64Url' - description: SHA-256(T_write 或 W),仅在成员可上传时提供 - owner_hash: - $ref: '#/components/schemas/Base64Url' - description: SHA-256(T_owner) + $ref: '../contracts/api.schemas.json#/$defs/CreateChannelRequest' ShareSettings: - type: object - required: [ttl_seconds, max_visitors, file_max_downloads] - properties: - ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 } - max_visitors: { type: integer, minimum: 0, maximum: 10000, default: 0, description: 0 为不限 } - file_max_downloads: { type: integer, minimum: 0, default: 10, description: 0 为不限 } + $ref: '../contracts/api.schemas.json#/$defs/ShareSettings' ShareSettingsPatch: - type: object - description: ShareSettings 的字段均可选。 - properties: - ttl_seconds: { type: integer, minimum: 60, maximum: 604800 } - max_visitors: { type: integer, minimum: 0, maximum: 10000 } - file_max_downloads: { type: integer, minimum: 0 } + $ref: '../contracts/api.schemas.json#/$defs/ShareSettingsPatch' CreateShareRequest: - type: object - required: [id, read_hash, owner_hash] - properties: - id: { $ref: '#/components/schemas/Base64Url' } - read_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_read) } - owner_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_owner) } - settings: { $ref: '#/components/schemas/ShareSettings' } + $ref: '../contracts/api.schemas.json#/$defs/CreateShareRequest' FinishShareRequest: - type: object - required: [manifest_item_id] - properties: - manifest_item_id: { $ref: '#/components/schemas/Base64Url' } + $ref: '../contracts/api.schemas.json#/$defs/FinishShareRequest' ShareItemInfo: - type: object - required: [item_id, bytes, frames, max_downloads, downloads_left] - properties: - complete: { type: boolean } - item_id: { $ref: '#/components/schemas/Base64Url' } - bytes: { type: integer, format: int64 } - frames: { type: integer } - max_downloads: { type: integer, format: int64, description: 0 为不限 } - downloads_left: { type: [integer, 'null'], format: int64, description: "null 表示不限" } + $ref: '../contracts/api.schemas.json#/$defs/ShareItemInfo' ShareInfo: - type: object - required: [id, state, expires_at, created_at, settings, items] - properties: - id: { $ref: '#/components/schemas/Base64Url' } - state: { type: string, enum: [draft, sealed] } - expires_at: { type: integer, format: int64 } - created_at: { type: integer, format: int64 } - sealed_at: { type: [integer, 'null'], format: int64 } - manifest_item_id: { type: [string, 'null'], description: 清单条目 id } - settings: { $ref: '#/components/schemas/ShareSettings' } - items: - type: array - items: { $ref: '#/components/schemas/ShareItemInfo' } + $ref: '../contracts/api.schemas.json#/$defs/ShareInfo' ChannelInfo: - type: object - required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at] - properties: - password_required: { type: boolean } - creator_account: { type: string } - id: { $ref: '#/components/schemas/Base64Url' } - mode: { $ref: '#/components/schemas/ChannelMode' } - settings: { $ref: '#/components/schemas/ChannelSettings' } - seats_used: { type: integer } - oldest_seq: { type: integer, format: int64 } - last_seq: { type: integer, format: int64 } - created_at: { type: integer, format: int64 } - expires_at: { type: integer, format: int64 } + $ref: '../contracts/api.schemas.json#/$defs/ChannelInfo' SeatJoined: - type: object - required: [endpoint_hash, joined_at, seats_used, seats_max] - properties: - endpoint_hash: { $ref: '#/components/schemas/Base64Url' } - joined_at: { type: integer, format: int64 } - seats_used: { type: integer } - seats_max: { type: integer } + $ref: '../contracts/api.schemas.json#/$defs/SeatJoined' Seat: - type: object - required: [endpoint_hash, joined_at] - properties: - endpoint_hash: { $ref: '#/components/schemas/Base64Url' } - joined_at: { type: integer, format: int64 } - label: - type: [string, 'null'] - description: 加密的设备名(base64url),用 K_enc 解密;未设置为 null + $ref: '../contracts/api.schemas.json#/$defs/SeatInfo' AppendResult: - type: object - required: [seq] - properties: - seq: { type: integer, format: int64 } + $ref: '../contracts/api.schemas.json#/$defs/AppendResult' diff --git a/docs/protocol.md b/docs/protocol.md index fe80f65..d341460 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,5 +1,15 @@ # 协议 v1 +### 频道数据域边界 + +新工作区的空间凭据只用于空间访问与管理,不作为频道内容密钥。每个频道有独立 chat/files 数据域与 epoch;客户端为每个条目生成随机 32 字节对象密钥,以对应数据域的 epoch key 用 AES-256-GCM 封装。封装上下文绑定空间、频道、数据域、epoch 和条目 ID。服务端保存对象归属与密钥封装,不能获得对象明文密钥。客户端解密必须先取得对应数据域授权,禁止回退到空间密钥。 + +域密钥由创建者随机生成,再通过现有账号 ECDH 公钥信封逐账号封装;域信封上下文包括空间 ID、频道 ID、域名和 epoch。邀请前服务端按成员权限和历史设置生成 epoch/收件人计划;浏览器只为计划中的收件人封装密钥,服务端在单事务中重算并校验邀请、权限、envelopes 和 epoch。服务端根据登录身份只返回该账号可读域的信封和匹配对象索引,并在日志读取、文件下载及写入时执行成员权限检查。默认频道的新成员获得 chat read/write、files read/upload;新增频道的访问不会由空间成员身份自动推导。Owner 可分别决定新成员能否读取聊天和文件历史。关闭某域历史时,邀请流程生成新 epoch,并将新密钥给现有授权成员与新成员;新成员不获旧 epoch 信封。未启用 E2EE 时,后端用服务端维护的 crypto revision 作为访问起点,过滤历史日志和目录位置。该策略不撤回已经交付的密钥;已移除成员的撤销轮换目前尚未实现。 + +该模型的服务端是授权执行方,而不是可被客户端验证的授权来源:目前没有 Owner 签名 Capability、账号签名身份、设备证书或事件签名。首次邀请公钥也来自服务端目录,公钥替换风险仍存在。不能把 AEAD 校验或服务端权限过滤描述成对恶意服务端的端到端授权验证。 + +新版本的频道、分组、目录和文件归属通过后端空间结构接口管理。资源内容继续采用浏览器端加密。`file.published` 的 item 使用独立协议结构,不序列化 UI 的 state、preview、remaining 或版本列表。接收端只重建资源渲染状态,不从资源正文构建账户或空间结构。 + 本文是协议的权威来源。所有实现(服务端、`@webdrop/core`、将来的 CLI)必须通过 `test-vectors/` 中的向量。 ## 1. 编码约定 @@ -12,7 +22,7 @@ |---|---|---| | `channel_id` | 16 B | 随机,客户端生成 | | `item_id` | 16 B | 随机,客户端生成 | -| `K` | 32 B | 频道密钥,仅 enc 模式,按账号加密封装;可选能力链接使用 fragment | +| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment | | `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 | | `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 | | `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 | @@ -70,7 +80,7 @@ raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。 | 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 | | 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 | | 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 | -| 4 | CONTROL | 频道结构控制条目 | +| 4 | CONTROL | 加密资源元数据记录 | | 5-7 | 保留 | 必须为 0 | 服务端拒绝不符合上述规则的封装,但从不解析负载。 @@ -126,7 +136,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... 第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。 -**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。thread 的创建、重命名,文件夹的创建/重命名,以及条目移动等操作以 `kind=other` 的 CONTROL 条目追加到同一频道日志;控制正文是版本化 JSON,仅客户端识别。它们不增加服务端表或第二套同步协议。新内容必须归属已创建的 thread;前端新建频道后自动发布“常规”thread 创建记录。GATED 的 announce 不含元数据,但 file.published 加密目录记录可在不下载内容时提供名称和归属;没有目录记录时显示通用占位符。 +**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。频道、分组、文件夹和条目归属由后端结构接口管理,不写入内容日志。新建空间与初始“常规”频道由后端原子创建。GATED 的 announce 不含元数据,file.published 加密记录提供名称和原始归属;目录记录未到达时显示等待同步占位符。 **raw 模式(API 契约 2)**:负载使用与 enc 相同的 `u16 meta_len ‖ meta_json ‖ content` 条目结构,只是不加密。名称、thread/folder ID 与其他元数据对服务端可见。旧版“纯字节、无元数据”的 raw 负载不属于新应用契约,旧客户端会被 API 主版本检查拒绝。 @@ -134,7 +144,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... ## 8. 日志、序号与条目生命周期 -`CONTROL=0x10` 标记频道结构控制条目;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。 +`CONTROL=0x10` 标记加密资源元数据记录;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。 频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。 @@ -165,13 +175,15 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... 带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是**非 GATED 的单帧**(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 `reason = 4` 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。 -## 10. 终端与席位 +## 10. 终端与成员人数 - 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。 -- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。 +- 终端通过显式的加入请求(`POST .../seats`)建立设备席位;创建者的当前终端在创建时自动入席。席位用于设备级认证、命名和撤销,不限制同一账号可加入的设备数量。未入席的终端访问频道返回 `not_joined`。 +- 频道设置字段 `seats` 保留原线格式名称,但 v5 起表示**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的账号成员加入返回 `seats_full`。 +- 未登录访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限;因此访客在多台设备加入会占多个名额。若希望严格按自然人去重,应要求加入者登录账号。 - 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。 -- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。 -- 终端可更新自己的名称;所有者也可重命名任意席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。所有者移除席位后,该终端不能继续读取频道,但持有成员链接的人仍可重新加入(若有空席位)。 +- 终端可以加密一个设备名上传,所有者能看到设备列表和设备名,并可以释放单个设备席位。 +- 终端可更新自己的名称;所有者也可重命名任意设备席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。撤销设备席位后,该终端不能继续读取频道,但账号成员身份和其人数名额保留;撤销账号成员/邀请才释放该账号名额。 - **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。 ## 11. 创建与验证码 @@ -193,7 +205,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... | 设置 | 默认 | 范围 | |---|---|---| | `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 | -| `seats` | 2 | 1..64(服务端可配置上限) | +| `seats`(v5 起为成员人数上限) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 | | `members_can_upload` | true | | | `delete_policy` | `own` | `owner_only` / `own` / `anyone` | | `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 | @@ -232,7 +244,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... | 模式 | enc 或 raw | **只有 enc** | | 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 | | 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 | -| 访客 | 成员,占席位 | 只读访客,按终端计数,不展示人数 | +| 访客 | 作为访客成员计入人数上限 | 只读访客,按终端计数,不展示人数 | 分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。 @@ -330,27 +342,45 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... - `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。 - 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。 -## 17. 按账号分发频道密钥 +## 17. 账号凭据与频道域密钥 -频道内容仍使用共享 K 加密。创建者用自己的账号 P-256 公钥封装 owner 凭据,随创建请求的 key_package 与频道、owner 成员记录在同一事务内保存。邀请时用收件账号公钥封装 member 凭据,服务端原子保存邀请和密钥包;账号接受邀请并成为成员后,可只凭账号 session 从 /accounts/channels 取回自己的密钥包,不需要含密钥的 URL,也不要求邀请者在线。成员包不得包含管理密钥 A。 +空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;设备席位仍在实际打开空间时建立。 -密钥包沿用邀请的 ECDH、HKDF、AES-GCM,AAD 绑定收件账号与频道 ID。服务端只保存 opaque 密文;成员私钥保存在口令加密的账号保险库。成员登录后解开保险库、取回密钥包,在 Worker 内恢复频道会话;新设备生成独立 S,打开频道时才占设备席位。待接受的邀请不会自动占席位。 +内容加密密钥由浏览器生成。每个频道各有独立 `chat` 与 `files` 域,每域从 epoch 1 开始使用随机 32 B key。域信封沿用账号 ECDH、HKDF、AES-GCM,AAD 绑定账号、空间 ID、频道 ID、域名与 epoch。服务端保存 opaque 密文,只向通过频道/域读取授权的账号返回信封。每条消息、文件和加密发布元数据使用独立随机 32 B 对象密钥;对象密钥由域 key 的 AES-GCM 封装,AAD 绑定空间、频道、域、epoch 和 item ID。 -撤销成员时同时删除密钥包和席位。此举不能收回已交付的 K 或历史明文;当前没有轮换密钥或前向保密。仅知道频道 ID 或登录账号并不足以获得密钥;新账号必须得到现有持钥 owner 的授权封装。邀请公钥仍来自服务端目录,尚无外部指纹核验。 +默认频道成员权限是 chat read/write 与 files read/upload;其它频道不能从空间成员身份推导访问权。Owner 邀请时可分别允许新成员读取已有聊天和文件。关闭历史会对对应域创建新 epoch,把新 key 封装给现有授权账号和新成员,不向新成员分发旧 epoch。此策略不撤回此前已交付的密钥,也不等于成员移除时的完整密码学撤销;已加入成员撤销后的轮换、离线和并发协议仍待实现。 + +服务端会按账号权限过滤频道结构、域信封、对象索引和日志/下载,并拒绝无写入授权或旧 epoch 的新帧。这是服务端执行的访问策略,不是客户端可独立验证的签名 Capability。域密钥封装目前按账号而非设备,目录公钥替换风险、恶意服务端伪造结构/作者和事件签名缺失仍然存在。 ## 18. 频道加密文件目录 -文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。记录与 thread/folder 控制记录共用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。 +文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。资源记录使用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。 -客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动覆盖位置索引,不重新加密原始文件,不改变最初认证的内容元数据。 +客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动由服务端更新归属,不重新加密原始文件,不改变最初认证的内容元数据。 ## 19. 空间内的频道分组 -界面顶层“空间”对应协议 channel,界面“频道”对应 thread。沿用 webdrop.workspace v1 CONTROL 正文:group.create {id,name},group.rename {id,name},thread.group {id,groupId}(null 表示未分组);thread.create 可包含 groupId。ID 为 UUID,name 为 1–80 个非控制字符。分组仅组织频道,成员、内容密钥和日志仍归属于空间。旧创建记录不含 groupId,作为未分组频道重放。 +界面顶层“空间”对应 API space/底层同步资源,界面“频道”是空间中的 WorkspaceChannel,帧元数据 threadId 映射到频道 ID。频道和分组由后端结构接口创建、重命名和管理归属,名称限制与关联验证由后端实施。成员与同步日志归属于空间,频道各自拥有 chat/files 密钥域;分组仅组织频道,不建立新授权或密钥。客户端不重放 group.create、thread.create 等结构记录。 ## 20. 发送者展示快照 -file.published.item 可携带 author {username,displayName,avatar?};另有 item.author {itemId,author} CONTROL,为文本关联展示身份。username 使用当前账号固定用户名,displayName 最多 48 个字符,avatar 仅允许不超过 8000 字符的 PNG/JPEG/WebP data URL。头像在客户端压缩为最多 64×64 后写入加密正文,不改变内容帧、密钥派生或鉴权头。 +聊天发送者由服务端已存条目的 authorAccount 与后端个人资料决定。file.published 不写入作者声明。头像在 Worker 内缩放为最多 64×64 后用于渲染,不影响加密帧或鉴权。 -快照是持钥成员提供的展示资料,不是发送者签名。无 author 的旧记录回退设备名;客户端不得将此类内容标记为当前账号发送。结构记录随既有 CONTROL 生命周期处理,实际消息可见性仍由条目和墓碑决定。 +服务端提供的作者归属与个人资料不代表端对端发送者签名;资源密文仍由浏览器验证。未关联账号的访客资源回退设备来源,不推测当前用户。 + +## 21. 签名扩展基础 + +本节定义后续签名授权和签名事件共用的签名原语;在对应 API 与事件模型完成前,它**不改变现有无签名请求的行为,也不表示频道操作已启用验签**。 + +- 算法为 ECDSA P-256 / SHA-256。签名线格式为 64 字节 IEEE P1363 `r ‖ s`;公钥按 P-256 JWK 的 `x`、`y` 表示,各为 32 字节 base64url。 +- 被签名字节为 `"WDS\x01" ‖ kind_len(u16) ‖ kind(ASCII) ‖ payload_len(u32) ‖ payload`,整数大端。`kind` 为 1–64 字节、匹配 `[a-z][a-z0-9._-]*`;payload 最大 1 MiB。 +- `kind` 必须区分对象用途,不能跨类型复用签名。payload 后续按设备证书、Capability、ChatEvent 各自定义唯一字段顺序和二进制编码;禁止直接签未经规范化的 JSON。 +- 浏览器和服务端都必须先校验 payload 结构、身份绑定、授权范围和上下文,再验签;任一步失败均拒绝操作。服务端接受请求不代替浏览器验签。 +- 此原语只证明某密钥持有者签署了这些字节,不证明公钥属于声称的账号,也不防止服务端隐藏、回滚或向不同客户端展示分叉状态。 + +`device.certificate` 的 payload 为 `username_len(u8) ‖ lowercase_username(ASCII) ‖ device_id_len(u8) ‖ lowercase_device_id(ASCII UUID D 格式) ‖ device_public_x(32 B) ‖ device_public_y(32 B)`。账号根密钥签发此证书;设备密钥预留用于签署频道结构命令和聊天/文件事件。新设备通过密码/Passkey 解锁保险库恢复账号根密钥,不要求旧设备在线批准。其他账号第一次看到某账号根公钥时采用 TOFU:首次固定并展示“未核验”状态,后续变化不得静默接受;这不能防止首次接触时服务端替换密钥。成员可通过独立渠道比较指纹。透明日志、跨客户端一致性证明和历史检查点属于后续阶段。 + +`content.event` 的 payload 顺序为 `space_id(16 B) ‖ channel_id_len(u8) ‖ channel_id(ASCII UUID) ‖ username_len(u8) ‖ username(lowercase ASCII) ‖ device_id_len(u8) ‖ device_id(lowercase ASCII UUID) ‖ event_id(16 B) ‖ event_kind_len(u8) ‖ event_kind(ASCII) ‖ domain(u8:chat=1,files=2) ‖ epoch(u32) ‖ item_id(16 B) ‖ frame_count(u32) ‖ manifest_hash(32 B) ‖ target_present(u8) ‖ target_item_id(16 B 或全零)`。`manifest_hash = SHA-256("WD1M" ‖ frame_count(u32) ‖ (frame_index(u32) ‖ SHA-256(完整 frame envelope))...)`,帧必须从 0 连续排列。事件类型包括 `chat.create/edit/snapshot/delete`、`file.publish/catalog/delete`。FINAL 上传头带事件字段;后端确认证书、签名、对象作者/domain/epoch/权限及数据库帧清单一致后,在对象元数据保存签名。`CryptoState` 按域授权过滤后把签名材料返回客户端。浏览器在投影完整帧前验设备证书、TOFU 根 key、事件签名和本地 manifest。 + +当前已接通新账号根公钥、设备证书校验,以及有账号设备签名密钥的对象最终帧验签/作者 manifest 校验;已登记签名设备的删除也要求签名并把删除证明保存到对象元数据;未注册签名设备的旧账号仍走兼容路径。**频道结构命令和成员/权限变更尚未签名**。TOFU 首次接触风险、服务器分叉/回滚、撤销设备密钥轮换仍未解决,不能宣称已完整抵抗主动恶意服务端。 diff --git a/scripts/check-api-contract.mjs b/scripts/check-api-contract.mjs index ac61848..4f6cac4 100644 --- a/scripts/check-api-contract.mjs +++ b/scripts/check-api-contract.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; @@ -8,6 +9,12 @@ const fail = (message) => { process.exit(1); }; +const dtoSources = ['server/src/WebDrop.Api/ApiJson.cs', 'server/src/WebDrop.Core/Dtos.cs', 'server/src/WebDrop.Core/Settings.cs', 'server/src/WebDrop.Core/Enums.cs', 'server/src/WebDrop.Core/Workspace.cs', 'server/src/WebDrop.Core/AccountProfile.cs', 'server/src/WebDrop.Api/PasskeyDtos.cs', 'server/src/WebDrop.Core/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs']; +const dtoDigest = createHash('sha256').update(dtoSources.map(path => `${path}\n${read(path).replace(/\r\n/g, '\n')}\n`).join('')).digest('hex'); +const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/core-bridge/generated/api.ts'))?.[1]; +if (generatedDigest !== dtoDigest) fail('Backend DTOs changed. Regenerate with dotnet run --project tools/ContractGenerator -- .'); +if (JSON.parse(read('contracts/api.schemas.json'))['x-backend-contract-sha256'] !== dtoDigest) fail('Generated API schemas are stale. Run ContractGenerator.'); + if (typeof contract.version !== 'string' || !semver.test(contract.version)) { fail('contracts/api-contract.json must contain a SemVer version.'); } diff --git a/server/README.md b/server/README.md index da3cc07..d857082 100644 --- a/server/README.md +++ b/server/README.md @@ -42,7 +42,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用 | `Captcha:ApiEndpoint` | 空 | 浏览器里 Cap widget 连接的地址,形如 `https://cap.example.com//`,经 `/info` 下发 | | `Captcha:SiteverifyUrl` | 空 | 本服务调用 Cap 校验 token 的地址,形如 `https://cap.example.com//siteverify`,可以是内网地址 | | `Captcha:Secret` | 空 | Cap 站点密钥的 secret,只在服务端使用 | -| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、席位、TTL 上限等 | +| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、成员人数上限、TTL 上限等 | ## 约束 diff --git a/server/src/WebDrop.Api/ApiContract.cs b/server/src/WebDrop.Api/ApiContract.cs index 9604002..28f48e4 100644 --- a/server/src/WebDrop.Api/ApiContract.cs +++ b/server/src/WebDrop.Api/ApiContract.cs @@ -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"; } diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 3dcc593..2afd11d 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -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; } } /// 告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。 @@ -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? 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 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))] +[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))] [JsonSerializable(typeof(ShareInfo))] [JsonSerializable(typeof(CreateShareRequest))] [JsonSerializable(typeof(ShareSettingsPatch))] @@ -134,9 +228,8 @@ public sealed class MemberBody [JsonSerializable(typeof(SeatJoined))] [JsonSerializable(typeof(List))] [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))] [JsonSerializable(typeof(JoinRequest))] [JsonSerializable(typeof(List))] diff --git a/server/src/WebDrop.Api/CapCaptchaVerifier.cs b/server/src/WebDrop.Api/CapCaptchaVerifier.cs index c8e8f71..c681f73 100644 --- a/server/src/WebDrop.Api/CapCaptchaVerifier.cs +++ b/server/src/WebDrop.Api/CapCaptchaVerifier.cs @@ -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; + /// /// 用 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); } diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs index 6a65087..0279b62 100644 --- a/server/src/WebDrop.Api/Endpoints.Accounts.cs +++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs @@ -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); diff --git a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs index 2d0cd05..96f9be4 100644 --- a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs +++ b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs @@ -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) => { diff --git a/server/src/WebDrop.Api/Endpoints.Passkeys.cs b/server/src/WebDrop.Api/Endpoints.Passkeys.cs new file mode 100644 index 0000000..f80c4bf --- /dev/null +++ b/server/src/WebDrop.Api/Endpoints.Passkeys.cs @@ -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 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 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 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 handler, UserManager 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 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 handler, UserManager 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); + } +} diff --git a/server/src/WebDrop.Api/Endpoints.Workspace.cs b/server/src/WebDrop.Api/Endpoints.Workspace.cs new file mode 100644 index 0000000..fdce5b3 --- /dev/null +++ b/server/src/WebDrop.Api/Endpoints.Workspace.cs @@ -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); + }); + } +} diff --git a/server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs b/server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs new file mode 100644 index 0000000..1575775 --- /dev/null +++ b/server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs @@ -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(); + }); + } +} diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs index a7b3397..8590c3b 100644 --- a/server/src/WebDrop.Api/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -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); diff --git a/server/src/WebDrop.Api/PasskeyCeremonies.cs b/server/src/WebDrop.Api/PasskeyCeremonies.cs new file mode 100644 index 0000000..721eadb --- /dev/null +++ b/server/src/WebDrop.Api/PasskeyCeremonies.cs @@ -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 _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))); +} diff --git a/server/src/WebDrop.Api/PasskeyDtos.cs b/server/src/WebDrop.Api/PasskeyDtos.cs new file mode 100644 index 0000000..44b47ee --- /dev/null +++ b/server/src/WebDrop.Api/PasskeyDtos.cs @@ -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 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; } +} diff --git a/server/src/WebDrop.Api/PasskeyUserStore.cs b/server/src/WebDrop.Api/PasskeyUserStore.cs new file mode 100644 index 0000000..a8c9ef4 --- /dev/null +++ b/server/src/WebDrop.Api/PasskeyUserStore.cs @@ -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; + +/// Reuse the framework's WebAuthn verifier without replacing the existing account/session system. +public sealed class PasskeyUserStore(SqliteAccountStore accounts) : IUserPasskeyStore +{ + private readonly Dictionary _readVersions = []; + public void Dispose() { } + public Task GetUserIdAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(user.Username); + public Task GetUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(user.Username); + public Task GetNormalizedUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(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 CreateAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException(); + public Task DeleteAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException(); + public Task UpdateAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(IdentityResult.Success); + public Task FindByIdAsync(string id, CancellationToken ct) => Task.FromResult(accounts.GetPublicKey(id) is null ? null : new PasskeyAccount(id.ToLowerInvariant())); + public Task FindByNameAsync(string name, CancellationToken ct) => FindByIdAsync(name, ct); + public Task FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => Task.FromResult(accounts.Passkey(Codec.Encode(id)) is { } record ? new PasskeyAccount(record.Username) : null); + public Task 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> GetPasskeysAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult>(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; } +} diff --git a/server/src/WebDrop.Api/RequestAuth.cs b/server/src/WebDrop.Api/RequestAuth.cs index cdfb563..4b50730 100644 --- a/server/src/WebDrop.Api/RequestAuth.cs +++ b/server/src/WebDrop.Api/RequestAuth.cs @@ -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); } /// @@ -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); diff --git a/server/src/WebDrop.Api/WebDropApp.cs b/server/src/WebDrop.Api/WebDropApp.cs index 2eca804..9f5b349 100644 --- a/server/src/WebDrop.Api/WebDropApp.cs +++ b/server/src/WebDrop.Api/WebDropApp.cs @@ -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(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs"))); builder.Services.AddSingleton(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db"))); builder.Services.AddSingleton(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db"))); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped>(sp => new( + sp.GetRequiredService(), Options.Create(new IdentityOptions()), new PasswordHasher(), [], [], + new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), sp, sp.GetRequiredService>>())); + builder.Services.Configure(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, PasskeyHandler>(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => CreateCaptchaVerifier(options.Captcha, sp)); builder.Services.AddSingleton(); @@ -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)))); } diff --git a/server/src/WebDrop.Api/WebDropOptions.cs b/server/src/WebDrop.Api/WebDropOptions.cs index b649dfe..8ea4ef8 100644 --- a/server/src/WebDrop.Api/WebDropOptions.cs +++ b/server/src/WebDrop.Api/WebDropOptions.cs @@ -18,6 +18,7 @@ public sealed class WebDropOptions /// 服务端进程本身不内置 QUIC:CreateSlimBuilder 不含 HTTP/3,需要时再接入。 /// public bool EnableHttp3 { get; set; } + public string? PasskeyRpId { get; set; } public int RequestsPerMinute { get; set; } = 600; public int ChannelCreatesPerMinute { get; set; } = 30; diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs index f9624fa..0f35185 100644 --- a/server/src/WebDrop.Core/Abstractions.cs +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -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 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 endpointHash); int CountSeats(Id16 channelId); + int CountMembers(Id16 channelId); void InsertSeat(Id16 channelId, Seat seat); bool DeleteSeat(Id16 channelId, ReadOnlySpan endpointHash); void SetSeatLabel(Id16 channelId, ReadOnlySpan endpointHash, byte[] label); diff --git a/server/src/WebDrop.Core/AccountProfile.cs b/server/src/WebDrop.Core/AccountProfile.cs new file mode 100644 index 0000000..7318ebd --- /dev/null +++ b/server/src/WebDrop.Core/AccountProfile.cs @@ -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; } +} diff --git a/server/src/WebDrop.Core/ChannelAdmission.cs b/server/src/WebDrop.Core/ChannelAdmission.cs index 856661f..09ea18b 100644 --- a/server/src/WebDrop.Core/ChannelAdmission.cs +++ b/server/src/WebDrop.Core/ChannelAdmission.cs @@ -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 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; }); diff --git a/server/src/WebDrop.Core/ChannelCrypto.cs b/server/src/WebDrop.Core/ChannelCrypto.cs new file mode 100644 index 0000000..ff17f51 --- /dev/null +++ b/server/src/WebDrop.Core/ChannelCrypto.cs @@ -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(); + 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 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); + } +} diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index edaf945..883d1a5 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -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 AppendFrameAsync( Id16 channelId, Credentials creds, ReadOnlyMemory 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 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(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(); 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('/', '_'), + }; + // ───────────────────────── 后台清理 ───────────────────────── /// 清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。 @@ -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, diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs new file mode 100644 index 0000000..2f750d0 --- /dev/null +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -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 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 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 { 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); + } + } +} diff --git a/server/src/WebDrop.Core/Dtos.cs b/server/src/WebDrop.Core/Dtos.cs index 7e49ca2..c526234 100644 --- a/server/src/WebDrop.Core/Dtos.cs +++ b/server/src/WebDrop.Core/Dtos.cs @@ -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; } + /// v5: 账号成员数加匿名访客终端数,不统计账号设备数。 [JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; } /// 从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。 @@ -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; } + /// 加入后的成员人数,同一账号的其他终端不增加此值。 [JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; } [JsonPropertyName("seats_max")] public required int SeatsMax { get; init; } } diff --git a/server/src/WebDrop.Core/Enums.cs b/server/src/WebDrop.Core/Enums.cs index 3b098d4..fec6786 100644 --- a/server/src/WebDrop.Core/Enums.cs +++ b/server/src/WebDrop.Core/Enums.cs @@ -9,6 +9,20 @@ public enum ChannelMode : byte [JsonStringEnumMemberName("raw")] Raw = 2, } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CryptoDomain : byte +{ + [JsonStringEnumMemberName("chat")] Chat = 1, + [JsonStringEnumMemberName("files")] Files = 2, +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum JoinPolicy : byte +{ + [JsonStringEnumMemberName("open")] Open = 1, + [JsonStringEnumMemberName("invite")] Invite = 2, +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum DeletePolicy : byte { diff --git a/server/src/WebDrop.Core/Errors.cs b/server/src/WebDrop.Core/Errors.cs index ee17952..b327ebc 100644 --- a/server/src/WebDrop.Core/Errors.cs +++ b/server/src/WebDrop.Core/Errors.cs @@ -21,6 +21,7 @@ public enum ErrorCode InvitationRequired, ChannelPasswordRequired, ChannelPasswordInvalid, + SpaceNameRequired, } /// 预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。 @@ -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, diff --git a/server/src/WebDrop.Core/Models.cs b/server/src/WebDrop.Core/Models.cs index 6fad787..436b399 100644 --- a/server/src/WebDrop.Core/Models.cs +++ b/server/src/WebDrop.Core/Models.cs @@ -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); /// 请求携带的凭据,尚未验证。 -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); diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs index 2c03fed..ee45a81 100644 --- a/server/src/WebDrop.Core/Settings.cs +++ b/server/src/WebDrop.Core/Settings.cs @@ -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; + /// 最大账号成员数。属性名和 info 字段保持旧线格式兼容。 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; } + /// 成员人数上限;线字段名保留为 seats 以兼容已有频道设置。 [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, }; } + +/// 创建接口与 /info 使用同一个配置来源;旧存储对象的字段默认值不作迁移。 +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), + }; +} diff --git a/server/src/WebDrop.Core/ShareService.cs b/server/src/WebDrop.Core/ShareService.cs index b422344..b0b2e82 100644 --- a/server/src/WebDrop.Core/ShareService.cs +++ b/server/src/WebDrop.Core/ShareService.cs @@ -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); diff --git a/server/src/WebDrop.Core/SignatureCodec.cs b/server/src/WebDrop.Core/SignatureCodec.cs new file mode 100644 index 0000000..e0d4fc9 --- /dev/null +++ b/server/src/WebDrop.Core/SignatureCodec.cs @@ -0,0 +1,122 @@ +using System.Security.Cryptography; +using System.Text; +using System.Buffers.Binary; + +namespace WebDrop.Core; + +/// Protocol signature framing. Payload encodings are defined by their signed statement type. +public static class SignatureCodec +{ + private static ReadOnlySpan 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 x, ReadOnlySpan 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 spaceId, string channelId, string username, string deviceId, + ReadOnlySpan eventId, string eventKind, CryptoDomain domain, int epoch, ReadOnlySpan itemId, + int frameCount, ReadOnlySpan manifestHash, ReadOnlySpan 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 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 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; + } + + /// Verifies WebCrypto ECDSA P-256/SHA-256 signatures in fixed-width IEEE P1363 form. + public static bool VerifyP256(ReadOnlySpan x, ReadOnlySpan y, string kind, + ReadOnlySpan payload, ReadOnlySpan 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; } + } +} diff --git a/server/src/WebDrop.Core/Workspace.cs b/server/src/WebDrop.Core/Workspace.cs new file mode 100644 index 0000000..8378a56 --- /dev/null +++ b/server/src/WebDrop.Core/Workspace.cs @@ -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 Channels { get; init; } = []; + [JsonPropertyName("groups")] public List Groups { get; init; } = []; + [JsonPropertyName("folders")] public List Folders { get; init; } = []; + [JsonPropertyName("locations")] public List Locations { get; set; } = []; +} + +[JsonSerializable(typeof(WorkspaceState))] +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public sealed partial class WorkspaceJson : JsonSerializerContext; diff --git a/server/src/WebDrop.Core/WorkspaceCrypto.cs b/server/src/WebDrop.Core/WorkspaceCrypto.cs new file mode 100644 index 0000000..58920d5 --- /dev/null +++ b/server/src/WebDrop.Core/WorkspaceCrypto.cs @@ -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 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 Batches { get; init; } +} +public sealed class CryptoState +{ + [JsonPropertyName("revision")] public long Revision { get; set; } + [JsonPropertyName("envelopes")] public List Envelopes { get; init; } = []; + [JsonPropertyName("objects")] public List Objects { get; init; } = []; + [JsonPropertyName("access")] public List 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 Envelopes { get; init; } +} + +[JsonSerializable(typeof(CryptoState))] +public sealed partial class WorkspaceCryptoJson : JsonSerializerContext; diff --git a/server/src/WebDrop.Storage/SqliteAccountProfiles.cs b/server/src/WebDrop.Storage/SqliteAccountProfiles.cs new file mode 100644 index 0000000..38d35d6 --- /dev/null +++ b/server/src/WebDrop.Storage/SqliteAccountProfiles.cs @@ -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); + } + } +} diff --git a/server/src/WebDrop.Storage/SqliteAccountSigning.cs b/server/src/WebDrop.Storage/SqliteAccountSigning.cs new file mode 100644 index 0000000..74986d9 --- /dev/null +++ b/server/src/WebDrop.Storage/SqliteAccountSigning.cs @@ -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; + } + } + +} diff --git a/server/src/WebDrop.Storage/SqliteAccountStore.cs b/server/src/WebDrop.Storage/SqliteAccountStore.cs index 9e15b1f..0b3d3b8 100644 --- a/server/src/WebDrop.Storage/SqliteAccountStore.cs +++ b/server/src/WebDrop.Storage/SqliteAccountStore.cs @@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite; namespace WebDrop.Storage; /// Account authentication and opaque client-encrypted vault storage. -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); diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs index 7b7419b..22059c9 100644 --- a/server/src/WebDrop.Storage/SqliteChannelStore.cs +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -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 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)); diff --git a/server/src/WebDrop.Storage/SqlitePasskeys.cs b/server/src/WebDrop.Storage/SqlitePasskeys.cs new file mode 100644 index 0000000..4719e5f --- /dev/null +++ b/server/src/WebDrop.Storage/SqlitePasskeys.cs @@ -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 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(); + 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(); + } + } +} diff --git a/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs index 05a359e..c7e20cb 100644 --- a/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs +++ b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs @@ -12,11 +12,14 @@ namespace WebDrop.Api.Tests; public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture { + private sealed record TestSigner(string Username, string DeviceId, byte[] PrivateKey); + private readonly Dictionary _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(); + 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 Register(string username) { var accounts = server.App.Services.GetRequiredService(); 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 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 + { + ["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(); Assert.Null(accounts.Authenticate(username, new byte[32])); using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService().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 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(); + 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 }))); + } } diff --git a/server/tests/WebDrop.Api.Tests/ChannelAccessContractTests.cs b/server/tests/WebDrop.Api.Tests/ChannelAccessContractTests.cs new file mode 100644 index 0000000..05ab476 --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/ChannelAccessContractTests.cs @@ -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); + } +} diff --git a/server/tests/WebDrop.Api.Tests/PasskeyCeremonyTests.cs b/server/tests/WebDrop.Api.Tests/PasskeyCeremonyTests.cs new file mode 100644 index 0000000..b65e32e --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/PasskeyCeremonyTests.cs @@ -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(() => ceremonies.Take(id, "alice", "register", "token-a")); + id = ceremonies.Put(state); Assert.Throws(() => ceremonies.Take(id, "bob", "register", "token-a")); + id = ceremonies.Put(state); Assert.Throws(() => ceremonies.Take(id, "alice", "register", "token-b")); + id = ceremonies.Put(state); Assert.Throws(() => ceremonies.Take(id, "alice", "login", "token-a")); + id = ceremonies.Put(state with { Expires = DateTimeOffset.UtcNow.AddSeconds(-1) }); Assert.Throws(() => ceremonies.Take(id, "alice", "register", "token-a")); + } +} diff --git a/server/tests/WebDrop.Api.Tests/RestTests.cs b/server/tests/WebDrop.Api.Tests/RestTests.cs index c6f6f61..74b4b17 100644 --- a/server/tests/WebDrop.Api.Tests/RestTests.cs +++ b/server/tests/WebDrop.Api.Tests/RestTests.cs @@ -53,6 +53,7 @@ public class RestTests(ServerFixture s) : IClassFixture var body = new Dictionary { ["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 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 + { + ["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); } diff --git a/server/tests/WebDrop.Api.Tests/ServerFixture.cs b/server/tests/WebDrop.Api.Tests/ServerFixture.cs index 9d92dcc..0ff4fdc 100644 --- a/server/tests/WebDrop.Api.Tests/ServerFixture.cs +++ b/server/tests/WebDrop.Api.Tests/ServerFixture.cs @@ -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); diff --git a/server/tests/WebDrop.Api.Tests/ShareTests.cs b/server/tests/WebDrop.Api.Tests/ShareTests.cs index 866bb3d..84ac8d1 100644 --- a/server/tests/WebDrop.Api.Tests/ShareTests.cs +++ b/server/tests/WebDrop.Api.Tests/ShareTests.cs @@ -105,14 +105,14 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture +{ + [Fact] + public async Task AccountProfileIsServerOwnedAndDoesNotChangeTheEncryptedVault() + { + var store = server.App.Services.GetRequiredService(); + 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()); + } +} diff --git a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs new file mode 100644 index 0000000..bc074a3 --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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" }], + }); + } +} diff --git a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs index eb71531..84bf4cc 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs @@ -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() { diff --git a/server/tests/WebDrop.Core.Tests/TestEnv.cs b/server/tests/WebDrop.Core.Tests/TestEnv.cs index 21f751b..5edf905 100644 --- a/server/tests/WebDrop.Core.Tests/TestEnv.cs +++ b/server/tests/WebDrop.Core.Tests/TestEnv.cs @@ -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(), diff --git a/server/tests/WebDrop.Core.Tests/VectorTests.cs b/server/tests/WebDrop.Core.Tests/VectorTests.cs index 5a8e24a..27a2714 100644 --- a/server/tests/WebDrop.Core.Tests/VectorTests.cs +++ b/server/tests/WebDrop.Core.Tests/VectorTests.cs @@ -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() { diff --git a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs new file mode 100644 index 0000000..5841f01 --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "访客分组")).Code); + } + +} diff --git a/test-vectors/generate.mjs b/test-vectors/generate.mjs index aeb0bf2..26087e7 100644 --- a/test-vectors/generate.mjs +++ b/test-vectors/generate.mjs @@ -9,7 +9,7 @@ // // 所有输入都是固定值,输出完全确定,可以放心提交到仓库。 -import { createCipheriv, createHash, hkdfSync } from 'node:crypto'; +import { createCipheriv, createHash, createPublicKey, hkdfSync, verify as verifySignature } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -149,6 +149,47 @@ function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snap const vectors = { version: 1 }; +// 固定 ECDSA P-256 验签向量。签名只用于测试,对应私钥不进入向量。 +const signatureFixture = { + x: 'OJVV0pkDZHH7F92rxIQt1EuXk4ZesLwoWTaMSANt79A', + y: 'HRP3ZgqoD_8Iv42Mw1oflcJUKeq3H1gEbbOLNJoC_zs', + kind: 'chat.create', + payload: '01020304deadbeef', + signature: '9dad329614d98c97acc5a60622329e57e111fd7579edc7e4bcc535476aae281cc7e3ee00fa2d5d901af525875af653d74bd441ca5dd54d00081b51b7c3b5adcb', +}; +const signatureKind = Buffer.from(signatureFixture.kind, 'ascii'); +const signaturePayload = unhex(signatureFixture.payload); +const signatureMessage = Buffer.concat([ + Buffer.from([0x57, 0x44, 0x53, 0x01]), u16(signatureKind.length), signatureKind, + u32(signaturePayload.length), signaturePayload, +]); +const signaturePublicKey = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: signatureFixture.x, y: signatureFixture.y }, format: 'jwk' }); +if (!verifySignature('sha256', signatureMessage, { key: signaturePublicKey, dsaEncoding: 'ieee-p1363' }, unhex(signatureFixture.signature))) + throw new Error('固定签名向量无效'); +vectors.signatures = { ...signatureFixture, message: hex(signatureMessage) }; + +const eventDigests = [seq(0x31, 32), seq(0x71, 32)]; +const eventManifestInput = Buffer.concat([Buffer.from('WD1M', 'ascii'), u32(eventDigests.length), + ...eventDigests.flatMap((digest, index) => [u32(index), digest])]); +const eventFields = { + space_id: hex(channelId), channel_id: '01234567-89ab-cdef-0123-456789abcdef', username: 'alice', + device_id: '11111111-2222-3333-4444-555555555555', event_id: hex(itemC), kind: 'chat.edit', + domain: 'chat', epoch: 2, item_id: hex(itemA), frame_count: 2, target_item_id: hex(itemB), +}; +const eventKind = Buffer.from(eventFields.kind, 'ascii'); +const eventChannel = Buffer.from(eventFields.channel_id, 'ascii'); +const eventUser = Buffer.from(eventFields.username, 'ascii'); +const eventDevice = Buffer.from(eventFields.device_id, 'ascii'); +const eventTarget = unhex(eventFields.target_item_id); +const eventManifest = sha256(eventManifestInput); +const eventPayload = Buffer.concat([ + channelId, Buffer.from([eventChannel.length]), eventChannel, Buffer.from([eventUser.length]), eventUser, + Buffer.from([eventDevice.length]), eventDevice, itemC, Buffer.from([eventKind.length]), eventKind, + Buffer.from([1]), u32(eventFields.epoch), itemA, u32(eventFields.frame_count), eventManifest, + Buffer.from([1]), eventTarget, +]); +vectors.content_event = { ...eventFields, frame_digests: eventDigests.map(hex), manifest_hash: hex(eventManifest), payload: hex(eventPayload) }; + vectors.inputs = { channel_id: hex(channelId), channel_id_b64: b64url(channelId), diff --git a/test-vectors/vectors.json b/test-vectors/vectors.json index 6300218..061a4d4 100644 --- a/test-vectors/vectors.json +++ b/test-vectors/vectors.json @@ -1,5 +1,32 @@ { "version": 1, + "signatures": { + "x": "OJVV0pkDZHH7F92rxIQt1EuXk4ZesLwoWTaMSANt79A", + "y": "HRP3ZgqoD_8Iv42Mw1oflcJUKeq3H1gEbbOLNJoC_zs", + "kind": "chat.create", + "payload": "01020304deadbeef", + "signature": "9dad329614d98c97acc5a60622329e57e111fd7579edc7e4bcc535476aae281cc7e3ee00fa2d5d901af525875af653d74bd441ca5dd54d00081b51b7c3b5adcb", + "message": "57445301000b636861742e6372656174650000000801020304deadbeef" + }, + "content_event": { + "space_id": "000102030405060708090a0b0c0d0e0f", + "channel_id": "01234567-89ab-cdef-0123-456789abcdef", + "username": "alice", + "device_id": "11111111-2222-3333-4444-555555555555", + "event_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "kind": "chat.edit", + "domain": "chat", + "epoch": 2, + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "frame_count": 2, + "target_item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "frame_digests": [ + "3132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50", + "7172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f90" + ], + "manifest_hash": "97c6a9995ae971032e62baa1f2388f966c814d8a2e22c7299715879809d6d98c", + "payload": "000102030405060708090a0b0c0d0e0f2430313233343536372d383961622d636465662d303132332d34353637383961626364656605616c6963652431313131313131312d323232322d333333332d343434342d353535353535353535353535d0d1d2d3d4d5d6d7d8d9dadbdcdddedf09636861742e656469740100000002a0a1a2a3a4a5a6a7a8a9aaabacadaeaf0000000297c6a9995ae971032e62baa1f2388f966c814d8a2e22c7299715879809d6d98c01b0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + }, "inputs": { "channel_id": "000102030405060708090a0b0c0d0e0f", "channel_id_b64": "AAECAwQFBgcICQoLDA0ODw", diff --git a/tools/ContractGenerator/ContractGenerator.csproj b/tools/ContractGenerator/ContractGenerator.csproj new file mode 100644 index 0000000..27de307 --- /dev/null +++ b/tools/ContractGenerator/ContractGenerator.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tools/ContractGenerator/Program.cs b/tools/ContractGenerator/Program.cs new file mode 100644 index 0000000..f10b126 --- /dev/null +++ b/tools/ContractGenerator/Program.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using System.Text.Json.Nodes; +using WebDrop.Api; +using WebDrop.Core; + +// Build-time reflection only; production serialization remains source-generated and AOT-safe. +var root = Path.GetFullPath(args.FirstOrDefault(a => a != "--check") ?? "."); +string[] sources = ["server/src/WebDrop.Api/ApiJson.cs", "server/src/WebDrop.Core/Dtos.cs", "server/src/WebDrop.Core/Settings.cs", "server/src/WebDrop.Core/Enums.cs", "server/src/WebDrop.Core/Workspace.cs", "server/src/WebDrop.Core/AccountProfile.cs", "server/src/WebDrop.Api/PasskeyDtos.cs", "server/src/WebDrop.Core/WorkspaceCrypto.cs", "server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs"]; +var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", sources.Select(p => p + "\n" + File.ReadAllText(Path.Combine(root, p)).Replace("\r\n", "\n") + "\n"))))); +var pending = new SortedDictionary(); +var done = new HashSet(); +foreach (var entry in typeof(ApiJson).GetCustomAttributesData().Where(a => a.AttributeType == typeof(JsonSerializableAttribute))) Add((Type)entry.ConstructorArguments[0].Value!); +var output = new StringBuilder($"// Generated from backend DTOs; do not edit.\n// backend-contract-sha256: {digest}\n\n"); +var nullable = new NullabilityInfoContext(); +var definitions = new JsonObject(); +while (pending.Count > 0) +{ + var type = pending.First().Value; + pending.Remove(type.Name); + if (!done.Add(type)) continue; + if (type.IsEnum) + { + var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute()?.Name ?? f.Name) + "'"); + output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};\n"); + definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute()?.Name ?? f.Name) as JsonNode).ToArray()) }; + continue; + } + output.AppendLine($"export interface {type.Name} {{"); + var properties = new JsonObject(); + var required = new JsonArray(); + foreach (var prop in type.GetProperties().OrderBy(p => p.GetCustomAttribute()?.Name ?? p.Name, StringComparer.Ordinal)) + { + var ignore = prop.GetCustomAttribute(); + if (ignore?.Condition == JsonIgnoreCondition.Always) continue; + var jsonName = prop.GetCustomAttribute()?.Name; + if (jsonName is null) throw new InvalidOperationException($"Wire property needs an explicit JSON name: {type.Name}.{prop.Name}"); + var isNullable = Nullable.GetUnderlyingType(prop.PropertyType) is not null || nullable.Create(prop).ReadState == NullabilityState.Nullable; + var optional = isNullable && ignore?.Condition != JsonIgnoreCondition.Never; + output.AppendLine($" {jsonName}{(optional ? "?" : "")}: {Ts(prop.PropertyType)}{(isNullable ? " | null" : "")};"); + properties[jsonName] = isNullable ? new JsonObject { ["anyOf"] = new JsonArray(Schema(prop.PropertyType), new JsonObject { ["type"] = "null" }) } : Schema(prop.PropertyType); + if (!optional) required.Add(jsonName); + } + output.AppendLine("}"); + definitions[type.Name] = new JsonObject { ["type"] = "object", ["properties"] = properties, ["required"] = required }; +} +var path = Path.Combine(root, "web/src/core-bridge/generated/api.ts"); +var text = output.ToString().Replace("\r\n", "\n"); +if (args.Contains("--check")) +{ + if (!File.Exists(path) || File.ReadAllText(path).Replace("\r\n", "\n") != text) throw new InvalidOperationException("Generated API types are stale. Run ContractGenerator."); +} +else { Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, text, new UTF8Encoding(false)); } +var schemaPath = Path.Combine(root, "contracts/api.schemas.json"); +var schemaText = new JsonObject { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", ["x-backend-contract-sha256"] = digest, ["$defs"] = definitions }.ToJsonString(new() { WriteIndented = true }).Replace("\r\n", "\n") + "\n"; +if (args.Contains("--check")) +{ + if (!File.Exists(schemaPath) || File.ReadAllText(schemaPath).Replace("\r\n", "\n") != schemaText) throw new InvalidOperationException("Generated API schemas are stale."); +} +else File.WriteAllText(schemaPath, schemaText, new UTF8Encoding(false)); +Console.WriteLine($"Backend API types {(args.Contains("--check") ? "verified" : "generated")}."); + +void Add(Type type) +{ + type = Nullable.GetUnderlyingType(type) ?? type; + if (type.IsArray) { Add(type.GetElementType()!); return; } + if (type.IsGenericType) { foreach (var arg in type.GetGenericArguments()) Add(arg); return; } + if ((type.Namespace is "WebDrop.Api" or "WebDrop.Core") && !done.Contains(type)) pending[type.Name] = type; +} +string Ts(Type type) +{ + type = Nullable.GetUnderlyingType(type) ?? type; + if (type == typeof(string)) return "string"; + if (type == typeof(bool)) return "boolean"; + if (type.IsPrimitive || type == typeof(decimal)) return "number"; + if (type.IsArray) { Add(type.GetElementType()!); return Ts(type.GetElementType()!) + "[]"; } + if (type.IsGenericType && type.GetGenericArguments().Length == 1) { var item = type.GetGenericArguments()[0]; Add(item); return Ts(item) + "[]"; } + Add(type); + return type.Name; +} +JsonObject Schema(Type type) +{ + type = Nullable.GetUnderlyingType(type) ?? type; + if (type == typeof(string)) return new() { ["type"] = "string" }; + if (type == typeof(bool)) return new() { ["type"] = "boolean" }; + if (type.IsPrimitive || type == typeof(decimal)) return new() { ["type"] = type == typeof(float) || type == typeof(double) || type == typeof(decimal) ? "number" : "integer" }; + if (type.IsArray) return new() { ["type"] = "array", ["items"] = Schema(type.GetElementType()!) }; + if (type.IsGenericType && type.GetGenericArguments().Length == 1) return new() { ["type"] = "array", ["items"] = Schema(type.GetGenericArguments()[0]) }; + return new() { ["$ref"] = "#/$defs/" + type.Name }; +} diff --git a/tools/ContractGenerator/README.md b/tools/ContractGenerator/README.md new file mode 100644 index 0000000..c36b08f --- /dev/null +++ b/tools/ContractGenerator/README.md @@ -0,0 +1,13 @@ +# Backend API contract generation + +From the repository root: + +```sh +dotnet run --project tools/ContractGenerator -- . +dotnet run --project tools/ContractGenerator -- . --check +node scripts/check-api-contract.mjs +``` + +The tool reads public DTOs registered in `WebDrop.Api.ApiJson` and their referenced types. It writes TypeScript interfaces to `web/src/core-bridge/generated/api.ts` and JSON Schema definitions to `contracts/api.schemas.json`. OpenAPI references these generated schemas. Backend-to-Cap serialization uses a separate context and is excluded from the browser contract. + +Reflection is used only by this development tool. The production API keeps source-generated, NativeAOT-compatible serialization. Generated files are committed; no extra runtime dependency is added to the frontend. diff --git a/web/src/core-bridge/contract.test.ts b/web/src/core-bridge/contract.test.ts index 1b948db..8a77765 100644 --- a/web/src/core-bridge/contract.test.ts +++ b/web/src/core-bridge/contract.test.ts @@ -3,14 +3,15 @@ import { supportsApiContract } from './contract'; describe('API contract compatibility', () => { it('accepts compatible SemVer releases in the supported major', () => { - expect(supportsApiContract('2.0.0')).toBe(true); - expect(supportsApiContract('2.8.2')).toBe(true); - expect(supportsApiContract('2.2.0-rc.1')).toBe(true); + expect(supportsApiContract('6.0.0')).toBe(true); + expect(supportsApiContract('6.8.2')).toBe(true); + expect(supportsApiContract('6.2.0-rc.1')).toBe(true); }); it('rejects a breaking major and malformed versions', () => { - expect(supportsApiContract('1.0.0')).toBe(false); expect(supportsApiContract('3.0.0')).toBe(false); + expect(supportsApiContract('4.0.0')).toBe(false); + expect(supportsApiContract('5.0.0')).toBe(false); expect(supportsApiContract('01.0.0')).toBe(false); expect(supportsApiContract(undefined)).toBe(false); }); diff --git a/web/src/core-bridge/contract.ts b/web/src/core-bridge/contract.ts index 11e3220..d0ce036 100644 --- a/web/src/core-bridge/contract.ts +++ b/web/src/core-bridge/contract.ts @@ -1,4 +1,4 @@ -export const SUPPORTED_API_CONTRACT_MAJOR = 2; +export const SUPPORTED_API_CONTRACT_MAJOR = 6; const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts new file mode 100644 index 0000000..af7ab0d --- /dev/null +++ b/web/src/core-bridge/generated/api.ts @@ -0,0 +1,422 @@ +// Generated from backend DTOs; do not edit. +// backend-contract-sha256: 654ec761e3481b423296b402497a97f4092ce167e7a2ca51a47d25f0fca4466e + +export interface AccountChannelBody { + channel_id: string; + created_at: number; + payload: string; + role: string; +} +export interface AccountDeviceBody { + created_at: number; + current: boolean; + id: string; + last_seen: number; + name: string; +} +export interface AccountDeviceNameRequest { + name: string; +} +export interface AccountDeviceSigningRequest { + device_id: string; + public_key: string; + signature: string; +} +export interface AccountProfile { + avatar?: string | null; + displayName: string; + modifiedAt: number; + username: string; +} +export interface AccountProfileRequest { + avatar?: string | null; + displayName: string; +} +export interface AccountRegisterRequest { + public_key: string; + salt: string; + signing_public_key: string; + username: string; + vault: string; + verifier: string; +} +export interface AccountSaltBody { + salt: string; +} +export interface AccountSaltRequest { + username: string; +} +export interface AccountSessionBody { + token: string; + username: string; +} +export interface AccountSessionRequest { + device_id?: string | null; + device_name?: string | null; + username: string; + verifier: string; +} +export interface AccountVaultBody { + revision: number; + vault: string; +} +export interface AccountVaultRequest { + revision: number; + vault: string; +} +export interface AppendResult { + seq: number; +} +export interface ChannelHistoryRequest { + chatHistory: boolean; + filesHistory: boolean; +} +export interface ChannelInfo { + can_manage_structure: boolean; + can_upload: boolean; + created_at: number; + creator_account?: string | null; + expires_at: number; + id: string; + last_seq: number; + mode: ChannelMode; + oldest_seq: number; + password_required: boolean; + seats_used: number; + settings: ChannelSettings; +} +export type ChannelMode = 'enc' | 'raw'; + +export interface ChannelSettings { + allow_guests: boolean; + delete_policy: DeletePolicy; + file_max_downloads: number; + join_policy: JoinPolicy; + max_items: number; + members_can_upload: boolean; + name?: string | null; + seats: number; + ttl_seconds: number; + ui_restrict_copy_download: boolean; +} +export interface ChannelSettingsPatch { + allow_guests?: boolean | null; + delete_policy?: DeletePolicy | null; + file_max_downloads?: number | null; + join_password?: string | null; + join_policy?: JoinPolicy | null; + max_items?: number | null; + members_can_upload?: boolean | null; + name?: string | null; + seats?: number | null; + ttl_seconds?: number | null; + ui_restrict_copy_download?: boolean | null; +} +export interface CreateChannelRequest { + id?: string | null; + join_password?: string | null; + key_package?: string | null; + mode?: ChannelMode | null; + owner_hash?: string | null; + read_hash?: string | null; + settings?: ChannelSettings | null; + write_hash?: string | null; +} +export interface CreateShareRequest { + id?: string | null; + owner_hash?: string | null; + read_hash?: string | null; + settings?: ShareSettings | null; +} +export interface CryptoState { + access: ChannelAccess[]; + envelopes: DomainEnvelope[]; + objects: ObjectEnvelope[]; + revision: number; +} +export interface ChannelAccess { + channelId: string; + chatFromRevision: number; + chatRead: boolean; + chatWrite: boolean; + filesFromRevision: number; + filesRead: boolean; + filesUpload: boolean; + username: string; +} +export type DeletePolicy = 'owner_only' | 'own' | 'anyone'; + +export interface DomainEnvelope { + channelId: string; + domain: CryptoDomain; + epoch: number; + payload: string; + username: string; +} +export type CryptoDomain = 'chat' | 'files'; + +export interface DomainPublishRequest { + channelId: string; + domain: CryptoDomain; + envelopes: DomainEnvelope[]; + epoch: number; +} +export interface ErrorBody { + error: string; +} +export interface FinishShareRequest { + manifest_item_id?: string | null; +} +export interface GoneLogBody { + error: string; + oldest_seq: number; +} +export interface InvitationBody { + channel_id: string; + created_at: number; + payload: string; + username: string; +} +export interface InvitationCryptoPlan { + batches: CryptoGrantBatch[]; + channelId: string; + username: string; +} +export interface CryptoGrantBatch { + domain: CryptoDomain; + epoch: number; + recipients: string[]; + rotate: boolean; +} +export interface InvitationRequest { + domain_envelopes?: DomainEnvelope[] | null; + payload: string; + username: string; +} +export type JoinPolicy = 'open' | 'invite'; + +export interface JoinRequest { + password?: string | null; +} +export interface KeyPackageRequest { + payload: string; +} +export interface MemberBody { + joined_at: number; + role: string; + username: string; +} +export interface ObjectEnvelope { + authorAccount?: string | null; + channelId: string; + createdRevision: number; + deleteEvent?: SignedContentEventRecord | null; + domain: CryptoDomain; + epoch: number; + event?: SignedContentEventRecord | null; + itemId: string; + keyEnvelope: string; +} +export interface PasskeyBeginRequest { + username: string; + verifier?: string | null; +} +export interface PasskeyEnvelopeRequest { + credential: string; + id: string; + key_envelope: string; + username: string; +} +export interface PasskeyFinishRequest { + credential: string; + device_id?: string | null; + device_name?: string | null; + id: string; + key_envelope?: string | null; + name?: string | null; + username: string; +} +export interface PasskeyInfoBody { + can_recover: boolean; + created_at: number; + id: string; + name: string; + prf_salt: string; +} +export interface PasskeyOptionsBody { + credentials: PasskeyInfoBody[]; + id: string; + options: string; + prf_salt?: string | null; +} +export interface PasskeySessionBody { + credential_id: string; + key_envelope?: string | null; + prf_salt: string; + token: string; + username: string; +} +export interface PublicKeyBody { + public_key: string; + signing_public_key?: string | null; +} +export interface SeatInfo { + account_name?: string | null; + endpoint_hash: string; + joined_at: number; + label?: string | null; +} +export interface SeatJoined { + endpoint_hash: string; + joined_at: number; + seats_max: number; + seats_used: number; +} +export interface ServerInfoBody { + captcha: CaptchaBody; + choices: SettingChoicesBody; + contract_version: string; + defaults: DefaultsBody; + limits: LimitsBody; + protocol: number; + transports: TransportsBody; +} +export interface CaptchaBody { + api_endpoint?: string | null; + enabled: boolean; + provider: string; +} +export interface DefaultsBody { + channel: ChannelSettings; + channel_mode: ChannelMode; + share: ShareSettings; +} +export interface LimitsBody { + max_channel_bytes: number; + max_frames_per_item: number; + max_items: number; + max_payload_bytes: number; + max_seats: number; + max_ttl_seconds: number; + max_visitors: number; + min_ttl_seconds: number; +} +export interface SettingChoicesBody { + file_max_downloads: number[]; + max_visitors: number[]; + seats: number[]; + ttl_seconds: number[]; +} +export interface ShareInfo { + created_at: number; + expires_at: number; + id: string; + items: ShareItemInfo[]; + manifest_item_id?: string | null; + sealed_at?: number | null; + settings: ShareSettings; + state: string; +} +export interface ShareItemInfo { + bytes: number; + complete: boolean; + downloads_left: number | null; + frames: number; + item_id: string; + max_downloads: number; +} +export interface ShareSettings { + file_max_downloads: number; + max_visitors: number; + ttl_seconds: number; +} +export interface ShareSettingsPatch { + file_max_downloads?: number | null; + max_visitors?: number | null; + ttl_seconds?: number | null; +} +export interface SignedContentEventRecord { + deviceCertificateSignature: string; + eventId: string; + frameCount: number; + kind: string; + manifestHash: string; + signature: string; + signerDeviceId: string; + signerDevicePublicKey: string; + signerRootPublicKey: string; + targetItemId?: string | null; +} +export interface TransportsBody { + http3: boolean; + sse: boolean; + websocket: boolean; +} +export interface WorkspaceBody { + people: AccountProfile[]; + structure: WorkspaceState; +} +export interface WorkspaceChannelCreatedBody { + channel: WorkspaceChannel; + workspace: WorkspaceBody; +} +export interface WorkspaceChannel { + chatEpoch: number; + chatHistory: boolean; + createdAt: number; + createdBy: string; + filesEpoch: number; + filesHistory: boolean; + groupId?: string | null; + id: string; + name: string; +} +export interface WorkspaceChannelRequest { + groupId?: string | null; + name: string; +} +export interface WorkspaceFolderRequest { + name: string; + parentId?: string | null; + threadId: string; +} +export interface WorkspaceGroupCreatedBody { + group: WorkspaceGroup; + workspace: WorkspaceBody; +} +export interface WorkspaceGroup { + createdAt: number; + id: string; + name: string; +} +export interface WorkspaceGroupRequest { + groupId?: string | null; +} +export interface WorkspaceMoveRequest { + folderId?: string | null; + itemIds: string[]; + threadId: string; +} +export interface WorkspaceNameRequest { + name: string; +} +export interface WorkspaceState { + channels: WorkspaceChannel[]; + defaultChannelId?: string | null; + folders: WorkspaceFolder[]; + groups: WorkspaceGroup[]; + locations: WorkspaceLocation[]; + revision: number; +} +export interface WorkspaceFolder { + id: string; + name: string; + parentId?: string | null; + threadId: string; +} +export interface WorkspaceLocation { + authorAccount?: string | null; + folderId?: string | null; + itemId: string; + threadId: string; +} diff --git a/web/src/core-bridge/worker/http.test.ts b/web/src/core-bridge/worker/http.test.ts index 1b56786..36060b8 100644 --- a/web/src/core-bridge/worker/http.test.ts +++ b/web/src/core-bridge/worker/http.test.ts @@ -8,7 +8,7 @@ describe('API contract preflight', () => { }); it('blocks API requests when the server has a newer breaking major', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '3.0.0' }), { status: 200 })); + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 })); vi.stubGlobal('fetch', fetchMock); setApiBase('http://api.test/api/v1'); @@ -19,7 +19,7 @@ describe('API contract preflight', () => { it('checks the contract once and then sends the API request', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '2.2.0' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '6.0.0' }), { status: 200 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock);