ZeroLink 产品需求文档 (PRD) v3.0

April 8, 2026 · View on GitHub

语言: English | 中文

ZeroLink 产品需求文档 (PRD) v3.0

Security-First / Low-Friction / DO-Atomic / WebAuthn Admin / TOFU-Safe / Padded Ciphertext

v3.0 变更摘要(相对 v2.5):将安全模式统一为两个用户入口:Quick Share(密码模式)和 Secure Share(Passkey 模式)。


1. 产品概述

ZeroLink 是一款零知识秘密分享工具:无账号、服务器不持有明文与私钥。内容端到端加密,只有接收方本地私钥可解密。发送方拥有管理权,可更新/销毁密文,但无法解密内容。

v3.0 的产品目标:

在不牺牲"极简使用体验"的前提下,把真实世界的高概率攻击面(抢占锁定、passkey 同步、密文长度侧信道、恶意下发 JS)降到可接受甚至可审计的级别。


2. 安全目标与威胁模型

2.1 安全目标(必须满足)

  1. 服务器零知识:服务器/DO 不存明文与任何私钥
  2. 端到端保密:明文仅在接收方本地出现
  3. 更新/销毁不可伪造:仅管理者可授权写入/销毁
  4. 抗重放/乱序/并发覆盖:version 单调 + nonce 去重 + DO 串行
  5. 最小元数据泄露:公共接口不可推断状态;receiver_pub 不向未授权者暴露
  6. 前端完整性可验证:CSP/Signed Manifest/零第三方脚本/可复现构建
  7. 管理权私钥不可导出:WebAuthn 私钥驻留系统/硬件
  8. TOFU 抢占锁定风险可控:预加载爬虫无法先于真实接收方 lock
  9. 密文长度泄露显著降低:默认 padding 到固定块边界

2.2 明确边界(必须写清)

  • 客户端被恶意扩展/木马控制:仍可能在用户确认窗口内滥用一次操作;无法静默导出管理私钥长期控制
  • Web 场景无法彻底解决"恶意服务器下发 JS"这一终极信任问题:v2.5 提供 自托管/可验证发布链作为可选"上限方案"

3. 核心改动概览(相对 v2.4)

3.1 新增:Lock Secret(URL Fragment)防抢占锁定

  • create 时生成 lock_secret(32 bytes 随机),只放在分享链接的 URL fragment(例如 /s/UUID#k=...)
  • fragment 不会被 HTTP 请求携带,预加载机器人即使访问 /s/UUID 也拿不到 lock_secret,因此无法 lock
  • lock 需要 lock_secret 参与挑战响应(Lock Challenge)

3.2 新增:Padding(块对齐)降低密文长度泄露

  • 明文加密前统一 padding 到 4KB/8KB(默认 4KB)倍数
  • padding 结构包含:原文长度 + 随机填充
  • cipher_bundle 仍为 AES-GCM 密文,但长度变成离散桶

3.3 收紧:接收方 KDF 强制 Argon2id

  • 默认且必须:Argon2id(参数目标耗时 250–500ms)
  • PBKDF2 未实现

3.4 两档用户入口(v3.0 简化)

创建时可选两档:

  • Quick Share:密码模式,本地生成 ECDSA 管理密钥(Argon2id 包裹),无需 passkey,4KB padding
  • Secure Share:Passkey 模式,UV=required / RK=discouraged,8KB padding

3.5 新增:Self-Hosting / Verifiable Releases

  • 官方 Cloudflare 版保持默认
  • 提供 Docker Compose 一键自托管(当前打包为 Caddy + Go API + PostgreSQL + Garage,对已发布前端契约提供协议等价实现)
  • 发布链:签名 Manifest + 可复现构建

4. 产品模式与安全档位(对外清晰)

创建时可选 securityProfile(v3.0 两档):

1. Quick Share(快速分享)

  • 管理权:本地生成 ECDSA P-256 keypair,由用户密码 Argon2id 包裹后编码在管理链接的 URL fragment 中(不存 IndexedDB)
  • WebAuthn:不需要
  • 接收方:Argon2id 强制
  • Padding:4KB 块
  • adminModepassword(内部协议字段)
  • 适合:跨设备/跨浏览器、无 passkey 支持环境,或希望使用密码管理器的用户

2. Secure Share(安全分享)

  • 管理权:WebAuthn passkey(设备或平台),UV=required,RK=discouraged
  • WebAuthn:必须,不可降级
  • 接收方:Argon2id 强制
  • Padding:8KB 块(更高隐私)
  • adminModewebauthn(内部协议字段)
  • 适合:最高安全需求,passkey 可用的环境

5. 用户流程(v2.5 UX 版)

5.1 创建(Sender)

  1. 选择模式:Quick Share(密码)或 Secure Share(Passkey)
  2. Quick Share 流程:输入密码 → 本地生成 ECDSA 密钥对 → Argon2id 包裹 → Create Finish(adminMode=password)
  3. Secure Share 流程:Create Begin → WebAuthn 注册(UV=required,RK=discouraged)→ Create Finish
  4. 页面显示两条链接:
    • 分享链接(接收方):/s/:uuid#k=<lock_secret_b64url>[&af=<sender_auth_fpr>]
    • 管理链接(发送方):/m/:uuid#wk=<wrapped_priv>(Quick Share)或 /m/:uuid(Secure Share)

UI 强制提示:分享链接必须完整复制(包括 # 后部分),否则接收方无法上锁

5.2 接收方上锁(Receiver:防呆)

  • 打开分享链接后,页面展示极简动画(3 帧):
    1. "你输入的密码短语只在你这里"
    2. "你的密码短语生成你专属的解密密钥——发送方不会知道"
    3. "上锁后,只有你能打开内容"
  • 输入密码 → 生成 RSA keypair → Argon2id 包裹私钥存本地
  • lock 请求必须携带 lock challenge 响应(见协议)
  • 上锁成功后显示 安全码(Safety Code)
    • Emoji 序列(8 个 emoji)
    • 颜色块(4×4 色块)
    • "高级"里可展开 raw hex 指纹

5.3 发送方投递(Sender:软化核对)

  • 管理页显示相同的 Safety Code(emoji/颜色块),文案是:
    • "请快速核对对方发来的安全码是否一致(推荐通过电话/另一个聊天工具核对)"
  • 默认 UI 不出现"指纹/哈希/公钥"等词;高级模式才显示
  • 点击投递:走 compound_begin/commit,一次系统确认完成写入

5.4 WebAuthn 不可用(降级 UX)

当 navigator.credentials 不可用或调用失败:

  • 页面自动检测 WebAuthn 支持状态
  • Quick Share:始终可用(不依赖 WebAuthn),当 WebAuthn 不可用时默认选中 Quick Share
  • Secure Share:显示"此环境不支持 Passkey"警告,按钮置灰不可点击
  • UI 显示提示:"Secure Share 需要 WebAuthn 支持,请换浏览器/设备,或使用 Quick Share"

6. 关键安全问题的 v2.5 解决方案

6.1 TOFU 抢占锁定(预加载爬虫先 lock)

v2.5 的硬修复:Lock Secret + Lock Challenge

  • 攻击者/爬虫即使先访问 /s/:uuid,也无法 lock,因为它没有 fragment 中的 lock_secret
  • lock 时 DO 下发一次性 challenge,接收方需提供 lock_proof = SHA256("GL-lock"||uuid||lock_challenge_id||lock_challenge||lock_key)
  • DO 验证 lock_proof 后才接受 receiver_pub

同时 UX 层仍建议:

  • 安全码核对建议走带外通道(电话/另一个 IM),但不再是唯一防线

6.2 密文长度泄露

v2.5 默认 padding:明文在加密前被填充到固定块倍数,降低长度推断精度。

6.3 Passkey 同步边界(v3.0 简化)

  • Quick Share:不使用 WebAuthn,无 passkey 同步问题
  • Secure Share:使用 WebAuthn(UV=required, RK=discouraged),允许平台同步 passkey;如浏览器提供 backupState/backupEligibility,可检测并提示,但不强制拒绝

6.4 恶意服务器下发 JS

v2.5 给出三层应对:

  1. 可验证发布链(Signed Manifest + 可复现构建):提升"被篡改可被发现"的概率
  2. 自托管(当前):Docker Compose 打包已提供协议等价实现,彻底把信任根交给用户

7. 密码学与数据格式(v2.5)

7.1 内容加密(不变 + padding)

  • AES-256-GCM 加密正文(密文包仍是 cipher_bundle)
  • RSA-OAEP-256 封装 AES key(enc_content_key)

Padding 方案(强制,默认开启)

定义 padded_plaintext 格式:

  • len:uint32(原文长度,big-endian)
  • data:原文 bytes
  • pad:随机 bytes,填充到 ceil((4 + len)/PAD_BLOCK)*PAD_BLOCK
  • 默认 PAD_BLOCK = 4096(可配置 8192)

最终加密 padded_plaintext,接收方解密后按 len 截取原文。

对超大内容(例如 >1MB)可允许关闭 padding 或采用更大块(例如 64KB),但默认仍启用。

7.2 Receiver 私钥包裹(强制 Argon2id)

  • Argon2id 参数采用目标耗时策略(250–500ms)
  • 参数写入本地包头:salt, m, t, p, version
  • PBKDF2 未实现

7.3 Safety Code(软化指纹核对)

从 receiver_pub_fpr = SHA256(SPKI(receiver_pub)) 计算:

  • Emoji Safety Code:取每个 hash byte 的低 nibble(4 bits)映射到 16 项 emoji 调色板(固定表,稳定输出)

  • Color Blocks:取 hash nibble 映射到固定调色板 显示规则:

  • 默认展示 Emoji 或 Color(可切换)

  • Advanced 展示:短指纹(前 6/后 6)+ 完整 hex(折叠)


8. 状态机(与 v2.4 类似,新增 lock challenge)

状态与转移保持 v2.4,但 lock 需要 lock_begin/lock_commit 的挑战流程(见 API)。

状态集合:Waiting, Locked, Delivered, Deleted, Expired

允许转移

  • Waiting -> Locked(lock_commit 成功)
  • Locked -> Delivered(compound_commit update)
  • Delivered -> Delivered(compound_commit update)
  • Waiting|Locked|Delivered -> Deleted(delete_commit)
  • Waiting|Locked|Delivered -> Expired(expire)

禁止转移

  • 非 Waiting 状态重复 lock_commit
  • Deleted/Expired 后任何写操作
  • 未通过 lock_begin 的 lock_commit(challenge 必须匹配且一次性)

9. Quick Share(密码模式)协议定义

Quick Share 是 v3.0 中替代"兼容模式(Compatibility Mode)"的正式用户入口,不再是降级选项:

  • 管理权:本地生成 ECDSA P-256 私钥(Admin-Priv),用用户密码 Argon2id 包裹后编码在管理链接的 URL fragment 中(不存 IndexedDB)
  • 更新/删除授权:ECDSA 签名 payload 模式(DO 仍负责 version/nonce 原子性)
  • 协议字段adminMode: "password"(内部)
  • Padding:4KB 块(相比 Secure Share 的 8KB,降低流量但稍低隐私)
  • UI:不标注"较低安全",而是作为独立的有效分享模式展示

注意:Quick Share 安全性取决于用户密码强度。UI 通过密码强度指示器引导用户选择足够强度的密码。


10. API(v3.0 当前)

通用要求:

  • 所有响应:Cache-Control: no-store
  • 所有敏感写操作走 DO 串行
  • 错误响应恒定形状 {ok: false, code: string}
  • adminMode 取值:"webauthn" | "password" | "softkey"softkeypassword 的 legacy alias,行为等价)

10.1 GET /api/public/:uuid

Response:

{
  "ok": true,
  "state": "waiting|locked|delivered|deleted|expired",
  "adminMode": "webauthn|password|softkey",
  "securityProfile": "quick|secure",
  "receiverPubFpr": "hex..."
}
  • receiverPubFpr 仅在接收方已上锁后返回
  • 频道被物理删除或过期后返回 404 NOT_FOUND

10.2 创建

POST /api/create_begin/:uuid

Request:

{
  "uuid": "string(21)",
  "timestamp": 1730000000000,
  "securityProfile": "quick|secure"
}

Response:

{
  "ok": true,
  "creationOptions": { "...": "WebAuthn PublicKeyCredentialCreationOptions" }
}
  • 创建 waiting 频道并持久化 securityProfile
  • Quick Share 前端不使用返回的 creationOptions
  • lock_secret 由前端本地生成,服务端只持久化派生的 lockKeyB64u

POST /api/create_finish/:uuid

WebAuthn 模式(adminMode: "webauthn"):

{
  "adminMode": "webauthn",
  "uuid": "string(21)",
  "attestation": { "...": "WebAuthn AttestationJSON" },
  "lockKeyB64u": "base64url(SHA256('GL-lockkey'||uuid||lock_secret))",
  "timestamp": 1730000000000
}

Quick Share 模式(adminMode: "password"):

{
  "adminMode": "password",
  "uuid": "string(21)",
  "softkeyPubJwk": { "...": "ECDSA P-256 公钥 JWK" },
  "lockKeyB64u": "base64url(SHA256('GL-lockkey'||uuid||lock_secret))",
  "timestamp": 1730000000000
}

Response:

{
  "ok": true,
  "shareUrl": "https://...",
  "manageUrl": "https://..."
}

10.3 上锁

POST /api/lock_begin/:uuid

Response:

{
  "ok": true,
  "lockChallenge": {
    "id": "base64url",
    "challenge": "base64url(32 bytes)",
    "expiresAt": 1730000000000
  }
}

challenge TTL 60s,一次性消费。

POST /api/lock_commit/:uuid

Request:

{
  "uuid": "string(21)",
  "lockChallengeId": "base64url",
  "lockProof": "hex(SHA256('GL-lock'||uuid||challengeId||challenge||lock_key))",
  "receiverPubJwk": { "...": "RSA-OAEP-256 公钥 JWK" },
  "receiverPubFpr": "hex(SHA256(SPKI(receiver_pub)))",
  "lockedAt": 1730000000000
}

DO 校验 challenge 未过期未消费、lock_proof 正确,写入 receiver_pub、fpr、status=Locked。

10.4 管理操作(投递 / 更新 / 删除)

所有管理操作(投递、更新内容、删除)共用两阶段流程:先 compound_begin 获取 challenge,再提交 compound_commitdelete_commit

POST /api/manage/compound_begin/:uuid

Request:

{ "uuid": "string(21)" }

Response:

{
  "ok": true,
  "challenge": {
    "id": "base64url",
    "seed": "base64url",
    "expiresAt": 1730000000000
  },
  "allowCredentials": [ "...可选,WebAuthn allow list..." ],
  "receiverPubFpr": "hex...",
  "receiverPubJwk": { "...": "RSA-OAEP-256 公钥 JWK" },
  "currentVersion": 0,
  "securityProfile": "quick|secure",
  "adminMode": "webauthn|password|softkey"
}

POST /api/manage/compound_commit/:uuid

WebAuthn 模式:

{
  "uuid": "string(21)",
  "assertion": { "...": "WebAuthn AssertionJSON" },
  "intentHash": "hex(SHA256(canonical(intent)))",
  "intent": {
    "op": "update",
    "uuid": "string(21)",
    "version": 1,
    "timestamp": 1730000000000,
    "nonce": "base64url(24 bytes)",
    "receiverPubFpr": "hex...",
    "payloadKind": "text|file",
    "cipherBundle": { "...": "见下方" },
    "expireAt": 1730000000000
  }
}

Quick Share 模式(额外字段 adminMode + softkeySignature,不含 assertion):

{
  "adminMode": "password|softkey",
  "uuid": "string(21)",
  "softkeySignature": "hex(ECDSA P-256 签名)",
  "intentHash": "hex...",
  "intent": { "...": "同上" }
}

cipherBundle 结构(inline 文本载荷):

{
  "ciphertext": "base64url",
  "iv": "base64url(12 bytes)",
  "aad": "base64url",
  "encContentKey": "base64url",
  "ciphertextHash": "hex(SHA256(ciphertext))",
  "padBlock": 4096
}

新的文件载荷使用 fileRef(见 10.6)替代 cipherBundlepayloadKind"file"

POST /api/delete_commit/:uuid

删除复用 compound_begin 的 challenge,intent.op 为 "delete"

{
  "uuid": "string(21)",
  "assertion": { "...": "WebAuthn AssertionJSON" },
  "intentHash": "hex...",
  "intent": {
    "op": "delete",
    "uuid": "string(21)",
    "version": 1,
    "timestamp": 1730000000000,
    "nonce": "base64url(24 bytes)"
  }
}

Quick Share 模式同样用 softkeySignature 替代 assertion

10.5 解密获取

GET /api/decrypt_fetch/:uuid

接收方上锁成功并输入口令后调用,获取密文包。

Response:

{
  "ok": true,
  "cipherBundle": { "...": "inline 载荷,与 10.4 相同结构" },
  "fileRef": { "...": "用于已交付文件载荷的 multipart 元数据" },
  "receiverPubFpr": "hex...",
  "cipherVersion": 1,
  "deliveryAuth": { "...": "投递者身份证明,可选" },
  "deliveredAt": 1730000000000
}

cipherBundlefileRef 互斥,必有且仅有一个。

10.6 文件 API(对象存储 / multipart)

GET /api/file_policy

返回当前部署的文件上传策略,前端在选择文件后调用以确认是否支持上传及大小限制。

Response:

{
  "ok": true,
  "policy": {
    "maxFileBytes": 104857600,
    "multipartThresholdBytes": 5242880,
    "chunkSizeBytes": 5242880,
    "maxChunks": 20,
    "multipartSupported": true
  }
}

POST /api/file/initiate

Request:

{
  "channelUuid": "string(21)",
  "chunkCount": 3,
  "totalCiphertextBytes": 15728640
}

Response:

{
  "ok": true,
  "uploadId": "base64url",
  "chunks": [
    { "index": 0, "uploadUrl": "https://r2-presigned-url..." },
    { "index": 1, "uploadUrl": "https://..." },
    { "index": 2, "uploadUrl": "https://..." }
  ]
}

前端用各 uploadUrl 直接 PUT 加密 chunk 到对象存储。

POST /api/file/complete

Request:

{
  "uploadId": "base64url",
  "baseIv": "base64url(12 bytes)",
  "encContentKey": "base64url",
  "chunkSizeBytes": 5242880,
  "totalPlaintextBytes": 15000000,
  "totalCiphertextBytes": 15728640,
  "chunks": [
    { "index": 0, "etag": "abc123", "ciphertextBytes": 5242896, "ciphertextHash": "hex..." },
    { "index": 1, "etag": "def456", "ciphertextBytes": 5242896, "ciphertextHash": "hex..." },
    { "index": 2, "etag": "ghi789", "ciphertextBytes": 5242848, "ciphertextHash": "hex..." }
  ]
}

Response:

{
  "ok": true,
  "fileRef": { "...": "MultipartFileRef,将作为 intent 的 fileRef 字段提交" }
}

GET /api/file/fetch/:uuid

接收方解密时调用,获取各分片的预签名下载 URL。

Response:

{
  "ok": true,
  "chunks": [
    { "index": 0, "downloadUrl": "https://r2-presigned-url..." },
    { "index": 1, "downloadUrl": "https://..." }
  ]
}

10.7 WebSocket

GET /api/ws/:uuid(Upgrade: websocket)

频道实时状态订阅。连接成功后服务端推送状态变更事件(如接收方上锁、发送方投递),前端用于无需轮询地感知频道变化。


11. WebAuthn 验证(v3.0,继承 v2.4 字节级规范)

  • origin、rpIdHash、UV/UP、challenge 精确匹配、COSE ES256 验签
  • Secure Share:
    • userVerification="required"
    • residentKey="discouraged"
    • attestation="none"

12. 前端完整性与"可验证发布链"(解决恶意下发 JS 的上限方案)

12.1 Signed Manifest(推荐)

  • 发布时生成 manifest.json,包含:
    • 版本号
    • 每个静态资源的 SHA-256
    • 构建时间、commit hash
  • 用项目的 离线签名私钥(Ed25519) 对 manifest 签名,发布 manifest.sig
  • App 在运行时展示 manifest hash(高级用户可核对)

注意:这无法阻止攻击者直接篡改 index.html 关闭校验,但能让"下载 + 校验工具"变得可行。

12.2 自托管(Self-Hosting)(当前)

  • 提供 Docker Compose:
    • 前端静态文件
    • API 服务(协议等价实现:挑战/nonce/version/lockkey/padding/webauthn 验证)
    • DB(Postgres/MySQL)或 SQLite + 事务锁
  • 自托管版必须通过同样的协议测试向量(canonical、challenge、nonce)

13. UI/UX 规范(落实产品经理建议)

13.1 指纹核对的柔化呈现

  • 默认:Emoji Safety Code(例如 8 个 emoji)
  • 次选:Color Blocks(例如 4×4)
  • Advanced:短指纹 + 完整 hex(折叠)

文案原则:

  • 不出现"指纹/哈希/公钥"术语(高级模式除外)
  • 强烈建议"带外核对",但不制造焦虑(用轻提示)

13.2 接收方防呆动画与文案

  • 3 帧以内动画 + 1 句强提示:
    • "你的密码短语生成了你的解密密钥——发送方不会知道"
  • 密码强度提示(但不强迫过强,避免劝退;Secure Share 作为更高安全选项单独提供)

13.3 WebAuthn 不可用时的引导

  • 失败时给出明确原因分类(不泄露敏感信息):
    • "浏览器不支持"
    • "当前页面不安全(非 https / 非同源)"
    • "系统未启用生物识别/安全密钥"
  • Quick Share:保持可用,并说明这是密码模式
  • Secure Share:阻断并给"换设备/换浏览器"建议

14. 测试向量与验收(v3.0)

必须新增测试:

  1. TOFU 抢占锁定:没有 fragment 的访问无法完成 lock_commit(lock_proof 验证失败)
  2. lock_challenge 重放:同 challenge_id 再次 lock_commit 必失败
  3. padding:不同长度明文映射到相同桶长度密文(至少 4KB 桶)
  4. Argon2id 强制:接收方私钥包裹必须为 Argon2id;Quick Share 管理密钥也必须使用 Argon2id 包裹
  5. Secure Share Policy:secure 必须要求 UV=required,且注册使用 non-discoverable credential(residentKey="discouraged"

15. 协议图(Mermaid)

sequenceDiagram
  autonumber
  participant S as Sender (Browser)
  participant R as Receiver (Browser)
  participant W as Worker
  participant D as DO(uuid)

  rect rgb(240,240,240)
  Note over S,D: Create (creationOptions + local lock_secret)
  S->>W: POST /api/create_begin/{uuid} (securityProfile)
  W->>D: forward
  D-->>W: creationOptions
  W-->>S: creationOptions
  S->>S: generate local lock_secret
  S->>S: lock_key = sha256("GL-lockkey"||uuid||lock_secret)
  S->>S: build share URL: /s/{uuid}#k=lock_secret[&af=sender_auth_fpr]
  S->>S: navigator.credentials.create(...) or generate local ECDSA admin key
  S->>S: build manage URL: /m/{uuid}#wk=wrapped_priv [Quick Share] or /m/{uuid} [Secure Share]
  S->>W: POST /api/create_finish/{uuid} (attestation or softkeyPubJwk + lockKeyB64u)
  W->>D: forward
  D->>D: store admin credential + lock_key + status=Waiting
  D-->>W: ok
  W-->>S: ok
  end

  rect rgb(240,240,240)
  Note over R,D: Lock begin/commit (TOFU-safe)
  R->>W: POST /api/lock_begin/{uuid}
  W->>D: forward
  D-->>W: lock_challenge_id + lock_challenge
  W-->>R: lock_challenge_id + lock_challenge
  R->>R: read lock_secret from URL fragment
  R->>R: lock_key = sha256("GL-lockkey"||uuid||lock_secret)
  R->>R: lock_proof = sha256("GL-lock"||uuid||cid||chal||lock_key)
  R->>W: POST /api/lock_commit/{uuid} (receiver_pub + fpr + lock_proof)
  W->>D: forward
  D->>D: verify lock_proof using stored lock_key, then store receiver_pub/fpr, status=Locked
  D-->>W: ok
  W-->>R: ok + SafetyCode shown locally
  end

  rect rgb(240,240,240)
  Note over S,D: Deliver (compound one-confirm)
  S->>W: POST /api/manage/compound_begin/{uuid}
  W->>D: forward
  D-->>W: challenge_id/seed + receiver_pub/fpr + last_version (if locked)
  W-->>S: begin
  S->>S: pad plaintext (4KB buckets) + hybrid encrypt + intent_hash
  S->>S: expected_challenge = sha256("GL-delivery-proof"||uuid||intent_hash)
  S->>S: Secure Share: navigator.credentials.get(...) / Quick Share: ECDSA sign with Admin-Priv
  S->>W: POST /api/manage/compound_commit/{uuid} (assertion or softkeySignature + update)
  W->>D: forward
  D->>D: verify intent_hash + delivery_proof challenge + admin signature (WebAuthn or ECDSA) + version/nonce
  D->>D: write cipher_bundle + status=Delivered + last_version++
  D-->>W: ok
  W-->>S: ok
  end

  rect rgb(240,240,240)
  Note over S,D: Delete (reuses compound_begin)
  S->>W: POST /api/manage/compound_begin/{uuid}
  W->>D: forward
  D-->>W: challenge_id/seed + last_version
  W-->>S: begin
  S->>S: intent_hash + expected_challenge = sha256("GLv2.5"||uuid||cid||intent_hash||seed)
  S->>S: admin sign (WebAuthn get or ECDSA sign)
  S->>W: POST /api/delete_commit/{uuid}
  W->>D: forward
  D->>D: verify intent_hash + nonce-bound challenge + admin signature
  D->>D: delete record
  D-->>W: ok
  W-->>S: ok
  end

  rect rgb(240,240,240)
  Note over R,D: Decrypt (receiver reads delivered secret)
  R->>W: GET /api/public/{uuid}
  W->>D: forward
  D-->>W: state=delivered
  W-->>R: state=delivered
  R->>W: GET /api/decrypt_fetch/{uuid}
  W->>D: forward
  D-->>W: cipherBundle + receiverPubFpr + cipherVersion + deliveryAuth
  W-->>R: cipher payload
  R->>R: load wrappedPrivateKey from IndexedDB
  R->>R: Argon2id(passphrase) → unwrap receiver_priv
  R->>R: RSA-OAEP unwrap AES content key
  R->>R: AES-GCM decrypt + remove padding → plaintext
  R->>R: verify deliveryAuth proof (if anchored channel)
  end

附录 A:参数表与常量(强制)

  • UUID_LENGTH = 21(nanoid)
  • TIMESTAMP_SKEW_MS = 120000(±120s)
  • NONCE_BYTES = 24(base64url)
  • NONCE_TTL_MS = 600000(10min)
  • CHALLENGE_BYTES = 32
  • CHALLENGE_TTL_MS = 60000(60s)
  • LOCK_SECRET_BYTES = 32(base64url,存在 URL fragment)
  • LOCK_KEY_BYTES = 32(server 存储,sha256 输出)
  • PAD_BLOCK_DEFAULT = 4096(可配置 8192)
  • PAD_BLOCK_MAX = 65536(上限)
  • MAX_PLAINTEXT_BYTES = 2MB(文本载荷与 legacy 兼容用的 inline 明文上限;新的文件上传需要对象存储支持)
  • WebAuthn:默认 alg = -7 (ES256)、UV required(Strict/HardwareOnly)

附录 B:Canonical(Ghost Canon v1)规范与测试向量(强制)

B1. 规则

  • object key 递归按 Unicode code point 升序
  • array 保持顺序
  • number 必须整数、十进制、无科学计数
  • 输出 minified JSON,无空格
  • UTF-8 bytes

B2. 测试向量(update / delete)

B2.1 update(无 sig)

输入对象(概念):

{
  "op": "update",
  "uuid": "u",
  "version": 1,
  "timestamp": 1730000000000,
  "nonce": "n",
  "receiver_pub_fpr": "f",
  "cipher_bundle": {
    "ciphertext": "ct",
    "iv": "iv",
    "aad": "aad",
    "enc_content_key": "ek",
    "ciphertext_hash": "h"
  },
  "expire_at": null,
  "pad_block": 4096
}

canonical 输出必须为:

{"cipher_bundle":{"aad":"aad","ciphertext":"ct","ciphertext_hash":"h","enc_content_key":"ek","iv":"iv"},"expire_at":null,"nonce":"n","op":"update","pad_block":4096,"receiver_pub_fpr":"f","timestamp":1730000000000,"uuid":"u","version":1}

B2.2 delete

输入对象(概念):

{
  "op": "delete",
  "uuid": "u",
  "version": 2,
  "timestamp": 1730000000000,
  "nonce": "n"
}

canonical 输出必须为:

{"nonce":"n","op":"delete","timestamp":1730000000000,"uuid":"u","version":2}

附录 C:TOFU 抢占锁定修复(Lock Secret / Lock Key / Lock Proof)精确定义

C1. Create 时生成与存储(关键)

  • 前端本地生成 lock_secret:随机 32 bytes,并写入分享链接 fragment:
    share_url = /s/<uuid>#k=<b64url(lock_secret)>
    
  • 前端计算 lock_key:
    lock_key = SHA256( UTF8("GL-lockkey") || UTF8(uuid) || lock_secret )
    
  • 前端在 create_finish 时回传 lock_key_b64u
  • 服务端存储 lock_key(base64url 或 hex,必须固定一种;推荐 base64url)

注意:lock_secret 永不入日志、永不以明文存储。

C2. Lock 两阶段流程

  1. lock_begin:DO 签发 {lock_challenge_id, lock_challenge}(随机 32 bytes,TTL 60s,一次性)
  2. lock_commit:客户端提交 receiver_pub + fpr + lock_proof

C3. lock_proof 计算(客户端)

  • 客户端从 fragment 拿到 lock_secret,本地算 lock_key(同 C1)
  • 再算:
    lock_proof = SHA256( UTF8("GL-lock") || UTF8(uuid) || b64url_decode(lock_challenge_id) || b64url_decode(lock_challenge) || lock_key )
    
  • lock_commit 只提交 lock_proof(hex 或 base64url;推荐 hex 小写)

C4. DO 验证(服务端)

  • 从 DO 存储取 lock_key
  • 用相同拼接重算 expected lock_proof
  • 一致才允许写入 receiver_pub

C5. 安全性质

  • 预加载爬虫没有 fragment → 得不到 lock_secret → 得不到 lock_key → 无法造 lock_proof
  • 即使拿到 lock_proof,也只能配合一次性 lock_challenge 使用,重放失败(challenge consumed)

附录 D:Lock API Schema(v2.5)

D1. POST /api/lock_begin/:uuid

Response:

{
  "ok": true,
  "uuid": "string(21)",
  "lock_challenge_id": "base64url(16-32)",
  "lock_challenge": "base64url(32)",
  "expires_at": 1730000000000
}

D2. POST /api/lock_commit/:uuid

Request:

{
  "uuid": "string(21)",
  "lock_challenge_id": "base64url",
  "lock_proof": "hex(lowercase)",
  "receiver_pub_jwk": {
    "kty": "RSA",
    "alg": "RSA-OAEP-256",
    "n": "...",
    "e": "...",
    "ext": true,
    "key_ops": ["encrypt"]
  },
  "receiver_pub_fpr": "hex(lowercase)",
  "locked_at": 1730000000000
}

Response:

{
  "ok": true
}

错误语义(粗粒度):

  • 401:challenge 过期/不存在
  • 403:lock_proof 不匹配 / 已非 Waiting
  • 409:challenge 已消费(重放)

附录 E:Padding 规范(精确字节格式 + 注意事项)

E1. padded_plaintext 格式(bytes)

  • orig_len: uint32 big-endian(4 bytes)
  • orig_data: orig_len bytes
  • pad_rand: 随机 bytes,长度使总长成为 PAD_BLOCK 的倍数
  • 总长度:ceil((4+orig_len)/PAD_BLOCK) * PAD_BLOCK

E2. 生成规则(客户端)

  • PAD_BLOCK 默认 4096,可在 update payload 中带 pad_block(用于审计一致性;不建议公开展示)
  • pad_rand 必须为加密安全随机数
  • 文本载荷继续在 MAX_PLAINTEXT_BYTES 约束下走 inline 路径。新的文件载荷不再用这个尺寸阈值决定传输方式:它们必须先上传加密 chunk 并提交 fileRef,若部署未声明对象存储能力则直接拒绝

E3. 解码规则(接收方)

  • 解密得到 padded_plaintext
  • 读取前 4 bytes 得到 orig_len
  • 截取后续 orig_len bytes 作为明文
  • 忽略剩余 pad_rand

E4. 与 AES-GCM 的关系

  • 仍然使用 AES-GCM;padding 不引入 padding oracle
  • AAD 继续绑定 uuid/version/fpr,防替换与上下文混淆

附录 F:Cipher Bundle 结构与长度泄露桶(bucket)策略

CipherBundle(base64url):

  • enc_content_key(RSA-OAEP 输出,长度固定约 256 bytes)
  • ciphertext(长度≈padded_plaintext_len + GCM tag)
  • iv(12 bytes)
  • aad(建议 base64url 的 AAD bytes)
  • ciphertext_hash(SHA-256 hex)

桶策略:

  • 默认 PAD_BLOCK=4096,泄露粒度为 4KB 桶
  • 高安全档位可提高到 8KB/16KB(更隐私但更浪费流量)

附录 G:WebAuthn Policy(v3.0)规范

G1. Quick Share(quick)

  • 不使用 WebAuthn,完全密码模式
  • adminMode = "password"

G2. Secure Share(secure)

  • userVerification = "required"(强制)
  • residentKey = "discouraged"(使用 non-discoverable credential)
  • attestation = "none"
  • 适合平台 passkey 和硬件密钥

附录 H:WebAuthn 验证字节级步骤(延续 v2.4,补充对 lock/profile 的约束点)

commit(compound/delete)校验顺序必须包含:

  1. 校验 credentialId == stored cred_id
  2. clientDataJSON:
    • type=="webauthn.get"
    • origin strict match
    • challenge strict match expected_challenge
  3. authenticatorData:
    • rpIdHash == SHA256(rpId)
    • flags:UP=1;UV 按 policy
  4. 验签:
    • signedData = authenticatorData || SHA256(clientDataJSON)
    • COSE ES256 → P-256 公钥
  5. signCount 策略:记录异常不强阻断(避免误伤同步)

附录 I:Quick Share(password/softkey)协议规范

Quick Share 在 v3.0 中是正式用户入口(不再是降级模式)。

I1. 管理密钥生成

  • 前端生成 ECDSA P-256 keypair
  • Admin-Priv 用 Argon2id 包裹后编码在管理链接的 URL fragment 中(不存 IndexedDB;密码由用户提供)
  • 服务器存 Admin-Pub(JWK)+ adminMode="password"

I2. 写入授权

  • update/delete 请求基于 ECDSA sig(Ghost Canon v1 canonical payload)
  • DO 仍负责 version/nonce/challenge 串行一致性

I3. UI 标注

  • 显示"Quick Share (Password)"徽章(而非"兼容模式")
  • 不强制二次确认风险(密码强度指示器引导用户)
  • 密码强度较低时,UI 给出建议但不强制阻断

附录 J:错误码、响应恒定形状与抗枚举策略

响应体统一:

{
  "ok": false
}

建议状态码:

  • 400:格式错误
  • 401:timestamp 窗口失败 / challenge 过期(统一)
  • 403:权限/状态不允许 / WebAuthn 失败 / lock_proof 失败(统一)
  • 404:uuid 不存在(可将 Deleted/Expired 也返回 404,进一步降泄露)
  • 409:nonce 重放 / version 冲突 / challenge 已消费(统一)

公共接口 /api/public/:uuid:

  • 返回当前 stateadminModesecurityProfile 和可选 receiverPubFpr
  • 物理删除或过期后返回 404 NOT_FOUND

附录 K:安全码(Safety Code)视觉化规范(emoji / color)

K1. 输入

  • receiver_pub_fpr(32 bytes sha256)

K2. Emoji 方案(推荐默认)

  • 取 fpr bytes 分成 8 组,每组取低 nibble(4 bits)→ 映射到 16 项 emoji 调色板(固定表)
  • 输出 8 个 emoji,跨端稳定一致
  • UI 展示为:🐳 🍀 🧩 ...(例子)

K3. Color Blocks

  • 取 fpr 的 32 bytes → 每个 nibble 映射到 16 色固定调色板
  • 输出 4×4 色块(固定布局),跨端稳定

K4. Advanced 展示

  • 短指纹:前 6 bytes + 后 6 bytes(hex)
  • 完整 hex 折叠显示(用户主动展开)