commit 53768a195dc9a63cd8394fb246dd31197854d3c9 Author: JustMe Date: Fri Oct 2 00:59:22 2026 +0800 chore(repo): initialize WebDrop monorepo Signed-off-by: JustMe diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73e3d73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# JavaScript dependencies and generated output +**/node_modules/ +**/dist/ +**/*.tsbuildinfo + +# .NET build output and local IDE files +**/bin/ +**/obj/ +server/out/ +server/.idea/ +**/*.user +**/*.suo + +# Local runtime data +**/data/ +**/*.db +**/*.db-shm +**/*.db-wal + +# Editor and OS files +.vscode/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..f460b92 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# WebDrop + +零信任的端到端加密暂存与同步:文本、JSON、剪贴板、文件。浏览器内加密,后端节点完全不受信任,只保存和转发密文。 + +## 状态 + +文档是权威来源,代码以文档为准。 + +| 部分 | 状态 | +|---|---| +| 协议、API、威胁模型 | 已定稿(`docs/`) | +| `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 | +| `server/` | 频道与便捷分享 API 已实现并通过 132 个测试;win-x64 与 linux-x64 NativeAOT 发布此前已验证零警告 | +| `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 | +| `web/` | 前端,开发中 | + +## 文档 + +| 文档 | 内容 | +|---|---| +| [docs/threat-model.md](docs/threat-model.md) | 信任边界、攻击者模型、每种模式的保证、元数据泄露、不做的事 | +| [docs/protocol.md](docs/protocol.md) | 密钥派生、令牌、帧格式、加密和 padding、校验算法、保留策略(协议权威文档) | +| [docs/api.md](docs/api.md) | HTTP / WebSocket / SSE 接口、鉴权头、错误码 | +| [docs/architecture.md](docs/architecture.md) | 组件、技术栈、NativeAOT 约束、存储模型、部署、阶段划分 | +| [docs/ui-design.md](docs/ui-design.md) | 前端视觉与交互:色彩、形状、blur 约束、页面、状态与诚实提示、动效 | +| [docs/frontend.md](docs/frontend.md) | 前端工程:技术栈、分层、状态模型、传输与重连、性能与可维护性约定 | +| [web/AGENTS.md](web/AGENTS.md) | 前端契约:权威顺序、范围、必须遵守与禁止项 | +| [packages/core/README.md](packages/core/README.md) | `@webdrop/core` 的接口、前端要做和不用做的事、必须注意的几点 | +| [server/README.md](server/README.md) | 服务端的结构、配置、AOT 发布、验证码(Cap)接入 | +| [test-vectors/README.md](test-vectors/README.md) | 协议测试向量的生成与使用 | + +## 核心概念 + +- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和席位。 +- **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。 +- **终端**:加入频道的设备或会话,占一个席位(默认 2 个)。 +- **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。 +- **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。 + +## 目录 + +``` +webdrop-proj/ +├─ docs/ +├─ test-vectors/ 协议测试向量,所有实现共用 +├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用) +├─ server/ .NET 10,NativeAOT +└─ web/ Vite + React + TypeScript +``` diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..fb3d1c4 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,191 @@ +# API v1 + +基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。 + +## 1. 鉴权与通用头 + +| 头 | 说明 | +|---|---| +| `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 | +| `Authorization` | `Bearer ` | +| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 | +| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) | +| `X-WD-Label` | raw 模式条目标签,≤255 B | +| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 | + +raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。 + +WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, wd.role., wd.auth., wd.ep.`,服务端回选 `wd.v1`。不要放进 URL,否则会被代理记录。 + +## 2. 权限矩阵 + +| 操作 | 需要 | +|---|---| +| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并占有席位 | +| 追加帧 | `write`(`members_can_upload` 为 true 时)或 `owner` | +| 删除条目 | `owner`;`delete_policy=own` 时终端哈希等于条目作者;`anyone` 时任意已入席终端 | +| 改设置、删频道、看和释放席位 | `owner` | + +## 3. 接口 + +### `GET /api/v1/info` +返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。 + +### `POST /api/v1/channels` +创建频道。服务端启用验证码时需要 `X-WD-Cap`;验证服务不可用时返回 `503 captcha_unavailable`,与「用户没通过验证」(`428 captcha_required`)区分。 + +```json +{ + "id": "", + "mode": "enc", + "settings": { "seats": 2, "members_can_upload": true, "delete_policy": "own", + "ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 }, + "read_hash": "", + "write_hash": "", + "owner_hash": "" +} +``` + +raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。 + +### `GET /api/v1/channels/{id}` +返回 `ChannelInfo`:设置、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。 + +### `POST /api/v1/channels/{id}/seats` +加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。除本接口和上面的「读取频道信息」外,其余频道接口都要求终端已入席,否则返回 `403 not_joined`。 + +### `PATCH /api/v1/channels/{id}/settings` +所有者修改设置。`ttl_seconds` 的修改从现在起算。 + +### `DELETE /api/v1/channels/{id}` +所有者删除整个频道和全部密文。 + +### `POST /api/v1/channels/{id}/frames` +追加一帧。请求体是完整封装(`application/octet-stream`)。 + +| 响应 | 含义 | +|---|---| +| `201 {"seq"}` | 新追加 | +| `200 {"seq"}` | 幂等重发,返回原 seq | +| `409` | 同一 `(item_id, frame_index)` 内容不同 | +| `403` | 无权、席位已满、模式或 ENC 标志不符 | +| `413` | 超出限制 | +| `429` | 限流 | + +### `GET /api/v1/channels/{id}/log?from=&limit=&wait=<秒>` +按 seq 拉取日志,返回记录的线格式拼接(`application/octet-stream`)。`wait` 大于 0 时为长轮询,最长 25 秒。满足 `0 < from < oldest_seq` 时返回 `410 {"error":"gone","oldest_seq":N}`,客户端必须从 `from=0` 全量同步并丢弃本地已不存在的条目(不是从 `oldest_seq` 读起,见 protocol §8)。`from=0` 永远允许。 + +### `GET /api/v1/channels/{id}/ws?from=` +WebSocket 订阅(降级链的第一级)。服务端向下推送: +- 二进制消息:一条日志记录。 +- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"seats"}` 席位变更。 + +上传仍走 HTTP `POST`,便于幂等重试。 + +### `GET /api/v1/channels/{id}/events?from=` +SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:` 是 seq,断线后用 `Last-Event-ID` 或 `from` 续传。由于 `EventSource` 不能设置头,前端用 `fetch` 流式读取。 + +### 降级与重连 +WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 `from=last_seq+1`。 + +### `GET /api/v1/channels/{id}/items/{item_id}/frames?from=` +拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载,`from>0` 视为续传不计数。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。 + +### `DELETE /api/v1/channels/{id}/items/{item_id}` +按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。 + +### 席位 +- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。 +- `PUT /api/v1/channels/{id}/seats/me`:终端上传加密设备名(≤256 B)。 +- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者释放席位。 + +## 4. 便捷分享接口 + +便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、席位和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。 + +| 接口 | 角色 | 阶段 | 说明 | +|---|---|---|---| +| `POST /shares` | 无(需验证码) | | 创建**草稿**。可带 `X-WD-Endpoint`:这个终端是创建者,不占访客名额 | +| `PATCH /shares/{id}/settings` | owner | 草稿 | 改有效期、访客上限、默认下载次数 | +| `POST /shares/{id}/frames` | owner | 草稿 | 追加帧。格式与频道的帧完全相同;不允许 SNAPSHOT 标志 | +| `DELETE /shares/{id}/items/{item_id}` | owner | 草稿 | 移除条目 | +| `DELETE /shares/{id}` | owner | 草稿 | 删除整个草稿 | +| `POST /shares/{id}/finish` | owner | 草稿 | **封存**,一次性、不可撤销 | +| `GET /shares/{id}` | read | 已封存 | 分享信息和每个文件的剩余下载次数(owner 在草稿阶段也可读) | +| `GET /shares/{id}/manifest` | read | 已封存 | 清单条目的封装字节;**首次读取登记访客** | +| `GET /shares/{id}/items/{item_id}/frames?from=` | read | 已封存 | 下载文件,`from=0` 计一次下载;**首次读取同样登记访客** | + +### `POST /api/v1/shares` + +```json +{ + "id": "", + "read_hash": "", + "owner_hash": "", + "settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 } +} +``` + +需要验证码(`X-WD-Cap`,与创建频道相同的规则),并受创建限流约束。返回 `201 ShareInfo`。草稿的 `expires_at` 是创建后 24 小时(服务端配置 `WebDrop:ShareDraftTtlSeconds`);`ttl_seconds` 是**封存之后**的有效期,封存时才开始计时。 + +| 设置 | 默认 | 范围 | +|---|---|---| +| `ttl_seconds` | 86400 | 60 ~ 604800 | +| `max_visitors` | 0(不限) | 0 ~ 10000 | +| `file_max_downloads` | 10 | 0(不限)或更大 | + +### `POST /api/v1/shares/{id}/frames` + +请求体是完整的帧封装,响应沿用频道的 `201`/`200 {"seq"}` 结构。便捷分享没有日志游标,这里的 `seq` 是该帧的 `frame_index`,用于确认追加与幂等重发。内容冲突返回 `409 conflict`,超限返回 `413 too_large`。额外规则:SNAPSHOT 标志返回 `400 bad_request`;分享已封存返回 `409 sealed`。第 0 帧可带 `X-WD-Max-Downloads` 设置该文件的下载次数上限。 + +### `POST /api/v1/shares/{id}/finish` + +```json +{ "manifest_item_id": "" } +``` + +按 protocol §15.5 校验后封存。成功返回 `200 ShareInfo`(此时 `state` 为 `sealed`)。清单条目不存在、不是单帧非 GATED 的完整条目,或草稿里还有没传完的条目,返回 `400 bad_request`。已封存的分享再次调用返回 `409 sealed`。 + +### `GET /api/v1/shares/{id}` + +```json +{ + "id": "…", "state": "sealed", + "expires_at": 1700000000000, "created_at": 1699990000000, "sealed_at": 1699999999000, + "manifest_item_id": "…", + "settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 }, + "items": [ { "item_id": "…", "bytes": 12345, "frames": 3, "max_downloads": 5, "downloads_left": 4 } ] +} +``` + +`downloads_left` 为 `null` 表示不限。响应**永远不包含访客人数**。草稿对读角色返回 `404 not_found`。 + +### `GET /api/v1/shares/{id}/manifest` + +返回清单条目的完整封装(`application/octet-stream`),必须带 `X-WD-Endpoint`。登记访客;访客名额已满且这个终端未登记时返回 `403 visitors_full`。 + +### `GET /api/v1/shares/{id}/items/{item_id}/frames` + +与频道的 GATED 拉取一致:`from=0` 计一次下载,`from>0` 视为续传不计数;达到上限后返回 `410 gone`,5 分钟宽限期内已开始的拉取可继续续传。必须带 `X-WD-Endpoint`,同样会登记访客。 + +## 5. 错误 + +错误响应体是 JSON `{"error": ""}`,不回显任何用户提交的内容。 + +| code | HTTP | 含义 | +|---|---|---| +| `bad_request` | 400 | 封装格式或参数错误 | +| `unauthorized` | 401 | 令牌缺失或错误 | +| `forbidden` | 403 | 角色不够 | +| `not_joined` | 403 | 终端未入席,或席位已被所有者释放 | +| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 | +| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 | +| `seats_full` | 403 | 席位已满 | +| `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 | +| `not_found` | 404 | 频道或条目不存在,或已过期 | +| `conflict` | 409 | 幂等冲突 | +| `gone` | 410 | 已删除、下载达上限,或 `0 < from < oldest_seq`(需要从 0 全量同步) | +| `too_large` | 413 | 超出限制 | +| `captcha_required` | 428 | 需要验证码:缺少 `X-WD-Cap`,或 token 无效、已用过 | +| `captcha_unavailable` | 503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 | +| `rate_limited` | 429 | 限流 | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4335306 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,123 @@ +# 架构 + +## 1. 组件 + +``` +浏览器 (React, 独立域名 + SRI) 服务端 (.NET 10, NativeAOT) +┌──────────────────────────────┐ ┌─────────────────────────────┐ +│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │ +│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │ +│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │ +│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │ +│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘ +└──────────────────────────────┘ │ │ + SQLite IBlobStore + (元数据、日志) (磁盘,预留 S3) +``` + +服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。 + +## 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 和常量时间比较,`System.Security.Cryptography` 在 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. 存储模型 + +SQLite 存元数据和小帧,磁盘存大帧。 + +| 表 | 字段 | +|---|---| +| `channels` | `id` PK, `mode`, `settings`(JSON), `read_hash`, `write_hash`, `owner_hash`, `created_at`, `expires_at`, `last_seq`, `compacted_through`, `bytes` | +| `shares` | `id` PK(与频道共用全局 id 空间), `settings`(JSON), `read_hash`, `owner_hash`(封存时清空), `creator_endpoint_hash`, `created_at`, `expires_at`, `sealed_at`, `manifest_item_id`, `bytes` | +| `seats` | `channel_id`, `endpoint_hash`, `label` BLOB, `joined_at`;PK(`channel_id`, `endpoint_hash`) | +| `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 与前端使用**不同域名**:前端静态资源可以放在 CDN 或独立静态主机,API 被攻破不影响前端。 +- 前端构建产物为所有脚本和样式生成 SRI,发布时公布构建哈希。 +- 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。 +- 反向代理不得记录请求头(令牌)和完整 URL。 +- 服务端日志不含频道 id 明文,只含截断后的哈希。 + +## 6. 前端要点 + +- 取出 fragment 后立即 `history.replaceState` 清掉密钥;K 导入为不可导出的 `CryptoKey`。 +- 加解密在 Worker 里流式处理,文件不整份进内存。 +- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。 +- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。 +- 下载大文件使用 Service Worker 流式写入,兼容没有 File System Access API 的 iOS Safari。 +- 本地「我的频道」:链接和密钥保存在 IndexedDB,只在本机。 + +## 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 | 可选账号、配额;CLI | 另行设计 | + +每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。 + +## 8. 验收时的检查清单 + +- `dotnet test`、`vitest`、Playwright 全部通过。 +- 对 SQLite 文件和 blob 目录 grep 明文关键字,必须查不到。 +- 服务端日志中没有 K、令牌和频道 id 明文。 +- 抓包确认请求里没有 K 和明文。 +- AOT 发布产物能在没有 .NET 运行时的机器上运行。 diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 0000000..0555973 --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,173 @@ +# 前端架构 + +`web/` 的工程文档。视觉与交互见 [ui-design.md](ui-design.md),协议见 [protocol.md](protocol.md),接口见 [api.md](api.md)。 + +## 1. 技术栈 + +| 项 | 选择 | 说明 | +|---|---|---| +| 构建 | Vite | 纯客户端 SPA,不做 SSR | +| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | | +| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary | +| 状态 | Zustand | 按领域拆 store | +| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」 | +| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS | +| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition | +| 测试 | Vitest(单元)、Playwright(端到端) | | +| 二维码生成 | 自写编码器 `lib/qr.ts`(字节模式、纠错 M、版本 1–10,最多 213 字节),与 Python `qrcode` 逐模块对比验证,见 `lib/qr.test.ts` | +| 扫码 | 浏览器自带 `BarcodeDetector` + `getUserMedia`,零依赖;不支持时退回粘贴链接 | +| 加解密 | `@webdrop/core`,经 `core-bridge` 调用,在 Worker 中运行 | | + +**依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。 + +## 2. 目录与分层 + +``` +web/src/ +├─ app/ 入口、Provider、导航、主题 +├─ styles/ tokens.css(唯一的颜色来源)、base.css +├─ ui/ 无业务的基础组件、图标(Button、Sheet、Menu、Toast) +├─ features/ 按功能垂直切分:home / channel / join / item / settings +├─ store/ 领域状态与事件流 reducer(纯逻辑) +├─ core-bridge/ 对 @webdrop/core、Worker、网络传输的封装 +└─ lib/ 与框架无关的小工具(链接解析、退避、格式化) +``` + +依赖方向**单向向下**: + +``` +app → features → ui + │ + └──→ store → core-bridge → @webdrop/core +lib 可被任何层使用,lib 不依赖其他层 +``` + +| 规则 | 说明 | +|---|---| +| `ui/` 不依赖 `features/`、`store/` | 基础组件无业务 | +| `features/` 之间不互相导入 | 需共享就上移到 `ui/` 或 `store/`;唯一例外:`channel/`、`home/` 是组合层,可导入 `item/`、`scan/`、`channel/` 里的弹层 | +| 组件不直接调用网络或加解密 | 只通过 `store/` 的 action 与 selector | +| 协议逻辑只在 `store/`、`core-bridge/`、`lib/` | 全部用纯函数,可脱离 DOM 测试 | +| 不使用 barrel(`index.ts` 汇总导出) | 直接从具体文件导入,利于 tree-shaking 与追踪 | + +## 3. 状态模型 + +频道是追加日志,**条目状态由日志推导,不单独存储可变副本**。 + +| store | 持有 | 说明 | +|---|---|---| +| `channels` | 本机已保存的频道、当前会话的频道元信息 | 持久化到 IndexedDB | +| `items`(按频道) | 由帧装配出的条目 Map(`item_id → Item`) | 按 `item_id` 建 Map,不用数组 `find` | +| `transport` | 连接状态、`last_seq`、降级级别 | | +| `queue` | 待发送帧(已加密的字节) | 持久化到 IndexedDB | + +条目用判别联合,所有 `switch` 必须穷尽: + +```ts +type ItemState = + | { kind: 'receiving' } + | { kind: 'complete' } + | { kind: 'incomplete' } + | { kind: 'tampered' } + | { kind: 'removed'; reason: 'deleted' | 'expired' | 'evicted' | 'limit' }; +``` + +组件订阅**派生出的最小值**(如 `isOnline`、某条目的 `state.kind`),不订阅整个 store。 + +## 4. 传输与重连 + +遵循 [api.md §降级与重连](api.md):WebSocket → SSE → 长轮询;心跳 20 秒,45 秒无消息判死;退避 1–30 秒加抖动;重连携带 `from=last_seq+1`;收到 `410` 则**从 `from=0` 全量同步**,并丢弃本地已不存在的条目(从 `oldest_seq` 读会漏掉 seq 更小的存活条目,见 api.md)。 + +- 传输层是 `core-bridge/` 内的状态机,对 `store/` 只暴露「记录流」和「连接状态」。 +- 上传永远走 HTTP `POST`,依赖协议幂等重发,不需要前端去重逻辑之外的补偿。 +- **队列里持久化的是已加密的帧**。重试必须发送完全相同的字节(protocol §5 的 IV 规则),前端任何位置都不得对同一 `(item_id, frame_index)` 重新加密。 + +## 5. 密钥与安全处理 + +| 规则 | 说明 | +|---|---| +| 取出 fragment 后立即 `history.replaceState` | 在任何异步代码之前完成 | +| K 导入为不可导出的 `CryptoKey` | 内存中不保留原始字节 | +| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 | +| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB | +| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` | +| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 | +| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 | + +## 6. 路由与导航 + +- 路由只有:`/`、`/c/:id`、`/c/:id/settings`。 +- **所有跳转经 `app/navigation.ts` 的 `navigate(to, direction)`**,内部用 `startTransition` + `addTransitionType` 触发 view transition。禁止在组件中直接 `history.pushState` 或 `location.assign`。 +- 浏览器后退/前进不会触发 view transition(`popstate` 同步),这是已知限制,接受其无动画。 +- 页面级方向性 `ViewTransition` 写在**页面组件**里,不写在布局里;用一个共享的 `DirectionalTransition` 包装。 + +## 7. 动画实现要点 + +遵循 `react-view-transitions` 技能的约束,并与 [ui-design.md §9](ui-design.md) 一致: + +- 动画 CSS 取自技能的配方(`--duration-*`、`fade`、`slide`、`nav-forward/back`),集中放在 `styles/transitions.css`,不在组件里写动画 CSS。 +- `ViewTransition` 一律 `default="none"`,显式列出 `enter` / `exit`,成对出现。 +- Suspense 的揭示动画用简单字符串属性,不用类型映射。 +- 共享元素的 `name` 全局唯一(如 `item-${id}`),不在可同时挂载两处的复用组件里写具名 VT。 + +## 8. 性能约定 + +遵循 `react-best-practices` 技能中适用于纯客户端 SPA 的部分: + +| 方面 | 约定 | +|---|---| +| 首屏体积 | 按路由 `lazy` 拆分;二维码、预览、设置等重组件动态加载;**禁止 barrel 导入** | +| 重渲染 | 订阅最小派生值;事件处理中才用的状态用 ref 或 `getState()`;高频值(上传进度)用 ref 或独立细粒度 store,不触发整列表重渲染 | +| 派生状态 | 渲染期计算,不用 effect 同步 | +| 列表 | `content-visibility: auto`;条目 key 为 `item_id` | +| 条件渲染 | 用三元,不用 `&&` 渲染可能为 `0` 的值 | +| 重计算 | 加解密、装配、哈希一律在 Worker,主线程不处理大文件 | +| 非紧急更新 | 用 `startTransition` / `useDeferredValue` | +| 模块级单例 | 只做一次的初始化(Worker、IndexedDB 连接)用惰性初始化,避免重复 | + +`server-*` 与 Next.js 相关规则不适用本项目。 + +## 9. 可维护性约定 + +1. 一个文件一个职责;组件文件约 150 行为上限,超出就拆。 +2. 不预先抽象。同样的代码第二次出现才抽,第三次必须抽。 +3. 导出面最小化:不被其他文件使用的符号不导出。 +4. 注释只写「为什么」,尤其是协议约束与安全理由,并指向文档章节(如 `// protocol §5: 重试必须发相同字节`)。 +5. 颜色、间距、圆角、阴影都不得写死在组件里,一律走 `tokens.css`。 +6. 改动协议相关行为前,先改 `docs/`,再改代码(与 architecture §7 一致)。 +7. 每个 `store/` 与 `lib/` 的模块有对应的 Vitest;每个用户可见流程有 Playwright 用例。 + +## 10. 测试 + +| 层 | 工具 | 内容 | +|---|---|---| +| `lib/`、`store/` | Vitest | 条目装配、状态推导、退避、队列、链接解析(含降级防御的负例) | +| `core-bridge/` | Vitest | 传输降级、`410` 重同步、断线续传(用假传输) | +| 界面流程 | Playwright | 双浏览器内容一致;断网恢复后无丢失无重复;抓包无明文和 K | +| 视觉 | Playwright 截图 | 亮暗两套、手机与桌面宽度、reduced-motion、无 `backdrop-filter` 回退 | + +## 11. 阶段(对应 architecture §7) + +| 阶段 | 前端内容 | +|---|---| +| 3 | 设计系统与骨架(静态,假数据) → 创建、加入、文本与 JSON 实时同步、重连、发送队列、「我的频道」 | +| 4 | 文件:流式上传下载、GATED 拉取、下载计数、预览、粘贴与拖放 | +| 6 | PWA、Web Share Target、二维码与短配对码 | + +在 `@webdrop/core` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。 + +## 12. 与 `@webdrop/core` 对接备忘 + +`core-bridge/api.ts` 是前端与后端的唯一边界,现在由 `mock.ts` 实现。接 `@webdrop/core`([packages/core/README.md](../packages/core/README.md))时对应关系: + +| 前端需要 | core / 协议 | +|---|---| +| 新建频道或分享 | `ChannelSession.create({ mode, settings, endpointSecret })` → `POST /channels`;分享的 `settings` 为 `members_can_upload=false`、`delete_policy=owner_only`,并设 `seats`、`ttl_seconds`、`file_max_downloads` | +| 草稿阶段调整设置 | `PATCH /channels/{id}/settings`(需所有者令牌) | +| **完成分享** | 客户端丢弃所有者密钥 A:用成员链接重新 `ChannelSession.open(...)`,此后 `session.can('owner')` 为 false。丢弃是客户端的自觉行为,服务端不知道;若要强制,需后端提供「封存」让服务端作废 `owner_hash`(属于后端与协议改动,前端只提请求) | +| 文件下载次数 | 每个条目的 `maxDownloads` 会强制 GATED,并带 `X-WD-Max-Downloads`,**必须随第 0 帧发送**。文本不要设,否则读文本也要拉取 | +| 内联渲染不烧次数 | GATED 条目每次 `GET /items/{id}/frames?from=0` 计一次下载;自动加载只用于非 GATED(见 ui-design §6.5) | +| 终端密钥 | `session.endpointSecret` 必须持久化到 IndexedDB,否则每次打开都占一个新席位。原型暂未做 | +| 创建频道的验证码 | 读 `GET /info` 的 `captcha`,用 Cap 求解后放进 `X-WD-Cap` | +| 校验失败 | reader 的 `rejected` 事件不能忽略,至少在界面提示「内容校验失败」 | +| Worker | `ChannelSession` 不能跨线程,在 Worker 里用链接和 `endpointSecret` 重建 | diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..87ae402 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,860 @@ +openapi: 3.1.0 +info: + title: WebDrop API + version: 1.0.0 +servers: + - url: /api/v1 + +tags: + - name: server + - name: channel + - name: seat + - name: frame + - name: item + - name: share + +paths: + /info: + get: + tags: [server] + security: [] + summary: 服务端能力与限制 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ServerInfo' } + + /channels: + post: + tags: [channel] + security: [] + summary: 创建频道 + description: | + 服务端启用验证码时需要 `X-WD-Cap`(Cap 的 token,见 protocol §11),并需要终端头 `X-WD-Endpoint`。 + 创建者的终端自动入席,占一个席位。 + parameters: + - { $ref: '#/components/parameters/Captcha' } + - { $ref: '#/components/parameters/Endpoint' } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateChannelRequest' } + responses: + '201': + description: 已创建 + content: + application/json: + schema: { $ref: '#/components/schemas/ChannelInfo' } + '400': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + '428': { $ref: '#/components/responses/Error' } + '429': { $ref: '#/components/responses/Error' } + '503': { $ref: '#/components/responses/Error' } + + /channels/{id}: + parameters: + - { $ref: '#/components/parameters/ChannelId' } + get: + tags: [channel] + summary: 读取频道信息(不占席位) + description: 任意角色可调用。用于加入前预览席位占用和核对模式。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/Role' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ChannelInfo' } + '401': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + delete: + tags: [channel] + summary: 删除整个频道及全部密文(所有者) + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/RoleOwner' } + responses: + '204': { description: 已删除 } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + + /channels/{id}/settings: + patch: + tags: [channel] + summary: 修改设置(所有者) + description: | + 字段均可选,只修改提供的字段。`ttl_seconds` 的变更从现在起算。 + 成功后向订阅者推送 `{"t":"settings"}`。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/RoleOwner' } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChannelSettingsPatch' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ChannelInfo' } + '400': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + + /channels/{id}/seats: + parameters: + - { $ref: '#/components/parameters/ChannelId' } + post: + tags: [seat] + summary: 加入频道(占用席位) + description: | + 幂等:同一终端重复调用返回同一席位,不重复占用。 + 席位已满返回 403 `seats_full`。 + 除本接口和「读取频道信息」外,其余频道接口都要求终端已入席,否则 403 `not_joined`。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + responses: + '200': + description: 已入席 + content: + application/json: + schema: { $ref: '#/components/schemas/SeatJoined' } + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + get: + tags: [seat] + summary: 查看席位列表(所有者) + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/RoleOwner' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/Seat' } + '403': { $ref: '#/components/responses/Error' } + + /channels/{id}/seats/me: + put: + tags: [seat] + summary: 终端上传自己的加密设备名 + description: 请求体是用 `K_enc` 加密后的设备名字节,≤256 B。服务端不解析。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + requestBody: + required: true + content: + application/octet-stream: + schema: { type: string, format: binary, maxLength: 256 } + responses: + '204': { description: 已保存 } + '403': { $ref: '#/components/responses/Error' } + '413': { $ref: '#/components/responses/Error' } + + /channels/{id}/seats/{endpointHash}: + delete: + tags: [seat] + summary: 释放席位(所有者) + description: 被释放的终端后续请求返回 `not_joined`。向订阅者推送 `{"t":"seats"}`。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - name: endpointHash + in: path + required: true + description: 终端标识,即 `base64url(SHA-256(S))` + schema: { $ref: '#/components/schemas/Base64Url' } + - { $ref: '#/components/parameters/RoleOwner' } + responses: + '204': { description: 已释放 } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + + /channels/{id}/frames: + post: + tags: [frame] + summary: 追加一帧 + description: | + 请求体是完整的帧封装(26 字节头 + 负载),格式见 `protocol.md` §4。 + 幂等键为 `(channel_id, item_id, frame_index)`。内容相同则 200 返回原 seq,内容不同则 409。 + 第 0 帧可带 `X-WD-Max-Downloads` 和(raw 模式)`X-WD-Label`。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/RoleWriter' } + - { $ref: '#/components/parameters/Endpoint' } + - name: X-WD-Max-Downloads + in: header + required: false + schema: { type: integer, minimum: 0 } + description: 条目的下载上限,0 为不限。只对 GATED 条目生效。 + - name: X-WD-Label + in: header + required: false + schema: { type: string, maxLength: 255 } + description: raw 模式的条目标签,明文。 + requestBody: + required: true + content: + application/octet-stream: + schema: { type: string, format: binary } + responses: + '201': + description: 新追加 + content: + application/json: + schema: { $ref: '#/components/schemas/AppendResult' } + '200': + description: 幂等重发 + content: + application/json: + schema: { $ref: '#/components/schemas/AppendResult' } + '400': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + '413': { $ref: '#/components/responses/Error' } + '429': { $ref: '#/components/responses/Error' } + + /channels/{id}/log: + get: + tags: [frame] + summary: 按 seq 拉取日志(长轮询、续传、全量同步) + description: | + 返回日志记录的线格式拼接(`protocol.md` §8):每条 `seq(u64) ‖ kind(u8) ‖ body`。 + `wait>0` 时无新记录会挂起,最长 25 秒,超时返回空体。 + 满足 `0 < from < oldest_seq` 时返回 410 并带 `oldest_seq`,客户端必须从 `from=0` 全量同步并丢弃本地已不存在的条目(不要从 `oldest_seq` 读起)。`from=0` 永远允许。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + - name: from + in: query + required: false + schema: { type: integer, format: int64, minimum: 0, default: 0 } + - name: limit + in: query + required: false + schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } + - name: wait + in: query + required: false + schema: { type: integer, minimum: 0, maximum: 25, default: 0 } + description: 长轮询等待秒数 + responses: + '200': + description: 记录拼接,可为空 + headers: + X-WD-Last-Seq: + description: 频道当前最新 seq + schema: { type: integer, format: int64 } + content: + application/octet-stream: + schema: { type: string, format: binary } + '403': { $ref: '#/components/responses/Error' } + '410': + description: 0 < from < oldest_seq,需要从 0 全量同步 + content: + application/json: + schema: { $ref: '#/components/schemas/GoneLog' } + + /channels/{id}/ws: + get: + tags: [frame] + security: [] + summary: WebSocket 订阅(降级链第一级) + description: | + 升级为 WebSocket。浏览器不能设置头,凭据通过子协议传递: + `Sec-WebSocket-Protocol: wd.v1, wd.role., wd.auth., wd.ep.`, + 服务端回选 `wd.v1`。不要把令牌放在 URL 里。 + + **服务端 → 客户端** + - 二进制消息:一条日志记录(`seq ‖ kind ‖ body`)。 + - 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"seats"}` 席位变更。 + + 客户端 45 秒收不到任何消息应判定死链,以指数退避(1~30 秒)加抖动重连,并带 `from=last_seq+1`。 + 上传不走 WebSocket,仍用 `POST /frames`。 + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - name: from + in: query + required: false + schema: { type: integer, format: int64, minimum: 0, default: 0 } + responses: + '101': { description: 已升级 } + '403': { $ref: '#/components/responses/Error' } + '410': + description: 0 < from < oldest_seq,需要从 0 全量同步 + content: + application/json: + schema: { $ref: '#/components/schemas/GoneLog' } + + /channels/{id}/events: + get: + tags: [frame] + summary: SSE 订阅(降级链第二级) + description: | + `text/event-stream`。每个事件的 `id:` 是 seq,`data:` 是 base64url 的日志记录; + 控制消息与 WebSocket 的文本消息相同,事件名为 `control`。 + `EventSource` 不能设置头,前端应使用 `fetch` 流式读取并带头部鉴权。 + 续传用 `from` 或 `Last-Event-ID`。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + - name: from + in: query + required: false + schema: { type: integer, format: int64, minimum: 0, default: 0 } + responses: + '200': + description: 事件流 + content: + text/event-stream: + schema: { type: string } + '403': { $ref: '#/components/responses/Error' } + '410': + description: 0 < from < oldest_seq,需要从 0 全量同步 + content: + application/json: + schema: { $ref: '#/components/schemas/GoneLog' } + + /channels/{id}/items/{itemId}/frames: + get: + tags: [item] + summary: 拉取条目的帧(GATED 条目的取数方式) + description: | + 返回封装拼接,每帧自带 `payload_len`,可流式解析。条目尚未 FINAL 时响应持续输出直到 FINAL。 + `from=0` 计一次下载;`from>0` 视为续传,不计数。 + 达到下载上限后返回 410 `gone`,5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/ItemId' } + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + - name: from + in: query + required: false + schema: { type: integer, format: int64, minimum: 0, default: 0 } + description: 起始 frame_index + responses: + '200': + description: 封装拼接 + content: + application/octet-stream: + schema: { type: string, format: binary } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '410': { $ref: '#/components/responses/Error' } + + /channels/{id}/items/{itemId}: + delete: + tags: [item] + summary: 删除条目 + description: | + 按频道的 `delete_policy` 授权:`owner_only` 仅所有者;`own` 时所有者或条目作者;`anyone` 时任意已入席终端。 + 成功后在日志追加 tombstone(`reason=0`)。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ChannelId' } + - { $ref: '#/components/parameters/ItemId' } + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + responses: + '204': { description: 已删除 } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + + /shares: + post: + tags: [share] + security: [] + summary: 创建便捷分享草稿 + description: 启用验证码时需携带 Cap token。创建者 endpoint 可选,创建者不计入访客数。 + parameters: + - { $ref: '#/components/parameters/Captcha' } + - { $ref: '#/components/parameters/ShareEndpoint' } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateShareRequest' } + responses: + '201': + description: 已创建草稿 + content: + application/json: + schema: { $ref: '#/components/schemas/ShareInfo' } + '400': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + '428': { $ref: '#/components/responses/Error' } + '429': { $ref: '#/components/responses/Error' } + '503': { $ref: '#/components/responses/Error' } + + /shares/{id}: + parameters: + - { $ref: '#/components/parameters/ShareId' } + get: + tags: [share] + summary: 读取分享信息 + description: 已封存时使用 read;草稿仅 owner 可读,read 对草稿返回 not_found。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/Role' } + responses: + '200': + description: 分享与条目下载状态 + content: + application/json: + schema: { $ref: '#/components/schemas/ShareInfo' } + '401': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + delete: + tags: [share] + summary: 删除草稿 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/RoleOwner' } + responses: + '204': { description: 草稿已删除 } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + + /shares/{id}/settings: + patch: + tags: [share] + summary: 修改草稿设置 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ShareId' } + - { $ref: '#/components/parameters/RoleOwner' } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ShareSettingsPatch' } + responses: + '200': + description: 设置已更新 + content: + application/json: + schema: { $ref: '#/components/schemas/ShareInfo' } + '400': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + + /shares/{id}/frames: + post: + tags: [share] + summary: 向草稿追加加密帧 + description: 不支持 SNAPSHOT。第 0 帧可用 X-WD-Max-Downloads 覆盖默认下载上限。响应的 seq 是 frame_index 确认值,不是日志游标。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ShareId' } + - { $ref: '#/components/parameters/RoleOwner' } + - name: X-WD-Max-Downloads + in: header + required: false + schema: { type: integer, format: int64, minimum: 0 } + requestBody: + required: true + content: + application/octet-stream: + schema: { type: string, format: binary } + responses: + '201': + description: 新帧已追加 + content: + application/json: + schema: { $ref: '#/components/schemas/AppendResult' } + '200': + description: 幂等重发 + content: + application/json: + schema: { $ref: '#/components/schemas/AppendResult' } + '400': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + '413': { $ref: '#/components/responses/Error' } + + /shares/{id}/items/{itemId}: + delete: + tags: [share] + summary: 从草稿移除条目 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ShareId' } + - { $ref: '#/components/parameters/ItemId' } + - { $ref: '#/components/parameters/RoleOwner' } + responses: + '204': { description: 条目已删除 } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + + /shares/{id}/finish: + post: + tags: [share] + summary: 封存分享 + description: 验证 manifest 是单帧非 GATED 完整条目,且草稿内所有条目完整;成功后永久禁用写权限。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ShareId' } + - { $ref: '#/components/parameters/RoleOwner' } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/FinishShareRequest' } + responses: + '200': + description: 分享已封存 + content: + application/json: + schema: { $ref: '#/components/schemas/ShareInfo' } + '400': { $ref: '#/components/responses/Error' } + '409': { $ref: '#/components/responses/Error' } + + /shares/{id}/manifest: + get: + tags: [share] + summary: 获取加密清单帧 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ShareId' } + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + responses: + '200': + description: 完整封装的二进制清单 + content: + application/octet-stream: + schema: { type: string, format: binary } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '410': { $ref: '#/components/responses/Error' } + + /shares/{id}/items/{itemId}/frames: + get: + tags: [share] + summary: 下载封存分享中的文件帧 + description: from=0 计一次下载;from>0 为续传。首次成功读取会登记访客终端。 + security: [{ bearer: [] }] + parameters: + - { $ref: '#/components/parameters/ShareId' } + - { $ref: '#/components/parameters/ItemId' } + - { $ref: '#/components/parameters/Role' } + - { $ref: '#/components/parameters/Endpoint' } + - name: from + in: query + required: false + schema: { type: integer, format: int64, minimum: 0, default: 0 } + responses: + '200': + description: 连续封装帧拼接 + content: + application/octet-stream: + schema: { type: string, format: binary } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } + '410': { $ref: '#/components/responses/Error' } + +components: + securitySchemes: + bearer: + type: http + scheme: bearer + description: | + 令牌的 base64url(`T_read` / `T_write` / `T_owner`),与 `X-WD-Role` 配套。 + raw 模式的读请求不需要令牌。 + + parameters: + ChannelId: + name: id + in: path + required: true + description: 频道 id,16 字节 base64url + schema: { $ref: '#/components/schemas/Base64Url' } + ShareId: + name: id + in: path + required: true + description: 便捷分享 id,16 字节 base64url + schema: { $ref: '#/components/schemas/Base64Url' } + ItemId: + name: itemId + in: path + required: true + description: 条目 id,16 字节 base64url + schema: { $ref: '#/components/schemas/Base64Url' } + Role: + name: X-WD-Role + in: header + required: true + description: 令牌所属角色 + schema: { type: string, enum: [read, write, owner] } + RoleWriter: + name: X-WD-Role + in: header + required: true + schema: { type: string, enum: [write, owner] } + RoleOwner: + name: X-WD-Role + in: header + required: true + schema: { type: string, enum: [owner] } + Endpoint: + name: X-WD-Endpoint + in: header + required: true + description: 终端密钥 `S` 的 base64url。每个频道每个终端各生成一个,仅存本地。 + schema: { $ref: '#/components/schemas/Base64Url' } + ShareEndpoint: + name: X-WD-Endpoint + in: header + required: false + description: 创建者终端密钥 S 的 base64url,可选,不占访客名额。 + schema: { $ref: '#/components/schemas/Base64Url' } + Captcha: + name: X-WD-Cap + in: header + required: false + description: Cap 验证码 token。服务端 `/info` 的 `captcha.enabled` 为 true 时必填;token 一次有效。 + schema: { type: string, maxLength: 2048 } + + responses: + Error: + description: 错误。响应体不回显任何用户提交的内容。 + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + schemas: + Base64Url: + type: string + pattern: '^[A-Za-z0-9_-]+$' + description: base64url,无填充 + + ErrorCode: + type: string + enum: + - bad_request + - unauthorized + - forbidden + - not_joined + - seats_full + - mode_mismatch + - not_found + - conflict + - gone + - too_large + - captcha_required + - captcha_unavailable + - rate_limited + - visitors_full + - sealed + + Error: + type: object + required: [error] + properties: + error: { $ref: '#/components/schemas/ErrorCode' } + + GoneLog: + type: object + required: [error, oldest_seq] + properties: + error: { type: string, const: gone } + oldest_seq: { type: integer, format: int64, description: "清理水位 + 1;0 < from < oldest_seq 的续传需要从 0 全量同步" } + + ServerInfo: + type: object + required: [protocol, limits, captcha, transports] + properties: + protocol: { type: integer, const: 1 } + 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 } + + ChannelMode: + type: string + enum: [enc, raw] + + DeletePolicy: + type: string + enum: [owner_only, own, anyone] + + ChannelSettings: + type: object + required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download] + properties: + 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: "仅供前端展示,服务端不强制" } + + ChannelSettingsPatch: + type: object + description: ChannelSettings 的全部字段均可选 + properties: + 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 } + + CreateChannelRequest: + type: object + required: [id, mode, settings, owner_hash] + properties: + 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) + + 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 为不限 } + + 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 } + + 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' } + + FinishShareRequest: + type: object + required: [manifest_item_id] + properties: + manifest_item_id: { $ref: '#/components/schemas/Base64Url' } + + ShareItemInfo: + type: object + required: [item_id, bytes, frames, max_downloads, downloads_left] + properties: + 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 表示不限" } + + 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' } + + ChannelInfo: + type: object + required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at] + properties: + 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 } + + 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 } + + 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 + + AppendResult: + type: object + required: [seq] + properties: + seq: { type: integer, format: int64 } diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..b70c71a --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,320 @@ +# 协议 v1 + +本文是协议的权威来源。所有实现(服务端、`@webdrop/core`、将来的 CLI)必须通过 `test-vectors/` 中的向量。 + +## 1. 编码约定 + +- 多字节整数一律**大端**。 +- 二进制数据在 URL、头部、JSON 中一律 **base64url(无填充)**。 +- 随机数一律来自 CSPRNG(`crypto.getRandomValues`)。 + +| 名称 | 长度 | 说明 | +|---|---|---| +| `channel_id` | 16 B | 随机,客户端生成 | +| `item_id` | 16 B | 随机,客户端生成 | +| `K` | 32 B | 频道密钥,仅 enc 模式,只存在于 URL fragment | +| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 | +| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 | +| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 | + +## 2. 模式与链接 + +频道创建时二选一,之后不可更改。 + +| | enc(默认) | raw | +|---|---|---| +| 负载 | AES-256-GCM 密文 | 原样字节,服务端可见 | +| 所有者链接 | `/c/{id}#k=&a=` | `/c/{id}#a=[&w=]` | +| 成员链接 | `/c/{id}#k=` | `/c/{id}[#w=]` | +| 读权限 | 持 `T_read` | 知道 id 即可(能力链接) | +| 写权限 | 持 `T_write`(成员可上传时)或 `T_owner` | 持 `W` 或 `A` | + +**降级防御**:客户端解析链接时,fragment 含 `k` 就**只**接受 `mode=enc` 的频道且只接受认证通过的帧;不含 `k` 的链接对应 raw。服务端报告的模式与链接不符时,客户端必须拒绝。 + +## 3. 密钥派生(enc) + +HKDF-SHA256,`salt = channel_id`(16 B),输出 32 B。`info` 为 UTF-8 字符串。 + +| 输出 | IKM | info | +|---|---|---| +| `K_enc` | `K` | `webdrop/v1/enc` | +| `T_read` | `K` | `webdrop/v1/token/read` | +| `T_write` | `K` | `webdrop/v1/token/write` | +| `T_owner` | `A` | `webdrop/v1/token/owner` | +| `K_item` | `K_enc`,`salt = item_id` | `webdrop/v1/item` | +| `K_label` | `K_enc`,`salt = channel_id` | `webdrop/v1/label` | + +raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。 + +服务端只保存令牌的 `SHA-256` 哈希(`read_hash`、`write_hash`、`owner_hash`),验证时对请求中的令牌取哈希后做常量时间比较。 + +## 4. 帧封装(Envelope) + +服务端可见的明文头部,固定 26 字节,后接负载。 + +``` +偏移 长度 字段 +0 1 ver = 0x01 +1 1 flags +2 16 item_id +18 4 frame_index 条目内的序号,从 0 开始 +22 4 payload_len 负载字节数 +26 ... payload +``` + +`flags`: + +| 位 | 名称 | 含义 | +|---|---|---| +| 0 | FINAL | 条目的最后一帧 | +| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 | +| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 | +| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 | +| 4-7 | 保留 | 必须为 0 | + +服务端拒绝不符合上述规则的封装,但从不解析负载。 + +## 5. 加密(enc) + +- 算法:AES-256-GCM,密钥为 `K_item`,标签 16 B。 +- **IV**(12 B)= `00 00 00 00 00 00 00 00 ‖ frame_index`(u32)。在每个条目独立的 `K_item` 下,`frame_index` 唯一即保证 IV 唯一。 +- **AAD** = `"WD1"`(3 B ASCII)‖ `channel_id` ‖ `envelope[0..22)`(ver、flags、item_id、frame_index)。 +- 负载 = `密文 ‖ tag`。 + +**重要规则**:同一 `(item_id, frame_index)` 只能加密一份固定明文。重试必须重发相同字节;断点续传时,明文要从文件偏移重新确定性生成,或在本地队列里持久化已加密的帧。违反规则会造成 IV 重用。 + +## 6. 明文布局与 padding + +每帧的明文: + +``` +data_len (u32) ‖ data ‖ 0x00 填充 +``` + +明文总长取不小于 `4 + data_len` 的最小**档位**: + +`1 KiB, 4 KiB, 16 KiB, 64 KiB, 256 KiB, 1 MiB` + +因此单帧明文最大 1 MiB,`data` 最大 1,048,572 B。负载长度 = 档位 + 16。接收方解密后检查 `data_len ≤ 明文长度 − 4`,并检查填充全为零。 + +大文件的中间帧都是满档(1 MiB),只有最后一帧会泄露所处档位。 + +## 7. 条目 + +条目是共享同一 `item_id` 的一组帧,`frame_index` 从 0 连续递增,最后一帧带 FINAL。 + +**第 0 帧的 `data`**: + +``` +meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... +``` + +`meta_json`(enc 模式下在密文里): + +| 字段 | 说明 | +|---|---| +| `v` | 1 | +| `kind` | `text` / `json` / `file` / `other` | +| `name` | 可选,文件名 | +| `mime` | 可选 | +| `size` | 可选,内容总字节数(流式时可省略) | +| `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 | +| `dev` | 可选,设备名 | + +第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。 + +**raw 模式**:负载就是内容字节,没有 meta。条目名称等信息由用户在上传时放进 `X-WD-Label`(明文,≤255 B)。 + +**未完成的条目**:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。 + +## 8. 日志、序号与条目生命周期 + +频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。 + +| kind | 值 | 内容 | +|---|---|---| +| frame | 1 | 一帧的完整封装(仅非 GATED 条目,它们都是单帧) | +| tombstone | 2 | `item_id`(16 B) ‖ `reason`(u8:0 删除,1 过期,2 超限淘汰,3 下载达上限,4 被新快照取代) | +| announce | 3 | `item_id`(16 B) ‖ `flags`(u8) ‖ `created_ms`(u64),GATED 条目只在第 0 帧到达时产生 | + +**记录的线格式**:`seq (u64) ‖ kind (u8) ‖ body`,body 长度:frame 为 `26 + payload_len`(封装自带长度,自分隔),tombstone 固定 17 字节,announce 固定 25 字节。 + +**seq 的分配**:服务端为**每一个**被接受的帧分配 seq,包括 GATED 条目中不进入日志的后续帧,以及每条墓碑,所以日志里的 seq 可以不连续。客户端只用 seq 做排序和续传,不要假定连续。 + +**推送规则**: +- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:**非 GATED 条目必须是单帧且带 FINAL**(`frame_index = 0`)。 +- GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),`from=0` 的拉取计入下载次数。 +- 因此客户端上传**多帧条目(文件)或带下载上限的条目时,必须设置 GATED**。 +- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。 + +**幂等**:服务端以 `(channel_id, item_id, frame_index)` 为键。重复提交内容相同则返回原 seq,内容不同返回 409。因此客户端只需「重发直到收到确认」,断线、超时、重复提交都不会产生重复帧。 + +**续传**:客户端记住处理过的最大 seq,重连时用 `from = last_seq + 1`。 + +**墓碑与全量同步**:墓碑保留 7 天(服务端可配置)后被清理,清理水位记为 `compacted_through`,频道信息里的 `oldest_seq = compacted_through + 1`。如果客户端的 `from` 满足 `0 < from < oldest_seq`,它可能漏掉了已被清理的删除事件,服务端返回 `410` 并带 `oldest_seq`。此时客户端必须**从 `from=0` 全量同步**:重新读取日志,并丢弃本地存在、但全量日志里已不存在的条目。**不要从 `oldest_seq` 开始读**,否则会漏掉 seq 小于它的、仍然存活的条目。`from=0` 永远允许。 + +## 9. 快照 + +带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是**非 GATED 的单帧**(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 `reason = 4` 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。 + +## 10. 终端与席位 + +- 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。 +- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。 +- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。 +- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。 +- **设备名格式(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. 创建与验证码 + +创建频道是匿名接口,需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。 + +流程: +1. 前端从 `GET /api/v1/info` 读到 `captcha.enabled` 和 `captcha.api_endpoint`。 +2. 如果启用,前端用 Cap 的 widget(可隐藏界面,后台求解)连接 `api_endpoint`,求解挑战并换得一个 token。 +3. 创建频道时把 token 放进请求头 `X-WD-Cap`。 +4. 服务端把 token 连同自己持有的 secret 交给 Cap 的 `POST //siteverify` 校验,通过才创建。token 一次有效。 + +频道 id 与 token **不绑定**(Cap 的 token 是通用的),重放由 Cap 的一次性保证。创建之后的所有操作都不需要验证码。验证码不影响加密:它既不接触密钥,也不接触内容。 + +## 12. 设置 + +频道设置对服务端可见,服务端据此执行: + +| 设置 | 默认 | 范围 | +|---|---|---| +| `seats` | 2 | 1..64(服务端可配置上限) | +| `members_can_upload` | true | | +| `delete_policy` | `own` | `owner_only` / `own` / `anyone` | +| `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 | +| `max_items` | 1000 | 超出后淘汰最旧的条目 | +| `file_max_downloads` | 10 | 0 为不限,仅对 GATED 条目生效,可被条目单独覆盖 | +| `ui_restrict_copy_download` | false | 仅供前端展示,服务端不强制 | + +频道在没有存活条目且空闲超过 `ttl_seconds` 后删除。 + +## 13. 限制(服务端默认,可配置) + +| 项 | 值 | +|---|---| +| 负载上限 | 1 MiB + 16 B | +| 每条目帧数 | 65,536(约 64 GiB) | +| 每频道字节数 | 1 GiB | +| 每频道条目数 | `max_items` | + +## 14. 接收方校验算法 + +对 enc 频道的每个条目: +1. 对每个收到的帧,用 `K_item`、规定的 IV 和 AAD 解密,失败则丢弃整条目并报告篡改。 +2. 要求 `frame_index` 从 0 连续,出现缺口则条目为「不完整」。 +3. 只有收到已认证的 FINAL 帧且前面连续无缺口,才标记为「完整」。 +4. 同一 `(item_id, frame_index)` 重复出现,内容必须逐字节相同。 +5. 帧的 flags 中 ENC 必须为 1;raw 频道中必须为 0。 + +## 15. 便捷分享(Share) + +便捷分享是和频道**并列**的另一种顶层资源:把一批文件和文本做成一份**只读清单**发给别人,**完成后封存**,之后没人能改,过期自动清除。它不是频道的变体:没有成员、没有上传、没有实时推送、没有日志和 seq。 + +| | 频道 | 便捷分享 | +|---|---|---| +| 路径 | `/c/{id}#k=…` | `/s/{id}#k=…` | +| 接口 | `/api/v1/channels/*` | `/api/v1/shares/*` | +| 模式 | enc 或 raw | **只有 enc** | +| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 | +| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 | +| 访客 | 成员,占席位 | 只读访客,按终端计数,不展示人数 | + +分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。 + +### 15.1 密钥 + +沿用 §3 的派生:`K → K_enc / T_read`,`A → T_owner`。分享**没有成员写入**,所以不派生也不存储 `T_write`。帧的 AAD 里的 `channel_id` 在分享中就是分享 id。 + +### 15.2 状态机 + +``` +草稿 (draft) ──[ finish ]──► 已封存 (sealed) ──[ 到期 / 下载次数用完 ]──► 清除 + │ 创建者可加、删、改设置 │ 任何人(含创建者)都不能写 + └─[ 24 小时未完成 / 创建者删除 ]──► 清除 +``` + +| 阶段 | 谁能做什么 | +|---|---| +| 草稿 | 只有持所有者令牌的创建者:追加帧、删除条目、改设置、删除草稿、完成分享。**读令牌看不到草稿**(返回 `not_found`),此时也没有分享链接 | +| 已封存 | 持读令牌的任何人:读清单、读分享信息、下载。**没有任何写入口**,持所有者令牌的请求也一律拒绝 | +| 清除 | 到有效期,整个分享被清除;文件的下载次数用完,该文件被清除 | + +### 15.3 清单(manifest) + +访客要看到文件名和大小,但这些在每个条目**第 0 帧的加密元数据**里,而读取 GATED 条目的第 0 帧会计一次下载,所以不能让访客逐个文件去读来拼清单。解决办法:创建者在**完成分享时**把清单本身加密成一个条目。 + +- 清单是一个**非 GATED 的单帧条目**,元数据 `kind = "manifest"`,内容是 UTF-8 JSON。 +- 服务端只知道「哪个条目是清单」(`finish` 时由创建者指定),看不到内容。 +- 清单内容(字段顺序固定,便于测试向量字节级比对): + +```json +{"v":1,"items":[ + {"t":"file","id":"<条目 id,base64url>","name":"a.pdf","mime":"application/pdf","size":123456,"ts":1700000000000}, + {"t":"text","name":"留言","text":"短文本直接内联在清单里","ts":1700000000000} +]} +``` + +| 字段 | 说明 | +|---|---| +| `t` | `file` 或 `text` | +| `id` | 仅 `file`:文件条目的 id,访客据此下载 | +| `name` | 文件名或文本标题,可选 | +| `mime` | 仅 `file`,可选 | +| `size` | 仅 `file`:内容总字节数。下载完成后必须与实际字节数一致,否则判定被截断 | +| `text` | 仅 `text`:内容。文本**内联**在清单里,所以不占下载次数,也没有「已用完」 | +| `ts` | 毫秒时间戳,仅供参考 | + +清单单帧最大约 1 MiB。文本太多放不下时,把大文本当文件添加。 + +### 15.4 草稿阶段 + +- 创建者在浏览器里为每个文件或文本生成 `item_id`,加密成帧,逐帧 `POST /shares/{id}/frames`。帧格式、IV、AAD、padding、幂等重发与 §4–§7 完全相同,所以**断点续传和重试同样安全**。 +- 文件条目必须是 GATED(多帧);**不允许 SNAPSHOT 标志**;文本不单独成条目,内联进清单。 +- 文件的**下载次数上限**通过第 0 帧的 `X-WD-Max-Downloads` 设置,未设置时用分享设置里的 `file_max_downloads`。 +- 草稿在服务端有 24 小时的存活期(可配置),防止遗弃的草稿一直占着空间。草稿阶段不消耗有效期。 + +### 15.5 完成分享(封存) + +`POST /shares/{id}/finish`,请求体 `{"manifest_item_id": ""}`,需要所有者令牌。服务端校验: +1. 分享处于草稿状态。 +2. 清单条目存在,是**单帧、非 GATED、已完整(带 FINAL)**。 +3. 草稿里**所有**条目都已完整(没有传了一半的)。 + +校验通过后,在**一个事务**里: +- 状态改为已封存,记下封存时间和清单条目 id。 +- **清除所有者令牌的哈希**。此后即使有人保留了旧的所有者令牌,服务端也无法验证它,所有写操作一律拒绝。 +- 有效期从**此刻**起算:`expires_at = now + ttl_seconds`,并把所有条目的过期时间对齐到它。 + +`finish` 是**一次性、不可撤销**的。创建者的浏览器在收到成功响应后,还要自己丢弃所有者密钥 `A`(`@webdrop/core` 的 `ShareSession.markSealed()` 会做)。服务端强制和浏览器丢弃是两层保证:前者防止别人(包括恶意的、保留了令牌的人)再写,后者防止创建者自己误操作。 + +### 15.6 访客 + +- 访客打开 `/s/{id}#k=…`,**没有「加入」步骤**。`GET /shares/{id}/manifest` 或下载接口的**首次读取**会登记这个终端(终端密钥的哈希)。 +- **最多访客** `max_visitors`(0 为不限):登记满了拒绝新终端(`visitors_full`),已登记的终端可以重复访问。**创建者的终端(创建时的 `X-WD-Endpoint`)不占名额**。服务端永远不向访客展示访客人数。 +- 访客只能预览、下载、复制。没有任何编辑或删除入口。 +- `GET /shares/{id}` 返回每个文件的**剩余下载次数**(`downloads_left`,不限为 `null`),创建者封存后靠它看进度。 + +### 15.7 保证与不保证 + +保证: +- 封存后**服务端不再接受任何写入**,包括持有旧所有者令牌的人。 +- 内容机密性、完整性与频道相同(AES-GCM、AAD 绑定分享 id、条目 id、序号、标志)。服务端没有 `K`,无法伪造清单或文件。 +- 访客用清单里的 `size` 核对下载结果;用请求的条目 id 核对帧头里的条目 id,防止服务端换成同一分享里的别的条目。 + +不保证(要如实告诉用户): +- **发错了撤不回**,只能等过期。所以「完成分享」前必须有明确确认。 +- **持有链接就能下载**,谁拿到链接谁就是访客。 +- 服务端仍然看得到访问时间、IP 和每个文件被拉取的次数(元数据),看不到内容。 +- 服务端不受信任:它**可以**拒绝服务或提前删除内容(可用性),但**不能**让访客看到被篡改的内容而不被发现。 +- 「封存后不再接受写入」是服务端按本协议实现的行为。对一个恶意服务端,它保证不了这条;但恶意服务端没有 `K`,无法产出能通过访客校验的新帧,所以最坏情况是内容消失,而不是内容被替换。 + +## 16. 版本与测试向量 + +- 协议版本在封装的 `ver` 字段和 HKDF 的 `info`(`webdrop/v1/...`)中同时体现。 +- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。 +- 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。 diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..3540a5b --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,87 @@ +# 威胁模型 + +## 1. 信任边界 + +| 角色 | 是否受信 | +|---|---| +| 用户浏览器中运行的前端代码 | **受信**(信任根,见 §5 的残余风险) | +| 后端进程、其内存、磁盘、日志、核心转储 | **不受信** | +| 反向代理、CDN、TLS 终结点、负载均衡 | **不受信** | +| 网络路径 | 不受信 | +| 持有链接的成员 | 对频道内容受信,对管理权不受信 | + +设计目标:即使整个服务端被攻击者完全控制,攻击者也读不到内容和文件名,并且无法在不被发现的情况下篡改、重排、截断或替换内容。 + +## 2. 服务端能看到什么 + +| 能看到 | 说明 | +|---|---| +| 频道 id、模式、设置 | 席位数、权限、TTL、下载上限等,服务端需要据此执行 | +| 帧数量、时间、序号 | 追加顺序由服务端分配 | +| 密文大小 | 经过档位 padding,见 protocol §6 | +| 帧标志 | FINAL、SNAPSHOT、GATED、ENC | +| 条目 id、帧序号 | 随机值,与内容无关 | +| 令牌的哈希、终端的哈希 | 不是原值,但每次请求中会短暂看到令牌原值 | +| 下载次数 | 服务端计数 | +| 传输层信息 | IP、TLS 指纹、访问时间 | + +| 看不到(enc 模式) | 说明 | +|---|---| +| 内容 | 文本、JSON、文件字节 | +| 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 | +| 密钥 K、管理密钥 A | 只在 URL fragment 中,不会发给服务器 | + +`raw` 模式下服务端能看到全部负载,界面必须明确提示。 + +## 3. 令牌的含义 + +令牌(`T_read` / `T_write` / `T_owner`)是授权凭证,每次请求都会经过服务端,所以恶意服务端可以截获它们。这不会导致内容泄露: +- 令牌由 K 经 HKDF 单向派生,不同用途用不同 info 标签,令牌无法反推 K 或 K_enc。 +- 令牌只授权访问密文,而密文本来就在服务端手里。 +- 截获 `T_owner` 的恶意服务端可以改设置、删除内容,影响的是**可用性**,不是机密性或完整性。 + +## 4. 攻击与对策 + +| 攻击 | 对策 | +|---|---| +| 服务端篡改帧内容 | AES-GCM 认证,失败即拒绝 | +| 重排、丢弃中间帧 | 帧序号在 AAD 中,接收方要求 0..n 连续 | +| 截断条目 | FINAL 标志在 AAD 中,没收到已认证的 FINAL 帧就标记为不完整 | +| 把帧搬到其他频道或条目 | AAD 绑定 channel_id 和 item_id | +| 翻转 FINAL 或 SNAPSHOT | 标志位在 AAD 中 | +| 模式降级(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 位随机 | +| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 | + +## 5. 前端信任根(残余风险) + +端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。 + +第一版的缓解: +- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端。 +- 所有脚本使用 SRI,构建产物带版本哈希,发布时公布哈希。 +- 前端托管方被攻破的风险仍然存在。 + +后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。 + +## 6. 不做的事 + +- **不隐藏 IP 和访问时间**。需要的话用 Tor 或自己的代理。 +- **不阻止成员保留明文**。复制和下载发生在浏览器里,服务端只能限制拉取密文的次数。「界面禁止复制或下载」只是界面约束。 +- **不防服务端的可用性攻击**:隐瞒、删除或拒绝服务。 +- **不提供前向保密**:持有链接的人可以解密该频道的全部历史密文。保护手段是删除密文和 TTL。 +- **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。 +- **不防已被攻破的终端**。 +- **不做口令叠加、后量子**,留待后续版本。 + +## 7. 服务端内存与日志的要求 + +- 服务端代码路径中不出现明文和 K,也没有解密函数。 +- 令牌只做哈希比对,使用常量时间比较,不写日志。 +- 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。 +- 错误信息不回显令牌和任何用户提交的字节。 diff --git a/docs/ui-design.md b/docs/ui-design.md new file mode 100644 index 0000000..79d4dac --- /dev/null +++ b/docs/ui-design.md @@ -0,0 +1,361 @@ +# 界面设计 + +前端的视觉与交互权威文档。协议、接口以 [protocol.md](protocol.md)、[api.md](api.md) 为准,本文只定义它们在界面上如何呈现。 + +## 1. 原则 + +1. **工具感的实用极简**:界面是载体,内容是主角。信息密度适中,层级靠字号、字重、间距,不靠装饰。 +2. **诚实**:安全状态如实呈现,不夸大。服务端能看到的、不能保证的,界面必须说清楚(见 §7)。 +3. **手机与电脑同一套骨架**:同一组组件,按宽度重排,不做两套界面。 +4. **克制的质感**:只在两处用 blur,只在浮层用阴影。 + +## 2. 反 AI 味清单(禁止项) + +| 禁止 | 原因 | +|---|---| +| 装饰性渐变、霓虹色、彩色大色块 | 默认 AI 模板特征。渐变只允许用来表达状态或层次,见 §4 | +| 超过 12px 的圆角、pill 形容器 | 同上 | +| 卡片套卡片、满屏悬浮玻璃面板 | 层级混乱,且耗性能 | +| 眉标签(eyebrow)、装饰性副标题、营销式文案 | 工具不需要 | +| Tailwind 默认式重阴影 | 见 §4 | +| 默认的 Inter / Roboto,默认细线图标集原样使用 | 无个性 | + +## 3. 色彩 + +灰白为主,亮暗两套。默认跟随系统(`prefers-color-scheme`),顶栏的外观菜单可手动指定「跟随系统 / 亮色 / 暗色」,选择只存在本机 `localStorage`,启动时在渲染前应用,避免闪烁。暗色变量在 `tokens.css` 里出现两次(跟随系统与手动指定),修改必须同步。 + +| token | 用途 | +|---|---| +| `--bg` / `--surface` | 页面底 / 输入、浮层底 | +| `--line` | 1px 分隔线,替代卡片边框 | +| `--text` / `--text-dim` | 正文 / 次要信息(时间、设备名) | +| `--accent` | 主按钮、焦点、选中态,与正文同为墨色 | +| `--ok` | enc 与已认证完整 | +| `--warn` | raw 模式、不完整、下载次数将尽 | +| `--danger` | 篡改、错误、删除 | +| `--busy` | 进行中、重连中 | + +**语义色只表达安全与状态,不做装饰。** 颜色字面量只允许出现在 `src/styles/tokens.css`,其余 CSS 一律 `var(--*)`,由 stylelint 强制。亮暗两套的语义色都要过对比度(正文 ≥ 4.5:1)。 + +## 4. 形状、阴影、blur + +| 项 | 规则 | +|---|---| +| 圆角 | 三档:`--radius-sm` 4、`--radius-md` 8、`--radius-lg` 12。只能用 token | +| 分隔 | 列表用 1px `--line`,不用阴影、不用卡片 | +| 阴影 | 只有 `--shadow-pop`,只给菜单、弹窗、Toast | +| blur | **仅顶栏与手机底部动作栏**使用 `backdrop-filter: var(--blur-bar)`,半径 10px | + +渐变:低饱和、小面积,只表达状态或层次,定义为 `tokens.css` 里的 token(`--fade-down`、`--tile-gradient`、`--progress-gradient`、`--warn-bar`、`--*-soft` 底色): + +| 用处 | 做法 | +|---|---| +| 顶栏下沿 | 向下渐隐到页面底色,代替硬分隔线 | +| raw 警示 | 顶栏下沿换成暖色渐隐条 | +| 传输进度 | 细条,左浅右深 | +| 冲突、篡改、警示行 | 左侧色条 + 向右渐隐的同色底 | +| 在线点 | 缓慢呼吸,不用发光阴影 | + +blur 的性能约束: + +- 全页面同时存在的 `backdrop-filter` 元素不超过 2 个。 +- 禁止用于滚动列表中的元素,以及弹窗遮罩(遮罩用纯色 `--scrim`)。 +- 必须有回退:`@supports not (backdrop-filter: blur(1px))` 与 `prefers-reduced-transparency: reduce` 时,背景退化为不透明的 `--bg`。 +- 玻璃背景下的文字对比度按「最差底色」验证,不得依赖背后内容。 + +## 5. 字体与图标 + +| 用途 | 字体 | +|---|---| +| 界面文字 | 思源黑体(`--font-sans`,系统无衬线兜底) | +| 链接、哈希、密钥、文件大小 | 等宽(`--font-mono`) | + +字号档位不超过 5 档,字重只用 400 与 600。图标自绘 SVG,统一 1.5px 描边、24 网格,放在 `ui/icons/`,不引入图标库。 + +## 6. 布局与页面 + +WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,不按「谁发的」;发送设备只作为一行小字。 + +### 6.1 手机(< 900px):最新 + 分类 + 底部动作栏 + +``` +┌────────────────────────────┐ +│▒ abc123 🔒加密 ●●○ 2/3 🔍 ◐ ⇧│ ← 模糊顶栏 +├────────────────────────────┤ +│ 最新 │ 最近一项放大,拇指区一键取走 +│ [图标] 屏幕录制.webm │ +│ iPhone · 14:20 · 1.8 MB │ +│ [ 预览 ] [ 下载 ] │ 文本则是 [ 复制 ] +├────────────────────────────┤ +│ ▸ 便签 待办… │ 折叠,点开编辑 +├────────────────────────────┤ +│ [全部][文件][文本][图片]… │ 类型分组常驻(可横向滑动) +│ [缩略] project-assets.zip ⬇│ +│ [缩略] 设计稿.svg v3 有更新 ⬇│ +│ [文本] 明天 10 点开会… ⧉ │ +├────────────────────────────┤ +│▒ 粘贴 文件 相册 拍照 扫码 写 │ ← 模糊底部动作栏 +└────────────────────────────┘ +``` + +- 底部是**动作**而不是输入框:粘贴(读剪贴板,文字或图片)、文件、相册、拍照、扫码、写。点「写」才弹出输入。 +- 点列表项或「最新」卡打开底部抽屉详情(预览、操作、历史)。 + +### 6.2 电脑(≥ 900px):列表 + 内联预览 + 窄栏 + +``` +┌───────────────────────────────────────────────────┬───┐ +│ abc123 🔒加密 ●●○ 2/3 🔍 ◐ │ ‹ │ +├────────────────────┬──────────────────────────────┤2/3│ +│ [粘贴][文件][扫码][写]│ 屏幕录制.webm │ ⇧ │ +│ ▸ 便签 待办… │ iPhone · 14:20 · 剩 8 次下载 │ │ +│ [全部 文件 文本 …] │ [下载] [删除] │ │ +│ ☐ [缩略] zip ⬇ │ ┌────────────────────────┐ │ │ +│ ☐ [缩略] 设计稿 ⬇ │ │ 内联预览(视频/图片/目录) │ │ │ +│ ☐ [文本] 开会… ⧉ │ └────────────────────────┘ │ │ +│ 已选 2 项 [下载] │ 修改历史 v3 当前 / v2 / v1 │ │ +└────────────────────┴──────────────────────────────┴───┘ + 全局:Ctrl+V 粘贴即发送;拖到窗口任意位置即上传 +``` + +- 点左侧一行,右侧内联预览,不弹窗来回开关;默认选中最新一项。 +- 多选后可批量下载。 +- 设备与分享是低频操作,默认收成右侧窄栏(在线数、分享图标),需要时展开。 +- 窗口任意位置 `Ctrl+V` 都能直接发送(在输入框内粘贴时不拦截);拖到窗口任意位置即上传。 + +### 6.3 顶栏 + +| 控件 | 作用 | +|---|---| +| 频道 id + 加密/明文徽标 | 当前频道与安全状态(§7.1) | +| 在线点 + `N/M` | 设备在线情况;手机上点开设备与分享抽屉 | +| 连接状态 | 仅在重连中或离线时显示 | +| 搜索 | 展开关键字输入(文本内容或文件名);再次点击关闭并清除关键字。类型分组一直可见 | +| 外观 | 跟随系统 / 亮色 / 暗色 | +| 设备与分享(仅手机) | 打开抽屉 | + +### 6.4 首页(初始状态)与页面 + +初衷是**快速分享文件和文本**,所以什么都没创建时,首页的主角就是「快速分享」,而不是「先建频道」。 + +``` +┌──────────────────────────────────┐ +│ WebDrop │ +│ 在自己的设备之间快速传文件和文本, │ +│ 也可以分享给别人。 │ +│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ +│ 快速分享 │ +│ 把文件拖到这里,或 Ctrl+V 粘贴 │ +│ 自动创建加密临时频道(24 小时) │ +│ [选择文件] [粘贴] [写文本] │ 触屏上多 [相册] [拍照] +│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ +│ [ 新建频道 ] [ 加入频道 ] │ +│ 我的频道(有才显示) │ +│ abc123… 🔒加密 所有者 3 台设备 │ +└──────────────────────────────────┘ +``` + +| 操作 | 结果 | +|---|---| +| 快速分享(拖入、粘贴、选择、写文本、相册、拍照) | 自动创建一个**分享草稿**(加密、只读、最多 10 位访客、24 小时、每个文件下载 10 次),把内容放进去;点「完成分享」后生成链接与二维码 | +| 新建频道 | 抽屉:模式(加密 / 明文)、设备数、保留时间、允许成员上传;创建后直接展示分享面板与二维码 | +| 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 | +| 我的频道 | 点一行进入;垃圾桶只是从本机移除,**不会删除频道**,需二次确认 | + +| 路由 | 页面 | +|---|---| +| `/` | 首页 | +| `/c/:id` | 已在「我的频道」里 → 频道暂存区;否则链接里带密钥 → 整页加入预览;否则「无法打开此频道」 | + +别人发来的链接打开时,页面一加载就把 `#` 后面的密钥取走并清掉地址栏,再显示加入预览,确认后才加入。 + +频道分享面板与分享页里的二维码只给**成员链接**;所有者链接含管理权限,只提供复制,不出二维码。二维码固定深色码点、浅色底,暗色主题下也不反转,否则很多扫码器识别不了。 + +### 6.4.1 分享(快速分享的结果) + +**分享和频道是两种东西,底层只维护一套**:分享是频道的一个预设——成员不可上传、删除策略仅所有者,并且创建者在点「完成分享」的那一刻**丢弃所有者密钥**,从此没有房主,任何人(包括创建者)都不能再追加、修改或删除,只能等 TTL 到期。 + +``` +草稿(创建者) 已封存(创建者与访客) +┌──────────────────────┐ ┌──────────────────────┐ +│ 新建分享 草稿 │ │ 我的分享 🔒已封存·只读│ +│ [粘贴][文件][写] │ │ 分享给别人 │ +│ ▢ a.zip 46MB ✕ │ │ 链接 [复制] [二维码] │ +│ 〃 开会地址… ✕ │ │ 最多 10 位 · 剩 24 小时 │ +│ 有效期 最多访客 下载次数│ │ 3 项内容 [全部下载] │ +│ [ 完成分享 ] │ │ 〃 开会地址… │ +└──────────────────────┘ │ ▢ a.zip [下载] │ + └──────────────────────┘ +``` + +| 阶段 | 说明 | +|---|---| +| 草稿 | 创建者还握着所有者密钥:可以添加、移除、调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 | +| 完成分享 | 确认框写明「此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期;发错了也撤不回」。确认后生成链接与二维码 | +| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) | +| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 | + +- 访客打开链接后先看加入预览,确认才占席位。访客那一端如何识别「分享」:`members_can_upload=false` 即渲染为只读收件页,不需要新协议字段。 +- 创建者的所有者链接不展示、不出二维码。 +- 首页分成「我的分享」(草稿 / 已封存 / 收到的,带剩余时间)和「我的频道」。 + +其他弹层: + +| 名称 | 说明 | +|---|---| +| 条目详情 | 手机为底部抽屉,电脑为内联预览区;内容一致 | +| 扫码加入 | 相机识别二维码(浏览器自带 `BarcodeDetector`,不支持或无权限时退回「粘贴链接」)→ 加入预览(模式、权限)→ 加入后进入该频道 | +| 写文本 | 抽屉,`Ctrl+Enter` 发送;首页上用于快速分享 | +| 频道设置 | 尚未实现(席位管理等) | + +扫到的链接含密钥,只放在内存里,不写日志、不持久化。 + +> 原型说明:前端目前是验证设计的框架,频道列表只在内存里,刷新即清空;真实实现把链接存进 IndexedDB(architecture §6)。 + +### 6.5 条目 + +列表里的一行按内容类型决定画法,**不大的文本、JSON、视频直接画在行里,不用点开**: + +| 内容 | 列表里的画法 | 快捷操作 | +|---|---|---| +| 文本 | 内容本身(没有标题);太长只显示前 6 行,点击进详情 | 复制 | +| JSON(能解析成对象或数组) | 格式化后等宽显示;超过 14 行或 1200 字符截断 | 复制 | +| 不大的视频(≤ 25 MB) | 行内播放器(`controls`、`preload="metadata"`) | 下载 | +| 图片 | 缩略图 + 标题 + 小字 | 下载 | +| 压缩包、其他文件、大视频 | 类型图标 + 标题 + 小字 | 下载 | + +小字依次为:设备 · 时间 · 大小 · 剩余下载次数,后面跟状态标签:`v3`(有历史)、`有更新`、`冲突`。传输中在行内显示细进度条。点标题行或文本区域打开详情(手机底部抽屉,电脑内联预览区)。 + +**不能烧下载次数**:按协议,文件超过 1 MiB 或设了下载上限就是 GATED,每次拉取都计一次下载(api.md)。所以: + +- 文本与 JSON 内容本来就在内存里,一律直接画。 +- 没有下载上限的视频、图片自动加载;**有下载上限的**不自动拉取:图片显示类型图标,视频显示占位「点击播放 · 会消耗 1 次下载」,点了才加载。 +- 滚动列表、刷新页面都不应该消耗下载次数。 + +- 长列表使用 `content-visibility: auto`。注意它会让后代的 `position: fixed` 失效,弹层必须用 portal 挂到 `body`。 +- **新的在前**。 +- 内联渲染一律是纯文本或原生媒体元素,不执行内容(threat-model §4)。 + +预览: + +| 类型 | 呈现 | +|---|---| +| 图片、视频、音频 | 原生元素播放 blob URL,不执行内容 | +| 文本、JSON | 纯文本等宽展示,JSON 格式化,最多读取前 200,000 字符 | +| 压缩包 | 只列出目录树与大小,不解压、不执行 | +| 其他 | 提示「暂不支持预览」,保留下载 | + +### 6.6 修改、历史与便签 + +- **修改 = 追加新版本,后写者胜**(不改协议)。文本编辑后显示「已编辑」;两台设备同时改同一条时,后到者生效,先到者的内容以冲突提示展示:「某设备同时改过:… [用他的][保留我的]」。 +- **文件修改历史**:新版本是一个替代旧条目的新条目,界面把它们折叠成同一个文件。条目显示 `v3`;别的设备更新了你还没看过的文件时显示「有更新」,详情里有一行「某设备 更新了此文件 [知道了]」;详情下方列出历史版本,可单独下载旧版本。需要在加密的 `meta_json` 里加可选字段 `replaces`(旧 `item_id`),后端不解析 meta,待写入 protocol §7。 +- **便签**是频道唯一的快照槽(protocol §9),多设备实时同步,最后写入者胜,不做字符级合并。默认折叠,折叠时显示第一行;他人正在编辑时显示「某设备 正在编辑」。 +- 暂不做字符级协同编辑(需要新增协议条目类型,属于后续议题)。 + +## 7. 状态与诚实提示 + +### 7.1 频道安全徽标(顶栏) + +| 状态 | 呈现 | +|---|---| +| enc | 小号锁图标 + 「加密」,`--ok`,点开说明「内容在浏览器内加密,服务端只见密文」 | +| raw | **整条顶栏下沿加 `--warn` 色条**,文字「明文 · 服务端可见」,不可收起 | + +### 7.2 条目状态 + +条目状态只有以下几种,由帧推导,组件按判别联合穷尽处理: + +| 状态 | 条件 | 呈现 | +|---|---|---| +| `receiving` | 无 FINAL,帧连续 | `--busy` 进度,标注「不完整」 | +| `complete` | 已认证 FINAL 且 0..n 连续 | 正常 | +| `incomplete` | 缺口,或长时间无 FINAL | `--warn`,可重试拉取 | +| `tampered` | 认证失败 | **整条替换为警示行**,`--danger`,说明「内容被篡改,已丢弃」,不展示任何内容 | +| `expired` / `removed` | tombstone | 淡出后移除,可短暂保留一条「已过期 / 已删除」提示 | + +### 7.3 连接状态(顶栏) + +`在线` · `重连中` · `离线(N 条待发送)`。离线时仍可添加内容,发送进入队列,恢复后自动补发,无需用户操作。 + +### 7.4 必须如实表述的内容 + +| 场景 | 文案原则 | +|---|---| +| `ui_restrict_copy_download` | 说明这是界面限制,「成员仍可通过其他方式保存内容」,不暗示安全保证 | +| 下载次数 | 下载前提示「此文件剩余 N 次下载」;达上限提示「已被删除」 | +| 链接 | 分享前说明「持有链接即可访问」;成员链接与所有者链接分开展示,所有者链接标注「含管理权限」 | +| 历史可读 | 加入说明处写明「持有链接的人可读取此频道全部历史」 | +| 元数据 | 创建与设置页注明「不隐藏 IP 与访问时间」 | + +### 7.5 空与错误状态 + +| 码 | 页面 | +|---|---| +| `seats_full` | 加入预览页:「席位已满」,告知联系所有者 | +| `not_joined`(被释放) | 频道页整体替换为「你已被移出此频道」 | +| `not_found` / `gone` | 「频道不存在或已过期」 | +| `rate_limited` | Toast,带重试倒计时 | +| 链接缺密钥 / 模式不符 | 拒绝进入,明确说明原因(降级防御,见 protocol §2) | + +## 8. 交互 + +| 场景 | 行为 | +|---|---| +| 发送文本 | 点「写」弹出输入,`Ctrl+Enter` 发送 | +| 粘贴 | 窗口任意位置 `Ctrl+V` 直接发送;手机点「粘贴」读取剪贴板(无权限时退回「写」)。文件或图片直接入队,先出现「传输中」条目 | +| 拖放 | 桌面整页接收拖放;拖入时出现覆盖提示,不改变布局 | +| 复制 | 点击复制,按钮短暂变为「已复制」,不弹 Toast | +| 删除 | 单击删除后条目消失并给 5 秒「撤销」(撤销只在本地延迟提交删除) | +| 新条目到达 | 若用户已滚到底部则自动跟随,否则底部出现「N 条新内容」 | +| 触控 | 可点击区域 ≥ 44px;底栏避开安全区(`env(safe-area-inset-*)`) | + +## 9. 动效 + +使用 React `ViewTransition`(稳定版 React 19.3 已包含),原则是**只在有空间关系处动**: + +| 场景 | 动画 | +|---|---| +| 首页 ↔ 频道 ↔ 设置(层级导航) | 方向性滑入 `nav-forward` / `nav-back` | +| 条目新增 | CSS 淡入(`row-in`)。store 经 `useSyncExternalStore` 订阅,更新不在 transition 内,`ViewTransition` 不会触发,所以不用它 | +| 开屏 | 单线手写 `WebDrop` 逐笔画出,约 1.2s,仅每个会话首次出现,点击或按键可跳过;加载未完成时停在末笔 | +| 桌面左栏切换频道 | 淡入淡出,不滑动 | +| 条目状态变化、重连、后台同步 | **无动画**(`default="none"`) | + +规则: + +- 一律 `default="none"`,显式开启需要的触发。 +- 时长:退出 150ms、进入 210ms、移动 400ms,取自 `--duration-*`。 +- 必须尊重 `prefers-reduced-motion`(已在 `base.css`)。 +- 顶栏、底栏用 `viewTransitionName` 隔离,并采用 backdrop-blur 的专用写法,避免闪烁。 +- 不支持的浏览器直接无动画,功能不受影响。 + +## 10. 无障碍 + +- 所有交互元素可键盘操作,焦点环可见(`--accent`)。 +- 状态不只靠颜色,配图标与文字。 +- 动态内容(新条目、连接状态)用 `aria-live="polite"`。 +- 触控目标 ≥ 44px;文本可缩放到 200% 不破版。 + +## 11. 文案 + +中文为默认语言;文案用语直白,不用感叹号与营销口吻。暂不做多语言,文案直接写在组件里。 + +## 12. 手机适配(iPhone / Android) + +| 方面 | 做法 | +|---|---| +| 刘海与手势条 | `viewport-fit=cover`;顶栏用 `env(safe-area-inset-top)`,底部动作栏与抽屉用 `inset-bottom`,手机布局两侧用 `inset-left/right`(横屏) | +| 视口高度 | `100vh` 兜底 + `100dvh`,避开地址栏伸缩 | +| 软键盘 | 需要输入的抽屉(写文本)从**顶部**出来,iOS 不缩小布局视口,底部抽屉会被键盘遮住;Android 用 `interactive-widget=resizes-content` | +| 输入框缩放 | 触屏上 input / textarea 字号 16px,避免 iOS 聚焦时自动放大 | +| 点按 | `touch-action: manipulation` 去掉双击缩放延迟;去掉点按高亮;触屏上可点区域 ≥ 44px;`hover` 样式只在支持 hover 的设备生效,避免粘在上一次点击处 | +| 返回键 | 弹层打开时压入一条历史记录,Android 返回键 / iOS 边缘滑动关闭弹层而不是离开页面(`lib/useBackToClose.ts`,这是唯一直接操作 history 的地方) | +| 状态栏颜色 | `theme-color` 跟随当前页面底色,手动切换亮暗和系统切换时都会更新 | +| Safari 旧版 | `backdrop-filter` 同时写 `-webkit-` 前缀 | +| 横屏 / 矮屏 | 高度 < 500px 时隐藏「最新」卡、动作栏改为横排;宽 ≥ 900 **且**高 ≥ 500 才用电脑布局,横屏手机不会被当成电脑 | +| 窄屏 | < 360px 时顶栏只留在线点,条目小字允许换行 | +| 相机 | 「拍照」用 ``,iOS 与 Android 都会直接拉起相机;扫码在 Android Chrome 用 `BarcodeDetector`,iOS Safari 不支持,提示用系统相机扫码后打开链接,或粘贴链接。相机、剪贴板读取都要求 https(`localhost` 除外) | +| 剪贴板 | iOS 读取时会弹出系统「粘贴」确认,Android 会要求权限;被拒绝时退回「写」 | +| 图片格式 | iPhone 的 HEIC 照片在非 Safari 浏览器里无法显示,预览失败时提示直接下载 | +| 下载 | iOS 上 `a[download]` 配合 blob 会弹出存储菜单;大文件要走 Service Worker 流式下载(架构 §6,阶段 4) | + diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/packages/core/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..525d492 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,177 @@ +# @webdrop/core + +WebDrop 的端到端加密核心。**前端只调用它,不碰密码学,也不用理解帧格式。** + +- 只依赖 WebCrypto(`crypto.subtle`),浏览器、Worker、Node 22+ 都能跑。 +- 纯逻辑:不含 UI,不发网络请求,不处理重连和队列(这些是前端的事)。 +- 协议权威文档是 [`docs/protocol.md`](../../docs/protocol.md),本库必须通过 [`test-vectors/`](../../test-vectors) 里的全部向量。 + +## 快速上手 + +```ts +import { ChannelSession, collectFrames, itemText } from '@webdrop/core'; + +// 1. 创建频道 —— 生成 id 和密钥,返回 POST /channels 要发的内容 +const { session, request } = await ChannelSession.create({ mode: 'enc' }); +const res = await fetch('/api/v1/channels', { + method: 'POST', + headers: { ...request.headers, 'X-WD-Cap': capToken /* 见下文「验证码」 */, 'Content-Type': 'application/json' }, + body: JSON.stringify(request.body), +}); +session.verifyChannelInfo(await res.json()); // 核对服务端返回的模式,防降级 + +const memberLink = session.link(location.origin); // 分享给对方:/c/{id}#k=… +const ownerLink = session.link(location.origin, 'owner'); + +// 2. 发送 —— 得到可以直接 POST 的帧 +const plan = session.encryptText('hello'); +for await (const frame of plan.frames) { + await fetch(`/api/v1/channels/${session.id}/frames`, { + method: 'POST', + headers: { ...session.authHeaders('owner'), ...(frame.index === 0 ? plan.frame0Headers : {}) }, + body: frame.bytes, + }); +} + +// 3. 对方打开链接,接收 +const member = await ChannelSession.open(location.href, { endpointSecret /* 见下文「终端密钥」 */ }); +const reader = member.createItemReader(); +ws.onmessage = async (m) => { + for (const ev of await reader.push(new Uint8Array(m.data))) { + if (ev.type === 'item' && ev.item.complete) show(itemText(ev.item)); + } +}; +``` + +## 前端要做的事 / 库做的事 + +| 库负责 | 前端负责 | +|---|---| +| 生成 id、密钥、令牌,派生一切 | HTTP 调用、WebSocket / SSE 重连与降级 | +| 加密成帧、padding、IV、AAD | 发送队列(IndexedDB)、失败重发 | +| 解密、完整性校验、乱序/重复/篡改处理 | 把事件渲染成界面 | +| 链接生成与解析、降级防御 | 持久化链接和**终端密钥** | +| 设备名加解密 | 复制、下载(把流写到文件)、预览 | +| 日志记录解析 | **Cap 验证码**(见下文) | + +## API + +### 会话 + +| 调用 | 说明 | +|---|---| +| `ChannelSession.create({ mode, settings, endpointSecret })` | 创建。返回 `{ session, request: { body, headers } }` | +| `ChannelSession.open(link, { endpointSecret })` | 打开链接(也导出为 `openLink`) | +| `session.authHeaders('read' \| 'write' \| 'owner')` | 请求头 `X-WD-Role` / `Authorization` / `X-WD-Endpoint`。没有对应权限时抛 `CapabilityError` | +| `session.can(role)` | 当前链接是否具备该角色 | +| `session.link(origin, 'member' \| 'owner')` | 生成分享链接 | +| `session.verifyChannelInfo({ id, mode })` | 核对服务端返回的频道信息,不一致抛 `ModeMismatchError` / `LinkError` | +| `session.id` / `.mode` / `.role` / `.endpointSecret` / `.endpointHash` | 只读属性 | +| `session.deviceName` | 设置后,加密的条目元数据里会带上设备名 | +| `session.dispose()` | 尽力清零密钥材料(JS 无法保证擦除) | + +### 发送 + +全部返回 `ItemPlan`:`{ itemId, gated, frame0Headers, frames }`。`frames` 是异步迭代器,逐帧产出 `{ index, final, bytes }`,`bytes` 是完整封装,直接作为 `POST /frames` 的请求体。 + +| 调用 | 说明 | +|---|---| +| `encryptText(text, opts?)` | 文本 | +| `encryptJson(value, opts?)` | JSON。`{ snapshot: true }` 作为频道的最新快照 | +| `encryptFile(blobOrStream, meta?, opts?)` | `File`/`Blob` 自动带文件名、类型、大小;`ReadableStream` 大小未知 | +| `encryptBytes(bytes, meta?, opts?)` | 任意字节 | +| `collectFrames(plan)` | 把整个计划收集成数组(小内容用) | + +常用 `opts`: + +| 选项 | 说明 | +|---|---| +| `maxDownloads` | 下载次数上限(0 不限)。会强制 `gated`,并在 `plan.frame0Headers` 里给出 `X-WD-Max-Downloads`,**必须随第 0 帧发送** | +| `gated` | `'auto'`(默认):放不进单帧或设置了 `maxDownloads` 时为 GATED。GATED 条目不实时推送内容,只推送 announce,字节要另外拉取 | +| `snapshot` | 快照必须是单帧(约 1 MiB 以内),不能 GATED | +| `itemId` | 重试和续传必须复用同一个 id | +| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` | +| `ts`, `dev` | 覆盖时间戳和设备名 | +| `label` | 仅 raw 模式:`X-WD-Label` | + +**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。 + +**重试安全**:同一个 `(itemId, index)` 只能加密一份固定明文,否则会造成 AES-GCM 的 IV 重用。库保证:同样的输入、同样的 `itemId` 得到**完全相同**的字节,所以「重发直到收到确认」是安全的。不要用同一个 `itemId` 加密不同内容。 + +### 接收 + +```ts +const reader = session.createItemReader(); +const events = await reader.push(recordOrBytes); // 日志记录(parseLogRecords 的一项,或 WebSocket 的一条二进制消息) +const events2 = await reader.pushFrame(envelope); // 单个封装(拉取 GATED 条目时按帧切出来的) +``` + +事件: + +| `type` | 含义 | +|---|---| +| `item` | 条目有新内容或已完成。`item.complete`、`item.kind`、`item.meta`、`item.content`、`item.snapshot`、`item.gated`。小内容(单帧)只触发一次 | +| `announce` | GATED 条目的通知,字节要用 `GET /items/{id}/frames` 拉取(计下载次数) | +| `removed` | 条目被删除/过期/淘汰/取代/下载达上限,`reason` 说明原因 | +| `rejected` | 一帧被拒绝(篡改、乱序、模式不符……),`reason` 说明原因。**不会抛异常**,该条目的缓冲已丢弃 | + +辅助:`itemText(item)`、`itemJson(item)`。**大文件不要用 `reader` 缓冲**,用流式解密: + +```ts +const res = await fetch(`/api/v1/channels/${id}/items/${itemId}/frames`, { headers: session.authHeaders('read') }); +const { content, result } = session.decryptItemStream(res.body!); +await content.pipeTo(fileWritableStream); // 边收边解边写盘,内存只有一帧 +const { complete, nextIndex, meta } = await result; +// 连接断了:complete 为 false,用 nextIndex 续传(from=nextIndex,服务端不重复计下载次数) +``` + +校验失败时流会出错(`IntegrityError`,带 `reason`)。流正常结束但没有 FINAL **不是错误**(连接可能断了),此时 `complete` 为 `false`。 + +### 其他 + +| 调用 | 说明 | +|---|---| +| `parseLogRecords(bytes)` / `parseLogRecord(bytes)` | 解析 `GET /log` 的响应或一条 WebSocket 消息 | +| `session.sealLabel(name)` / `session.openLabel(blob, endpointHash)` | 设备名(`PUT /seats/me` 的请求体 / 席位列表里的 `label`) | +| `parseLink(url)` / `buildLink(origin, parts)` | 链接 | +| `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 | + +## 必须注意的几点 + +**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它占用席位。不持久化的话,每次打开链接都会占用一个新席位。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。 + +**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。 + +**3. 验证码(Cap)。** 工作量证明由 [Cap](https://github.com/tiagozip/cap) 提供,本库不实现。创建频道前读 `GET /api/v1/info` 的 `captcha`:`enabled` 为 true 时,用 Cap widget 连接 `captcha.api_endpoint` 求解得到 token,放进创建请求的 `X-WD-Cap` 头。 + +**4. `rejected` 不能忽略。** 它意味着服务端(或网络)给了你不符合协议的数据。至少要在界面上提示「内容校验失败」,不要静默丢弃。 + +**5. 链接就是密钥。** 密钥只放在 `#` 后面,浏览器不会把它发给服务器。拿到链接后立刻用 `history.replaceState` 清掉地址栏里的 fragment。链接里的密钥放在 `?` 后面会被 `parseLink` 直接拒绝。 + +**6. raw 模式没有保密性。** raw 频道的内容服务端完全可见,没有完整性保护,界面必须明确提示。`label`(条目标签)目前只能写入、不能从服务端读回。 + +**7. 密钥材料。** AES 密钥是不可导出的 `CryptoKey`,`K_enc` 导入后原始字节即清零。但链接里的 K/A/W 必须留在内存里(用来重新生成分享链接),令牌必须以字节形式放进请求头。**JS 无法保证擦除内存**,`dispose()` 只是尽力而为。 + +## 开发 + +```bash +npm test # 全部测试(含对着真实服务端的端到端) +npm run typecheck +npm run build # 输出到 dist/ +npm run vectors:check # 校验 test-vectors/vectors.json 是最新的 +``` + +端到端测试需要服务端可执行文件,按顺序查找:环境变量 `WEBDROP_SERVER`、`server/out/win-x64/webdrop.exe`、`server/out/linux-x64/webdrop`、`server/src/WebDrop.Api/bin/Debug/net10.0/webdrop.dll`(用 `dotnet` 运行)。都找不到时跳过这一组。 + +### 测试构成 + +| 文件 | 内容 | +|---|---| +| `vectors.test.ts` | 逐字节复现 `test-vectors/` 里 node:crypto 独立生成的全部向量,含 17 个负例 | +| `session.test.ts` | 创建、链接、角色、降级防御、收发、设备名、raw 模式、条目计划 | +| `reader.test.ts` | 日志记录、重放、乱序、缺帧、FINAL 冲突、标志变化、模式隔离 | +| `stream.test.ts` | 任意切分的字节流、流式解密、篡改/拼接/丢帧/截断、断点续传 | +| `properties.test.ts` | 每个字节每一位的篡改、padding 档位、随机往返、来源块边界对齐、性能底线 | +| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传、设备名 | + +另外对测试集做过**变异检验**:故意在实现里注入 10 类典型错误(AAD 少一字节、跳过零填充检查、去掉模式/条目 id 校验、base64 不查规范性、分块边界差一、IV 不含帧序号、重复帧不比较、FINAL 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。 diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json new file mode 100644 index 0000000..1c26f0a --- /dev/null +++ b/packages/core/package-lock.json @@ -0,0 +1,1518 @@ +{ + "name": "@webdrop/core", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@webdrop/core", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "@types/node": "^26.6.3", + "typescript": "^7.0.2", + "vitest": "^5.0.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..e89e1b8 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@webdrop/core", + "version": "0.1.0", + "description": "WebDrop 端到端加密核心:密钥派生、帧加解密、条目装配、链接。只依赖 WebCrypto,无 UI、无网络。", + "type": "module", + "sideEffects": false, + "license": "UNLICENSED", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./internal": { + "types": "./dist/internal.d.ts", + "import": "./dist/internal.js" + } + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "vectors:check": "node ../../test-vectors/generate.mjs --check" + }, + "devDependencies": { + "@types/node": "^26.6.3", + "typescript": "^7.0.2", + "vitest": "^5.0.3" + } +} diff --git a/packages/core/src/bytes.ts b/packages/core/src/bytes.ts new file mode 100644 index 0000000..bcd0dbd --- /dev/null +++ b/packages/core/src/bytes.ts @@ -0,0 +1,175 @@ +/** + * 字节工具。只用标准 JS,不依赖 Node 或 DOM 之外的任何东西。 + * + * WebCrypto 的类型(TS 5.7+)要求 BufferSource 由 ArrayBuffer 支撑, + * 所以库内部统一使用 Bytes = Uint8Array。 + */ + +export type Bytes = Uint8Array; + +/** 确保由 ArrayBuffer 支撑(SharedArrayBuffer 或其他情况下复制一份)。 */ +export function toBytes(u: Uint8Array): Bytes { + return u.buffer instanceof ArrayBuffer ? (u as Bytes) : new Uint8Array(u); +} + +export function concat(...parts: Uint8Array[]): Bytes { + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +export function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number); + return diff === 0; +} + +export function readU16(b: Uint8Array, offset: number): number { + return ((b[offset] as number) << 8) | (b[offset + 1] as number); +} + +export function readU32(b: Uint8Array, offset: number): number { + return ( + (((b[offset] as number) << 24) | + ((b[offset + 1] as number) << 16) | + ((b[offset + 2] as number) << 8) | + (b[offset + 3] as number)) >>> + 0 + ); +} + +/** 读 u64,超过 2^53 - 1 时抛错(seq 和时间戳实际远小于这个值)。 */ +export function readU64(b: Uint8Array, offset: number): number { + const hi = readU32(b, offset); + const lo = readU32(b, offset + 4); + if (hi > 0x1fffff) throw new RangeError('u64 超出 JS 安全整数范围'); + return hi * 0x1_0000_0000 + lo; +} + +export function writeU16(value: number): Bytes { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new RangeError('u16 超出范围'); + return new Uint8Array([value >>> 8, value & 0xff]); +} + +export function writeU32(value: number): Bytes { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) throw new RangeError('u32 超出范围'); + return new Uint8Array([value >>> 24, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]); +} + +export function writeU64(value: number): Bytes { + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('u64 超出范围'); + const out = new Uint8Array(8); + const hi = Math.floor(value / 0x1_0000_0000); + const lo = value >>> 0; + out.set(writeU32(hi), 0); + out.set(writeU32(lo), 4); + return out; +} + +// ───────── hex ───────── + +export function toHex(b: Uint8Array): string { + let s = ''; + for (const x of b) s += x.toString(16).padStart(2, '0'); + return s; +} + +export function fromHex(s: string): Bytes { + if (s.length % 2 !== 0 || /[^0-9a-fA-F]/.test(s)) throw new RangeError('非法的十六进制字符串'); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; +} + +// ───────── base64url(无填充,严格规范) ───────── + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const LOOKUP = (() => { + const t = new Int8Array(128).fill(-1); + for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET.charCodeAt(i)] = i; + return t; +})(); + +export function toBase64Url(b: Uint8Array): string { + let out = ''; + let i = 0; + for (; i + 2 < b.length; i += 3) { + const n = ((b[i] as number) << 16) | ((b[i + 1] as number) << 8) | (b[i + 2] as number); + out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]! + ALPHABET[(n >>> 6) & 63]! + ALPHABET[n & 63]!; + } + const rest = b.length - i; + if (rest === 1) { + const n = (b[i] as number) << 16; + out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]!; + } else if (rest === 2) { + const n = ((b[i] as number) << 16) | ((b[i + 1] as number) << 8); + out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]! + ALPHABET[(n >>> 6) & 63]!; + } + return out; +} + +/** + * 严格解码:只接受 url 字母表,拒绝填充和非规范编码(末尾多余的比特必须为 0), + * 与服务端 Codec.TryDecodeExact 的行为一致。非法输入返回 null,绝不抛异常。 + */ +export function fromBase64Url(s: string, expectedLength?: number): Bytes | null { + const len = s.length; + if (len % 4 === 1) return null; + const outLen = Math.floor((len * 3) / 4); + if (expectedLength !== undefined && outLen !== expectedLength) return null; + + const out = new Uint8Array(outLen); + let o = 0; + let acc = 0; + let bits = 0; + for (let i = 0; i < len; i++) { + const code = s.charCodeAt(i); + const v = code < 128 ? (LOOKUP[code] as number) : -1; + if (v < 0) return null; + acc = (acc << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out[o++] = (acc >>> bits) & 0xff; + acc &= (1 << bits) - 1; + } + } + if (acc !== 0) return null; // 末尾多余比特必须为 0 + return out; +} + +// ───────── 文本 ───────── + +const encoder = new TextEncoder(); +const strictDecoder = new TextDecoder('utf-8', { fatal: true }); + +export function utf8(s: string): Bytes { + return encoder.encode(s) as Bytes; +} + +/** 严格的 UTF-8 解码,非法序列返回 null。 */ +export function tryDecodeUtf8(b: Uint8Array): string | null { + try { + return strictDecoder.decode(b); + } catch { + return null; + } +} + +export function randomBytes(n: number): Bytes { + const out = new Uint8Array(n); + globalThis.crypto.getRandomValues(out); + return out; +} + +/** 尽力清零。JS 无法保证内存被真正擦除(GC 可能已复制过),这只是缩小暴露窗口。 */ +export function wipe(b: Uint8Array): void { + b.fill(0); +} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts new file mode 100644 index 0000000..358e6b7 --- /dev/null +++ b/packages/core/src/constants.ts @@ -0,0 +1,49 @@ +/** 协议常量,与 docs/protocol.md 一一对应。 */ + +export const PROTOCOL_VERSION = 1; + +export const ID_SIZE = 16; +export const KEY_SIZE = 32; +export const TAG_SIZE = 16; +export const HEADER_SIZE = 26; + +/** 帧标志(protocol §4)。 */ +export const Flags = { + Final: 1, + Snapshot: 2, + Gated: 4, + Enc: 8, +} as const; + +/** 档位 padding 的明文长度(protocol §6)。 */ +export const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576] as const; + +/** 单帧明文上限 1 MiB,含 4 字节长度前缀,所以 data 最多 1,048,572 字节。 */ +export const MAX_FRAME_PLAINTEXT = 1048576; +export const MAX_FRAME_DATA = MAX_FRAME_PLAINTEXT - 4; + +/** raw 模式的负载没有加密开销,单帧上限取 1 MiB。 */ +export const RAW_MAX_PAYLOAD = 1048576; + +/** HKDF info 标签(protocol §3)。 */ +export const Labels = { + enc: 'webdrop/v1/enc', + read: 'webdrop/v1/token/read', + write: 'webdrop/v1/token/write', + owner: 'webdrop/v1/token/owner', + item: 'webdrop/v1/item', + label: 'webdrop/v1/label', +} as const; + +/** AAD 前缀。 */ +export const AAD_FRAME = 'WD1'; +export const AAD_LABEL = 'WD1L'; + +/** 设备名总长上限(iv + 密文 + tag),与服务端 MaxSeatLabelBytes 一致。 */ +export const MAX_LABEL_BYTES = 256; +export const LABEL_IV_SIZE = 12; + +export const POW_NONCE_SIZE = 8; + +export type ChannelMode = 'enc' | 'raw'; +export type ItemKind = 'text' | 'json' | 'file' | 'other'; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..74da167 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,64 @@ +/** + * 错误类型。消息里绝不包含密钥、令牌或明文。 + */ + +export class WebDropError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = new.target.name; + this.code = code; + } +} + +/** 接收方校验失败的原因,与 test-vectors 的 expect 字段对应。 */ +export type IntegrityReason = + | 'auth_failed' // GCM 认证失败:内容被篡改、头部被改、换了频道或条目 + | 'bad_envelope' // 封装格式不合法 + | 'wrong_mode' // enc 会话收到没有 ENC 标志的帧,或 raw 会话收到 ENC 帧 + | 'bad_padding' // 明文长度字段或填充不合法 + | 'bad_meta' // 条目元数据不合法 + | 'gap' // 帧序号不连续 + | 'item_mismatch' // 同一个流里混入了别的条目的帧 + | 'flags_changed' // 同一条目内 SNAPSHOT / GATED 标志不一致 + | 'duplicate_mismatch' // 同一 (条目, 序号) 出现了不同内容 + | 'frame_after_final' // FINAL 之后又出现了帧 + | 'final_conflict' // FINAL 的位置前后矛盾 + | 'incomplete'; // 流结束时条目还没有已认证的 FINAL + +/** 数据被篡改或不符合协议。接收方应丢弃整个条目。 */ +export class IntegrityError extends WebDropError { + readonly reason: IntegrityReason; + + constructor(reason: IntegrityReason) { + super('integrity', `完整性校验失败: ${reason}`); + this.reason = reason; + } +} + +export class LinkError extends WebDropError { + constructor(message: string) { + super('link', message); + } +} + +/** 服务端报告的频道模式与链接不符(降级攻击或链接损坏)。 */ +export class ModeMismatchError extends WebDropError { + constructor() { + super('mode_mismatch', '频道模式与链接不一致,已拒绝'); + } +} + +/** 当前会话没有这个能力(例如成员链接没有所有者令牌)。 */ +export class CapabilityError extends WebDropError { + constructor(what: string) { + super('capability', `当前链接不具备 ${what} 权限`); + } +} + +export class LimitError extends WebDropError { + constructor(message: string) { + super('limit', message); + } +} diff --git a/packages/core/src/frame.ts b/packages/core/src/frame.ts new file mode 100644 index 0000000..3e43553 --- /dev/null +++ b/packages/core/src/frame.ts @@ -0,0 +1,144 @@ +import { AAD_FRAME, Flags, HEADER_SIZE, ID_SIZE, MAX_FRAME_DATA, MAX_FRAME_PLAINTEXT, TAG_SIZE, TIERS } from './constants.js'; +import { concat, readU32, utf8, writeU32, type Bytes } from './bytes.js'; +import { IntegrityError } from './errors.js'; + +// ───────────────────────── padding(protocol §6) ───────────────────────── + +/** 不小于 total 的最小档位。 */ +export function tierFor(total: number): number { + for (const t of TIERS) if (t >= total) return t; + throw new RangeError('超出单帧上限'); +} + +/** 明文 = u32 data_len ‖ data ‖ 零填充,总长为档位。 */ +export function padPlaintext(data: Uint8Array): Bytes { + if (data.length > MAX_FRAME_DATA) throw new RangeError('单帧 data 最多 1,048,572 字节'); + const tier = tierFor(4 + data.length); + const out = new Uint8Array(tier); + out.set(writeU32(data.length), 0); + out.set(data, 4); + return out; +} + +/** 接收方校验:长度字段不越界,总长是档位,填充全为零。 */ +export function unpadPlaintext(plain: Uint8Array): Bytes { + if (plain.length < 4 || !(TIERS as readonly number[]).includes(plain.length)) throw new IntegrityError('bad_padding'); + const len = readU32(plain, 0); + if (len > plain.length - 4) throw new IntegrityError('bad_padding'); + for (let i = 4 + len; i < plain.length; i++) { + if (plain[i] !== 0) throw new IntegrityError('bad_padding'); + } + return plain.slice(4, 4 + len); +} + +// ───────────────────────── 封装(protocol §4) ───────────────────────── + +export interface Envelope { + flags: number; + itemId: Bytes; + frameIndex: number; + payloadLength: number; + /** 前 26 字节。 */ + header: Bytes; + payload: Bytes; + /** 完整封装。 */ + bytes: Bytes; +} + +/** 严格解析:版本、保留位、长度必须全部合法。与服务端 Envelope.TryParse 一致。 */ +export function parseEnvelope(data: Uint8Array): Envelope { + if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope'); + const flags = data[1] as number; + if ((flags & 0xf0) !== 0) throw new IntegrityError('bad_envelope'); + const payloadLength = readU32(data, 22); + if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope'); + + const bytes = data.slice(); + return { + flags, + itemId: bytes.slice(2, 2 + ID_SIZE), + frameIndex: readU32(bytes, 18), + payloadLength, + header: bytes.slice(0, HEADER_SIZE), + payload: bytes.slice(HEADER_SIZE), + bytes, + }; +} + +export function encodeEnvelope(flags: number, itemId: Uint8Array, frameIndex: number, payload: Uint8Array): Bytes { + if (itemId.length !== ID_SIZE) throw new RangeError('item_id 必须是 16 字节'); + const header = concat(new Uint8Array([1, flags]), itemId, writeU32(frameIndex), writeU32(payload.length)); + return concat(header, payload); +} + +/** 流式解析时用:从缓冲区起点读出下一个封装的总长度,数据不够返回 null。 */ +export function peekEnvelopeLength(buf: Uint8Array, maxPayload: number): number | null { + if (buf.length < HEADER_SIZE) return null; + if (buf[0] !== 1) throw new IntegrityError('bad_envelope'); + const payloadLength = readU32(buf, 22); + if (payloadLength > maxPayload) throw new IntegrityError('bad_envelope'); + return HEADER_SIZE + payloadLength; +} + +export const MAX_ENC_PAYLOAD = MAX_FRAME_PLAINTEXT + TAG_SIZE; + +// ───────────────────────── 加解密(protocol §5) ───────────────────────── + +/** IV = 8 字节零 ‖ frame_index(u32)。每个条目有独立的密钥,所以 frame_index 唯一即保证 IV 唯一。 */ +export function frameIv(frameIndex: number): Bytes { + const iv = new Uint8Array(12); + iv.set(writeU32(frameIndex), 8); + return iv; +} + +/** AAD = "WD1" ‖ channel_id ‖ envelope[0..22),绑定频道、条目、序号和全部标志位。 */ +export function frameAad(channelId: Uint8Array, header: Uint8Array): Bytes { + return concat(utf8(AAD_FRAME), channelId, header.subarray(0, 22)); +} + +export interface SealParams { + flags: number; + itemId: Uint8Array; + index: number; + data: Uint8Array; +} + +/** + * 加密一帧并返回完整封装。 + * 注意:同一 (itemId, index) 只能加密一份固定明文,否则会造成 IV 重用。重试必须重发相同字节。 + */ +export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise { + if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志'); + if ((p.flags & 0xf0) !== 0) throw new RangeError('保留标志位必须为 0'); + + const plaintext = padPlaintext(p.data); + const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE)); + + const cipher = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: frameIv(p.index), additionalData: frameAad(channelId, header), tagLength: 128 }, + itemKey, + plaintext, + ); + return concat(header, new Uint8Array(cipher)); +} + +/** 认证并解密一帧,返回去掉 padding 的 data。任何失败都抛 IntegrityError。 */ +export async function openFrame(itemKey: CryptoKey, channelId: Uint8Array, env: Envelope): Promise { + if ((env.flags & Flags.Enc) === 0) throw new IntegrityError('wrong_mode'); + if (env.payloadLength < TAG_SIZE) throw new IntegrityError('bad_envelope'); + + let plain: Bytes; + try { + plain = new Uint8Array( + await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: frameIv(env.frameIndex), additionalData: frameAad(channelId, env.header), tagLength: 128 }, + itemKey, + env.payload, + ), + ); + } catch { + throw new IntegrityError('auth_failed'); + } + return unpadPlaintext(plain); +} + diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..859f00d --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,55 @@ +/** + * @webdrop/core:WebDrop 的端到端加密核心。 + * + * 前端只需要这几件事: + * ChannelSession.create(...) 创建频道,得到会话、POST /channels 的请求体和请求头、分享链接 + * ChannelSession.open(link) 打开链接,得到会话 + * session.authHeaders(role) 所有请求要带的头 + * session.encryptText/Json/File 把内容加密成可直接 POST 的帧 + * session.createItemReader() 喂入 WebSocket / log 的记录,得到解密后的条目 + * session.decryptItemStream() 流式解密大文件 + * + * 本包只依赖 WebCrypto,不含 UI,不发网络请求,不处理重连和队列。 + */ + +export { ChannelSession, openLink } from './session.js'; +export type { + AuthRole, + ChannelSettings, + CreateChannelBody, + CreatedChannel, + CreateOptions, + FileMetaOverrides, + OpenOptions, + PlanItemOptions, +} from './session.js'; + +export { collectFrames } from './item.js'; +export type { ContentSource, EncodedFrame, ItemMeta, ItemPlan, PlanOptions } from './item.js'; + +export { ItemReader, itemJson, itemText } from './reader.js'; +export type { ItemEvent, ReaderContext, ReceivedItem, ReceivedKind } from './reader.js'; + +export { decryptItemFrames, decryptItemStream, splitEnvelopes } from './stream.js'; +export type { DecryptStreamOptions, ItemDecodeResult } from './stream.js'; + +export { parseLogRecord, parseLogRecords } from './log.js'; +export type { LogRecord, RemovedReason } from './log.js'; + +export { buildLink, parseLink } from './link.js'; +export type { LinkParts, ParsedLink } from './link.js'; + +export { fromBase64Url, toBase64Url } from './bytes.js'; + +export { Flags, MAX_FRAME_DATA, PROTOCOL_VERSION } from './constants.js'; +export type { ChannelMode, ItemKind } from './constants.js'; + +export { + CapabilityError, + IntegrityError, + LimitError, + LinkError, + ModeMismatchError, + WebDropError, +} from './errors.js'; +export type { IntegrityReason } from './errors.js'; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts new file mode 100644 index 0000000..572a30f --- /dev/null +++ b/packages/core/src/internal.ts @@ -0,0 +1,42 @@ +/** + * 仅供测试和调试:把通常不可导出的中间密钥以字节形式导出,用来和测试向量逐字节比对。 + * 正常使用请只依赖主入口。这里的函数会让密钥材料暴露在 JS 变量里,不要在产品代码中使用。 + */ + +import { Labels } from './constants.js'; +import { utf8, type Bytes } from './bytes.js'; + +export { encodeEnvelope, frameAad, frameIv, padPlaintext, parseEnvelope, sealFrame, tierFor, unpadPlaintext } from './frame.js'; +export { encodeMeta, decodeFirstFrame, planEncItem, planRawItem } from './item.js'; +export { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256 } from './kdf.js'; + +async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise { + const key = await globalThis.crypto.subtle.importKey('raw', ikm as Bytes, 'HKDF', false, ['deriveBits']); + const bits = await globalThis.crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) }, + key, + 256, + ); + return new Uint8Array(bits); +} + +export interface RawChannelSecrets { + kEnc: Bytes; + tRead: Bytes; + tWrite: Bytes; + kLabel: Bytes; +} + +export async function deriveRawChannelSecrets(channelId: Uint8Array, k: Uint8Array): Promise { + const kEnc = await hkdfBytes(k, channelId, Labels.enc); + return { + kEnc, + tRead: await hkdfBytes(k, channelId, Labels.read), + tWrite: await hkdfBytes(k, channelId, Labels.write), + kLabel: await hkdfBytes(kEnc, channelId, Labels.label), + }; +} + +export const deriveRawOwnerToken = (channelId: Uint8Array, a: Uint8Array): Promise => hkdfBytes(a, channelId, Labels.owner); + +export const deriveRawItemKey = (kEnc: Uint8Array, itemId: Uint8Array): Promise => hkdfBytes(kEnc, itemId, Labels.item); diff --git a/packages/core/src/item.ts b/packages/core/src/item.ts new file mode 100644 index 0000000..5ed38ea --- /dev/null +++ b/packages/core/src/item.ts @@ -0,0 +1,340 @@ +import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js'; +import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js'; +import { IntegrityError, LimitError } from './errors.js'; + +// ───────────────────────── 条目元数据(protocol §7) ───────────────────────── + +export interface ItemMeta { + kind: ItemKind; + name?: string; + mime?: string; + /** 内容总字节数,流式上传时可能未知。 */ + size?: number; + /** 毫秒时间戳,仅供参考,排序以 seq 为准。 */ + ts: number; + /** 设备名。 */ + dev?: string; +} + +const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'other']; + +/** u16 meta_len ‖ meta_json。字段顺序固定,undefined 的字段省略,保证字节级确定。 */ +export function encodeMeta(meta: ItemMeta): Bytes { + const o: Record = { v: 1, kind: meta.kind }; + if (meta.name !== undefined) o.name = meta.name; + if (meta.mime !== undefined) o.mime = meta.mime; + if (meta.size !== undefined) o.size = meta.size; + o.ts = meta.ts; + if (meta.dev !== undefined) o.dev = meta.dev; + + const json = utf8(JSON.stringify(o)); + if (json.length > 0xffff) throw new LimitError('条目元数据过大'); + return concat(writeU16(json.length), json); +} + +/** 解析第 0 帧的 data:u16 meta_len ‖ meta_json ‖ 内容。 */ +export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: Bytes } { + if (data.length < 2) throw new IntegrityError('bad_meta'); + const metaLen = readU16(data, 0); + if (data.length < 2 + metaLen) throw new IntegrityError('bad_meta'); + + const text = tryDecodeUtf8(data.subarray(2, 2 + metaLen)); + if (text === null) throw new IntegrityError('bad_meta'); + + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new IntegrityError('bad_meta'); + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new IntegrityError('bad_meta'); + const o = raw as Record; + + if (o.v !== 1 || typeof o.kind !== 'string' || typeof o.ts !== 'number' || !Number.isFinite(o.ts)) { + throw new IntegrityError('bad_meta'); + } + const meta: ItemMeta = { + // 未知的 kind 按 other 处理,向前兼容 + kind: (KINDS as readonly string[]).includes(o.kind) ? (o.kind as ItemKind) : 'other', + ts: o.ts, + }; + for (const key of ['name', 'mime', 'dev'] as const) { + const v = o[key]; + if (v === undefined) continue; + if (typeof v !== 'string') throw new IntegrityError('bad_meta'); + meta[key] = v; + } + if (o.size !== undefined) { + if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta'); + meta.size = o.size; + } + return { meta, content: data.slice(2 + metaLen) }; +} + +// ───────────────────────── 条目编码(加密) ───────────────────────── + +export type ContentSource = Uint8Array | Blob | ReadableStream | AsyncIterable; + +export interface PlanOptions { + /** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */ + itemId?: Uint8Array; + /** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */ + snapshot?: boolean; + /** + * 是否 GATED(不实时内联推送,只能按条目拉取并计下载次数)。 + * 'auto'(默认):内容放不进一帧,或设置了 maxDownloads 时为 GATED。 + */ + gated?: boolean | 'auto'; + /** 下载次数上限,0 为不限。设置后条目必须是 GATED;需要作为 X-WD-Max-Downloads 随第 0 帧发送。 */ + maxDownloads?: number; + /** 单帧 data 的字节上限,默认 1,048,572。调小只用于测试和极弱网络。 */ + maxFrameData?: number; + /** 从第几帧开始产出(断点续传)。只支持长度已知的来源(Uint8Array、Blob)。 */ + fromFrame?: number; +} + +export interface EncodedFrame { + index: number; + final: boolean; + /** 完整封装,可直接作为 POST /frames 的请求体。 */ + bytes: Bytes; +} + +export interface ItemPlan { + /** 条目 id 的 base64url 文本。 */ + itemId: string; + idBytes: Bytes; + /** + * 是否 GATED。来源长度已知时在创建计划时就确定;来源是流(长度未知)时, + * 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。 + */ + readonly gated: boolean; + /** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */ + frame0Headers: Record; + /** 按序产出加密后的帧。同一个计划只能迭代一次。 */ + frames: AsyncGenerator; +} + +export async function collectFrames(plan: ItemPlan): Promise { + const out: EncodedFrame[] = []; + for await (const f of plan.frames) out.push(f); + return out; +} + +export function newItemId(): Bytes { + const id = new Uint8Array(16); + globalThis.crypto.getRandomValues(id); + return id; +} + +/** 把各种来源统一成字节块的异步序列。 */ +async function* chunksOf(source: ContentSource): AsyncGenerator { + if (source instanceof Uint8Array) { + yield source; + return; + } + if (typeof Blob !== 'undefined' && source instanceof Blob) { + yield* chunksOf(source.stream()); + return; + } + if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) { + // 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有) + const reader = source.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } + } + yield* source as AsyncIterable; +} + +interface Chunk { + data: Bytes; + last: boolean; +} + +/** + * 把任意切分的字节流重新切成 [firstCap, cap, cap, ...] 大小的块,并通过向前看一个块知道哪一块是最后一块。 + * 内容恰好填满某一块时不会多出一个空块;内容为空时产出一个空的最后一块。 + */ +async function* rechunk(source: ContentSource, firstCap: number, cap: number): AsyncGenerator { + const parts: Uint8Array[] = []; + let total = 0; + let target = firstCap; + let ended = false; + const iter = chunksOf(source)[Symbol.asyncIterator](); + + const take = (n: number): Bytes => { + const out = new Uint8Array(n); + let filled = 0; + while (filled < n) { + const head = parts[0] as Uint8Array; + const need = n - filled; + if (head.length <= need) { + out.set(head, filled); + filled += head.length; + parts.shift(); + } else { + out.set(head.subarray(0, need), filled); + parts[0] = head.subarray(need); + filled += need; + } + } + total -= n; + return out; + }; + + for (;;) { + // 需要缓冲到 target+1 字节,才能确定当前块不是最后一块 + while (!ended && total <= target) { + const r = await iter.next(); + if (r.done) ended = true; + else if (r.value.length > 0) { + parts.push(r.value); + total += r.value.length; + } + } + if (total > target) { + yield { data: take(target), last: false }; + target = cap; + continue; + } + yield { data: take(total), last: true }; + return; + } +} + +function isSized(source: ContentSource): source is Uint8Array | Blob { + return source instanceof Uint8Array || (typeof Blob !== 'undefined' && source instanceof Blob); +} + +/** 构造条目计划的公共部分:标志位、GATED 的取舍、校验。 */ +function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined): boolean { + const wantsLimit = opts.maxDownloads !== undefined; + if (opts.snapshot && (opts.gated === true || wantsLimit)) throw new RangeError('快照不能是 GATED,也不能设置下载上限'); + if (opts.gated === true || wantsLimit) return true; + if (opts.gated === false) { + if (fitsInOneFrame === false) throw new LimitError('内容放不进单帧,不能关闭 GATED'); + return false; + } + return fitsInOneFrame === false; +} + +/** + * enc 模式:把内容加密成一个条目(protocol §7)。 + * 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。 + */ +export function planEncItem( + seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise, + source: ContentSource, + meta: ItemMeta, + opts: PlanOptions = {}, +): ItemPlan { + const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId(); + const maxData = opts.maxFrameData ?? MAX_FRAME_DATA; + if (maxData < 1 || maxData > MAX_FRAME_DATA) throw new RangeError('maxFrameData 超出范围'); + + const head = encodeMeta(meta); + const cap0 = maxData - head.length; + if (cap0 < 0) throw new LimitError('元数据超过单帧上限'); + + const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined; + const fits = size === undefined ? undefined : size <= cap0; + let gated = resolveGating(opts, fits); + + const fromFrame = opts.fromFrame ?? 0; + if (fromFrame > 0 && !isSized(source)) throw new RangeError('只有长度已知的来源才能从中间帧续传'); + + const frame0Headers: Record = {}; + if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads); + + const snapshot = opts.snapshot === true; + + async function* frames(): AsyncGenerator { + const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc; + const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise => ({ + index, + final: last, + bytes: await seal({ flags: base(gatedNow) | (last ? Flags.Final : 0), itemId: idBytes, index, data }), + }); + + if (fromFrame > 0) { + // 断点续传:跳过已经上传的帧。第 k(>=1)帧的内容起点 = cap0 + (k-1) * maxData + const blob = source as Uint8Array | Blob; + const total = blob instanceof Uint8Array ? blob.length : blob.size; + if (total <= cap0) throw new RangeError('内容只有一帧,没有可续传的后续帧'); + const offset = cap0 + (fromFrame - 1) * maxData; + if (offset >= total) throw new RangeError('fromFrame 超出条目的帧数'); + const rest = blob instanceof Uint8Array ? blob.subarray(offset) : blob.slice(offset); + let index = fromFrame; + for await (const chunk of rechunk(rest, maxData, maxData)) { + yield await emit(index++, chunk.last, chunk.data, true); + } + return; + } + + let index = 0; + for await (const chunk of rechunk(source, cap0, maxData)) { + if (index === 0) { + // 第 0 帧:来源长度未知时,到这里才知道放不放得进单帧 + if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false; + else if (!chunk.last) { + if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧'); + if (opts.gated === false) throw new LimitError('内容放不进单帧,不能关闭 GATED'); + gated = true; + } + yield await emit(0, chunk.last, concat(head, chunk.data), gated); + } else { + yield await emit(index, chunk.last, chunk.data, true); + } + index++; + } + } + + return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() }; +} + +/** + * raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。 + * 注意 raw 内容对服务端完全可见。 + */ +export function planRawItem( + build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes, + source: ContentSource, + opts: PlanOptions & { label?: string } = {}, +): ItemPlan { + const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId(); + const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD); + const snapshot = opts.snapshot === true; + + const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined; + let gated = resolveGating(opts, size === undefined ? undefined : size <= cap); + + const frame0Headers: Record = {}; + if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads); + if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label; + + async function* frames(): AsyncGenerator { + let index = 0; + for await (const chunk of rechunk(source, cap, cap)) { + if (index === 0) { + if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false; + else if (!chunk.last) { + if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧'); + gated = true; + } + } + const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0); + yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) }; + index++; + } + } + + return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() }; +} + diff --git a/packages/core/src/kdf.ts b/packages/core/src/kdf.ts new file mode 100644 index 0000000..49483ec --- /dev/null +++ b/packages/core/src/kdf.ts @@ -0,0 +1,71 @@ +import { Labels } from './constants.js'; +import { utf8, wipe, type Bytes } from './bytes.js'; + +/** + * 密钥派生(protocol §3)。全部是 HKDF-SHA256,salt 为 channel_id(或 item_id),info 为固定标签。 + * + * 尽量让原始密钥字节不落在 JS 变量里: + * - K_enc 导入为不可导出的 HKDF CryptoKey 后立即清零; + * - K_item / K_label 直接用 deriveKey 得到不可导出的 AES-GCM CryptoKey。 + * 令牌(T_read 等)必须以字节形式放进请求头,所以是 Uint8Array。 + */ + +const subtle = (): SubtleCrypto => globalThis.crypto.subtle; + +function importHkdf(ikm: Uint8Array): Promise { + return subtle().importKey('raw', ikm as Bytes, 'HKDF', false, ['deriveBits', 'deriveKey']); +} + +async function hkdfBits(key: CryptoKey, salt: Uint8Array, label: string): Promise { + const bits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) }, key, 256); + return new Uint8Array(bits); +} + +function hkdfAesKey(key: CryptoKey, salt: Uint8Array, label: string): Promise { + return subtle().deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) }, + key, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +export interface ChannelKeys { + /** 用于派生每个条目的密钥。不可导出,原始字节已清零。 */ + encRoot: CryptoKey; + /** 设备名加密密钥。不可导出。 */ + labelKey: CryptoKey; + readToken: Bytes; + writeToken: Bytes; +} + +/** 由频道密钥 K 派生。channelId 作为 HKDF 的 salt。 */ +export async function deriveChannelKeys(channelId: Uint8Array, k: Uint8Array): Promise { + const root = await importHkdf(k); + + const kEnc = await hkdfBits(root, channelId, Labels.enc); + const encRoot = await importHkdf(kEnc); + wipe(kEnc); + + return { + encRoot, + labelKey: await hkdfAesKey(encRoot, channelId, Labels.label), + readToken: await hkdfBits(root, channelId, Labels.read), + writeToken: await hkdfBits(root, channelId, Labels.write), + }; +} + +/** 所有者令牌由管理密钥 A 派生。raw 模式同样如此。 */ +export async function deriveOwnerToken(channelId: Uint8Array, a: Uint8Array): Promise { + return hkdfBits(await importHkdf(a), channelId, Labels.owner); +} + +/** 每个条目独立的 AES-GCM 密钥,所以同一条目内 frame_index 唯一即保证 IV 唯一。 */ +export function deriveItemKey(encRoot: CryptoKey, itemId: Uint8Array): Promise { + return hkdfAesKey(encRoot, itemId, Labels.item); +} + +export async function sha256(data: Uint8Array): Promise { + return new Uint8Array(await subtle().digest('SHA-256', data as Bytes)); +} diff --git a/packages/core/src/link.ts b/packages/core/src/link.ts new file mode 100644 index 0000000..b318d38 --- /dev/null +++ b/packages/core/src/link.ts @@ -0,0 +1,92 @@ +import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js'; +import { fromBase64Url, toBase64Url, type Bytes } from './bytes.js'; +import { LinkError } from './errors.js'; + +/** + * 频道链接(protocol §2): + * enc 所有者 /c/{id}#k=&a= 成员 /c/{id}#k= + * raw 所有者 /c/{id}#a=[&w=] 成员 /c/{id}[#w=] + * + * 密钥只放在 fragment 里,浏览器不会把它发给服务器。降级防御:带 k 就是 enc,否则是 raw。 + */ + +export interface ParsedLink { + /** 频道 id 的 base64url 文本。 */ + id: string; + idBytes: Bytes; + /** 带 k 即 enc,否则 raw。客户端必须据此核对服务端报告的模式。 */ + mode: ChannelMode; + role: 'owner' | 'member'; + /** 频道密钥,仅 enc。 */ + k?: Bytes; + /** 管理密钥,仅所有者链接。 */ + a?: Bytes; + /** raw 模式成员的写密钥。 */ + w?: Bytes; +} + +const PATH = /(?:^|\/)c\/([A-Za-z0-9_-]{22})\/?$/; + +export function parseLink(input: string | URL): ParsedLink { + let url: URL; + try { + url = typeof input === 'string' ? new URL(input) : input; + } catch { + throw new LinkError('不是合法的链接'); + } + + const match = PATH.exec(url.pathname); + if (!match) throw new LinkError('链接里没有频道 id'); + const id = match[1] as string; + const idBytes = fromBase64Url(id, ID_SIZE); + if (!idBytes) throw new LinkError('频道 id 不合法'); + + // 密钥放在查询串里会随请求发给服务器,等于已经泄露,必须拒绝而不是悄悄当成 raw 链接 + for (const name of ['k', 'a', 'w']) { + if (url.searchParams.has(name)) throw new LinkError('密钥必须放在 # 后面,放在 ? 后面会被发送给服务器'); + } + + const params = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash); + const key = (name: string): Bytes | undefined => { + const v = params.get(name); + if (v === null) return undefined; + const bytes = fromBase64Url(v, KEY_SIZE); + if (!bytes) throw new LinkError(`链接里的 ${name} 不合法`); + return bytes; + }; + + const k = key('k'); + const a = key('a'); + const w = key('w'); + + // enc 频道的写令牌由 K 派生,出现 w 说明链接被拼错或被篡改 + if (k && w) throw new LinkError('enc 链接不应包含 w'); + + const link: ParsedLink = { + id, + idBytes, + mode: k ? 'enc' : 'raw', + role: a ? 'owner' : 'member', + }; + if (k) link.k = k; + if (a) link.a = a; + if (w) link.w = w; + return link; +} + +export interface LinkParts { + id: string; + k?: Uint8Array; + a?: Uint8Array; + w?: Uint8Array; +} + +/** 生成链接。origin 如 "https://app.example.com",路径固定为 /c/{id}。 */ +export function buildLink(origin: string, parts: LinkParts): string { + const params: string[] = []; + if (parts.k) params.push(`k=${toBase64Url(parts.k)}`); + if (parts.a) params.push(`a=${toBase64Url(parts.a)}`); + if (parts.w) params.push(`w=${toBase64Url(parts.w)}`); + const base = `${origin.replace(/\/+$/, '')}/c/${parts.id}`; + return params.length > 0 ? `${base}#${params.join('&')}` : base; +} diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 0000000..b5f1f4b --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,86 @@ +import { HEADER_SIZE, ID_SIZE } from './constants.js'; +import { readU32, readU64, toBase64Url, type Bytes } from './bytes.js'; +import { IntegrityError } from './errors.js'; + +/** 墓碑原因(protocol §8)。 */ +export type RemovedReason = 'deleted' | 'expired' | 'evicted' | 'download_limit' | 'superseded'; + +const REASONS: readonly RemovedReason[] = ['deleted', 'expired', 'evicted', 'download_limit', 'superseded']; + +export type LogRecord = + | { kind: 'frame'; seq: number; envelope: Bytes } + | { kind: 'tombstone'; seq: number; itemId: string; reason: RemovedReason } + | { kind: 'announce'; seq: number; itemId: string; flags: number; createdAt: number }; + +const KIND_FRAME = 1; +const KIND_TOMBSTONE = 2; +const KIND_ANNOUNCE = 3; + +/** body 的字节数,不够返回 null。 */ +function bodyLength(kind: number, buf: Uint8Array, bodyStart: number): number | null { + switch (kind) { + case KIND_FRAME: { + // 封装自带 payload_len:body 长度 = 26 + payload_len + if (buf.length < bodyStart + HEADER_SIZE) return null; + return HEADER_SIZE + readU32(buf, bodyStart + 22); + } + case KIND_TOMBSTONE: + return ID_SIZE + 1; + case KIND_ANNOUNCE: + return ID_SIZE + 1 + 8; + default: + throw new IntegrityError('bad_envelope'); + } +} + +function parseOne(buf: Uint8Array, offset: number): { record: LogRecord; next: number } { + if (buf.length < offset + 9) throw new IntegrityError('bad_envelope'); + const seq = readU64(buf, offset); + const kind = buf[offset + 8] as number; + const bodyStart = offset + 9; + + const len = bodyLength(kind, buf, bodyStart); + if (len === null || buf.length < bodyStart + len) throw new IntegrityError('bad_envelope'); + const body = buf.subarray(bodyStart, bodyStart + len); + const next = bodyStart + len; + + switch (kind) { + case KIND_FRAME: + return { record: { kind: 'frame', seq, envelope: body.slice() }, next }; + case KIND_TOMBSTONE: { + const reason = REASONS[body[ID_SIZE] as number]; + if (!reason) throw new IntegrityError('bad_envelope'); + return { record: { kind: 'tombstone', seq, itemId: toBase64Url(body.subarray(0, ID_SIZE)), reason }, next }; + } + default: + return { + record: { + kind: 'announce', + seq, + itemId: toBase64Url(body.subarray(0, ID_SIZE)), + flags: body[ID_SIZE] as number, + createdAt: readU64(body, ID_SIZE + 1), + }, + next, + }; + } +} + +/** 解析 log 接口返回的记录拼接(长轮询的响应体)。 */ +export function parseLogRecords(data: Uint8Array): LogRecord[] { + const out: LogRecord[] = []; + let offset = 0; + while (offset < data.length) { + const { record, next } = parseOne(data, offset); + out.push(record); + offset = next; + } + return out; +} + +/** 解析恰好一条记录(WebSocket 的一条二进制消息)。多余或缺少字节都视为非法。 */ +export function parseLogRecord(data: Uint8Array): LogRecord { + const { record, next } = parseOne(data, 0); + if (next !== data.length) throw new IntegrityError('bad_envelope'); + return record; +} diff --git a/packages/core/src/reader.ts b/packages/core/src/reader.ts new file mode 100644 index 0000000..b35e725 --- /dev/null +++ b/packages/core/src/reader.ts @@ -0,0 +1,210 @@ +import { Flags, type ChannelMode, type ItemKind } from './constants.js'; +import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './bytes.js'; +import { IntegrityError, type IntegrityReason } from './errors.js'; +import { openFrame, parseEnvelope, type Envelope } from './frame.js'; +import { sha256 } from './kdf.js'; +import { decodeFirstFrame, type ItemMeta } from './item.js'; +import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js'; + +/** 接收端需要的会话信息。由 ChannelSession 提供。 */ +export interface ReaderContext { + channelId: Uint8Array; + mode: ChannelMode; + /** enc 模式:取某个条目的 AES-GCM 密钥。 */ + itemKey(itemId: Uint8Array): Promise; +} + +export type ReceivedKind = ItemKind | 'raw'; + +export interface ReceivedItem { + itemId: string; + /** 帧所在日志记录的 seq(经 pushFrame 单独喂入且未提供 seq 时为空)。 */ + seq: number | undefined; + kind: ReceivedKind; + /** 仅 enc 模式。 */ + meta?: ItemMeta; + snapshot: boolean; + gated: boolean; + /** 已收到已认证的 FINAL,且 0..FINAL 的帧全部到齐。 */ + complete: boolean; + /** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */ + content: Bytes; +} + +export type ItemEvent = + /** GATED 条目的通知:字节需要另外拉取(GET /items/{id}/frames),拉取计入下载次数。 */ + | { type: 'announce'; itemId: string; seq: number; flags: number; createdAt: number } + /** 一个条目有新内容或已完成。小内容(单帧)只会触发一次,complete 为 true。 */ + | { type: 'item'; item: ReceivedItem } + /** 条目被删除、过期、淘汰、取代或下载达上限。 */ + | { type: 'removed'; itemId: string; seq: number; reason: RemovedReason } + /** 一帧被拒绝(篡改、乱序、模式不符等)。该条目的缓冲状态已丢弃。 */ + | { type: 'rejected'; itemId: string | undefined; seq: number | undefined; reason: IntegrityReason }; + +interface FrameState { + content: Bytes; + digest: Bytes; +} + +interface ItemState { + itemFlags: number; + frames: Map; + finalIndex: number | undefined; + maxIndex: number; + meta: ItemMeta | undefined; +} + +/** + * 条目装配器:喂入日志记录或条目的帧,吐出解密、校验后的事件。 + * + * 校验失败**不抛异常**,而是产生 `rejected` 事件并丢弃该条目的缓冲: + * 一个坏帧(或恶意服务端)不应让整个订阅挂掉。直接调用 openFrame 才会抛 IntegrityError。 + */ +export class ItemReader { + readonly #ctx: ReaderContext; + readonly #open = new Map(); + /** 已完成的条目。回放时重复到达的帧直接忽略,不会让条目「复活」。 */ + readonly #done = new Set(); + + constructor(ctx: ReaderContext) { + this.#ctx = ctx; + } + + /** 喂入一条日志记录(WebSocket 的一条二进制消息,或 parseLogRecords 的一项)。 */ + async push(input: Uint8Array | LogRecord): Promise { + let record: LogRecord; + try { + record = input instanceof Uint8Array ? parseLogRecord(input) : input; + } catch (e) { + if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq: undefined, reason: e.reason }]; + throw e; + } + + switch (record.kind) { + case 'announce': + return [{ type: 'announce', itemId: record.itemId, seq: record.seq, flags: record.flags, createdAt: record.createdAt }]; + case 'tombstone': + this.#open.delete(record.itemId); + this.#done.delete(record.itemId); + return [{ type: 'removed', itemId: record.itemId, seq: record.seq, reason: record.reason }]; + case 'frame': + return this.pushFrame(record.envelope, record.seq); + } + } + + /** 喂入一个完整的封装(例如拉取 GATED 条目时按帧切出来的)。 */ + async pushFrame(envelope: Uint8Array, seq?: number): Promise { + let env: Envelope; + try { + env = parseEnvelope(envelope); + } catch (e) { + if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq, reason: e.reason }]; + throw e; + } + + const itemId = toBase64Url(env.itemId); + try { + return await this.#accept(env, itemId, seq); + } catch (e) { + if (!(e instanceof IntegrityError)) throw e; + this.#open.delete(itemId); + return [{ type: 'rejected', itemId, seq, reason: e.reason }]; + } + } + + /** 丢弃某个条目的缓冲(例如用户取消了下载)。 */ + forget(itemId: string): void { + this.#open.delete(itemId); + this.#done.delete(itemId); + } + + async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise { + const isEncFrame = (env.flags & Flags.Enc) !== 0; + // 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之 + if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode'); + if (this.#done.has(itemId)) return []; + + const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot); + const gated = (env.flags & Flags.Gated) !== 0; + const isFinal = (env.flags & Flags.Final) !== 0; + // 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制) + if (!gated && (env.frameIndex !== 0 || !isFinal)) throw new IntegrityError('bad_envelope'); + + let state = this.#open.get(itemId); + if (state && state.itemFlags !== itemFlags) throw new IntegrityError('flags_changed'); + if (state?.finalIndex !== undefined && env.frameIndex > state.finalIndex) throw new IntegrityError('frame_after_final'); + if (isFinal && state) { + if (state.maxIndex > env.frameIndex) throw new IntegrityError('final_conflict'); + if (state.finalIndex !== undefined && state.finalIndex !== env.frameIndex) throw new IntegrityError('final_conflict'); + } + + // 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件 + const digest = await sha256(env.payload); + const dup = state?.frames.get(env.frameIndex); + if (dup) { + if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch'); + return []; + } + + // 解密 / 取出内容 + let data: Bytes; + if (this.#ctx.mode === 'enc') { + data = await openFrame(await this.#ctx.itemKey(env.itemId), this.#ctx.channelId, env); + } else { + data = env.payload; + } + + let meta = state?.meta; + let content = data; + if (this.#ctx.mode === 'enc' && env.frameIndex === 0) { + const first = decodeFirstFrame(data); + meta = first.meta; + content = first.content; + } + + if (!state) { + state = { itemFlags, frames: new Map(), finalIndex: undefined, maxIndex: -1, meta: undefined }; + this.#open.set(itemId, state); + } + state.frames.set(env.frameIndex, { content, digest }); + state.maxIndex = Math.max(state.maxIndex, env.frameIndex); + if (isFinal) state.finalIndex = env.frameIndex; + if (meta) state.meta = meta; + + // 拼接到第一个缺口为止 + const parts: Uint8Array[] = []; + for (let i = 0; state.frames.has(i); i++) parts.push((state.frames.get(i) as FrameState).content); + const complete = state.finalIndex !== undefined && parts.length === state.finalIndex + 1; + + const item: ReceivedItem = { + itemId, + seq, + kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'), + snapshot: (itemFlags & Flags.Snapshot) !== 0, + gated, + complete, + content: concat(...parts), + }; + if (state.meta) item.meta = state.meta; + + if (complete) { + this.#open.delete(itemId); + this.#done.add(itemId); + } + return [{ type: 'item', item }]; + } +} + +// ───────────────────────── 便利函数 ───────────────────────── + +/** 内容按 UTF-8 解码,非法序列抛 TypeError。 */ +export function itemText(item: Pick): string { + const text = tryDecodeUtf8(item.content); + if (text === null) throw new TypeError('条目内容不是合法的 UTF-8'); + return text; +} + +export function itemJson(item: Pick): unknown { + return JSON.parse(itemText(item)); +} + diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts new file mode 100644 index 0000000..4673452 --- /dev/null +++ b/packages/core/src/session.ts @@ -0,0 +1,401 @@ +import { AAD_LABEL, ID_SIZE, KEY_SIZE, LABEL_IV_SIZE, MAX_LABEL_BYTES, TAG_SIZE, type ChannelMode } from './constants.js'; +import { concat, fromBase64Url, randomBytes, toBase64Url, tryDecodeUtf8, utf8, wipe, type Bytes } from './bytes.js'; +import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchError } from './errors.js'; +import { encodeEnvelope, sealFrame } from './frame.js'; +import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js'; +import { buildLink, parseLink, type ParsedLink } from './link.js'; +import { planEncItem, planRawItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js'; +import { ItemReader, type ReaderContext } from './reader.js'; +import { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js'; + +export type AuthRole = 'read' | 'write' | 'owner'; + +/** 频道设置,字段名与 API 一致(snake_case)。 */ +export interface ChannelSettings { + seats?: number; + members_can_upload?: boolean; + delete_policy?: 'owner_only' | 'own' | 'anyone'; + ttl_seconds?: number; + max_items?: number; + file_max_downloads?: number; + ui_restrict_copy_download?: boolean; +} + +/** POST /api/v1/channels 的请求体。 */ +export interface CreateChannelBody { + id: string; + mode: ChannelMode; + settings?: ChannelSettings; + read_hash?: string; + write_hash?: string; + owner_hash: string; +} + +export interface CreateOptions { + mode?: ChannelMode; + settings?: ChannelSettings; + /** 本设备在这个频道里的终端密钥。由前端持久化(IndexedDB),下次打开链接时传回来;不传则新生成。 */ + endpointSecret?: Uint8Array; +} + +export interface OpenOptions { + endpointSecret?: Uint8Array; +} + +export interface CreatedChannel { + session: ChannelSession; + /** + * 创建请求。服务端启用验证码时,还要在 headers 之外加上 `X-WD-Cap: ` + * (token 由前端用 Cap widget 求解得到,见 GET /info 的 captcha.api_endpoint)。 + */ + request: { body: CreateChannelBody; headers: Record }; +} + +export interface PlanItemOptions extends PlanOptions { + /** 覆盖默认的时间戳(毫秒),主要用于测试。 */ + ts?: number; + /** 覆盖设备名。默认使用 session.deviceName。 */ + dev?: string; + /** 仅 raw 模式:条目标签,作为 X-WD-Label 随第 0 帧发送(明文,服务端可见)。enc 模式下忽略,名字放在加密的元数据里。 */ + label?: string; +} + +export interface FileMetaOverrides { + name?: string; + mime?: string; + size?: number; +} + +const ITEM_KEY_CACHE = 256; + +interface SessionInit { + link: Pick; + k?: Bytes; + a?: Bytes; + w?: Bytes; + keys?: ChannelKeys; + ownerToken?: Bytes; + endpointSecret: Bytes; + endpointHash: Bytes; +} + +/** + * 一个频道的会话:由链接中的密钥派生出一切,并提供加密、解密和请求头。 + * + * 密钥材料说明:AES 密钥(每条目、设备名)是不可导出的 CryptoKey,K_enc 导入后原始字节即清零。 + * 但链接里的 K / A / W 必须保留在内存里(用来重新生成分享链接),令牌必须以字节形式放进请求头, + * JS 无法保证这些内存被真正擦除。调用 dispose() 只能尽力清零。 + */ +export class ChannelSession { + readonly id: string; + readonly idBytes: Bytes; + readonly mode: ChannelMode; + /** 来自链接:带管理密钥的是所有者,否则是成员。 */ + readonly role: 'owner' | 'member'; + /** 本终端的密钥 S(32 字节)。前端必须持久化它,否则每次打开都会占用新席位。 */ + readonly endpointSecret: Bytes; + /** SHA-256(S),即服务端看到的终端标识。 */ + readonly endpointHash: Bytes; + /** 加密条目元数据时默认带上的设备名。 */ + deviceName: string | undefined; + + readonly #k: Bytes | undefined; + readonly #a: Bytes | undefined; + readonly #w: Bytes | undefined; + readonly #keys: ChannelKeys | undefined; + readonly #ownerToken: Bytes | undefined; + readonly #itemKeys = new Map>(); + + private constructor(init: SessionInit) { + this.id = init.link.id; + this.idBytes = init.link.idBytes; + this.mode = init.link.mode; + this.role = init.link.role; + this.#k = init.k; + this.#a = init.a; + this.#w = init.w; + this.#keys = init.keys; + this.#ownerToken = init.ownerToken; + this.endpointSecret = init.endpointSecret; + this.endpointHash = init.endpointHash; + } + + // ───────────────────────── 创建与打开 ───────────────────────── + + /** 打开一个频道链接(含降级防御:链接里有 k 就是 enc,否则 raw)。 */ + static async open(link: string | URL | ParsedLink, opts: OpenOptions = {}): Promise { + const parsed = typeof link === 'string' || link instanceof URL ? parseLink(link) : link; + const endpointSecret = opts.endpointSecret ? Uint8Array.from(opts.endpointSecret) : randomBytes(KEY_SIZE); + if (endpointSecret.length !== KEY_SIZE) throw new RangeError('终端密钥必须是 32 字节'); + + const init: SessionInit = { + link: parsed, + endpointSecret, + endpointHash: await sha256(endpointSecret), + }; + if (parsed.k) { + init.k = parsed.k; + init.keys = await deriveChannelKeys(parsed.idBytes, parsed.k); + } + if (parsed.a) { + init.a = parsed.a; + init.ownerToken = await deriveOwnerToken(parsed.idBytes, parsed.a); + } + if (parsed.w) init.w = parsed.w; + return new ChannelSession(init); + } + + /** + * 创建新频道:生成 id 和密钥,返回会话以及 POST /channels 的请求体和请求头。 + * 随机数全部来自 crypto.getRandomValues。 + */ + static async create(opts: CreateOptions = {}): Promise { + const mode = opts.mode ?? 'enc'; + const idBytes = randomBytes(ID_SIZE); + const id = toBase64Url(idBytes); + const a = randomBytes(KEY_SIZE); + const endpointSecret = opts.endpointSecret ? Uint8Array.from(opts.endpointSecret) : randomBytes(KEY_SIZE); + if (endpointSecret.length !== KEY_SIZE) throw new RangeError('终端密钥必须是 32 字节'); + + const ownerToken = await deriveOwnerToken(idBytes, a); + const body: CreateChannelBody = { id, mode, owner_hash: toBase64Url(await sha256(ownerToken)) }; + if (opts.settings) body.settings = opts.settings; + + const init: SessionInit = { + link: { id, idBytes, mode, role: 'owner' as const }, + a, + ownerToken, + endpointSecret, + endpointHash: await sha256(endpointSecret), + }; + + if (mode === 'enc') { + const k = randomBytes(KEY_SIZE); + const keys = await deriveChannelKeys(idBytes, k); + init.k = k; + init.keys = keys; + body.read_hash = toBase64Url(await sha256(keys.readToken)); + // enc 频道的写令牌总是由 K 派生;成员能不能写由频道设置 members_can_upload 决定 + body.write_hash = toBase64Url(await sha256(keys.writeToken)); + } else if (opts.settings?.members_can_upload !== false) { + // raw 频道没有 K:成员的写令牌是随机的 W,放进成员链接 + const w = randomBytes(KEY_SIZE); + init.w = w; + body.write_hash = toBase64Url(await sha256(w)); + } + + const session = new ChannelSession(init); + return { session, request: { body, headers: session.authHeaders('owner') } }; + } + + // ───────────────────────── 凭据与链接 ───────────────────────── + + /** 当前会话能以哪些角色发请求。 */ + can(role: AuthRole): boolean { + switch (role) { + case 'read': + return true; // raw 无需令牌;enc 链接一定带 K + case 'write': + return this.mode === 'enc' ? this.#keys !== undefined : this.#w !== undefined; + case 'owner': + return this.#ownerToken !== undefined; + } + } + + /** X-WD-Role / Authorization / X-WD-Endpoint。缺少对应密钥时抛 CapabilityError。 */ + authHeaders(role: AuthRole): Record { + if (!this.can(role)) throw new CapabilityError(role); + const headers: Record = { + 'X-WD-Role': role, + 'X-WD-Endpoint': toBase64Url(this.endpointSecret), + }; + const token = this.#token(role); + if (token) headers.Authorization = `Bearer ${toBase64Url(token)}`; + return headers; + } + + #token(role: AuthRole): Bytes | undefined { + if (role === 'owner') return this.#ownerToken; + if (this.mode === 'enc') return role === 'read' ? this.#keys?.readToken : this.#keys?.writeToken; + return role === 'write' ? this.#w : undefined; // raw 的读权限就是 id 本身 + } + + /** + * 核对服务端返回的频道信息(GET /channels/{id} 或创建响应)与链接是否一致。 + * 链接带 k 却被告知是 raw,说明服务端想让客户端降级成明文,必须拒绝。 + */ + verifyChannelInfo(info: { id: string; mode: string }): void { + if (info.id !== this.id) throw new LinkError('服务端返回了另一个频道'); + if (info.mode !== this.mode) throw new ModeMismatchError(); + } + + /** 生成分享链接。origin 如 "https://app.example.com"。 */ + link(origin: string, kind: 'member' | 'owner' = 'member'): string { + if (kind === 'owner') { + if (!this.#a) throw new CapabilityError('owner'); + return buildLink(origin, { id: this.id, ...(this.#k && { k: this.#k }), a: this.#a, ...(this.#w && { w: this.#w }) }); + } + return buildLink(origin, { id: this.id, ...(this.#k && { k: this.#k }), ...(this.mode === 'raw' && this.#w && { w: this.#w }) }); + } + + // ───────────────────────── 发送:加密成帧 ───────────────────────── + + /** 一个条目的 AES-GCM 密钥,带缓存。 */ + itemKey(itemId: Uint8Array): Promise { + if (!this.#keys) throw new CapabilityError('enc'); + const cacheKey = toBase64Url(itemId); + let key = this.#itemKeys.get(cacheKey); + if (!key) { + key = deriveItemKey(this.#keys.encRoot, itemId); + if (this.#itemKeys.size >= ITEM_KEY_CACHE) this.#itemKeys.delete(this.#itemKeys.keys().next().value as string); + this.#itemKeys.set(cacheKey, key); + } + return key; + } + + #meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial = {}): ItemMeta { + const meta: ItemMeta = { kind, ts: opts.ts ?? Date.now(), ...extra }; + const dev = opts.dev ?? this.deviceName; + if (dev !== undefined) meta.dev = dev; + return meta; + } + + #plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan { + if (this.mode === 'enc') { + return planEncItem( + async (p) => sealFrame(await this.itemKey(p.itemId), this.idBytes, p), + source, + meta, + opts, + ); + } + // raw:没有元数据,内容原样放进负载 + return planRawItem((p) => encodeEnvelope(p.flags, p.itemId, p.index, p.payload), source, opts); + } + + encryptBytes(content: Uint8Array, meta: Partial & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan { + const { kind, ...rest } = meta; + return this.#plan(content, this.#meta(kind ?? 'other', opts, rest), opts); + } + + encryptText(text: string, opts: PlanItemOptions = {}): ItemPlan { + return this.#plan(utf8(text), this.#meta('text', opts), opts); + } + + /** 把一个 JSON 值作为条目发送。`snapshot: true` 时作为频道的最新快照。 */ + encryptJson(value: unknown, opts: PlanItemOptions = {}): ItemPlan { + const text = JSON.stringify(value); + if (text === undefined) throw new TypeError('值无法序列化为 JSON'); + return this.#plan(utf8(text), this.#meta('json', opts), opts); + } + + /** + * 发送文件或流。File/Blob 会自动带上文件名、类型和大小;ReadableStream 的大小未知, + * 可通过 meta 覆盖。放不进单帧的内容自动拆成多帧并标记 GATED。 + */ + encryptFile(file: Blob | ReadableStream, meta: FileMetaOverrides = {}, opts: PlanItemOptions = {}): ItemPlan { + const extra: Partial = {}; + const isBlob = typeof Blob !== 'undefined' && file instanceof Blob; + const name = meta.name ?? (isBlob && 'name' in file ? (file as File).name : undefined); + const mime = meta.mime ?? (isBlob && file.type ? file.type : undefined); + const size = meta.size ?? (isBlob ? file.size : undefined); + if (name !== undefined) extra.name = name; + if (mime !== undefined) extra.mime = mime; + if (size !== undefined) extra.size = size; + return this.#plan(file, this.#meta('file', opts, extra), opts); + } + + // ───────────────────────── 接收:解密与装配 ───────────────────────── + + get #readerContext(): ReaderContext { + return { + channelId: this.idBytes, + mode: this.mode, + itemKey: (itemId) => this.itemKey(itemId), + }; + } + + /** 创建一个条目装配器:喂入 WebSocket / log 接口的记录,得到解密后的条目事件。 */ + createItemReader(): ItemReader { + return new ItemReader(this.#readerContext); + } + + /** 流式解密一个条目(GET /items/{id}/frames 的响应体),用于大文件。 */ + decryptItemStream( + source: ReadableStream | AsyncIterable, + opts: DecryptStreamOptions = {}, + ): { content: ReadableStream; result: Promise } { + return decryptItemStream(this.#readerContext, source, opts); + } + + // ───────────────────────── 设备名(protocol §10) ───────────────────────── + + /** + * 加密设备名,作为 PUT /seats/me 的请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16), + * AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。 + * @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。 + */ + async sealLabel(name: string, opts: { iv?: Uint8Array } = {}): Promise { + const plain = utf8(name); + if (this.mode === 'raw') { + if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长'); + return plain; + } + if (plain.length === 0) throw new RangeError('设备名不能为空'); + if (plain.length + LABEL_IV_SIZE + TAG_SIZE > MAX_LABEL_BYTES) { + throw new LimitError(`设备名过长(最多 ${MAX_LABEL_BYTES - LABEL_IV_SIZE - TAG_SIZE} 字节 UTF-8)`); + } + const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE); + const sealed = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(this.endpointHash), tagLength: 128 }, + (this.#keys as ChannelKeys).labelKey, + plain, + ); + return concat(iv, new Uint8Array(sealed)); + } + + /** 解密席位列表里某个终端的设备名。endpointHash 可以是 base64url 文本或字节。 */ + async openLabel(blob: Uint8Array | string, endpointHash: Uint8Array | string): Promise { + const raw = typeof blob === 'string' ? fromBase64Url(blob) : blob; + const hash = typeof endpointHash === 'string' ? fromBase64Url(endpointHash, 32) : endpointHash; + if (!raw || !hash) throw new IntegrityError('bad_envelope'); + + if (this.mode === 'raw') { + const text = tryDecodeUtf8(raw); + if (text === null) throw new IntegrityError('bad_meta'); + return text; + } + if (raw.length < LABEL_IV_SIZE + TAG_SIZE + 1) throw new IntegrityError('bad_envelope'); + try { + const plain = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: raw.slice(0, LABEL_IV_SIZE), additionalData: this.#labelAad(hash), tagLength: 128 }, + (this.#keys as ChannelKeys).labelKey, + raw.slice(LABEL_IV_SIZE), + ); + const text = tryDecodeUtf8(new Uint8Array(plain)); + if (text === null) throw new IntegrityError('bad_meta'); + return text; + } catch (e) { + if (e instanceof IntegrityError) throw e; + throw new IntegrityError('auth_failed'); + } + } + + #labelAad(endpointHash: Uint8Array): Bytes { + return concat(utf8(AAD_LABEL), this.idBytes, endpointHash); + } + + // ───────────────────────── 清理 ───────────────────────── + + /** 尽力清零可以清零的字节并清空缓存。JS 无法保证擦除,这只是缩小暴露窗口。 */ + dispose(): void { + for (const b of [this.#k, this.#a, this.#w, this.#ownerToken, this.#keys?.readToken, this.#keys?.writeToken]) { + if (b) wipe(b); + } + wipe(this.endpointSecret); + this.#itemKeys.clear(); + } +} + +/** 打开频道链接的便捷函数。 */ +export const openLink = ChannelSession.open; diff --git a/packages/core/src/stream.ts b/packages/core/src/stream.ts new file mode 100644 index 0000000..4fbd9fb --- /dev/null +++ b/packages/core/src/stream.ts @@ -0,0 +1,199 @@ +import { Flags } from './constants.js'; +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 type { ReaderContext } from './reader.js'; + +/** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */ +class ByteQueue { + readonly #parts: Uint8Array[] = []; + #length = 0; + + get length(): number { + return this.#length; + } + + push(chunk: Uint8Array): void { + if (chunk.length === 0) return; + this.#parts.push(chunk); + this.#length += chunk.length; + } + + /** 复制前 n 个字节,不消耗。 */ + peek(n: number): Bytes { + const out = new Uint8Array(n); + let filled = 0; + for (const part of this.#parts) { + if (filled >= n) break; + const take = Math.min(part.length, n - filled); + out.set(part.subarray(0, take), filled); + filled += take; + } + return out; + } + + take(n: number): Bytes { + const out = new Uint8Array(n); + let filled = 0; + while (filled < n) { + const head = this.#parts[0] as Uint8Array; + const need = n - filled; + if (head.length <= need) { + out.set(head, filled); + filled += head.length; + this.#parts.shift(); + } else { + out.set(head.subarray(0, need), filled); + this.#parts[0] = head.subarray(need); + filled += need; + } + } + this.#length -= n; + return out; + } +} + +async function* bytesOf(source: ReadableStream | AsyncIterable): AsyncGenerator { + if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) { + const reader = source.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } + } + yield* source as AsyncIterable; +} + +/** + * 把「封装首尾相接」的字节流(GET /items/{id}/frames 的响应体)切成一个个完整封装。 + * 连接中途断开会在末尾留下半个封装:这是正常的网络情况,不是篡改,所以直接丢弃, + * 调用方根据 decryptItemStream 报告的 nextIndex 续传即可。 + */ +export async function* splitEnvelopes( + source: ReadableStream | AsyncIterable, +): AsyncGenerator { + const queue = new ByteQueue(); + for await (const chunk of bytesOf(source)) { + queue.push(chunk); + for (;;) { + if (queue.length < 26) break; + const total = peekEnvelopeLength(queue.peek(26), MAX_ENC_PAYLOAD); + if (total === null || queue.length < total) break; + yield queue.take(total); + } + } +} + +export interface DecryptStreamOptions { + /** 这次响应从哪一帧开始(拉取时的 from 参数),默认 0。 */ + fromIndex?: number; + /** 期望的条目 id(base64url 解码后的字节)。提供后,混入别的条目的帧会被拒绝。 */ + itemId?: Uint8Array; +} + +export interface ItemDecodeResult { + /** 已收到已认证的 FINAL。 */ + complete: boolean; + /** 下一个期望的帧序号。没收完时用它作为 from 续传(不会重复计下载次数)。 */ + nextIndex: number; + /** 第 0 帧里的元数据;从中间帧续传时没有。 */ + meta: ItemMeta | undefined; +} + +/** + * 流式解密一个条目:边收边解边产出内容,内存占用只有一帧。可以放进 Worker 里运行。 + * + * 逐帧校验:认证、条目 id、帧序号连续、标志一致、FINAL 之后不再有帧。 + * 任何一项失败都会抛 IntegrityError(流会出错),已产出的内容不可信的部分由调用方丢弃。 + * 流正常结束但没有 FINAL 不是错误(连接可能被断开),此时 complete 为 false。 + */ +export async function* decryptItemFrames( + ctx: ReaderContext, + source: ReadableStream | AsyncIterable, + opts: DecryptStreamOptions = {}, +): AsyncGenerator { + let expected = opts.fromIndex ?? 0; + let itemId = opts.itemId; + let flagsMask: number | undefined; + let finalSeen = false; + let meta: ItemMeta | undefined; + + for await (const bytes of splitEnvelopes(source)) { + const env = parseEnvelope(bytes); + + const isEnc = (env.flags & Flags.Enc) !== 0; + if (isEnc !== (ctx.mode === 'enc')) throw new IntegrityError('wrong_mode'); + + if (!itemId) itemId = env.itemId; + else if (!equalBytes(itemId, env.itemId)) throw new IntegrityError('item_mismatch'); + + if (finalSeen) throw new IntegrityError('frame_after_final'); + if (env.frameIndex !== expected) throw new IntegrityError('gap'); + + const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot); + if (flagsMask === undefined) flagsMask = itemFlags; + else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed'); + + let data: Bytes; + if (ctx.mode === 'enc') { + data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env); + if (env.frameIndex === 0) { + const first = decodeFirstFrame(data); + meta = first.meta; + data = first.content; + } + } else { + data = env.payload; + } + + expected++; + if ((env.flags & Flags.Final) !== 0) finalSeen = true; + if (data.length > 0) yield data; + } + + return { complete: finalSeen, nextIndex: expected, meta }; +} + +/** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */ +export function decryptItemStream( + ctx: ReaderContext, + source: ReadableStream | AsyncIterable, + opts: DecryptStreamOptions = {}, +): { content: ReadableStream; result: Promise } { + const gen = decryptItemFrames(ctx, source, opts); + let resolve!: (r: ItemDecodeResult) => void; + let reject!: (e: unknown) => void; + const result = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + result.catch(() => {}); // 错误通过流本身传播,避免未处理的 rejection + + const content = new ReadableStream({ + async pull(controller) { + try { + const r = await gen.next(); + if (r.done) { + resolve(r.value); + controller.close(); + } else { + controller.enqueue(r.value); + } + } catch (e) { + reject(e); + controller.error(e); + } + }, + async cancel() { + await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined }); + }, + }); + return { content, result }; +} + diff --git a/packages/core/test/e2e.test.ts b/packages/core/test/e2e.test.ts new file mode 100644 index 0000000..2ee6e09 --- /dev/null +++ b/packages/core/test/e2e.test.ts @@ -0,0 +1,435 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { toBase64Url } from '../src/bytes.js'; +import { + ChannelSession, + collectFrames, + itemJson, + itemText, + parseLogRecords, + type AuthRole, + type ItemEvent, + type ItemPlan, + type ReceivedItem, +} from '../src/index.js'; +import { concatAll, ORIGIN, pseudoRandom, readAll, toHex } from './helpers.js'; + +/** + * 端到端:用 @webdrop/core 生成的真实密文,对着真实的服务端(NativeAOT 可执行文件, + * 没有就退回到 JIT 的 dll)走一遍完整流程。证明库产出的帧能通过服务端的全部校验, + * 并且经由真实的 HTTP / WebSocket 往返后能被正确解密。 + * + * 用环境变量 WEBDROP_SERVER 指定可执行文件;WEBDROP_SERVER_DLL 指定 dll。 + */ + +const root = join(import.meta.dirname, '..', '..', '..', 'server'); +const exe = process.env.WEBDROP_SERVER ?? join(root, 'out', 'win-x64', 'webdrop.exe'); +const linuxExe = join(root, 'out', 'linux-x64', 'webdrop'); +const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll'); + +const command: [string, string[]] | undefined = existsSync(exe) + ? [exe, []] + : existsSync(linuxExe) + ? [linuxExe, []] + : existsSync(dll) + ? ['dotnet', [dll]] + : undefined; + +let proc: ChildProcess | undefined; +let dataDir = ''; +let base = ''; + +beforeAll(async () => { + if (!command) return; + dataDir = mkdtempSync(join(tmpdir(), 'webdrop-e2e-')); + proc = spawn(command[0], [...command[1], '--urls', 'http://127.0.0.1:0'], { + env: { + ...process.env, + WebDrop__DataDir: dataDir, + WebDrop__RequestsPerMinute: '1000000', + WebDrop__ChannelCreatesPerMinute: '1000000', + WebDrop__Captcha__Provider: 'none', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + base = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('服务端没有在 20 秒内启动')), 20_000); + let buffer = ''; + proc!.stdout!.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + const m = /Now listening on:\s*(http:\/\/\S+)/.exec(buffer); + if (m) { + clearTimeout(timer); + resolve(m[1]!); + } + }); + proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`))); + }); +}, 30_000); + +afterAll(async () => { + if (proc && !proc.killed) proc.kill(); + await new Promise((r) => setTimeout(r, 300)); + if (dataDir) rmSync(dataDir, { recursive: true, force: true }); +}); + +// ───────────────────────── 客户端辅助 ───────────────────────── + +const api = (path: string) => `${base}/api/v1${path}`; + +async function createChannel(opts: Parameters[0] = {}) { + const created = await ChannelSession.create(opts); + const res = await fetch(api('/channels'), { + method: 'POST', + headers: { ...created.request.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(created.request.body), + }); + expect(res.status).toBe(201); + const info = (await res.json()) as { id: string; mode: string }; + created.session.verifyChannelInfo(info); // 核对服务端返回的模式与链接一致 + return created.session; +} + +async function joinChannel(session: ChannelSession, role: AuthRole = 'read') { + const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: session.authHeaders(role) }); + expect(res.status, await res.clone().text()).toBe(200); +} + +async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) { + const seqs: number[] = []; + let first = true; + for await (const frame of plan.frames) { + const res = await fetch(api(`/channels/${session.id}/frames`), { + method: 'POST', + headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) }, + body: frame.bytes, + }); + expect(res.status, await res.clone().text()).toBeLessThan(300); + seqs.push(((await res.json()) as { seq: number }).seq); + first = false; + } + return seqs; +} + +async function readLog(session: ChannelSession, role: AuthRole, from = 0) { + const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: session.authHeaders(role) }); + expect(res.status).toBe(200); + return parseLogRecords(new Uint8Array(await res.arrayBuffer())); +} + +/** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */ +function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) { + const h = session.authHeaders(role); + const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`]; + if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`); + + const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols); + ws.binaryType = 'arraybuffer'; + const reader = session.createItemReader(); + const events: ItemEvent[] = []; + + const done = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`等待 WebSocket 事件超时,已收到 ${JSON.stringify(events.map((e) => e.type))}`)), 15_000); + let chain = Promise.resolve(); + ws.onmessage = (m) => { + if (typeof m.data === 'string') return; // 控制消息(心跳等) + chain = chain.then(async () => { + events.push(...(await reader.push(new Uint8Array(m.data as ArrayBuffer)))); + if (until(events)) { + clearTimeout(timer); + resolve(events); + } + }); + }; + ws.onerror = () => reject(new Error('WebSocket 出错')); + }); + const opened = new Promise((resolve) => (ws.onopen = () => resolve())); + return { ws, done, opened, events }; +} + +const items = (events: ItemEvent[]): ReceivedItem[] => events.flatMap((e) => (e.type === 'item' ? [e.item] : [])); + +const suite = command ? describe : describe.skip; +// 全局 WebSocket 从 Node 22 起提供(本包要求 >=22);更旧的 Node 上明确跳过依赖它的用例,而不是误报失败 +const wsIt = typeof WebSocket === 'undefined' ? it.skip : it; + +suite('端到端(真实服务端)', () => { + it('文本:所有者发送,成员经日志拉取并解密', async () => { + const owner = await createChannel(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + owner.deviceName = '桌面'; + await joinChannel(member, 'read'); + + await send(owner, owner.encryptText('hello 端到端', { ts: 1 }), 'owner'); + + const reader = member.createItemReader(); + const events: ItemEvent[] = []; + for (const rec of await readLog(member, 'read')) events.push(...(await reader.push(rec))); + + const [item] = items(events); + expect(itemText(item!)).toBe('hello 端到端'); + expect(item!.meta).toMatchObject({ kind: 'text', dev: '桌面' }); + }); + + wsIt('WebSocket 实时推送:成员在线时收到所有者新发的内容', async () => { + const owner = await createChannel(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + await joinChannel(member, 'read'); + + const sub = subscribe(member, 'read', (ev) => items(ev).length >= 2); + await sub.opened; + await send(owner, owner.encryptText('第一条'), 'owner'); + await send(owner, owner.encryptJson({ n: 2 }), 'owner'); + + const got = items(await sub.done); + expect(itemText(got[0]!)).toBe('第一条'); + expect(itemJson(got[1]!)).toEqual({ n: 2 }); + sub.ws.close(); + }); + + wsIt('成员也能上传(写令牌由 K 派生),所有者实时收到', async () => { + const owner = await createChannel(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + await joinChannel(member, 'write'); + + const sub = subscribe(owner, 'owner', (ev) => items(ev).length >= 1); + await sub.opened; + await send(member, member.encryptText('来自成员'), 'write'); + expect(itemText(items(await sub.done)[0]!)).toBe('来自成员'); + sub.ws.close(); + }); + + wsIt('快照:新快照取代旧快照,订阅者先后收到 item 和 removed(superseded)', async () => { + const owner = await createChannel(); + const sub = subscribe(owner, 'owner', (ev) => ev.some((e) => e.type === 'removed')); + await sub.opened; + + await send(owner, owner.encryptJson({ v: 1 }, { snapshot: true }), 'owner'); + await send(owner, owner.encryptJson({ v: 2 }, { snapshot: true }), 'owner'); + + const events = await sub.done; + expect(events.map((e) => e.type)).toEqual(['item', 'item', 'removed']); + expect(items(events).map((i) => itemJson(i))).toEqual([{ v: 1 }, { v: 2 }]); + expect(events.find((e) => e.type === 'removed')).toMatchObject({ reason: 'superseded' }); + sub.ws.close(); + }); + + it('幂等:重发同一帧返回 200 和同一个 seq(确定性加密)', async () => { + const owner = await createChannel(); + const plan = owner.encryptText('retry', { ts: 5 }); + const [first] = await send(owner, plan, 'owner'); + + const again = owner.encryptText('retry', { ts: 5, itemId: plan.idBytes }); + const frame = (await collectFrames(again))[0]!; + const res = await fetch(api(`/channels/${owner.id}/frames`), { + method: 'POST', + headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, + body: frame.bytes, + }); + expect(res.status).toBe(200); + expect(((await res.json()) as { seq: number }).seq).toBe(first); + }); + + it('服务端的存储里没有明文', async () => { + const owner = await createChannel(); + const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c'; + await send(owner, owner.encryptText(marker), 'owner'); + + const res = await fetch(api(`/channels/${owner.id}/log`), { headers: owner.authHeaders('owner') }); + const wire = new Uint8Array(await res.arrayBuffer()); + expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker); + expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"'); + }); + + it('大文件:多帧 GATED 上传,日志里只有 announce,下载流式解密后与原文一致', async () => { + const owner = await createChannel(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + await joinChannel(member, 'read'); + + const content = pseudoRandom(3_300_000, 99); + const plan = owner.encryptFile(new Blob([content]), { name: 'big.bin', mime: 'application/octet-stream' }, { maxDownloads: 5 }); + await send(owner, plan, 'owner'); + expect(plan.gated).toBe(true); + + // 日志里只有一条 announce,没有任何文件字节 + const log = await readLog(member, 'read'); + expect(log.map((r) => r.kind)).toEqual(['announce']); + + // 流式下载 + 流式解密 + const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: member.authHeaders('read') }); + expect(res.status).toBe(200); + const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes }); + const out = await readAll(stream); + + expect(await result).toMatchObject({ complete: true }); + expect(out.length).toBe(content.length); + expect(Buffer.compare(out, content)).toBe(0); + expect((await result).meta).toMatchObject({ name: 'big.bin', size: content.length }); + }, 60_000); + + it('下载上限:超过后从头下载返回 410,续传(from>0)在宽限期内仍可用', async () => { + const owner = await createChannel(); + const content = pseudoRandom(2_500_000, 5); + const plan = owner.encryptFile(new Blob([content]), { name: 'once.bin' }, { maxDownloads: 1 }); + await send(owner, plan, 'owner'); + + const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`); + const first = await fetch(url, { headers: owner.authHeaders('owner') }); + const decoded = owner.decryptItemStream(first.body!); + expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0); + + const second = await fetch(url, { headers: owner.authHeaders('owner') }); + expect(second.status).toBe(410); + expect(((await second.json()) as { error: string }).error).toBe('gone'); + + const resumed = await fetch(`${url}?from=1`, { headers: owner.authHeaders('owner') }); + expect(resumed.status).toBe(200); + await resumed.arrayBuffer(); + }, 60_000); + + it('上传断点续传:中途中断后用 fromFrame 重新加密剩余部分,服务端得到完整条目', async () => { + const owner = await createChannel(); + const content = pseudoRandom(3_300_000, 123); + const blob = new Blob([content]); + + const itemId = owner.encryptFile(blob).idBytes; + const full = await collectFrames(owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9 })); + expect(full.length).toBe(4); + + // 第一轮只上传前两帧,然后「崩溃」 + const first = owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9 }); + let sent = 0; + let isFirst = true; + for await (const f of first.frames) { + if (sent === 2) break; + const res = await fetch(api(`/channels/${owner.id}/frames`), { + method: 'POST', + headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) }, + body: f.bytes, + }); + expect(res.status).toBe(201); + isFirst = false; + sent++; + } + + // 第二轮:从第 2 帧开始重新加密(字节与完整加密一致),并且重发第 1 帧也是幂等的 + const rest = owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9, fromFrame: 1 }); + const statuses: number[] = []; + for await (const f of rest.frames) { + const res = await fetch(api(`/channels/${owner.id}/frames`), { + method: 'POST', + headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, + body: f.bytes, + }); + statuses.push(res.status); + } + expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的 + + const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: owner.authHeaders('owner') }); + const { content: stream, result } = owner.decryptItemStream(res.body!); + expect(Buffer.compare(await readAll(stream), content)).toBe(0); + expect((await result).complete).toBe(true); + }, 60_000); + + it('下载中途断线,用 nextIndex 续传,拼起来与原文一致', async () => { + const owner = await createChannel(); + const content = pseudoRandom(3_300_000, 7); + const plan = owner.encryptFile(new Blob([content]), { name: 'cut.bin' }); + await send(owner, plan, 'owner'); + const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`); + + // 第一次:只读前约 1.5 帧就断开 + const first = await fetch(url, { headers: owner.authHeaders('owner') }); + const reader = first.body!.getReader(); + const chunks: Uint8Array[] = []; + let got = 0; + while (got < 1_600_000) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + got += value.length; + } + await reader.cancel(); + + const part1 = await (async () => { + const dec = owner.decryptItemStream(new ReadableStream({ start: (c) => { chunks.forEach((x) => c.enqueue(x)); c.close(); } })); + return { bytes: await readAll(dec.content), result: await dec.result }; + })(); + expect(part1.result.complete).toBe(false); + + const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: owner.authHeaders('owner') }); + expect(second.status).toBe(200); + const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex }); + const part2 = await readAll(dec2.content); + + expect(toHex(concatAll([part1.bytes, part2]).subarray(0, 32))).toBe(toHex(content.subarray(0, 32))); + expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0); + }, 60_000); + + it('设备名:成员加密上传,所有者在席位列表里解开', async () => { + const owner = await createChannel(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + await joinChannel(member, 'read'); + + const blob = await member.sealLabel('我的 iPhone'); + const put = await fetch(api(`/channels/${owner.id}/seats/me`), { + method: 'PUT', + headers: { ...member.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, + body: blob, + }); + expect(put.status).toBe(204); + + const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: owner.authHeaders('owner') }); + const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>; + const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!; + expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone'); + // 服务端存的是密文 + expect(Buffer.from(mine.label!, 'base64url').toString('utf8')).not.toContain('iPhone'); + }); + + it('最长的设备名(228 字节)也被服务端接受', async () => { + const owner = await createChannel(); + const blob = await owner.sealLabel('x'.repeat(228)); + const put = await fetch(api(`/channels/${owner.id}/seats/me`), { + method: 'PUT', + headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, + body: blob, + }); + expect(put.status).toBe(204); + }); + + it('raw 频道:匿名成员无需令牌即可读取,服务端看得到明文', async () => { + const owner = await createChannel({ mode: 'raw' }); + const member = await ChannelSession.open(owner.link(ORIGIN)); + await joinChannel(member, 'read'); + + await send(owner, owner.encryptText('raw 明文 visible'), 'owner'); + const records = await readLog(member, 'read'); + const [item] = items(await (async () => { + const reader = member.createItemReader(); + const out: ItemEvent[] = []; + for (const r of records) out.push(...(await reader.push(r))); + return out; + })()); + expect(item!.kind).toBe('raw'); + expect(itemText(item!)).toBe('raw 明文 visible'); + + // 对比 enc:raw 的明文确实出现在服务端返回的字节里 + const res = await fetch(api(`/channels/${owner.id}/log`), { headers: member.authHeaders('read') }); + expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible'); + }); + + it('下发给成员的链接没有管理权:成员拿不到所有者能力', async () => { + const owner = await createChannel(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + await joinChannel(member, 'read'); + + const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...member.authHeaders('read') } }); + expect(res.status).toBe(403); + expect(() => member.authHeaders('owner')).toThrow(); + }); +}); diff --git a/packages/core/test/helpers.ts b/packages/core/test/helpers.ts new file mode 100644 index 0000000..08287ab --- /dev/null +++ b/packages/core/test/helpers.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { fromHex, toBase64Url, toHex, type Bytes } from '../src/bytes.js'; +import { ChannelSession, collectFrames, type EncodedFrame, type ItemPlan } from '../src/index.js'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const vectors: any = JSON.parse(readFileSync(new URL('../../../test-vectors/vectors.json', import.meta.url), 'utf8')); + +export const h = (s: string): Bytes => fromHex(s); +export { toHex }; + +/** 向量里的固定输入。 */ +export const inputs = { + channelId: h(vectors.inputs.channel_id), + channelIdB64: vectors.inputs.channel_id_b64 as string, + k: h(vectors.inputs.k), + a: h(vectors.inputs.a), + w: h(vectors.inputs.w), + endpointSecret: h(vectors.inputs.endpoint_secret), + itemA: h(vectors.inputs.item_a), + itemB: h(vectors.inputs.item_b), + itemC: h(vectors.inputs.item_c), +}; + +export const ORIGIN = 'https://app.example.test'; + +/** 用向量里的 channel_id / K(/A) 打开一个会话,终端密钥也取向量值。 */ +export function openVectorSession(opts: { owner?: boolean } = {}): Promise { + const parts = [`k=${toBase64Url(inputs.k)}`]; + if (opts.owner) parts.push(`a=${toBase64Url(inputs.a)}`); + return ChannelSession.open(`${ORIGIN}/c/${inputs.channelIdB64}#${parts.join('&')}`, { endpointSecret: inputs.endpointSecret }); +} + +export async function framesOf(plan: ItemPlan): Promise { + return collectFrames(plan); +} + +/** 随机但可复现的字节(线性同余),测试里需要「看起来随机」的内容时用。 */ +export function pseudoRandom(length: number, seed = 1): Bytes { + const out = new Uint8Array(length); + let x = seed >>> 0 || 1; + for (let i = 0; i < length; i++) { + x = (Math.imul(x, 1664525) + 1013904223) >>> 0; + out[i] = x >>> 24; + } + return out; +} + +/** 把字节流在随机位置切成小块,模拟网络分片。 */ +export function* splitAt(bytes: Uint8Array, seed = 7): Generator { + let x = seed >>> 0 || 1; + let offset = 0; + while (offset < bytes.length) { + x = (Math.imul(x, 1664525) + 1013904223) >>> 0; + const size = 1 + (x >>> 8) % 4096; + yield bytes.subarray(offset, offset + size); + offset += size; + } +} + +export async function* asyncChunks(chunks: Iterable): AsyncGenerator { + for (const c of chunks) yield c; +} + +export async function readAll(stream: ReadableStream): Promise { + const parts: Uint8Array[] = []; + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} + +export function concatAll(parts: Uint8Array[]): Bytes { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} diff --git a/packages/core/test/properties.test.ts b/packages/core/test/properties.test.ts new file mode 100644 index 0000000..1687986 --- /dev/null +++ b/packages/core/test/properties.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { ChannelSession, collectFrames, decryptItemFrames, type ReceivedItem } from '../src/index.js'; +import { parseEnvelope } from '../src/internal.js'; +import { openFrame } from '../src/frame.js'; +import { asyncChunks, concatAll, ORIGIN, pseudoRandom, splitAt, toHex } from './helpers.js'; + +async function pair() { + const { session: owner } = await ChannelSession.create(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + return { owner, member }; +} + +const TIERS_PLUS_TAG = [1040, 4112, 16400, 65552, 262160, 1048592]; + +describe('篡改全覆盖', () => { + it('翻转一帧里任意一个字节的任意一位,接收方都不会产出内容', async () => { + const { owner, member } = await pair(); + const [frame] = await collectFrames(owner.encryptText('篡改检测:每个字节都被认证', { ts: 1 })); + const original = frame!.bytes; + expect(original.length).toBe(26 + 1040); + + const accepted: string[] = []; + for (let i = 0; i < original.length; i++) { + for (const bit of [0, 7]) { + const mutated = original.slice(); + mutated[i]! ^= 1 << bit; + const events = await member.createItemReader().pushFrame(mutated); + if (events.some((e) => e.type === 'item')) accepted.push(`字节 ${i} 位 ${bit}`); + } + } + expect(accepted).toEqual([]); + }); + + it('openFrame 对头部每个字节的改动要么在解析阶段、要么在认证阶段失败', async () => { + const { owner } = await pair(); + const [frame] = await collectFrames(owner.encryptText('x')); + const env = parseEnvelope(frame!.bytes); + const key = await owner.itemKey(env.itemId); + + for (let i = 0; i < 22; i++) { + // 前 22 字节(版本、标志、条目 id、帧序号)都在 AAD 里 + const mutated = frame!.bytes.slice(); + mutated[i]! ^= 1; + let failed = false; + try { + await openFrame(key, owner.idBytes, parseEnvelope(mutated)); + } catch { + failed = true; + } + expect(failed, `头部字节 ${i}`).toBe(true); + } + }); + + it('截断任意长度的帧都会被拒绝', async () => { + const { owner, member } = await pair(); + const [frame] = await collectFrames(owner.encryptText('truncate')); + for (const cut of [1, 2, 15, 16, 17, 100, 1039, 1040]) { + const events = await member.createItemReader().pushFrame(frame!.bytes.slice(0, frame!.bytes.length - cut)); + expect(events[0], `截掉 ${cut} 字节`).toMatchObject({ type: 'rejected' }); + } + }); + + it('交换两个条目的密文负载(头部不动)会被拒绝', async () => { + const { owner, member } = await pair(); + const [a] = await collectFrames(owner.encryptText('A')); + const [b] = await collectFrames(owner.encryptText('B')); + const swapped = a!.bytes.slice(); + swapped.set(b!.bytes.slice(26), 26); + expect((await member.createItemReader().pushFrame(swapped))[0]).toMatchObject({ type: 'rejected', reason: 'auth_failed' }); + }); +}); + +describe('padding 与帧长度', () => { + it('每种内容长度产生的帧负载都落在档位上(服务端据此强制 padding)', async () => { + const { owner } = await pair(); + const sizes = [0, 1, 50, 800, 900, 1000, 1010, 1018, 1019, 1020, 3000, 4000, 4080, 4096, 16000, 16380, 60000, 65000, 250000, 262000, 1048000]; + const seen = new Set(); + for (const size of sizes) { + const frames = await collectFrames(owner.encryptBytes(pseudoRandom(size, size + 1), { kind: 'other' }, { ts: 1 })); + for (const f of frames) { + const payload = f.bytes.length - 26; + expect(TIERS_PLUS_TAG, `size=${size}`).toContain(payload); + seen.add(payload); + } + } + expect(seen.size).toBeGreaterThanOrEqual(5); // 确实覆盖了多个档位 + }); + + it('长度相近的内容落在同一档位,看不出具体长度', async () => { + const { owner } = await pair(); + const lens: number[] = []; + for (const size of [10, 200, 500, 700]) { + const [f] = await collectFrames(owner.encryptBytes(pseudoRandom(size), { kind: 'other' }, { ts: 1 })); + lens.push(f!.bytes.length); + } + expect(new Set(lens).size).toBe(1); + }); +}); + +describe('随机往返', () => { + it('随机长度、随机帧大小、随机网络切分:内容始终一致', async () => { + const { owner, member } = await pair(); + + // 用固定种子的伪随机,失败时可复现 + let x = 12345; + const rnd = (n: number) => { + x = (Math.imul(x, 1664525) + 1013904223) >>> 0; + return (x >>> 8) % n; + }; + + for (let round = 0; round < 40; round++) { + const size = rnd(6000); + const maxFrameData = 150 + rnd(900); + const content = pseudoRandom(size, round + 100); + + const plan = owner.encryptFile(new Blob([content]), { name: `r${round}` }, { ts: round, maxFrameData }); + const frames = await collectFrames(plan); + const wire = concatAll(frames.map((f) => f.bytes)); + + // 经 ItemReader(逐帧) + const reader = member.createItemReader(); + let last: ReceivedItem | undefined; + for (const f of frames) { + for (const ev of await reader.pushFrame(f.bytes)) if (ev.type === 'item') last = ev.item; + } + expect(last?.complete, `round ${round} size ${size} frame ${maxFrameData}`).toBe(true); + expect(toHex(last!.content)).toBe(toHex(content)); + + // 经流式解密(随机切分) + const gen = decryptItemFrames( + { channelId: member.idBytes, mode: 'enc', itemKey: (id) => member.itemKey(id) }, + asyncChunks(splitAt(wire, round + 1)), + ); + const parts: Uint8Array[] = []; + for (;;) { + const r = await gen.next(); + if (r.done) { + expect(r.value.complete).toBe(true); + break; + } + parts.push(r.value); + } + expect(toHex(concatAll(parts))).toBe(toHex(content)); + + // 帧数符合预期:第 0 帧装 maxFrameData - 元数据 的内容,其余每帧 maxFrameData + expect(frames.at(-1)!.final).toBe(true); + expect(frames.slice(0, -1).every((f) => !f.final)).toBe(true); + } + }); + + it('不同条目的密钥互相独立:用条目 A 的密钥解不开条目 B', async () => { + const { owner } = await pair(); + const [a] = await collectFrames(owner.encryptText('A')); + const [b] = await collectFrames(owner.encryptText('B')); + const keyA = await owner.itemKey(parseEnvelope(a!.bytes).itemId); + await expect(openFrame(keyA, owner.idBytes, parseEnvelope(b!.bytes))).rejects.toMatchObject({ reason: 'auth_failed' }); + }); +}); + +describe('性能底线', () => { + it('8 MiB 文件加密 + 流式解密在几秒内完成', async () => { + const { owner, member } = await pair(); + const content = pseudoRandom(8 * 1024 * 1024, 77); + + const t0 = performance.now(); + const frames = await collectFrames(owner.encryptFile(new Blob([content]), { name: 'big' })); + const wire = concatAll(frames.map((f) => f.bytes)); + const t1 = performance.now(); + + const { content: stream } = member.decryptItemStream(asyncChunks([wire])); + const reader = stream.getReader(); + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + } + const t2 = performance.now(); + + expect(total).toBe(content.length); + console.log(`8 MiB:加密 ${(t1 - t0).toFixed(0)} ms,解密 ${(t2 - t1).toFixed(0)} ms,${frames.length} 帧`); + expect(t2 - t0).toBeLessThan(10_000); + }); +}); + +describe('来源块边界恰好落在帧边界上', () => { + // 来源是流时,我们只有「多读一个字节」才能知道当前帧是不是最后一帧。 + // 如果块边界恰好等于帧容量而后面还有数据,误判为最后一帧会悄悄丢掉后面的内容。 + const META = { kind: 'file' as const, name: 'b' }; + const metaJsonLength = JSON.stringify({ v: 1, kind: 'file', name: 'b', ts: 1 }).length; + const MAX = 1000; + const CAP0 = MAX - 2 - metaJsonLength; + + const streamOf = (sizes: number[], content: Uint8Array) => + new ReadableStream({ + start(c) { + let o = 0; + for (const n of sizes) { + c.enqueue(content.slice(o, o + n)); + o += n; + } + c.close(); + }, + }); + + const cases: Array<[string, number[]]> = [ + ['第一块恰好填满第 0 帧,后面还有', [CAP0, 700]], + ['第一块恰好填满第 0 帧,后面还有多帧', [CAP0, MAX, MAX, 5]], + ['每一块都恰好是一帧', [CAP0, MAX, MAX, MAX]], + ['块边界落在第 1 帧边界', [CAP0 + MAX, 300]], + ['块跨过多个帧边界', [CAP0 + MAX * 2, 17]], + ['逐字节来源', Array.from({ length: CAP0 + MAX + 40 }, () => 1)], + ['最后一块恰好填满最后一帧(不能多出空帧)', [CAP0, MAX]], + ]; + + for (const [name, sizes] of cases) { + it(name, async () => { + const { owner, member } = await pair(); + const total = sizes.reduce((a, b) => a + b, 0); + const content = pseudoRandom(total, total); + + const frames = await collectFrames(owner.encryptFile(streamOf(sizes, content), META, { ts: 1, maxFrameData: MAX })); + const reader = member.createItemReader(); + let last: ReceivedItem | undefined; + for (const f of frames) for (const ev of await reader.pushFrame(f.bytes)) if (ev.type === 'item') last = ev.item; + + expect(last?.complete).toBe(true); + expect(last!.content.length).toBe(total); // 一个字节都不能丢 + expect(toHex(last!.content)).toBe(toHex(content)); + expect(frames.at(-1)!.final).toBe(true); + expect(frames.slice(0, -1).some((f) => f.final)).toBe(false); + + // 帧数必须与「长度已知」的来源完全一致,也就是不能多出空帧、不能少帧 + const known = await collectFrames(owner.encryptBytes(content, META, { ts: 1, maxFrameData: MAX })); + expect(frames).toHaveLength(known.length); + }); + } +}); diff --git a/packages/core/test/reader.test.ts b/packages/core/test/reader.test.ts new file mode 100644 index 0000000..0c4906f --- /dev/null +++ b/packages/core/test/reader.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; +import { concat, toBase64Url, writeU32, writeU64 } from '../src/bytes.js'; +import { ChannelSession, collectFrames, itemText, parseLogRecords, type ItemEvent, type ItemPlan, type ReceivedItem } from '../src/index.js'; +import { encodeMeta, parseEnvelope, sealFrame } from '../src/internal.js'; +import { ORIGIN, pseudoRandom, toHex } from './helpers.js'; + +const GATED = 4; +const ENC = 8; +const FINAL = 1; +const SNAPSHOT = 2; + +async function pair(mode: 'enc' | 'raw' = 'enc') { + const { session: owner } = await ChannelSession.create({ mode }); + const member = await ChannelSession.open(owner.link(ORIGIN)); + return { owner, member }; +} + +/** 一条 frame 日志记录的线格式:seq(8) ‖ kind(1) ‖ 封装。 */ +const frameRecord = (seq: number, envelope: Uint8Array) => concat(writeU64(seq), new Uint8Array([1]), envelope); + +const only = (events: ItemEvent[]): ItemEvent => { + expect(events).toHaveLength(1); + return events[0]!; +}; +const itemOf = (events: ItemEvent[]): ReceivedItem => { + const ev = only(events); + if (ev.type !== 'item') throw new Error(`期望 item,得到 ${JSON.stringify(ev)}`); + return ev.item; +}; + +/** 构造一个多帧 GATED 条目的各帧(小帧,便于测试)。 */ +async function gatedFrames(session: ChannelSession, size: number, frameData = 100) { + const content = pseudoRandom(size, 11); + const plan: ItemPlan = session.encryptBytes(content, { kind: 'file', name: 'f' }, { maxFrameData: frameData, ts: 1 }); + return { content, plan, frames: await collectFrames(plan) }; +} + +describe('日志记录', () => { + it('frame 记录:带 seq,解密出条目', async () => { + const { owner, member } = await pair(); + const [frame] = await collectFrames(owner.encryptText('via log')); + const events = await member.createItemReader().push(frameRecord(42, frame!.bytes)); + const item = itemOf(events); + expect(item.seq).toBe(42); + expect(itemText(item)).toBe('via log'); + }); + + it('parseLogRecords 解析的记录可直接喂给装配器', async () => { + const { owner, member } = await pair(); + const frames = [ + ...(await collectFrames(owner.encryptText('a'))), + ...(await collectFrames(owner.encryptText('b'))), + ]; + const blob = concat(frameRecord(1, frames[0]!.bytes), frameRecord(2, frames[1]!.bytes)); + + const reader = member.createItemReader(); + const texts: string[] = []; + for (const rec of parseLogRecords(blob)) for (const ev of await reader.push(rec)) if (ev.type === 'item') texts.push(itemText(ev.item)); + expect(texts).toEqual(['a', 'b']); + }); + + it('announce:GATED 条目只通知,不含内容', async () => { + const { member } = await pair(); + const id = new Uint8Array(16).fill(5); + const record = concat(writeU64(9), new Uint8Array([3]), id, new Uint8Array([GATED | ENC]), writeU64(1700000000123)); + expect(only(await member.createItemReader().push(record))).toEqual({ + type: 'announce', + itemId: toBase64Url(id), + seq: 9, + flags: GATED | ENC, + createdAt: 1700000000123, + }); + }); + + it('tombstone:删除条目,并清掉装配中的缓冲', async () => { + const { owner, member } = await pair(); + const { frames, plan } = await gatedFrames(owner, 250); + const reader = member.createItemReader(); + await reader.pushFrame(frames[0]!.bytes); // 只收到一部分 + + const tomb = concat(writeU64(7), new Uint8Array([2]), plan.idBytes, new Uint8Array([3])); + expect(only(await reader.push(tomb))).toEqual({ type: 'removed', itemId: plan.itemId, seq: 7, reason: 'download_limit' }); + + // 缓冲已清空:后面的帧从头开始装配,不会和旧状态混在一起 + const ev = itemOf(await reader.pushFrame(frames[1]!.bytes)); + expect(ev.complete).toBe(false); + }); + + it('损坏的日志记录变成 rejected,不抛异常', async () => { + const { member } = await pair(); + const ev = only(await member.createItemReader().push(new Uint8Array([1, 2, 3]))); + expect(ev).toMatchObject({ type: 'rejected', reason: 'bad_envelope' }); + }); +}); + +describe('重放与重复', () => { + it('同一帧重复到达:不产生新事件(日志回放不会让条目重复出现)', async () => { + const { owner, member } = await pair(); + const [frame] = await collectFrames(owner.encryptText('once')); + const reader = member.createItemReader(); + expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true); + expect(await reader.pushFrame(frame!.bytes)).toEqual([]); + expect(await reader.pushFrame(frame!.bytes)).toEqual([]); + }); + + it('装配中的条目收到内容不同的同序号帧:duplicate_mismatch,并丢弃该条目', async () => { + const { owner, member } = await pair(); + const { frames } = await gatedFrames(owner, 250); + const reader = member.createItemReader(); + await reader.pushFrame(frames[0]!.bytes); + + const forged = frames[0]!.bytes.slice(); + forged[forged.length - 1]! ^= 1; + expect(only(await reader.pushFrame(forged))).toMatchObject({ type: 'rejected', reason: 'duplicate_mismatch' }); + }); + + it('条目被删除后,再来的旧帧会重新装配(回放到墓碑之前的数据时不会卡住)', async () => { + const { owner, member } = await pair(); + const [frame] = await collectFrames(owner.encryptText('again')); + const reader = member.createItemReader(); + const { itemId } = parseEnvelopeId(frame!.bytes); + await reader.pushFrame(frame!.bytes); + await reader.push(concat(writeU64(2), new Uint8Array([2]), itemId, new Uint8Array([0]))); + expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true); + }); +}); + +function parseEnvelopeId(envelope: Uint8Array) { + return { itemId: parseEnvelope(envelope).itemId }; +} + +describe('多帧条目的装配', () => { + it('乱序到达也能装配,到齐才 complete', async () => { + const { owner, member } = await pair(); + const { content, frames } = await gatedFrames(owner, 450); // 约 5 帧 + expect(frames.length).toBeGreaterThanOrEqual(4); + + const reader = member.createItemReader(); + const order = [2, 0, 3, 1, ...frames.slice(4).map((_, i) => i + 4)]; + const states: boolean[] = []; + let last: ReceivedItem | undefined; + for (const i of order) { + last = itemOf(await reader.pushFrame(frames[i]!.bytes)); + states.push(last.complete); + } + expect(states.slice(0, -1).every((c) => !c)).toBe(true); + expect(last!.complete).toBe(true); + expect(toHex(last!.content)).toBe(toHex(content)); + expect(last!.meta?.name).toBe('f'); + }); + + it('缺一帧时不会 complete,content 只到第一个缺口', async () => { + const { owner, member } = await pair(); + const { frames } = await gatedFrames(owner, 250); + expect(frames).toHaveLength(3); + + const reader = member.createItemReader(); + await reader.pushFrame(frames[0]!.bytes); + const item = itemOf(await reader.pushFrame(frames[2]!.bytes)); // 跳过第 1 帧 + expect(item.complete).toBe(false); + + const filled = itemOf(await reader.pushFrame(frames[1]!.bytes)); + expect(filled.complete).toBe(true); + }); + + it('FINAL 之后又出现更大序号的帧:frame_after_final', async () => { + const { owner, member } = await pair(); + const { frames, plan } = await gatedFrames(owner, 250); // 3 帧,序号 2 是 FINAL + const reader = member.createItemReader(); + await reader.pushFrame(frames[0]!.bytes); + await reader.pushFrame(frames[2]!.bytes); + + const key = await owner.itemKey(plan.idBytes); + const extra = await sealFrame(key, owner.idBytes, { flags: GATED | ENC, itemId: plan.idBytes, index: 3, data: new Uint8Array(5) }); + expect(only(await reader.pushFrame(extra))).toMatchObject({ type: 'rejected', reason: 'frame_after_final' }); + }); + + it('FINAL 出现在已有更大序号之前:final_conflict', async () => { + const { owner, member } = await pair(); + const { frames, plan } = await gatedFrames(owner, 250); + const reader = member.createItemReader(); + await reader.pushFrame(frames[2]!.bytes); // 序号 2 先到(它带 FINAL) + + // 伪造一个序号 1、也带 FINAL 的帧:FINAL 位置前后矛盾 + const key = await owner.itemKey(plan.idBytes); + const clash = await sealFrame(key, owner.idBytes, { flags: GATED | ENC | FINAL, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) }); + expect(only(await reader.pushFrame(clash))).toMatchObject({ type: 'rejected', reason: 'final_conflict' }); + }); + + it('同一条目内标志位变化:flags_changed', async () => { + const { owner, member } = await pair(); + const { frames, plan } = await gatedFrames(owner, 250); + const reader = member.createItemReader(); + await reader.pushFrame(frames[0]!.bytes); + + const key = await owner.itemKey(plan.idBytes); + // 第 0 帧是 GATED,这里发一个带 SNAPSHOT 的第 1 帧(密文本身是合法认证的) + const odd = await sealFrame(key, owner.idBytes, { flags: GATED | SNAPSHOT | ENC, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) }); + expect(only(await reader.pushFrame(odd))).toMatchObject({ type: 'rejected', reason: 'flags_changed' }); + }); + + it('非 GATED 条目的帧序号不是 0 或没有 FINAL:拒绝(协议规则)', async () => { + const { owner, member } = await pair(); + const id = new Uint8Array(16).fill(1); + const key = await owner.itemKey(id); + const noFinal = await sealFrame(key, owner.idBytes, { flags: ENC, itemId: id, index: 0, data: concat(encodeMeta({ kind: 'text', ts: 1 }), new Uint8Array(1)) }); + expect(only(await member.createItemReader().pushFrame(noFinal))).toMatchObject({ type: 'rejected', reason: 'bad_envelope' }); + }); + + it('第 0 帧的元数据不合法:bad_meta', async () => { + const { owner, member } = await pair(); + const id = new Uint8Array(16).fill(2); + const key = await owner.itemKey(id); + const garbage = new TextEncoder().encode('{"v":1,"kind":123,"ts":"x"}'); + const frame = await sealFrame(key, owner.idBytes, { flags: ENC | FINAL, itemId: id, index: 0, data: concat(new Uint8Array([0, garbage.length]), garbage) }); + expect(only(await member.createItemReader().pushFrame(frame))).toMatchObject({ type: 'rejected', reason: 'bad_meta' }); + }); +}); + +describe('模式隔离(防降级)', () => { + it('enc 会话拒绝没有 ENC 标志的帧(恶意服务端注入明文)', async () => { + const { member } = await pair('enc'); + const raw = concat(new Uint8Array([1, FINAL]), new Uint8Array(16), writeU32(0), writeU32(4), new TextEncoder().encode('evil')); + expect(only(await member.createItemReader().pushFrame(raw))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' }); + }); + + it('raw 会话拒绝带 ENC 标志的帧', async () => { + const { owner: encOwner } = await pair('enc'); + const { member: rawMember } = await pair('raw'); + const [frame] = await collectFrames(encOwner.encryptText('x')); + expect(only(await rawMember.createItemReader().pushFrame(frame!.bytes))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' }); + }); + + it('一个条目被拒绝不影响其他条目', async () => { + const { owner, member } = await pair(); + const reader = member.createItemReader(); + const [good1] = await collectFrames(owner.encryptText('one')); + const [bad] = await collectFrames(owner.encryptText('bad')); + const [good2] = await collectFrames(owner.encryptText('two')); + bad!.bytes[bad!.bytes.length - 1]! ^= 1; + + const results = [ + ...(await reader.pushFrame(good1!.bytes)), + ...(await reader.pushFrame(bad!.bytes)), + ...(await reader.pushFrame(good2!.bytes)), + ]; + expect(results.map((e) => e.type)).toEqual(['item', 'rejected', 'item']); + }); +}); diff --git a/packages/core/test/session.test.ts b/packages/core/test/session.test.ts new file mode 100644 index 0000000..d3de0eb --- /dev/null +++ b/packages/core/test/session.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it } from 'vitest'; +import { fromBase64Url, toBase64Url } from '../src/bytes.js'; +import { + CapabilityError, + ChannelSession, + IntegrityError, + LimitError, + LinkError, + ModeMismatchError, + collectFrames, + itemJson, + itemText, + parseLink, + type ItemEvent, + type ReceivedItem, +} from '../src/index.js'; +import { sha256 } from '../src/internal.js'; +import { ORIGIN, pseudoRandom, toHex } from './helpers.js'; + +const bearer = (headers: Record): Uint8Array => { + const token = headers.Authorization!.replace('Bearer ', ''); + return fromBase64Url(token, 32)!; +}; + +const itemOf = (events: ItemEvent[]): ReceivedItem => { + const ev = events.find((e) => e.type === 'item'); + if (!ev || ev.type !== 'item') throw new Error(`没有 item 事件: ${JSON.stringify(events)}`); + return ev.item; +}; + +describe('创建 enc 频道', () => { + it('请求体里的哈希与会话实际使用的令牌一致', async () => { + const { session, request } = await ChannelSession.create(); + const { body, headers } = request; + + expect(body.mode).toBe('enc'); + expect(body.id).toBe(session.id); + expect(body.id).toHaveLength(22); + + // 服务端存的是令牌的 SHA-256:客户端之后出示的令牌必须能对上 + expect(body.read_hash).toBe(toBase64Url(await sha256(bearer(session.authHeaders('read'))))); + expect(body.write_hash).toBe(toBase64Url(await sha256(bearer(session.authHeaders('write'))))); + expect(body.owner_hash).toBe(toBase64Url(await sha256(bearer(session.authHeaders('owner'))))); + + expect(headers['X-WD-Role']).toBe('owner'); + expect(headers['X-WD-Endpoint']).toBe(toBase64Url(session.endpointSecret)); + expect(headers.Authorization).toBeDefined(); + }); + + it('三个令牌互不相同,且都不等于密钥', async () => { + const { session } = await ChannelSession.create(); + const tokens = (['read', 'write', 'owner'] as const).map((r) => toHex(bearer(session.authHeaders(r)))); + expect(new Set(tokens).size).toBe(3); + }); + + it('每次创建的 id 和密钥都不同', async () => { + const a = await ChannelSession.create(); + const b = await ChannelSession.create(); + expect(a.session.id).not.toBe(b.session.id); + expect(a.session.link(ORIGIN)).not.toBe(b.session.link(ORIGIN)); + }); + + it('设置原样放进请求体', async () => { + const { request } = await ChannelSession.create({ settings: { seats: 3, delete_policy: 'anyone' } }); + expect(request.body.settings).toEqual({ seats: 3, delete_policy: 'anyone' }); + }); +}); + +describe('创建 raw 频道', () => { + it('没有 read_hash;成员可上传时有写令牌 W,放进成员链接', async () => { + const { session, request } = await ChannelSession.create({ mode: 'raw' }); + expect(request.body.read_hash).toBeUndefined(); + expect(request.body.write_hash).toBeDefined(); + + const member = parseLink(session.link(ORIGIN)); + expect(member.mode).toBe('raw'); + expect(member.role).toBe('member'); + expect(member.w).toBeDefined(); + expect(request.body.write_hash).toBe(toBase64Url(await sha256(member.w!))); + }); + + it('禁止成员上传时没有写令牌,成员链接也不带 w', async () => { + const { session, request } = await ChannelSession.create({ mode: 'raw', settings: { members_can_upload: false } }); + expect(request.body.write_hash).toBeUndefined(); + expect(parseLink(session.link(ORIGIN)).w).toBeUndefined(); + }); + + it('读请求不带 Authorization(读权限就是 id 本身)', async () => { + const { session } = await ChannelSession.create({ mode: 'raw' }); + const headers = session.authHeaders('read'); + expect(headers.Authorization).toBeUndefined(); + expect(headers['X-WD-Role']).toBe('read'); + }); +}); + +describe('链接与角色', () => { + it('所有者链接和成员链接得到同一把密钥,权限不同', async () => { + const { session: owner } = await ChannelSession.create(); + const member = await ChannelSession.open(owner.link(ORIGIN, 'member')); + const reopened = await ChannelSession.open(owner.link(ORIGIN, 'owner')); + + expect(member.role).toBe('member'); + expect(member.can('owner')).toBe(false); + expect(reopened.role).toBe('owner'); + expect(reopened.can('owner')).toBe(true); + + expect(toHex(bearer(member.authHeaders('read')))).toBe(toHex(bearer(owner.authHeaders('read')))); + expect(toHex(bearer(member.authHeaders('write')))).toBe(toHex(bearer(owner.authHeaders('write')))); + expect(toHex(bearer(reopened.authHeaders('owner')))).toBe(toHex(bearer(owner.authHeaders('owner')))); + }); + + it('成员链接里没有管理密钥', async () => { + const { session } = await ChannelSession.create(); + const memberLink = session.link(ORIGIN, 'member'); + expect(memberLink).not.toContain('a='); + expect(memberLink).toMatch(/#k=[A-Za-z0-9_-]{43}$/); + }); + + it('成员没有所有者权限:authHeaders 和 owner 链接都抛 CapabilityError', async () => { + const { session } = await ChannelSession.create(); + const member = await ChannelSession.open(session.link(ORIGIN)); + expect(() => member.authHeaders('owner')).toThrowError(CapabilityError); + expect(() => member.link(ORIGIN, 'owner')).toThrowError(CapabilityError); + }); + + it('raw 成员链接没有 w 时不能写', async () => { + const { session } = await ChannelSession.create({ mode: 'raw', settings: { members_can_upload: false } }); + const member = await ChannelSession.open(session.link(ORIGIN)); + expect(member.can('read')).toBe(true); + expect(member.can('write')).toBe(false); + expect(() => member.authHeaders('write')).toThrowError(CapabilityError); + }); + + it('终端密钥可以持久化:传回同一个密钥得到同一个终端标识', async () => { + const { session } = await ChannelSession.create(); + const link = session.link(ORIGIN); + const first = await ChannelSession.open(link); + const again = await ChannelSession.open(link, { endpointSecret: first.endpointSecret }); + const other = await ChannelSession.open(link); + + expect(toHex(again.endpointHash)).toBe(toHex(first.endpointHash)); + expect(toHex(other.endpointHash)).not.toBe(toHex(first.endpointHash)); + expect(toHex(first.endpointHash)).toBe(toHex(await sha256(first.endpointSecret))); + }); + + it('拒绝长度不对的终端密钥', async () => { + const { session } = await ChannelSession.create(); + await expect(ChannelSession.open(session.link(ORIGIN), { endpointSecret: new Uint8Array(16) })).rejects.toThrow(RangeError); + }); + + it('密钥放进查询串的链接被拒绝', async () => { + const { session } = await ChannelSession.create(); + const k = parseLink(session.link(ORIGIN)).k!; + await expect(ChannelSession.open(`${ORIGIN}/c/${session.id}?k=${toBase64Url(k)}`)).rejects.toThrowError(LinkError); + }); +}); + +describe('核对服务端返回的频道信息(降级防御)', () => { + it('一致时通过', async () => { + const { session } = await ChannelSession.create(); + expect(() => session.verifyChannelInfo({ id: session.id, mode: 'enc' })).not.toThrow(); + }); + + it('链接带密钥却被告知是 raw:拒绝', async () => { + const { session } = await ChannelSession.create(); + expect(() => session.verifyChannelInfo({ id: session.id, mode: 'raw' })).toThrowError(ModeMismatchError); + }); + + it('链接没有密钥却被告知是 enc:拒绝', async () => { + const { session } = await ChannelSession.create({ mode: 'raw' }); + expect(() => session.verifyChannelInfo({ id: session.id, mode: 'enc' })).toThrowError(ModeMismatchError); + }); + + it('频道 id 不一致:拒绝', async () => { + const { session } = await ChannelSession.create(); + expect(() => session.verifyChannelInfo({ id: 'AAAAAAAAAAAAAAAAAAAAAA', mode: 'enc' })).toThrowError(LinkError); + }); +}); + +describe('发送和接收(enc)', () => { + async function pair() { + const { session: owner } = await ChannelSession.create(); + owner.deviceName = '我的 iPhone'; + const member = await ChannelSession.open(owner.link(ORIGIN)); + return { owner, member }; + } + + it('文本:所有者发送,成员解密,带设备名和时间', async () => { + const { owner, member } = await pair(); + const frames = await collectFrames(owner.encryptText('hello, 世界', { ts: 1700000000000 })); + expect(frames).toHaveLength(1); + expect(frames[0]!.final).toBe(true); + + const events = await member.createItemReader().pushFrame(frames[0]!.bytes); + const item = itemOf(events); + expect(item.kind).toBe('text'); + expect(item.complete).toBe(true); + expect(item.gated).toBe(false); + expect(item.snapshot).toBe(false); + expect(itemText(item)).toBe('hello, 世界'); + expect(item.meta).toEqual({ kind: 'text', ts: 1700000000000, dev: '我的 iPhone' }); + }); + + it('JSON 和快照', async () => { + const { owner, member } = await pair(); + const value = { theme: 'dark', n: [1, 2, 3], nested: { ok: true } }; + const frames = await collectFrames(owner.encryptJson(value, { snapshot: true })); + const item = itemOf(await member.createItemReader().pushFrame(frames[0]!.bytes)); + expect(item.kind).toBe('json'); + expect(item.snapshot).toBe(true); + expect(itemJson(item)).toEqual(value); + }); + + it('空文本也是合法条目', async () => { + const { owner, member } = await pair(); + const frames = await collectFrames(owner.encryptText('')); + const item = itemOf(await member.createItemReader().pushFrame(frames[0]!.bytes)); + expect(item.complete).toBe(true); + expect(itemText(item)).toBe(''); + }); + + it('不是所有者也能用成员身份加密、对方能解开(同一把 K)', async () => { + const { owner, member } = await pair(); + const frames = await collectFrames(member.encryptText('来自成员')); + expect(itemText(itemOf(await owner.createItemReader().pushFrame(frames[0]!.bytes)))).toBe('来自成员'); + }); + + it('二进制内容原样往返,所有帧负载长度都是 档位+16', async () => { + const { owner, member } = await pair(); + const content = pseudoRandom(50_000, 3); + const plan = owner.encryptBytes(content, { kind: 'other' }); + const frames = await collectFrames(plan); + expect(frames).toHaveLength(1); + expect([1040, 4112, 16400, 65552, 262160, 1048592]).toContain(frames[0]!.bytes.length - 26); + + const item = itemOf(await member.createItemReader().pushFrame(frames[0]!.bytes)); + expect(toHex(item.content)).toBe(toHex(content)); + }); + + it('密文不含明文', async () => { + const { owner } = await pair(); + const secret = 'TOP-SECRET-MARKER-0123456789'; + const [frame] = await collectFrames(owner.encryptText(secret)); + const haystack = new TextDecoder('latin1').decode(frame!.bytes); + expect(haystack).not.toContain('TOP-SECRET'); + expect(haystack).not.toContain('"kind"'); // 元数据也是密文 + }); + + it('同样的内容两次加密(不同条目)得到不同密文', async () => { + const { owner } = await pair(); + const [a] = await collectFrames(owner.encryptText('same')); + const [b] = await collectFrames(owner.encryptText('same')); + expect(toHex(a!.bytes)).not.toBe(toHex(b!.bytes)); + }); + + it('同一个条目 id 重新加密得到完全相同的字节(重试安全)', async () => { + const { owner } = await pair(); + const plan1 = owner.encryptText('retry me', { ts: 5 }); + const [a] = await collectFrames(plan1); + const [b] = await collectFrames(owner.encryptText('retry me', { ts: 5, itemId: plan1.idBytes })); + expect(toHex(a!.bytes)).toBe(toHex(b!.bytes)); + }); + + it('另一个频道的成员解不开(密钥不同)', async () => { + const { owner } = await pair(); + const { session: stranger } = await ChannelSession.create(); + const [frame] = await collectFrames(owner.encryptText('x')); + const events = await stranger.createItemReader().pushFrame(frame!.bytes); + expect(events[0]).toMatchObject({ type: 'rejected', reason: 'auth_failed' }); + }); + + it('同一把 K 但频道 id 不同:换频道重放被 AAD 拒绝', async () => { + const { owner } = await pair(); + const k = parseLink(owner.link(ORIGIN)).k!; + const otherId = toBase64Url(new Uint8Array(16).fill(9)); + const sameKeyOtherChannel = await ChannelSession.open(`${ORIGIN}/c/${otherId}#k=${toBase64Url(k)}`); + + const [frame] = await collectFrames(owner.encryptText('x')); + const events = await sameKeyOtherChannel.createItemReader().pushFrame(frame!.bytes); + expect(events[0]).toMatchObject({ type: 'rejected', reason: 'auth_failed' }); + }); +}); + +describe('设备名', () => { + it('所有者能读出成员的设备名,其他人换个终端哈希读不出', async () => { + const { session: owner } = await ChannelSession.create(); + const member = await ChannelSession.open(owner.link(ORIGIN)); + const blob = await member.sealLabel('办公室电脑'); + + expect(await owner.openLabel(blob, member.endpointHash)).toBe('办公室电脑'); + expect(await owner.openLabel(toBase64Url(blob), toBase64Url(member.endpointHash))).toBe('办公室电脑'); + await expect(owner.openLabel(blob, owner.endpointHash)).rejects.toThrowError(IntegrityError); + }); + + it('每次加密使用新的随机 IV', async () => { + const { session } = await ChannelSession.create(); + const a = await session.sealLabel('x'); + const b = await session.sealLabel('x'); + expect(toHex(a.slice(0, 12))).not.toBe(toHex(b.slice(0, 12))); + }); + + it('篡改后解不开', async () => { + const { session } = await ChannelSession.create(); + const blob = await session.sealLabel('abc'); + blob[blob.length - 1]! ^= 1; + await expect(session.openLabel(blob, session.endpointHash)).rejects.toThrowError(IntegrityError); + }); + + it('raw 模式下设备名是明文', async () => { + const { session } = await ChannelSession.create({ mode: 'raw' }); + const blob = await session.sealLabel('plain'); + expect(new TextDecoder().decode(blob)).toBe('plain'); + expect(await session.openLabel(blob, session.endpointHash)).toBe('plain'); + }); +}); + +describe('raw 模式往返', () => { + it('单帧:负载就是内容,没有加密标志', async () => { + const { session: owner } = await ChannelSession.create({ mode: 'raw' }); + const member = await ChannelSession.open(owner.link(ORIGIN)); + + const [frame] = await collectFrames(owner.encryptText('明文内容')); + expect(frame!.bytes[1]! & 8).toBe(0); // 没有 ENC 标志 + expect(new TextDecoder().decode(frame!.bytes.slice(26))).toBe('明文内容'); + + const item = itemOf(await member.createItemReader().pushFrame(frame!.bytes)); + expect(item.kind).toBe('raw'); + expect(item.meta).toBeUndefined(); + expect(itemText(item)).toBe('明文内容'); + }); + + it('大内容拆成 GATED 多帧', async () => { + const { session } = await ChannelSession.create({ mode: 'raw' }); + const content = pseudoRandom(100, 5); + const plan = session.encryptBytes(content, {}, { maxFrameData: 40 }); + const frames = await collectFrames(plan); + + expect(plan.gated).toBe(true); + expect(frames.map((f) => f.bytes.length - 26)).toEqual([40, 40, 20]); + expect(frames.map((f) => f.final)).toEqual([false, false, true]); + + const reader = session.createItemReader(); + let last: ReceivedItem | undefined; + for (const f of frames) last = itemOf(await reader.pushFrame(f.bytes)); + expect(last!.complete).toBe(true); + expect(toHex(last!.content)).toBe(toHex(content)); + }); + + it('label 放进第 0 帧的附加请求头', async () => { + const { session } = await ChannelSession.create({ mode: 'raw' }); + const plan = session.encryptBytes(new Uint8Array(3), {}, { label: 'note.txt' }); + expect(plan.frame0Headers['X-WD-Label']).toBe('note.txt'); + }); +}); + +describe('条目计划', () => { + it('下载上限强制 GATED,并生成 X-WD-Max-Downloads 头', async () => { + const { session } = await ChannelSession.create(); + const plan = session.encryptText('limited', { maxDownloads: 3 }); + expect(plan.gated).toBe(true); + expect(plan.frame0Headers['X-WD-Max-Downloads']).toBe('3'); + const [frame] = await collectFrames(plan); + expect((frame!.bytes[1]! & 4) !== 0).toBe(true); + }); + + it('快照不能 GATED,也不能超过一帧', async () => { + const { session } = await ChannelSession.create(); + expect(() => session.encryptJson({}, { snapshot: true, gated: true })).toThrow(RangeError); + expect(() => session.encryptJson({}, { snapshot: true, maxDownloads: 1 })).toThrow(RangeError); + + const big = session.encryptBytes(pseudoRandom(300), { kind: 'json' }, { snapshot: true, maxFrameData: 100 }); + await expect(collectFrames(big)).rejects.toThrowError(LimitError); + }); + + it('gated:false 与放不进单帧的内容冲突', async () => { + const { session } = await ChannelSession.create(); + expect(() => session.encryptBytes(pseudoRandom(500), {}, { gated: false, maxFrameData: 100 })).toThrowError(LimitError); + }); + + it('元数据本身超过单帧上限:拒绝', async () => { + const { session } = await ChannelSession.create(); + expect(() => session.encryptBytes(new Uint8Array(1), { name: 'x'.repeat(200) }, { maxFrameData: 100 })).toThrowError(LimitError); + }); + + it('encryptJson 拒绝无法序列化的值', async () => { + const { session } = await ChannelSession.create(); + expect(() => session.encryptJson(undefined)).toThrow(TypeError); + }); + + it('File 自动带上文件名、类型和大小', async () => { + const { session } = await ChannelSession.create(); + const file = new File([new Uint8Array(10).fill(1)], 'a.bin', { type: 'application/octet-stream' }); + const frames = await collectFrames(session.encryptFile(file, {}, { ts: 1 })); + const item = itemOf(await session.createItemReader().pushFrame(frames[0]!.bytes)); + expect(item.meta).toMatchObject({ kind: 'file', name: 'a.bin', mime: 'application/octet-stream', size: 10 }); + }); + + it('流式来源:长度未知,小内容仍然是单帧非 GATED', async () => { + const { session } = await ChannelSession.create(); + const stream = new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array([1, 2, 3])); + c.enqueue(new Uint8Array([4, 5])); + c.close(); + }, + }); + const plan = session.encryptFile(stream, { name: 'live.bin' }); + const frames = await collectFrames(plan); + expect(frames).toHaveLength(1); + expect(plan.gated).toBe(false); // 第 0 帧产出之后才确定 + const item = itemOf(await session.createItemReader().pushFrame(frames[0]!.bytes)); + expect(Array.from(item.content)).toEqual([1, 2, 3, 4, 5]); + expect(item.meta?.size).toBeUndefined(); + }); + + it('流式来源:内容多时变成 GATED 多帧', async () => { + const { session } = await ChannelSession.create(); + const content = pseudoRandom(700, 9); + const stream = new ReadableStream({ + start(c) { + for (let i = 0; i < content.length; i += 33) c.enqueue(content.slice(i, i + 33)); + c.close(); + }, + }); + const plan = session.encryptFile(stream, {}, { maxFrameData: 200 }); + const frames = await collectFrames(plan); + expect(plan.gated).toBe(true); + expect(frames.length).toBeGreaterThan(2); + expect(frames.at(-1)!.final).toBe(true); + + const reader = session.createItemReader(); + let last: ReceivedItem | undefined; + for (const f of frames) last = itemOf(await reader.pushFrame(f.bytes)); + expect(toHex(last!.content)).toBe(toHex(content)); + }); + + it('内容恰好填满某一帧时不会多出空帧', async () => { + const { session } = await ChannelSession.create(); + const meta = { kind: 'file' as const, name: 'b' }; + // 先算出第 0 帧的内容容量:maxFrameData - (2 + meta_json 长度) + const probe = await collectFrames(session.encryptBytes(new Uint8Array(0), meta, { ts: 1, maxFrameData: 1000 })); + expect(probe).toHaveLength(1); + const metaJsonLen = JSON.stringify({ v: 1, kind: 'file', name: 'b', ts: 1 }).length; + const cap0 = 1000 - 2 - metaJsonLen; + + for (const [size, expectedFrames] of [ + [cap0, 1], + [cap0 + 1, 2], + [cap0 + 1000, 2], // 第 0 帧满 + 第 1 帧满,恰好两帧 + [cap0 + 1001, 3], + [cap0 + 2000, 3], + ] as const) { + const frames = await collectFrames(session.encryptBytes(pseudoRandom(size), meta, { ts: 1, maxFrameData: 1000 })); + expect(frames, `size=${size}`).toHaveLength(expectedFrames); + expect(frames.at(-1)!.final).toBe(true); + expect(frames.slice(0, -1).every((f) => !f.final)).toBe(true); + } + }); +}); + +describe('openLink 别名', () => { + it('作为独立函数调用也能工作(静态方法没有依赖 this)', async () => { + const { openLink } = await import('../src/index.js'); + const { session } = await ChannelSession.create(); + const reopened = await openLink(session.link(ORIGIN, 'owner')); + expect(reopened.id).toBe(session.id); + expect(reopened.role).toBe('owner'); + }); +}); diff --git a/packages/core/test/stream.test.ts b/packages/core/test/stream.test.ts new file mode 100644 index 0000000..14017fb --- /dev/null +++ b/packages/core/test/stream.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest'; +import { ChannelSession, IntegrityError, collectFrames, decryptItemFrames, splitEnvelopes } from '../src/index.js'; +import { sealFrame } from '../src/internal.js'; +import type { Bytes } from '../src/bytes.js'; +import { concatAll, ORIGIN, pseudoRandom, readAll, splitAt, asyncChunks, toHex } from './helpers.js'; + +const GATED = 4; +const ENC = 8; +const FINAL = 1; + +async function pair(mode: 'enc' | 'raw' = 'enc') { + const { session: owner } = await ChannelSession.create({ mode }); + const member = await ChannelSession.open(owner.link(ORIGIN)); + return { owner, member }; +} + +async function encode(session: ChannelSession, content: Bytes, maxFrameData?: number) { + const plan = session.encryptFile(new Blob([content]), { name: 'doc.bin', mime: 'application/octet-stream' }, { ts: 7, ...(maxFrameData && { maxFrameData }) }); + const frames = await collectFrames(plan); + return { plan, frames, wire: concatAll(frames.map((f) => f.bytes)) }; +} + +/** 迭代解密生成器,收集内容并拿到返回值。 */ +async function drain(gen: ReturnType) { + const parts: Uint8Array[] = []; + for (;;) { + const r = await gen.next(); + if (r.done) return { content: concatAll(parts), result: r.value }; + parts.push(r.value); + } +} + +const ctx = (s: ChannelSession) => ({ channelId: s.idBytes, mode: s.mode, itemKey: (id: Uint8Array) => s.itemKey(id) }); + +describe('splitEnvelopes', () => { + it('任意切分的字节流都能还原出原来的封装', async () => { + const { owner } = await pair(); + const { frames, wire } = await encode(owner, pseudoRandom(3000, 1), 700); + expect(frames.length).toBeGreaterThan(3); + + for (const seed of [1, 2, 3, 4, 5]) { + const out: Uint8Array[] = []; + for await (const env of splitEnvelopes(asyncChunks(splitAt(wire, seed)))) out.push(env); + expect(out.map(toHex)).toEqual(frames.map((f) => toHex(f.bytes))); + } + }); + + it('逐字节喂入也行', async () => { + const { owner } = await pair(); + const { frames, wire } = await encode(owner, pseudoRandom(500, 2), 300); + const out: Uint8Array[] = []; + for await (const env of splitEnvelopes(asyncChunks(Array.from(wire, (b) => new Uint8Array([b]))))) out.push(env); + expect(out).toHaveLength(frames.length); + }); + + it('末尾被截断的半个封装直接丢弃(这是断线,不是篡改)', async () => { + const { owner } = await pair(); + const { frames, wire } = await encode(owner, pseudoRandom(1500, 3), 500); + const cut = wire.slice(0, wire.length - 10); + const out: Uint8Array[] = []; + for await (const env of splitEnvelopes(asyncChunks([cut]))) out.push(env); + expect(out).toHaveLength(frames.length - 1); + }); + + it('声明了离谱的负载长度:拒绝,而不是等待永远不会到的数据', async () => { + const header = new Uint8Array(26); + header[0] = 1; + header.set([0xff, 0xff, 0xff, 0xff], 22); + const gen = splitEnvelopes(asyncChunks([header])); + await expect(gen.next()).rejects.toThrowError(IntegrityError); + }); + + it('接受 ReadableStream', async () => { + const { owner } = await pair(); + const { frames, wire } = await encode(owner, pseudoRandom(900, 4), 400); + const stream = new ReadableStream({ + start(c) { + for (const chunk of splitAt(wire, 9)) c.enqueue(chunk); + c.close(); + }, + }); + const out: Uint8Array[] = []; + for await (const env of splitEnvelopes(stream)) out.push(env); + expect(out).toHaveLength(frames.length); + }); +}); + +describe('流式解密', () => { + it('大文件(多个 1 MiB 帧)往返一致,元数据正确,标记 GATED', async () => { + const { owner, member } = await pair(); + const content = pseudoRandom(2_600_000, 21); + const { plan, frames, wire } = await encode(owner, content); + expect(plan.gated).toBe(true); + expect(frames).toHaveLength(3); // 2.6 MB / 约 1 MiB 每帧 + + const { content: out, result } = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire, 5)))); + expect(result.complete).toBe(true); + expect(result.nextIndex).toBe(frames.length); + expect(result.meta).toMatchObject({ kind: 'file', name: 'doc.bin', mime: 'application/octet-stream', size: content.length }); + expect(out.length).toBe(content.length); + expect(toHex(out.slice(0, 64))).toBe(toHex(content.slice(0, 64))); + expect(toHex(out.slice(-64))).toBe(toHex(content.slice(-64))); + expect(Buffer.compare(out, content)).toBe(0); + }); + + it('包成 ReadableStream 使用,并给出 result', async () => { + const { owner, member } = await pair(); + const content = pseudoRandom(5000, 22); + const { wire } = await encode(owner, content, 1500); + + const { content: stream, result } = member.decryptItemStream(asyncChunks(splitAt(wire, 6))); + expect(toHex(await readAll(stream))).toBe(toHex(content)); + expect(await result).toMatchObject({ complete: true, nextIndex: 4 }); + }); + + it('中间帧被篡改:流报 auth_failed,result 同样失败', async () => { + const { owner, member } = await pair(); + const { frames } = await encode(owner, pseudoRandom(4000, 23), 1000); + const tampered = frames.map((f) => f.bytes.slice()); + tampered[2]![tampered[2]!.length - 3]! ^= 1; + + const { content, result } = member.decryptItemStream(asyncChunks([concatAll(tampered)])); + await expect(readAll(content)).rejects.toMatchObject({ reason: 'auth_failed' }); + await expect(result).rejects.toThrowError(IntegrityError); + }); + + it('混入别的条目的帧(服务端拼接):item_mismatch', async () => { + const { owner, member } = await pair(); + const a = await encode(owner, pseudoRandom(2500, 24), 1000); + const b = await encode(owner, pseudoRandom(2500, 25), 1000); + // 前两帧来自 A,第三帧换成 B 的第三帧(两者都是合法认证过的帧) + const spliced = concatAll([a.frames[0]!.bytes, a.frames[1]!.bytes, b.frames[2]!.bytes]); + await expect(drain(decryptItemFrames(ctx(member), asyncChunks([spliced])))).rejects.toMatchObject({ reason: 'item_mismatch' }); + }); + + it('指定了期望的条目 id 时,第一帧就不匹配也会被拒绝', async () => { + const { owner, member } = await pair(); + const a = await encode(owner, pseudoRandom(100, 26)); + const b = await encode(owner, pseudoRandom(100, 27)); + await expect( + drain(decryptItemFrames(ctx(member), asyncChunks([a.wire]), { itemId: b.plan.idBytes })), + ).rejects.toMatchObject({ reason: 'item_mismatch' }); + }); + + it('跳过一帧(服务端丢帧):gap', async () => { + const { owner, member } = await pair(); + const { frames } = await encode(owner, pseudoRandom(4000, 28), 1000); + const dropped = concatAll([frames[0]!.bytes, frames[2]!.bytes, frames[3]!.bytes]); + await expect(drain(decryptItemFrames(ctx(member), asyncChunks([dropped])))).rejects.toMatchObject({ reason: 'gap' }); + }); + + it('重复的帧:同样被当作 gap 拒绝', async () => { + const { owner, member } = await pair(); + const { frames } = await encode(owner, pseudoRandom(3000, 29), 1000); + const dup = concatAll([frames[0]!.bytes, frames[0]!.bytes, frames[1]!.bytes]); + await expect(drain(decryptItemFrames(ctx(member), asyncChunks([dup])))).rejects.toMatchObject({ reason: 'gap' }); + }); + + it('FINAL 之后还有帧:frame_after_final', async () => { + const { owner, member } = await pair(); + const { frames, plan } = await encode(owner, pseudoRandom(2500, 30), 1000); + const key = await owner.itemKey(plan.idBytes); + const extra = await sealFrame(key, owner.idBytes, { flags: GATED | ENC, itemId: plan.idBytes, index: frames.length, data: new Uint8Array(3) }); + await expect( + drain(decryptItemFrames(ctx(member), asyncChunks([concatAll([...frames.map((f) => f.bytes), extra])]))), + ).rejects.toMatchObject({ reason: 'frame_after_final' }); + }); + + it('被截断的内容不会被当成完整:没有 FINAL 时 complete 为 false', async () => { + const { owner, member } = await pair(); + const content = pseudoRandom(4000, 31); + const { frames } = await encode(owner, content, 1000); + const withoutLast = concatAll(frames.slice(0, -1).map((f) => f.bytes)); // 服务端丢掉最后一帧 + + const { result } = await drain(decryptItemFrames(ctx(member), asyncChunks([withoutLast]))); + expect(result.complete).toBe(false); + expect(result.nextIndex).toBe(frames.length - 1); + }); + + it('enc 会话里出现没有 ENC 标志的帧:wrong_mode', async () => { + const { member } = await pair(); + const evil = concatAll([new Uint8Array([1, FINAL]), new Uint8Array(16), new Uint8Array(4), new Uint8Array([0, 0, 0, 4]), new Uint8Array(4)]); + await expect(drain(decryptItemFrames(ctx(member), asyncChunks([evil])))).rejects.toMatchObject({ reason: 'wrong_mode' }); + }); +}); + +describe('断点续传', () => { + it('接收端:连接中途断开,用 nextIndex 续传,拼起来与原文一致', async () => { + const { owner, member } = await pair(); + const content = pseudoRandom(9000, 40); + const { frames } = await encode(owner, content, 1000); + expect(frames.length).toBeGreaterThanOrEqual(9); + const wire = concatAll(frames.map((f) => f.bytes)); + + // 第一次:在第 5 帧中间断开 + const firstLen = frames.slice(0, 4).reduce((n, f) => n + f.bytes.length, 0) + 100; + const first = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire.slice(0, firstLen), 3)))); + expect(first.result.complete).toBe(false); + expect(first.result.nextIndex).toBe(4); + expect(first.result.meta?.name).toBe('doc.bin'); + + // 第二次:从 nextIndex 开始(服务端 GET ...?from=4,不重复计下载) + const rest = concatAll(frames.slice(4).map((f) => f.bytes)); + const second = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(rest, 4)), { fromIndex: first.result.nextIndex })); + expect(second.result.complete).toBe(true); + expect(second.result.meta).toBeUndefined(); // 续传的响应里没有第 0 帧 + + expect(toHex(concatAll([first.content, second.content]))).toBe(toHex(content)); + }); + + it('从中间开始时,帧序号必须从 fromIndex 起连续', async () => { + const { owner, member } = await pair(); + const { frames } = await encode(owner, pseudoRandom(4000, 41), 1000); + const wrongStart = concatAll(frames.slice(2).map((f) => f.bytes)); + await expect(drain(decryptItemFrames(ctx(member), asyncChunks([wrongStart]), { fromIndex: 1 }))).rejects.toMatchObject({ reason: 'gap' }); + }); + + it('发送端:从第 k 帧开始重新加密,得到与完整加密完全相同的字节', async () => { + const { owner } = await pair(); + const content = pseudoRandom(5500, 42); + const blob = new Blob([content]); + const full = owner.encryptFile(blob, { name: 'x' }, { ts: 3, maxFrameData: 1000 }); + const fullFrames = await collectFrames(full); + expect(fullFrames.length).toBeGreaterThanOrEqual(6); + + for (const from of [1, 2, fullFrames.length - 1]) { + const resumed = await collectFrames( + owner.encryptFile(blob, { name: 'x' }, { ts: 3, maxFrameData: 1000, itemId: full.idBytes, fromFrame: from }), + ); + expect(resumed.map((f) => f.index)).toEqual(fullFrames.slice(from).map((f) => f.index)); + expect(resumed.map((f) => toHex(f.bytes))).toEqual(fullFrames.slice(from).map((f) => toHex(f.bytes))); + } + }); + + it('发送端续传:Uint8Array 来源同样适用', async () => { + const { owner } = await pair(); + const content = pseudoRandom(3200, 43); + const full = owner.encryptBytes(content, { kind: 'file' }, { ts: 1, maxFrameData: 800 }); + const fullFrames = await collectFrames(full); + const resumed = await collectFrames(owner.encryptBytes(content, { kind: 'file' }, { ts: 1, maxFrameData: 800, itemId: full.idBytes, fromFrame: 2 })); + expect(resumed.map((f) => toHex(f.bytes))).toEqual(fullFrames.slice(2).map((f) => toHex(f.bytes))); + }); + + it('发送端续传的边界错误:单帧条目、越界、流式来源', async () => { + const { owner } = await pair(); + const content = pseudoRandom(2500, 44); + const frames = await collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 1000 })); + + await expect(collectFrames(owner.encryptBytes(new Uint8Array(5), {}, { fromFrame: 1 }))).rejects.toThrow(RangeError); + await expect(collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 1000, fromFrame: frames.length }))).rejects.toThrow(RangeError); + // 长度未知的来源无法定位第 k 帧的起点,在创建计划时就拒绝 + expect(() => owner.encryptFile(new ReadableStream(), {}, { fromFrame: 1 })).toThrow(RangeError); + }); +}); + +describe('raw 模式的流', () => { + it('透传内容,不做解密', async () => { + const { owner, member } = await pair('raw'); + const content = pseudoRandom(700, 50); + const frames = await collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 300 })); + const wire = concatAll(frames.map((f) => f.bytes)); + + const { content: out, result } = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire, 2)))); + expect(result.complete).toBe(true); + expect(toHex(out)).toBe(toHex(content)); + }); +}); + +describe('取消', () => { + it('取消流不会抛异常,也不会继续消费来源', async () => { + const { owner, member } = await pair(); + const { wire } = await encode(owner, pseudoRandom(6000, 60), 1000); + + let pulled = 0; + async function* source() { + for (const chunk of splitAt(wire, 8)) { + pulled++; + yield chunk; + } + } + const { content } = member.decryptItemStream(source()); + const reader = content.getReader(); + await reader.read(); + await reader.cancel(); + const after = pulled; + await new Promise((r) => setTimeout(r, 20)); + expect(pulled).toBe(after); + }); +}); diff --git a/packages/core/test/vectors.test.ts b/packages/core/test/vectors.test.ts new file mode 100644 index 0000000..99cd0ab --- /dev/null +++ b/packages/core/test/vectors.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from 'vitest'; +import { fromBase64Url, toBase64Url } from '../src/bytes.js'; +import { + IntegrityError, + buildLink, + parseLink, + parseLogRecord, + type ItemEvent, +} from '../src/index.js'; +import { + deriveChannelKeys, + deriveItemKey, + deriveOwnerToken, + deriveRawChannelSecrets, + deriveRawItemKey, + deriveRawOwnerToken, + frameAad, + frameIv, + padPlaintext, + parseEnvelope, + sealFrame, + sha256, + tierFor, + unpadPlaintext, + encodeEnvelope, +} from '../src/internal.js'; +import { openFrame } from '../src/frame.js'; +import { framesOf, h, inputs, openVectorSession, toHex, vectors } from './helpers.js'; + +/** + * 这些向量由 test-vectors/generate.mjs 用 node:crypto 独立生成(不引用本包), + * 服务端的 C# 测试用 .NET 的密码学实现再复现一遍。 + * 三套互不相干的实现一致,才说明 protocol.md 没有歧义。 + */ + +describe('密钥派生', () => { + it('原始字节与向量一致', async () => { + const secrets = await deriveRawChannelSecrets(inputs.channelId, inputs.k); + expect(toHex(secrets.kEnc)).toBe(vectors.kdf.k_enc); + expect(toHex(secrets.tRead)).toBe(vectors.kdf.t_read); + expect(toHex(secrets.tWrite)).toBe(vectors.kdf.t_write); + expect(toHex(secrets.kLabel)).toBe(vectors.kdf.k_label); + expect(toHex(await deriveRawOwnerToken(inputs.channelId, inputs.a))).toBe(vectors.kdf.t_owner); + + for (const item of vectors.item_keys) { + expect(toHex(await deriveRawItemKey(secrets.kEnc, h(item.item_id)))).toBe(item.k_item); + } + }); + + it('生产路径(不可导出的 CryptoKey)得到同样的令牌', async () => { + const keys = await deriveChannelKeys(inputs.channelId, inputs.k); + expect(toHex(keys.readToken)).toBe(vectors.kdf.t_read); + expect(toHex(keys.writeToken)).toBe(vectors.kdf.t_write); + expect(toHex(await deriveOwnerToken(inputs.channelId, inputs.a))).toBe(vectors.kdf.t_owner); + }); + + it('生产路径里的密钥确实不可导出', async () => { + const keys = await deriveChannelKeys(inputs.channelId, inputs.k); + const itemKey = await deriveItemKey(keys.encRoot, inputs.itemA); + expect(itemKey.extractable).toBe(false); + expect(keys.encRoot.extractable).toBe(false); + expect(keys.labelKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('raw', itemKey)).rejects.toThrow(); + }); + + it('令牌哈希与向量一致', async () => { + expect(toHex(await sha256(h(vectors.kdf.t_read)))).toBe(vectors.kdf.read_hash); + expect(toHex(await sha256(h(vectors.kdf.t_write)))).toBe(vectors.kdf.write_hash); + expect(toHex(await sha256(h(vectors.kdf.t_owner)))).toBe(vectors.kdf.owner_hash); + expect(toHex(await sha256(h(vectors.kdf.raw_write_token)))).toBe(vectors.kdf.raw_write_hash); + expect(toHex(await sha256(inputs.endpointSecret))).toBe(vectors.kdf.endpoint_hash); + }); +}); + +describe('IV 与 AAD', () => { + it('IV = 8 字节零 ‖ frame_index', () => { + for (const v of vectors.iv) expect(toHex(frameIv(v.index))).toBe(v.iv); + }); + + it('AAD = "WD1" ‖ channel_id ‖ 头部前 22 字节', () => { + for (const c of vectors.aad) { + // 只含头部的封装(payload_len = 0),AAD 只取前 22 字节 + const header = encodeEnvelope(c.flags, h(c.item_id), c.index, new Uint8Array(0)); + expect(toHex(frameAad(inputs.channelId, header))).toBe(c.aad); + } + }); +}); + +describe('padding', () => { + it('每个档位边界的明文长度与向量一致', () => { + for (const p of vectors.padding) { + expect(tierFor(4 + p.data_len)).toBe(p.plaintext_len); + const padded = padPlaintext(new Uint8Array(p.data_len)); + expect(padded.length).toBe(p.plaintext_len); + expect(unpadPlaintext(padded).length).toBe(p.data_len); + } + }); + + it('拒绝非零填充、越界长度和非档位长度', () => { + const padded = padPlaintext(new Uint8Array(10)); + const dirty = padded.slice(); + dirty[dirty.length - 1] = 1; + expect(() => unpadPlaintext(dirty)).toThrowError(IntegrityError); + + const lying = padded.slice(); + lying.set([0xff, 0xff, 0xff, 0xff], 0); + expect(() => unpadPlaintext(lying)).toThrowError(IntegrityError); + + expect(() => unpadPlaintext(new Uint8Array(1000))).toThrowError(IntegrityError); // 不是档位 + expect(() => unpadPlaintext(new Uint8Array(2))).toThrowError(IntegrityError); + }); + + it('超过单帧上限时拒绝', () => { + expect(() => padPlaintext(new Uint8Array(1048573))).toThrow(RangeError); + expect(padPlaintext(new Uint8Array(1048572)).length).toBe(1048576); + }); +}); + +describe('单帧加解密', () => { + for (const f of vectors.frames) { + it(`${f.name}:密文逐字节一致,并能解回原文`, async () => { + const keys = await deriveChannelKeys(inputs.channelId, inputs.k); + const itemKey = await deriveItemKey(keys.encRoot, h(f.item_id)); + + const sealed = await sealFrame(itemKey, inputs.channelId, { + flags: f.flags, + itemId: h(f.item_id), + index: f.index, + data: h(f.data), + }); + expect(toHex(sealed)).toBe(f.envelope); + + const opened = await openFrame(itemKey, inputs.channelId, parseEnvelope(h(f.envelope))); + expect(toHex(opened)).toBe(f.data); + }); + } +}); + +describe('条目分帧', () => { + for (const item of vectors.items) { + it(`${item.name}:分帧与 GATED 决策和向量一致`, async () => { + const session = await openVectorSession(); + const m = item.meta; + const { kind, name, mime, size } = m; + const plan = session.encryptBytes( + h(item.content), + { kind, ...(name !== undefined && { name }), ...(mime !== undefined && { mime }), ...(size !== undefined && { size }) }, + { + itemId: h(item.item_id), + ts: m.ts, + ...(m.dev !== undefined && { dev: m.dev }), + snapshot: item.snapshot, + gated: item.force_gated ? true : 'auto', + maxFrameData: item.max_frame_data, + }, + ); + + const frames = await framesOf(plan); + expect(frames.map((f) => toHex(f.bytes))).toEqual(item.frames); + expect(plan.gated).toBe(item.gated); + expect(frames.map((f) => f.final)).toEqual(frames.map((_, i) => i === frames.length - 1)); + }); + } + + it('边界:恰好放得进第 0 帧是单帧,多一个字节就变成两帧 GATED', () => { + const fits = vectors.items.find((i: { name: string }) => i.name === 'boundary_fits_exactly'); + const over = vectors.items.find((i: { name: string }) => i.name === 'boundary_one_byte_over'); + expect([fits.gated, fits.frames.length]).toEqual([false, 1]); + expect([over.gated, over.frames.length]).toEqual([true, 2]); + }); +}); + +describe('raw 帧', () => { + it('封装与向量一致', () => { + for (const f of vectors.raw_frames) { + const built = encodeEnvelope(f.flags, h(f.item_id), f.index, h(f.payload)); + expect(toHex(built)).toBe(f.envelope); + } + }); +}); + +describe('设备名', () => { + it('固定 IV 下的密文一致,并能解回', async () => { + const session = await openVectorSession(); + for (const l of vectors.labels) { + const sealed = await session.sealLabel(l.name, { iv: h(l.iv) }); + expect(toHex(sealed)).toBe(l.blob); + expect(sealed.length).toBeLessThanOrEqual(256); + expect(await session.openLabel(sealed, session.endpointHash)).toBe(l.name); + } + }); + + it('换一个终端的哈希就解不开(AAD 绑定终端)', async () => { + const session = await openVectorSession(); + const sealed = await session.sealLabel('我的 iPhone'); + await expect(session.openLabel(sealed, new Uint8Array(32))).rejects.toThrowError(IntegrityError); + }); + + it('超过 228 字节 UTF-8 时拒绝', async () => { + const session = await openVectorSession(); + await expect(session.sealLabel('x'.repeat(229))).rejects.toThrow(); + await expect(session.sealLabel('x'.repeat(228))).resolves.toHaveLength(256); + }); +}); + +describe('链接', () => { + it('合法链接解析结果与向量一致', () => { + for (const v of vectors.links.valid) { + const link = parseLink(v.url); + expect(link.id).toBe(v.id); + expect(link.mode).toBe(v.mode); + expect(link.role).toBe(v.role); + expect(link.k && toHex(link.k)).toBe(v.k); + expect(link.a && toHex(link.a)).toBe(v.a); + expect(link.w && toHex(link.w)).toBe(v.w); + } + }); + + it('非法链接全部被拒绝', () => { + for (const v of vectors.links.invalid) { + expect(() => parseLink(v.url), v.name).toThrow(); + } + }); + + it('生成的链接能被解析回原值', () => { + const url = buildLink('https://app.example.test/', { id: inputs.channelIdB64, k: inputs.k, a: inputs.a }); + expect(url).toBe(`https://app.example.test/c/${inputs.channelIdB64}#k=${toBase64Url(inputs.k)}&a=${toBase64Url(inputs.a)}`); + const back = parseLink(url); + expect(toHex(back.k!)).toBe(toHex(inputs.k)); + expect(back.role).toBe('owner'); + }); +}); + +describe('日志记录', () => { + it('解析结果与向量一致', () => { + for (const r of vectors.log_records) { + const rec = parseLogRecord(h(r.hex)); + const p = r.parsed; + expect(rec.kind).toBe(p.kind); + expect(rec.seq).toBe(p.seq); + if (rec.kind === 'frame') expect(toHex(rec.envelope)).toBe(p.envelope); + if (rec.kind === 'tombstone') { + expect(toHex(fromBase64Url(rec.itemId)!)).toBe(p.item_id); + expect(['deleted', 'expired', 'evicted', 'download_limit', 'superseded'][p.reason]).toBe(rec.reason); + } + if (rec.kind === 'announce') { + expect(toHex(fromBase64Url(rec.itemId)!)).toBe(p.item_id); + expect(rec.flags).toBe(p.flags); + expect(rec.createdAt).toBe(p.created_at); + } + } + }); + + it('损坏的记录被拒绝', () => { + for (const r of vectors.log_invalid) { + expect(() => parseLogRecord(h(r.hex)), r.name).toThrowError(IntegrityError); + } + }); +}); + +describe('负例:必须被接收方拒绝', () => { + for (const n of vectors.negative) { + it(`${n.name} → ${n.expect}`, async () => { + const envelope = h(n.envelope); + const channelId = n.channel_id ? h(n.channel_id) : inputs.channelId; + + if (n.expect === 'bad_envelope') { + expect(() => parseEnvelope(envelope)).toThrowError(IntegrityError); + return; + } + + const env = parseEnvelope(envelope); + const keys = await deriveChannelKeys(channelId === inputs.channelId ? inputs.channelId : inputs.channelId, inputs.k); + // 注意:wrong_channel 用的是「错误的 channel_id 参与 AAD」,密钥仍由真实频道派生 + const itemKey = await deriveItemKey(keys.encRoot, env.itemId); + + const failure = await openFrame(itemKey, channelId, env).then( + () => null, + (e: unknown) => e, + ); + expect(failure).toBeInstanceOf(IntegrityError); + expect((failure as IntegrityError).reason).toBe(n.expect); + }); + } + + it('装配器对每个负例都产生 rejected 事件,而不是抛异常或产出内容', async () => { + const session = await openVectorSession(); + const reader = session.createItemReader(); + + // 这两个负例在到达解密之前就违反了结构规则(非 GATED 条目必须是单帧且带 FINAL), + // 装配器先以 bad_envelope 拒绝;直接 openFrame 时则是 auth_failed(上面已断言) + const structural = new Set(['clear_final_flag', 'change_frame_index']); + + for (const n of vectors.negative) { + if (n.channel_id) continue; // 这一条是换了会话的 channel_id,装配器视角下等同于正常帧 + const events: ItemEvent[] = await reader.pushFrame(h(n.envelope)); + expect(events, n.name).toHaveLength(1); + const ev = events[0]!; + expect(ev.type, n.name).toBe('rejected'); + if (ev.type === 'rejected') expect(ev.reason, n.name).toBe(structural.has(n.name) ? 'bad_envelope' : n.expect); + } + }); +}); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..a495d44 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [], + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..e658ef7 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..3bcb832 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + testTimeout: 30_000, + }, +}); diff --git a/test-vectors/README.md b/test-vectors/README.md new file mode 100644 index 0000000..252a0ac --- /dev/null +++ b/test-vectors/README.md @@ -0,0 +1,24 @@ +# 协议测试向量 + +`vectors.json` 是协议的**可执行规格**:固定输入、固定输出,覆盖密钥派生、令牌、IV、AAD、padding、帧密文、条目分帧、设备名、链接、日志记录,以及 17 个必须被拒绝的负例。 + +## 为什么有三套实现 + +| 实现 | 密码学来源 | 位置 | +|---|---|---| +| **生成器(参照)** | `node:crypto` | `generate.mjs`,故意不引用任何项目代码 | +| 服务端 | .NET 的 `HKDF` / `AesGcm` / `SHA256` | `server/tests/WebDrop.Core.Tests/VectorTests.cs` | +| 客户端 | 浏览器的 WebCrypto | `packages/core/test/vectors.test.ts` | + +三套互不相干的实现逐字节一致,才说明 `docs/protocol.md` 没有歧义。以后接入 CLI 或其他语言的客户端,同样必须通过这批向量。 + +## 使用 + +```bash +node test-vectors/generate.mjs # 重新生成 vectors.json +node test-vectors/generate.mjs --check # 只校验磁盘上的文件是最新的(CI 用) +``` + +输出完全确定(没有随机数、没有时间),可以放心提交。**改协议时的顺序**:先改 `docs/protocol.md`,再改 `generate.mjs` 并重新生成,最后让服务端和 `@webdrop/core` 的测试重新变绿。 + +设备名向量使用了固定 IV,仅为让输出确定;真实实现必须使用随机 IV。 diff --git a/test-vectors/generate.mjs b/test-vectors/generate.mjs new file mode 100644 index 0000000..53e88a8 --- /dev/null +++ b/test-vectors/generate.mjs @@ -0,0 +1,407 @@ +// 协议测试向量生成器。 +// +// 这是协议的「独立参照实现」:只依赖 node:crypto,故意不引用 @webdrop/core,也不引用服务端代码。 +// 之后 @webdrop/core(WebCrypto)、服务端测试(C# 的 HKDF/AesGcm)都要复现这里的每一个字节; +// 三套互不相干的实现一致,才说明协议文档写得无歧义。 +// +// 用法: node test-vectors/generate.mjs 生成 vectors.json +// node test-vectors/generate.mjs --check 只校验磁盘上的文件与重新生成的结果一致(CI 用) +// +// 所有输入都是固定值,输出完全确定,可以放心提交到仓库。 + +import { createCipheriv, createHash, hkdfSync } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const outPath = join(here, 'vectors.json'); + +// ───────────── 基础工具 ───────────── + +const hex = (b) => Buffer.from(b).toString('hex'); +const unhex = (s) => Buffer.from(s, 'hex'); +const b64url = (b) => Buffer.from(b).toString('base64url'); +const sha256 = (b) => createHash('sha256').update(b).digest(); +const seq = (start, n) => Buffer.from(Array.from({ length: n }, (_, i) => (start + i) & 0xff)); +const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16BE(n); return b; }; +const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; }; +const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; }; + +const hkdf = (ikm, salt, info) => Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from(info, 'utf8'), 32)); + +// ───────────── 协议常量(protocol.md) ───────────── + +const FLAG = { FINAL: 1, SNAPSHOT: 2, GATED: 4, ENC: 8 }; +const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576]; +const MAX_FRAME_DATA = 1048576 - 4; +const LABEL = { + enc: 'webdrop/v1/enc', + read: 'webdrop/v1/token/read', + write: 'webdrop/v1/token/write', + owner: 'webdrop/v1/token/owner', + item: 'webdrop/v1/item', + label: 'webdrop/v1/label', +}; + +// ───────────── 固定输入 ───────────── + +const channelId = seq(0x00, 16); +const K = seq(0x20, 32); +const A = seq(0x40, 32); +const W = seq(0x60, 32); +const S = seq(0xc0, 32); // 终端密钥 +const endpointHash = sha256(S); +const itemA = seq(0xa0, 16); +const itemB = seq(0xb0, 16); +const itemC = seq(0xd0, 16); + +// ───────────── 密钥派生 ───────────── + +const kEnc = hkdf(K, channelId, LABEL.enc); +const tRead = hkdf(K, channelId, LABEL.read); +const tWrite = hkdf(K, channelId, LABEL.write); +const tOwner = hkdf(A, channelId, LABEL.owner); +const kLabel = hkdf(kEnc, channelId, LABEL.label); +const itemKey = (itemId) => hkdf(kEnc, itemId, LABEL.item); + +// ───────────── 帧层 ───────────── + +function tierFor(total) { + const t = TIERS.find((x) => x >= total); + if (!t) throw new Error('data 超出单帧上限'); + return t; +} + +function padPlain(data) { + const tier = tierFor(4 + data.length); + return Buffer.concat([u32(data.length), data, Buffer.alloc(tier - 4 - data.length)]); +} + +const ivFor = (index) => Buffer.concat([Buffer.alloc(8), u32(index)]); + +function header(flags, itemId, index, payloadLen) { + return Buffer.concat([Buffer.from([1, flags]), itemId, u32(index), u32(payloadLen)]); +} + +const aadFor = (chId, flags, itemId, index) => + Buffer.concat([Buffer.from('WD1', 'ascii'), chId, Buffer.from([1, flags]), itemId, u32(index)]); + +function gcmEncrypt(key, iv, aad, plaintext) { + const c = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); + c.setAAD(aad); + const ct = Buffer.concat([c.update(plaintext), c.final()]); + return Buffer.concat([ct, c.getAuthTag()]); +} + +/** 加密一帧,返回完整封装。flags 必须自己带上 ENC。 */ +function sealFrame({ chId = channelId, itemId, index, flags, data }) { + const plaintext = padPlain(data); + const payload = gcmEncrypt(itemKey(itemId), ivFor(index), aadFor(chId, flags, itemId, index), plaintext); + return { plaintext, envelope: Buffer.concat([header(flags, itemId, index, payload.length), payload]) }; +} + +// ───────────── 条目层 ───────────── + +function metaJson(m) { + // 字段顺序固定,undefined 的字段省略 —— TS 实现必须产出完全相同的字节 + const o = { v: 1, kind: m.kind }; + if (m.name !== undefined) o.name = m.name; + if (m.mime !== undefined) o.mime = m.mime; + if (m.size !== undefined) o.size = m.size; + o.ts = m.ts; + if (m.dev !== undefined) o.dev = m.dev; + return Buffer.from(JSON.stringify(o), 'utf8'); +} + +/** 条目编码的参照实现:小内容单帧(非 GATED),放不下则多帧 GATED。 */ +function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snapshot = false, forceGated = false }) { + const mj = metaJson(meta); + const head = Buffer.concat([u16(mj.length), mj]); + const cap0 = maxFrameData - head.length; + if (cap0 < 0) throw new Error('meta 过大'); + + const frames = []; + if (content.length <= cap0 && !forceGated) { + frames.push({ index: 0, flags: FLAG.FINAL | FLAG.ENC | (snapshot ? FLAG.SNAPSHOT : 0), data: Buffer.concat([head, content]) }); + return { gated: false, frames }; + } + if (snapshot) throw new Error('快照不能是 GATED'); + + const chunks = [content.subarray(0, Math.min(cap0, content.length))]; + for (let off = chunks[0].length; off < content.length; off += maxFrameData) { + chunks.push(content.subarray(off, Math.min(off + maxFrameData, content.length))); + } + chunks.forEach((chunk, i) => { + const last = i === chunks.length - 1; + frames.push({ + index: i, + flags: FLAG.GATED | FLAG.ENC | (last ? FLAG.FINAL : 0), + data: i === 0 ? Buffer.concat([head, chunk]) : chunk, + }); + }); + return { gated: true, frames }; +} + +// ───────────── 生成向量 ───────────── + +const vectors = { version: 1 }; + +vectors.inputs = { + channel_id: hex(channelId), + channel_id_b64: b64url(channelId), + k: hex(K), + a: hex(A), + w: hex(W), + endpoint_secret: hex(S), + item_a: hex(itemA), + item_b: hex(itemB), + item_c: hex(itemC), +}; + +vectors.kdf = { + k_enc: hex(kEnc), + t_read: hex(tRead), + t_write: hex(tWrite), + t_owner: hex(tOwner), + k_label: hex(kLabel), + read_hash: hex(sha256(tRead)), + write_hash: hex(sha256(tWrite)), + owner_hash: hex(sha256(tOwner)), + endpoint_hash: hex(endpointHash), + // raw 模式:T_write 就是随机的 W 本身 + raw_write_token: hex(W), + raw_write_hash: hex(sha256(W)), +}; + +vectors.item_keys = [itemA, itemB, itemC].map((id) => ({ item_id: hex(id), k_item: hex(itemKey(id)) })); + +vectors.iv = [0, 1, 255, 65536, 4294967295].map((i) => ({ index: i, iv: hex(ivFor(i)) })); + +vectors.aad = [ + { flags: FLAG.FINAL | FLAG.ENC, item_id: hex(itemA), index: 0 }, + { flags: FLAG.GATED | FLAG.ENC, item_id: hex(itemA), index: 7 }, + { flags: FLAG.GATED | FLAG.ENC | FLAG.FINAL, item_id: hex(itemB), index: 4294967295 }, + { flags: FLAG.FINAL | FLAG.ENC | FLAG.SNAPSHOT, item_id: hex(itemB), index: 0 }, +].map((c) => ({ ...c, aad: hex(aadFor(channelId, c.flags, unhex(c.item_id), c.index)) })); + +// padding:覆盖每个档位的边界 +vectors.padding = [0, 1, 1019, 1020, 1021, 4091, 4092, 4093, 16380, 16381, 65532, 65533, 262140, 262141, 1048572].map( + (n) => ({ data_len: n, plaintext_len: tierFor(4 + n) }), +); + +// 单帧向量:明文、padding 后的明文、完整封装 +const frameCases = [ + { name: 'empty', item: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: Buffer.alloc(0) }, + { name: 'short_text', item: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: Buffer.from('hello', 'utf8') }, + { name: 'exact_tier_1k', item: itemB, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: seq(1, 1020) }, + { name: 'just_over_1k', item: itemB, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: seq(2, 1021) }, + { name: 'gated_middle', item: itemC, index: 7, flags: FLAG.GATED | FLAG.ENC, data: seq(3, 300) }, + { name: 'gated_final_high_index', item: itemC, index: 4294967295, flags: FLAG.GATED | FLAG.ENC | FLAG.FINAL, data: seq(4, 17) }, + { name: 'snapshot', item: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC | FLAG.SNAPSHOT, data: Buffer.from('{"a":1}', 'utf8') }, +]; +vectors.frames = frameCases.map((c) => { + const { plaintext, envelope } = sealFrame({ itemId: c.item, index: c.index, flags: c.flags, data: c.data }); + return { + name: c.name, + item_id: hex(c.item), + index: c.index, + flags: c.flags, + data: hex(c.data), + plaintext: hex(plaintext), + envelope: hex(envelope), + }; +}); + +// 条目向量:meta + 内容 + 分帧 +const TS = 1700000000000; +const itemCases = [ + { + name: 'text_small', + itemId: itemA, + meta: { kind: 'text', ts: TS }, + content: Buffer.from('hello, 世界', 'utf8'), + }, + { + name: 'text_with_device', + itemId: itemB, + meta: { kind: 'text', ts: TS, dev: '我的 iPhone' }, + content: Buffer.from('带设备名的文本', 'utf8'), + }, + { + name: 'json_snapshot', + itemId: itemA, + meta: { kind: 'json', ts: TS }, + content: Buffer.from('{"theme":"dark","n":[1,2,3]}', 'utf8'), + snapshot: true, + }, + { + name: 'file_single_frame', + itemId: itemC, + meta: { kind: 'file', name: 'note.txt', mime: 'text/plain', size: 11, ts: TS }, + content: Buffer.from('hello world', 'utf8'), + }, + { + name: 'empty_text', + itemId: itemB, + meta: { kind: 'text', ts: TS }, + content: Buffer.alloc(0), + }, +]; + +// 多帧:用很小的单帧数据上限,避免向量过大。size 已知。 +const fileContent = Buffer.from(Array.from({ length: 2500 }, (_, i) => i % 251)); +const fileMeta = { kind: 'file', name: 'a.bin', mime: 'application/octet-stream', size: fileContent.length, ts: TS }; +const smallCap = 1000; +const cap0 = smallCap - (2 + metaJson(fileMeta).length); + +itemCases.push( + { name: 'file_multi_frame', itemId: itemC, meta: fileMeta, content: fileContent, maxFrameData: smallCap }, + // 边界:内容恰好放得进第 0 帧 → 仍是单帧非 GATED;多一个字节 → 变成两帧 GATED + { + name: 'boundary_fits_exactly', + itemId: itemA, + meta: { kind: 'file', name: 'b', ts: TS }, + content: seq(5, smallCap - (2 + metaJson({ kind: 'file', name: 'b', ts: TS }).length)), + maxFrameData: smallCap, + }, + { + name: 'boundary_one_byte_over', + itemId: itemA, + meta: { kind: 'file', name: 'b', ts: TS }, + content: seq(5, smallCap - (2 + metaJson({ kind: 'file', name: 'b', ts: TS }).length) + 1), + maxFrameData: smallCap, + }, + // 显式要求 GATED(例如带下载上限的小内容) + { + name: 'small_forced_gated', + itemId: itemB, + meta: { kind: 'file', name: 'c.txt', ts: TS }, + content: Buffer.from('gated small', 'utf8'), + forceGated: true, + }, +); + +vectors.items = itemCases.map((c) => { + const plan = encodeItem(c); + const envelopes = plan.frames.map((f) => + sealFrame({ itemId: c.itemId, index: f.index, flags: f.flags, data: f.data }).envelope, + ); + return { + name: c.name, + item_id: hex(c.itemId), + meta: JSON.parse(metaJson(c.meta).toString('utf8')), + meta_json: metaJson(c.meta).toString('utf8'), + content: hex(c.content), + max_frame_data: c.maxFrameData ?? MAX_FRAME_DATA, + snapshot: !!c.snapshot, + force_gated: !!c.forceGated, + gated: plan.gated, + frames: envelopes.map(hex), + }; +}); +vectors.item_notes = { multi_frame_first_frame_content_capacity: cap0 }; + +// raw 模式:负载原样,没有 ENC 标志,没有 meta +vectors.raw_frames = [ + { name: 'raw_text', item_id: hex(itemA), index: 0, flags: FLAG.FINAL, payload: hex(Buffer.from('plain 明文', 'utf8')) }, + { name: 'raw_gated_first', item_id: hex(itemB), index: 0, flags: FLAG.GATED, payload: hex(seq(9, 40)) }, + { name: 'raw_gated_last', item_id: hex(itemB), index: 1, flags: FLAG.GATED | FLAG.FINAL, payload: hex(seq(50, 10)) }, +].map((c) => { + const payload = unhex(c.payload); + return { ...c, envelope: hex(Buffer.concat([header(c.flags, unhex(c.item_id), c.index, payload.length), payload])) }; +}); + +// 设备名(固定 IV 仅用于向量,真实实现必须用随机 IV) +const labelIv = seq(0x10, 12); +vectors.labels = ['我的 iPhone', 'Laptop', 'x'.repeat(228)].map((name) => { + const aad = Buffer.concat([Buffer.from('WD1L', 'ascii'), channelId, endpointHash]); + const sealed = gcmEncrypt(kLabel, labelIv, aad, Buffer.from(name, 'utf8')); + return { name, iv: hex(labelIv), aad: hex(aad), blob: hex(Buffer.concat([labelIv, sealed])) }; +}); + +// 链接 +const chB64 = b64url(channelId); +vectors.links = { + valid: [ + { url: `https://app.example.test/c/${chB64}#k=${b64url(K)}&a=${b64url(A)}`, id: chB64, mode: 'enc', role: 'owner', k: hex(K), a: hex(A) }, + { url: `https://app.example.test/c/${chB64}#k=${b64url(K)}`, id: chB64, mode: 'enc', role: 'member', k: hex(K) }, + { url: `https://app.example.test/c/${chB64}#a=${b64url(A)}&w=${b64url(W)}`, id: chB64, mode: 'raw', role: 'owner', a: hex(A), w: hex(W) }, + { url: `https://app.example.test/c/${chB64}#w=${b64url(W)}`, id: chB64, mode: 'raw', role: 'member', w: hex(W) }, + { url: `https://app.example.test/c/${chB64}`, id: chB64, mode: 'raw', role: 'member' }, + { url: `https://app.example.test/base/c/${chB64}/#k=${b64url(K)}&unknown=1`, id: chB64, mode: 'enc', role: 'member', k: hex(K) }, + ], + invalid: [ + { name: 'no_channel_path', url: `https://app.example.test/x/${chB64}#k=${b64url(K)}` }, + { name: 'short_id', url: `https://app.example.test/c/abc#k=${b64url(K)}` }, + { name: 'non_canonical_id', url: `https://app.example.test/c/AAAAAAAAAAAAAAAAAAAAAB#k=${b64url(K)}` }, + { name: 'short_key', url: `https://app.example.test/c/${chB64}#k=${b64url(K.subarray(0, 16))}` }, + { name: 'key_in_query_not_fragment', url: `https://app.example.test/c/${chB64}?k=${b64url(K)}` }, + { name: 'enc_with_raw_write_token', url: `https://app.example.test/c/${chB64}#k=${b64url(K)}&w=${b64url(W)}` }, + { name: 'padding_in_key', url: `https://app.example.test/c/${chB64}#k=${Buffer.from(K).toString('base64')}` }, + ], +}; + +// 日志记录的线格式 +const tombItem = itemB; +const frameForLog = sealFrame({ itemId: itemA, index: 0, flags: FLAG.FINAL | FLAG.ENC, data: Buffer.from('hello', 'utf8') }).envelope; +vectors.log_records = [ + { name: 'frame', hex: hex(Buffer.concat([u64(1), Buffer.from([1]), frameForLog])), parsed: { kind: 'frame', seq: 1, envelope: hex(frameForLog) } }, + { name: 'tombstone_download_limit', hex: hex(Buffer.concat([u64(7), Buffer.from([2]), tombItem, Buffer.from([3])])), parsed: { kind: 'tombstone', seq: 7, item_id: hex(tombItem), reason: 3 } }, + { name: 'tombstone_superseded', hex: hex(Buffer.concat([u64(8), Buffer.from([2]), itemA, Buffer.from([4])])), parsed: { kind: 'tombstone', seq: 8, item_id: hex(itemA), reason: 4 } }, + { + name: 'announce', + hex: hex(Buffer.concat([u64(9), Buffer.from([3]), itemC, Buffer.from([FLAG.GATED | FLAG.ENC]), u64(1700000000123)])), + parsed: { kind: 'announce', seq: 9, item_id: hex(itemC), flags: FLAG.GATED | FLAG.ENC, created_at: 1700000000123 }, + }, +]; +vectors.log_invalid = [ + { name: 'truncated_header', hex: hex(Buffer.concat([u64(1)])) }, + { name: 'unknown_kind', hex: hex(Buffer.concat([u64(1), Buffer.from([9]), Buffer.alloc(8)])) }, + { name: 'truncated_tombstone', hex: hex(Buffer.concat([u64(2), Buffer.from([2]), itemA])) }, + { name: 'frame_length_mismatch', hex: hex(Buffer.concat([u64(1), Buffer.from([1]), frameForLog.subarray(0, frameForLog.length - 1)])) }, +]; + +// 负例:每一条都必须被接收方拒绝 +const base = unhex(vectors.frames.find((f) => f.name === 'short_text').envelope); +const mutate = (fn) => { const b = Buffer.from(base); fn(b); return hex(b); }; +const otherChannel = seq(0x70, 16); +vectors.negative = [ + { name: 'flip_last_payload_byte', envelope: mutate((b) => { b[b.length - 1] ^= 1; }), expect: 'auth_failed' }, + { name: 'flip_first_payload_byte', envelope: mutate((b) => { b[26] ^= 1; }), expect: 'auth_failed' }, + { name: 'flip_tag_byte', envelope: mutate((b) => { b[b.length - 16] ^= 0x80; }), expect: 'auth_failed' }, + { name: 'clear_final_flag', envelope: mutate((b) => { b[1] &= ~FLAG.FINAL; }), expect: 'auth_failed' }, + { name: 'add_gated_flag', envelope: mutate((b) => { b[1] |= FLAG.GATED; }), expect: 'auth_failed' }, + { name: 'add_snapshot_flag', envelope: mutate((b) => { b[1] |= FLAG.SNAPSHOT; }), expect: 'auth_failed' }, + { name: 'change_frame_index', envelope: mutate((b) => { b[21] ^= 1; }), expect: 'auth_failed' }, + { name: 'change_item_id', envelope: mutate((b) => { b[2] ^= 1; }), expect: 'auth_failed' }, + { name: 'wrong_channel', envelope: hex(base), channel_id: hex(otherChannel), expect: 'auth_failed' }, + { name: 'truncated_payload', envelope: hex(base.subarray(0, base.length - 1)), expect: 'bad_envelope' }, + { name: 'trailing_byte', envelope: hex(Buffer.concat([base, Buffer.from([0])])), expect: 'bad_envelope' }, + { name: 'header_only', envelope: hex(base.subarray(0, 26)), expect: 'bad_envelope' }, + { name: 'too_short', envelope: hex(base.subarray(0, 10)), expect: 'bad_envelope' }, + { name: 'bad_version', envelope: mutate((b) => { b[0] = 2; }), expect: 'bad_envelope' }, + { name: 'reserved_flag_bit', envelope: mutate((b) => { b[1] |= 0x10; }), expect: 'bad_envelope' }, + { name: 'payload_len_mismatch', envelope: mutate((b) => { b[25] ^= 1; }), expect: 'bad_envelope' }, + // 没有 ENC 标志的帧,在 enc 会话里必须被拒绝(防降级:服务端不能注入明文帧) + { + name: 'injected_plaintext_frame', + envelope: hex(Buffer.concat([header(FLAG.FINAL, itemA, 0, 5), Buffer.from('evil!')])), + expect: 'wrong_mode', + }, +]; + +// ───────────── 输出 ───────────── + +const json = JSON.stringify(vectors, null, 2) + '\n'; +if (process.argv.includes('--check')) { + const onDisk = readFileSync(outPath, 'utf8'); + if (onDisk !== json) { + console.error('vectors.json 与重新生成的结果不一致,请运行 node test-vectors/generate.mjs 并提交'); + process.exit(1); + } + console.log('vectors.json 是最新的'); +} else { + writeFileSync(outPath, json); + console.log(`已生成 ${outPath}(${vectors.frames.length} 个单帧,${vectors.items.length} 个条目,${vectors.negative.length} 个负例)`); +} diff --git a/test-vectors/vectors.json b/test-vectors/vectors.json new file mode 100644 index 0000000..1e4b522 --- /dev/null +++ b/test-vectors/vectors.json @@ -0,0 +1,667 @@ +{ + "version": 1, + "inputs": { + "channel_id": "000102030405060708090a0b0c0d0e0f", + "channel_id_b64": "AAECAwQFBgcICQoLDA0ODw", + "k": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "a": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "w": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "endpoint_secret": "c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "item_a": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "item_b": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "item_c": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf" + }, + "kdf": { + "k_enc": "0f7bbc36f19977e226b471fb1b830b68a70f0c93c5a1f7c64f2c113d4f7fe3d0", + "t_read": "6e1b70f60c77f1a8c2c5e5fe6e750be1360669c46821dba189c75b43a1067905", + "t_write": "eab6040640cb78e23550ce1714e0af5f96593e198d0dc44085c941f0a5b7dc07", + "t_owner": "164f6a1e0542a36f5ba8624a494670e3f0b18e80261ff2dfde0358dda40ae2d8", + "k_label": "692ee0caee059e9f293e81f34e517c1ca065ce8fcc88f633dcb9fc00b3f2b9ae", + "read_hash": "95603c1548f48c92ae3d64cb7a8d5526aeb8b8c4ec875af6accd1ce8216103a0", + "write_hash": "9a84db4fb7b2ad28d6d25755f4596f2bf02600d98efd8788231ea24cb7300839", + "owner_hash": "1826151f6c5744e81dc124d5577fcc3b09b52d73f14efde94aeec30f5d1e7645", + "endpoint_hash": "ec071e0a0136c837c051cee6a7713edbaea6936712d1a3ca37e84fee3226e61d", + "raw_write_token": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "raw_write_hash": "4d8d274ff7e176af977a95a0055c8c5f3478d38640343a060cee893e56f39957" + }, + "item_keys": [ + { + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "k_item": "3c28ae12e72f9243c8e7de1e2326fe82a2e70ebd86254f7ae6749a865896240c" + }, + { + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "k_item": "8e8056b5e5d8b151fe72cd86b7fd20902de65bba24863e2fd67e5d19e1a6f201" + }, + { + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "k_item": "6730cc43da452d564bf0cdd232a69ece496883861edf4a24bd839ec14b0287bd" + } + ], + "iv": [ + { + "index": 0, + "iv": "000000000000000000000000" + }, + { + "index": 1, + "iv": "000000000000000000000001" + }, + { + "index": 255, + "iv": "0000000000000000000000ff" + }, + { + "index": 65536, + "iv": "000000000000000000010000" + }, + { + "index": 4294967295, + "iv": "0000000000000000ffffffff" + } + ], + "aad": [ + { + "flags": 9, + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "index": 0, + "aad": "574431000102030405060708090a0b0c0d0e0f0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf00000000" + }, + { + "flags": 12, + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "index": 7, + "aad": "574431000102030405060708090a0b0c0d0e0f010ca0a1a2a3a4a5a6a7a8a9aaabacadaeaf00000007" + }, + { + "flags": 13, + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "index": 4294967295, + "aad": "574431000102030405060708090a0b0c0d0e0f010db0b1b2b3b4b5b6b7b8b9babbbcbdbebfffffffff" + }, + { + "flags": 11, + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "index": 0, + "aad": "574431000102030405060708090a0b0c0d0e0f010bb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000" + } + ], + "padding": [ + { + "data_len": 0, + "plaintext_len": 1024 + }, + { + "data_len": 1, + "plaintext_len": 1024 + }, + { + "data_len": 1019, + "plaintext_len": 1024 + }, + { + "data_len": 1020, + "plaintext_len": 1024 + }, + { + "data_len": 1021, + "plaintext_len": 4096 + }, + { + "data_len": 4091, + "plaintext_len": 4096 + }, + { + "data_len": 4092, + "plaintext_len": 4096 + }, + { + "data_len": 4093, + "plaintext_len": 16384 + }, + { + "data_len": 16380, + "plaintext_len": 16384 + }, + { + "data_len": 16381, + "plaintext_len": 65536 + }, + { + "data_len": 65532, + "plaintext_len": 65536 + }, + { + "data_len": 65533, + "plaintext_len": 262144 + }, + { + "data_len": 262140, + "plaintext_len": 262144 + }, + { + "data_len": 262141, + "plaintext_len": 1048576 + }, + { + "data_len": 1048572, + "plaintext_len": 1048576 + } + ], + "frames": [ + { + "name": "empty", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "index": 0, + "flags": 9, + "data": "", + "plaintext": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1fe3ff50aae4d85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab679d63ba7d88a3c743c692eb923ac4ed2d" + }, + { + "name": "short_text", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "index": 0, + "flags": 9, + "data": "68656c6c6f", + "plaintext": "0000000568656c6c6f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553" + }, + { + "name": "exact_tier_1k", + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "index": 0, + "flags": 9, + "data": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfc", + "plaintext": "000003fc0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfc", + "envelope": "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89f075cf14a352cf1997a99ffbd3a7f6f01b25c3364a0ac602d079856845c6e236db33b2a916a19d97d282e7ef675b29301a79f3a06c1323e0dca365e74f3c27b5256fbe28add4a45febf87d9706919c7ca3750f32849f60d91a655fa60a0c65bfecea4bfa02c3037f85544025c9db537b499254c586cfb2aa6e84ed4a212b62d5bfa30a466086ba94a55d94d0758e9dd7e0afc65f45585373204736e929282d74c0fe7aff037d37646db5e9c1ee62ab6ec720e75623ad56768d2633a49271b5e0a33f6913dd2e867d91908c734f3c37d1d4cb5edd137cc1d794148c5e54163c8e254ff9f5d03505fc8089e7e4a27473220b87a883200e6de4cfafe865b5190e33bbfd8280429a89360d5d08e5e9d52b614e4886808c39b5b8113078e504a697b3f8ff61e88c274447b2e24830165be48c33eb0edbd479e4c2d8e0c70acf5bd430072dda3d106f1a7298ca22fafd6b9aae690bc061511779fdcc0fa561ee9f1902416625b300449840b5de5128aa4ea332b90cb9661377de92370dbd33ed4863ef4ad513bee812892c304babe39594ff5fc7fd0a36890247a4daadbb022fe93a48eef494db2799df0773d114c3ce21897890222a1f1b486d8c51194dce14a9f4261015e12b10b01555792ba169649a9fde6eaf514dc086b48a97c82e395bc0f25220f881232001fa5ebc1976b11eda465d2677031c92cf87aa851ca12e47720c365fd0c45269d75a955edb9176862a4237aebc763a320312240229aa58a80000aba7b0e1c1f43c6e9a7b3cc9f27c89137782a61f53f5b8d58eead26a2cb010054cebc94c16950cd1ffcc113dbb5d0dd1659b36ca66808333dce53f345cc0e3b7efea81ebd32e376444909e17f695130dedf761cba6c4f66fe323dafdf6b89ac673869291e133e6b66d545bcf25ac514644c7bb0ab71f72e19abf21556c50fc6b15178aafaa0531ed0032575fcacf40b79696bd9ad5cb41381cd4594e0195bc7de449b1f448c8bddc65772e0822d8e574404636fb88df0d0f7739d6dc7730e436b2ef7fc1fd1acf7f96fc24900de29524d5b9420fa7d1cb40e36b8afba8d2913cad420cce0228ea375cecd56b93a7a9b672150c69b480e9bd979d3842a966efdf1269fb69feda400f608870ef7889de2b7e38ac71ae3a4c4a3edb614e0d311c40af0f7c76dde10fbf4f592b7729eb5d84f67e3d6a0e3f29aea3714dd1b2e9a1f2e21a33e5944de0c5476d67c95f23739328ea2a018afa1bf559c3e6ac7678db8af43694b691d91c9266d3c858b3a91e8882636342221116ac3ae9bf088d924c74c2a45d02f871c6d239c07c9f6ef0eb8c553fbc2fd9efaae8a188890244fd85584b47e01d8248386a3bcab778752546c438606755e41b8841461eca98d3532b360f85711ef77e797786f9ba77184893e5896af538a85a7831e84d0aaedc078eef9198101af6c1e68943cebd5c5762d" + }, + { + "name": "just_over_1k", + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "index": 0, + "flags": 9, + "data": "02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfe", + "plaintext": "000003fd02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfe0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "envelope": "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000101012c89f065ff04d342ff0967b9afeba3b7c6e1eb35f3263a1af6122069b57835d6d2252b2382b966b1ad872292d7ff174b1921ea69c3b01c0313f02cb355f73f2c17a2d57f8e38ddc4944f1be84d877681ac6d53653f22f48f50c9ea756fb67a1c55ac1cfa7bea72d3336f75447035b9cb636ab98264d5f6df82ba9e94dd5a513b52ca4fb33a5610968a84554da4c0059eadc610bff64f35486363d05706f959381d6730ee4aef736d07749da5d9d19e729b7f3730d74653bd66667d3603b4e26185f7532f5903ad3eb66d6180bc633f2c07c024db6ecd636cf1c76404bc4e24060c9dd55fc9e5a02535ec7099d7f4d2644333fb979893501e5df43fbfd875c5093e2c4bedb290328ab926fd4d38f599c51b70be58b690fc2985a8e12048f574b6a7a008ef51f8fc37745742f27820664bd49dc3fb3ecba469d4d228f0f71abf4be427f73dea2d607f2a6268da12ea8d7baabf991bf071210749ed3c1f95719e8f2911b17615a37054a85045ce6138da5e9323491c89766367ee82c71d8d239d5853f0bac523ae9802b93cc05b9bf3e584cf4e37ed3a26f91277b42abd8b125ff90a5b1ee4a4cb5789ef1783c124d3be31b96960321a0f6b585d9ca1097dde64b9c431e005d13b60a02545893b9179148aafcf9ebf615db096849a67d81e292bd0c241d0e8b1335011ca4e4c0946a16eca764cd667330ce2dfb7ba750c913e37623c29afc0f44219c76a85aecba166f63a72265eac462a42132234f2399a48d81030b857a0d1d1842c5e8a8b2cf9e20c99236672b62f4385a8e59e1ac25a3cc000355b1bd97c06e51ce1ef3c010dab2d1de1746b26fa76f09303cc152f044cb0f387fc1a91dbc35e275454608e27e6e5033dfc0771fbb6b4e65ff3d3cacde6c88af66c7682a1f143f6867da44bff35dc41765537ab3aa76f62d18a4f31657c20ec5b06e79a9fba7521dd10c2476fdabf50878766adaac5bb51080c24497e11e5ac4df3b9a1c458b8adec75873e3832a8f54451b626cb98af1d3f67c9c6ec6740f406a11f6ff1ed6adf4f860c34a01d928514c449523fb7a1db70f39b9acbb8a2810cb2b21cfe1258fa074c1cc55b83d7b98663e51c59a4f0f98d876d2872b916ffef0199eb59eeaa503f707860df68f9ce1b6fc8bc41be4a5c7a2e2b717e1d410c70b8ff6c46cd911f8f5fa93b47399b4db4e78e2d5a1e4f399eb3815de1a299b1c2f1ea23d5843df0f5579d77f94f5363a3391a3a319a8a0bc54933f69c6608cbbaebc6848681a90ca27623d868a3d90eb8939373723261069c2a19af389de25c44d1544d32e801d6e229306caf7e80fbbc44cfac1fc99fbad8b1789932548d95685cb7f02d9238285a2b3aa748655556f429907765f46b987156eedaa8c3233b061c75612ee70e69479609aa47083883d5989ae508b82a6801f8bd1a9ecc7792af888cc37801540e6e8c640859baa526decd95252a5ac551ce5fbb8582cb58d3d2abce8a2f6e1b600d991fd66245f01b175e32d01b53157de7e3601a00f56a36bc1af07fb5f0f67c7535ae0a0a08cd5606e11039e23536f43f4376e4dd7734a39907c66bcac324d70c57477bb9107174df807e66122e5012a52f44a0f2ce22d3bf8b63d6c4133008c12953e7da5f728269e025acca241394ec2f4549d90d5b8b2aac5ea7158839988a97438dd87567f453298ed45a0c7a902dd9345aecf275fc71aa60e75cef32bae89cb758574587cba234bb7ed3aa180460b165db57f0400e149db6b8d764cd8ba0b325dbe4739796a4549b5455998cbebf431e1085556710af0c506fd737f82712c658f9618ead5ec4ad49d6019bb43a8b6ab2054f9ebb28fa7dc4d661bc094b920f8fa675b1d2b535cfe21af71c3844f9d7007bf1cbd3b1630495156d54d3ddd058d2324c627193b7157429eb331d5421589f13ea13c707c092669f3bd10ee0ae786fe11093b505919a6a2566af323c61bb8cd66e2cfbca01b812b8e5e47ede6572b8f2ad2d012a14d753c5294ac4c7b6aa73ff7c409e58e498fb1b0dfeb752634c3d95176a3fc33d752d6f957701aab0a88b66a212fca2dab9127799c413822f1f98e750c0bb47ee8e1d74b073983982379c4a9e54749d88339c85305df77f6e92c17acaa8b36342f679f6afe0ea20e8dfcc7744f7fb2481d8c2b67d16e65bb40014224be66e227eabe13a9182ceed731c05c9e5184d6142a8cd0bb9c4c30e200990a483173b23b67a80e580bfa2f4d99ea05f719566f96f2c2e63047a853c1d8bbf5a31fb7124db5181e8329e01e186d7c983f4a8664683e31b401bfe0485d7bffa37687b54ef0ff661d3d6899071ee686cd193a9e761da1018683eb4f01e0e5b34eb33ac2fe2d441845649ff8f490a19a0b304ba3ddca2bb6a1d2cdac3eb1409ca85a9a46ddf28052c171e2742aded6f42c2889fbc1569378a81cd2e36c18aeb7b2d0b6f9ef3bd0e0f731cecf7bc4d9ea0ca717264b23d512dc44a96cd0030035f360ef6751a4c9f80bd9177aad8e7cd92550b9cf1d40a5e271d80cbd9957e6c8be47f491bfed9f6cf98fccb5f7ebbb5dc521c5221df2b5b1b927c3cbc61cf5859e063ae06daf00f37ad4f6da651e99f07695bac25f460d2486ea81c39aa8dd8bfc3b945a2acd0048c8d51638c3b2da66238fb56b529e2f0afbfeaafda49df4c3612a5c00c41cf2e96af7231d12c2bdca7503ebad27730cacdcdfe79425a5e9db0969c3fa362e859d124292a2569eb3c355264c09240d0cf01df23d7f97295d8dfe729ede2e5876ab30876c6560ae2f422f743678d4401995db5b80dae50048fbb6ccb4996cc987c958f34c3ba328f935483077f154534b713edef030665dd40b7926cb8ca0d9139aa44f602d51b68dd6723e702a7d4e83ab7e2cb2e46659d27ddc08f407bd0696dc925c79572a970a6ee5ca85880f509a291f519b60440401b287d15d5c8cb017b783305f090491cac8a6cb42b60619f1155202e23825becfff76861fb2a21ff353b85cc815b72a75b9692ac9d1fe7821b361e663de8f12eb072fdecee754995990e3a9a88143c86632d8c6e79a09ddcd9a6bc397957f3b4788e1c8153045686add0eadbce16d6e282139390aa24d3ca1cae9c7b105f9a3ad0a87895ff7fa180cf870e4f91f0d7d48c462aa4f6db623a0140189f87e9b9b4028e63c9ed0463d13a1eef7b6b606cf00103aaea2b6f5702664fc2c21c883e91171b175b56081e5f743bd8b9d1bf22721aac476549a1653609edcbae76d797f3e2c9cebb8a25acc25c10180c22c4f7306fbc8dd86f1e6055b0bf0b1cf45027a2cb387372d7cac89211173f1fd3fcf8c5f93242d145d1c2e479a596d159234ba987bb95482aeb4e4c46d3248cc28cfd5b1e4d57ed14fdfac4938734ecdefc5c8115e6d370ce5f8c6d93a7fcd9315a54a1f6dcc7af075a7d04cd3507a919759d72c733f5e32c90d3308adb775ac98e56e975cc8512cbbcb4add5f11ba08e037497509a9360f0138112e026e9fa2195c3894d2e54d45412aa969dccc029131d312f919198fa8308fb0abfee25a6edc31f590276670d4d1a0db2384304a3721a0a5c451d800663c1a4900bc66d437c9ec8bf9684081332a581e3fac833b2a33e6248051e96fc248ea741dd7d468f71160281d1343294209988953c6cc9d75c3e56d0ad0caa94c831f7dd2d0ee6b73035a49a97b32c8ec549534f70150679d98d8b6ae27b0cd96d4c86cee4ca606b9a5c00c54ac19f7cb7053cd183bde9ebdf2a31f74b8768d71be30d4b5816d3f2f15a0c664e0b605b04d194dce561054a1ddf6f85c2c7f83e5433614fa57b0982bf743665b7c0b8f722f544dae4b011e9eed09537ad2f32eefeaeb81079a454ec9e7ed795d9ceb3531c087030d26edf38282d68bb5e2d45782ac6c4d124e43ed9668ec5c66cad215a3e22a20a9b2ef734ea0482f890d2166484582f21bc1e0bfed771f090574a9e80c7ae1d1e31138d79b12f68eee0652a508f34e2ab3763223f92c47a6e13801389ec9a8c056c86ddba8901136b6543289e30ab484ccaa4b98046445af78d2ced8f149ca5f36c163a5fb878b5bdf6f7c4d2806e0fc602636f2c0761479d99173f1f633e3468e620420c7f4e8dae790430248a397a11fb06324268db07179140448d059c0e1ed76041cd9743da76e6a02402eae63a9d675b4984b2af9047ba521ba1275f6f36bc055513a112a6675655b65fcf7726b6f6eb410e5ce5782c4a742dbd70c09d9072b5ea3a1adbb2457fa9d3967610316b6105decf89112561998c18ac03e8e145acc326611e1e9ae5320f4a323f03d726d981ab750463730b81da3b646a3c08bef541431ea0afbb5ab618fc0da7038cdf712bf04ea37aa8027fe4116b5cca9aa46366dc1f855111dbfeb0c3043468560152e73e51a8af0ef6f5fa4eb4ebb5bf1f9b8660672135165e50768d055d0290f67727996eec8ac458a814d9d96755aa528a5f2e2226d1610bee9d37aecf85b069748f9bb4f693facc5f3c2ac4719c2885cb95c24d8ff73c7e0b170bb5c54009d53f6e52d76f82c52d6da47bc160ffaa82e06427367a9214d9b94b495c1c6505f5f2ce01196ae0994c1f3b97f1e6da9c24cc0c633707371913d75617ad82d528202ca9b05f697fa172e2f4bab136a053959d0248103ad5d14d10cbf39cbfc830bde66fd4321d21b38b948ecd586ce1053fb25bb0802b6471106e4663fa57b0cbdbcc6b455d9e10133ff553f83229c9496797cae65fbe3b02810c6289ba830e9fcdc15a8f12d936fe950098db77fd5ca9ef9dd2e6d11121788dd7c1814630397f62fc4cd31366e418e0168272d313af04e06073ae647e73e972d42d8f9ea17c6ed672328591eae5229f5862a0e974c2a334fd3dff559d063302b6299d1949bbbb5bafa819629c7fad6fb072728048e28bc45fa7b59c56e079eef9b382bf514bf5487c850a1c0b19add9177a738d72bd04d5d7c21dfad315faeb2a05075f0989a80232fa4a7a0d399ffd9be4bb2b654242f4a65d2ef0d342befdb721cef893f558f14be8151494740271ec18cdb5a7f6575d6c72b666cea33fddebaf3326e8d2e17eca807fd625134bdd7197c4426a5700644a2e7cb99d5a7ccb9fc9b418d4a427416a91eef97de1885ccfce3e626b883fa7c49db15b593d5c33fef3057acf4b9c84f36f9e1fed020f877b81a619f16504a64508ab405c9cf855a95865f046a0b65423abe5d1b5362523ebe9ca7292544641c9f63a0c220cc8ca75d61affd0a1b80ca8de0b78f28223dea485f56364a471ebbae238244fd49bafdc02447077c6aaea46c5df7cd274203549840397eaf29f218ec06a44a0cf43c65d648a78283d54ff843dc37d6ea2ba9125b6ab44c6e1a857b06ae440728732cbb55d488408c8b4ac247ce17b13f715f7e834ada21b5373aff873dada966ce3028198f70bfd32886793f17fbb0042969d982b3330ef4966a0561ba6c215e01e4c555490d3cf4654e93d1c145e4df94252c0e86fe2b84b47f962aba52f5bd8adcc9b2a48bfb2e918b3ae09b0cf56e4883b4b41244a48233dca2fa2e293c6b8dae131eccf4ddc505775b82f433fce70c65bde62214146d10421c84a8585c13bcff7b8bc7ccb3115abe9b4a9d2a6ff84c37286f83bc116c2eb36a3a635feb7986c8aa199978efb07b1f62c548678fbe1a1b79291c09703c5018becdcc9591efb43c4bb85d47eb245bcbef18e81a2547a78c147e6820aa65855911ed80181e7eb1dd26e02a7d67cc80a290b8144cc55e1bfbc8383b173ec567fba017e727d8c8973d8514e9abbb3a6e" + }, + { + "name": "gated_middle", + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "index": 7, + "flags": 12, + "data": "030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e", + "plaintext": "0000012c030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "envelope": "010cd0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000700000410be695d8334a2840be20e2bf6e3ca2499d0bb1a0ec6e08296a177161cb65a970f3e308a0dc67050b68e7030c8e113e8fa520c95abadab0744a6c3cc294212128fd3197a242741fa4161a7ce9de373806873fc2b3a52e689810aa0ab2bcca4e129973b2b68476815d2295988f92c56d5afd94145192942bd054effd4c0cf1686975d552b16fa729f6eef5c6daaffc227ae7cfb0119108296fcc42d55cd194f95b2ff48dc79c6d97fdc4ab68f87c90d0041bf1c49327cff2beb8c62b9551e387c5f230d9cf2b7355818b7c626a5beb3982d64585568771a28d0b39140536876bdcff466fdb5df7fd73e19f7f4d2b23a37875f7b591d3efb013094cbbd76737afc1f5a4377598da5bd357e0849c588eb4ac24d0534fbbc0e5d57c5a72cd7f838dabda207a4a63b102b6f5708cdb32866fe3114facd6cabd005cc2e64d4db1708cd9403b1faec3aa34e4714c241962168b5194975bbefd0a63eca9fe1ab080fc33451c10340c27bd53ef50c0ccd23652372d6f21d4cc595b78f2ea13d0bae16d3f8a393d7cb817a41f846da67c984cc2b06db6ccf89601a16502b331461aebee96ab37d81f9c89588fef7c5740ea1727cb275f1eab1d84fd8b012b12da793eaf6923c734478debd2ca5ffe6eed398d4a69505a736f1ad0aee8b0f3e5db68b33f2a4827fed3af22726fd4e19349966cd107e54eb05b3df90cd34cb1c8052bc38d35209c02ccf0223ce1a1b76b4425508a6b295441a8640a31f6a703d6be4596be56be638ddd4ff2d3b3be82a3661b322692dc2ceefa87b12e1e654466d7655592ccd7dba6ccc99d8e186eea9b2490bcb2c9b779cf3e77c316f83c8664cbdbd5275ff860a0ef44e997004383c2ced950fdce1529ff70400fafaf490b9c03b5b79219e1f16731a7dabe2709cdcd7b07a9d0e8b1c375f73a5fb5db4f25fc6c89939828cf422edb8b7027b18132a4adfc2d27702f4a8f627bc436354a90bac46cc004d54890ab2c55940c7bf25325588e95f12ae3430e51307e793ae7aa02a48f5a0284ded826c9ba2c65d31e69530de3fed9ad817131eef8b4feaa32331582a3ccf2114beb6dfe8feeb55911f5500e0b24fe45181e92fc1932c26a894b56b8b78bd83e18c3c3327fa38b9e7c734a73c5ac19501c4eb26e35ec84767878acd0a6f69cfc911e42bbe2818a06042337400e81b5a0ecffc2f1a0ed938c4c63561a8d72943ac98183aab7cbc89c8c52426818b9e610ac7258bd198353d294c1e3ba88a197c30299265aeec93f5ab492066eb9fb247b6a3387b806fe52034e269feb2346be7b3b93889ec3ebc86f3669d3157685ddf554d081d6827c6018a7e900c1549b2af387d9ca1dc93f69e607883010019aed39f3a9b116b72d264dcbc651c55fef6f408a896c0f83d32bb0d1b3a00e8e5006f056a43747901c6ee8bc44c149fc395c9d6d0bf6f59752d87b2589f3db89d3026295c" + }, + { + "name": "gated_final_high_index", + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "index": 4294967295, + "flags": 13, + "data": "0405060708090a0b0c0d0e0f1011121314", + "plaintext": "000000110405060708090a0b0c0d0e0f101112131400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "envelope": "010dd0d1d2d3d4d5d6d7d8d9dadbdcdddedfffffffff00000410a5436c297c45d652953f657961b24cf52462fb030ee7de044aa0103e809c471450937018b46e5fb4c9f9c10fba07ccf8436c47bbce8d70d5b0b9efc14e4abc1b9d437dbcf22f9386f9e0f9459c71474c73df2843b3a3caf806b699990fcf1064eb9a9b3c2803614095f4acb5048f7b02cfca0d315e9aceb7ba74cc5f041d1044df3856a14991e0c3a060f72a10d480866ed70b9beb84c70a2625a48995b9d98d121ad4b4fd87af3bb4d85e9282fed4194019ce01757089910fe63d66c65c68f94309b14cce5807b6f4b4d54b5274e181b8800cb4b96e7ba232b2bc6010765fdc8465d961e51f5efcf26cd5e5fcb1e83e6836eed043c4bc2aaccc60d7b1590be097a250679bb496d87aacc8af364b9ac79ab4b9db3f87c79a1aa5d319674854094b75b346fba89a57a92b80ebec0d217aa105d24d91448ba880993cbf0fdb8c3198581bfba9e74116c5cd8df8ab7f5c9ea0bce5dded228bfb237dfe648c34342a36373b838db448323d3a901174d288ebe7f84e2b2f471e3ab4772562b80b7e1b248e95c03e37f3fb81d48cf65257211056324d7c4f203684f62020dadcf2d74dbc13bb2b2bb432079a31205679c940fff62a94e0f64148f9102aa080edc39a792b480d9c1e80ca5b8b1f0823dca5dd4ccf25cc5da52a8b634a6a0bf141677c6c088cd3df443bfd2bc1032cc9cabfdff68844a2f756fe2faa1272f45a51fd1db1a8915a4f7f235b828ac4ef5828c2a0647bcc339e622265d37ad45d7fd905d7b2e6781484d82b9a73334a14f120c5839b866728e9bffb31eb86633695fdea0d41b07880fd67db73ac913f25cc6b587642e5ccb1ae2c1a924587edac3951ef1640b3df8b7118e2a54713eb3b192e845aeb3928ba977900e00915429721153dac309dfc212d844b0cefc71acf5cffde6a521e7d737a4eb2b2d3ae0c2fbc8b0f8698c0c3a230caaf6c7833af453d51ad0a47293e7da1b45cdc2309e5c8d688a3c82db5c65d8f5d4abba49ba5608dab08b5a495157e068b72b2604427675099d19afe871c1fb37fa3b00c48868c690c811c81ce9429296e7fe0fad41eb1efa093672b0d5cad15e57c839e615635c7820521d9fe5e739a4520b2efc1f152915b3748743f26dc00666b347fc5da17197102276a9c84e40cbff5743b87a328e487e5b02f09b220cbda39b54829e955d05261fe9fe8faad5ee87119353e68fd104e103447d275fd343db9edf310e8f923daa28b4319f6e14ad5eb2ac953afc9617e0f507244b5db9a1f4ea2c3bee6e1a420f27bf4bd7e684ad03ed71489fe805ba624bd9869464c7412a11a6c4c295c349f76655039797ef9d88137303609d6ac363a421cd0784b8e22e24a8d28fe6413a08dfbf8b556119a179d398395da2d17dbd2cd1c180bf4fa9f36e0bd5e5262058df5469f57150fea5d0b42c3b203914b2e69d8ca904a6dd7f7f5d7868ebdbefb2c987283" + }, + { + "name": "snapshot", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "index": 0, + "flags": 11, + "data": "7b2261223a317d", + "plaintext": "000000077b2261223a317d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "envelope": "010ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1898dd3188dee9200101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67901cd2a2f608edcac646fe1350ae6d3b" + } + ], + "items": [ + { + "name": "text_small", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "meta": { + "v": 1, + "kind": "text", + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"text\",\"ts\":1700000000000}", + "content": "68656c6c6f2c20e4b896e7958c", + "max_frame_data": 1048572, + "snapshot": false, + "force_gated": false, + "gated": false, + "frames": [ + "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a28e3d72b8892fa67302d87ec18c8cc4c994d9de36816339181f976623149c97875b81538c552b91afc51004ad6e248676e829e4961a7de3055b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67053d1bece4e7ae471253b03e4702dc5b" + ] + }, + { + "name": "text_with_device", + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "meta": { + "v": 1, + "kind": "text", + "ts": 1700000000000, + "dev": "我的 iPhone" + }, + "meta_json": "{\"v\":1,\"kind\":\"text\",\"ts\":1700000000000,\"dev\":\"我的 iPhone\"}", + "content": "e5b8a6e8aebee5a487e5908de79a84e69687e69cac", + "max_frame_data": 1048572, + "snapshot": false, + "force_gated": false, + "gated": false, + "frames": [ + "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cae5dcd32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303778ff3dfacb0cad8ceebd64e092bed2663427a091204f6b5f186f71a1bd5c534976aba8b313018197269d810f5bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84d13bb985883794cf8485891a1b2e69d5" + ] + }, + { + "name": "json_snapshot", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "meta": { + "v": 1, + "kind": "json", + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"json\",\"ts\":1700000000000}", + "content": "7b227468656d65223a226461726b222c226e223a5b312c322c335d7d", + "max_frame_data": 1048572, + "snapshot": true, + "force_gated": false, + "gated": false, + "frames": [ + "010ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a59e3d72b8892fa67302d87ec18c8cc4c994d83f57f0c339181f976623149c97875b81538c552b91afc51005991fa4c6d2fc758cbd5242ace3e9544d563cc01a765b6d92c1dbe6a539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab676ca576ae90cf2fe459e7372edf815e5d" + ] + }, + { + "name": "file_single_frame", + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "meta": { + "v": 1, + "kind": "file", + "name": "note.txt", + "mime": "text/plain", + "size": 11, + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"file\",\"name\":\"note.txt\",\"mime\":\"text/plain\",\"size\":11,\"ts\":1700000000000}", + "content": "68656c6c6f20776f726c64", + "max_frame_data": 1048572, + "snapshot": false, + "force_gated": false, + "gated": false, + "frames": [ + "0109d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763b22f261aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6212b1ea8d7f3c8c2f961ddb7afa2ed065dedad63bde32176671fb54a09756f0285f0a6842ba9dbd9ff235644cdec7241932d76cc918dc34254884668b65abb76aba2fcde3cd9f302268fd0eca75c5bd627b3e4e35ab40fbc0b895caafc2c5d3b3d3e44098064e17a4eb1895055bf9f7936c2a33021c7da0078528167b2c00f299814ef5bfdb6bbfe70d25731d200bcbb92af971eea0824667f896d6376e0aab89dd5a1228be6bccb9379dfdc79e7074d4d5f3c604b3c709348c35c92de47f30ec82073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922d46f5736a5fe59b5ee5cabff0aea533d6" + ] + }, + { + "name": "empty_text", + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "meta": { + "v": 1, + "kind": "text", + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"text\",\"ts\":1700000000000}", + "content": "", + "max_frame_data": 1048572, + "snapshot": false, + "force_gated": false, + "gated": false, + "frames": [ + "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cd15ddb32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303729dd599fbd2e97ae0835f507083af20f644fcfff456d16baa0c9990f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84cfc631583cbee2e31f9a66775ccc580f" + ] + }, + { + "name": "file_multi_frame", + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "meta": { + "v": 1, + "kind": "file", + "name": "a.bin", + "mime": "application/octet-stream", + "size": 2500, + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"file\",\"name\":\"a.bin\",\"mime\":\"application/octet-stream\",\"size\":2500,\"ts\":1700000000000}", + "content": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0", + "max_frame_data": 1000, + "snapshot": false, + "force_gated": false, + "gated": true, + "frames": [ + "010cd0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c7603f2f1b1aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6d53a7e6cd2968da60d352d331ad69932f8cc2da20cb690e657ebd55e1cf11f76c561b381deac1b391a4352f0c8ad22e1b37d76cd50a98773742c839de39f7e87afd708fbf99af4d2269ff0dce70c3ba6a72344539a64ef4d0a987d9bbd7d3c4abcafe5b841b500884ca3ab6217edfd0bb4500182e31538f37b41a254f1936c5a1b874ce83e65580a74c673059654d8cf163b33aa2edcc0937a9c485633b5cfcd184004974e33593d956ff9ea3fb1613bcbc99ad68dea96644fd47ba5991094794fb7d47e6f0ed2258bfb85084e2f8d99ae8508cc0cef67825b03adaa0264ae1fc6e78f0f70392f3f25382a2d6379804d1e27399ef2f33895583407371b868adfe6a453caec61fcfedb262f1fdd694f9424361076e0cff98e938e92face2a5165e6e258b15c3430e6c3dda3d7275af7838b3303bc15e60f24ca943da9c05f71beedbb2cba8da4182b6f214fa72922f3478a0a4776ae4a4543368733d81290e28ccdedae01b0e99c81c415d9be80a42e5164f52185b9a615c911baa202234172fbc889e95b5824bf5a9c2db7bb75888b973743ba9bf4d7578129efa973952400819baf67de98bf25586be6117c2120a3b69bd5a90d7fa6d8bfcd372428b7b805163d3babe2008b145489c97cb271aff0e93b7e818c49ff9d4aab3e28dad96d9d61b48b982e13384635f0897b3f136859d778b24be24e07c44057c2fdb995ed1832ada27883b1f33fc0865b10832a326b017d0e56e62ee123a496c265f7b06e87a054816e9689cdf01a65563903d0391e097966c8a4afc3cf2f1bd0b8d1147cd5ac5d0dd4e1ea351c285727fe77e48506b8c6648cf8d3077b9be450203c7c23153827e72a171442dee973bcbb6748911645026828354bf77411d776a0c1822a99f98982f8f9cc6eb4092326d3494fd00c79cbea840aaf4eb79c15a4c28676296f29c0db877a9cc41c852b6c271ae6d5a665612d055b2a7de72017e11ff8a38f5231a61b6af3197e6d62ca8f4448c210a7d70120971dac9047df12e0b39b9782fca7e821d421d46fb53e6a8a6be11badb14b491bed34468aa00537f16eaf15c0de2de52caff1cf3de24aa8611d0250cd24b6bd0258087df18f4ec68dbe157687cf06e64bae9a96d1b53083b8593eb00171455145d069675f77cb17834032d8d16cf1854c37dd4a4b1e1500395af68cb627111035529304e1f1100af2e14d9364974d1eb443ca289c4b3f15b33eb1b00dbf85982add3ac652a2a9fe6d327667dcc0b2be25f0a93a07066a9c9ad1d1c4bfb348a8c551cf6a2d4427201239afc8e664fa2d13920fb05c5c446c82206a9b1d98a2c9fbe12419ba4f29f02e0e64b5c3799f278e7a6ddc4375ba25878596cde0a948d9fdccccbd53ec3b1c3e3922dd672eb64712415b3dbfc6596618f5bb1", + "010cd0d1d2d3d4d5d6d7d8d9dadbdcdddedf00000001000004107b0a2503880449898c35bf4b6b61d6a83c6f2b7b69fa3959d251b2f147489afbca87d2dcb8ff41984e957d727f6ab8877afca1f4e85c920a49c810ae81cbdd4f09b68e6bef1f1a6031bde1c8ea22d55c08f5ad0c79721c9819228ef96e615e3e93f62f8660ca7a23ea345513d78e658a4720019ede136de301275ba04020abace73d3ca81bd5ce5d89dd12aee326a34e19af08797b02643beac8a75e2b42fd30f1d4ae7a064186b481e106fe3945d4efb13d9be12de5dd30fbaab2287d6ed1a22e5ab22a74117e4cafe0f2ff74bcc5069e1f708194fc68ab30ea71d2d9e0090764c33e379c09035fc99b8bacf2aea6b83742a8247abf60b96dfb5d058ea61e985b7a315b6d34ff7df8cdefe9ec2c0d41d87d2d6374baa4312e4487239268a939e336e1876136dd3570f5f4b4fb87cc10c7e48f6723abc8f2aafc254c8187ee8fcb011ee0d66977f4b12775fc53d9e85d52faf58addbb2e8308bc867017a8b8753f7efbb9d18fc9cdfb1e3a1e690a5950b442acbed581ea2e4ee9b60bc7b351f4098252c7b08e1c82ddfa13fc99ff6bf911b23d24425667ee244882c879c16f80471d089c03b8cd83384cfa80bce25846979003a6e22fa7973fe1c068e81a83f8f7d043579999c3689e9e39a129ddecfff8554b8f28a77eaeb4cdf5644a805ad606ca9d4ce3664da16760b2861d4053317b93ccc56760e106fc063e72469451bdb0533e004b034435ecf75fb7fb393364cc1a322cec84c5d5c3ccb44db5ba8091a9827da5093f15bc1fd1884b444107fca8f446a7bd5339b0c54681097e21a8311639c3634edaf3980545fec0117b455dca9c41e701b3797e9414427cbc285ef57c3878bca3c9de348959802a133f7def1d328f3a02faf04bd543d2c92e4c1dedce90ccff192e9dd4d79cd7a25d7c25c8710cf64633a4699adb404da9785918b8059096eef8fe7cc265b05d4bc378c0819936485a9668e02da347d6560627bcf863a87e9bf3b9e2c1dcdd9c201c9bdc34a1971a5b63de244577f3d7f68db7c6646d4f8285331c447312cfbd783c304d64c5e47fdcd139b2caf8763f5ff731b9a9bda1a597382334cac985af58c34fd6ae72cc363b68e77d21e00ca7ebe68d4b8c7aecd7a9bf7f84030fc1990468ed34efd7cf38a67c827fc81f7c769cdcce159bc3cbc7d6a93bb77f52fd44c0c818b571af8ce80cfeaa95f5cba38c0e52bca9425768fe97d043292cfaa1c0055aa3ac6a17438725d91f607ffd3ee42bb71b5b31b995ce6040ef41229a19ca0136058e3a50272e64f265537605e27401641b3d829bcc7be61ea73cd93628cea1a137e2a45b8c13c1f984f6cb74de1d9c3c0bb6a0aa6298e194212eb5fcd711428659ee8538085ba31106205bfd607003a6bcb7f23d011bd3be9517f01c9d8bce9ed97375ecdd67c989e4764da2a19e754a70ab84faec6d54c882142dab373895bd11fd47", + "010dd0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000200000410c081ed71508100e5f8a79f8bcb190dcc339f2d037ddf258f2bf88810429a9e179b3efe4cef0728ac327892677c0ea9147b61c62b519ea0f7c67ac164f78d5b976a8e5c4b659976cc8db4def6cb12f1c8dcdc31c04ee94e3549b59f77c32b2c2c092a305d02ab1060459a9e6320bfedf01533081607c53ac295666ee1db31639a16c2abca170ffa81d5607a8d8a76206f48fd00767c2f55ae3796da873a7d289a2d92b9ab423661b99c9fbd90a3d5425d269741f65d141c911ab5892977454086650ca7922389a4b1cfc59c356725e3fbebe06334598aceca3188a87bc5ec97faa2afc2e7eba100bd7a9d16418d3d74dff437ecbdfde35b0f211c3f3f5b1e76f325dd13d4610b4ef5bee4cf9c11e476bd56bd6330542b25d5f3ab1e42b9149dabeacd4817b8895e1914a4ec956af2744d42812884f6db58aed57ff494fddd161821406b1bf5f5dbefe5873aa8220a883a40efd4ca364f5ef86472f72188966d2c40e6b94a05dfdb71336b638394a3174b7659764a543e35625548e2d4dfb605568e784322cccf290a63861f05d9dc3da9b6f0666638dc7fd5533be6c29411279d50235b5160f2ad213d6bef0e5691161fd89b99a71263b1e14f3af618c71bb0a6484b934bd2269d4c66a778379618524155317773d28e1905ce98f4fd781a1247ca200cf496a5bab51ca80faa33240ec2cb745352b8205ffadaa342ff28f25afc1eddea59b8149432d2b2ed2b8efa055c36fe4ed8d57739c4ede580f7de9141848cf5a77399388fed59992d07d25ee801b78709709f024f06ba0992e04f44469fc2e25857ce04902df7b170bd46d956f1c02eb2825273f88839969847463fb1082a0b77a813120abd2179424a8b0ec2687ad743f7da19d0279ef54f50ddbd26f047b228dbb0a2ec600c19cfab3a5dd747a0508d8c3f0d92d3ebb39e728fa7c545cd6fb5530ccd0af5aa031f8c0120b9cce3e151d6745dfb22d7b912101a018d278cbfb71a5f70e465a4b905674f3d7c76bc14faacf69fed3e0ceb745d5b134e22082a910b0e6104985c09e5528ed15ddb7ccb4c5d83570255ebdd6a7962cd6be3d99065776448335278224db236a6eaee2669b949c4aa32d986d01897959d345d3021363b7d9e9e28448cb37f95a48f857a8cc410626080dc75b27dcc3fa51cf6a33c38f2cd88163c27cd213ad6bb3a3874c57c5635cf3c95afb5a5e1362a8413c4fd5bc31ce094cd668044961b4f4193f08c51d4dd3dc8fe9b183c8bb6abd0ec785047ce543e06a9b4032d48b2119f110d87bf25abfdcc269cd7e80f9ce9ff544fb5b8bf43fc669f892439ce943e4a6915a60d817babe4676c5772d533bea75cd06f2af7e8c5d8036ddd66d21d9c902068c0fc9bfb8c040eb996bea0fb53295a1f08406b22456fd12274466eed7d030a1e151543b35d860d7bc364b093169f7a8649dbebc59c2cd6804a9b77429009d1" + ] + }, + { + "name": "boundary_fits_exactly", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "meta": { + "v": 1, + "kind": "file", + "name": "b", + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"file\",\"name\":\"b\",\"ts\":1700000000000}", + "content": "05060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7", + "max_frame_data": 1000, + "snapshot": false, + "force_gated": false, + "gated": false, + "frames": [ + "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab675ad6bd81fb6f247b08a9250f920b4660" + ] + }, + { + "name": "boundary_one_byte_over", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "meta": { + "v": 1, + "kind": "file", + "name": "b", + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"file\",\"name\":\"b\",\"ts\":1700000000000}", + "content": "05060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8", + "max_frame_data": 1000, + "snapshot": false, + "force_gated": false, + "gated": true, + "frames": [ + "010ca0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab6752de780d3621900034a5e664d343c68d", + "010da0a1a2a3a4a5a6a7a8a9aaabacadaeaf0000000100000410245196c5b06e9ee040a4ea355eed05369c93d735b353cf38541dd5d97ada998192d18bf0fda42f7b34c882c20ce8d19ce78f41b7c10980aca57f3a7d4127fa54458a7b80c5338f2e24f005a39f6398030de5e9551433a4b7de68c129db616b669af17a198a48dede637b3b2c6206db1e6beaa85c9d9485158d682f215afa97463ebabe61468d844183cbe777027e93991fc18fca7fc437c275297c7886982c78831e53223dd5a2d2c4ed9762ca51cd354d712d43cd928c6788b59c9785f460df5ce0b78ff5041fecd2f82b7b3a24d672947343786aa4852d4890b49ded4b867c2abf77f7047fe4bf9b827047cc0975f474257f84fc34a2f31b67e25550e5fd7ca625906921db5c7896452375a49966493c4b1bc6a998689517f3d72da93c32b72582ee1c0bbdd2aa096df33b6c3654706ae36b3adda492e67fab39e1ddd256daea19c4ab2f0f4919a0e7652c1d5c562c9ef4359473a1d28d0398a62b9ec2214b69e5d04be6ba1fd14c39c6abc392a0649be1f8fb53092cdde7fb3f886b3cf2dc27a0a36d80d8d329d9d96e20ab5e4fbe729cfe44bd0e20812f00c4b7be2d721a8009752df9367b6d414bf108950d1430720c08dc0545fd3c8afd173a782f26f585e14f2b0852e944c8d067f5b72100a60de5ae67d70b4c4c154e4c8b24d2744ca61b4e33ae683221e052a4e1c1eb995285189bc22b82975187527f6c98b9c48950146305a051af760f1602e4b3364102e92dcabb6aa60c40866f8b13b521fa9d3c4e4b63e7c3d3da89613067405707f2b3c5ca109bfdea9eb95cc677db12a498fa820bdee23912970ca9fa9872d278cd3548742e1507d2ec548f9392af7455ba917b27767b9ec588754d31cb5e26d673e89c53d9a45eb5172a7237e5978d4d65a474756d4c399105f3b4ecb25aa79a31689afa4b9f7d619b8e0848474cf0238cb87e3ca971e40126e89301749967a54aab15473970aa276f88fd46fdbef3ea94a6afa06e65dc245767205731b4ed3bfea4faf459da896353618486e6ec5aefe704e1df754bbb859bc808542d060de9803e5b3fee3f4d0e61bce7976764ea7eb92bb1df83d8b7fab1882b20a9318da126841606e725f82de3b2b0c2425d39bf887b409984bbbf3cf40c3656892c8ba3de02122566ef0d4e8dfd43925ce9e5e3af8241caabdc45773a16d85759e72a41c91cd5ae325710e38c80b48fe09f461e55f9030b4d9bea2fb079ed019eb9393766a38d8e53672f33ec84886cfd462530943353e7cad298eb65d003cc7f8d03817820baa3d137b58943b51dd64143109c0de606c409ba196ad20242afc61b1518703a7c0da4057db816ec7b456b42a0224238a4ec4c2b14d3410227b6ee5a9e4a5aeb0c2f31b05a4dc8ec2837b637c6642b18c9884f8a9988e5da04ffb28a18979f661dd5f07f192f8041f8ef26916834fdba656768e01907f72debb570f34005a1" + ] + }, + { + "name": "small_forced_gated", + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "meta": { + "v": 1, + "kind": "file", + "name": "c.txt", + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"file\",\"name\":\"c.txt\",\"ts\":1700000000000}", + "content": "676174656420736d616c6c", + "max_frame_data": 1048572, + "snapshot": false, + "force_gated": true, + "gated": true, + "frames": [ + "010db0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cbf5dc432135fd5a443bcd7dd5f1c052c986f471ed8dc54163def2df225510750f0347cca3a1ed378747476e768a88d1ea79e3805c537380ac272032ebb9a214d65d7c1a5f50f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b8443f6f752e2e1fac46a20c4f005725253" + ] + } + ], + "item_notes": { + "multi_frame_first_frame_content_capacity": 897 + }, + "raw_frames": [ + { + "name": "raw_text", + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "index": 0, + "flags": 1, + "payload": "706c61696e20e6988ee69687", + "envelope": "0101a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000000c706c61696e20e6988ee69687" + }, + { + "name": "raw_gated_first", + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "index": 0, + "flags": 4, + "payload": "090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30", + "envelope": "0104b0b1b2b3b4b5b6b7b8b9babbbcbdbebf0000000000000028090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30" + }, + { + "name": "raw_gated_last", + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "index": 1, + "flags": 5, + "payload": "32333435363738393a3b", + "envelope": "0105b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000010000000a32333435363738393a3b" + } + ], + "labels": [ + { + "name": "我的 iPhone", + "iv": "101112131415161718191a1b", + "aad": "5744314c000102030405060708090a0b0c0d0e0fec071e0a0136c837c051cee6a7713edbaea6936712d1a3ca37e84fee3226e61d", + "blob": "101112131415161718191a1b7306ab8573e3985ba022ead810b1b640e52b997f6e36f3e8dbee19f4b4" + }, + { + "name": "Laptop", + "iv": "101112131415161718191a1b", + "aad": "5744314c000102030405060708090a0b0c0d0e0fec071e0a0136c837c051cee6a7713edbaea6936712d1a3ca37e84fee3226e61d", + "blob": "101112131415161718191a1bd9ef4a16861759258697050c1b43c77ff7978da62776" + }, + { + "name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "iv": "101112131415161718191a1b", + "aad": "5744314c000102030405060708090a0b0c0d0e0fec071e0a0136c837c051cee6a7713edbaea6936712d1a3ca37e84fee3226e61d", + "blob": "101112131415161718191a1bedf6421a911fc04a8832fdce0dd1ee5c851253e8c68e4de06d230239c5dffa032b5e88c192581725a2a598a3ae7512a8f8b85c594a0661826cc7792852a0b50ab4a5fcc33e45e0e9d08b8109b85b713449313a034bf17304ab842a1a088ac8cb39d9f10435f057d133d06c50bcb2d838b3f6f52f3f767c06ae44b1332528e353585c2e32b6f6e2e14055272b9404f20e4eec6331ebb4e07730973e0bcafec2fc771262b99d907a1272c4e67de0f996d21ec504d7024b1f8ec1d8b6d55f24144042acf81ae02fd3e506fa720c7a83816870871a4f5a86bba408663709bae39a862762691090fd72e6eb6b622bb96a31909281119a" + } + ], + "links": { + "valid": [ + { + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8&a=QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8", + "id": "AAECAwQFBgcICQoLDA0ODw", + "mode": "enc", + "role": "owner", + "k": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "a": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f" + }, + { + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8", + "id": "AAECAwQFBgcICQoLDA0ODw", + "mode": "enc", + "role": "member", + "k": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f" + }, + { + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw#a=QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8&w=YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8", + "id": "AAECAwQFBgcICQoLDA0ODw", + "mode": "raw", + "role": "owner", + "a": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "w": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f" + }, + { + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw#w=YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8", + "id": "AAECAwQFBgcICQoLDA0ODw", + "mode": "raw", + "role": "member", + "w": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f" + }, + { + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw", + "id": "AAECAwQFBgcICQoLDA0ODw", + "mode": "raw", + "role": "member" + }, + { + "url": "https://app.example.test/base/c/AAECAwQFBgcICQoLDA0ODw/#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8&unknown=1", + "id": "AAECAwQFBgcICQoLDA0ODw", + "mode": "enc", + "role": "member", + "k": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f" + } + ], + "invalid": [ + { + "name": "no_channel_path", + "url": "https://app.example.test/x/AAECAwQFBgcICQoLDA0ODw#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8" + }, + { + "name": "short_id", + "url": "https://app.example.test/c/abc#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8" + }, + { + "name": "non_canonical_id", + "url": "https://app.example.test/c/AAAAAAAAAAAAAAAAAAAAAB#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8" + }, + { + "name": "short_key", + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw#k=ICEiIyQlJicoKSorLC0uLw" + }, + { + "name": "key_in_query_not_fragment", + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw?k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8" + }, + { + "name": "enc_with_raw_write_token", + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8&w=YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8" + }, + { + "name": "padding_in_key", + "url": "https://app.example.test/c/AAECAwQFBgcICQoLDA0ODw#k=ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=" + } + ] + }, + "log_records": [ + { + "name": "frame", + "hex": "0000000000000001010109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "parsed": { + "kind": "frame", + "seq": 1, + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553" + } + }, + { + "name": "tombstone_download_limit", + "hex": "000000000000000702b0b1b2b3b4b5b6b7b8b9babbbcbdbebf03", + "parsed": { + "kind": "tombstone", + "seq": 7, + "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "reason": 3 + } + }, + { + "name": "tombstone_superseded", + "hex": "000000000000000802a0a1a2a3a4a5a6a7a8a9aaabacadaeaf04", + "parsed": { + "kind": "tombstone", + "seq": 8, + "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "reason": 4 + } + }, + { + "name": "announce", + "hex": "000000000000000903d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0c0000018bcfe5687b", + "parsed": { + "kind": "announce", + "seq": 9, + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "flags": 12, + "created_at": 1700000000123 + } + } + ], + "log_invalid": [ + { + "name": "truncated_header", + "hex": "0000000000000001" + }, + { + "name": "unknown_kind", + "hex": "0000000000000001090000000000000000" + }, + { + "name": "truncated_tombstone", + "hex": "000000000000000202a0a1a2a3a4a5a6a7a8a9aaabacadaeaf" + }, + { + "name": "frame_length_mismatch", + "hex": "0000000000000001010109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f245" + } + ], + "negative": [ + { + "name": "flip_last_payload_byte", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24552", + "expect": "auth_failed" + }, + { + "name": "flip_first_payload_byte", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041004ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "auth_failed" + }, + { + "name": "flip_tag_byte", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab6734a51368bae07810a9cd8b5f49f24553", + "expect": "auth_failed" + }, + { + "name": "clear_final_flag", + "envelope": "0108a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "auth_failed" + }, + { + "name": "add_gated_flag", + "envelope": "010da0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "auth_failed" + }, + { + "name": "add_snapshot_flag", + "envelope": "010ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "auth_failed" + }, + { + "name": "change_frame_index", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000010000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "auth_failed" + }, + { + "name": "change_item_id", + "envelope": "0109a1a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "auth_failed" + }, + { + "name": "wrong_channel", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "channel_id": "707172737475767778797a7b7c7d7e7f", + "expect": "auth_failed" + }, + { + "name": "truncated_payload", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f245", + "expect": "bad_envelope" + }, + { + "name": "trailing_byte", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f2455300", + "expect": "bad_envelope" + }, + { + "name": "header_only", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf0000000000000410", + "expect": "bad_envelope" + }, + { + "name": "too_short", + "envelope": "0109a0a1a2a3a4a5a6a7", + "expect": "bad_envelope" + }, + { + "name": "bad_version", + "envelope": "0209a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "bad_envelope" + }, + { + "name": "reserved_flag_bit", + "envelope": "0119a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "bad_envelope" + }, + { + "name": "payload_len_mismatch", + "envelope": "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041105ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "expect": "bad_envelope" + }, + { + "name": "injected_plaintext_frame", + "envelope": "0101a0a1a2a3a4a5a6a7a8a9aaabacadaeaf00000000000000056576696c21", + "expect": "wrong_mode" + } + ] +}