diff --git a/README.md b/README.md index 67e1301..c7d15b5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ | 文档 | 内容 | |---|---| | [docs/threat-model.md](docs/threat-model.md) | 信任边界、攻击者模型、每种模式的保证、元数据泄露、不做的事 | +| [docs/e2ee-boundary-comparison.md](docs/e2ee-boundary-comparison.md) | 当前信任边界、签名授权/事件的目标设计与分阶段路线 | | [docs/protocol.md](docs/protocol.md) | 密钥派生、令牌、帧格式、加密和 padding、校验算法、保留策略(协议权威文档) | | [docs/api.md](docs/api.md) | HTTP / WebSocket / SSE 接口、鉴权头、错误码 | | [docs/architecture.md](docs/architecture.md) | 组件、技术栈、NativeAOT 约束、存储模型、部署、阶段划分 | @@ -32,9 +33,9 @@ ## 核心概念 -- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和席位。 +- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和账号成员人数上限。 - **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。 -- **终端**:加入频道的设备或会话,占一个席位(默认 2 个)。 +- **终端**:加入频道的设备或会话,每个设备有独立终端席位;频道容量按账号成员人数计算,同一账号的多设备只算一人。 - **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。 - **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。 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/account-channel-architecture.md b/docs/account-channel-architecture.md index 7e56ee7..7ea44b4 100644 --- a/docs/account-channel-architecture.md +++ b/docs/account-channel-architecture.md @@ -4,36 +4,44 @@ ## 领域边界 -Account 使用不可修改的 username 作为当前版本稳定身份,displayName/avatar 为可修改的个人资料。AccountSettings 与资料在加密保险库中同步。AccountDevice 是账号下的一个浏览器安装身份,具有独立登录 session、名称与撤销状态;每个频道的设备 S 继续独立生成。ChannelMember 绑定账号与频道角色,不绑定某一次登录或某个设备。 +### 已确认的下一版规则 -Channel 是成员权限和内容加密的共同边界。Thread 属于 Channel,Folder 属于 Thread,文件与消息必须属于 Thread。Thread 不再创建第二套成员、密钥或网络连接;当前频道成员可读取该频道所有 thread。创建频道时自动建立“常规”thread,用户可继续创建其他 thread,分别上传文件或发消息。 +注册设置初始密码。密码和绑定的 Passkey 是账号认证/解锁入口,不要求旧设备批准新设备;Passkey 的 WebAuthn 验证由 .NET 10 官方组件完成。支持 PRF 时浏览器封装保险库解密凭据;PRF 输出不传给服务器。不支持 PRF 的 Passkey 可登录,资源解密仍需密码。绑定需重新验证密码,撤销后该凭据不能再创建会话。 + +Member 加入空间后自动获得默认频道的聊天读取/发言和文件读取/上传权,只能修改自己的内容,不能管理频道或邀请成员。其他频道单独授权;历史聊天和文件由 Owner 分别决定是否向新成员开放。历史选择在接受邀请时生效:关闭时分别轮换该数据域 epoch,仅给新成员当前 epoch;已获授权的成员保留历史访问。之后修改开关只影响后续邀请,不撤回已交付密钥。 + +Account 使用不可修改的 username 作为稳定身份,displayName/avatar 由后端个人资料接口管理。AccountDevice 是账号下的浏览器身份,具有独立登录 session、名称与撤销状态;每个空间的设备 S 独立生成。空间成员绑定账号与角色,不绑定某次登录或设备。 + +空间是成员管理与同步的边界,不是内容密钥边界。频道属于空间,分组归属空间,文件夹属于频道,资源归属由后端校验和保存。新空间与初始“常规”频道在同一事务中创建。每频道独立维护 chat 与 files 两个数据域及 epoch;分组仅组织频道,不创建密钥域。 ## 模块互通 -账号模块提供当前身份、个人资料、设备会话与解锁后的账号密钥。频道模块负责创建/邀请/接受/打开频道、thread 和文件目录。加密模块不读取 UI 或数据库,只提供账号密钥封装、频道凭据封装和帧加解密。Worker 是三者的编排入口,组件经 store/action 调用它。 +账号模块提供当前身份、个人资料、设备会话与解锁后的账号密钥。空间模块负责创建空间、初始频道、成员邀请与加入;频道模块负责频道、分组、文件夹和频道内的聊天/文件访问。权限、历史范围、epoch 和授权收件人由后端计算、持久化和校验。浏览器只根据服务端返回的授权计划生成密钥并进行封装;它不能选择或扩大授权集合。加密模块不读取 UI 或数据库,只提供账号密钥封装、随机对象密钥封装和帧加解密。Worker 是 API 与密码学之间的编排层,不是业务规则来源。 账号登录授权访问服务端;频道内容密钥授权解密内容。这两种能力分开校验,服务端不能通过登录 token 推出频道密钥。频道主的成员记录才是 owner 权限的最终依据,不能凭复制管理密钥冒充另一个账号。 ## 密钥与多设备 -账号邀请私钥保存在密码加密的保险库中,多台设备登录同一账号后解锁同一账号私钥。频道创建时生成独立随机 K 和管理密钥 A;创建者将 owner 凭据封装给自己,服务端原子存储频道、owner 成员与该账号密钥包。邀请其他账号时仅封装 member 凭据,不能交付 A。密钥包绑定 recipient、channelId 和 mode;服务端保存密文,收件账号自动领取并在本机解密。 +账号邀请私钥保存在密码加密的保险库中,多台设备登录同一账号后解锁同一账号私钥。空间创建时生成空间访问凭据,Owner 凭据与 Owner 账号密钥包由后端原子存储;它们不再加密频道内容。频道 chat/files 域密钥在浏览器生成,并通过 Owner 账号 ECDH 公钥封装;服务端保存密文,获授权成员的账号可领取并在本机解封。 -已加入成员的新设备只凭账号登录即可恢复频道密钥,不需要邀请者在线,不需要复制带密钥的链接。每个浏览器有独立设备登录凭据和独立频道 S;读取账号频道列表不占席位,实际打开频道才为设备加入席位。设备撤销会撤销其账号 sessions;账号频道成员身份保留,其他设备继续访问。 +已加入成员的新设备只凭账号登录即可解封账号分发给该账号的数据域密钥,不需要邀请者在线,也不需要复制带内容密钥的链接。每个浏览器有独立设备登录凭据和独立空间终端 S;读取账号空间列表不占成员名额,实际打开空间才登记设备席位。同一账号在一个空间只占一个成员名额,设备数量不受成员上限影响。设备撤销会撤销其账号 sessions;账号空间成员身份及其人数名额保留,其他设备继续访问。 -本机记住登录保存 session token、不可导出的保险库 CryptoKey 和加密保险库,不保存密码/verifier。它允许本机用户解锁此账号;退出删除该记录。登录态与本机资源数据库在同一启动屏障后恢复,避免先加载访客资源再切换账号。 +本机记住登录保存 session token、不可导出的保险库 CryptoKey 和加密保险库。勾选记住账号密码时,将填充资料用另一把不可导出的本机 AES-GCM 密钥加密保存;不保存 verifier。退出清除自动恢复 session,保留已选择保存的填充资料,明确清除时全部移除。登录态与本机资源数据库在同一启动屏障后恢复,避免先加载访客资源再切换账号。 ## 文件目录与内容 -thread/文件夹结构以及文件目录记录使用频道加密日志。上传文件先完成密文上传,再发布加密 file.published 目录记录,包含文件 ID、thread/folder、名称、类型、大小;成员同步目录无需下载文件内容,也不消耗下载次数。目录与文件内容中的元数据必须相互匹配,否则拒绝展示。 +频道、分组、目录和资源归属通过服务端结构接口管理,前端拉取快照并渲染。上传文件完成密文传输后提交资源归属;归属操作也保存在本机队列中,断网重试不丢失。文件名称、类型、大小继续保存在加密 file.published 元数据中,读取元数据不消耗下载次数;下载内容后校验元数据一致性。 -频道 K 经 HKDF 派生内容根与每文件独立 AES-GCM 密钥;保留现有逐帧认证、FINAL、连续性、padding、ID/AAD 校验与持久化密文重试。下载逐块解密到本机临时存储,完整性和大小校验成功才交付文件;出错清除临时内容。OPFS 不可用时使用 Blob 回退并明确限制。 +不迁移旧加密结构或旧账户资料,不提供导入接口。新结构与新资料直接由后端管理,客户端不再处理结构控制记录。 + +空间访问凭据不再用于频道内容加密。每频道 chat/files 域各自生成随机 32 字节 epoch 密钥,按账号公钥封装;每个聊天条目、文件及加密发布元数据再独立生成随机对象密钥,由对应域密钥加密封装。对象封装的 AAD 绑定空间、频道、域、epoch 和 itemId。保留现有逐帧认证、FINAL、连续性、padding、ID/AAD 校验与持久化密文重试。下载逐块解密到本机临时存储,完整性和大小校验成功才交付文件;出错清除临时内容。OPFS 不可用时使用 Blob 回退并明确限制。 ## 安全边界 -共享频道密钥意味着任何成员都能解密历史,不能通过移除成员收回其已持有的 K 或明文。当前不实现密钥 epoch 轮换或前向保密,不能声称移除成员可阻止恶意服务端向其提供将来密文。服务端成员/设备撤销负责正常服务下的访问控制。 +频道域 epoch 与随机对象密钥允许新成员按设置只领取当前 epoch,服务端也会在写入时检查作者、频道授权及当前 epoch。`WorkspaceChannel` 是唯一频道聚合;Chat 与 FileGroup 是独立域,授权和密钥隔离,但共享对象帧存储。账号设备已具备根签证书和 FINAL 对象帧签名验证基础;频道结构命令、删除事件和成员/设备撤销后的域轮换仍未完成。已获得的历史密钥和明文无法被收回,因此不能声称已实现前向保密。 -首次邀请公钥来自服务端目录,必须如实保留公钥替换风险;AES-GCM 认证证明密文未被未持钥者篡改,不证明具体发送账号。逐账号签名、密钥透明性与密钥轮换属于单独的协议升级,不能以补齐账号功能代替这些保证。前端代码与运行终端仍是信任根。 +账号签名根公钥和根签名设备证书已有基础实现,首次从服务端获得的其他账号根公钥使用 TOFU 固定;首次密钥替换仍无法检测。频道结构、权限、作者归属和聊天/文件生命周期目前尚未改为签名事件,客户端尚未校验 Owner Capability 或事件签名。密钥透明性、授权/事件签名和成员撤销密钥轮换仍未完成,不能宣称已抵御主动恶意服务端。前端代码与运行终端仍是信任根。 -账号昵称与设备名是不同概念,昵称不参与密钥派生。头像仅接受有大小上限的 PNG/JPEG/WebP,保存在加密资料中,禁止第三方头像 URL 和可执行 SVG。 +账号昵称与设备名是不同概念,昵称不参与密钥派生。头像接受有大小上限的 PNG/JPEG/WebP,由后端管理,禁止第三方头像 URL 和可执行 SVG。聊天发送者由服务端条目作者和账号资料提供,不接受加密内容中的身份声明。 -界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部 Channel/Thread 命名保留,分别映射空间/频道;分组沿用加密 CONTROL 日志,不引入新的成员或密钥体系。 +界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。旧资源路由 /channels 和帧协议 threadId 保留,分别映射空间/频道;新结构接口使用 /spaces。账号保险库保存私钥和访问凭据,资料与结构不再由客户端合并同步。 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/architecture.md b/docs/architecture.md index cd5d235..c2f4b46 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,13 @@ # 架构 +公共业务数据(空间设置、成员、设备、邀请、默认值和部署限制)由后端 DTO 和服务端响应定义;前端类型由构建工具生成。本地资源保险库只携带访问凭据与离线副本,不覆盖在线响应中的设置。在线刷新不改变资源的用户修改时间,也不产生删除标记;只在网络故障时回退缓存。 + +账户昵称头像、空间、频道、分组、文件夹和文件归属由后端管理。空间结构变更经服务端验证关联和权限后持久化,再通知设备重新拉取。新空间和初始“常规”频道在一个事务中创建。 + +消息、文件内容和文件名称等资源元数据由客户端加密,后端保存密文和加密密钥包。账号保险库保存解密凭据,不再作为个人资料或空间结构的数据源。 + +不提供旧数据迁移或导入接口。新空间结构由后端直接创建和管理,客户端不解码或重放旧结构控制记录。 + ## 1. 组件 账号、设备、频道、thread 与内容加密的统一边界见 [account-channel-architecture.md](account-channel-architecture.md)。该文档明确账号成员与设备会话分离、按账号领取频道密钥包及加密文件目录。 @@ -106,9 +114,9 @@ SQLite 存元数据和小帧,磁盘存大帧。 - 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。 - 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。 - 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。 -- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,频道内成员角色与设备席位分开。所有写入都检查账号或访客设备、频道角色/席位和能力权限。 -- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库只同步资源凭据、草稿清单和移除墓碑;设备 S、设备标签、缓存、原始文件和 outbox 保留在本机。新设备生成独立 S 后重新加入。 -- 一个 Channel/Thread 模型共用日志、权限与生命周期;内容是否端到端加密只切换客户端封装加解密。结构 CONTROL 记录不按普通内容 TTL/max_items 清理,随频道删除。 +- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,成员人数按账号去重,设备终端席位独立管理。所有写入都检查账号或访客设备、频道角色/成员名额/设备席位和能力权限。 +- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。 +- 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。 ## 7. 阶段划分 @@ -122,16 +130,16 @@ SQLite 存元数据和小帧,磁盘存大帧。 | 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 | | 6 | PWA、Web Share Target、二维码和短配对码(PAKE) | 手机端走通 | | 7 | 账号登录、客户端加密资源保险库 | 已实现客户端派生口令 verifier/保险库密钥、SQLite opaque vault、资源同步;口令丢失时明确告知无法恢复 | -| 8 | thread、虚拟文件夹与 IRC 风格聊天 | 已实现沿用频道加密日志,消息按 thread 归属并展示发送设备,文件夹元数据由客户端加密 | +| 8 | 空间、频道、分组、文件夹与聊天 | 结构和作者归属由后端管理,客户端加解密资源并渲染后端快照 | 每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。 ## 8. 验收时的检查清单 - `dotnet test`、`vitest`、Playwright 全部通过。 -- 对 SQLite 文件和 blob 目录 grep 明文关键字,必须查不到。 +- 对 SQLite 文件和 blob 目录检查资源内容与文件名明文关键字,必须查不到;账号资料和业务结构按设计对后端可见。 - 服务端日志中没有 K、令牌和频道 id 明文。 -- 抓包确认请求里没有 K 和明文。 +- 抓包确认资源上传请求不包含 K 或资源明文;账户资料和结构请求使用后端业务接口。 - AOT 发布产物能在没有 .NET 运行时的机器上运行。 -界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部 Channel/Thread 命名保留,分别映射空间/频道;分组沿用加密 CONTROL 日志,不引入新的成员或密钥体系。 +界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部资源 Channel 与帧元数据 threadId 保留,分别映射空间/频道;分组和目录由后端结构接口管理,不引入新密钥体系。 diff --git a/docs/e2ee-boundary-comparison.md b/docs/e2ee-boundary-comparison.md new file mode 100644 index 0000000..c85f3c2 --- /dev/null +++ b/docs/e2ee-boundary-comparison.md @@ -0,0 +1,116 @@ +# E2EE 协作架构边界对照 + +对照来源:用户提供的 `D:/Users/JustMe/Downloads/E2EE_Collaboration_Architecture.md`。 +核查对象:2026-10-13 当前工作区代码,包含尚未提交、尚未部署的后端业务模型改造。此次对照不修改密钥协议,不迁移旧数据。 + +## 判断 + +当前实现已推进为“后端管理业务结构和访问策略、浏览器持有频道数据域密钥并加解密资源”的模型。 +参考文档是“后端协调存储、客户端验证授权”的设备身份、频道数据域和 epoch 模型。 +两者的主要差距在身份可信来源、密钥分发粒度、授权验证和撤销后的密码学隔离,而非 CRUD 或界面布局。 + +## 对照 + +| 边界 | 参考文档 | 当前代码 | 判断 | +|---|---|---|---| +| 业务结构 | 后端提供 Identity、Space、Channel、Chat、File、Sync 模块 | 账户资料、空间成员、频道、分组、目录、文件归属已放到后端,前端接口类型从 DTO 生成 | 模块方向一致 | +| 资源加密 | 设备加密,服务端保存密文 | Worker + WebCrypto,AES-GCM 分帧,AAD/ID/FINAL/大小校验,持久化密文重试 | 可沿用基础能力 | +| 密码与数据密钥 | 账户认证不直接充当数据加密根 | 密码/可用的 Passkey PRF 解锁保险库;随机账号 ECDH 私钥解开账号 Key Envelope;频道数据域密钥独立随机生成 | 密码不直接生成资源密钥;账号恢复仍与 E2EE 身份私钥恢复耦合 | +| 用户身份 | Signing Identity 与 Device Keys | 账号保险库保存 P-256 签名根密钥;本机保存不可导出的设备签名私钥,根签名设备证书由后端校验并登记 | 身份密钥基础已落地;尚未用于业务事件 | +| 新设备 | 可信恢复或已有设备审批 | 用户已决定不要求旧设备审批;新设备以账号密码/支持的 Passkey PRF 解锁同一保险库私钥 | 与参考文档的设备审批模型不同;账号被攻破会暴露该账号全部空间密钥 | +| 设备分发 | 每个获授权设备各有 Key Envelope | 按 username 分发;设备只有 UUID、session、席位 S,没有设备解包公钥 | 分发对象不同 | +| Space | 主要是授权管理范围 | 成员、频道目录、日志与同步边界;不再作为频道内容根 | 业务边界已收束,仍由服务端定义 | +| Channel | Chat 与 FileGroup 有独立数据域和密钥 | 每频道独立 chat/files 随机 epoch key;对象另有随机 key,由域密钥封装 | 数据域已落地,密钥包仍按账号而非设备 | +| 授权链 | Owner 签名 Capability,客户端验证 | owner/member + bearer 能力令牌 + 服务端成员表,客户端没有验证签名授权链 | 服务端仍是授权判断的信任方 | +| 管理与读取 | Admin 可以只管理而没有解密权限 | 没有 Admin Capability;Owner 管理凭据与频道数据域密钥格式分开,但 Owner 同时能管理并读取其已授权频道 | 仍缺管理授权与数据读取权限分离 | +| 细分权限 | 按频道、聊天/文件域授予 read/write/upload 等权限 | 默认频道权限、历史策略、epoch 与收件人计划由后端计算;邀请提交时原子校验域密钥包集合,日志、对象下载和写入按权限检查 | 初始权限和邀请策略已落地;其他频道授权 UI 和成员撤销后的轮换待补 | +| Epoch | 成员或设备撤销触发新 epoch,排除被撤销者 | Owner 关闭新成员历史时,邀请流程按 chat/files 分别轮换 epoch;服务端拒绝旧 epoch 写入 | 新成员历史隔离已实现;成员撤销后轮换、并发与离线恢复未实现 | +| FileKey | 每文件随机 FileKey,由 FileGroupKey 封装 | 每条目(文件、消息、加密元数据)独立随机对象密钥,由对应频道域 epoch key 封装 | 已具备独立重封装基础,批量 rewrap/撤销流程未做 | +| 消息事件 | 已签名的 create/edit/delete/reaction,客户端 reduce | 完整对象的最终帧经账号设备签名并绑定密文 manifest;编辑是签名新对象后再删除旧对象,删除/频道结构命令尚无签名事件 | 创建/发布事件基础已接入,删除和结构授权仍缺 | +| 作者证明 | 消息签名绑定用户/设备 | 后端 AuthorAccount 与账号资料决定显示身份 | 能提供正常服务下的身份归属,不能抵抗恶意服务端改写 | +| 前端业务缓存 | 后端定义账户、空间、频道、权限及 API 契约 | DTO 从后端生成;IndexedDB `SavedResource` 仍缓存服务端设置/角色与本机凭据,在线发送不再根据缓存上传开关授权 | 服务端是在线权限来源;本地快照仍需与密钥/队列拆分 | +| 存储部署 | PostgreSQL、Redis、S3 | SQLite、本机 blob、进程内 ChannelHub;NativeAOT + WebSocket/SSE/HTTP 已有 | 部署规模不同,不是首要密码学差距 | + +已收回后端的有:邀请/历史授权计划、默认访问权限、epoch 选择与域密钥包原子提交;上传权限不再读取本地 `membersCanUpload` 缓存;频道/分组创建直接返回服务端生成的实体 ID;`chat/files` 与 `open/invite` 已由后端枚举定义并生成前端联合类型。 + +## 关键代码依据 + +1. [accountCrypto.ts](../web/src/core-bridge/worker/accountCrypto.ts):密码派生登录 verifier 和保险库密钥;账号保险库另含 ECDSA P-256 根签名密钥。`generateInvitationKeys()` 仍专用于 ECDH 邀请。 +2. [account.ts](../web/src/core-bridge/worker/account.ts)、[accountChannels.ts](../web/src/core-bridge/worker/accountChannels.ts):新设备登录后恢复同一账号私钥,按账号领取空间授权包。 +3. [SqliteAccountStore.cs](../server/src/WebDrop.Storage/SqliteAccountStore.cs)、[SqliteAccountSigning.cs](../server/src/WebDrop.Storage/SqliteAccountSigning.cs):账号保存根签名公钥,设备目录保存根签名证书;尚无设备解包公钥/旧设备审批。 +4. [domainCrypto.ts](../web/src/core-bridge/worker/domainCrypto.ts)、[channelDataKeys.ts](../web/src/core-bridge/worker/channelDataKeys.ts):频道 chat/files epoch key 与随机对象 key 在客户端生成,账号 ECDH envelope 与对象封装也在客户端执行。`packages/core/kdf.ts` 仍服务于访问凭据和独立 Share/旧资源路径,不是新 Workspace 的内容密钥路径。 +5. [ChannelWorkspace.cs](../server/src/WebDrop.Core/ChannelWorkspace.cs):后端创建频道、分组、目录并校验资源归属;`WorkspaceChannel` 保存 chat/files epoch 与新成员历史策略。 +6. [ChannelCrypto.cs](../server/src/WebDrop.Core/ChannelCrypto.cs):后端计算邀请 epoch/收件人计划,在单事务校验并提交域 envelopes、默认访问权和 epoch;按频道、域、epoch 过滤对象并校验历史起点、日志读取、下载与写入权限。客户端只生成密钥并封装到该计划指定的账号。 +7. [channelSend.ts](../web/src/core-bridge/worker/channelSend.ts):文本编辑目前是新内容上传成功后删除旧条目。 +8. [reduce.ts](../web/src/store/reduce.ts):作者和资源位置来自后端快照,没有作者签名或签名状态验证。 + +## 用参考文档的威胁模型审视当前实现 + +### 正常服务和被动泄露 + +加密模式下,数据库和 blob 不保存资源明文或未封装的空间 K。数据密钥随机生成,不能仅凭登录 token 或数据库中的 verifier 直接推出保险库密钥或内容密钥。 +机密性仍依赖密码强度、端点安全和实际密钥分发过程;不能将此结论扩展为对主动恶意服务端的完整防护。 + +### 主动恶意服务端 + +- 邀请时,发送端从服务端目录读取收件账号公钥。当前没有可信签名身份、公钥固定或授权链验证;服务端替换公钥可使新邀请的密钥包被攻击者解开。 +- 服务端可改写未签名的成员、权限、作者和结构数据。客户端即使正确校验资源 AEAD,也不能据此验证作者、授权链或当前业务归属。 +- 已移除成员仍持有此前领取的频道域密钥。成员移除后的 epoch 轮换未实现;若恶意服务端继续向其提供后续密文,当前协议不能保证其无法解密。 +- 客户端没有验证 Owner 签名授权链;恶意服务端可伪造访问快照、作者归属或返回过期签名状态。 + +因此,当前实现仍没有达到参考文档对“不可信服务器”的完整目标。现在的后端执行频道/域授权,并由客户端只解开获授权的密钥包;这能收束正常服务下的访问边界,但后端仍是身份目录、授权与事件归属的信任方。后端访问控制与客户端独立验证签名授权,是不同的安全保证。 + +## 正确的前后端责任划分 + +后端继续定义、存储和提供业务实体及接口,包括账户资料、设备目录、空间、频道、分组、文件夹、成员和同步游标。 + +客户端负责生成和保管私钥、建立设备信任、签署并验证授权、校验 Key Envelope/epoch/消息签名、加解密资源,然后把验证通过的数据交给渲染层。 + +这些客户端职责不应被当作“重复的业务实现”删除。CryptoProtocol 的结构可以统一定义并生成类型,但密码学验证必须在客户端执行,不能由不可信服务器替客户端宣布验证成功。 + +## 当前前端可继续收束的部分 + +已经由后端定义并生成前端 DTO 的有:账户资料、空间/频道/分组/文件夹、资源位置、频道设置与限制、成员和同步接口。`ChannelInfo`、`Item`、`PreviewKind` 等 camelCase 类型是 UI 或解密后的投影,不是第二份后端实体;AES 帧解析、账号私钥、Key Envelope 和对象 key 也必须留在客户端。 + +仍有几处边界值得收回或明确: + +1. `SavedResource` 仍把 endpoint secret、链接等本机凭据与频道名称、席位数等服务端展示快照放在同一结构;账号保险库也同步部分快照。建议拆为本机凭据、服务端快照和传输队列,离线快照只用于只读展示。 +2. Worker 从本地 `maxDownloads` 提供上传表单初始值;服务端已校验每个 GATED 文件的下载上限,缓存仅影响表单显示,不决定授权。 +3. `account.ts` 仍包含用户名输入规范化和账号本地恢复编排。服务端负责账户验证、设备 session、个人资料和保险库并发版本;浏览器只生成/持有私钥,并执行解锁加密保险库所需的本地 KDF。 + +文件名、MIME、大小等 E2EE 正文结构和 `file.published` 不能移到服务器定义,否则服务器需解析或生成加密正文。有一处重复是有意的:加密文件元数据保留上传时原始 thread/folder,供解密后完整性核对;后端 WorkspaceLocation 表示当前可移动的位置。两者应明确命名为“原始来源”和“当前目录位置”,不能混为一个字段。 + +参考文档把 Server 定义为不可信,同时要求客户端验证身份、签名授权和事件。后端权威管理账户与空间业务状态并不冲突,但这不代表客户端能防止恶意后端篡改状态。若保留“不信任 Server”的安全目标,后端可负责存储与 API 真值,签名身份、Capability 和 epoch 变更仍须客户端验证;在这些协议实现前,产品只能承诺正常服务下的后端权限执行与内容 E2EE,不能声称抵抗主动恶意服务器。 + +边界原则:除端到端密钥生成、封装、解密与签名验证外,所有可由请求参数决定的业务规则都必须由后端计算和校验。前端校验、禁用按钮和隐藏菜单只能改善交互,不能决定账户角色、频道成员范围、权限、默认值、资源归属或状态转换。 + +## 参考文档还需补足的协议细节 + +### 已确认的实施目标(尚未部署) + +- 账号根签名密钥签发设备证书;设备私钥独立生成。新设备通过密码或 Passkey 解锁保险库恢复根密钥,不要求旧设备批准。 +- 首次收到的账号根公钥采用 TOFU 并展示“未核验”;之后密钥变化必须阻止静默更新。可选使用独立渠道/二维码核对指纹。TOFU 不防服务端在首次接触时替换密钥。 +- 第一阶段覆盖频道结构命令与聊天 create/edit/delete 事件。服务端拒绝无效签名并执行权限规则;浏览器同样验证签名、证书和授权链后才更新投影。 +- 现有服务端仍保存/排序/推送密文与事件;明文加解密继续只在浏览器。服务端分配的 seq 不由客户端签名,事件需绑定客户端 event ID、作者设备、类型、频道/epoch 和密文摘要。 + +- **可信根**:首次确认用户/Owner 签名公钥的方式,避免服务端替换整个身份及授权链。 +- **设备恢复**:项目当前选择不要求旧设备审批;需明确密码、支持 PRF 的 Passkey 和不支持 PRF 的 Passkey 各自能恢复哪些账号/频道密钥,以及账号失陷的影响范围。 +- **签名格式**:确定签名覆盖的 subject/device/scope/permissions/version/epoch/密钥指纹等字段、规范编码和验证规则。 +- **状态一致性**:签名不能单独防止服务端返回旧的合法授权。需要定义版本、前序状态、可信检查点,以及跨设备回滚/分叉的检测范围。 +- **轮换过程**:原子提交新 epoch 与剩余设备的 envelopes,拒绝撤销后的旧 epoch 写入,处理离线设备、并发轮换和上传中的文件。 +- **历史读取策略**:新成员能否读历史、移除成员后哪些旧 FileKey 需要重新封装。重新封装不能撤回已被保存的旧密钥或明文。 +- **元数据可见性**:明确账号资料、频道/目录名称、资源归属和访问时间哪些允许服务端看到。文档中的“用户数据”不能替代字段级隐私范围。 + +Epoch 轮换可以隔离正确切换后新数据的读取权限,不自动提供完整前向保密。服务端拒绝服务及删除密文仍属于无法单靠加密消除的可用性风险。 + +## 按依赖推进的顺序 + +1. 确定签名身份、可信根、设备证书与设备解包密钥;定义新协议版本,不迁移旧数据。 +2. 设计 Owner/Admin/Member 的签名 Capability,将管理能力和数据读取权拆开,并让后端给出可验证、原子提交的授权变更计划。 +3. 将账号级 envelopes 升级为设备级 envelopes,明确项目选择无旧设备审批后,Passkey 恢复、设备失陷与私钥复制的边界。 +4. 为成员/设备撤销实现每个 ChannelChat/ChannelFileGroup 的原子 epoch 轮换,覆盖离线成员、并发邀请和上传中的资源。 +5. 引入签名 ChatEvent、作者验证、可信同步检查点,再接入客户端投影和界面。 +6. 拆分 `SavedResource` 中的后端快照与本机密钥/队列,移除 Worker 对缓存权限字段的业务判断,在线操作以服务端授权为准。 +7. 根据多实例和存储规模需求替换数据库、事件通知和对象存储适配器。 + +已完成的后端 CRUD、契约生成、Worker 分层、频道数据域、对象 key 封装、分帧 AEAD、完整性检查及持久化重试可以作为基础;下一阶段重点应转向可验证授权、设备级密钥分发、撤销轮换和前端缓存收束。 diff --git a/docs/frontend.md b/docs/frontend.md index 85c1437..ccbf383 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -4,6 +4,14 @@ ## 1. 技术栈 +### 数据来源与契约 + +HTTP 请求和响应类型由后端 DTO 生成到 `core-bridge/generated/api.ts`,不手写第二份接口模型。 +运行 `dotnet run --project tools/ContractGenerator -- <仓库目录>` 更新;`scripts/check-api-contract.mjs` 检查生成物是否与后端来源一致。 +`/info` 下发创建默认值、可选设置和部署限制,创建与设置页面通过 store 读取,不在组件里定义业务默认值。 +在线空间与分享列表从服务端刷新元数据;IndexedDB 作为离线缓存、凭据与发送队列。网络故障可显示缓存,认证或契约错误不能当作离线成功。 +账户资料、频道、分组、目录和资源归属通过后端接口加载及修改。加密资源元数据使用独立协议模型,不序列化 UI 的进度、预览、选中或历史版本状态。Worker 解密资源,store 保存服务端结构和资源投影,组件渲染。 + | 项 | 选择 | 说明 | |---|---|---| | 构建 | Vite | 纯客户端 SPA,不做 SSR | @@ -93,8 +101,8 @@ type ItemState = | 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 | | 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB | | 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 | -| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;仅同步 link/ownerLink、草稿清单、邀请私钥和移除墓碑;不含终端 S、设备名、缓存或文件 Blob。按账号/访客分库,会话在 Worker 中使用,通过本机 IndexedDB 恢复 | -| thread 与虚拟文件夹 | `@webdrop/core` 用共享结构编码 `thread_id`/`folder_id` 和 CONTROL;mode 只切换是否加密;worker 解析控制正文,store 保留最新位置以防旧条目更新回退 | +| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;同步资源访问凭据、邀请私钥和移除墓碑;不含个人资料、草稿内容、终端 S、设备名或 Blob。按账号/访客分库,会话通过本机 IndexedDB 恢复 | +| 频道与文件夹 | 后端持久化结构和资源归属,Worker 读取结构快照;加密文件元数据中的原始位置用于内容校验,UI 使用后端当前归属 | | 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` | | 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 | | 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 | @@ -159,15 +167,15 @@ type ItemState = | 4 | 文件:流式上传下载、GATED 拉取、下载计数、预览、粘贴与拖放 | | 6 | PWA、Web Share Target、二维码与短配对码 | -在 `@webdrop/core` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。 +`@webdrop/core` 与真实 Worker 已接入,原型假数据与模拟连接实现已移除。接口测试使用测试替身,不保留第二套业务实现。 ## 12. 实际边界与会话 -core-bridge/instance.ts 使用真实 Worker 实现,mock 仅用于原型和测试。账号与设备由 worker/account.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;频道成员/设置/设备席位通过 channelResources.ts 调用 API;thread/folder/file.published 共用 channelThreads.ts 的加密 CONTROL 日志。 +core-bridge/instance.ts 使用真实 Worker 实现。账号、设备和个人资料由 worker/account.ts 与 accountProfile.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;空间成员/设置/设备席位通过 channelResources.ts 调用 API;频道、分组和目录由 channelThreads.ts 调用后端。workspaceRecords.ts 只读取加密文件元数据,不处理账户或结构操作。 @webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。 -频道和 Share 各有独立领域 API 与生命周期;Share 封存由服务端事务清除 owner_hash,不能再写。账号保险库同步个人资料、邀请私钥、资源凭据和移除墓碑;设备 S、文件缓存与 outbox 保留本机。 +空间和 Share 各有独立领域 API 与生命周期;Share 封存由服务端事务清除 owner_hash,不能再写。账号保险库同步邀请私钥、资源凭据和移除墓碑;个人资料从后端读取,设备 S、文件缓存与 outbox 保留本机。 ## 13. 账号会话恢复与频道密钥领取 @@ -177,7 +185,7 @@ core-bridge/instance.ts 使用真实 Worker 实现,mock 仅用于原型和测 ## 14. 用户概念映射与分组 -界面 Space 对应既有顶层 ChannelInfo 与 /c/:id;界面频道对应 ThreadInfo 和 thread_id。ChannelGroupInfo 属于当前空间,ThreadInfo.groupId 可选。store 从 group-created/group-renamed/thread-grouped 事件重建分组;threadGroups 保留最新归属,避免延迟的创建事件将频道移回旧分组。分组不是文件夹,也不是成员/密钥边界。 +界面 Space 对应既有顶层 ChannelInfo 与 /c/:id;界面频道对应后端 WorkspaceChannel 和帧协议 threadId。store 直接渲染服务端结构快照,不重放创建/重命名/分组事件。ThreadInfo 与 FolderInfo 只将后端可空字段转成 UI 的可选字段。分组不是成员或密钥边界。 ## 15. 文件交互边界 @@ -187,7 +195,7 @@ ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关 ## 16. 文件聊天消息与身份快照 -ItemBase.author 为可选发送者快照 {username,displayName,avatar?}。文件的快照放入已有 file.published 加密目录记录,文本通过 item.author CONTROL 记录关联。store 的 authors 索引支持身份记录先于条目到达,并在晚到的下载/进度更新中保留身份,避免取文件内容后丢失头像。该快照用于展示,不代表逐账号数字签名。 +ItemBase.author 由服务端资源作者和账号资料投影得到,晚到的解密内容不能覆盖服务器提供的作者与位置。客户端提供的作者声明不能覆盖服务端资料。这是服务端身份归属,不是端对端逐账号签名。 ChatPane 订阅当前频道的条目 ID,ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。 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/docs/threat-model.md b/docs/threat-model.md index f98b949..8f97dc6 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -16,7 +16,7 @@ | 能看到 | 说明 | |---|---| -| 频道 id、模式、设置 | 包括共享频道名、席位数、权限、TTL、下载上限等,服务端需要据此执行 | +| 频道 id、模式、设置 | 包括共享频道名、成员人数上限、权限、TTL、下载上限等,服务端需要据此执行 | | 帧数量、时间、序号 | 追加顺序由服务端分配 | | 密文大小 | 经过档位 padding,见 protocol §6 | | 帧标志 | FINAL、SNAPSHOT、GATED、ENC | @@ -25,11 +25,12 @@ | 下载次数 | 服务端计数 | | 传输层信息 | IP、TLS 指纹、访问时间 | | 账号服务可见 | 用户名、口令 verifier、保险库密文大小和更新时间;不会看到保险库明文、频道密钥或分享链接 | +| 业务结构和个人资料 | 账号昵称头像、空间和频道名称、分组、文件夹名称、资源归属、资源作者;由后端管理,不属于资源内容的端对端加密承诺 | | 看不到(enc 模式) | 说明 | |---|---| | 内容 | 文本、JSON、文件字节 | -| 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 | +| 文件名、类型、总大小、文件内设备标签 | 都在加密的条目元数据里 | | 密钥 K、管理密钥 A | 由客户端保管,以账号公钥封装的密文可存服务器;可选分享链接使用 fragment | `raw` 模式下服务端能看到全部负载,界面必须明确提示。 @@ -53,14 +54,14 @@ | 模式降级(enc 换成 raw) | 链接带 `k` 就只接受 enc 且认证通过的帧,不匹配直接拒绝 | | 重放旧帧 | 同一条目内 (item_id, frame_index) 唯一,服务端幂等,客户端按条目去重 | | 服务端隐瞒或删除条目 | **无法防止**(可用性),见 §6 | -| 链接泄露 | 链接即能力。席位上限限制加入数,所有者可释放席位和删除频道 | +| 链接泄露 | 链接即能力。成员人数上限限制账号成员和访客终端加入数,所有者可撤销成员、释放设备和删除频道 | | fragment 外泄 | 取出后立即 `history.replaceState`;`Referrer-Policy: no-referrer`;不把 K 放进查询串 | | XSS | 严格 CSP;条目预览在沙箱中渲染;`raw` 内容不内联渲染,下载带 `attachment` 和 `nosniff` | | 暴力猜频道 id | id 128 位随机 | | 撞库/截获账号登录 | 客户端 PBKDF2 派生登录凭据,经 HTTPS 传输;服务端另存独立随机 salt 的 PBKDF2 慢哈希,并限流。TLS 或运行终端泄露仍可能暴露可登录凭据 | | 账号数据库泄露 | 数据库只有加盐慢哈希,不能直接用于登录;弱口令仍可被离线猜测。登录凭据不能直接派生保险库密钥 | | 账号混用/设备身份混用 | 按账号与访客隔离 IndexedDB,切换时中止后台任务;保险库不包含设备 S,每台设备单独生成 | -| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session 与设备席位;加密选项不会绕开权限 | +| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session、成员人数和设备席位;加密选项不会绕开权限 | | 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 | ## 5. 前端信任根(残余风险) @@ -101,6 +102,6 @@ ## 8. 账号设备与本机缓存 -同一账号的各设备拥有独立 sessions 和频道 S。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。 +同一账号的各设备拥有独立 sessions 和频道 S,但在频道成员上限中只计一个账号成员。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或释放其成员名额,也不会影响其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。未登录访客无法跨设备去重,每个访客终端单独计入人数上限。 本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。 diff --git a/docs/ui-design.md b/docs/ui-design.md index 5d0c209..88fcf55 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -117,7 +117,7 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr │ │ 资源清单先在本机加密后同步 │ │ │ └────────────────────────────┘ │ │ 我的频道(有才显示) │ -│ abc123… 🔒加密 所有者 3 台设备 │ +│ abc123… 🔒加密 所有者 最多 3 人 │ │ ┌ 设备频道 ───────────────────┐ │ │ │ 多台设备之间长期同步 │ │ │ │ [ 新建频道 ] [ 加入频道 ] │ │ @@ -128,7 +128,7 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr | 操作 | 结果 | |---|---| | 快速分享(输入文本、拖入、粘贴、选择、相册、拍照) | 先在首页编辑临时文本并收集文件;点「创建临时分享」后建立加密草稿并带入内容;点「完成分享」后生成链接与二维码 | -| 新建频道 | 登录后创建,自动成为该频道所有者;可选择是否端到端加密,默认启用;设备数、TTL、受限下载次数和上传权限仍用同一频道模型 | +| 新建频道 | 登录后创建,自动成为该频道所有者;可选择是否端到端加密,默认启用;成员人数上限、TTL、受限下载次数和上传权限仍用同一频道模型 | | 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 | | 我的频道 | 点一行进入;垃圾桶从当前账号/访客资源清单移除,**不会删除频道**,需二次确认;账号清单移除会同步到其他设备 | | 账号与同步 | 用户名密码注册或登录;本地频道/分享凭据在 Worker 加密后同步,另一台设备登录后恢复;退出时撤销会话 | @@ -156,7 +156,7 @@ Share 是独立的只读发布资源,不创建频道成员、threads 或席位 左栏按 thread 显示虚拟文件夹与文件。可以创建/重命名 thread、创建/重命名文件夹、多选文件并移动到当前 thread 的文件夹。文件预览仍使用中央预览区;消息不混入文件列表。手机上 thread 和文件树可横向/纵向收缩,聊天输入固定在文件列表下方、上传动作栏上方。 -thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同步;端到端加密开启时元数据是密文,关闭时服务端可见。两种传输选项共享相同的线程和目录模型。GATED 文件通过独立加密目录显示名称和归属,内容仅在显式操作后读取;没有目录记录的旧文件仍显示通用占位。 +频道名、文件夹名和移动操作通过后端结构接口管理,其他设备收到通知后重新读取。文件名称和类型仍通过加密资源元数据同步,GATED 内容仅在显式操作后读取。 其他弹层: @@ -165,8 +165,8 @@ thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同 | 条目详情 | 手机为底部抽屉,电脑为内联预览区;内容一致 | | 扫码加入 | 相机识别二维码(浏览器自带 `BarcodeDetector`,不支持或无权限时退回「粘贴链接」)→ 加入预览(模式、权限)→ 加入后进入该频道 | | 写文本 | 抽屉,`Ctrl+Enter` 发送;首页上用于快速分享 | -| 频道设置 | 顶栏齿轮打开设置面板;所有者可修改名称、席位、成员上传、保留时间和受限文件下载次数 | -| 设备管理 | 设备面板显示已加入席位数;设备可重命名,所有者可重命名任意设备并移除其他设备 | +| 频道设置 | 顶栏齿轮打开设置面板;所有者可修改名称、成员人数上限、成员上传、保留时间和受限文件下载次数 | +| 设备管理 | 设备面板分别显示成员人数与已连接设备;成员人数按账号去重,同一账号可在多台设备登录;设备可重命名,所有者可移除任意设备 | 扫到的链接含密钥,只放在内存里,不写日志、不持久化。 @@ -258,7 +258,7 @@ thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同 | 码 | 页面 | |---|---| -| `seats_full` | 加入预览页:「席位已满」,告知联系所有者 | +| `seats_full` | 加入预览页:「成员人数已满」,告知联系所有者 | | `not_joined`(被释放) | 频道页整体替换为「你已被移出此频道」 | | `not_found` / `gone` | 「频道不存在或已过期」 | | `rate_limited` | Toast,带重试倒计时 | @@ -331,11 +331,11 @@ thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同 ## 13. 空间、频道与频道分组 -界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、设备席位和加密边界;频道分组只组织导航,不改变权限或加密密钥。 +界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备席位只用于单设备认证与撤销。频道分组只组织导航,不改变权限或加密密钥。 左侧上段显示账号与空间列表,下段按分组展示频道。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。 -空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属通过已有加密 CONTROL 日志同步,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。 +空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属由后端持久化并通过通知刷新,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。 ## 14. 文件管理器交互 @@ -343,7 +343,7 @@ thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同 右键文件提供预览、下载、复制名称、移动和删除;文件夹提供打开和重命名;空白区域提供上传、新建文件夹、全选。菜单和悬停卡通过 portal 挂载到 body,贴近指针且限制在视口内,支持 Escape、外部点击和键盘导航。 -文件可拖入当前频道的文件夹或面包屑目录,多选文件可一起移动;跨空间/频道的内部拖放拒绝。外部文件可拖入指定文件夹上传。内部拖拽移动仅追加目录控制记录,不重新加密或拉取文件。 +文件可拖入当前频道的文件夹或面包屑目录,多选文件可一起移动;跨空间/频道的内部拖放拒绝。外部文件可拖入指定文件夹上传。内部拖拽移动由后端更新资源归属,不重新加密或拉取文件。 悬停卡延迟显示名称、类型、大小、时间;已同步且非 GATED 的小图片/文本可显示缩略预览。GATED、上传中或校验失败的文件不会自动取内容,悬停不消耗下载次数。右侧文件区与聊天之间可拖动分隔条调整宽度,分隔条也支持键盘左右键。 @@ -351,7 +351,7 @@ thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同 聊天时间线同时显示文本和已发布的文件,文件消息使用头像、发送者名称、时间和文件卡片,卡片提供预览和下载。当前账号发出的消息靠右,其他人的消息靠左;没有头像时显示名称首字。上传中显示进度和状态,受限文件不自动加载内容或媒体缩略图。 -发送者名称优先使用发送时的个人名称,回退固定用户名;设备名保留在提示中。姓名和头像快照随空间加密日志同步,其他账号和设备刷新后仍能看到原上传者。旧记录没有发送者快照时回退设备来源,不推测为当前用户。 +发送者名称与头像从后端账号资料读取,回退固定用户名;设备名保留在提示中。资源作者由后端记录,昵称修改后其他设备同步更新展示。没有账号归属时回退设备来源。 头像缩略图在 Worker 内生成,最大 64×64、编码后不超过 8 KB,仅使用 PNG/JPEG/WebP data URL;不使用第三方头像链接。圆形头像使用专门的 --radius-avatar token,消息气泡仍遵守现有圆角规范。 diff --git a/packages/core/README.md b/packages/core/README.md index 2650eaa..17c051f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -137,7 +137,7 @@ const { complete, nextIndex, meta } = await result; ## 必须注意的几点 -**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它占用席位。不持久化的话,每次打开链接都会占用一个新席位。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。 +**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它登记独立设备席位以便认证和撤销;频道成员上限按账号数计算,同一账号的多台设备不重复占名额。不持久化的话,每次打开链接都会登记一个新设备终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。 **2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 859f00d..7ea4c00 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ */ export { ChannelSession, openLink } from './session.js'; +export { parseEnvelope } from './frame.js'; export type { AuthRole, ChannelSettings, @@ -40,6 +41,8 @@ export { buildLink, parseLink } from './link.js'; export type { LinkParts, ParsedLink } from './link.js'; export { fromBase64Url, toBase64Url } from './bytes.js'; +export { contentEventPayload, frameManifestHash, generateSigningKeyPair, importSigningPublicKey, signStatement, verifyStatement } from './signatures.js'; +export type { ContentEventStatement } from './signatures.js'; export { Flags, MAX_FRAME_DATA, PROTOCOL_VERSION } from './constants.js'; export type { ChannelMode, ItemKind } from './constants.js'; diff --git a/packages/core/src/reader.ts b/packages/core/src/reader.ts index 4d9e16e..944a149 100644 --- a/packages/core/src/reader.ts +++ b/packages/core/src/reader.ts @@ -3,6 +3,7 @@ import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './by import { IntegrityError, type IntegrityReason } from './errors.js'; import { openFrame, parseEnvelope, type Envelope } from './frame.js'; import { sha256 } from './kdf.js'; +import { frameManifestHash } from './signatures.js'; import { decodeFirstFrame, type ItemMeta } from './item.js'; import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js'; @@ -29,6 +30,8 @@ export interface ReceivedItem { complete: boolean; /** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */ content: Bytes; + /** SHA-256 manifest over the complete ciphertext envelopes in frame-index order. */ + ciphertextManifestHash?: Bytes; } export type ItemEvent = @@ -104,7 +107,7 @@ export class ItemReader { const itemId = toBase64Url(env.itemId); try { - return await this.#accept(env, itemId, seq); + return await this.#accept(env, itemId, seq, Uint8Array.from(envelope)); } catch (e) { if (!(e instanceof IntegrityError)) throw e; this.#open.delete(itemId); @@ -118,7 +121,7 @@ export class ItemReader { this.#done.delete(itemId); } - async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise { + async #accept(env: Envelope, itemId: string, seq: number | undefined, envelope: Bytes): Promise { const isEncFrame = (env.flags & Flags.Enc) !== 0; // 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之 if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode'); @@ -139,7 +142,7 @@ export class ItemReader { } // 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件 - const digest = await sha256(env.payload); + const digest = await sha256(envelope); const dup = state?.frames.get(env.frameIndex); if (dup) { if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch'); @@ -186,6 +189,9 @@ export class ItemReader { content: concat(...parts), }; if (state.meta) item.meta = state.meta; + if (complete) item.ciphertextManifestHash = Uint8Array.from(await frameManifestHash( + Array.from({ length: state.finalIndex! + 1 }, (_, index) => state!.frames.get(index)!.digest), + )); if (complete) { this.#open.delete(itemId); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index c9f3a83..3dc38ee 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -92,6 +92,8 @@ interface SessionInit { * JS 无法保证这些内存被真正擦除。调用 dispose() 只能尽力清零。 */ export class ChannelSession { + /** Applications may supply independently authorized object keys instead of the space root. */ + objectKeyResolver?: (itemId: string) => Promise; readonly id: string; readonly idBytes: Bytes; readonly mode: ChannelMode; @@ -247,6 +249,7 @@ export class ChannelSession { /** 一个条目的 AES-GCM 密钥,带缓存。 */ itemKey(itemId: Uint8Array): Promise { + if (this.objectKeyResolver) return this.objectKeyResolver(toBase64Url(itemId)); if (!this.#keys) throw new CapabilityError('enc'); const cacheKey = toBase64Url(itemId); let key = this.#itemKeys.get(cacheKey); diff --git a/packages/core/src/signatures.ts b/packages/core/src/signatures.ts new file mode 100644 index 0000000..16d9f0c --- /dev/null +++ b/packages/core/src/signatures.ts @@ -0,0 +1,110 @@ +const SIGNATURE_MAGIC = new Uint8Array([0x57, 0x44, 0x53, 0x01]); // WDS\x01 +const MAX_KIND_BYTES = 64; +const MAX_PAYLOAD_BYTES = 1_048_576; +const source = (value: Uint8Array): ArrayBuffer => { + const copy = new Uint8Array(value.length); + copy.set(value); + return copy.buffer; +}; + +export interface ContentEventStatement { + spaceId: Uint8Array; + channelId: string; + username: string; + deviceId: string; + eventId: Uint8Array; + eventKind: string; + domain: 'chat' | 'files'; + epoch: number; + itemId: Uint8Array; + frameCount: number; + manifestHash: Uint8Array; + targetItemId?: Uint8Array; +} + +export function contentEventPayload(event: ContentEventStatement): Uint8Array { + const channel = new TextEncoder().encode(event.channelId.toLowerCase()); + const username = new TextEncoder().encode(event.username.toLowerCase()); + const device = new TextEncoder().encode(event.deviceId.toLowerCase()); + const kind = new TextEncoder().encode(event.eventKind); + const target = event.targetItemId ?? new Uint8Array(); + if (event.spaceId.length !== 16 || event.eventId.length !== 16 || event.itemId.length !== 16 || event.manifestHash.length !== 32 + || target.length !== 0 && target.length !== 16 || !Number.isInteger(event.epoch) || event.epoch < 0 + || !Number.isInteger(event.frameCount) || event.frameCount < 1 || event.frameCount > 65_536 + || !/^[0-9a-f-]{36}$/.test(event.channelId.toLowerCase()) || !/^[0-9a-f-]{36}$/.test(event.deviceId.toLowerCase()) + || !/^[a-z0-9_-]{3,32}$/.test(event.username.toLowerCase()) || !/^[a-z][a-z.\-]{0,31}$/.test(event.eventKind)) + throw new TypeError('签名事件字段无效'); + const bytes = new Uint8Array(16 + 1 + channel.length + 1 + username.length + 1 + device.length + 16 + 1 + kind.length + 1 + 4 + 16 + 4 + 32 + 1 + 16); + let offset = 0; + bytes.set(event.spaceId, offset); offset += 16; + bytes[offset++] = channel.length; bytes.set(channel, offset); offset += channel.length; + bytes[offset++] = username.length; bytes.set(username, offset); offset += username.length; + bytes[offset++] = device.length; bytes.set(device, offset); offset += device.length; + bytes.set(event.eventId, offset); offset += 16; + bytes[offset++] = kind.length; bytes.set(kind, offset); offset += kind.length; + bytes[offset++] = event.domain === 'chat' ? 1 : 2; + new DataView(bytes.buffer).setUint32(offset, event.epoch, false); offset += 4; + bytes.set(event.itemId, offset); offset += 16; + new DataView(bytes.buffer).setUint32(offset, event.frameCount, false); offset += 4; + bytes.set(event.manifestHash, offset); offset += 32; + bytes[offset++] = target.length === 0 ? 0 : 1; + if (target.length) bytes.set(target, offset); + return bytes; +} + +export async function frameManifestHash(frameDigests: readonly Uint8Array[]): Promise { + if (frameDigests.length < 1 || frameDigests.length > 65_536 || frameDigests.some(digest => digest.length !== 32)) + throw new RangeError('帧清单无效'); + const bytes = new Uint8Array(8 + frameDigests.length * 36); + bytes.set([0x57, 0x44, 0x31, 0x4d]); // WD1M + new DataView(bytes.buffer).setUint32(4, frameDigests.length, false); + let offset = 8; + for (let index = 0; index < frameDigests.length; index++) { + new DataView(bytes.buffer).setUint32(offset, index, false); offset += 4; + bytes.set(frameDigests[index]!, offset); offset += 32; + } + return new Uint8Array(await crypto.subtle.digest('SHA-256', source(bytes))); +} + +function signingBytes(kind: string, payload: Uint8Array): Uint8Array { + if (!/^[a-z][a-z0-9._-]{0,63}$/.test(kind)) throw new TypeError('签名类型无效'); + if (payload.length > MAX_PAYLOAD_BYTES) throw new RangeError('签名内容过大'); + const kindBytes = new TextEncoder().encode(kind); + if (kindBytes.length > MAX_KIND_BYTES) throw new RangeError('签名类型过长'); + const bytes = new Uint8Array(SIGNATURE_MAGIC.length + 2 + kindBytes.length + 4 + payload.length); + let offset = 0; + bytes.set(SIGNATURE_MAGIC, offset); offset += SIGNATURE_MAGIC.length; + new DataView(bytes.buffer).setUint16(offset, kindBytes.length, false); offset += 2; + bytes.set(kindBytes, offset); offset += kindBytes.length; + new DataView(bytes.buffer).setUint32(offset, payload.length, false); offset += 4; + bytes.set(payload, offset); + return bytes; +} + +export async function generateSigningKeyPair(extractable = false): Promise { + return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, extractable, ['sign', 'verify']); +} + +export async function signStatement(privateKey: CryptoKey, kind: string, payload: Uint8Array): Promise { + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, privateKey, source(signingBytes(kind, payload)), + ); + const bytes = new Uint8Array(signature); + if (bytes.length !== 64) throw new Error('签名格式无效'); + return bytes; +} + +export async function verifyStatement( + publicKey: CryptoKey, kind: string, payload: Uint8Array, signature: Uint8Array, +): Promise { + if (signature.length !== 64) return false; + return crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, publicKey, source(signature), source(signingBytes(kind, payload)), + ); +} + +export async function importSigningPublicKey(key: JsonWebKey): Promise { + if (key.kty !== 'EC' || key.crv !== 'P-256' || !key.x || !key.y || key.d !== undefined) + throw new TypeError('签名公钥格式无效'); + return crypto.subtle.importKey('jwk', key, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); +} diff --git a/packages/core/src/stream.ts b/packages/core/src/stream.ts index d25979a..3acae14 100644 --- a/packages/core/src/stream.ts +++ b/packages/core/src/stream.ts @@ -3,6 +3,8 @@ import { equalBytes, type Bytes } from './bytes.js'; import { IntegrityError } from './errors.js'; import { MAX_ENC_PAYLOAD, openFrame, parseEnvelope, peekEnvelopeLength } from './frame.js'; import { decodeFirstFrame, type ItemMeta } from './item.js'; +import { sha256 } from './kdf.js'; +import { frameManifestHash } from './signatures.js'; import type { ReaderContext } from './reader.js'; /** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */ @@ -106,6 +108,8 @@ export interface ItemDecodeResult { nextIndex: number; /** 第 0 帧里的元数据;从中间帧续传时没有。 */ meta: ItemMeta | undefined; + /** Present only for complete reads starting at frame zero. */ + ciphertextManifestHash?: Bytes; } /** @@ -125,8 +129,10 @@ export async function* decryptItemFrames( let flagsMask: number | undefined; let finalSeen = false; let meta: ItemMeta | undefined; + const frameDigests: Bytes[] = []; for await (const bytes of splitEnvelopes(source)) { + frameDigests.push(await sha256(bytes)); const env = parseEnvelope(bytes); const isEnc = (env.flags & Flags.Enc) !== 0; @@ -157,7 +163,8 @@ export async function* decryptItemFrames( if (data.length > 0) yield data; } - return { complete: finalSeen, nextIndex: expected, meta }; + return { complete: finalSeen, nextIndex: expected, meta, + ...(finalSeen && (opts.fromIndex ?? 0) === 0 ? { ciphertextManifestHash: Uint8Array.from(await frameManifestHash(frameDigests)) } : {}) }; } /** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */ diff --git a/packages/core/test/e2e.test.ts b/packages/core/test/e2e.test.ts index ac277b3..9b03a50 100644 --- a/packages/core/test/e2e.test.ts +++ b/packages/core/test/e2e.test.ts @@ -78,9 +78,12 @@ beforeAll(async () => { const verifier = crypto.getRandomValues(new Uint8Array(32)); const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); const pubkey = await crypto.subtle.exportKey('jwk', keys.publicKey); + const signingKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const signingPublicKey = await crypto.subtle.exportKey('jwk', signingKeys.publicKey); const encoded = (value: Uint8Array) => Buffer.from(value).toString('base64'); const registered = await fetch(`${base}/api/v1/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)), public_key: JSON.stringify(pubkey), + username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)), + public_key: JSON.stringify(pubkey), signing_public_key: JSON.stringify(signingPublicKey), }) }); expect(registered.status).toBe(204); const session = await fetch(`${base}/api/v1/accounts/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'core_e2e', verifier: encoded(verifier) }) }); @@ -98,7 +101,7 @@ afterAll(async () => { const api = (path: string) => `${base}/api/v1${path}`; async function createChannel(opts: Parameters[0] = {}) { - const created = await ChannelSession.create(opts); + const created = await ChannelSession.create({ ...opts, settings: { name: 'Core E2E', ...opts.settings } }); const res = await fetch(api('/channels'), { method: 'POST', headers: { ...created.request.headers, 'X-WD-Account': accountToken, 'Content-Type': 'application/json' }, diff --git a/packages/core/test/signatures.test.ts b/packages/core/test/signatures.test.ts new file mode 100644 index 0000000..942e674 --- /dev/null +++ b/packages/core/test/signatures.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { fromBase64Url } from '../src/bytes.js'; +import { contentEventPayload, frameManifestHash, generateSigningKeyPair, signStatement, verifyStatement } from '../src/signatures.js'; +import { h, vectors } from './helpers.js'; + +describe('协议签名原语', () => { + it('跨语言固定向量可以验签,且类型和负载均受签名保护', async () => { + const fixture = vectors.signatures; + const publicKey = await crypto.subtle.importKey('jwk', { + kty: 'EC', crv: 'P-256', x: fixture.x, y: fixture.y, ext: true, + }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + const payload = h(fixture.payload); + const signature = h(fixture.signature); + expect(await verifyStatement(publicKey, fixture.kind, payload, signature)).toBe(true); + expect(await verifyStatement(publicKey, 'chat.edit', payload, signature)).toBe(false); + payload[0] = payload[0]! ^ 1; + expect(await verifyStatement(publicKey, fixture.kind, payload, signature)).toBe(false); + expect(await verifyStatement(publicKey, fixture.kind, h(fixture.payload), signature.subarray(1))).toBe(false); + expect(fromBase64Url(fixture.x)).toHaveLength(32); + }); + + it('浏览器签名可验证,篡改被拒绝', async () => { + const pair = await generateSigningKeyPair(); + expect(pair.privateKey.extractable).toBe(false); + const payload = new TextEncoder().encode('signed command bytes'); + const signature = await signStatement(pair.privateKey, 'workspace.channel.create', payload); + expect(signature).toHaveLength(64); + expect(await verifyStatement(pair.publicKey, 'workspace.channel.create', payload, signature)).toBe(true); + expect(await verifyStatement(pair.publicKey, 'workspace.group.create', payload, signature)).toBe(false); + await expect(signStatement(pair.privateKey, 'Workspace.Channel.Create', payload)).rejects.toThrow(); + }); + + it('聊天/文件签名上下文与帧清单跨语言向量一致', async () => { + const event = vectors.content_event; + const manifest = await frameManifestHash(event.frame_digests.map((digest: string) => h(digest))); + expect(manifest).toEqual(h(event.manifest_hash)); + expect(contentEventPayload({ + spaceId: h(event.space_id), channelId: event.channel_id, username: event.username, deviceId: event.device_id, + eventId: h(event.event_id), eventKind: event.kind, domain: event.domain, epoch: event.epoch, + itemId: h(event.item_id), frameCount: event.frame_count, manifestHash: manifest, targetItemId: h(event.target_item_id), + })).toEqual(h(event.payload)); + }); +}); diff --git a/scripts/browser-account-channel-smoke.js b/scripts/browser-account-channel-smoke.js index 803e1df..cad2cae 100644 --- a/scripts/browser-account-channel-smoke.js +++ b/scripts/browser-account-channel-smoke.js @@ -36,7 +36,7 @@ async (page) => { await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); if (await owner.getByLabel('个人名称', { exact: true }).inputValue() !== '多设备测试用户') throw new Error('Profile did not survive reload'); await owner.getByRole('img', { name: '个人头像', exact: true }).waitFor(); - results.push('login, encrypted profile and avatar survive reload'); + results.push('server-owned profile and avatar survive login and reload'); await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('架构验收空间'); await owner.getByRole('button', { name: '创建', exact: true }).click(); @@ -52,9 +52,11 @@ async (page) => { await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click(); await owner.getByLabel('所属分组').selectOption({ label: '工作' }); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); await owner.getByRole('button', { name: '重命名分组 工作', exact: true }).click(); await owner.getByLabel('分组名称', { exact: true }).fill('项目'); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); await owner.getByRole('button', { name: '分组 项目', exact: true }).click(); if (await owner.getByRole('button', { name: '讨论', exact: true }).isVisible()) throw new Error('Group did not collapse'); await owner.getByRole('button', { name: '分组 项目', exact: true }).click(); @@ -65,12 +67,14 @@ async (page) => { await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click(); await owner.getByLabel('所属分组').selectOption({ label: '未分组' }); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); if (await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).count()) throw new Error('Ungrouping did not remove group membership'); await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click(); await owner.getByLabel('所属分组').selectOption({ label: '项目' }); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); await owner.getByRole('button', { name: '常规', exact: true }).click(); - results.push('channel groups create, rename, collapse and survive encrypted replay'); + results.push('backend channels and groups create, rename, collapse and survive reload'); const path = new URL(owner.url()).pathname; if (owner.url().includes('#')) throw new Error('Account channel navigation retained a secret fragment'); await owner.locator('textarea').last().fill('常规 thread 聊天验证'); @@ -116,7 +120,7 @@ async (page) => { await owner.getByLabel('移动所选文件到文件夹').selectOption({ label: '资料目录' }); await owner.getByRole('button', { name: '资料目录', exact: true }).dblclick(); await owner.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor(); - results.push('folder navigation and encrypted file moves work'); + results.push('backend folders and file locations update without re-encrypting content'); await second.goto(origin); const selfRow = second.getByRole('region', { name: '账号设备' }).locator('div').filter({ has: second.getByText('本机', { exact: true }) }); await selfRow.locator('input').fill('验收副设备'); diff --git a/scripts/browser-backend-data-smoke.js b/scripts/browser-backend-data-smoke.js new file mode 100644 index 0000000..8f97d9a --- /dev/null +++ b/scripts/browser-backend-data-smoke.js @@ -0,0 +1,53 @@ +// Works against either the development proxy or the embedded production frontend. +async (page) => { + const origin = new URL(page.url()).origin; + const context = await page.context().browser().newContext({ viewport: { width: 1440, height: 900 } }); + const owner = await context.newPage(); const results = []; + try { + await owner.goto(origin); + await owner.getByLabel('用户名', { exact: true }).fill(`backend_${Date.now()}`); + await owner.getByLabel('密码', { exact: true }).fill('Backend-data-test-password'); + await owner.getByRole('button', { name: '创建账号', exact: true }).click(); + await owner.getByRole('button', { name: '新建空间', exact: true }).click(); + await owner.getByLabel('空间名称(加入者和服务端可见)').fill('后端数据源测试'); + await owner.getByRole('button', { name: '创建', exact: true }).click(); + await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); + const spaceId = new URL(owner.url()).pathname.split('/').pop(); + await owner.getByRole('button', { name: '新建频道分组', exact: true }).click(); + await owner.getByLabel('分组名称', { exact: true }).fill('前端分组'); + const requestPromise = owner.waitForRequest(request => request.method() === 'POST' && request.url().endsWith(`/spaces/${spaceId}/groups`)); + await owner.getByRole('button', { name: '创建', exact: true }).click(); + const request = await requestPromise; + const allHeaders = await request.allHeaders(); + const headers = Object.fromEntries(['authorization', 'x-wd-role', 'x-wd-endpoint', 'x-wd-account', 'content-type'].filter(key => allHeaders[key]).map(key => [key, allHeaders[key]])); + await owner.getByRole('button', { name: '分组 前端分组', exact: true }).waitFor(); + const initial = await context.request.get(`${origin}/api/v1/spaces/${spaceId}/structure`, { headers }).then(r => r.json()); + const groupId = initial.structure.groups[0].id; + const renamed = await context.request.patch(`${origin}/api/v1/spaces/${spaceId}/groups/${groupId}`, { headers, data: { name: '后端分组' } }); + if (!renamed.ok()) throw new Error(`Backend rename failed ${renamed.status()}`); + await owner.getByRole('button', { name: '分组 后端分组', exact: true }).waitFor(); + const created = await context.request.post(`${origin}/api/v1/spaces/${spaceId}/channels`, { headers, data: { name: '后端频道', groupId } }); + if (created.status() !== 201) throw new Error('Backend channel creation failed'); + await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor(); + results.push('direct backend group and channel edits update the UI without client structure records'); + const locationResponse = owner.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/spaces/${spaceId}/locations`)); + await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('后端作者归属测试'); + await owner.getByRole('button', { name: '发送消息', exact: true }).click(); + if (!(await locationResponse).ok()) throw new Error('Backend item association failed'); + const profile = await context.request.patch(`${origin}/api/v1/accounts/profile`, { headers, data: { displayName: '后端昵称' } }); + if (!profile.ok()) throw new Error('Backend profile update failed'); + await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).getByText('后端作者归属测试', { exact: true }).waitFor(); + results.push('backend account profile and stored authors determine message identity'); + const spaces = await context.request.get(`${origin}/api/v1/accounts/spaces`, { headers }).then(r => r.json()); + if (!spaces.some(space => space.id === spaceId) || spaces.some(space => 'read_hash' in space || 'ownerLink' in space || 'memberLink' in space)) throw new Error('Space metadata catalog is invalid'); + for (const path of ['/accounts/profile/import', `/spaces/${spaceId}/structure/import`]) { + const response = await context.request.post(`${origin}/api/v1${path}`, { headers, data: {} }); + if (response.status() !== 404 && response.status() !== 405) throw new Error('Unexpected legacy import route'); + } + await owner.reload(); + await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor(); + await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).waitFor(); + results.push('server-owned state survives reload and there are no migration endpoints'); + return { passed: results }; + } finally { await context.close(); } +} diff --git a/scripts/browser-channel-domains-smoke.js b/scripts/browser-channel-domains-smoke.js new file mode 100644 index 0000000..63d793e --- /dev/null +++ b/scripts/browser-channel-domains-smoke.js @@ -0,0 +1,50 @@ +async (page) => { + const origin = page.url().startsWith('http') ? new URL(page.url()).origin : 'http://127.0.0.1:5179'; const browser = page.context().browser(); const contexts = []; const results = []; + const password = 'Channel-domains-test-password'; const suffix = Date.now(); const ownerName = `domains_owner_${suffix}`; const memberName = `domains_member_${suffix}`; + let owner, member; const failures = []; + async function account(username) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); contexts.push(context); const target = await context.newPage(); + await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password); + await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '新建空间', exact: true }).waitFor(); return target; + } + async function send(target, text) { await target.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill(text); await target.getByRole('button', { name: '发送消息', exact: true }).click(); await target.getByRole('article').getByText(text, { exact: true }).waitFor(); } + try { + owner = await account(ownerName); member = await account(memberName); + member.on('response', async response => { if (response.status() >= 400 && response.url().includes('/api/v1/')) failures.push({ status: response.status(), method: response.request().method(), path: new URL(response.url()).pathname, code: (await response.json().catch(() => ({}))).error }); }); + await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('独立数据域测试'); await owner.getByRole('button', { name: '创建', exact: true }).click(); + await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); const path = new URL(owner.url()).pathname; + await send(owner, '加入前的历史聊天'); + const ownerExplorer = owner.getByRole('region', { name: '频道文件管理器' }); + await ownerExplorer.locator('input[type=file]').setInputFiles({ name: 'old-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('historical file must remain private') }); + await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await owner.getByRole('button', { name: 'old-domain-file.txt', exact: true }).waitFor(); + await owner.getByRole('button', { name: '在未分组新建频道', exact: true }).click(); await owner.getByLabel('频道名称', { exact: true }).fill('单独授权频道'); await owner.getByRole('button', { name: '创建频道', exact: true }).click(); + await owner.getByRole('button', { name: '单独授权频道', exact: true }).waitFor(); await owner.getByRole('button', { name: '常规', exact: true }).click(); + await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click(); await owner.getByLabel('新成员可读历史聊天').uncheck(); await owner.getByLabel('新成员可读历史文件').uncheck(); + await owner.getByRole('button', { name: '保存', exact: true }).click(); await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); + await owner.getByRole('button', { name: '空间设置', exact: true }).click(); await owner.getByLabel('被邀请用户的用户名').fill(memberName); await owner.getByRole('button', { name: '发送邀请', exact: true }).click(); + await owner.getByText(`${memberName} · 已邀请`, { exact: true }).waitFor(); await owner.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); + await member.goto(origin + path); await member.getByRole('button', { name: '接受并加入空间', exact: true }).click(); await member.getByRole('button', { name: '常规', exact: true }).waitFor(); + if (await member.getByRole('button', { name: '单独授权频道', exact: true }).count()) throw new Error('Member received an unauthorized channel'); + if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Member received historical chat keys'); + if (await member.getByRole('button', { name: 'old-domain-file.txt', exact: true }).count()) throw new Error('Member received historical file keys'); + await send(owner, '加入后的新聊天'); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor(); + const ownerMessage = owner.locator('article').filter({ hasText: '加入后的新聊天' }); + const memberCopy = member.locator('article').filter({ hasText: '加入后的新聊天' }); + if (await ownerMessage.evaluate(element => getComputedStyle(element).flexDirection) !== 'row-reverse' + || await memberCopy.evaluate(element => getComputedStyle(element).flexDirection) !== 'row') throw new Error('Messages are not aligned by sender'); + await send(member, '成员发言验证'); await owner.getByRole('article').getByText('成员发言验证', { exact: true }).waitFor(); + results.push('default-channel access is automatic; other channels and past epochs remain inaccessible'); + const explorer = ownerExplorer; + await explorer.locator('input[type=file]').setInputFiles({ name: 'new-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('independent random file key verification') }); + await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).waitFor(); + await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).dblclick(); + await member.getByRole('dialog').locator('pre').filter({ hasText: 'independent random file key verification' }).waitFor(); + results.push('file-group key unwraps an independent file key and decrypts the file on another account'); + await member.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); await member.reload(); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor(); + if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Reload restored disallowed history'); + results.push('authorization and epoch boundaries survive reload'); + return { passed: results }; + } catch (error) { + throw new Error(`${error.message}; failures=${JSON.stringify(failures)}; owner alerts=${JSON.stringify(owner ? await owner.getByRole('alert').allTextContents() : [])}; member alerts=${JSON.stringify(member ? await member.getByRole('alert').allTextContents() : [])}`); + } finally { for (const context of contexts) await context.close(); } +} diff --git a/scripts/browser-chat-files-smoke.js b/scripts/browser-chat-files-smoke.js index 48aec77..bbb71bf 100644 --- a/scripts/browser-chat-files-smoke.js +++ b/scripts/browser-chat-files-smoke.js @@ -42,13 +42,20 @@ async (page) => { await file.getByRole('img', { name: '小白 的头像', exact: true }).waitFor(); await viewer.reload(); await viewer.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }).getByRole('img', { name: '小白 的头像', exact: true }).waitFor(); if (downloads !== 1) throw new Error('Reloading file bubbles consumed a download'); - results.push('identity survives download updates and encrypted history replay without automatic content reads'); + results.push('server-owned identity survives download updates and reload without automatic content reads'); await viewer.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('我收到了。'); await viewer.getByRole('button', { name: '发送消息', exact: true }).click(); await sender.getByRole('article', { name: '小黑 的消息', exact: true }).getByText('我收到了。', { exact: true }).waitFor(); results.push('each participant keeps their own sender identity'); await viewer.screenshot({ path: 'C:/Users/JustMe/Desktop/webdrop-proj/.playwright-mcp/chat-file-messages.png', fullPage: true }); for (const width of [1440, 390]) { await viewer.setViewportSize({ width, height: 900 }); for (const colorScheme of ['light', 'dark']) { - await viewer.emulateMedia({ colorScheme }); if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error('Chat layout overflow'); + await viewer.emulateMedia({ colorScheme }); + // Allow the media-query subscription to switch the React workspace layout. + await viewer.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) { + const overflow = await viewer.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth, + elements: [...document.querySelectorAll('body *')].filter(e => e.getBoundingClientRect().right > innerWidth + 1).slice(0, 12).map(e => ({ tag: e.tagName, cls: e.className, right: e.getBoundingClientRect().right })) })); + throw new Error(`Chat layout overflow: ${JSON.stringify(overflow)}`); + } } } return { passed: results }; } finally { for (const context of contexts) await context.close(); } diff --git a/scripts/browser-passkey-smoke.js b/scripts/browser-passkey-smoke.js new file mode 100644 index 0000000..a746e33 --- /dev/null +++ b/scripts/browser-passkey-smoke.js @@ -0,0 +1,44 @@ +async (page) => { + const origin = new URL(page.url()).origin; + const context = await page.context().browser().newContext(); const owner = await context.newPage(); + const cdp = await context.newCDPSession(owner); const results = []; + const username = `passkey_${Date.now()}`; const password = 'Passkey-test-password-2026'; + const errors = []; const calls = []; + owner.on('pageerror', error => errors.push(error.message)); + owner.on('request', request => { if (request.url().includes('/api/v1/')) calls.push(new URL(request.url()).pathname); }); + await cdp.send('WebAuthn.enable'); + const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal', hasResidentKey: true, + hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, hasPrf: true } }); + let secretExtensionSent = false; + owner.on('request', request => { if (request.url().includes('/accounts/passkeys/') && request.postData()) { + const body = JSON.parse(request.postData()); if (body.credential && JSON.parse(body.credential).clientExtensionResults?.prf?.results) secretExtensionSent = true; + } }); + try { + await owner.goto(origin); await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password); + await owner.getByRole('button', { name: '创建账号', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); + await owner.getByLabel('Passkey名称', { exact: true }).fill('测试恢复凭据'); await owner.getByLabel('验证账号密码', { exact: true }).fill(password); + await owner.getByRole('button', { name: '绑定Passkey', exact: true }).click(); + await owner.getByText('测试恢复凭据 · 登录与解密恢复', { exact: true }).waitFor(); + if (secretExtensionSent) throw new Error('PRF results were sent to the API'); + results.push('verified WebAuthn binding enables encrypted recovery without sending PRF outputs to the server'); + await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('Passkey恢复测试'); + await owner.getByRole('button', { name: '创建', exact: true }).click(); await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); + await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('Passkey恢复后的私密内容'); await owner.getByRole('button', { name: '发送消息', exact: true }).click(); + await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor(); + const path = new URL(owner.url()).pathname; + await owner.goto(origin); await owner.getByRole('button', { name: '退出登录', exact: true }).click(); + await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).waitFor(); await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).click(); + await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); await owner.goto(origin + path); + await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor(); + results.push('Passkey restores the vault and decrypts resources without entering the password'); + await owner.goto(origin); await owner.getByRole('button', { name: '撤销 测试恢复凭据', exact: true }).click(); await owner.getByText('Passkey已撤销', { exact: true }).waitFor(); + await owner.getByRole('button', { name: '退出登录', exact: true }).click(); + await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password); + await owner.getByRole('button', { name: '登录并同步', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); + results.push('revocation removes the credential while password recovery remains available'); + return { passed: results }; + } catch (error) { + const inputs = await owner.locator('input').evaluateAll(inputs => inputs.map(input => ({ name: input.name, type: input.type, length: input.value.length, valid: input.validity.valid, message: input.validationMessage }))); + throw new Error(`Passkey smoke failed after ${results.length} checks: ${error.message}; body=${(await owner.locator('body').innerText()).slice(0, 800)}; inputs=${JSON.stringify(inputs)}; errors=${JSON.stringify(errors)}; calls=${JSON.stringify(calls)}`); + } finally { await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }).catch(() => {}); await context.close(); } +} 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/AGENTS.md b/web/AGENTS.md index 6686932..33842bc 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -14,8 +14,7 @@ ## 范围 -- 只修改 `web/` 与前端相关文档(`docs/ui-design.md`、`docs/frontend.md`)。 -- **不修改** `server/`、`docs/protocol.md`、`docs/api.md`、`docs/openapi.yaml`、`docs/threat-model.md`、`docs/architecture.md`。需要变更时,写明诉求交给负责人。 +- 按用户要求修改项目文件,遵循对应目录的约定与测试要求。 - `@webdrop/core` 未就绪时,在 `src/core-bridge/` 按文档接口做适配与假实现,不自行发明协议。 ## 命令 diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 8fadfee..0653937 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -10,6 +10,7 @@ import { ShareScreen } from './ShareScreen'; import { channelPath, navigate, useRoute } from './navigation'; import { Splash } from './Splash'; import { AccountScreen } from '@/features/home/AccountScreen'; +import { AccountDock } from '@/features/home/AccountDock'; import { useAccount } from '@/store/account'; const SEEN_KEY = 'wd.splash'; @@ -80,6 +81,7 @@ export function App() { return ( <> {screen} + {username ? : null} {splash ? : null} ); diff --git a/web/src/core-bridge/account.ts b/web/src/core-bridge/account.ts index c8f5dc5..c0b618b 100644 --- a/web/src/core-bridge/account.ts +++ b/web/src/core-bridge/account.ts @@ -1,10 +1,12 @@ import { callWorker } from './workerClient'; +import type { AccountDeviceBody, InvitationBody } from './generated/api'; +import type { PersonalProfile } from './protocol/account'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; -export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: { displayName: string; avatar?: string; modifiedAt: number }; error?: string } -export interface AccountDevice { id: string; name: string; created_at: number; last_seen: number; current: boolean } -export interface AccountInvitation { channel_id: string; username: string; payload: string; created_at: number } +export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string } +export type AccountDevice = AccountDeviceBody; +export type AccountInvitation = InvitationBody; export const accountApi = { register: (username: string, password: string, remember = true) => callWorker('registerAccount', { username, password, remember, apiBase: API_BASE }), diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts index 7807369..1237899 100644 --- a/web/src/core-bridge/api.ts +++ b/web/src/core-bridge/api.ts @@ -1,9 +1,9 @@ import type { ParsedChannelLink } from '@/lib/channelLink'; import type { AccountInvitation } from './account'; -import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types'; /** - * 常驻空间的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。 + * 常驻空间的 Worker 边界;业务结构来自后端,资源加解密由 @webdrop/core 处理。 * 便捷分享有自己的边界,见 shareApi.ts。 */ export interface WorkspaceApi { @@ -21,8 +21,6 @@ export interface WorkspaceApi { listSaved(): Promise; /** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */ forget(id: string): Promise; - /** 仅假实现提供:载入演示用的空间 */ - loadDemo?(): ChannelInfo[]; /** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */ connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void; @@ -33,6 +31,7 @@ export interface WorkspaceApi { renameGroup(id: string, name: string): Promise; groupThread(id: string, groupId?: string): Promise; renameThread(threadId: string, name: string): Promise; + setChannelHistory(threadId: string, chatHistory: boolean, filesHistory: boolean): Promise; createFolder(threadId: string, name: string, parentId?: string): Promise; renameFolder(folderId: string, threadId: string, name: string): Promise; moveItems(itemIds: string[], threadId: string, folderId?: string): Promise; @@ -46,6 +45,4 @@ export interface WorkspaceApi { listArchive(id: string): Promise; /** 标记「别的设备的修改」已看过(本地状态) */ markSeen(id: string): Promise; - /** 仅假实现提供,用于演示连接状态 */ - simulateConnection?(state: ConnectionState): void; } diff --git a/web/src/core-bridge/captcha.ts b/web/src/core-bridge/captcha.ts index df439ec..5cea3c7 100644 --- a/web/src/core-bridge/captcha.ts +++ b/web/src/core-bridge/captcha.ts @@ -1,18 +1,8 @@ -const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; - -interface ServerInfo { - captcha?: { enabled?: boolean; api_endpoint?: string }; -} +import { serverApi } from './server'; /** Cap performs challenge solving in its own workers and only returns a one-use token. */ export async function solveCreationCaptcha(): Promise { - const response = await fetch(`${API_BASE.replace(/\/$/, '')}/info`, { - cache: 'no-store', - credentials: 'omit', - referrerPolicy: 'no-referrer', - }); - if (!response.ok) throw new Error('无法读取服务端能力'); - const info = await response.json() as ServerInfo; + const info = await serverApi.info(); if (!info.captcha?.enabled) return undefined; if (typeof info.captcha.api_endpoint !== 'string') throw new Error('验证码服务配置无效'); diff --git a/web/src/core-bridge/channelAccess.ts b/web/src/core-bridge/channelAccess.ts index aa9e143..8a5f39a 100644 --- a/web/src/core-bridge/channelAccess.ts +++ b/web/src/core-bridge/channelAccess.ts @@ -1,8 +1,9 @@ import { callWorker } from './workerClient'; import type { ChannelInfo } from './types'; import type { ParsedChannelLink } from '@/lib/channelLink'; +import type { MemberBody } from './generated/api'; const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; -export interface ChannelMember { username: string; role: 'owner' | 'member'; joined_at: number } +export type ChannelMember = MemberBody; export const channelAccessApi = { members: (id: string) => callWorker('channelMembers', { id, apiBase }), preview: (link: ParsedChannelLink) => callWorker('previewChannel', { link, apiBase }), 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/database.ts b/web/src/core-bridge/database.ts index ebb45f0..3f168ab 100644 --- a/web/src/core-bridge/database.ts +++ b/web/src/core-bridge/database.ts @@ -1,11 +1,16 @@ import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; import type { Item } from './types'; +import type { ChannelInfo as ChannelMetadata, ShareInfo as ShareMetadata, WorkspaceBody } from './generated/api'; export type ManifestEntry = | { t: 'file'; id: string; name: string; mime: string; size: number; ts: number } | { t: 'text'; localId: string; name?: string; text: string; ts: number }; export interface SavedResource { + domainKeys?: Record; + workspaceMetadata?: WorkspaceBody; + channelMetadata?: ChannelMetadata; + shareMetadata?: ShareMetadata; creatorAccount?: string; localScope?: string; modifiedAt?: number; @@ -26,7 +31,6 @@ export interface SavedResource { createdAt: number; expiresAt: number; seats: number; - membersCanUpload: boolean; state?: 'draft' | 'sealed'; maxVisitors?: number; maxDownloads?: number | null; @@ -55,6 +59,7 @@ interface DraftFile { } interface WebDropDb extends DBSchema { + locationOutbox: { key: string; value: QueuedLocation; indexes: { 'by-resource': string } }; resourceDeletes: { key: string; value: { id: string; modifiedAt: number } }; resources: { key: string; value: SavedResource }; outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string; 'by-resource-created': [string, number] } }; @@ -73,8 +78,9 @@ export function setResourceChangeListener(listener: (() => void) | undefined): v function db(): Promise> { let dbPromise = databases.get(resourceScope); - dbPromise ??= openDB(`webdrop-2-${resourceScope}`, 2, { + dbPromise ??= openDB(`webdrop-2-${resourceScope}`, 3, { upgrade(database, oldVersion, _newVersion, tx) { + if (oldVersion < 3) database.createObjectStore('locationOutbox', { keyPath: 'key' }).createIndex('by-resource', 'resourceId'); if (oldVersion < 1) { database.createObjectStore('resources', { keyPath: 'id' }); const outbox = database.createObjectStore('outbox', { keyPath: 'key' }); @@ -143,6 +149,19 @@ export async function getResource(id: string): Promise): Promise { + if (resource.localScope !== currentResourceScope()) throw new DOMException('账号已切换', 'AbortError'); + const database = await db(); + const tx = database.transaction('resources', 'readwrite'); + const current = await tx.store.get(resource.id); + if (!current) throw new Error('资源已移除'); + const updated = { ...current, ...patch }; + await tx.store.put(updated); + await tx.done; + return updated; +} + export async function listResources(kind: SavedResource['kind']): Promise { const values = await (await db()).getAll('resources'); return values.filter((resource) => resource.kind === kind).sort((a, b) => b.createdAt - a.createdAt); @@ -150,7 +169,9 @@ export async function listResources(kind: SavedResource['kind']): Promise { const database = await db(); - const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes'], 'readwrite'); + const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes', 'locationOutbox'], 'readwrite'); + const locationKeys = await tx.objectStore('locationOutbox').index('by-resource').getAllKeys(id); + await Promise.all(locationKeys.map(key => tx.objectStore('locationOutbox').delete(key))); await tx.objectStore('resources').delete(id); await tx.objectStore('resourceDeletes').put({ id, modifiedAt: Date.now() }); const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id); @@ -213,4 +234,14 @@ export async function dequeueItem(resourceId: string, itemId: string): Promise frame.itemId === itemId).map((frame) => tx.store.delete(frame.key))); await tx.done; + await (await db()).delete('locationOutbox', `${resourceId}:${itemId}`); } + +export interface QueuedLocation { key: string; resourceId: string; itemId: string; threadId: string; folderId?: string } +export async function queueLocation(resourceId: string, itemId: string, threadId: string, folderId?: string): Promise { + await (await db()).put('locationOutbox', { key: `${resourceId}:${itemId}`, resourceId, itemId, threadId, folderId }); +} +export async function nextQueuedLocation(resourceId: string): Promise { + return (await (await db()).getAllFromIndex('locationOutbox', 'by-resource', resourceId, 1))[0]; +} +export async function dequeueLocation(key: string): Promise { await (await db()).delete('locationOutbox', key); } diff --git a/web/src/core-bridge/errors.ts b/web/src/core-bridge/errors.ts index 9789756..55653ac 100644 --- a/web/src/core-bridge/errors.ts +++ b/web/src/core-bridge/errors.ts @@ -1,6 +1,6 @@ const messages: Record = { not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。', - seats_full: '空间席位已满,请联系所有者。', not_joined: '你已被移出此空间。', + seats_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。', visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。', forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。', too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。', @@ -12,6 +12,7 @@ const messages: Record = { account_invalid: '用户名或密码错误,或登录已过期。', account_required: '请先登录账号。', invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。', channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。', + space_name_required: '请填写空间名称。', }; export function errorMessage(error: unknown): string { 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/mock.ts b/web/src/core-bridge/mock.ts deleted file mode 100644 index 037845b..0000000 --- a/web/src/core-bridge/mock.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { ParsedChannelLink } from '@/lib/channelLink'; -import { previewKindOf } from '@/lib/fileKind'; -import type { WorkspaceApi } from './api'; -import { b64url } from './mockLinks'; -import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed'; -import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, FileUploadOptions, Item, Snapshot, WorkspaceEvent } from './types'; - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -/** protocol §2:链接形态。enc 带 k,raw 不带 */ -function buildLinks(id: string, o: CreateOptions): Pick { - const base = `${location.origin}/c/${id}`; - const a = b64url(32); - if (o.mode === 'enc') { - const k = b64url(32); - return { memberLink: `${base}#k=${k}`, ownerLink: `${base}#k=${k}&a=${a}` }; - } - const w = o.membersCanUpload ? b64url(32) : null; - return { memberLink: w ? `${base}#w=${w}` : base, ownerLink: `${base}#a=${a}${w ? `&w=${w}` : ''}` }; -} - -interface Runtime { - info: ChannelInfo; - items: Map; - blobs: Map>; - devices: Device[]; - snapshot: Snapshot; - demo: boolean; -} - -const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', self: true, joinedAt: Date.now() }; - -/** 空间的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */ -export function createMockApi(): WorkspaceApi { - const runtimes = new Map(); - const timers = new Set(); - let current: Runtime | undefined; - let emit: (e: WorkspaceEvent) => void = () => {}; - - const later = (ms: number, fn: () => void) => { - const id = window.setTimeout(() => { - timers.delete(id); - fn(); - }, ms); - timers.add(id); - }; - const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`; - const put = (item: Item) => { - current?.items.set(item.id, item); - emit({ t: 'item', item }); - }; - - function newRuntime(info: ChannelInfo, devices: Device[]): Runtime { - const rt: Runtime = { info, items: new Map(), blobs: new Map(), devices, snapshot: { text: '', updatedBy: '', updatedAt: 0 }, demo: false }; - runtimes.set(info.id, rt); - return rt; - } - - function seedDemo(): Runtime { - const rt = newRuntime(demoChannel, demoDevices); - rt.demo = true; - rt.snapshot = demoSnapshot; - for (const item of seedItems().items) rt.items.set(item.id, item); - rt.blobs.set('it-image', makeSvgBlob()); - rt.blobs.set('it-video', makeVideoBlob().then((b) => b ?? Promise.reject(new Error('unsupported')))); - rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' })); - rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' })); - return rt; - } - - /** 演示空间里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */ - function runDemoActivity(rt: Runtime) { - later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' })); - later(14000, () => { - const cur = rt.items.get('it-image'); - if (cur?.kind !== 'file') return; - const prev = { id: `v${(cur.versions?.length ?? 0) + 1}`, ts: cur.ts, deviceId: cur.deviceId, size: cur.size }; - put({ ...cur, ts: Date.now(), deviceId: PHONE, size: cur.size + 140, versions: [prev, ...(cur.versions ?? [])], unseen: true }); - }); - later(5000, () => emit({ t: 'devices', devices: rt.devices.map((d) => (d.id === PHONE ? { ...d, editing: true } : d)) })); - later(11000, () => emit({ t: 'devices', devices: rt.devices })); - } - - return { - async inviteUser() {}, - async listInvitations() { return []; }, - async revokeInvitation() {}, - async listSaved() { return []; }, - async forget() {}, - async createChannel(opts) { - await wait(300); - const id = b64url(16); - const createdAt = Date.now(); - const info: ChannelInfo = { id, name: opts.name?.trim() || undefined, mode: opts.mode, role: 'owner', seats: opts.seats, seatsUsed: 1, membersCanUpload: opts.membersCanUpload, maxDownloads: opts.fileMaxDownloads ?? 10, ttlSeconds: opts.ttlSeconds, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) }; - newRuntime(info, [selfDevice]); - return info; - }, - - async joinChannel(link: ParsedChannelLink) { - await wait(500); - const known = runtimes.get(link.id); - if (known) return { ...known.info, role: 'member', ownerLink: undefined }; - const base = `${location.origin}/c/${link.id}`; - const frag = (keys: Record) => - Object.entries(keys).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join('&'); - const member = frag({ k: link.keys.k, w: link.keys.w }); - const info: ChannelInfo = { - id: link.id, - mode: link.mode, - role: link.isOwner ? 'owner' : 'member', - seats: 3, - seatsUsed: 1, - membersCanUpload: true, - maxDownloads: 10, - ttlSeconds: 86400, - createdAt: Date.now(), - expiresAt: Date.now() + 86400_000, - memberLink: member ? `${base}#${member}` : base, - ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined, - }; - newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', joinedAt: Date.now() }]); - return info; - }, - - loadDemo() { - return [(runtimes.get(demoChannel.id) ?? seedDemo()).info]; - }, - - connect(channelId, onEvent) { - emit = onEvent; - current = runtimes.get(channelId); - const rt = current; - if (!rt) return () => {}; - later(500, () => { - emit({ t: 'channel', info: rt.info }); - emit({ t: 'devices', devices: rt.devices }); - emit({ t: 'snapshot', snapshot: rt.snapshot }); - for (const item of rt.items.values()) emit({ t: 'item', item }); - emit({ t: 'connection', state: 'online' }); - emit({ t: 'ready' }); - }); - if (rt.demo) runDemoActivity(rt); - return () => { - for (const id of timers) clearTimeout(id); - timers.clear(); - emit = () => {}; - }; - }, - - async sendText(text, threadId) { - put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text, ...(threadId ? { threadId } : {}) }); - }, - - async updateChannelSettings(patch) { - if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以修改空间设置'); - current.info = { - ...current.info, - ...(patch.name === undefined ? {} : { name: patch.name.trim() || undefined }), - ...(patch.seats === undefined ? {} : { seats: patch.seats }), - ...(patch.membersCanUpload === undefined ? {} : { membersCanUpload: patch.membersCanUpload, canUpload: current.info.role === 'owner' || patch.membersCanUpload }), - ...(patch.ttlSeconds === undefined ? {} : { expiresAt: Date.now() + patch.ttlSeconds * 1000 }), - ...(patch.fileMaxDownloads === undefined ? {} : { maxDownloads: patch.fileMaxDownloads }), - }; - emit({ t: 'channel', info: current.info }); - }, - - async renameDevice(endpointHash, name) { - if (!current) throw new Error('空间未打开'); - const device = current.devices.find((entry) => entry.id === endpointHash); - if (!device) throw new Error('设备不存在'); - if (current.info.role !== 'owner' && !device.self) throw new Error('只有所有者可以重命名其他设备'); - current.devices = current.devices.map((entry) => entry.id === endpointHash ? { ...entry, name: name.trim() } : entry); - emit({ t: 'devices', devices: current.devices }); - }, - - async releaseDevice(endpointHash) { - if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以移除设备'); - current.devices = current.devices.filter((entry) => entry.id !== endpointHash); - current.info = { ...current.info, seatsUsed: current.devices.length }; - emit({ t: 'devices', devices: current.devices }); - emit({ t: 'channel', info: current.info }); - }, - - async sendFiles(files, options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId, folderId) { - for (const file of files) { - const id = uid(); - const base: FileItem = { - id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 }, - ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), - name: file.name, mime: file.type, size: file.size, remaining: options.directPreview || options.maxDownloads === 0 ? null : options.maxDownloads, - gated: !options.directPreview, - preview: previewKindOf(file.type, file.name), - }; - current?.blobs.set(id, file); - put(base); - for (let p = 0.2; p < 1; p += 0.2) { - await wait(250); - put({ ...base, state: { kind: 'receiving', progress: p } }); - } - await wait(250); - put({ ...base, state: { kind: 'complete' } }); - } - }, - - async createThread(name, groupId) { - const id = crypto.randomUUID(); - emit({ t: 'thread-created', thread: { id, name: name.trim(), createdAt: Date.now(), createdBy: SELF, ...(groupId ? { groupId } : {}) } }); - return id; - }, - - async renameThread(threadId, name) { emit({ t: 'thread-renamed', id: threadId, name: name.trim() }); }, - async createGroup(name) { const id = crypto.randomUUID(); emit({ t: 'group-created', group: { id, name: name.trim(), createdAt: Date.now() } }); return id; }, - async renameGroup(id, name) { emit({ t: 'group-renamed', id, name: name.trim() }); }, - async groupThread(id, groupId) { emit({ t: 'thread-grouped', id, ...(groupId ? { groupId } : {}) }); }, - - async createFolder(threadId, name, parentId) { - emit({ t: 'folder-created', folder: { id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: name.trim() } }); - }, - - async renameFolder(folderId, _threadId, name) { emit({ t: 'folder-renamed', id: folderId, name: name.trim() }); }, - - async moveItems(itemIds, threadId, folderId) { - for (const id of itemIds) { - const item = current?.items.get(id); - if (item) put({ ...item, threadId, folderId }); - } - emit({ t: 'items-moved', itemIds, threadId, ...(folderId ? { folderId } : {}) }); - }, - - async editText(id, text) { - const cur = current?.items.get(id); - if (cur?.kind !== 'text') return; - put({ ...cur, text, editedAt: Date.now() }); - }, - - async resolveConflict(id, choice) { - const cur = current?.items.get(id); - if (cur?.kind !== 'text' || !cur.conflict) return; - put({ ...cur, text: choice === 'theirs' ? cur.conflict.text : cur.text, conflict: undefined }); - }, - - async remove(id) { - current?.items.delete(id); - current?.blobs.delete(id); - emit({ t: 'removed', id }); - }, - - async setSnapshot(text) { - const snapshot = { text, updatedBy: SELF, updatedAt: Date.now() }; - if (current) current.snapshot = snapshot; - emit({ t: 'snapshot', snapshot }); - }, - - async getBlob(id) { - const b = current?.blobs.get(id); - if (b) return b; - const item = current?.items.get(id); - if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' }); - throw new Error('not found'); - }, - - async markSeen(id) { - const cur = current?.items.get(id); - if (cur?.kind !== 'file' || !cur.unseen) return; - put({ ...cur, unseen: false }); - }, - - async listArchive() { - await wait(250); - return archiveEntries; - }, - - simulateConnection(state: ConnectionState) { - emit({ t: 'connection', state }); - }, - }; -} diff --git a/web/src/core-bridge/mockLinks.ts b/web/src/core-bridge/mockLinks.ts deleted file mode 100644 index 72505af..0000000 --- a/web/src/core-bridge/mockLinks.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** 假实现用的随机 id 与密钥:n 字节 → base64url 无填充(16 字节 22 字符,32 字节 43 字符) */ -export function b64url(bytes: number): string { - const b = crypto.getRandomValues(new Uint8Array(bytes)); - return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} diff --git a/web/src/core-bridge/mockSeed.ts b/web/src/core-bridge/mockSeed.ts deleted file mode 100644 index 7dc8038..0000000 --- a/web/src/core-bridge/mockSeed.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { ArchiveEntry, ChannelInfo, Device, Item, Snapshot } from './types'; - -/** 仅供假实现使用的演示数据与演示用文件内容 */ - -export const SELF = 'self'; -export const PHONE = 'phone'; -export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n'; - -const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF'; - -export const devices: Device[] = [ - { id: SELF, name: '本机 · Chrome', kind: 'desktop', self: true, joinedAt: Date.now() }, - { id: PHONE, name: 'iPhone', kind: 'phone', joinedAt: Date.now() - 3600_000 }, - { id: 'tablet', name: 'iPad', kind: 'tablet', joinedAt: Date.now() - 3 * 3600_000 }, -]; - -export const channel: ChannelInfo = { - id: DEMO_ID, - mode: 'enc', - role: 'owner', - seats: 3, - seatsUsed: 3, - createdAt: Date.now() - 3 * 3600_000, - expiresAt: Date.now() + 21 * 3600_000, - memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`, - ownerLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}&a=${KEY}`, -}; - -export const snapshot: Snapshot = { - text: '待办\n- 取快递\n- 发周报\n- 备份相册', - updatedBy: PHONE, - updatedAt: Date.now() - 20 * 60_000, -}; - -export const archiveEntries: ArchiveEntry[] = [ - { path: 'project-assets/', size: 0, dir: true }, - { path: 'project-assets/README.md', size: 2_140, dir: false }, - { path: 'project-assets/design/', size: 0, dir: true }, - { path: 'project-assets/design/logo.svg', size: 8_420, dir: false }, - { path: 'project-assets/design/cover.png', size: 3_281_990, dir: false }, - { path: 'project-assets/design/mockup.fig', size: 18_204_113, dir: false }, - { path: 'project-assets/fonts/', size: 0, dir: true }, - { path: 'project-assets/fonts/SourceHanSans-Regular.otf', size: 16_532_204, dir: false }, - { path: 'project-assets/data/', size: 0, dir: true }, - { path: 'project-assets/data/export.csv', size: 412_873, dir: false }, - { path: 'project-assets/notes.txt', size: 1_822, dir: false }, -]; - -const ago = (min: number) => Date.now() - min * 60_000; - -export function seedItems(): { items: Item[]; sizeOverride: Record } { - const logText = Array.from({ length: 180 }, (_, i) => { - const level = i % 17 === 0 ? 'WARN' : 'INFO'; - return `2026-10-01 14:${String(i % 60).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')} ${level} worker-${i % 4} handled request #${1000 + i} in ${20 + ((i * 13) % 80)}ms`; - }).join('\n'); - - const items: Item[] = [ - { - id: 'it-video', kind: 'file', ts: ago(3), deviceId: PHONE, state: { kind: 'complete' }, - name: '屏幕录制.webm', mime: 'video/webm', size: 1_840_000, remaining: null, preview: 'video', - }, - { - id: 'it-zip', kind: 'file', ts: ago(7), deviceId: PHONE, state: { kind: 'complete' }, - name: 'project-assets.zip', mime: 'application/zip', size: 48_200_000, remaining: 8, preview: 'archive', - }, - { - id: 'it-image', kind: 'file', ts: ago(12), deviceId: PHONE, state: { kind: 'complete' }, - name: '设计稿.svg', mime: 'image/svg+xml', size: 6_420, remaining: null, preview: 'image', - versions: [ - { id: 'v2', ts: ago(55), deviceId: SELF, size: 6_110 }, - { id: 'v1', ts: ago(130), deviceId: SELF, size: 5_820 }, - ], - unseen: true, - }, - { - id: 'it-log', kind: 'text', ts: ago(18), deviceId: SELF, state: { kind: 'complete' }, - text: logText, - }, - { - id: 'it-conflict', kind: 'text', ts: ago(26), deviceId: SELF, state: { kind: 'complete' }, - text: '会议室 B204', editedAt: ago(25), conflict: { deviceId: PHONE, text: '会议室 B305' }, - }, - { - id: 'it-json', kind: 'text', ts: ago(30), deviceId: PHONE, state: { kind: 'complete' }, - text: '{"name":"webdrop","version":1,"tags":["share","e2ee"],"limits":{"seats":3,"ttl":86400}}', - }, - { - id: 'it-short', kind: 'text', ts: ago(41), deviceId: PHONE, state: { kind: 'complete' }, - text: '明天 10 点开会,带电脑', - }, - { - id: 'it-pdf', kind: 'file', ts: ago(95), deviceId: SELF, state: { kind: 'complete' }, - name: 'invoice-2026-09.pdf', mime: 'application/pdf', size: 1_240_000, remaining: null, preview: 'none', - }, - ]; - return { items, sizeOverride: {} }; -} - -export function makeSvgBlob(): Blob { - const svg = ` - - - -设计稿 · 演示图片`; - return new Blob([svg], { type: 'image/svg+xml' }); -} - -/** 用 canvas + MediaRecorder 临时生成一段几秒的演示视频;浏览器不支持则返回 null */ -export async function makeVideoBlob(): Promise { - try { - if (typeof MediaRecorder === 'undefined') return null; - const canvas = document.createElement('canvas'); - canvas.width = 480; - canvas.height = 270; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - const rec = new MediaRecorder(canvas.captureStream(24), { mimeType: 'video/webm' }); - const chunks: Blob[] = []; - rec.ondataavailable = (e) => chunks.push(e.data); - const done = new Promise((res) => { - rec.onstop = () => res(new Blob(chunks, { type: 'video/webm' })); - }); - rec.start(); - const t0 = performance.now(); - await new Promise((resolve) => { - const draw = () => { - const t = (performance.now() - t0) / 1000; - ctx.fillStyle = `hsl(${(t * 60) % 360} 10% ${30 + 10 * Math.sin(t * 2)}%)`; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#fff'; - ctx.font = '28px sans-serif'; - ctx.fillText(`演示视频 ${t.toFixed(1)}s`, 150, 140); - if (t < 3) requestAnimationFrame(draw); - else resolve(); - }; - draw(); - }); - rec.stop(); - return await done; - } catch { - return null; - } -} - -export function makeLogBlob(text: string): Blob { - return new Blob([text], { type: 'text/plain' }); -} diff --git a/web/src/core-bridge/mockShare.ts b/web/src/core-bridge/mockShare.ts deleted file mode 100644 index 784dda5..0000000 --- a/web/src/core-bridge/mockShare.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { previewKindOf } from '@/lib/fileKind'; -import type { ShareApi } from './shareApi'; -import { b64url } from './mockLinks'; -import { PHONE, archiveEntries, makeSvgBlob, seedItems } from './mockSeed'; -import type { FileItem, Item, Share, ShareEvent, ShareSettings } from './types'; - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const CREATOR = 'creator'; -export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd'; -const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF'; - -interface Runtime { - share: Share; - items: Map; - blobs: Map>; -} - -/** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */ -export function createMockShareApi(): ShareApi { - const runtimes = new Map(); - let current: Runtime | undefined; - let emit: (e: ShareEvent) => void = () => {}; - const timers = new Set(); - - const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`; - const put = (item: Item) => { - current?.items.set(item.id, item); - emit({ t: 'item', item }); - }; - const draftOnly = () => { - if (!current || current.share.state !== 'draft') throw new Error('forbidden'); - return current; - }; - - function seedDemo(): Runtime { - const share: Share = { - id: DEMO_SHARE_ID, - state: 'sealed', - mine: false, - link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`, - createdAt: Date.now() - 2 * 3600_000, - expiresAt: Date.now() + 22 * 3600_000, - ttlSeconds: 86400, - maxVisitors: 10, - maxDownloads: 5, - }; - const rt: Runtime = { share, items: new Map(), blobs: new Map() }; - const keep = new Set(['it-zip', 'it-image', 'it-json', 'it-short', 'it-pdf']); - for (const item of seedItems().items) { - if (!keep.has(item.id)) continue; - rt.items.set(item.id, item.kind === 'file' - ? { ...item, deviceId: CREATOR, versions: undefined, unseen: false, remaining: share.maxDownloads } - : { ...item, deviceId: CREATOR }); - } - rt.blobs.set('it-image', makeSvgBlob()); - rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' })); - rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' })); - runtimes.set(share.id, rt); - return rt; - } - - return { - async listSaved() { return []; }, - async forget() {}, - async createDraft(settings) { - await wait(300); - const id = b64url(16); - const share: Share = { - id, - state: 'draft', - mine: true, - link: `${location.origin}/s/${id}#k=${b64url(32)}`, - createdAt: Date.now(), - expiresAt: Date.now() + 86400_000, - ...settings, - }; - runtimes.set(id, { share, items: new Map(), blobs: new Map() }); - return share; - }, - - async updateSettings(id, patch: Partial) { - const rt = runtimes.get(id); - if (!rt || rt.share.state !== 'draft') throw new Error('forbidden'); - rt.share = { ...rt.share, ...patch }; - emit({ t: 'share', share: rt.share }); - return rt.share; - }, - - async seal(id) { - await wait(300); - const rt = runtimes.get(id); - if (!rt || rt.share.state !== 'draft') throw new Error('forbidden'); - // 丢弃所有者密钥:此后没有人能改,创建者也只是一个读者 - rt.share = { ...rt.share, state: 'sealed' }; - for (const item of rt.items.values()) { - if (item.kind === 'file') rt.items.set(item.id, { ...item, remaining: rt.share.maxDownloads }); - } - emit({ t: 'share', share: rt.share }); - for (const item of rt.items.values()) emit({ t: 'item', item }); - return rt.share; - }, - - async join(link) { - await wait(300); - const known = runtimes.get(link.id); - if (known) return known.share; - // 原型里不认识的链接:当作一个已封存、没有内容的分享 - const share: Share = { - id: link.id, - state: 'sealed', - mine: false, - link: `${location.origin}/s/${link.id}#k=${link.key}`, - createdAt: Date.now(), - expiresAt: Date.now() + 86400_000, - ttlSeconds: 86400, - maxVisitors: 10, - maxDownloads: null, - }; - runtimes.set(link.id, { share, items: new Map(), blobs: new Map() }); - return share; - }, - - loadDemo() { - return [(runtimes.get(DEMO_SHARE_ID) ?? seedDemo()).share]; - }, - - connect(shareId, onEvent) { - emit = onEvent; - current = runtimes.get(shareId); - const rt = current; - if (!rt) return () => {}; - const id = window.setTimeout(() => { - timers.delete(id); - emit({ t: 'share', share: rt.share }); - for (const item of rt.items.values()) emit({ t: 'item', item }); - emit({ t: 'ready' }); - }, 400); - timers.add(id); - return () => { - for (const t of timers) clearTimeout(t); - timers.clear(); - emit = () => {}; - }; - }, - - async addText(text) { - draftOnly(); - put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text }); - }, - - async editText(id, text) { - const rt = draftOnly(); - const item = rt.items.get(id); - if (!item || item.kind !== 'text') throw new Error('not_found'); - put({ ...item, text, ts: Date.now() }); - }, - - async addFiles(files) { - const rt = draftOnly(); - for (const file of files) { - const id = uid(); - const base: FileItem = { - id, kind: 'file', ts: Date.now(), deviceId: PHONE, state: { kind: 'receiving', progress: 0 }, - name: file.name, mime: file.type, size: file.size, remaining: null, - preview: previewKindOf(file.type, file.name), - }; - rt.blobs.set(id, file); - put(base); - for (let p = 0.25; p < 1; p += 0.25) { - await wait(200); - put({ ...base, state: { kind: 'receiving', progress: p } }); - } - await wait(200); - put({ ...base, state: { kind: 'complete' } }); - } - }, - - async removeItem(id) { - const rt = draftOnly(); - rt.items.delete(id); - rt.blobs.delete(id); - emit({ t: 'removed', id }); - }, - - async getBlob(id) { - const item = current?.items.get(id); - // 封存后每次拉取消耗一次下载(GATED,api.md);用完即 410 - if (item?.kind === 'file' && current?.share.state === 'sealed' && item.remaining !== null) { - if (item.remaining <= 0) throw new Error('gone'); - put({ ...item, remaining: item.remaining - 1 }); - } - const b = current?.blobs.get(id); - if (b) return b; - if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' }); - throw new Error('not found'); - }, - - async listArchive() { - await wait(250); - return archiveEntries; - }, - }; -} diff --git a/web/src/core-bridge/passkeys.ts b/web/src/core-bridge/passkeys.ts new file mode 100644 index 0000000..aba29d7 --- /dev/null +++ b/web/src/core-bridge/passkeys.ts @@ -0,0 +1,58 @@ +import { callWorker } from './workerClient'; +import type { AccountStatus } from './account'; +import type { PasskeyOptionsBody, PasskeyInfoBody } from './generated/api'; + +const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +const call = (operation: string, payload: object = {}) => callWorker(operation, { ...payload, apiBase }); +export const passkeysAvailable = () => typeof PublicKeyCredential !== 'undefined' && window.isSecureContext; +const decode = (value: string): Uint8Array => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')), c => c.charCodeAt(0)); +interface PrfResult { enabled?: boolean; results?: { first?: ArrayBuffer } } +const prfResult = (credential: PublicKeyCredential) => (credential.getClientExtensionResults() as AuthenticationExtensionsClientOutputs & { prf?: PrfResult }).prf; +function credentialJson(credential: PublicKeyCredential): string { + // W3C PRF: omit client extension outputs when sending credentials to the server. + return JSON.stringify({ ...credential.toJSON(), clientExtensionResults: {} }); +} +async function assertion(options: PasskeyOptionsBody, credentialId?: string): Promise { + const json = JSON.parse(options.options) as PublicKeyCredentialRequestOptionsJSON; + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json); + if (credentialId) publicKey.allowCredentials = [{ type: 'public-key', id: decode(credentialId) }]; + const evalByCredential = Object.fromEntries(options.credentials.filter(p => !credentialId || p.id === credentialId).map(p => [p.id, { first: decode(p.prf_salt) }])); + publicKey.extensions = { ...publicKey.extensions, prf: { evalByCredential } } as AuthenticationExtensionsClientInputs; + const credential = await navigator.credentials.get({ publicKey }); + if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey验证未完成'); + return credential; +} +export async function bindPasskey(password: string, name: string): Promise { + if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost'); + let prf: ArrayBuffer | undefined; + try { + const options = await call('beginPasskeyRegistration', { password }); + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(JSON.parse(options.options)); + publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: decode(options.prf_salt!) } } } as AuthenticationExtensionsClientInputs; + const credential = await navigator.credentials.create({ publicKey }); + if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey绑定未完成'); + const extension = prfResult(credential); prf = extension?.results?.first; + const registered = await call('finishPasskeyRegistration', { id: options.id, credentialId: credential.id, credential: credentialJson(credential), name, prf }); + if (!registered.can_recover && extension?.enabled) { + const request = await call('beginPasskeyRecovery'); + const proof = await assertion(request, registered.id); prf = prfResult(proof)?.results?.first; + if (prf) await call('finishPasskeyRecovery', { id: request.id, credentialId: proof.id, credential: credentialJson(proof), prf }); + } + } finally { + if (prf) new Uint8Array(prf).fill(0); + await call('cancelPasskey').catch(() => {}); + } +} +export async function loginWithPasskey(username: string, remember: boolean): Promise { + if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost'); + const options = await call('beginPasskeyLogin', { username }); + const proof = await assertion(options); const prf = prfResult(proof)?.results?.first; + try { return await call('finishPasskeyLogin', { username, id: options.id, credential: credentialJson(proof), prf, remember }); } + finally { if (prf) new Uint8Array(prf).fill(0); } +} +export const passkeyApi = { + list: () => call('listPasskeys'), + remove: (id: string) => call('removePasskey', { id }), + unlock: (password: string, remember: boolean) => call('unlockPasskeyLogin', { password, remember }), + cancel: () => call('cancelPasskey'), +}; diff --git a/web/src/core-bridge/protocol/account.ts b/web/src/core-bridge/protocol/account.ts new file mode 100644 index 0000000..726f7d9 --- /dev/null +++ b/web/src/core-bridge/protocol/account.ts @@ -0,0 +1,27 @@ +import type { AccountProfile } from '../generated/api'; +export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } +export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } +/** Account vault v2: credentials and cached metadata, encrypted before transport. */ +export type PersonalProfile = Pick & { avatar?: string }; +export interface ResourceDelete { id: string; modifiedAt: number } +export interface VaultResource { + domainKeys?: Record; + id: string; + kind: 'channel' | 'share'; + mode: 'enc' | 'raw'; + role: 'owner' | 'member'; + link: string; + ownerLink?: string; + modifiedAt?: number; + createdAt: number; + expiresAt: number; + creatorAccount?: string; + name?: string; + seats: number; + ttlSeconds?: number; + maxDownloads?: number | null; + maxVisitors?: number; + mine?: boolean; + state?: 'draft' | 'sealed'; +} +export interface VaultProfile { v: 3; keys: InvitationKeyPair & { signing: SigningKeyPair }; resources: VaultResource[]; deletes: ResourceDelete[] } diff --git a/web/src/core-bridge/protocol/workspace.ts b/web/src/core-bridge/protocol/workspace.ts new file mode 100644 index 0000000..f8818da --- /dev/null +++ b/web/src/core-bridge/protocol/workspace.ts @@ -0,0 +1,15 @@ +/** Encrypted resource metadata v1. Account and structure DTOs are generated from the backend. */ +export interface PublishedFile { + id: string; + kind: 'file'; + threadId: string; + folderId?: string; + name: string; + mime: string; + size: number; + ts: number; + deviceId: string; + gated: boolean; +} +export type ResourceControl = { op: 'file.published'; item: PublishedFile }; +export const WORKSPACE_APP = 'webdrop.workspace'; diff --git a/web/src/core-bridge/real.ts b/web/src/core-bridge/real.ts index 8bb6ff5..ac294fc 100644 --- a/web/src/core-bridge/real.ts +++ b/web/src/core-bridge/real.ts @@ -4,7 +4,7 @@ import type { ParsedShareLink } from '@/lib/shareLink'; import type { WorkspaceApi } from './api'; import type { ShareApi } from './shareApi'; import { callWorker, subscribeWorker } from './workerClient'; -import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; @@ -44,6 +44,7 @@ export const realApi: WorkspaceApi = { renameGroup: (groupId, name) => callWorker('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }), groupThread: (threadId, groupId) => callWorker('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }), renameThread: (threadId, name) => callWorker('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }), + setChannelHistory: (threadId, chatHistory, filesHistory) => callWorker('setChannelHistory', { id: currentChannelId(), threadId, chatHistory, filesHistory, apiBase: API_BASE }), createFolder: (threadId, name, parentId) => callWorker('createFolder', { id: currentChannelId(), threadId, name, parentId, apiBase: API_BASE }), renameFolder: (folderId, threadId, name) => callWorker('renameFolder', { id: currentChannelId(), folderId, threadId, name, apiBase: API_BASE }), moveItems: (itemIds, threadId, folderId) => callWorker('moveItems', { id: currentChannelId(), itemIds, threadId, folderId, apiBase: API_BASE }), @@ -96,5 +97,3 @@ function currentChannelId(): string { function currentShareId(): string { return activeShareId; } - -export function simulateConnection(_state: ConnectionState): void {} diff --git a/web/src/core-bridge/server.ts b/web/src/core-bridge/server.ts new file mode 100644 index 0000000..30c6f16 --- /dev/null +++ b/web/src/core-bridge/server.ts @@ -0,0 +1,5 @@ +import { callWorker } from './workerClient'; +import type { ServerInfoBody } from './generated/api'; + +const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +export const serverApi = { info: () => callWorker('serverInfo', { apiBase }) }; diff --git a/web/src/core-bridge/settings.ts b/web/src/core-bridge/settings.ts new file mode 100644 index 0000000..ed7391a --- /dev/null +++ b/web/src/core-bridge/settings.ts @@ -0,0 +1,8 @@ +import type { DefaultsBody } from './generated/api'; +import type { CreateOptions } from './types'; + +export function creationOptions(defaults: DefaultsBody): CreateOptions { + const settings = defaults.channel; + return { mode: defaults.channel_mode, seats: settings.seats, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload, + fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests }; +} diff --git a/web/src/core-bridge/shareApi.ts b/web/src/core-bridge/shareApi.ts index 001e364..e92589b 100644 --- a/web/src/core-bridge/shareApi.ts +++ b/web/src/core-bridge/shareApi.ts @@ -20,8 +20,6 @@ export interface ShareApi { join(link: ParsedShareLink): Promise; /** 仅从本机移除链接和终端密钥,不会删除已封存的远端分享 */ forget(id: string): Promise; - /** 仅假实现提供:载入演示用的分享 */ - loadDemo?(): Share[]; /** 订阅某个分享的事件流,返回取消订阅函数 */ connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void; diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index 2fb6766..0efddf7 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -1,3 +1,5 @@ +import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api'; + export type DeviceKind = 'desktop' | 'phone' | 'tablet'; export type ConnectionState = 'online' | 'reconnecting' | 'offline'; export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none'; @@ -14,6 +16,7 @@ export interface Device { /** 常驻空间:自己多台设备之间长期同步 */ export interface ChannelInfo { + dataSource?: 'server' | 'cache'; creatorAccount?: string; joinPolicy?: 'open' | 'invite'; allowGuests?: boolean; @@ -21,10 +24,10 @@ export interface ChannelInfo { id: string; /** 服务端 channel settings 中的共享名称,加入者可见。 */ name?: string; - mode: 'enc' | 'raw'; + mode: ChannelMode; role: 'owner' | 'member'; seats: number; - seatsUsed?: number; + membersUsed?: number; membersCanUpload?: boolean; ttlSeconds?: number; /** 每个新上传文件的默认下载次数上限。 */ @@ -35,6 +38,7 @@ export interface ChannelInfo { createdAt: number; expiresAt: number; canUpload?: boolean; + canManageStructure?: boolean; } /** 新建空间的选项(protocol §12 设置的子集) */ @@ -43,7 +47,7 @@ export interface CreateOptions { allowGuests?: boolean; joinPassword?: string; name?: string; - mode: 'enc' | 'raw'; + mode: ChannelMode; seats: number; ttlSeconds: number; membersCanUpload: boolean; @@ -72,6 +76,7 @@ export interface ChannelSettingsPatch { * 完成分享时丢弃,此后无人可改,只能等过期。始终是加密模式。 */ export interface Share { + dataSource?: 'server' | 'cache'; id: string; /** draft:创建者可编辑;sealed:已封存,只读 */ state: 'draft' | 'sealed'; @@ -112,7 +117,7 @@ interface ItemBase { folderId?: string; } -export interface SenderInfo { username: string; displayName: string; avatar?: string } +export type SenderInfo = Pick & { avatar?: string }; export interface TextItem extends ItemBase { kind: 'text'; @@ -165,11 +170,12 @@ export interface Snapshot { updatedAt: number; } -export interface ThreadInfo { id: string; name: string; createdAt: number; createdBy: string; groupId?: string } -export interface ChannelGroupInfo { id: string; name: string; createdAt: number } -export interface FolderInfo { id: string; threadId: string; parentId?: string; name: string } +export type ThreadInfo = Omit & { groupId?: string }; +export type ChannelGroupInfo = WorkspaceGroup; +export type FolderInfo = Omit & { parentId?: string }; export type WorkspaceEvent = + | { t: 'structure'; state: WorkspaceState; people: AccountProfile[] } | { t: 'reset' } | { t: 'error'; message: string } | { t: 'ready' } @@ -178,16 +184,7 @@ export type WorkspaceEvent = | { t: 'devices'; devices: Device[] } | { t: 'item'; item: Item } | { t: 'file-catalog'; item: FileItem } - | { t: 'item-author'; id: string; author: SenderInfo } | { t: 'removed'; id: string } - | { t: 'thread-created'; thread: ThreadInfo } - | { t: 'thread-renamed'; id: string; name: string } - | { t: 'group-created'; group: ChannelGroupInfo } - | { t: 'group-renamed'; id: string; name: string } - | { t: 'thread-grouped'; id: string; groupId?: string } - | { t: 'folder-created'; folder: FolderInfo } - | { t: 'folder-renamed'; id: string; name: string } - | { t: 'items-moved'; itemIds: string[]; threadId: string; folderId?: string } | { t: 'snapshot'; snapshot: Snapshot }; export type ShareEvent = diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts index 1607b6c..bc7e58a 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -1,4 +1,5 @@ -import { setApiBase } from './worker/http'; +import { setApiBase, getServerInfo } from './worker/http'; +import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys'; import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems'; import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources'; import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend'; @@ -11,7 +12,7 @@ import { setEventSink } from './worker/port'; import { serial } from './worker/serial'; import { errorMessage } from './errors'; import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; -import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread } from './worker/channelThreads'; +import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads'; import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; interface WorkerRequest { requestId: number; operation: string; payload: unknown } @@ -26,9 +27,21 @@ setEventSink((resourceId, event) => scope.postMessage({ resourceId, event })); async function dispatch(operation: string, input: unknown): Promise { const payload = input as Record; if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase); + if (operation === 'serverInfo') return getServerInfo(); await restoreAccount(); const id = typeof payload.id === 'string' ? payload.id : ''; switch (operation) { + case 'setChannelHistory': return setChannelHistory(id, String(payload.threadId ?? ''), payload.chatHistory === true, payload.filesHistory === true); + case 'listPasskeys': return listPasskeys(); + case 'removePasskey': return removePasskey(String(payload.id ?? '')); + case 'cancelPasskey': clearPasskeyState(); return accountStatus(); + case 'beginPasskeyRegistration': return beginPasskeyRegistration(String(payload.password ?? '')); + case 'finishPasskeyRegistration': return finishPasskeyRegistration(id, String(payload.credentialId ?? ''), String(payload.credential ?? ''), String(payload.name ?? ''), payload.prf as ArrayBuffer | undefined); + case 'beginPasskeyRecovery': return beginPasskeyRecovery(); + case 'finishPasskeyRecovery': return finishPasskeyRecovery(id, String(payload.credentialId ?? ''), String(payload.credential ?? ''), payload.prf as ArrayBuffer); + case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? '')); + case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false); + case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false); case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); return accountStatus(); case 'accountDevices': return listAccountDevices(); case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true; diff --git a/web/src/core-bridge/worker/account.ts b/web/src/core-bridge/worker/account.ts index 0fc6b0e..c7d7e31 100644 --- a/web/src/core-bridge/worker/account.ts +++ b/web/src/core-bridge/worker/account.ts @@ -2,24 +2,38 @@ import { importResources, importResourceDeletes, listAllResources, listResourceD import { apiFetch } from './http'; import { clearResourceSessions, getSession } from './sessions'; import { setAccountIdentity, requireAccount } from './accountSession'; -import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys } from './accountCrypto'; -import { mergeVault, validateVault, validatePersonal, type VaultProfile, type PersonalProfile } from './accountVault'; +import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto'; +import { mergeVault, validateVault, type VaultProfile } from './accountVault'; import { joinChannel } from './channelResources'; import { openChannelGrant, sealChannelGrant } from './channelGrant'; import { recoverAccountChannels } from './accountChannels'; import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence'; +import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api'; -interface VaultBody { revision: number; vault: string } +import { loadPersonal, personalProfile } from './accountProfile'; +import type { AccountProfile, AccountProfileRequest } from '../generated/api'; + +import type { PersonalProfile } from '../protocol/account'; +import { passkeyUnlockUsername, clearPasskeyState } from './passkeys'; +import { packageInvitationDomains } from './channelDataKeys'; +import { clearDeviceSigner, ensureDeviceSigning } from './accountSigning'; + +type VaultBody = AccountVaultBody; interface AccountSession { username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number; - remember: boolean; + deviceSigning?: CryptoKeyPair; + remember: boolean; personal?: PersonalProfile; controller: AbortController; dirty: boolean; syncing?: Promise; error?: string; timer?: ReturnType; polling?: ReturnType; } -export interface Invitation { channel_id: string; username: string; payload: string; created_at: number } +export type Invitation = InvitationBody; let account: AccountSession | undefined; +export function unlockedAccount() { + if (!account) throw new Error('请先登录账号'); + return account; +} let restoration: Promise | undefined; -function usernameOf(input: string): string { +export function usernameOf(input: string): string { const username = input.trim().toLowerCase(); if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线'); return username; @@ -33,7 +47,7 @@ async function applyProfile(profile: VaultProfile): Promise { await importResourceDeletes(profile.deletes); } async function persistSession(session: AccountSession, value: VaultBody): Promise { - if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, ...value }); + if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, personal: session.personal, ...value }); else await forgetAccount(); } async function syncVault(): Promise { @@ -41,6 +55,7 @@ async function syncVault(): Promise { const session = account; if (session.syncing) { session.dirty = true; return session.syncing; } session.syncing = (async () => { + session.personal = await loadPersonal(session.token); await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal); do { session.dirty = false; @@ -48,11 +63,14 @@ async function syncVault(): Promise { const remote = await apiFetch('/accounts/vault', { headers: headers(session), signal: session.controller.signal }).then((r) => r.json() as Promise); const profile = validateVault(await openJson(remote.vault, session.key)); if (profile.keys.publicKey.x !== session.profile.keys.publicKey.x || profile.keys.publicKey.y !== session.profile.keys.publicKey.y) throw new Error('账号邀请密钥发生冲突,已停止同步'); - const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes(), session.profile.personal); + const remoteSigning = profile.keys.signing.publicKey; const localSigning = session.profile.keys.signing.publicKey; + if (remoteSigning.x !== localSigning.x || remoteSigning.y !== localSigning.y) + throw new Error('账号签名密钥发生冲突,已停止同步'); + const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes()); if (JSON.stringify(merged) === JSON.stringify(profile)) { stillActive(session); await applyProfile(merged); session.profile = merged; session.revision = remote.revision; session.error = undefined; - setAccountIdentity(session, merged.keys.publicKey, merged.personal); + setAccountIdentity(session, merged.keys.publicKey, session.personal); await persistSession(session, remote); break; } @@ -64,7 +82,7 @@ async function syncVault(): Promise { stillActive(session); await applyProfile(merged); session.profile = merged; session.revision = result.revision; session.error = undefined; - setAccountIdentity(session, merged.keys.publicKey, merged.personal); + setAccountIdentity(session, merged.keys.publicKey, session.personal); await persistSession(session, { vault, revision: result.revision }); break; } catch (error) { @@ -75,6 +93,7 @@ async function syncVault(): Promise { })().catch((error: unknown) => { if (account === session) session.error = error instanceof Error ? error.message : '同步失败'; if (account === session && error instanceof Error && 'status' in error && error.status === 401) { + clearDeviceSigner(); account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); void forgetAccount(); @@ -85,11 +104,17 @@ async function syncVault(): Promise { return session.syncing; } -async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true): Promise { +async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true, cachedPersonal?: PersonalProfile): Promise { const profile = validateVault(await openJson(remote.vault, key)); + const deviceSigning = await ensureDeviceSigning(identity.username, identity.token, profile.keys.signing); clearResourceSessions(); setResourceScope(identity.username); - const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false }; - account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal); + const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning }; + try { session.personal = await loadPersonal(identity.token); } + catch (error) { + if (!(error instanceof Error && error.message === '网络连接失败')) throw error; + session.personal = cachedPersonal; + } + account = session; setAccountIdentity(identity, profile.keys.publicKey, session.personal); await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes())); setResourceChangeListener(scheduleSync); session.polling = setInterval(scheduleSync, 20_000); @@ -108,10 +133,8 @@ export function restoreAccount(): Promise { remote = saved; } try { - const session = await activate(saved, saved.key, remote); - const local = validateVault(await openJson(saved.vault, saved.key)); - if (local.personal && local.personal.modifiedAt > (session.profile.personal?.modifiedAt ?? -1)) session.profile = { ...session.profile, personal: local.personal }; - setAccountIdentity(session, session.profile.keys.publicKey, session.profile.personal); + const session = await activate(saved, saved.key, remote, true, saved.personal); + setAccountIdentity(session, session.profile.keys.publicKey, session.personal); await syncVault().catch(() => {}); } catch (error) { await logoutAccount(); throw error; } })().catch((error: unknown) => { restoration = undefined; throw error; }); @@ -129,10 +152,12 @@ export async function registerAccount(input: string, password: string, remember if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符'); const salt = crypto.getRandomValues(new Uint8Array(16)); const { verifier, key } = await deriveAccountKeys(password, salt); - const profile: VaultProfile = { v: 2, keys: await generateInvitationKeys(), resources: [], deletes: [] }; + const invitationKeys = await generateInvitationKeys(); const signing = await generateAccountSigningKeys(); + const profile: VaultProfile = { v: 3, keys: { ...invitationKeys, signing }, resources: [], deletes: [] }; try { await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), vault: await sealJson(profile, key) }) }); + body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), + signing_public_key: JSON.stringify(signing.publicKey), vault: await sealJson(profile, key) } satisfies AccountRegisterRequest) }); } finally { verifier.fill(0); } return loginAccount(username, password, remember); } @@ -140,22 +165,31 @@ export async function loginAccount(input: string, password: string, remember = t if (account) throw new Error('请先退出当前账号'); const username = usernameOf(input); const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) - .then((r) => r.json() as Promise<{ salt: string }>); + .then((r) => r.json() as Promise); const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt)); - let identity: { username: string; token: string }; + let identity: AccountSessionBody; try { const device = await localAccountDevice(); - identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name }) }) - .then((r) => r.json() as Promise<{ username: string; token: string }>); + identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name } satisfies AccountSessionRequest) }) + .then((r) => r.json() as Promise); } finally { verifier.fill(0); } + const status = await unlockAuthenticatedAccount(identity, key, remember); + if (remember) await saveLoginFields(username, password); else await forgetLoginFields(); + return status; +} +export async function unlockAuthenticatedAccount(identity: AccountSessionBody, key: CryptoKey, remember = true): Promise>> { + if (account) throw new Error('请先退出当前账号'); try { - const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then((r) => r.json() as Promise); + const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then(r => r.json() as Promise); const profile = validateVault(await openJson(remote.vault, key)); - const publicKey = await apiFetch(`/accounts/users/${username}/key`, { headers: headers(identity) }).then((r) => r.json() as Promise<{ public_key: string }>); + const publicKey = await apiFetch('/accounts/users/' + identity.username + '/key', { headers: headers(identity) }).then(r => r.json() as Promise); const published = JSON.parse(publicKey.public_key) as JsonWebKey; if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配'); + if (!publicKey.signing_public_key) throw new Error('账号签名公钥不存在。'); + const publishedSigning = JSON.parse(publicKey.signing_public_key) as JsonWebKey; + if (publishedSigning.x !== profile.keys.signing.publicKey.x || publishedSigning.y !== profile.keys.signing.publicKey.y) + throw new Error('账号签名公钥与保险库不匹配'); const session = await activate(identity, key, remote, remember); - if (remember) await saveLoginFields(username, password); else await forgetLoginFields(); await persistSession(session, remote); await syncVault().catch(() => {}); return accountStatus(); @@ -165,13 +199,15 @@ export async function loginAccount(input: string, password: string, remember = t throw error; } } -export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string }> { +export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> { return { username: account?.username ?? null, revision: account?.revision ?? 0, - remembered: account?.remember ?? false, - ...(account?.profile.personal ? { personal: account.profile.personal } : {}), + remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(), + ...(account?.personal ? { personal: account.personal } : {}), ...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) }; } export async function logoutAccount(): Promise { + clearDeviceSigner(); + clearPasskeyState(); const session = account; account = undefined; if (session) { clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); await session.syncing?.catch(() => {}); } setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); @@ -192,13 +228,15 @@ export async function clearSavedLogin(): Promise { } export async function updatePersonal(displayName: string, avatar?: string): Promise { if (!account) throw new Error('请先登录账号'); - account.profile = { ...account.profile, personal: validatePersonal({ displayName, ...(avatar ? { avatar } : {}), modifiedAt: Date.now() }) }; - setAccountIdentity(account, account.profile.keys.publicKey, account.profile.personal); - // Retain an offline profile edit for the next session restoration. - await persistSession(account, { vault: await sealJson(account.profile, account.key), revision: account.revision }); - await syncVault(); + const session = account; + const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise); + stillActive(session); + session.personal = personalProfile(value); + setAccountIdentity(session, session.profile.keys.publicKey, session.personal); + await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision }); } -export async function listAccountDevices(): Promise { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); } +export async function listAccountDevices(): Promise { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise); } export async function renameAccountDevice(id: string, name: string): Promise { requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); if ((await localAccountDevice()).id === id) await saveDeviceName(name.trim()); @@ -221,9 +259,12 @@ export async function acceptInvitation(channelId: string): Promise { export async function inviteChannelUser(channelId: string, input: string): Promise { requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId); if (!session.can('owner')) throw new Error('只有空间主可以邀请用户'); - const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<{ public_key: string }>); + const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') }) + .then(r => r.json() as Promise); + const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise); const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey); - await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload }) }); + const domainEnvelopes = await packageInvitationDomains(resource, session, plan); + await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) }); } export async function listChannelInvitations(channelId: string): Promise { const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise); diff --git a/web/src/core-bridge/worker/accountChannels.ts b/web/src/core-bridge/worker/accountChannels.ts index b3f729f..8be6e2d 100644 --- a/web/src/core-bridge/worker/accountChannels.ts +++ b/web/src/core-bridge/worker/accountChannels.ts @@ -1,18 +1,18 @@ import { ChannelSession } from '@webdrop/core'; -import { getResource, importResources, listAllResources, listResourceDeletes, type SavedResource } from '../database'; -import { openChannelGrant, sealChannelGrant } from './channelGrant'; +import { getResource, importResources, listResourceDeletes, type SavedResource } from '../database'; +import { openChannelGrant } from './channelGrant'; import { makeLink } from './links'; import { apiFetch, readJson } from './http'; import type { InvitationKeyPair } from './accountCrypto'; import type { ChannelWire } from './wire'; - -interface AccountChannel { channel_id: string; role: 'owner' | 'member'; payload: string; created_at: number } +import type { AccountChannelBody } from '../generated/api'; +import { channelCache } from './resourceMetadata'; export async function recoverAccountChannels(username: string, keys: InvitationKeyPair, signal: AbortSignal): Promise { - const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise); + const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise); const deletes = new Map((await listResourceDeletes()).map((row) => [row.id, row.modifiedAt])); - const known = new Set(grants.map((row) => row.channel_id)); for (const grant of grants) { + if (grant.role !== 'owner' && grant.role !== 'member') throw new Error('空间成员权限无效'); if (signal.aborted) throw new DOMException('账号已切换', 'AbortError'); if ((deletes.get(grant.channel_id) ?? -1) >= grant.created_at || await getResource(grant.channel_id)) continue; const parsed = await openChannelGrant(grant.payload, username, grant.channel_id, keys.privateKey, grant.role, self.location.origin); @@ -21,25 +21,13 @@ export async function recoverAccountChannels(username: string, keys: InvitationK const info = await readJson(`/channels/${session.id}`, session.authHeaders('read')); session.verifyChannelInfo(info); if (grant.role === 'owner' && info.creator_account !== username) throw new Error('空间所属账号不匹配'); - const resource: SavedResource = { id: session.id, kind: 'channel', mode: session.mode, role: grant.role, - creatorAccount: info.creator_account, name: info.settings.name, + const resource: SavedResource = { ...channelCache(info), id: session.id, kind: 'channel', mode: session.mode, role: grant.role, + creatorAccount: info.creator_account ?? undefined, name: info.settings.name ?? undefined, link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}), endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at, - seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, maxDownloads: info.settings.file_max_downloads }; + seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads }; if (signal.aborted) throw new DOMException('账号已切换', 'AbortError'); await importResources([resource]); } finally { session.dispose(); } } - // Populate owner grants for channels created before account-bound delivery existed. - for (const resource of await listAllResources()) { - if (resource.kind !== 'channel' || resource.role !== 'owner' || !resource.ownerLink || known.has(resource.id)) continue; - const session = await ChannelSession.open(resource.ownerLink, { endpointSecret: resource.endpointSecret }); - try { - const payload = await sealChannelGrant(resource.ownerLink, username, keys.publicKey); - await apiFetch(`/channels/${resource.id}/key-package`, { method: 'PUT', signal, - headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ payload }) }); - } catch (error) { - if (!(error instanceof Error && 'status' in error && error.status === 404)) throw error; - } finally { session.dispose(); } - } } diff --git a/web/src/core-bridge/worker/accountCrypto.ts b/web/src/core-bridge/worker/accountCrypto.ts index 08ffacd..2786578 100644 --- a/web/src/core-bridge/worker/accountCrypto.ts +++ b/web/src/core-bridge/worker/accountCrypto.ts @@ -8,17 +8,19 @@ export function b64(value: Uint8Array): string { return btoa(text); } export function unb64(value: string): Uint8Array { return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); } -export async function deriveAccountKeys(password: string, salt: Uint8Array): Promise<{ verifier: Uint8Array; key: CryptoKey }> { +export async function deriveAccountKeys(password: string, salt: Uint8Array, exportMaterial = false): Promise<{ verifier: Uint8Array; key: CryptoKey; material?: Uint8Array }> { if (salt.length !== 16 || password.length > 1024) throw new Error('账号凭据格式无效'); const base = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']); const root = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: bytes(salt), iterations: 600_000 }, base, 256)); try { const hmac = await crypto.subtle.importKey('raw', root, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const verifier = new Uint8Array(await crypto.subtle.sign('HMAC', hmac, encoder.encode('webdrop-account-login-v1'))); - const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveKey']); - const key = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf, - { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - return { verifier, key }; + const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveBits']); + const material = new Uint8Array(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf, 256)); + try { + const key = await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); + return { verifier, key, ...(exportMaterial ? { material: Uint8Array.from(material) } : {}) }; + } finally { material.fill(0); } } finally { root.fill(0); } } export async function sealJson(value: unknown, key: CryptoKey, context = vaultLabel): Promise { @@ -36,11 +38,18 @@ export async function openJson(value: string, key: CryptoKey, context = vaultLab return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plain)); } catch { throw new Error('加密数据校验失败,已拒绝导入'); } } -export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } +export type { InvitationKeyPair } from '../protocol/account'; +import type { InvitationKeyPair } from '../protocol/account'; +import type { SigningKeyPair } from '../protocol/account'; +import { generateSigningKeyPair } from '@webdrop/core'; export async function generateInvitationKeys(): Promise { const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey) }; } +export async function generateAccountSigningKeys(): Promise { + const pair = await generateSigningKeyPair(true); + return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey) }; +} async function invitationKey(privateKey: JsonWebKey, publicKey: JsonWebKey, context: string): Promise { const own = await crypto.subtle.importKey('jwk', privateKey, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); const peer = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []); diff --git a/web/src/core-bridge/worker/accountPersistence.ts b/web/src/core-bridge/worker/accountPersistence.ts index b0811ae..a10f4b2 100644 --- a/web/src/core-bridge/worker/accountPersistence.ts +++ b/web/src/core-bridge/worker/accountPersistence.ts @@ -1,8 +1,10 @@ import { openDB, type DBSchema } from 'idb'; import { apiPath } from './http'; +import type { PersonalProfile } from '../protocol/account'; import { decryptLogin, encryptLogin, type EncryptedLogin, type LoginFields } from './credentialCipher'; export interface RememberedAccount { + personal?: PersonalProfile; username: string; token: string; key: CryptoKey; @@ -13,10 +15,13 @@ interface LocalIdentity extends DBSchema { credentials: { key: string; value: EncryptedLogin }; sessions: { key: string; value: RememberedAccount }; device: { key: string; value: { id: string; name: string } }; + deviceSigning: { key: string; value: CryptoKeyPair }; + trustedSigningRoots: { key: string; value: JsonWebKey }; } -const database = () => openDB('webdrop-account-local-2', 2, { upgrade(db, oldVersion) { +const database = () => openDB('webdrop-account-local-2', 3, { upgrade(db, oldVersion) { if (oldVersion < 1) { db.createObjectStore('sessions'); db.createObjectStore('device'); } if (oldVersion < 2) db.createObjectStore('credentials'); + if (oldVersion < 3) { db.createObjectStore('deviceSigning'); db.createObjectStore('trustedSigningRoots'); } } }); const endpoint = () => apiPath('/'); export async function rememberedAccount(): Promise { return (await database()).get('sessions', endpoint()); } @@ -43,3 +48,18 @@ export async function localAccountDevice(): Promise<{ id: string; name: string } export async function saveDeviceName(name: string): Promise { const device = await localAccountDevice(); await (await database()).put('device', { ...device, name }, endpoint()); } +export async function localDeviceSigningKeys(username: string, deviceId: string): Promise { + return (await database()).get('deviceSigning', `${endpoint()}:${username}:${deviceId}`); +} +export async function saveLocalDeviceSigningKeys(username: string, deviceId: string, keys: CryptoKeyPair): Promise { + await (await database()).put('deviceSigning', keys, `${endpoint()}:${username}:${deviceId}`); +} +export async function pinSigningRoot(username: string, key: JsonWebKey): Promise { + const databaseRef = await database(); const tx = databaseRef.transaction('trustedSigningRoots', 'readwrite'); + const dbKey = `${endpoint()}:${username}`; const existing = await tx.store.get(dbKey); + if (existing && (existing.kty !== key.kty || existing.crv !== key.crv || existing.x !== key.x || existing.y !== key.y)) { + tx.abort(); throw new Error('账号签名公钥已变化,已阻止静默替换;请通过可信渠道核对指纹。'); + } + if (!existing) await tx.store.put(key, dbKey); + await tx.done; +} diff --git a/web/src/core-bridge/worker/accountProfile.ts b/web/src/core-bridge/worker/accountProfile.ts new file mode 100644 index 0000000..d4107b8 --- /dev/null +++ b/web/src/core-bridge/worker/accountProfile.ts @@ -0,0 +1,12 @@ +import { apiFetch } from './http'; +import type { AccountProfile } from '../generated/api'; +import type { PersonalProfile } from '../protocol/account'; + +export function personalProfile(value: AccountProfile): PersonalProfile { + return { displayName: value.displayName, avatar: value.avatar ?? undefined, modifiedAt: value.modifiedAt }; +} +export async function loadPersonal(token: string): Promise { + const headers = { 'X-WD-Account': token }; + const profile = await apiFetch('/accounts/profile', { headers }).then(r => r.json() as Promise); + return personalProfile(profile); +} diff --git a/web/src/core-bridge/worker/accountSession.ts b/web/src/core-bridge/worker/accountSession.ts index bd8bab0..c2f1ef0 100644 --- a/web/src/core-bridge/worker/accountSession.ts +++ b/web/src/core-bridge/worker/accountSession.ts @@ -1,4 +1,4 @@ -import type { PersonalProfile } from './accountVault'; +import type { PersonalProfile } from '../protocol/account'; let identity: { username: string; token: string } | undefined; let publicKey: JsonWebKey | undefined; let personal: PersonalProfile | undefined; diff --git a/web/src/core-bridge/worker/accountSigning.ts b/web/src/core-bridge/worker/accountSigning.ts new file mode 100644 index 0000000..64ba963 --- /dev/null +++ b/web/src/core-bridge/worker/accountSigning.ts @@ -0,0 +1,98 @@ +import { contentEventPayload, frameManifestHash, fromBase64Url, generateSigningKeyPair, signStatement, toBase64Url, verifyStatement } from '@webdrop/core'; +import type { AccountDeviceSigningRequest } from '../generated/api'; +import { b64 } from './accountCrypto'; +import { apiFetch } from './http'; +import { localAccountDevice, localDeviceSigningKeys, pinSigningRoot, saveLocalDeviceSigningKeys } from './accountPersistence'; +import type { SigningKeyPair } from '../protocol/account'; + +interface ActiveSigner { username: string; deviceId: string; pair: CryptoKeyPair } +let activeSigner: ActiveSigner | undefined; + +function certificatePayload(username: string, deviceId: string, key: JsonWebKey): Uint8Array { + const name = new TextEncoder().encode(username.toLowerCase()); + const device = new TextEncoder().encode(deviceId.toLowerCase()); + if (name.length < 3 || name.length > 32 || device.length !== 36 || !/^[a-z0-9_-]+$/.test(username.toLowerCase()) + || !/^[0-9a-f-]{36}$/.test(deviceId.toLowerCase()) || !key.x || !key.y) + throw new Error('设备签名证书字段无效'); + const x = fromBase64Url(key.x); const y = fromBase64Url(key.y); + if (!x || !y || x.length !== 32 || y.length !== 32) throw new Error('设备签名公钥格式无效'); + const payload = new Uint8Array(1 + name.length + 1 + device.length + 64); + let offset = 0; + payload[offset++] = name.length; payload.set(name, offset); offset += name.length; + payload[offset++] = device.length; payload.set(device, offset); offset += device.length; + payload.set(x, offset); payload.set(y, offset + 32); + return payload; +} + +export async function ensureDeviceSigning( + username: string, accountToken: string, root: SigningKeyPair, +): Promise { + await pinSigningRoot(username, root.publicKey); + const device = await localAccountDevice(); + let pair = await localDeviceSigningKeys(username, device.id); + if (!pair) { + pair = await generateSigningKeyPair(); + await saveLocalDeviceSigningKeys(username, device.id, pair); + } + const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey); + const rootPrivateKey = await crypto.subtle.importKey('jwk', root.privateKey, + { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); + const signature = await signStatement(rootPrivateKey, 'device.certificate', certificatePayload(username, device.id, publicKey)); + const request: AccountDeviceSigningRequest = { + device_id: device.id, + public_key: JSON.stringify(publicKey), + signature: b64(signature), + }; + await apiFetch('/accounts/devices/signing-key', { method: 'PUT', + headers: { 'X-WD-Account': accountToken, 'Content-Type': 'application/json' }, body: JSON.stringify(request) }); + activeSigner = { username: username.toLowerCase(), deviceId: device.id.toLowerCase(), pair }; + return pair; +} + +export function clearDeviceSigner(): void { activeSigner = undefined; } + +export async function signContentEvent(input: { + spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string; + eventKind: string; targetItemId?: string; frameDigests: readonly Uint8Array[]; +}): Promise> { + return signContentEventMetadata({ ...input, frameCount: input.frameDigests.length, manifestHash: await frameManifestHash(input.frameDigests) }); +} + +export async function signExistingContentEvent(input: { + spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string; + eventKind: string; frameCount: number; manifestHash: Uint8Array; targetItemId?: string; +}): Promise> { + return signContentEventMetadata(input); +} + +async function signContentEventMetadata(input: { + spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string; + eventKind: string; frameCount: number; manifestHash: Uint8Array; targetItemId?: string; +}): Promise> { + const signer = activeSigner; + if (!signer) throw new Error('当前账号设备没有已注册的签名密钥'); + const spaceId = fromBase64Url(input.spaceId, 16); const itemId = fromBase64Url(input.itemId, 16); + const targetItemId = input.targetItemId ? fromBase64Url(input.targetItemId, 16) : undefined; + if (!spaceId || !itemId || (input.targetItemId && !targetItemId)) throw new Error('签名事件资源 ID 无效'); + const eventId = crypto.getRandomValues(new Uint8Array(16)); + const payload = contentEventPayload({ spaceId, channelId: input.channelId, username: signer.username, deviceId: signer.deviceId, + eventId, eventKind: input.eventKind, domain: input.domain, epoch: input.epoch, itemId, + frameCount: input.frameCount, manifestHash: input.manifestHash, ...(targetItemId ? { targetItemId } : {}) }); + const signature = await signStatement(signer.pair.privateKey, 'content.event', payload); + return { + 'X-WD-Event-Id': toBase64Url(eventId), 'X-WD-Event-Kind': input.eventKind, + 'X-WD-Event-Channel': input.channelId, 'X-WD-Event-Domain': input.domain, + 'X-WD-Event-Epoch': String(input.epoch), 'X-WD-Event-Frames': String(input.frameCount), + 'X-WD-Event-Manifest': toBase64Url(input.manifestHash), 'X-WD-Event-Signature': toBase64Url(signature), + ...(input.targetItemId ? { 'X-WD-Event-Target': input.targetItemId } : {}), + }; +} + +export async function verifyDeviceCertificate( + username: string, deviceId: string, publicKey: JsonWebKey, signature: Uint8Array, root: CryptoKey, +): Promise { + try { + const payload = certificatePayload(username, deviceId, publicKey); + return verifyStatement(root, 'device.certificate', payload, signature); + } catch { return false; } +} diff --git a/web/src/core-bridge/worker/accountVault.test.ts b/web/src/core-bridge/worker/accountVault.test.ts index b6c90fd..7129282 100644 --- a/web/src/core-bridge/worker/accountVault.test.ts +++ b/web/src/core-bridge/worker/accountVault.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { exportResource, mergeVault, validatePersonal, type VaultProfile } from './accountVault'; +import { exportResource, mergeVault, type VaultProfile } from './accountVault'; import type { SavedResource } from '../database'; const resource = (modifiedAt = 10): SavedResource => ({ id: 'AAAAAAAAAAAAAAAAAAAAAA', kind: 'channel', mode: 'enc', role: 'owner', link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), deviceLabel: 'device-a', localScope: 'account:alice', - createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, membersCanUpload: true, name: `version-${modifiedAt}` }); -const profile = (): VaultProfile => ({ v: 2, keys: { publicKey: {}, privateKey: {} }, resources: [exportResource(resource(20))], deletes: [] }); + createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, name: `version-${modifiedAt}` }); +const profile = (): VaultProfile => ({ v: 3, keys: { publicKey: {}, privateKey: {}, signing: { publicKey: {}, privateKey: {} } }, resources: [exportResource(resource(20))], deletes: [] }); describe('account vault portability and merge', () => { it('never synchronizes device identity or local scope', () => { const exported = exportResource(resource()); @@ -24,12 +24,5 @@ describe('account vault portability and merge', () => { const merged = mergeVault(remote, [resource(40)], []); expect(merged.resources).toHaveLength(1); expect(merged.deletes).toEqual([]); }); - it('keeps the newest personal profile across stale devices, including avatar removal', () => { - const remote = { ...profile(), personal: { displayName: '最新名称', avatar: 'data:image/png;base64,AA==', modifiedAt: 20 } }; - expect(mergeVault(remote, [], [], { displayName: '旧名称', modifiedAt: 10 }).personal).toEqual(remote.personal); - expect(mergeVault(remote, [], [], { displayName: '新名称', modifiedAt: 30 }).personal).not.toHaveProperty('avatar'); - }); - it('rejects remote executable and external avatars', () => { - for (const avatar of ['https://tracker.test/avatar.png', 'data:image/svg+xml;base64,AA==']) expect(() => validatePersonal({ displayName: 'Alice', avatar, modifiedAt: 1 })).toThrow(); - }); + }); diff --git a/web/src/core-bridge/worker/accountVault.ts b/web/src/core-bridge/worker/accountVault.ts index 46dc3fe..6a20b53 100644 --- a/web/src/core-bridge/worker/accountVault.ts +++ b/web/src/core-bridge/worker/accountVault.ts @@ -1,37 +1,36 @@ import type { SavedResource } from '../database'; -import type { InvitationKeyPair } from './accountCrypto'; -export interface ResourceDelete { id: string; modifiedAt: number } -export type VaultResource = Omit; -export interface PersonalProfile { displayName: string; avatar?: string; modifiedAt: number } -export interface VaultProfile { v: 2; keys: InvitationKeyPair; resources: VaultResource[]; deletes: ResourceDelete[]; personal?: PersonalProfile } +import type { ResourceDelete, VaultResource, VaultProfile } from '../protocol/account'; +export type { ResourceDelete, VaultResource, VaultProfile }; export function exportResource(resource: SavedResource): VaultResource { - const entry = { ...resource }; - delete entry.localScope; delete entry.localItems; delete entry.deviceLabel; delete entry.needsJoin; delete entry.pendingManifestId; - const { endpointSecret: _endpointSecret, ...portable } = entry; - return portable; + // Explicit protocol projection prevents browser draft/UI fields leaking into the vault. + return { id: resource.id, kind: resource.kind, mode: resource.mode, role: resource.role, + domainKeys: resource.domainKeys, + link: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt, + modifiedAt: resource.modifiedAt, creatorAccount: resource.creatorAccount, name: resource.name, + seats: resource.seats, ttlSeconds: resource.ttlSeconds, + maxDownloads: resource.maxDownloads, maxVisitors: resource.maxVisitors, mine: resource.mine, state: resource.state }; } export function validateVault(value: unknown): VaultProfile { if (!value || typeof value !== 'object') throw new Error('账号保险库格式无效'); const profile = value as VaultProfile; - if (profile.v !== 2 || !profile.keys?.privateKey?.d || profile.keys.privateKey.crv !== 'P-256' + if (profile.v !== 3) throw new Error('旧账号保险库版本不兼容 API 6,且不会自动迁移。'); + if (!profile.keys?.privateKey?.d || profile.keys.privateKey.crv !== 'P-256' || !profile.keys.publicKey?.x || profile.keys.publicKey.d || !Array.isArray(profile.resources) || !Array.isArray(profile.deletes)) throw new Error('账号保险库格式无效'); + if (!profile.keys.signing || profile.keys.signing.privateKey?.crv !== 'P-256' + || !profile.keys.signing.privateKey.d || profile.keys.signing.privateKey.kty !== 'EC' + || profile.keys.signing.publicKey?.crv !== 'P-256' || !profile.keys.signing.publicKey.x + || !profile.keys.signing.publicKey.y || profile.keys.signing.publicKey.d !== undefined) + throw new Error('账号签名密钥格式无效'); for (const row of profile.resources) { if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !['channel', 'share'].includes(row.kind) || typeof row.link !== 'string' || !['owner', 'member'].includes(row.role) || !Number.isFinite(row.createdAt) || !Number.isFinite(row.expiresAt) || (row.modifiedAt !== undefined && !Number.isFinite(row.modifiedAt))) throw new Error('账号资源清单格式无效'); } for (const row of profile.deletes) if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !Number.isFinite(row.modifiedAt)) throw new Error('账号资源清单格式无效'); - if (profile.personal) validatePersonal(profile.personal); - return profile; + return { v: profile.v, keys: profile.keys, resources: profile.resources, deletes: profile.deletes }; } -export function validatePersonal(value: PersonalProfile): PersonalProfile { - if (typeof value.displayName !== 'string' || value.displayName.trim().length > 48 || /[\u0000-\u001f]/.test(value.displayName) - || !Number.isFinite(value.modifiedAt) || (value.avatar !== undefined && (typeof value.avatar !== 'string' || value.avatar.length > 350_000 - || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(value.avatar)))) throw new Error('个人资料格式无效'); - return { ...value, displayName: value.displayName.trim() }; -} -export function mergeVault(remote: VaultProfile, resources: SavedResource[], deletes: ResourceDelete[], personal?: PersonalProfile): VaultProfile { +export function mergeVault(remote: VaultProfile, resources: SavedResource[], deletes: ResourceDelete[]): VaultProfile { const byId = new Map(remote.resources.map((entry) => [entry.id, entry])); const removed = new Map(remote.deletes.map((entry) => [entry.id, entry])); for (const row of resources) { @@ -43,6 +42,5 @@ export function mergeVault(remote: VaultProfile, resources: SavedResource[], del if ((removed.get(id)?.modifiedAt ?? -1) >= (row.modifiedAt ?? row.createdAt)) byId.delete(id); else removed.delete(id); } - const newest = personal && personal.modifiedAt > (remote.personal?.modifiedAt ?? -1) ? personal : remote.personal; - return { ...remote, ...(newest ? { personal: newest } : {}), resources: [...byId.values()], deletes: [...removed.values()] }; + return { v: remote.v, keys: remote.keys, resources: [...byId.values()], deletes: [...removed.values()] }; } diff --git a/web/src/core-bridge/worker/channelDataKeys.ts b/web/src/core-bridge/worker/channelDataKeys.ts new file mode 100644 index 0000000..11d65cb --- /dev/null +++ b/web/src/core-bridge/worker/channelDataKeys.ts @@ -0,0 +1,216 @@ +import { contentEventPayload, fromBase64Url, importSigningPublicKey, toBase64Url, verifyStatement, type ChannelSession } from '@webdrop/core'; +import type { CryptoState, DomainEnvelope, InvitationCryptoPlan, ObjectEnvelope, PublicKeyBody } from '../generated/api'; +import { getResource, saveResource, type SavedResource } from '../database'; +import { apiFetch } from './http'; +import { accountPublicKey, accountToken, requireAccount } from './accountSession'; +import { decryptInvitation, encryptInvitation } from './accountCrypto'; +import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto'; +import { loadWorkspace } from './workspaceStructure'; +import { pinSigningRoot } from './accountPersistence'; +import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning'; + +const objectKeys = new Map(); +const states = new Map(); +const domainId = (channelId: string, domain: string, epoch: number) => `${channelId}:${domain}:${epoch}`; +const contextId = (spaceId: string, channelId: string, domain: string, epoch: number) => `${spaceId}:${channelId}:${domain}:${epoch}`; +export function clearDataKeys(): void { objectKeys.clear(); states.clear(); } +export function attachDataKeys(session: ChannelSession, resource: SavedResource): void { + if (resource.kind !== 'channel') return; + session.objectKeyResolver = async itemId => { + const cached = objectKeys.get(`${resource.id}:${itemId}`); if (cached) return cached; + const { keys } = await readDataKeys(resource, session); + const object = states.get(resource.id)?.objects.find(o => o.itemId === itemId); + if (!object || object.domain !== 'chat' && object.domain !== 'files') throw new Error('该对象没有已授权的解密数据域'); + const key = keys[domainId(object.channelId, object.domain, object.epoch)]; + if (!key) throw new Error('未获得该频道数据域的访问授权'); + const unwrapped = await unwrapObjectKey(key, object.keyEnvelope, { spaceId: resource.id, channelId: object.channelId, domain: object.domain, epoch: object.epoch, itemId }); + objectKeys.set(`${resource.id}:${itemId}`, unwrapped); return unwrapped; + }; +} +export async function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<{ state: CryptoState; keys: Record }> { + const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise); + states.set(resource.id, state); + const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys }; + for (const envelope of state.envelopes) { + const { unlockedAccount } = await import('./account'); const account = unlockedAccount(); + const id = domainId(envelope.channelId, envelope.domain, envelope.epoch); + if (keys[id]) continue; + const plain = await decryptInvitation(envelope.payload, account.username, contextId(resource.id, envelope.channelId, envelope.domain, envelope.epoch), account.profile.keys.privateKey); + const value = JSON.parse(plain) as { channelId: string; domain: string; epoch: number; key: string }; + if (value.channelId !== envelope.channelId || value.domain !== envelope.domain || value.epoch !== envelope.epoch || typeof value.key !== 'string') throw new Error('数据域密钥包与授权范围不匹配'); + keys[id] = value.key; + } + if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys }); + return { state, keys }; +} +async function envelope(spaceId: string, channelId: string, domain: 'chat' | 'files', epoch: number, key: string, username: string): Promise { + const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise)).public_key) as JsonWebKey; + return { channelId, domain, epoch, username, payload: await encryptInvitation(JSON.stringify({ channelId, domain, epoch, key }), username, contextId(spaceId, channelId, domain, epoch), publicKey) }; +} +export async function packageInvitationDomains(resource: SavedResource, session: ChannelSession, plan: InvitationCryptoPlan): Promise { + if (plan.channelId !== (await loadWorkspace(resource.id)).structure.defaultChannelId) throw new Error('服务端授权计划的默认频道不匹配'); + if (resource.mode !== 'enc') { + if (plan.batches.length) throw new Error('未加密空间收到加密授权计划'); + return []; + } + if (!session.can('owner')) throw new Error('只有空间主可以封装频道密钥'); + const { keys } = await readDataKeys(resource, session); + const result: DomainEnvelope[] = []; + for (const batch of plan.batches) { + if (batch.domain !== 'chat' && batch.domain !== 'files' || batch.epoch < 1 || batch.recipients.length < 1 || new Set(batch.recipients).size !== batch.recipients.length) + throw new Error('服务端加密授权计划格式无效'); + const keyId = domainId(plan.channelId, batch.domain, batch.epoch); + let key = keys[keyId]; + if (!key) { + if (!batch.rotate) throw new Error('缺少历史 epoch 密钥,不能为受邀成员重建或替换密钥'); + key = randomDomainKey(); keys[keyId] = key; + const latest = await getResource(resource.id); + if (!latest) throw new Error('空间本地密钥状态不存在'); + await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [keyId]: key } }); + } + for (const username of batch.recipients) result.push(await envelope(resource.id, plan.channelId, batch.domain, batch.epoch, key, username)); + } + return result; +} +export async function initializeChannelKeys(resource: SavedResource, session: ChannelSession, channelId: string): Promise { + if (resource.mode !== 'enc') return; + const { structure } = await loadWorkspace(resource.id); const channel = structure.channels.find(c => c.id === channelId); + if (!channel) throw new Error('频道不存在'); + if (!session.can('owner')) return; + const owner = requireAccount(); + for (const domain of ['chat', 'files'] as const) { + const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch; + if (epoch > 0) continue; + const key = randomDomainKey(); const latest = (await getResource(resource.id))!; + const id = domainId(channelId, domain, 1); + const existing = latest.domainKeys?.[id]; const selected = existing ?? key; + if (!existing) await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [id]: selected } }); + const packaged = await envelope(resource.id, channelId, domain, 1, selected, owner); + await apiFetch(`/spaces/${resource.id}/crypto/domains`, { method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, + body: JSON.stringify({ channelId, domain, epoch: 1, envelopes: [packaged] }) }); + } + await loadWorkspace(resource.id); +} +export interface PreparedObject { itemIdBytes: Uint8Array; itemId: string; channelId: string; domain: 'chat' | 'files'; epoch: number } +export async function prepareObject(resource: SavedResource, session: ChannelSession, channelId: string, domain: 'chat' | 'files'): Promise { + const idBytes = crypto.getRandomValues(new Uint8Array(16)); const itemId = toBase64Url(idBytes); + const { structure } = await loadWorkspace(resource.id); let channel = structure.channels.find(c => c.id === channelId); + if (!channel) throw new Error('请先创建频道'); + if (resource.mode === 'enc' && !(domain === 'chat' ? channel.chatEpoch : channel.filesEpoch)) { + await initializeChannelKeys(resource, session, channelId); + channel = (await loadWorkspace(resource.id)).structure.channels.find(c => c.id === channelId)!; + } + const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch; + let keyEnvelope = ''; + if (resource.mode === 'enc') { + const { keys } = await readDataKeys(resource, session); const key = keys[domainId(channelId, domain, epoch)]; + if (!key) throw new Error('未获得该频道数据域的访问授权'); + const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope; + objectKeys.set(`${resource.id}:${itemId}`, created.key); + } + const object = await apiFetch(`/spaces/${resource.id}/crypto/objects`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, + body: JSON.stringify({ itemId, channelId, domain, epoch, keyEnvelope }) }).then(r => r.json() as Promise); + const state = states.get(resource.id); if (state) state.objects.push(object); + return { itemIdBytes: idBytes, itemId, channelId, domain, epoch }; +} +export async function canDecryptRecord(resource: SavedResource, session: ChannelSession, itemId: string): Promise { + if (resource.mode !== 'enc') return true; + let state = states.get(resource.id); let object = state?.objects.find(o => o.itemId === itemId); + let keys = (await getResource(resource.id))?.domainKeys ?? {}; + if (!object) { const refreshed = await readDataKeys(resource, session); state = refreshed.state; keys = refreshed.keys; object = state.objects.find(o => o.itemId === itemId); } + if (!object) return false; + return Boolean(keys[domainId(object.channelId, object.domain, object.epoch)]); +} + +export async function verifyObjectEvent(resource: SavedResource, session: ChannelSession, itemId: string, manifestHash: Uint8Array): Promise { + if (resource.mode === 'raw' && !accountToken()) return true; + let state = states.get(resource.id); + let object = state?.objects.find(row => row.itemId === itemId); + if (!object?.event?.signature) { + const refreshed = await readDataKeys(resource, session); state = refreshed.state; + object = state.objects.find(row => row.itemId === itemId); + } + if (resource.mode === 'raw' && !object?.event?.signature) return true; + const signed = object?.event; + if (!object || !signed || !object.authorAccount || !signed.eventId || !signed.kind || !signed.frameCount + || !signed.manifestHash || !signed.signerDeviceId || !signed.signerRootPublicKey + || !signed.signerDevicePublicKey || !signed.deviceCertificateSignature || !signed.signature) return false; + const expectedManifest = fromBase64Url(signed.manifestHash, 32); + const spaceId = fromBase64Url(resource.id, 16); const eventId = fromBase64Url(signed.eventId, 16); + const itemBytes = fromBase64Url(itemId, 16); const eventSignature = fromBase64Url(signed.signature, 64); + const deviceCertificateSignature = fromBase64Url(signed.deviceCertificateSignature, 64); + if (!expectedManifest || !spaceId || !eventId || !itemBytes || !eventSignature || !deviceCertificateSignature + || expectedManifest.length !== manifestHash.length || !expectedManifest.every((byte, index) => byte === manifestHash[index])) return false; + const { unlockedAccount } = await import('./account'); const local = unlockedAccount(); + const rootJwk = JSON.parse(signed.signerRootPublicKey) as JsonWebKey; + if (object.authorAccount === local.username && local.profile.keys.signing + && (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false; + await pinSigningRoot(object.authorAccount, rootJwk); + const root = await importSigningPublicKey(rootJwk); + const deviceJwk = JSON.parse(signed.signerDevicePublicKey) as JsonWebKey; + if (!await verifyDeviceCertificate(object.authorAccount, signed.signerDeviceId, deviceJwk, deviceCertificateSignature, root)) return false; + const deviceKey = await importSigningPublicKey(deviceJwk); + const domain = object.domain; + if (domain !== 'chat' && domain !== 'files') return false; + const targetItemId = signed.targetItemId ? fromBase64Url(signed.targetItemId, 16) : undefined; + if (signed.targetItemId && !targetItemId) return false; + const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount, + deviceId: signed.signerDeviceId, eventId, eventKind: signed.kind, domain, epoch: object.epoch, + itemId: itemBytes, frameCount: signed.frameCount, manifestHash: expectedManifest, + ...(targetItemId ? { targetItemId } : {}) }); + return verifyStatement(deviceKey, 'content.event', payload, eventSignature); +} + +export async function objectEventProjection(resource: SavedResource, session: ChannelSession, itemId: string): Promise<{ kind: string; targetItemId?: string } | undefined> { + let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId); + if (!object) { state = (await readDataKeys(resource, session)).state; object = state.objects.find(row => row.itemId === itemId); } + return object?.event?.signature && object.event.kind + ? { kind: object.event.kind, ...(object.event.targetItemId ? { targetItemId: object.event.targetItemId } : {}) } + : undefined; +} + +export async function signObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise> { + const { state } = await readDataKeys(resource, session); + const object = state.objects.find(row => row.itemId === itemId); + const signed = object?.event; + if (!signed?.manifestHash || !signed.frameCount || (object?.domain !== 'chat' && object?.domain !== 'files')) + throw new Error('对象缺少已验签的密文清单,不能生成删除事件'); + const manifestHash = fromBase64Url(signed.manifestHash, 32); + if (!manifestHash) throw new Error('对象密文清单格式无效'); + return signExistingContentEvent({ spaceId: resource.id, channelId: object.channelId, domain: object.domain, + epoch: object.epoch, itemId, eventKind: object.domain === 'chat' ? 'chat.delete' : 'file.delete', + frameCount: signed.frameCount, manifestHash }); +} + +export async function verifyObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise { + let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId); + if (!object?.deleteEvent?.signature) { + const refreshed = await readDataKeys(resource, session); state = refreshed.state; + object = state.objects.find(row => row.itemId === itemId); + } + const deleted = object?.deleteEvent; + if (resource.mode === 'raw' && !deleted?.signature) return true; + if (!object || !deleted?.signature || !deleted.eventId || !deleted.kind || !deleted.signerDeviceId + || !deleted.signerRootPublicKey || !deleted.signerDevicePublicKey || !deleted.deviceCertificateSignature + || !object.authorAccount || !object.event?.manifestHash || !object.event.frameCount || (object.domain !== 'chat' && object.domain !== 'files')) return false; + const spaceId = fromBase64Url(resource.id, 16); const itemBytes = fromBase64Url(itemId, 16); + const eventId = fromBase64Url(deleted.eventId, 16); const manifestHash = fromBase64Url(object.event.manifestHash, 32); + const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64); + if (!spaceId || !itemBytes || !eventId || !manifestHash || !signature || !certificate) return false; + const rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey; + const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey; + const root = await importSigningPublicKey(rootJwk); + const { unlockedAccount } = await import('./account'); const local = unlockedAccount(); + if (object.authorAccount === local.username && local.profile.keys.signing + && (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false; + await pinSigningRoot(object.authorAccount, rootJwk); + if (!await verifyDeviceCertificate(object.authorAccount, deleted.signerDeviceId, deviceJwk, certificate, root)) return false; + const domain = object.domain; + const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete'; + if (deleted.kind !== expectedKind) return false; + const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount, + deviceId: deleted.signerDeviceId, eventId, eventKind: expectedKind, domain, epoch: object.epoch, + itemId: itemBytes, frameCount: object.event.frameCount, manifestHash }); + const deviceKey = await importSigningPublicKey(deviceJwk); + return verifyStatement(deviceKey, 'content.event', payload, signature); +} diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts index bd49540..c473ac7 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -1,12 +1,16 @@ -import { parseLogRecords, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core'; -import { getResource, saveResource, type SavedResource } from '../database'; +import { parseLogRecords, parseEnvelope, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core'; +import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDeletion, verifyObjectEvent } from './channelDataKeys'; +import { getResource, cacheResource, type SavedResource } from '../database'; +import { channelCache } from './resourceMetadata'; +import { loadWorkspace } from './workspaceStructure'; +import type { SeatInfo } from '../generated/api'; import type { FileItem, Item, Snapshot } from '../types'; import { previewKindOf } from '@/lib/fileKind'; import { blobs, readers } from './sessions'; import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation'; import { type ChannelWire, toChannelInfo } from './wire'; import { apiFetch, readJson } from './http'; -import { applyControl } from './channelThreads'; +import { readResourceRecord } from './workspaceRecords'; import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog'; export function mapReceived(item: ReceivedItem): Item { @@ -30,6 +34,10 @@ export function mapReceived(item: ReceivedItem): Item { } export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise { + if (!(input instanceof Uint8Array)) { + const itemId = input.kind === 'frame' ? toBase64Url(parseEnvelope(input.envelope).itemId) : input.itemId; + if (!(await canDecryptRecord(resource, session, itemId))) return; + } let reader = readers.get(resource.id); if (!reader) { reader = session.createItemReader(); @@ -39,7 +47,15 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann for (const event of events) { switch (event.type) { case 'item': { - const control = applyControl(event.item); + if (!event.item.complete || !event.item.ciphertextManifestHash + || !await verifyObjectEvent(resource, session, event.item.itemId, event.item.ciphertextManifestHash)) { + showRejected(resource.id, { type: 'rejected', itemId: event.item.itemId, seq: event.item.seq, reason: 'auth_failed' }); + break; + } + const signedEvent = await objectEventProjection(resource, session, event.item.itemId); + if (signedEvent?.kind === 'chat.edit' && signedEvent.targetItemId) + publishWorkspace(resource.id, { t: 'removed', id: signedEvent.targetItemId }); + const control = readResourceRecord(event.item); if (control !== false) { if (control && control.t === 'file-catalog') { const key = `${resource.id}:${control.item.id}`; @@ -84,6 +100,10 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann break; } case 'removed': + if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId)) { + showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' }); + break; + } visibleFiles.delete(`${resource.id}:${event.itemId}`); fileCatalogs.delete(`${resource.id}:${event.itemId}`); blobs.delete(`${resource.id}:${event.itemId}`); @@ -101,19 +121,16 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann } export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise { - const info = await readJson(`/channels/${resource.id}`, session.authHeaders('read')); + const info = await readJson(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read')); session.verifyChannelInfo(info); - resource.name = info.settings.name; - resource.membersCanUpload = info.settings.members_can_upload; - resource.expiresAt = info.expires_at; - const latest = await getResource(resource.id); - await saveResource({ ...latest, ...resource, localItems: latest?.localItems }); + Object.assign(resource, await cacheResource(resource, channelCache(info))); publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) }); + await loadWorkspace(resource.id); const ownId = toBase64Url(session.endpointHash); const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name; let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)]; if (session.can('owner')) { - const seats = await readJson>( + const seats = await readJson( `/channels/${resource.id}/seats`, session.authHeaders('owner')); devices = await Promise.all(seats.map(async (seat) => { let name: string | undefined; @@ -128,6 +145,7 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS } export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise { + if (resource.mode === 'enc') await readDataKeys(resource, session); publishWorkspace(resource.id, { t: 'reset' }); clearFileCatalog(resource.id); readers.set(resource.id, session.createItemReader()); diff --git a/web/src/core-bridge/worker/channelGrant.ts b/web/src/core-bridge/worker/channelGrant.ts index 830ad97..e05b372 100644 --- a/web/src/core-bridge/worker/channelGrant.ts +++ b/web/src/core-bridge/worker/channelGrant.ts @@ -21,17 +21,12 @@ export async function sealChannelGrant(link: string, recipient: string, publicKe export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey, role: 'owner' | 'member', origin: string): Promise { const plain = await decryptInvitation(payload, recipient, channelId, privateKey); - let parsed: ReturnType; - if (plain.startsWith('{')) { - const grant = JSON.parse(plain) as ChannelGrant; - if (grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode) - || !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败'); - parsed = parseChannelLink(makeLink(origin, channelId, grant.keys)); - if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配'); - } else { - // Existing invitations hold an encrypted link; their secret never becomes a navigation URL. - parsed = parseChannelLink(plain); - } + let grant: ChannelGrant; + try { grant = JSON.parse(plain) as ChannelGrant; } catch { throw new Error('空间授权包格式无效'); } + if (!grant || grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode) + || !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败'); + const parsed = parseChannelLink(makeLink(origin, channelId, grant.keys)); + if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配'); if (!parsed.ok || parsed.link.id !== channelId || parsed.link.isOwner !== (role === 'owner')) throw new Error('空间授权包权限不匹配'); return parsed.link; } diff --git a/web/src/core-bridge/worker/channelItems.ts b/web/src/core-bridge/worker/channelItems.ts index 894693a..ac5b29c 100644 --- a/web/src/core-bridge/worker/channelItems.ts +++ b/web/src/core-bridge/worker/channelItems.ts @@ -13,6 +13,8 @@ import { IntegrityError } from '@webdrop/core'; import { fileCatalogs, verifyFileCatalog } from './fileCatalog'; import { dequeueItem, saveResource } from '../database'; import { serial } from './serial'; +import { signObjectDeletion } from './channelDataKeys'; +import { accountToken } from './accountSession'; export function connectChannelResource(id: string): () => void { void connectChannel(id); @@ -23,7 +25,8 @@ export async function removeChannelItem(resourceId: string, itemId: string): Pro const { session, resource } = await getSession(resourceId); const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'; await serial(`outbox:${resourceId}`, async () => { - await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders(role) }); + const eventHeaders = resource.kind === 'channel' && accountToken() ? await signObjectDeletion(resource, session, itemId) : {}; + await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: { ...session.authHeaders(role), ...eventHeaders } }); await dequeueItem(resourceId, itemId); resource.localItems = resource.localItems?.filter((item) => item.id !== itemId); await saveResource(resource); diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts index 459d7d0..6e6fdb7 100644 --- a/web/src/core-bridge/worker/channelResources.ts +++ b/web/src/core-bridge/worker/channelResources.ts @@ -1,9 +1,13 @@ import { ChannelSession, toBase64Url } from '@webdrop/core'; -import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; +import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; import type { ParsedChannelLink } from '@/lib/channelLink'; import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types'; -import { apiFetch, readJson } from './http'; +import { apiFetch, readJson, getServerInfo } from './http'; +import { channelCache, isOfflineFailure } from './resourceMetadata'; +import { accountToken } from './accountSession'; +import { attachDataKeys, initializeChannelKeys } from './channelDataKeys'; +import type { ChannelSettingsPatch as SettingsRequest, SeatJoined, MemberBody } from '../generated/api'; import { getSession, sessions } from './sessions'; import { deviceIdentity } from './presentation'; import { makeLink } from './links'; @@ -13,26 +17,26 @@ import { publishWorkspace } from './presentation'; import { channelMetadata } from './channelEvents'; import { requireAccount, accountPublicKey } from './accountSession'; import { sealChannelGrant } from './channelGrant'; -import { createThread } from './channelThreads'; export async function createChannel(options: CreateOptions, captchaToken?: string): Promise { const username = requireAccount(); + const defaults = (await getServerInfo()).defaults?.channel; + if (!defaults) throw new Error('服务端需要升级以提供统一设置'); + if (defaults.join_policy !== 'open' && defaults.join_policy !== 'invite') throw new Error('空间加入规则无效'); const endpointSecret = crypto.getRandomValues(new Uint8Array(32)); const { session, request } = await ChannelSession.create({ mode: options.mode, endpointSecret, settings: { + ...defaults, name: options.name?.trim() || undefined, seats: options.seats, ttl_seconds: options.ttlSeconds, members_can_upload: options.membersCanUpload, - delete_policy: 'own', - max_items: 1000, - file_max_downloads: options.fileMaxDownloads ?? 10, - ui_restrict_copy_download: false, - join_policy: options.joinPolicy ?? 'invite', allow_guests: options.allowGuests ?? false, + file_max_downloads: options.fileMaxDownloads, + join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests, }, }); try { @@ -42,13 +46,14 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin }).then((response) => response.json() as Promise); session.verifyChannelInfo(info); const resource: SavedResource = { + ...channelCache(info), id: session.id, - creatorAccount: info.creator_account, + creatorAccount: info.creator_account ?? undefined, kind: 'channel', mode: session.mode, role: 'owner', - name: info.settings.name, + name: info.settings.name ?? undefined, link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'), endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at, - seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, + seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads, localItems: [], }; const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); @@ -57,7 +62,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin }); await saveResource(resource); sessions.set(session.id, session); - await createThread(session.id, '常规'); + attachDataKeys(session, resource); + const workspace = await import('./workspaceStructure').then(module => module.loadWorkspace(session.id)); + if (workspace.structure.defaultChannelId) await initializeChannelKeys(resource, session, workspace.structure.defaultChannelId); return toChannelInfo(info, resource); } catch (error) { session.dispose(); @@ -75,15 +82,16 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s session.verifyChannelInfo(info); const joined = await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }), - }).then((response) => response.json() as Promise<{ seats_used: number }>); + }).then((response) => response.json() as Promise); info.seats_used = joined.seats_used; const resource: SavedResource = { + ...channelCache(info), id, kind: 'channel', mode: session.mode, role: session.role, - creatorAccount: info.creator_account, - name: info.settings.name, + creatorAccount: info.creator_account ?? undefined, + name: info.settings.name ?? undefined, link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined, endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at, - seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, + seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [], }; const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); @@ -106,28 +114,52 @@ export async function previewChannel(link: ParsedChannelLink): Promise { +export async function channelMembers(id: string): Promise { const { session } = await getSession(id); return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read')); } export async function listChannels(): Promise { const saved = await listResources('channel'); - return saved.map((resource) => ({ + let catalog: Map | undefined; + if (accountToken()) { + try { + const rows = await apiFetch('/accounts/spaces').then(r => r.json() as Promise); + catalog = new Map(rows.map(row => [row.id, row])); + } catch (error) { if (!isOfflineFailure(error)) throw error; } + } + const rows = await Promise.all(saved.map(async (resource): Promise => { + try { + if (catalog && !catalog.has(resource.id)) return null; + const { session } = await getSession(resource.id); + const raw = catalog?.get(resource.id) ?? await readJson(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read')); + session.verifyChannelInfo(raw); + const info = toChannelInfo(raw, resource); + await cacheResource(resource, channelCache(raw)); + return { ...info, dataSource: 'server' }; + } catch (error) { + if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null; + if (!isOfflineFailure(error)) throw error; + if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' }; + return { id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined, creatorAccount: resource.creatorAccount, memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt, + dataSource: 'cache', + }; + } })); + return rows.filter((row): row is ChannelInfo => row !== null); } export async function updateChannelSettings(resourceId: string, patch: ChannelSettingsPatch): Promise { const { session, resource } = await getSession(resourceId); if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置'); - const body: Record = {}; + const body: SettingsRequest = {}; if (patch.name !== undefined) body.name = patch.name.trim(); if (patch.seats !== undefined) body.seats = patch.seats; if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload; @@ -141,13 +173,8 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe body: JSON.stringify(body), }).then((response) => response.json() as Promise); session.verifyChannelInfo(info); - resource.name = info.settings.name; - resource.seats = info.settings.seats; - resource.membersCanUpload = info.settings.members_can_upload; - resource.maxDownloads = info.settings.file_max_downloads; - resource.expiresAt = info.expires_at; - await saveResource(resource); - publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, resource) }); + const updated = await cacheResource(resource, channelCache(info)); + publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) }); } export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise { diff --git a/web/src/core-bridge/worker/channelSend.ts b/web/src/core-bridge/worker/channelSend.ts index 3953629..dcdd4a2 100644 --- a/web/src/core-bridge/worker/channelSend.ts +++ b/web/src/core-bridge/worker/channelSend.ts @@ -1,9 +1,10 @@ import { removeChannelItem } from './channelItems'; -import { queueFileCatalog, queueItemAuthor } from './channelThreads'; +import { queueFileCatalog } from './channelThreads'; import { senderSnapshot } from './senderSnapshot'; +import { prepareObject } from './channelDataKeys'; import { errorMessage } from '../errors'; -import { saveResource } from '../database'; +import { saveResource, queueLocation } from '../database'; import { previewKindOf } from '@/lib/fileKind'; import type { FileItem, FileUploadOptions, Item } from '../types'; @@ -16,31 +17,35 @@ import { deviceIdentity } from './presentation'; import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer'; import { publishWorkspace } from './presentation'; -export async function sendText(resourceId: string, text: string, threadId?: string): Promise<{ itemId: string; uploaded: boolean }> { +export async function sendText(resourceId: string, text: string, threadId?: string, + event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> { if (!threadId) throw new Error('请先选择频道'); const { session, resource } = await getSession(resourceId); - if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限'); + if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限'); const identity = await deviceIdentity(session.endpointSecret); const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; const ts = Date.now(); const author = await senderSnapshot(); - const plan = session.encryptText(text, { dev: device.name, ts, threadId }); + const prepared = await prepareObject(resource, session, threadId, 'chat'); + const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes }); const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) }; blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' })); publishWorkspace(resourceId, { t: 'item', item }); - await persistPlan(resource, plan); - if (author) await queueItemAuthor(resource, session, plan.itemId, author, threadId, ts); + await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, + eventKind: event?.kind ?? 'chat.create', ...(event?.targetItemId ? { targetItemId: event.targetItemId } : {}) }); + await queueLocation(resourceId, item.id, threadId); const uploaded = await flushOutbox(resource, session); publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } }); if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' }); return { itemId: plan.itemId, uploaded }; } -export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId?: string, folderId?: string): Promise { +export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise { if (!threadId) throw new Error('请先选择频道'); const { session, resource } = await getSession(resourceId); - if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限'); + const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 }; + if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限'); const identity = await deviceIdentity(session.endpointSecret); const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; @@ -50,7 +55,9 @@ export async function sendFiles(resourceId: string, files: File[], options: File const ts = Date.now(); let item: FileItem | undefined; try { + const prepared = await prepareObject(resource, session, threadId, 'files'); const plan = session.encryptFile(file, {}, { + itemId: prepared.itemIdBytes, ...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }), dev: device.name, ts, maxFrameData, threadId, folderId, }); @@ -60,7 +67,7 @@ export async function sendFiles(resourceId: string, files: File[], options: File ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), name: file.name, mime: file.type || 'application/octet-stream', size: file.size, remaining: plan.gated - ? (options.directPreview ? resource.maxDownloads ?? 10 : options.maxDownloads) || null + ? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null : null, preview: previewKindOf(file.type, file.name), gated: plan.gated, }; @@ -84,9 +91,10 @@ export async function sendFiles(resourceId: string, files: File[], options: File const planned = await persistPlan(resource, plan, (_frames) => { const processed = Math.min(file.size, _frames * MAX_FRAME_DATA); updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting'); - }); + }, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' }); const uploadedFrames = new Set(); await queueFileCatalog(resource, session, item); + await queueLocation(resourceId, item.id, threadId, folderId); let uploadedBytes = 0; const uploadStartedAt = performance.now(); const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => { @@ -120,13 +128,16 @@ export async function sendFiles(resourceId: string, files: File[], options: File export async function updateSnapshot(resourceId: string, text: string): Promise { const { session, resource } = await getSession(resourceId); const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name; - const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name }); - await persistPlan(resource, plan); + const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId)); + if (!structure.defaultChannelId) throw new Error('请先创建默认频道'); + const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat'); + const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes }); + await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' }); await flushOutbox(resource, session); } export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise { - const created = await sendText(resourceId, text, threadId); + const created = await sendText(resourceId, text, threadId, { kind: 'chat.edit', targetItemId: itemId }); if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。'); await removeChannelItem(resourceId, itemId); } diff --git a/web/src/core-bridge/worker/channelThreads.ts b/web/src/core-bridge/worker/channelThreads.ts index 6d17043..17f6904 100644 --- a/web/src/core-bridge/worker/channelThreads.ts +++ b/web/src/core-bridge/worker/channelThreads.ts @@ -1,172 +1,55 @@ -import type { ChannelSession, ReceivedItem } from '@webdrop/core'; +import type { ChannelSession } from '@webdrop/core'; import type { SavedResource } from '../database'; -import type { FileItem, FolderInfo, SenderInfo, ThreadInfo, WorkspaceEvent } from '../types'; -import { readSender } from '@/lib/sender'; -import { previewKindOf } from '@/lib/fileKind'; -import { publishWorkspace } from './presentation'; +import type { FileItem } from '../types'; +import { persistPlan } from './transfer'; +import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup } from './workspaceStructure'; import { getSession } from './sessions'; -import { deviceIdentity } from './presentation'; -import { flushOutbox, persistPlan } from './transfer'; +import { initializeChannelKeys, prepareObject } from './channelDataKeys'; +import { toPublishedFile } from './fileCatalog'; +import { apiFetch } from './http'; +import { WORKSPACE_APP, type ResourceControl } from '../protocol/workspace'; +import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest } from '../generated/api'; -const CONTROL_APP = 'webdrop.workspace'; const encoder = new TextEncoder(); -type Control = - | { op: 'item.author'; itemId: string; author: SenderInfo } - | { op: 'file.published'; item: FileItem } - | { op: 'thread.create'; id: string; name: string; groupId?: string } - | { op: 'group.create'; id: string; name: string } - | { op: 'group.rename'; id: string; name: string } - | { op: 'thread.group'; id: string; groupId: string | null } - | { op: 'thread.rename'; id: string; name: string } - | { op: 'folder.create'; id: string; threadId: string; parentId?: string; name: string } - | { op: 'folder.rename'; id: string; name: string } - | { op: 'items.move'; itemIds: string[]; threadId: string; folderId: string | null }; -function cleanName(input: string): string { - const name = input.trim(); - if (!name || name.length > 80 || /[\u0000-\u001f]/.test(name)) throw new Error('名称需为 1–80 个字符,不能包含控制字符'); - return name; -} - -async function sendControl(resourceId: string, control: Control, threadId?: string): Promise { - const { session, resource } = await getSession(resourceId); - if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); - if (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload)) throw new Error('此链接没有上传权限'); - const device = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name; - const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })), { - kind: 'other', threadId, dev: device, - }, { ts: Date.now(), dev: device, control: true }); - await persistPlan(resource, plan); - const event = controlEvent(control, Date.now(), device); - if (event) publishWorkspace(resourceId, event); - await flushOutbox(resource, session); -} - -export async function createThread(resourceId: string, input: string, groupId?: string): Promise { - if (groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效'); - const id = crypto.randomUUID(); - await sendControl(resourceId, { op: 'thread.create', id, name: cleanName(input), ...(groupId ? { groupId } : {}) }); +export async function createThread(resourceId: string, name: string, groupId?: string): Promise { + const id = await createWorkspaceChannel(resourceId, { name, groupId } satisfies WorkspaceChannelRequest); + const { resource, session } = await getSession(resourceId); await initializeChannelKeys(resource, session, id); return id; } - -export async function createGroup(resourceId: string, input: string): Promise { - const id = crypto.randomUUID(); await sendControl(resourceId, { op: 'group.create', id, name: cleanName(input) }); return id; +export async function createGroup(resourceId: string, name: string): Promise { + return createWorkspaceGroup(resourceId, { name } satisfies WorkspaceNameRequest); } -export async function renameGroup(resourceId: string, id: string, input: string): Promise { - if (!isId(id)) throw new Error('频道分组格式无效'); - await sendControl(resourceId, { op: 'group.rename', id, name: cleanName(input) }); +export async function renameGroup(resourceId: string, id: string, name: string): Promise { + await changeWorkspace(resourceId, 'groups/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); } export async function groupThread(resourceId: string, id: string, groupId?: string): Promise { - if (!isId(id) || groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效'); - await sendControl(resourceId, { op: 'thread.group', id, groupId: groupId ?? null }, id); + await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest); } - -export async function renameThread(resourceId: string, id: string, input: string): Promise { - await sendControl(resourceId, { op: 'thread.rename', id, name: cleanName(input) }, id); +export async function renameThread(resourceId: string, id: string, name: string): Promise { + await changeWorkspace(resourceId, 'channels/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); } - -export async function createFolder(resourceId: string, threadId: string, input: string, parentId?: string): Promise { - await sendControl(resourceId, { op: 'folder.create', id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: cleanName(input) }, threadId); +export async function setChannelHistory(resourceId: string, id: string, chatHistory: boolean, filesHistory: boolean): Promise { + const { session } = await getSession(resourceId); + await apiFetch(`/spaces/${resourceId}/channels/${id}/history`, { method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ chatHistory, filesHistory }) }); + await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId)); } - -export async function renameFolder(resourceId: string, id: string, input: string, threadId: string): Promise { - await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId); +export async function createFolder(resourceId: string, threadId: string, name: string, parentId?: string): Promise { + await changeWorkspace(resourceId, 'folders', 'POST', { threadId, name, parentId } satisfies WorkspaceFolderRequest); +} +export async function renameFolder(resourceId: string, id: string, name: string, _threadId: string): Promise { + await changeWorkspace(resourceId, 'folders/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); } - export async function moveItems(resourceId: string, itemIds: string[], threadId: string, folderId?: string): Promise { - if (itemIds.length < 1 || itemIds.length > 100) throw new Error('一次最多移动 100 个文件'); - await sendControl(resourceId, { op: 'items.move', itemIds, threadId, folderId: folderId ?? null }, threadId); + await changeWorkspace(resourceId, 'locations', 'PUT', { itemIds, threadId, folderId } satisfies WorkspaceMoveRequest); } export async function queueFileCatalog(resource: SavedResource, session: ChannelSession, item: FileItem): Promise { - const control: Control = { op: 'file.published', item: { ...item, state: { kind: 'complete' } } }; - const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })), - { kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true }); - await persistPlan(resource, plan); -} - -export async function queueItemAuthor(resource: SavedResource, session: ChannelSession, itemId: string, author: SenderInfo, threadId: string, ts: number): Promise { - const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, op: 'item.author', itemId, author })), - { kind: 'other', threadId }, { ts, control: true }); - await persistPlan(resource, plan); -} - -function isId(value: unknown): value is string { - return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value); -} -function isItemId(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); } -function validName(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0 && value.length <= 80 && !/[\u0000-\u001f]/.test(value); -} - -function controlEvent(control: Control, ts: number, author: string): WorkspaceEvent | null { - switch (control.op) { - case 'item.author': return { t: 'item-author', id: control.itemId, author: control.author }; - case 'file.published': return { t: 'file-catalog', item: control.item }; - case 'thread.create': return { t: 'thread-created', thread: { id: control.id, name: control.name, createdAt: ts, createdBy: author, ...(control.groupId ? { groupId: control.groupId } : {}) } }; - case 'group.create': return { t: 'group-created', group: { id: control.id, name: control.name, createdAt: ts } }; - case 'group.rename': return { t: 'group-renamed', id: control.id, name: control.name }; - case 'thread.group': return { t: 'thread-grouped', id: control.id, ...(control.groupId ? { groupId: control.groupId } : {}) }; - case 'thread.rename': return { t: 'thread-renamed', id: control.id, name: control.name }; - case 'folder.create': return { t: 'folder-created', folder: { id: control.id, threadId: control.threadId, ...(control.parentId ? { parentId: control.parentId } : {}), name: control.name } }; - case 'folder.rename': return { t: 'folder-renamed', id: control.id, name: control.name }; - case 'items.move': return { t: 'items-moved', itemIds: control.itemIds, threadId: control.threadId, ...(control.folderId ? { folderId: control.folderId } : {}) }; - } -} - -export function applyControl(item: ReceivedItem): WorkspaceEvent | false { - if (item.meta?.kind !== 'other') return false; - let value: unknown; - try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; } - if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== CONTROL_APP) return false; - const row = value as Record; - if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - const author = item.meta.dev ?? '未知设备'; - switch (row.op) { - case 'item.author': { - const sender = readSender(row.author); - return isItemId(row.itemId) && sender ? { t: 'item-author', id: row.itemId, author: sender } : { t: 'error', message: '发送者资料格式无效,已拒绝应用。' }; - } - case 'file.published': { - const file = row.item as FileItem | undefined; - const sender = readSender(file?.author); - if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId)) - || file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256 - || !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean' || file.author !== undefined && !sender) return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' }; - return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, ...(file.folderId ? { folderId: file.folderId } : {}), - name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备', - ...(sender ? { author: sender } : {}), - gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } }; - } - case 'thread.create': - return isId(row.id) && validName(row.name) && (row.groupId === undefined || isId(row.groupId)) - ? { t: 'thread-created', thread: { id: row.id, name: row.name, createdAt: item.meta.ts, createdBy: author, - ...(row.groupId ? { groupId: row.groupId as string } : {}) } satisfies ThreadInfo } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - case 'group.create': - return isId(row.id) && validName(row.name) ? { t: 'group-created', group: { id: row.id, name: row.name, createdAt: item.meta.ts } } - : { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' }; - case 'group.rename': - return isId(row.id) && validName(row.name) ? { t: 'group-renamed', id: row.id, name: row.name } - : { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' }; - case 'thread.group': - return isId(row.id) && (row.groupId === null || isId(row.groupId)) ? { t: 'thread-grouped', id: row.id, ...(row.groupId ? { groupId: row.groupId as string } : {}) } - : { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' }; - case 'thread.rename': - return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80 - ? { t: 'thread-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - case 'folder.create': - if (!isId(row.id) || !isId(row.threadId) || (row.parentId !== undefined && !isId(row.parentId)) - || typeof row.name !== 'string' || row.name.length < 1 || row.name.length > 80) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - return { t: 'folder-created', folder: { id: row.id, threadId: row.threadId, ...(row.parentId ? { parentId: row.parentId as string } : {}), name: row.name } satisfies FolderInfo }; - case 'folder.rename': - return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80 - ? { t: 'folder-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - case 'items.move': - if (!Array.isArray(row.itemIds) || row.itemIds.length > 100 || !row.itemIds.every(isItemId) || !isId(row.threadId) - || (row.folderId !== null && !isId(row.folderId))) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - return { t: 'items-moved', itemIds: row.itemIds, threadId: row.threadId, ...(row.folderId ? { folderId: row.folderId as string } : {}) }; - default: - return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - } + if (!item.threadId) throw new Error('请先选择频道'); + const prepared = await prepareObject(resource, session, item.threadId, 'files'); + const control: ResourceControl = { op: 'file.published', item: toPublishedFile(item) }; + const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: WORKSPACE_APP, v: 1, ...control })), + { kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes }); + await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.catalog' }); } diff --git a/web/src/core-bridge/worker/domainCrypto.test.ts b/web/src/core-bridge/worker/domainCrypto.test.ts new file mode 100644 index 0000000..c531cd8 --- /dev/null +++ b/web/src/core-bridge/worker/domainCrypto.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto'; +import { openJson, sealJson } from './accountCrypto'; + +describe('channel data-domain object keys', () => { + it('uses random independent file keys and binds envelopes to channel, domain, epoch and object', async () => { + const domain = randomDomainKey(); + const context = { spaceId: 'space', channelId: 'channel-a', domain: 'files' as const, epoch: 1, itemId: 'file-a' }; + const a = await newObjectKey(domain, context); const b = await newObjectKey(domain, context); + expect(a.key.extractable).toBe(false); expect(a.envelope).not.toBe(b.envelope); + const ciphertext = await sealJson({ data: 'private file' }, a.key); + const recovered = await unwrapObjectKey(domain, a.envelope, context); + expect(await openJson(ciphertext, recovered)).toEqual({ data: 'private file' }); + await expect(openJson(ciphertext, b.key)).rejects.toThrow(); + await expect(unwrapObjectKey(randomDomainKey(), a.envelope, context)).rejects.toThrow(); + for (const changed of [{ ...context, spaceId: 'other-space' }, { ...context, channelId: 'channel-b' }, { ...context, domain: 'chat' as const }, { ...context, epoch: 2 }, { ...context, itemId: 'file-b' }]) + await expect(unwrapObjectKey(domain, a.envelope, changed)).rejects.toThrow(); + }); +}); diff --git a/web/src/core-bridge/worker/domainCrypto.ts b/web/src/core-bridge/worker/domainCrypto.ts new file mode 100644 index 0000000..6e0d8e1 --- /dev/null +++ b/web/src/core-bridge/worker/domainCrypto.ts @@ -0,0 +1,22 @@ +import { bytes, b64, unb64, sealJson, openJson } from './accountCrypto'; + +export interface ObjectContext { spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string } +const label = (c: ObjectContext) => JSON.stringify(['webdrop/object-key/v1', c.spaceId, c.channelId, c.domain, c.epoch, c.itemId]); +export const randomDomainKey = () => b64(crypto.getRandomValues(new Uint8Array(32))); +async function aes(value: string): Promise { + const material = unb64(value); + try { if (material.length !== 32) throw new Error('数据域密钥格式无效'); return await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); } + finally { material.fill(0); } +} +export async function newObjectKey(domainKey: string, context: ObjectContext): Promise<{ key: CryptoKey; envelope: string }> { + const raw = crypto.getRandomValues(new Uint8Array(32)); + try { + const key = await crypto.subtle.importKey('raw', bytes(raw), 'AES-GCM', false, ['encrypt', 'decrypt']); + return { key, envelope: await sealJson({ key: b64(raw) }, await aes(domainKey), label(context)) }; + } finally { raw.fill(0); } +} +export async function unwrapObjectKey(domainKey: string, envelope: string, context: ObjectContext): Promise { + const value = await openJson(envelope, await aes(domainKey), label(context)) as { key?: unknown }; + if (typeof value.key !== 'string') throw new Error('对象密钥封装格式无效'); + return aes(value.key); +} diff --git a/web/src/core-bridge/worker/downloadStorage.ts b/web/src/core-bridge/worker/downloadStorage.ts index 8389e4f..5974efa 100644 --- a/web/src/core-bridge/worker/downloadStorage.ts +++ b/web/src/core-bridge/worker/downloadStorage.ts @@ -40,7 +40,8 @@ export async function storeVerifiedDownload( decoded: { content: ReadableStream; result: Promise }, expectedSize?: number, createTemporary: () => Promise = temporaryFile, -): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> { + verifyResult?: (result: ItemDecodeResult) => Promise, +): Promise<{ blob: Blob; meta: ItemDecodeResult['meta']; ciphertextManifestHash?: Uint8Array }> { const reader = decoded.content.getReader(); let temporary: TemporaryFile | undefined; const parts: ArrayBuffer[] = []; @@ -64,8 +65,9 @@ export async function storeVerifiedDownload( const result = await decoded.result; if (!result.complete) throw new Error('文件传输不完整'); if (expectedSize !== undefined && size !== expectedSize || result.meta?.size !== undefined && size !== result.meta.size) throw new IntegrityError('bad_meta'); + await verifyResult?.(result); if (temporary) { await temporary.close(); closed = true; } - return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta }; + return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta, ciphertextManifestHash: result.ciphertextManifestHash }; } catch (error) { await reader.cancel(error).catch(() => {}); if (temporary) { diff --git a/web/src/core-bridge/worker/fileCatalog.ts b/web/src/core-bridge/worker/fileCatalog.ts index 527dab5..cec15d7 100644 --- a/web/src/core-bridge/worker/fileCatalog.ts +++ b/web/src/core-bridge/worker/fileCatalog.ts @@ -1,5 +1,13 @@ import type { FileItem } from '../types'; import { IntegrityError, type ItemMeta } from '@webdrop/core'; +import type { PublishedFile } from '../protocol/workspace'; + +export function toPublishedFile(item: FileItem): PublishedFile { + if (!item.threadId) throw new Error('请先选择频道'); + return { id: item.id, kind: 'file', threadId: item.threadId, folderId: item.folderId, + name: item.name, mime: item.mime, size: item.size, ts: item.ts, deviceId: item.deviceId, + gated: item.gated ?? false }; +} export const fileCatalogs = new Map(); export const visibleFiles = new Set(); diff --git a/web/src/core-bridge/worker/groupControl.test.ts b/web/src/core-bridge/worker/groupControl.test.ts deleted file mode 100644 index a998a22..0000000 --- a/web/src/core-bridge/worker/groupControl.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ChannelSession, collectFrames } from '@webdrop/core'; -import { applyControl } from './channelThreads'; - -const groupId = 'd7a19ad2-690c-44f2-bd0a-1b30b309bf92'; -const threadId = '15e7fb09-984d-47d4-8e74-477c7d69c58c'; -async function decode(control: Record) { - const { session } = await ChannelSession.create(); - try { - const frames = await collectFrames(session.encryptBytes(new TextEncoder().encode(JSON.stringify({ app: 'webdrop.workspace', v: 1, ...control })), - { kind: 'other' }, { control: true, ts: 1 })); - const event = (await session.createItemReader().pushFrame(frames[0]!.bytes)).find((row) => row.type === 'item'); - if (!event || event.type !== 'item') throw new Error('missing decoded control'); - return applyControl(event.item); - } finally { session.dispose(); } -} -describe('encrypted channel groups', () => { - it('replays the sender profile from an encrypted identity record', async () => { - const author = { username: 'alice', displayName: '小白', avatar: 'data:image/png;base64,AA==' }; - expect(await decode({ op: 'item.author', itemId: 'AAAAAAAAAAAAAAAAAAAAAA', author })).toEqual({ t: 'item-author', id: 'AAAAAAAAAAAAAAAAAAAAAA', author }); - expect(await decode({ op: 'item.author', itemId: 'AAAAAAAAAAAAAAAAAAAAAA', author: { ...author, avatar: 'https://tracker.test/avatar' } })).toMatchObject({ t: 'error' }); - }); - it('replays a group and a channel created inside it', async () => { - expect(await decode({ op: 'group.create', id: groupId, name: '项目' })).toEqual({ t: 'group-created', group: { id: groupId, name: '项目', createdAt: 1 } }); - expect(await decode({ op: 'thread.create', id: threadId, name: '讨论', groupId })).toMatchObject({ t: 'thread-created', thread: { id: threadId, groupId } }); - }); - it('replays moves and returning to ungrouped', async () => { - expect(await decode({ op: 'thread.group', id: threadId, groupId })).toEqual({ t: 'thread-grouped', id: threadId, groupId }); - expect(await decode({ op: 'thread.group', id: threadId, groupId: null })).toEqual({ t: 'thread-grouped', id: threadId }); - expect(await decode({ op: 'group.rename', id: groupId, name: '资料' })).toEqual({ t: 'group-renamed', id: groupId, name: '资料' }); - }); - it.each([ - { op: 'group.create', id: 'invalid', name: '项目' }, - { op: 'group.rename', id: groupId, name: ' ' }, - { op: 'group.create', id: groupId, name: '\u0000project' }, - { op: 'thread.group', id: threadId, groupId: 'invalid' }, - { op: 'thread.group', id: threadId }, - { op: 'thread.create', id: threadId, name: '讨论', groupId: 'invalid' }, - ])('rejects malformed control %#', async (control) => { - expect(await decode(control)).toMatchObject({ t: 'error' }); - }); -}); 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); diff --git a/web/src/core-bridge/worker/http.ts b/web/src/core-bridge/worker/http.ts index fdada35..cbaf0d4 100644 --- a/web/src/core-bridge/worker/http.ts +++ b/web/src/core-bridge/worker/http.ts @@ -1,9 +1,10 @@ import { supportsApiContract } from '../contract'; import { accountToken } from './accountSession'; +import type { ServerInfoBody } from '../generated/api'; export type ApiError = Error & { status?: number; code?: string }; let apiBase = '/api/v1'; -let contractCheck: Promise | undefined; +let contractCheck: Promise | undefined; export function setApiBase(value: string): void { if (apiBase === value) return; @@ -20,15 +21,16 @@ export const apiPath = (path: string): string => new URL( currentOrigin(), ).toString(); -function ensureApiContract(): Promise { +export function getServerInfo(): Promise { contractCheck ??= fetch(apiPath('/info'), { cache: 'no-store', credentials: 'omit', referrerPolicy: 'no-referrer', }).then(async (response) => { if (!response.ok) throw new Error('api_info_unavailable'); - const info = await response.json() as { contract_version?: unknown }; + const info = await response.json() as ServerInfoBody; if (!supportsApiContract(info.contract_version)) throw new Error('api_contract_mismatch'); + return info; }).catch((error: unknown) => { contractCheck = undefined; throw error; @@ -38,7 +40,7 @@ function ensureApiContract(): Promise { export async function apiFetch(path: string, init: RequestInit = {}): Promise { try { - if (path !== '/info') await ensureApiContract(); + if (path !== '/info') await getServerInfo(); const headers = new Headers(init.headers); const token = accountToken(); if (token && !headers.has('X-WD-Account')) headers.set('X-WD-Account', token); @@ -71,7 +73,7 @@ export async function apiUpload( onProgress: (loadedBytes: number, totalBytes: number) => void, ): Promise { try { - await ensureApiContract(); + await getServerInfo(); const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', apiPath(path)); diff --git a/web/src/core-bridge/worker/passkeyCrypto.test.ts b/web/src/core-bridge/worker/passkeyCrypto.test.ts new file mode 100644 index 0000000..138ad79 --- /dev/null +++ b/web/src/core-bridge/worker/passkeyCrypto.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { openPasskeyKey, sealPasskeyKey } from './passkeyCrypto'; +import { bytes, openJson, sealJson } from './accountCrypto'; + +describe('Passkey recovery key envelopes', () => { + it('recovers the same non-exportable vault key without exposing plaintext to the backend', async () => { + const material = crypto.getRandomValues(new Uint8Array(32)); const prf = crypto.getRandomValues(new Uint8Array(32)); + const key = await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); + const vault = await sealJson({ private: 'test-private-identity' }, key); + const envelope = await sealPasskeyKey(material, bytes(prf), 'alice', 'credential-a'); + expect(envelope).not.toContain('test-private-identity'); + const recovered = await openPasskeyKey(envelope, bytes(prf), 'alice', 'credential-a'); + expect(recovered.extractable).toBe(false); expect(await openJson(vault, recovered)).toEqual({ private: 'test-private-identity' }); + await expect(openPasskeyKey(envelope, bytes(prf), 'bob', 'credential-a')).rejects.toThrow(); + await expect(openPasskeyKey(envelope, bytes(prf), 'alice', 'credential-b')).rejects.toThrow(); + await expect(openPasskeyKey(envelope, bytes(crypto.getRandomValues(new Uint8Array(32))), 'alice', 'credential-a')).rejects.toThrow(); + }); + it('rejects malformed PRF outputs', async () => { + await expect(sealPasskeyKey(new Uint8Array(32), new ArrayBuffer(31), 'alice', 'credential')).rejects.toThrow(); + }); +}); diff --git a/web/src/core-bridge/worker/passkeyCrypto.ts b/web/src/core-bridge/worker/passkeyCrypto.ts new file mode 100644 index 0000000..3314399 --- /dev/null +++ b/web/src/core-bridge/worker/passkeyCrypto.ts @@ -0,0 +1,23 @@ +import { bytes, sealJson, openJson, b64, unb64 } from './accountCrypto'; + +const label = 'webdrop/passkey-recovery/v1'; +async function prfKey(result: ArrayBuffer): Promise { + if (result.byteLength !== 32) throw new Error('Passkey恢复输出格式无效'); + const base = await crypto.subtle.importKey('raw', result, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode(label) }, base, + { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); +} +const context = (username: string, credentialId: string) => `${label}:${username}:${credentialId}`; +export async function sealPasskeyKey(material: Uint8Array, result: ArrayBuffer, username: string, credentialId: string): Promise { + if (material.byteLength !== 32) throw new Error('账号恢复密钥格式无效'); + return sealJson({ key: b64(material) }, await prfKey(result), context(username, credentialId)); +} +export async function openPasskeyKey(envelope: string, result: ArrayBuffer, username: string, credentialId: string): Promise { + const value = await openJson(envelope, await prfKey(result), context(username, credentialId)) as { key?: unknown }; + if (typeof value.key !== 'string') throw new Error('账号恢复密钥格式无效'); + const material = unb64(value.key); + try { + if (material.length !== 32) throw new Error('账号恢复密钥格式无效'); + return await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); + } finally { material.fill(0); } +} diff --git a/web/src/core-bridge/worker/passkeys.ts b/web/src/core-bridge/worker/passkeys.ts new file mode 100644 index 0000000..9742c34 --- /dev/null +++ b/web/src/core-bridge/worker/passkeys.ts @@ -0,0 +1,85 @@ +import type { AccountSaltBody, AccountVaultBody, PasskeyFinishRequest, PasskeyInfoBody, PasskeyOptionsBody, PasskeySessionBody } from '../generated/api'; +import { apiFetch } from './http'; +import { b64, unb64, deriveAccountKeys, openJson } from './accountCrypto'; +import { unlockedAccount, usernameOf, unlockAuthenticatedAccount, accountStatus } from './account'; +import { localAccountDevice } from './accountPersistence'; +import { sealPasskeyKey, openPasskeyKey } from './passkeyCrypto'; +import { validateVault } from './accountVault'; + +interface Binding { username: string; material: Uint8Array; options: PasskeyOptionsBody; expiry: number; timer: ReturnType } +let binding: Binding | undefined; +let pendingLogin: PasskeySessionBody | undefined; +export function passkeyUnlockUsername(): string | undefined { return pendingLogin?.username; } +export function clearPasskeyState(): void { + if (binding) { binding.material.fill(0); clearTimeout(binding.timer); } + binding = undefined; + const identity = pendingLogin; pendingLogin = undefined; + if (identity) void apiFetch('/accounts/session', { method: 'DELETE', headers: { 'X-WD-Account': identity.token } }).catch(() => {}); +} +export async function listPasskeys(): Promise { + unlockedAccount(); return apiFetch('/accounts/passkeys').then(r => r.json() as Promise); +} +export async function removePasskey(id: string): Promise { unlockedAccount(); await apiFetch(`/accounts/passkeys/${encodeURIComponent(id)}`, { method: 'DELETE' }); } +export async function beginPasskeyRegistration(password: string): Promise { + clearPasskeyState(); + const account = unlockedAccount(); + const salt = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: account.username }) }).then(r => r.json() as Promise); + const { verifier, key, material } = await deriveAccountKeys(password, unb64(salt.salt), true); + try { + const vault = await apiFetch('/accounts/vault').then(r => r.json() as Promise); + const profile = validateVault(await openJson(vault.vault, key)); + if (profile.keys.publicKey.x !== account.profile.keys.publicKey.x || profile.keys.publicKey.y !== account.profile.keys.publicKey.y) throw new Error('账号解锁信息不匹配'); + const options = await apiFetch('/accounts/passkeys/register/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: account.username, verifier: b64(verifier) }) }).then(r => r.json() as Promise); + binding = { username: account.username, material: material!, options, expiry: Date.now() + 120_000, timer: setTimeout(clearPasskeyState, 120_000) }; + return options; + } catch (error) { material?.fill(0); throw error; } + finally { verifier.fill(0); } +} +export async function finishPasskeyRegistration(id: string, credentialId: string, credential: string, name: string, prf?: ArrayBuffer): Promise { + const account = unlockedAccount(); + if (!binding || binding.username !== account.username || binding.options.id !== id || binding.expiry < Date.now()) throw new Error('Passkey绑定已过期,请重试'); + const keyEnvelope = prf ? await sealPasskeyKey(binding.material, prf, account.username, credentialId) : undefined; + const result = await apiFetch('/accounts/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: account.username, id, credential, name, key_envelope: keyEnvelope } satisfies PasskeyFinishRequest) }).then(r => r.json() as Promise); + if (prf) clearPasskeyState(); + return result; +} +export async function beginPasskeyRecovery(): Promise { + unlockedAccount(); return apiFetch('/accounts/passkeys/recovery/options', { method: 'POST' }).then(r => r.json() as Promise); +} +export async function finishPasskeyRecovery(id: string, credentialId: string, credential: string, prf: ArrayBuffer): Promise { + const account = unlockedAccount(); + if (!binding || binding.username !== account.username || binding.expiry < Date.now()) throw new Error('Passkey绑定已过期,请重试'); + try { + const envelope = await sealPasskeyKey(binding.material, prf, account.username, credentialId); + await apiFetch('/accounts/passkeys/recovery', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: account.username, id, credential, key_envelope: envelope }) }); + } finally { clearPasskeyState(); } +} +export async function beginPasskeyLogin(input: string): Promise { + const username = usernameOf(input); + clearPasskeyState(); + return apiFetch('/accounts/passkeys/login/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }).then(r => r.json() as Promise); +} +export async function finishPasskeyLogin(input: string, id: string, credential: string, prf?: ArrayBuffer, remember = true) { + const username = usernameOf(input); const device = await localAccountDevice(); + const identity = await apiFetch('/accounts/passkeys/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, id, credential, device_id: device.id, device_name: device.name } satisfies PasskeyFinishRequest) }).then(r => r.json() as Promise); + if (identity.username !== username) throw new Error('Passkey账号不匹配'); + pendingLogin = identity; + if (!prf || !identity.key_envelope) return accountStatus(); + try { + const key = await openPasskeyKey(identity.key_envelope, prf, username, identity.credential_id); + const status = await unlockAuthenticatedAccount(identity, key, remember); pendingLogin = undefined; + return { ...status, unlockUsername: undefined }; + } catch (error) { clearPasskeyState(); throw error; } +} +export async function unlockPasskeyLogin(password: string, remember = true) { + const identity = pendingLogin; + if (!identity) throw new Error('请先使用Passkey登录'); + const salt = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: identity.username }) }).then(r => r.json() as Promise); + const { verifier, key } = await deriveAccountKeys(password, unb64(salt.salt)); verifier.fill(0); + try { const status = await unlockAuthenticatedAccount(identity, key, remember); pendingLogin = undefined; return { ...status, unlockUsername: undefined }; } + catch (error) { clearPasskeyState(); throw error; } +} diff --git a/web/src/core-bridge/worker/resourceMetadata.test.ts b/web/src/core-bridge/worker/resourceMetadata.test.ts new file mode 100644 index 0000000..681e0f6 --- /dev/null +++ b/web/src/core-bridge/worker/resourceMetadata.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { isOfflineFailure } from './resourceMetadata'; +import { toPublishedFile } from './fileCatalog'; +import { exportResource } from './accountVault'; +import type { FileItem } from '../types'; +import type { SavedResource } from '../database'; + +describe('resource and rendering boundaries', () => { + it('does not treat authorization, tampering or contract errors as offline cache hits', () => { + expect(isOfflineFailure(new Error('网络连接失败'))).toBe(true); + expect(isOfflineFailure(Object.assign(new Error('forbidden'), { status: 403 }))).toBe(false); + expect(isOfflineFailure(new Error('api_contract_mismatch'))).toBe(false); + expect(isOfflineFailure(new Error('bad_meta'))).toBe(false); + }); + it('publishes only resource metadata, excluding UI state and download counters', () => { + const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1, deviceId: 'device', + gated: true, remaining: 5, preview: 'text', state: { kind: 'receiving', progress: 0.2 }, unseen: true, versions: [] }; + const wire = toPublishedFile(item); + expect(wire.name).toBe('secret.txt'); + for (const field of ['state', 'preview', 'remaining', 'versions', 'unseen']) expect(wire).not.toHaveProperty(field); + }); + it('never synchronizes draft content or server structure snapshots inside the key vault', () => { + const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2, + endpointSecret: new Uint8Array(32), seats: 0, draftItems: [], manifestEntries: [], + workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } }; + const exported = exportResource(resource); + for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field); + }); +}); diff --git a/web/src/core-bridge/worker/resourceMetadata.ts b/web/src/core-bridge/worker/resourceMetadata.ts new file mode 100644 index 0000000..f502e81 --- /dev/null +++ b/web/src/core-bridge/worker/resourceMetadata.ts @@ -0,0 +1,19 @@ +import type { ChannelInfo, ShareInfo } from '../generated/api'; +import type { SavedResource } from '../database'; + +/** Server-owned fields only: preserve local credentials, drafts, and user edit timestamps. */ +export function channelCache(raw: ChannelInfo): Partial { + return { name: raw.settings.name ?? undefined, creatorAccount: raw.creator_account ?? undefined, + createdAt: raw.created_at, expiresAt: raw.expires_at, seats: raw.settings.seats, + maxDownloads: raw.settings.file_max_downloads, + ttlSeconds: raw.settings.ttl_seconds, channelMetadata: raw }; +} +export function shareCache(raw: ShareInfo): Partial { + if (raw.state !== 'draft' && raw.state !== 'sealed') throw new Error('分享状态无效'); + return { createdAt: raw.sealed_at ?? raw.created_at, expiresAt: raw.expires_at, state: raw.state, + ttlSeconds: raw.settings.ttl_seconds, maxVisitors: raw.settings.max_visitors, + maxDownloads: raw.settings.file_max_downloads || null, shareMetadata: raw }; +} +export function isOfflineFailure(error: unknown): boolean { + return error instanceof Error && error.message === '网络连接失败'; +} diff --git a/web/src/core-bridge/worker/senderSnapshot.ts b/web/src/core-bridge/worker/senderSnapshot.ts index 48a681c..98c5b27 100644 --- a/web/src/core-bridge/worker/senderSnapshot.ts +++ b/web/src/core-bridge/worker/senderSnapshot.ts @@ -1,8 +1,7 @@ import type { SenderInfo } from '../types'; import { accountDisplay } from './accountSession'; -let lastAvatar: string | undefined; -let thumbnail: Promise | undefined; +const thumbnails = new Map>(); async function compactAvatar(value: string): Promise { if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') return value.length <= 8000 ? value : undefined; try { @@ -23,7 +22,15 @@ async function compactAvatar(value: string): Promise { export async function senderSnapshot(): Promise { const sender = accountDisplay(); if (!sender) return undefined; if (!sender.avatar) return { username: sender.username, displayName: sender.displayName }; - if (lastAvatar !== sender.avatar || !thumbnail) { lastAvatar = sender.avatar; thumbnail = compactAvatar(sender.avatar); } - const avatar = await thumbnail; + const avatar = await avatarThumbnail(sender.avatar); return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) }; } + +export function avatarThumbnail(value: string): Promise { + let thumbnail = thumbnails.get(value); + if (!thumbnail) { + if (thumbnails.size >= 32) thumbnails.clear(); + thumbnail = compactAvatar(value); thumbnails.set(value, thumbnail); + } + return thumbnail; +} diff --git a/web/src/core-bridge/worker/sessions.ts b/web/src/core-bridge/worker/sessions.ts index ee4f469..f9baeb6 100644 --- a/web/src/core-bridge/worker/sessions.ts +++ b/web/src/core-bridge/worker/sessions.ts @@ -4,12 +4,16 @@ import { fail } from './http'; import { toCoreLink } from './links'; import { clearFileCatalog } from './fileCatalog'; import { clearTemporaryDownloads } from './downloadStorage'; +import { clearWorkspaceStructures } from './workspaceStructure'; +import { attachDataKeys, clearDataKeys } from './channelDataKeys'; export const sessions = new Map(); export const readers = new Map>(); export const active = new Map(); export const blobs = new Map(); const opening = new Map>(); export function clearResourceSessions(): void { + clearDataKeys(); + clearWorkspaceStructures(); clearFileCatalog(); void clearTemporaryDownloads().catch(() => {}); for (const controller of active.values()) controller.abort(); @@ -31,5 +35,6 @@ export async function getSession(id: string): Promise<{ session: ChannelSession; if (resource.localScope !== currentResourceScope()) { session.dispose(); throw new Error('账号已切换'); } sessions.set(id, session); } + attachDataKeys(session, resource); return { session, resource }; } diff --git a/web/src/core-bridge/worker/shareRead.ts b/web/src/core-bridge/worker/shareRead.ts index 7bc9e7e..eb31353 100644 --- a/web/src/core-bridge/worker/shareRead.ts +++ b/web/src/core-bridge/worker/shareRead.ts @@ -26,7 +26,7 @@ export async function connectShare(id: string): Promise { session = (await getSession(id)).session; if (controller.signal.aborted) return; const share = toShareInfo(raw, resource); - resource.state = raw.state; + resource.state = share.state; resource.expiresAt = raw.expires_at; await saveResource(resource); publishShare(id, { t: 'share', share }); diff --git a/web/src/core-bridge/worker/shareResources.ts b/web/src/core-bridge/worker/shareResources.ts index 513d08b..5924673 100644 --- a/web/src/core-bridge/worker/shareResources.ts +++ b/web/src/core-bridge/worker/shareResources.ts @@ -1,5 +1,8 @@ import { ChannelSession } from '@webdrop/core'; -import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; +import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; +import { shareCache, isOfflineFailure } from './resourceMetadata'; +import type { ShareSettingsPatch } from '../generated/api'; +import { readShareInfo } from './shareState'; import type { Share, ShareSettings } from '../types'; import { active, blobs, getSession, sessions } from './sessions'; @@ -29,11 +32,12 @@ export async function createShare(settings: ShareSettings, captchaToken?: string }); const raw = await response.json() as ShareWire; const resource: SavedResource = { + ...shareCache(raw), id: raw.id, kind: 'share', mode: 'enc', role: 'owner', mine: true, link: toShareLink(session.link(self.location.origin)), ownerLink: toShareLink(session.link(self.location.origin, 'owner')), endpointSecret: Uint8Array.from(session.endpointSecret), - createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0, membersCanUpload: false, + createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0, state: 'draft', maxVisitors: raw.settings.max_visitors, ttlSeconds: raw.settings.ttl_seconds, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads, @@ -56,10 +60,11 @@ export async function joinShare(input: { link: { id: string; key: string } }): P const raw = await readJson(`/shares/${input.link.id}`, session.authHeaders('read')); if (raw.state !== 'sealed') throw fail(404, 'not_found'); const resource: SavedResource = { + ...shareCache(raw), id: input.link.id, kind: 'share', mode: 'enc', role: 'member', mine: false, link: toShareLink(makeLink(self.location.origin, input.link.id, { k: input.link.key })), endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: raw.created_at, expiresAt: raw.expires_at, - seats: 0, membersCanUpload: false, state: raw.state, + seats: 0, state: raw.state, ttlSeconds: raw.settings.ttl_seconds, maxVisitors: raw.settings.max_visitors, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads, }; @@ -75,19 +80,15 @@ export async function joinShare(input: { link: { id: string; key: string } }): P export async function updateShareSettings(id: string, patch: Partial): Promise { const { session, resource } = await getSession(id); if (resource.kind !== 'share' || !session.can('owner')) throw new Error('此分享不再可修改'); - const body: Record = {}; + const body: ShareSettingsPatch = {}; if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds; if (patch.maxVisitors !== undefined) body.max_visitors = patch.maxVisitors; if (patch.maxDownloads !== undefined) body.file_max_downloads = patch.maxDownloads ?? 0; const raw = await apiFetch(`/shares/${id}/settings`, { method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then((response) => response.json() as Promise); - resource.expiresAt = raw.expires_at; - resource.ttlSeconds = raw.settings.ttl_seconds; - resource.maxVisitors = raw.settings.max_visitors; - resource.maxDownloads = raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads; - await saveResource(resource); - return toShareInfo(raw, resource); + const updated = await cacheResource(resource, shareCache(raw)); + return toShareInfo(raw, updated); } export async function removeShare(id: string): Promise { @@ -101,10 +102,25 @@ export async function removeShare(id: string): Promise { export async function listSavedShares(): Promise { const saved = await listResources('share'); - return saved.map((resource) => ({ + const rows = await Promise.all(saved.map(async (resource): Promise => { + try { + const { session } = await getSession(resource.id); + const raw = await readShareInfo(session, resource); + const info = toShareInfo(raw, resource); + await cacheResource(resource, shareCache(raw)); + return { ...info, dataSource: 'server' }; + } catch (error) { + if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null; + if (!isOfflineFailure(error)) throw error; + if (resource.shareMetadata) return { ...toShareInfo(resource.shareMetadata, resource), dataSource: 'cache' }; + return { id: resource.id, state: resource.state ?? 'draft', mine: resource.mine ?? false, link: resource.link, createdAt: resource.createdAt, expiresAt: resource.expiresAt, - ttlSeconds: resource.ttlSeconds ?? 86400, maxVisitors: resource.maxVisitors ?? 0, + ttlSeconds: resource.ttlSeconds ?? Math.max(0, (resource.expiresAt - resource.createdAt) / 1000), maxVisitors: resource.maxVisitors ?? 0, maxDownloads: resource.maxDownloads ?? null, + dataSource: 'cache', + }; + } })); + return rows.filter((row): row is Share => row !== null); } diff --git a/web/src/core-bridge/worker/transfer.ts b/web/src/core-bridge/worker/transfer.ts index e9d759c..85d910f 100644 --- a/web/src/core-bridge/worker/transfer.ts +++ b/web/src/core-bridge/worker/transfer.ts @@ -1,6 +1,10 @@ import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core'; -import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, type SavedResource } from '../database'; +import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type SavedResource } from '../database'; +import { registerItemLocation } from './workspaceStructure'; +import { verifyObjectEvent } from './channelDataKeys'; import { apiFetch, apiUpload, type ApiError } from './http'; +import { signContentEvent } from './accountSigning'; +import { accountToken } from './accountSession'; import { serial } from './serial'; import { storeVerifiedDownload } from './downloadStorage'; @@ -13,23 +17,40 @@ export async function persistPlan( resource: SavedResource, plan: ItemPlan, onProgress?: (frames: number, bytes: number) => void, + signedEvent?: { channelId: string; domain: 'chat' | 'files'; epoch: number; eventKind: string; targetItemId?: string }, ): Promise<{ frames: number; bytes: number }> { const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1); let frames = 0; let bytes = 0; - for await (const frame of plan.frames) { + const frameDigests: Uint8Array[] = []; + let pending: { index: number; bytes: Uint8Array; extraHeaders?: Record } | undefined; + const persistFrame = async (frame: NonNullable, extraHeaders: Record = {}) => { await queueFrame({ key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`, resourceId: resource.id, itemId: plan.itemId, index: frame.index, bytes: frame.bytes, - extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {}, + extraHeaders: { ...(frame.index === 0 ? plan.frame0Headers : {}), ...frame.extraHeaders, ...extraHeaders }, createdAt: createdAt + frame.index, }); frames += 1; bytes += frame.bytes.byteLength; onProgress?.(frames, bytes); + }; + for await (const frame of plan.frames) { + if (pending) await persistFrame(pending); + const frameBuffer = frame.bytes.slice().buffer as ArrayBuffer; + frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frameBuffer))); + pending = frame; + } + if (pending) { + const headers = signedEvent && resource.kind === 'channel' && accountToken() + ? await signContentEvent({ spaceId: resource.id, channelId: signedEvent.channelId, domain: signedEvent.domain, + epoch: signedEvent.epoch, itemId: plan.itemId, eventKind: signedEvent.eventKind, + ...(signedEvent.targetItemId ? { targetItemId: signedEvent.targetItemId } : {}), frameDigests }) + : {}; + await persistFrame(pending, headers); } return { frames, bytes }; } @@ -56,7 +77,20 @@ async function flush( ): Promise { for (;;) { const frame = await nextQueuedFrame(resource.id); - if (!frame) return true; + if (!frame) { + if (resource.kind !== 'channel') return true; + const location = await nextQueuedLocation(resource.id); + if (!location) return true; + try { + await registerItemLocation(resource.id, { id: location.itemId, threadId: location.threadId, folderId: location.folderId }); + await dequeueLocation(location.key); + } catch (error) { + if (error instanceof Error && error.message === '网络连接失败') return false; + if ((error as ApiError).status === 404 || (error as ApiError).status === 410) await dequeueLocation(location.key); + throw error; + } + continue; + } const role = resource.kind === 'share' ? 'owner' : writeRole(session); const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`; let maxLoadedBytes = 0; @@ -104,5 +138,9 @@ export async function downloadDecrypted( const expectedId = fromBase64Url(itemId, 16); if (!expectedId) throw new IntegrityError('bad_meta'); const decoded = session.decryptItemStream(response.body, { itemId: expectedId }); - return storeVerifiedDownload(decoded, expectedSize); + return storeVerifiedDownload(decoded, expectedSize, undefined, async result => { + if (resource.kind === 'channel' && (!result.ciphertextManifestHash + || !await verifyObjectEvent(resource, session, itemId, result.ciphertextManifestHash))) + throw new IntegrityError('auth_failed'); + }); } diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/core-bridge/worker/wire.ts index 6ba4f9c..ca55318 100644 --- a/web/src/core-bridge/worker/wire.ts +++ b/web/src/core-bridge/worker/wire.ts @@ -1,40 +1,21 @@ import type { SavedResource } from '../database'; import type { ChannelInfo, Share, ShareSettings } from '../types'; -export interface ChannelWire { - creator_account?: string; - password_required?: boolean; - id: string; - mode: 'enc' | 'raw'; - settings: { name?: string; join_policy?: 'open' | 'invite'; allow_guests?: boolean; seats: number; members_can_upload: boolean; delete_policy: 'owner_only' | 'own' | 'anyone'; ttl_seconds: number; max_items: number; file_max_downloads: number }; - seats_used: number; - oldest_seq: number; - last_seq: number; - created_at: number; - expires_at: number; -} - -export interface ShareWireItem { item_id: string; bytes: number; frames: number; complete?: boolean; max_downloads: number; downloads_left: number | null } -export interface ShareWire { - id: string; - state: 'draft' | 'sealed'; - expires_at: number; - created_at: number; - sealed_at?: number; - manifest_item_id?: string; - settings: { ttl_seconds: number; max_visitors: number; file_max_downloads: number }; - items: ShareWireItem[]; -} +import type { ChannelInfo as ChannelWire, ShareInfo as ShareWire, ShareItemInfo as ShareWireItem } from '../generated/api'; +export type { ChannelWire, ShareWire, ShareWireItem }; export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo { + const joinPolicy = raw.settings.join_policy; + if (joinPolicy !== 'open' && joinPolicy !== 'invite') throw new Error('空间加入规则无效'); return { id: raw.id, - creatorAccount: raw.creator_account, - joinPolicy: raw.settings.join_policy ?? 'open', allowGuests: raw.settings.allow_guests ?? true, passwordRequired: raw.password_required ?? false, - name: raw.settings.name, + creatorAccount: raw.creator_account ?? undefined, + joinPolicy, allowGuests: raw.settings.allow_guests, passwordRequired: raw.password_required, + name: raw.settings.name ?? undefined, mode: raw.mode, - role: saved.role, - canUpload: saved.role === 'owner' || raw.settings.members_can_upload, + role: raw.can_manage_structure ? 'owner' : 'member', + canUpload: raw.can_upload, + canManageStructure: raw.can_manage_structure, seats: raw.settings.seats, - seatsUsed: raw.seats_used, + membersUsed: raw.seats_used, membersCanUpload: raw.settings.members_can_upload, ttlSeconds: raw.settings.ttl_seconds, maxDownloads: raw.settings.file_max_downloads, @@ -46,6 +27,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn } export function toShareInfo(raw: ShareWire, saved: SavedResource): Share { + if (raw.state !== 'draft' && raw.state !== 'sealed') throw new Error('分享状态无效'); return { id: raw.id, state: raw.state, diff --git a/web/src/core-bridge/worker/workspaceRecords.test.ts b/web/src/core-bridge/worker/workspaceRecords.test.ts new file mode 100644 index 0000000..d686383 --- /dev/null +++ b/web/src/core-bridge/worker/workspaceRecords.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { ChannelSession, collectFrames } from '@webdrop/core'; +import { readResourceRecord } from './workspaceRecords'; + +const threadId = '15e7fb09-984d-47d4-8e74-477c7d69c58c'; +async function decode(record: Record) { + const { session } = await ChannelSession.create(); + try { + const frames = await collectFrames(session.encryptBytes(new TextEncoder().encode(JSON.stringify({ app: 'webdrop.workspace', v: 1, ...record })), + { kind: 'other' }, { control: true, ts: 1 })); + const event = (await session.createItemReader().pushFrame(frames[0]!.bytes)).find(row => row.type === 'item'); + if (!event || event.type !== 'item') throw new Error('Missing decrypted record'); + return readResourceRecord(event.item); + } finally { session.dispose(); } +} +describe('encrypted resource records', () => { + it('ignores client-defined account and structure operations', async () => { + for (const op of ['thread.create', 'group.create', 'folder.create', 'items.move', 'item.author']) expect(await decode({ op })).toBeNull(); + }); + it('renders authenticated file metadata without accepting client-supplied identity or UI state', async () => { + const value = await decode({ op: 'file.published', item: { id: 'AAAAAAAAAAAAAAAAAAAAAA', threadId, kind: 'file', name: 'private.txt', mime: 'text/plain', size: 10, gated: true, + author: { username: 'fake', displayName: '伪造资料' }, state: { kind: 'tampered' } } }); + expect(value).toMatchObject({ t: 'file-catalog', item: { name: 'private.txt', state: { kind: 'complete' } } }); + if (value && value.t === 'file-catalog') expect(value.item).not.toHaveProperty('author'); + }); + it('rejects malformed encrypted file metadata', async () => { + expect(await decode({ op: 'file.published', item: { id: 'invalid', threadId, kind: 'file', size: -1 } })).toMatchObject({ t: 'error' }); + }); +}); diff --git a/web/src/core-bridge/worker/workspaceRecords.ts b/web/src/core-bridge/worker/workspaceRecords.ts new file mode 100644 index 0000000..7767319 --- /dev/null +++ b/web/src/core-bridge/worker/workspaceRecords.ts @@ -0,0 +1,26 @@ +import type { ReceivedItem } from '@webdrop/core'; +import type { WorkspaceEvent } from '../types'; +import { WORKSPACE_APP, type PublishedFile } from '../protocol/workspace'; +import { previewKindOf } from '@/lib/fileKind'; + +const isId = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value); +const isItemId = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); + +/** Decode encrypted resource metadata only; account and structure data come from the backend. */ +export function readResourceRecord(item: ReceivedItem): WorkspaceEvent | false | null { + if (item.meta?.kind !== 'other') return false; + let value: unknown; + try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; } + if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== WORKSPACE_APP) return false; + const row = value as Record; + if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '资源记录格式无效,已拒绝应用。' }; + if (row.op !== 'file.published') return null; + const file = row.item as PublishedFile | undefined; + if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId)) + || file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256 + || !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean') + return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' }; + return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, folderId: file.folderId, + name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备', + gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } }; +} diff --git a/web/src/core-bridge/worker/workspaceStructure.ts b/web/src/core-bridge/worker/workspaceStructure.ts new file mode 100644 index 0000000..f5a1902 --- /dev/null +++ b/web/src/core-bridge/worker/workspaceStructure.ts @@ -0,0 +1,57 @@ +import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest } from '../generated/api'; +import type { FileItem } from '../types'; +import { getResource, cacheResource } from '../database'; +import { getSession } from './sessions'; +import { apiFetch } from './http'; +import { publishWorkspace } from './presentation'; +import { avatarThumbnail } from './senderSnapshot'; + +const current = new Map(); +export function clearWorkspaceStructures(): void { current.clear(); } +async function publish(id: string, body: WorkspaceBody): Promise { + const previous = current.get(id); + if (previous && previous.structure.revision > body.structure.revision) return previous; + const previousPeople = new Map(previous?.people.map(person => [person.username, person])); + body = { ...body, people: body.people.map(person => { + const newer = previousPeople.get(person.username); + return newer && newer.modifiedAt > person.modifiedAt ? newer : person; + }) }; + current.set(id, body); + const resource = await getResource(id); + if (resource) await cacheResource(resource, { workspaceMetadata: body }); + const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await avatarThumbnail(person.avatar) : undefined }))); + if (current.get(id) !== body) return current.get(id) ?? body; + publishWorkspace(id, { t: 'structure', state: body.structure, people }); + return body; +} +export async function loadWorkspace(id: string): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise); + return publish(id, body); +} +export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT', value: unknown): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/${path}`, { + method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value), + }).then(r => r.json() as Promise); + await publish(id, body); + return body; +} +export async function createWorkspaceChannel(id: string, value: WorkspaceChannelRequest): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/channels`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) }) + .then(r => r.json() as Promise); + await publish(id, body.workspace); + return body.channel.id; +} +export async function createWorkspaceGroup(id: string, value: WorkspaceNameRequest): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/groups`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) }) + .then(r => r.json() as Promise); + await publish(id, body.workspace); + return body.group.id; +} +export async function registerItemLocation(id: string, item: Pick): Promise { + if (!item.threadId) return; + await changeWorkspace(id, 'locations', 'PUT', { itemIds: [item.id], threadId: item.threadId, folderId: item.folderId } satisfies WorkspaceMoveRequest); +} diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx index 1d43952..11e1c13 100644 --- a/web/src/features/channel/ChannelSettingsSheet.tsx +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -5,12 +5,9 @@ import { Sheet } from '@/ui/Sheet'; import styles from './ChannelSettingsSheet.module.css'; import { ChannelInvitations } from './ChannelInvitations'; -const TTL_OPTIONS = [ - { label: '1 小时', value: 3600 }, - { label: '24 小时', value: 86400 }, - { label: '7 天', value: 604800 }, -]; -const DOWNLOAD_OPTIONS = [0, 1, 3, 5, 10, 20]; +import { useServerSettings } from '@/store/server'; +import type { ServerInfoBody } from '@/core-bridge/generated/api'; +import { ttlLabel, withCurrent } from '@/lib/settingOptions'; interface Props { channel: ChannelInfo; @@ -18,23 +15,28 @@ interface Props { onSave: (patch: ChannelSettingsPatch) => Promise; } -export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) { +export function ChannelSettingsSheet(props: Props) { + const { info, error, load } = useServerSettings(); + if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined) + return

{error ?? '正在读取空间设置…'}

+ {error ? : null}
; + return ; +} +function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) { const owner = channel.role === 'owner'; const [name, setName] = useState(channel.name ?? ''); const [seats, setSeats] = useState(channel.seats); - const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload ?? channel.canUpload ?? true); - const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds ?? 86400); - const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads ?? 10); - const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy ?? 'open'); - const [allowGuests, setAllowGuests] = useState(channel.allowGuests ?? true); + const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!); + const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!); + const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!); + const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!); + const [allowGuests, setAllowGuests] = useState(channel.allowGuests!); const [joinPassword, setJoinPassword] = useState(''); const [clearPassword, setClearPassword] = useState(false); const [saving, setSaving] = useState(false); const [failed, setFailed] = useState(false); - const ttlOptions = TTL_OPTIONS.some((option) => option.value === ttlSeconds) - ? TTL_OPTIONS - : [{ label: `当前设置 (${ttlSeconds} 秒)`, value: ttlSeconds }, ...TTL_OPTIONS]; - const downloadOptions = [...new Set([...DOWNLOAD_OPTIONS, fileMaxDownloads])].sort((a, b) => a - b); + const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds); + const downloadOptions = withCurrent(info.choices.file_max_downloads, fileMaxDownloads); async function save() { if (!owner || saving) return; @@ -61,14 +63,14 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) { @@ -99,7 +101,7 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) { {owner ? ( <> {failed ?

保存失败,请检查名称和设置后重试。

: null} - + ) :

只有所有者可以修改空间设置。

} diff --git a/web/src/features/channel/ChannelSwitcher.tsx b/web/src/features/channel/ChannelSwitcher.tsx index 2af53b7..6c6e09e 100644 --- a/web/src/features/channel/ChannelSwitcher.tsx +++ b/web/src/features/channel/ChannelSwitcher.tsx @@ -8,12 +8,7 @@ import styles from './ThreadSidebar.module.css'; export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) { const channels = useChannels((s) => s.list); const username = useAccount((s) => s.username); - const personal = useAccount((s) => s.personal); return
-
空间
@@ -139,16 +83,6 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) { : null}
- {import.meta.env.DEV ? ( -
-

演示

-
- - - -
-
- ) : null} ); } diff --git a/web/src/features/channel/FileExplorer.tsx b/web/src/features/channel/FileExplorer.tsx index ac287ed..0b1eac7 100644 --- a/web/src/features/channel/FileExplorer.tsx +++ b/web/src/features/channel/FileExplorer.tsx @@ -21,6 +21,7 @@ interface Props { selectedId?: string; checked: ReadonlySet; enabled: boolean; + structureEnabled: boolean; onFolder: (id?: string) => void; onSelect: (id?: string) => void; onOpen: (id: string) => void; @@ -71,7 +72,7 @@ export function FileExplorer(props: Props) {
{ if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} /> - +