chore(repo): initialize WebDrop monorepo

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:22 +08:00
commit 53768a195d
39 files changed
+9063

No files matched your search

+23
View File
@@ -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
+49
View File
@@ -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
```
+191
View File
@@ -0,0 +1,191 @@
# API v1
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
## 1. 鉴权与通用头
| 头 | 说明 |
|---|---|
| `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 |
| `Authorization` | `Bearer <base64url(令牌)>` |
| `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.<role>, wd.auth.<base64url(令牌)>, wd.ep.<base64url(S)>`,服务端回选 `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": "<channel_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": "<sha256(T_read)>",
"write_hash": "<sha256(T_write)>",
"owner_hash": "<sha256(T_owner)>"
}
```
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=<seq>&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=<seq>`
WebSocket 订阅(降级链的第一级)。服务端向下推送:
- 二进制消息:一条日志记录。
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"seats"}` 席位变更。
上传仍走 HTTP `POST`,便于幂等重试。
### `GET /api/v1/channels/{id}/events?from=<seq>`
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=<index>`
拉取条目的帧(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=<index>` | read | 已封存 | 下载文件,`from=0` 计一次下载;**首次读取同样登记访客** |
### `POST /api/v1/shares`
```json
{
"id": "<share_id>",
"read_hash": "<sha256(T_read)>",
"owner_hash": "<sha256(T_owner)>",
"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": "<b64url>" }
```
按 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>"}`,不回显任何用户提交的内容。
| 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 | 限流 |
+123
View File
@@ -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 运行时的机器上运行。
+173
View File
@@ -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` 重建 |
+860
View File
@@ -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.<role>, wd.auth.<base64url(令牌)>, wd.ep.<base64url(S)>`,
服务端回选 `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 }
+320
View File
@@ -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=<K>&a=<A>` | `/c/{id}#a=<A>[&w=<W>]` |
| 成员链接 | `/c/{id}#k=<K>` | `/c/{id}[#w=<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 /<site_key>/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": "<b64url>"}`,需要所有者令牌。服务端校验:
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)、多快照槽。
+87
View File
@@ -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 在日志中截断或哈希。
- 错误信息不回显令牌和任何用户提交的字节。
+361
View File
@@ -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 时顶栏只留在线点,条目小字允许换行 |
| 相机 | 「拍照」用 `<input capture="environment">`,iOS 与 Android 都会直接拉起相机;扫码在 Android Chrome 用 `BarcodeDetector`,iOS Safari 不支持,提示用系统相机扫码后打开链接,或粘贴链接。相机、剪贴板读取都要求 https(`localhost` 除外) |
| 剪贴板 | iOS 读取时会弹出系统「粘贴」确认,Android 会要求权限;被拒绝时退回「写」 |
| 图片格式 | iPhone 的 HEIC 照片在非 Safari 浏览器里无法显示,预览失败时提示直接下载 |
| 下载 | iOS 上 `a[download]` 配合 blob 会弹出存储菜单;大文件要走 Service Worker 流式下载(架构 §6,阶段 4) |
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+177
View File
@@ -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 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
+1518
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -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"
}
}
+175
View File
@@ -0,0 +1,175 @@
/**
* 字节工具。只用标准 JS,不依赖 Node 或 DOM 之外的任何东西。
*
* WebCrypto 的类型(TS 5.7+)要求 BufferSource 由 ArrayBuffer 支撑,
* 所以库内部统一使用 Bytes = Uint8Array<ArrayBuffer>。
*/
export type Bytes = Uint8Array<ArrayBuffer>;
/** 确保由 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);
}
+49
View File
@@ -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';
+64
View File
@@ -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);
}
}
+144
View File
@@ -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<Bytes> {
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<Bytes> {
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);
}
+55
View File
@@ -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';
+42
View File
@@ -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<Bytes> {
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<RawChannelSecrets> {
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<Bytes> => hkdfBytes(a, channelId, Labels.owner);
export const deriveRawItemKey = (kEnc: Uint8Array, itemId: Uint8Array): Promise<Bytes> => hkdfBytes(kEnc, itemId, Labels.item);
+340
View File
@@ -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<string, unknown> = { 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<string, unknown>;
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<Uint8Array> | AsyncIterable<Uint8Array>;
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<string, string>;
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */
frames: AsyncGenerator<EncodedFrame, void, void>;
}
export async function collectFrames(plan: ItemPlan): Promise<EncodedFrame[]> {
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<Uint8Array, void, void> {
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<Uint8Array>;
}
interface Chunk {
data: Bytes;
last: boolean;
}
/**
* 把任意切分的字节流重新切成 [firstCap, cap, cap, ...] 大小的块,并通过向前看一个块知道哪一块是最后一块。
* 内容恰好填满某一块时不会多出一个空块;内容为空时产出一个空的最后一块。
*/
async function* rechunk(source: ContentSource, firstCap: number, cap: number): AsyncGenerator<Chunk, void, void> {
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<Bytes>,
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<string, string> = {};
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
const snapshot = opts.snapshot === true;
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
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<EncodedFrame> => ({
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<string, string> = {};
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<EncodedFrame, void, void> {
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() };
}
+71
View File
@@ -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<CryptoKey> {
return subtle().importKey('raw', ikm as Bytes, 'HKDF', false, ['deriveBits', 'deriveKey']);
}
async function hkdfBits(key: CryptoKey, salt: Uint8Array, label: string): Promise<Bytes> {
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<CryptoKey> {
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<ChannelKeys> {
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<Bytes> {
return hkdfBits(await importHkdf(a), channelId, Labels.owner);
}
/** 每个条目独立的 AES-GCM 密钥,所以同一条目内 frame_index 唯一即保证 IV 唯一。 */
export function deriveItemKey(encRoot: CryptoKey, itemId: Uint8Array): Promise<CryptoKey> {
return hkdfAesKey(encRoot, itemId, Labels.item);
}
export async function sha256(data: Uint8Array): Promise<Bytes> {
return new Uint8Array(await subtle().digest('SHA-256', data as Bytes));
}
+92
View File
@@ -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=<K>&a=<A> 成员 /c/{id}#k=<K>
* raw 所有者 /c/{id}#a=<A>[&w=<W>] 成员 /c/{id}[#w=<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;
}
+86
View File
@@ -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;
}
+210
View File
@@ -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<CryptoKey>;
}
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<number, FrameState>;
finalIndex: number | undefined;
maxIndex: number;
meta: ItemMeta | undefined;
}
/**
* 条目装配器:喂入日志记录或条目的帧,吐出解密、校验后的事件。
*
* 校验失败**不抛异常**,而是产生 `rejected` 事件并丢弃该条目的缓冲:
* 一个坏帧(或恶意服务端)不应让整个订阅挂掉。直接调用 openFrame 才会抛 IntegrityError。
*/
export class ItemReader {
readonly #ctx: ReaderContext;
readonly #open = new Map<string, ItemState>();
/** 已完成的条目。回放时重复到达的帧直接忽略,不会让条目「复活」。 */
readonly #done = new Set<string>();
constructor(ctx: ReaderContext) {
this.#ctx = ctx;
}
/** 喂入一条日志记录(WebSocket 的一条二进制消息,或 parseLogRecords 的一项)。 */
async push(input: Uint8Array | LogRecord): Promise<ItemEvent[]> {
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<ItemEvent[]> {
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<ItemEvent[]> {
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<ReceivedItem, 'content'>): string {
const text = tryDecodeUtf8(item.content);
if (text === null) throw new TypeError('条目内容不是合法的 UTF-8');
return text;
}
export function itemJson(item: Pick<ReceivedItem, 'content'>): unknown {
return JSON.parse(itemText(item));
}
+401
View File
@@ -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: <Cap 的 token>`
* (token 由前端用 Cap widget 求解得到,见 GET /info 的 captcha.api_endpoint)。
*/
request: { body: CreateChannelBody; headers: Record<string, string> };
}
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<ParsedLink, 'id' | 'idBytes' | 'mode' | 'role'>;
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<string, Promise<CryptoKey>>();
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<ChannelSession> {
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<CreatedChannel> {
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<string, string> {
if (!this.can(role)) throw new CapabilityError(role);
const headers: Record<string, string> = {
'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<CryptoKey> {
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> = {}): 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<ItemMeta> & { 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<Uint8Array>, meta: FileMetaOverrides = {}, opts: PlanItemOptions = {}): ItemPlan {
const extra: Partial<ItemMeta> = {};
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<Uint8Array> | AsyncIterable<Uint8Array>,
opts: DecryptStreamOptions = {},
): { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> } {
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<Bytes> {
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<string> {
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;
+199
View File
@@ -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<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> {
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<Uint8Array>;
}
/**
* 把「封装首尾相接」的字节流(GET /items/{id}/frames 的响应体)切成一个个完整封装。
* 连接中途断开会在末尾留下半个封装:这是正常的网络情况,不是篡改,所以直接丢弃,
* 调用方根据 decryptItemStream 报告的 nextIndex 续传即可。
*/
export async function* splitEnvelopes(
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
): AsyncGenerator<Bytes, void, void> {
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<Uint8Array> | AsyncIterable<Uint8Array>,
opts: DecryptStreamOptions = {},
): AsyncGenerator<Bytes, ItemDecodeResult, void> {
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<Uint8Array> | AsyncIterable<Uint8Array>,
opts: DecryptStreamOptions = {},
): { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> } {
const gen = decryptItemFrames(ctx, source, opts);
let resolve!: (r: ItemDecodeResult) => void;
let reject!: (e: unknown) => void;
const result = new Promise<ItemDecodeResult>((res, rej) => {
resolve = res;
reject = rej;
});
result.catch(() => {}); // 错误通过流本身传播,避免未处理的 rejection
const content = new ReadableStream<Uint8Array>({
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 };
}
+435
View File
@@ -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<string>((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<typeof ChannelSession.create>[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<ItemEvent[]>((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<void>((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<Uint8Array>({ 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();
});
});
+90
View File
@@ -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<ChannelSession> {
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<EncodedFrame[]> {
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<Uint8Array> {
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<Uint8Array>): AsyncGenerator<Uint8Array> {
for (const c of chunks) yield c;
}
export async function readAll(stream: ReadableStream<Uint8Array>): Promise<Bytes> {
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;
}
+238
View File
@@ -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<number>();
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<Uint8Array>({
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);
});
}
});
+249
View File
@@ -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']);
});
});
+469
View File
@@ -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<string, string>): 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<Uint8Array>({
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<Uint8Array>({
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');
});
});
+289
View File
@@ -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<typeof decryptItemFrames>) {
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<Uint8Array>({
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<Uint8Array>(), {}, { 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);
});
});
+303
View File
@@ -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);
}
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [],
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+20
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
testTimeout: 30_000,
},
});
+24
View File
@@ -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。
+407
View File
@@ -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} 个负例)`);
}
+667
View File
@@ -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": "000003fd02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfe0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Line truncated
"envelope": "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000101012c89f065ff04d342ff0967b9afeba3b7c6e1eb35f3263a1af6122069b57835d6d2252b2382b966b1ad872292d7ff174b1921ea69c3b01c0313f02cb355f73f2c17a2d57f8e38ddc4944f1be84d877681ac6d53653f22f48f50c9ea756fb67a1c55ac1cfa7bea72d3336f75447035b9cb636ab98264d5f6df82ba9e94dd5a513b52ca4fb33a5610968a84554da4c0059eadc610bff64f35486363d05706f959381d6730ee4aef736d07749da5d9d19e729b7f3730d74653bd66667d3603b4e26185f7532f5903ad3eb66d6180bc633f2c07c024db6ecd636cf1c76404bc4e24060c9dd55fc9e5a02535ec7099d7f4d2644333fb979893501e5df43fbfd875c5093e2c4bedb290328ab926fd4d38f599c51b70be58b690fc2985a8e12048f574b6a7a008ef51f8fc37745742f27820664bd49dc3fb3ecba469d4d228f0f71abf4be427f73dea2d607f2a6268da12ea8d7baabf991bf071210749ed3c1f95719e8f2911b17615a37054a85045ce6138da5e9323491c89766367ee82c71d8d239d5853f0bac523ae9802b93cc05b9bf3e584cf4e37ed3a26f91277b42abd8b125ff90a5b1ee4a4cb5789ef1783c124d3be31b96960321a0f6b585d9ca1097dde64b9c431e005d13b60a02545893b9179148aafcf9ebf615db096849a67d81e292bd0c241d0e8b1335011ca4e4c0946a16eca764cd667330ce2dfb7ba750c913e37623c29afc0f44219c76a85aecba166f63a72265eac462a42132234f2399a48d81030b857a0d1d1842c5e8a8b2cf9e20c99236672b62f4385a8e59e1ac25a3cc000355b1bd97c06e51ce1ef3c010dab2d1de1746b26fa76f09303cc152f044cb0f387fc1a91dbc35e275454608e27e6e5033dfc0771fbb6b4e65ff3d3cacde6c88af66c7682a1f143f6867da44bff35dc41765537ab3aa76f62d18a4f31657c20ec5b06e79a9fba7521dd10c2476fdabf50878766adaac5bb51080c24497e11e5ac4df3b9a1c458b8adec75873e3832a8f54451b626cb98af1d3f67c9c6ec6740f406a11f6ff1ed6adf4f860c34a01d928514c449523fb7a1db70f39b9acbb8a2810cb2b21cfe1258fa074c1cc55b83d7b98663e51c59a4f0f98d876d2872b916ffef0199eb59eeaa503f707860df68f9ce1b6fc8bc41be4a5c7a2e2b717e1d410c70b8ff6c46cd911f8f5fa93b47399b4db4e78e2d5a1e4f399eb3815de1a299b1c2f1ea23d5843df0f5579d77f94f5363a3391a3a319a8a0bc54933f69c6608cbbaebc6848681a90ca27623d868a3d90eb8939373723261069c2a19af389de25c44d1544d32e801d6e229306caf7e80fbbc44cfac1fc99fbad8b1789932548d95685cb7f02d9238285a2b3aa748655556f429907765f46b987156eedaa8c3233b061c75612ee70e69479609aa47083883d5989ae508b82a6801f8bd1a9ecc7792af888cc37801540e6e8c640859baa526decd95252a5ac551ce5fbb8582cb58d3d2abce8a2f6e1b600d991fd66245f01b175e32d01b53157de7e3601a00f56a36bc1af07fb5f0f67c7535ae0a0a08cd5606e11039e23536f43f4376e4dd7734a39907c66bcac324d70c57477bb9107174df807e66122e5012a52f44a0f2ce22d3bf8b63d6c4133008c12953e7da5f728269e025acca241394ec2f4549d90d5b8b2aac5ea7158839988a97438dd87567f453298ed45a0c7a902dd9345aecf275fc71aa60e75cef32bae89cb758574587cba234bb7ed3aa180460b165db57f0400e149db6b8d764cd8ba0b325dbe4739796a4549b5455998cbebf431e1085556710af0c506fd737f82712c658f9618ead5ec4ad49d6019bb43a8b6ab2054f9ebb28fa7dc4d661bc094b920f8fa675b1d2b535cfe21af71c3844f9d7007bf1cbd3b1630495156d54d3ddd058d2324c627193b7157429eb331d5421589f13ea13c707c092669f3bd10ee0ae786fe11093b505919a6a2566af323c61bb8cd66e2cfbca01b812b8e5e47ede6572b8f2ad2d012a14d753c5294ac4c7b6aa73ff7c409e58e498fb1b0dfeb752634c3d95176a3fc33d752d6f957701aab0a88b66a212fca2dab9127799c413822f1f98e750c0bb47ee8e1d74b073983982379c4a9e54749d88339c85305df77f6e92c17acaa8b36342f679f6afe0ea20e8dfcc7744f7fb2481d8c2b67d16e65bb40014224be66e227eabe13a9182ceed731c05c9e5184d6142a8cd0bb9c4c30e200990a483173b23b67a80e580bfa2f4d99ea05f719566f96f2c2e63047a853c1d8bbf5a31fb7124db5181e8329e01e186d7c983f4a8664683e31b401bfe0485d7bffa37687b54ef0ff661d3d6899071ee686cd193a9e761da1018683eb4f01e0e5b34eb33ac2fe2d441845649ff8f490a19a0b304ba3ddca2bb6a1d2cdac3eb1409ca85a9a46ddf28052c171e2742aded6f42c2889fbc1569378a81cd2e36c18aeb7b2d0b6f9ef3bd0e0f731cecf7bc4d9ea0ca717264b23d512dc44a96cd0030035f360ef6751a4c9f80bd9177aad8e7cd92550b9cf1d40a5e271d80cbd9957e6c8be47f491bfed9f6cf98fccb5f7ebbb5dc521c5221df2b5b1b927c3cbc61cf5859e063ae06daf00f37ad4f6da651e99f07695bac25f460d2486ea81c39aa8dd8bfc3b945a2acd0048c8d51638c3b2da66238fb56b529e2f0afbfeaafda49df4c3612a5c00c41cf2e96af7231d12c2bdca7503ebad27730cacdcdfe79425a5e9db0969c3fa362e859d124292a2569eb3c355264c09240d0cf01df23d7f97295d8dfe729ede2e5876ab30876c6560ae2f422f743678d4401995db5b80dae50048fbb6ccb4996cc987c958f34c3ba328f935483077f154534b713edef030665dd40b7926cb8ca0d9139aa44f602d51b68dd6723e702a7d4e83ab7e2cb2e46659d27ddc08f407bd0696dc925c79572a970a6ee5ca85880f509a291f519b60440401b287d15d5c8cb017b783305f090491cac8a6cb42b60619f1155202e23825becfff76861fb2a21ff353b85cc815b72a75b9692ac9d1fe7821b361e663de8f12eb072fdecee754995990e3a9a88143c86632d8c6e79a09ddcd9a6bc397957f3b4788e1c8153045686add0eadbce16d6e282139390aa24d3ca1cae9c7b105f9a3ad0a87895ff7fa180cf870e4f91f0d7d48c462aa4f6db623a0140189f87e9b9b4028e63c9ed0463d13a1eef7b6b606cf00103aaea2b6f5702664fc2c21c883e91171b175b56081e5f743bd8b9d1bf22721aac476549a1653609edcbae76d797f3e2c9cebb8a25acc25c10180c22c4f7306fbc8dd86f1e6055b0bf0b1cf45027a2cb387372d7cac89211173f1fd3fcf8c5f93242d145d1c2e479a596d159234ba987bb95482aeb4e4c46d3248cc28cfd5b1e4d57ed14fdfac4938734ecdefc5c8115e6d370ce5f8c6d93a7fcd9315a54a1f6dcc7af075a7d04cd3507a919759d72c733f5e32c90d3308adb775ac98e56e975cc8512cbbcbLine truncated
},
{
"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": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fa000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6eLine truncated
"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"
}
]
}