安全与加密边界
Hashee 的安全模型不是”我们承诺保密”,而是”我们没法不保密”—— 后端架构上只能看到密文,不能解密、不能验签、不能 forge 用户消息。 这一页讲 SDK 内部和后端契约的分界线、各连接模式的安全差异、以及作为 Agent 开发者 能做什么、不能做什么、丢密钥后能不能恢复。
技术真源:
- E2EE 规范 (E2EE 6 层栈唯一真源)
.claude/rules/security-e2ee.md(强制规则)
6 层加密栈速读
每条 Hashee 消息(H2H / H2A / H2G)穿过 6 层:
| 层 | 名 | 算法 | 作用 |
|---|---|---|---|
| L1 | Content | AES-256-GCM + AAD | 内容加密;CEK 一次性 |
| L2 | Wrap | X25519 ECDH + HKDF + AES-256-GCM | per-recipient 包裹 CEK |
| L3 | Sign | Ed25519 | 签发件人,不可否认 |
| L4 | Ratchet | Signal Double Ratchet | 前向保密 + 后向保密(仅 WS 模式) |
| L5 | X3DH | Signal X3DH | 异步初始密钥协商(仅 WS 模式) |
| L6 | Grant Ledger | Postgres + append-only trigger | 数据授权审计 |
群聊(H2G)走简化栈:L1 内容 + L2 wrap group key + L6 grant,不走 L3/L4/L5 (用 group key version rotation 替代 forward secrecy)。
后端能看到的 vs 看不到的
| 字段 | 后端可见? | 备注 |
|---|---|---|
conversation_id, sender_id, recipient_id | ✓ 元数据 | 用于路由与计费 |
created_at, client_message_id | ✓ | 幂等去重用 |
encrypted_payload (整段 wire envelope) | ✓ 但是密文 | 后端只看字节 |
attachment_id 引用 R2 对象 | ✓ R2 对象 metadata | 内容也加密 |
agent_id, user_id 公钥 | ✓ | 路由必需 |
| 消息明文 | ✗ | 永不解密 |
| CEK | ✗ | 永不见 unwrap 后形态 |
| 私钥(X25519 / Ed25519) | ✗ | 永不持有 |
| Ed25519 签名是否合法 | ✗ | 不验,留给客户端 |
| Ratchet state | ✗ | 仅在客户端 |
私钥永不落后端
Agent 持有两对私钥(X25519 wrap + Ed25519 sign),完全不发给 Hashee 后端:
HasheeAgent.init() 启动 │ ├─ 检查 onKeyGenerated 回调里持久化的私钥 ├─ 没有 → SDK 内部生成(crypto.subtle) ├─ 计算公钥 └─ POST /agents/:id/keys/register body: { x25519_public, ed25519_public } ✗ 不含任何私钥字节 ✗后端只持有:
agents.x25519_public_key(raw 32 bytes)agents.ed25519_public_key(raw 32 bytes)
如果你 Agent 进程被攻破:攻击者拿到的私钥可以解密未来的消息(直到你旋转 / 删除 Agent), 但拿不到历史消息(如果走 WS 模式,Ratchet 提供前向保密——历史消息 CEK 已 forget)。
Webhook vs WebSocket 安全差异
| 维度 | WebSocket | Webhook |
|---|---|---|
| L1 内容加密 | ✓ AES-256-GCM | ✓ AES-256-GCM |
| L2 per-recipient wrap | ✓ X25519 | ✓ X25519 |
| L3 Ed25519 签名 | ✓ | ✓ |
| L4 Double Ratchet | ✓ | ✗ |
| L5 X3DH | ✓ | ✗ |
| L6 Grant Ledger | ✓ | ✓ |
| HMAC 签名 webhook 请求 | n/a | ✓ HMAC-SHA256 + ±5min ts |
Webhook 跳过 L4/L5 是因为无状态环境无法维护 ratchet state(每次冷启动会丢)。 对绝大多数应用够用——L1-L3 已经达到银行级”per-message 内容加密 + 签发件人不可否认”。
但要注意:L4 提供的 forward secrecy 在 Webhook 模式没有。如果你 Agent 私钥泄露, 攻击者能解密所有历史 inbound 消息(只要他能拿到密文)。
高敏感场景(医疗、金融、隐私顾问)推荐 WS 模式。
CEK 生命周期
Agent 进程内(永不离开): ↓1. generateCek() → 32 字节随机2. 用 CEK 加密 plaintext (Layer 1)3. 用收件人 X25519 公钥 wrap CEK (Layer 2)4. 用 Agent Ed25519 私钥签 envelope (Layer 3)5. 把 wire envelope (含 wraps + signed content + signature) 序列化6. 通过 WS / HTTP 发出去7. CEK 立即 zero-fill 然后丢弃 (永不存盘)CEK 永远不写盘、不发后端、不落 log。SDK 在 send 完成后立即 cek.fill(0)。
AAD 绑定(防协议混淆)
Layer 1 加密的 Additional Authenticated Data:
content_aad = conversation_id (16B) || epoch_id (8B BE) || "hashee-envelope-v1.1" (20B) = 44 字节固定结构意义:
- 同一 CEK + 同一密文,换 conversation_id 就解不出来 → 防”密文搬运”。
- 换 epoch_id 也解不出来 → 支持密钥轮换的 forward secrecy。
- wire 字面量绑死 → 防协议混淆攻击(v1.1 密文不能被解析为 v1)。
群聊有自己的 AAD:
group_aad = conversation_id (16B) || group_key_version (8B BE) || "hashee-group-v1.2" (20B)key version 增加时旧 group key 无法解新消息 → 移除被踢成员后必须轮换。
SDK 用户能做 / 不能做
能做:
- ✓ 生成自己的 X25519 + Ed25519 keypair → 注册到 Agent
- ✓ 接收入站消息(SDK 自动解密)→ 应用业务逻辑 → 发送回复
- ✓ 上传文件(SDK 自动加密)到 R2
- ✓ 发送 artifact、artifact_update、tool_call
- ✓ 声明 Capability Manifest + 处理 tool_response
- ✓ 流式输出(delta 模式)
- ✓ 在 WebSocket / Webhook / Polling 模式间切换
- ✓ 用 CLI 进行本地测试
- ✓ 显式调用底层 L1-L3 API(非标准场景,如自定义 transport)
不能做:
- ✗ 改写或包装用户消息(违反盲管道不变量;客户端会拒)
- ✗ 直接调用 Ratchet API(Webhook agent 没有 ratchet state;WS agent 由 SDK 内部管理)
- ✗ 绕过 aadContext 强制(v0.2.0 会 throw
AadContextRequiredError) - ✗ 伪造签名或修改已签名的消息(Ed25519 验签会失败)
- ✗ 访问其他 Agent 的私钥或对话
- ✗ 在后端存储 private keys(CEK / identity key 永不离开客户端)
- ✗ 用 SDK 从后端读取用户消息明文(后端本身不持有明文)
- ✗ 让 Agent 替用户在群里”宣告”内容(mention / artifact 必须从用户触发的对话出发)
私钥丢失能不能恢复
不能——盲管道前提下后端没法做托管恢复。
如果两份备份(热 + 冷)都丢了:
- 在 Hashee app 让系统 Agent 删除现有 Agent。
- 用户对你 Agent 的 H2A 关系会被同步取消并收到通知(cascade delete with notification)。
- 重新创建 Agent → 拿到新
agent_id+agent_token+ 新私钥。 - 历史消息无法恢复(前向保密的代价)。
为了避免这个,请按 Agent 身份与密钥 里的”备份与恢复”做好两层备份。
Ratchet 状态(只 WS 模式)
L4 Double Ratchet 的状态:
- DH ratchet keypair:每条消息一对新 ephemeral X25519
- Sending chain key:派生发出消息的 CEK
- Receiving chain key:派生解入站消息的 CEK
- Skipped message keys:留着解乱序到达的旧消息
SDK 把这些状态存在内存(WS agent 长驻)。重启 → 状态丢 → 仍能解新消息(X3DH 重做), 但乱序到达的旧消息可能解不出来。这是为什么我们推荐你做”持久化 ratchet state” (V1.1 路线图)。
V1 当前 acceptable trade-off:少量乱序未解消息 → 客户端会重发 + 显示”无法解密” banner 让用户重新触发。
哪些行为会被 CI / hook 阻断
.claude/rules/security-e2ee.md §2 有完整禁字识别表。简言之你不应该:
- import 任何
legacy-crypto、encryptH2H、decryptH2H、x25519-aes256gcm-v1等老 API - 在 backend 代码(apps/api / apps/queue-consumer / apps/cron)里
import解密函数 - 在 mobile facade(
apps/mobile/src/{hooks,screens,stores,components})外 import L1-L5 原语
如果你 fork 了 SDK 或扩展自定义功能,CI 会在这些边界报错。
检查清单(Agent 上线前)
- 私钥放在 Secret Manager / Vault / 加密文件,不在 git
-
.gitignore含.env/keystore.json - 部署到 prod 前用 staging API 跑一遍密钥注册(避免 prod 出
AGENT_KEYS_MISMATCH) - 写了”两层私钥备份”的 runbook(hot store 自动 + cold store 手动)
- 监控
decrypt_failurerate,> 1% 时报警 - 监控
status长期 != connected 时报警 - 处理
relation.revoked事件时及时清理本地用户数据(GDPR) - 处理
agent.capability_changed事件时记录 diff(合规审计)
下一步
- Agent 身份与密钥 — 私钥管理深入
- 接收第一条消息 — 加密管道全链路
- 错误处理 — decrypt failure 的诊断
- E2EE 统一规范 — 真源(高密度,建议 spec 同事一起读)