Files
WebDrop/docs/architecture.md
justme 12980ba6fe
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
docs: describe login-only spaces and the 8.0.0 contract [CI]
2026-10-16 06:46:16 +08:00

14 KiB

架构

公共业务数据(空间设置、成员、设备、邀请、默认值和部署限制)由后端 DTO 和服务端响应定义;前端类型由构建工具生成。本地资源保险库只携带访问凭据与离线副本,不覆盖在线响应中的设置。在线刷新不改变资源的用户修改时间,也不产生删除标记;只在网络故障时回退缓存。

账户昵称头像、空间、频道、分组、文件夹和文件归属由后端管理。空间结构变更经服务端验证关联和权限后持久化,再通知设备重新拉取。新空间和初始“常规”频道在一个事务中创建。

消息、文件内容和文件名称等资源元数据由客户端加密,后端保存密文和加密密钥包。账号保险库保存解密凭据,不再作为个人资料或空间结构的数据源。

不提供旧数据迁移或导入接口。新空间结构由后端直接创建和管理,客户端不解码或重放旧结构控制记录。

1. 组件

账号、设备、频道、thread 与内容加密的统一边界见 account-channel-architecture.md。该文档明确账号成员与设备会话分离、按账号领取频道密钥包及加密文件目录。

浏览器 (React, 独立托管或 API 同源嵌入)          服务端 (.NET 10, NativeAOT)
┌──────────────────────────────┐             ┌─────────────────────────────┐
│ UI: 频道、条目、预览、二维码  │             │ Minimal API + WebSocket/SSE │
│ @webdrop/core (加解密、帧)    │  密文 + 令牌 │ 鉴权: 只比对令牌哈希         │
│ Worker: 流式加解密            │◄───────────►│ 日志: 分配 seq、幂等、推送   │
│ IndexedDB: 发送队列、本地收藏 │             │ BackgroundService: 过期清理  │
│ OPFS: 逐块写入临时文件       │             └──────┬───────────┬──────────┘
└──────────────────────────────┘                    │           │
                                              SQLite       IBlobStore
                                         (频道、账号密文库)  (磁盘,预留 S3)

服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 WebDrop.Frontend 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。

2. 技术栈

层 选型
服务端 .NET 10,ASP.NET Core Minimal API,原生 WebSocket,Microsoft.Data.Sqlite
前端 Vite + React + TypeScript,WebCrypto
共享核心 @webdrop/core(TS,纯函数,无 UI 依赖),将来 CLI 直接复用
测试 xUnit、Vitest、Playwright(含断网恢复)

3. NativeAOT 约束(服务端)

服务端必须能 PublishAot=true 发布成单个原生可执行文件,因此:

约束 做法
构建器 用 WebApplication.CreateSlimBuilder,不用默认的 CreateBuilder
路由 只用 Minimal API,不用 MVC 控制器,不用反射式模型绑定
JSON 全部通过 System.Text.Json 源生成(JsonSerializerContext),禁用反射序列化
实时 不使用 SignalR(不支持 AOT),用原生 WebSocket 加 SSE,这与协议设计一致
数据访问 不使用 EF Core,用 Microsoft.Data.Sqlite 和手写 SQL,结果映射用显式代码
依赖注入 只用简单的构造函数注入,避免开放泛型和反射式注册
日志 简单控制台日志格式,不用依赖反射的格式化器
全球化 InvariantGlobalization=true
库 所有项目标记 IsAotCompatible=true,把 IL2026、IL3050 等 AOT 和裁剪警告视为错误
HTTP/3 CreateSlimBuilder 不含 HTTP/3。服务进程本身暂不内置 QUIC,由反向代理终结 HTTP/3,后端用 HTTP/1.1 或 HTTP/2;WebDrop:EnableHttp3 只是在 /info 里通告。需要内置时再接入 Kestrel 的 QUIC(依赖原生 msquic)
密码学 内容加解密在客户端;服务端做令牌 SHA-256、登录/加入密码的加盐 PBKDF2 与常量时间比较,全部兼容 AOT

验证结果(win-x64,.NET 10.0.204):

  • Slim builder、Minimal API、原生 WebSocket、SSE、BackgroundService、Microsoft.Data.Sqlite(随包的 e_sqlite3 原生库,SQLite 3.53)在 NativeAOT 下都能正常运行。
  • 完整服务 AOT 发布零警告(TreatWarningsAsErrors 已开启),产物约 12 MB 单文件,另带 1.9 MB 的 e_sqlite3.dll。
  • scripts/smoke.ps1 对原生可执行文件跑完整流程(加入、追加、幂等、日志、WebSocket 推送、GATED 下载与上限、错误码、删除,并检查数据目录里没有令牌原值),全部通过。
  • linux-x64 已验证(WSL Debian,.NET 10.0.300,clang + zlib),并在引入 Cap 验证码之后重新验证:130 个测试在 Linux 上全部通过,AOT 发布零警告(约 14.8 MB),scripts/smoke.py 对 Linux 原生二进制的端到端冒烟通过。@webdrop/core 也在 Linux 上对着 Linux 的 AOT 服务端跑过端到端(Node 20 没有全局 WebSocket,其中 3 个 WebSocket 用例被明确跳过,它们在 Windows 上通过)。
  • linux-arm64 尚未验证,需要在 arm64 机器或 CI 上跑。每次构建都应执行 AOT 发布加冒烟。
  • Windows 上 AOT 发布需要 vswhere.exe 在 PATH 里,否则 ILCompiler 找不到 MSVC 链接器。把 %ProgramFiles(x86)%\Microsoft Visual Studio\Installer 加进 PATH,或在 VS 的开发者命令行里发布。
  • 注意 Base64Url.TryDecodeFromChars 遇到非法输入会抛出 FormatException,不是返回 false。需要不抛异常的解码时用返回 OperationStatus 的 DecodeFromChars(Codec.TryDecodeExact 已按此实现)。

4. 存储模型

应用契约 2 使用 DataDir/v2/webdrop.db 和 DataDir/v2/blobs,仅包含当前表结构,不维护旧 schema/旧账号保险库/旧频道认领/旧明文负载的迁移或兼容模型。开发数据可以重新创建,历史目录不自动导入。浏览器采用新的按账号/访客隔离的数据库命名空间。

SQLite 存元数据和小帧,磁盘存大帧。

表 字段
channels id PK, mode(内容加密选项), creator_account, settings, 能力令牌哈希, 加入密码哈希/salt, 时间、序号、字节数
channel_members 频道 ID + username,频道内 owner/member 角色、加入时间;创建者自动为 owner
channel_invitations 频道 ID + username、客户端加密的邀请 payload、时间
channel_join_denials 被移除的账号或设备标识,再次邀请账号时清除账号禁入
accounts/account_sessions 登录 salt、登录凭据慢哈希及独立 salt、邀请公钥、保险库密文/revision;随机 session token 的 SHA-256 与过期时间
shares id PK(与频道共用全局 id 空间), settings(JSON), read_hash, owner_hash(封存时清空), creator_endpoint_hash, created_at, expires_at, sealed_at, manifest_item_id, bytes
channel_members 频道成员(账号):channel_id, username, role, joined_at。v8 起不再有访客席位,旧库里的 seats 表在启动时丢弃
share_visitors share_id, endpoint_hash, joined_at;PK(share_id, endpoint_hash),创建者终端不写入此表
items channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label;PK(channel_id, item_id)。完整与否由 final_index 和 frame_count 推出,是否进入宽限期由 close_deadline 表示
share_items 分享条目的帧状态和下载次数,独立于频道条目
log channel_id, seq, kind, item_id, frame_index, aux, created_at;PK(channel_id, seq)。aux 存墓碑原因
frames channel_id, item_id, frame_index, seq, length, digest(SHA-256), inline_blob 或 blob_key;PK(channel_id, item_id, frame_index)。digest 用于幂等比较,不必读 blob
share_frames 分享帧的封装、摘要与 blob 引用,独立于频道帧,不进入频道日志

IBlobStore:PutAsync、ReadAsync、Delete、DeletePrefix、Enumerate。磁盘实现的键是 {channelHex}/{itemHex}/{index}.{digest16}:

  • 目录名用小写十六进制,不用 base64url。频道 id 由客户端选择,而 Windows 文件系统不区分大小写,用 base64url 会让攻击者构造只差大小写的 id,与别人共用目录。
  • 键里含内容摘要:并发写入内容不同的同一帧,不会互相覆盖;文件已存在就意味着内容相同,直接跳过。
  • 先写临时文件再重命名。小于 64 KiB 的帧直接放 SQLite 的 BLOB。以后可以补 S3 实现,不改上层。

写入顺序:大帧先写 blob,再在单个事务内分配 seq、写日志、写帧、更新条目和频道。崩溃只会留下孤立 blob,不会出现数据库指向不存在文件的情况。冲突或失败时,只有「没有任何帧引用」的 blob 才会被删除。last_seq 在同一事务内递增,seq 因此严格单调且无并发重复。

并发模型:写入由单个 SQLite 连接加锁串行执行(WAL 模式),读取使用独立连接,每次 Read 在显式事务里,看到一致的快照。订阅枢纽 ChannelHub 只传递「有变化」的信号,订阅者被唤醒后自己按游标读日志,所以不存在回放与实时之间漏帧的竞态,也无需为慢客户端缓冲数据。代价是目前只支持单实例部署。

便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不登记成员或日志;封存时在单个事务里清空 owner_hash、记录清单 id 并启动 TTL。

后台服务每 30 秒清理:过期条目、下载达上限且宽限期已过的条目、过期频道,每次产生对应的墓碑并删除密文;每 20 轮额外清理超过 10 分钟的孤立 blob。超出 max_items 的淘汰和新快照取代旧快照,在追加时同步完成。

5. 部署

  • 安全隔离部署时,API 与前端使用不同域名:前端静态资源可以放在独立静态主机,API 被攻破不影响前端代码。
  • Compose 单机模式可以把 Vite dist 嵌入 WebDrop.Frontend,由 API 同源提供页面;此模式简化部署,但 API 主机也是前端代码的信任根。
  • 独立托管时,前端资源应使用 HTTPS 发布;若部署 SRI,发布时公布构建哈希。
  • 响应头:Referrer-Policy: no-referrer、严格 CSP、X-Content-Type-Options: nosniff。
  • 反向代理不得记录请求头(令牌)和完整 URL。
  • 服务端日志不含频道 id 明文,只含截断后的哈希。

6. 前端要点

  • 取出 fragment 后立即 history.replaceState 清掉密钥;K 导入为不可导出的 CryptoKey。
  • 加解密在 Worker 里流式处理,文件不整份进内存。
  • 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
  • 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
  • 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
  • 首页先注册/登录;频道链接只对登录账号有效(未登录先登录),便捷分享链接任何人可读。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 /accounts/devices 管理)。所有频道写入都检查账号会话、频道角色、成员名额和能力权限。
  • IndexedDB 按账号分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
  • 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。

7. 阶段划分

阶段 内容 验收
0 文档、test-vectors/ 向量由独立脚本生成并人工复核
1 @webdrop/core:密钥派生、封装、加解密、padding、条目装配 已完成:Vitest 通过全部向量和负例,并对着真实服务端端到端通过
2 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、成员、在线状态、便捷分享访客名额、清理 xUnit 通过;目标平台 AOT 发布并冒烟通过
3 前端:创建频道、文本和 JSON 实时同步、断线重连、发送队列、「我的频道」 Playwright:双浏览器内容一致,断网恢复后无丢失无重复,抓包无明文和 K
4 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 100 MB 文件往返一致,下载计数正确
5 GB 级文件:并行上传和断点续传 中途断网后续传成功,内存占用平稳
6 PWA、Web Share Target、二维码和短配对码(PAKE) 手机端走通
7 账号登录、客户端加密资源保险库 已实现客户端派生口令 verifier/保险库密钥、SQLite opaque vault、资源同步;口令丢失时明确告知无法恢复
8 空间、频道、分组、文件夹与聊天 结构和作者归属由后端管理,客户端加解密资源并渲染后端快照

每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。

8. 验收时的检查清单

  • dotnet test、vitest、Playwright 全部通过。
  • 对 SQLite 文件和 blob 目录检查资源内容与文件名明文关键字,必须查不到;账号资料和业务结构按设计对后端可见。
  • 服务端日志中没有 K、令牌和频道 id 明文。
  • 抓包确认资源上传请求不包含 K 或资源明文;账户资料和结构请求使用后端业务接口。
  • AOT 发布产物能在没有 .NET 运行时的机器上运行。

界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部资源 Channel 与帧元数据 threadId 保留,分别映射空间/频道;分组和目录由后端结构接口管理,不引入新密钥体系。