docs(relay): security audit report + progress log for the relay findings

REVIEW_RELAY_SECURITY.md: two-round multi-agent audit — F1–F6 with fix plan/
resolution, refuted-but-recorded items, and the e2e harness coverage table.
PROGRESS_LOG.md: cross-session record of the audit + fixes + harness.
This commit is contained in:
Yaojia Wang
2026-07-02 16:41:19 +02:00
parent ad5cf06207
commit 0ad7c31549
2 changed files with 178 additions and 0 deletions

View File

@@ -24,6 +24,15 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### ✅ 安全审计 relay-e2e + relay-auth — 上线前深度审计 + 修复(完成 — 2026-07-02,分支 `security/relay-auth-audit-fixes`,未提交)
上线门:自动生成的 E2E/鉴权核心须过安全专家审计(前次复审判 approve-with-changes)。**多 agent 编排 = 两个 ultracode `Workflow`**:①**审计**(99 agents)—— 11 维 finder 并行(两包)→ 每个 finding 3 lens 对抗式 verify(exploitability/crypto-spec/refuter 多数票)→ 第二轮「重复 review」深挖 → 每包 completeness critic → dedup+定级;②**修复循环**(10 agents)—— foundation(types)→ 3 组文件互斥并行修复(module fixers)→ **verify 循环至 tsc 干净 + 全绿** → 5 路对抗式 re-audit 确认每个 exploit 已闭合。
- **审计结论**: **relay-e2e = PASS 零改动**(所有候选在 verify 阶段被驳回:确定性 nonce 安全 —— 方向分离 + 真实 agent 侧 `createReplaySealer` 各自持独立单调 seq;strict-successor recv guard 无法导致 replay-accept)。**relay-auth = approve-with-changes,5 条确认**(2 HIGH / 1 MED / 2 LOW),全部在 human-auth/enforcement 层,全部包内可修。驳回但存档(不再复议):E2E replay nonce reuse、mTLS EKU、SPIFFE 域未 pin、reattach host-scope、TOTP 时序。完整报告见 `docs/REVIEW_RELAY_SECURITY.md`
- **修复**(均含回归测试): **F1 HIGH** step-up 因子降级 —— `needsStepUp` 现把新鲜度绑定到**精确方法**(`stepUpMethod`),钓来的 TOTP 不再满足 passkey;**F2 HIGH** step-up 在新建会话路径被完全跳过(生产 caller `term-relay/data-plane/upgrade.ts:105` 硬编码 `principal:null` → 生产环境 step-up 形同虚设)—— 改为**主机驱动 + fail-closed**(`policy.required` 时 principal 缺失/过期/错方法一律 403);**F3 MED** WebAuthn 断言未绑定已存凭据 —— 现校验 credentialId 且转发 pubkey 给 verifier;**F4 LOW** DPoP verify 未捕获抛出 → INV15 门未审计地崩(审计逃逸+DoS)—— `verifyDpopProof` 全体 try/catch→false + `coreAuthorize` 边界兜成干净审计 401 + issue 校验 cnfJkt 格式;**F5 LOW** 验证过的 signCount 被丢弃 → 克隆检测失效 —— `finishAuthentication` 现返回 `{principal,newSignCount}` 供持久化。
- **验证(orchestrator 亲验)**: relay-auth `tsc --noEmit` 干净 + **122 测试全绿**(基线 104,+18 回归);relay-e2e 未动、tsc 干净、76 全绿。re-audit **5/5 闭合**(置信 0.950.97,均有回归测试)。
- **遗留/behavioral note(非阻塞)**: F2 改为 fail-closed 后,**任何 `stepUpPolicyFor(host).required===true` 的主机,P1(term-relay)必须在 upgrade ctx 传入已认证 principal**,否则正确地被拒;当前 v0.9 默认 `NO_STEPUP_POLICY.required===false`,现网非 step-up 主机不受影响。P1 集成时须补此接线(见 [[relay-stepup-needs-principal-wiring]])。
- **第二轮独立 review(Fable 5,换模型增强多样性)+ F6 修复**: 第二个 `Workflow`(fix-regression 猎取 + 新角度 sweep + 对抗式 re-attack 被驳回项)判:**5 条修复 0 regression**、被驳回/D-i-D 项 re-attack **0 存活**(确认真不可利用),但**新增 1 条 HIGH——F6**:relay-e2e `replay-key.ts` 的可恢复 `K_content` 仅由 `(hostContentSecret, sessionId)` 决定(稳定),而真实消费者 `agent/src/e2e/replaySeal.ts``let seq=0n` 在每次重建(重启/重连)时归零 → 两代 sealer 在**相同 (key, nonce)** 下封不同明文 = 灾难性 AEAD 复用。**第一轮曾误驳此项**(verifier 看到"独立单调计数器"就类比 live 路径判安全,漏了 K_content 跨重启稳定);第二轮定向 re-attack + orchestrator 亲读 `replaySeal.ts` 证实为真。**修法(用户选 epoch-in-key,4 包)**:`ReplayKeyParams``epoch`;`deriveContentKey` 把 epoch 以 `sessionId‖0x1f‖epoch` 混入 HKDF salt;`createReplaySealer` 每代 `randomUUID()` 新 epoch 并暴露;`ReplaySource` 带 epoch、浏览器按之重推。**验证(亲验)**:4 包 tsc 干净 + 全绿(relay-contracts 81 / relay-e2e 78[+2 F6 回归] / agent 133 / relay-web 99);re-audit 判闭合(回归证:两代同 seq=0 nonce 但不同 key)。**残留(fail-closed 非复用)**:ring-buffer 传输(P1/P2)须持久化并按代下发 epoch —— 已记 TODO,当前 replay 路径尚未端到端接线(`manage-page.loadReplay` 仍是 throw stub),故 F6 此刻是潜伏漏洞。完整报告 `docs/REVIEW_RELAY_SECURITY.md`(含 F6 + RESOLUTION)。
- **动态 e2e 安全测试台(新增 `e2e/` 包)**: 跨包 harness `buildRelayWorld()` 把**真实** P5(auth)+P4(crypto)+P2(agent replay) 通过内存 seam + 不可信 relay「RelaySpy」攻击者视角接起来(仅 I/O 边界 fake,所有安全检查走生产代码路径)。全流程 issue cap→upgrade/authz→handshake→sealed session 过 relay→reattach→revoke。**21 测试全绿、tsc 干净(亲验)**。把 F1/F2/F3/F4/F6 从「静态+单测」升级为「动态攻击者实测」,另加 MITM/reflection/replay/reorder/INV2(live+replay)/cross-tenant/single-use。F5(signCount)为返回形状改动、由 relay-auth 单测覆盖;RelaySpy 代替 term-relay mux(真多进程/浏览器 e2e 需尚未建的 run tooling)。用符号链接免 `npm install`(`e2e/node_modules/*` → 各包源码)。覆盖表见 `docs/REVIEW_RELAY_SECURITY.md`。**未提交**,留待用户决定。
### ✅ 桌面客户端 v0.1 — Electron 一体化壳(代码完成 — 2026-07-01,分支 `feat/desktop-electron`,未提交)
Mac/Windows 桌面 App,**内嵌现有 Node 服务器 + node-pty**(all-in-one):主进程 `startEmbeddedServer` 复用 `src/server.ts``startServer(cfg)`/`loadConfig`(**服务器零改动** —— 它本就导出可编程启动且 import 无副作用),窗口 `loadURL('http://127.0.0.1:<port>/')` 复用**前端零改动**(前端严格同源,`location.host` 自动指向内嵌服务器;Origin 白名单默认含 localhost)。核心价值 = **原生通知**(轮询 `/live-sessions``computeNotifications` → 系统通知)+ 托盘常驻 + 深链 `terminalapp://` + 开机自启。
- **编排**: ultracode `Workflow` —— 脚手架(orchestrator 冻结 `desktop/src/types.ts` 契约 + package/tsconfig/esbuild/electron-builder)→ Build(4 builder 并行、文件互斥、对齐冻结签名)→ Review(correctness/security/typescript 三 lens 并行、schema 结构化 findings)。纯逻辑尽量抽出可单测;Electron glue(main/window/tray/menu/preload/embedded-server/logger)作为不可单测 wiring 排除出覆盖率(沿用既有 vitest.config 先例)。**无用 zod、无用 electron-store**(遵循项目手写校验 + 手写 JSON 持久化的最小依赖惯例)。

View File

@@ -0,0 +1,169 @@
# Security Audit — relay-e2e + relay-auth (pre-production go-live gate)
**Date:** 2026-07-02 · **Branch:** `security/relay-auth-audit-fixes` · **Prior verdict:** approve-with-changes
**Method:** multi-agent deep audit — 11 security dimensions fanned out across both packages, every
candidate finding adversarially verified by ≥3 independent skeptic lenses (exploitability /
crypto-spec / refuter, majority vote), a second "repeat-review" deep-dive pass, a per-package
completeness critic, then dedup + severity re-rank. 99 agents, ~6.9M tokens. The orchestrator
independently read 100% of both `src/` trees to adjudicate.
## Verdict
**relay-e2e (crypto core): PASS — no changes required.** Every candidate against the E2E core
(replay-key nonce reuse, sequence/handshake ordering, key-hygiene, X25519 low-order) was **refuted**
on verification: the deterministic-nonce discipline is safe because the direction split + the real
agent-side `createReplaySealer` (in `term-relay/agent`) each own an independent monotonic counter;
recv `accept()` is a strict-successor guard whose pre-auth commit cannot cause a replay-accept.
**relay-auth: APPROVE-WITH-CHANGES — 5 confirmed findings (2 HIGH, 1 MED, 2 LOW), all in the
human-auth / enforcement layer.** All fixes are contained in relay-auth; the P5-local types
(`AuthenticatedPrincipal`, `StepUpPolicy`) are extendable without touching any frozen contract.
### Refuted (verified NOT exploitable — recorded so they are not re-litigated)
- E2E replay `K_content` nonce reuse — real consumer keeps an independent monotonic seq (no reset-to-0).
- mTLS `verifyChain` missing EKU/keyUsage — every abuse cert fails the later mandatory SPIFFE-SAN →
enrolled-&-non-revoked-host registry gate.
- SPIFFE trust-domain not pinned on verify — gated by chain-valid + registry; defense-in-depth only.
- Reattach host-scope "bypass" — `token.host === requestedHostId` bind at `decide.ts:62` blocks it;
session↔host binding is enforced at connect by P1/P3. (Kept as a cheap D-i-D hardening note.)
- TOTP non-constant-time compare / device-proof `acct` canonicalization / DPoP ±future-iat — LOW, refuted.
---
## Confirmed findings + fix plan
### F1 · HIGH — Step-up factor downgrade (`human/stepup/stepup.ts:33`)
`needsStepUp()` tests `policy.requiredMethod ∈ principal.amr` and freshness via a single
method-agnostic `stepUpAt`. `amr` is cumulative across login + every step-up, so a **passkey**
requirement is satisfied by a passkey used at *login*, while the freshness timestamp can be produced
by a *weaker* step-up (e.g. phished TOTP). The phishing-resistant control silently downgrades.
**Fix:** bind freshness to the method. Add `stepUpMethod` to the principal; `needsStepUp` requires
`stepUpMethod === requiredMethod` AND fresh; `recordStepUp` stamps the method. (Group A)
### F2 · HIGH — Mandatory step-up skipped on the new-session path (`enforce/onUpgrade.ts:128`)
The gate is wrapped in `if (ctx.principal !== null)`, but the production caller
(`term-relay/data-plane/upgrade.ts:105`) hardcodes `principal: null` on **both** connect and reattach
(session principal exists only *after* authz). Result: **step-up never runs in production** — a valid
DPoP-bound token opens a root shell with the mandatory passkey ceremony never performed.
Deny-by-default is violated (absence of proof → allow).
**Fix:** make step-up **host-driven and fail-closed**: when `stepUpPolicyFor(host).required`, DENY
(403 `step_up_required`) unless an authenticated principal proves fresh step-up. Applies to connect
and reattach. Default v0.9 policy is `required:false` → existing non-step-up hosts unaffected.
**Behavioral note:** once a host sets `required:true`, the P1 caller must supply a step-up principal;
until then such hosts correctly deny. (Group A)
### F3 · MEDIUM — WebAuthn assertion not bound to the stored credential (`human/webauthn/authenticate.ts:40`)
`finishAuthentication` forwards only `signCount` to the verifier — never `cred.publicKey` /
`cred.credentialId`, and there is no `credentialId` cross-check. Any reasonable verifier can only
check challenge/origin/rpId, so an assertion from a *different* registered credential authenticates.
**Fix:** add `credentialPublicKey` + `expectedCredentialId` to the verifier interface & forward them;
assert `resp.credentialId === cred.credentialId` before trusting `verified`. (Group B)
### F4 · LOW — DPoP verify throws (unhandled) → INV15 gate fails un-audited (`capability/verify.ts:142`)
`importEd25519PublicRaw(rawPub)` (and `readCnfJkt`) run outside try/catch; a thumbprint-matching but
malformed key makes `verifyDpopProof` **reject** instead of returning false. Callers (`decide.ts:57`,
`onUpgrade.ts:112`) don't catch → the sole authz gate throws an unhandled exception and emits **no**
`deny` AuditEvent (audit-evasion + repeatable unhandled-rejection DoS). Fail-closed but off-contract.
**Fix (defense-in-depth):** (a) wrap the risky calls in `verifyDpopProof` → return false; (b) wrap the
`verifyDpopProof` call in `coreAuthorize` → clean audited `deny(401)`; (c) validate `cnfJkt` format at
issue. (Group C)
### F5 · LOW — WebAuthn verified `signCount` discarded → clone detection inert (`human/webauthn/authenticate.ts:52`)
The advanced `newSignCount` is used only for the regression check and never returned/persisted, so the
guard forever compares against the registration-time count — a cloned authenticator is never detected.
**Fix:** return `newSignCount` (updated credential) so the caller can persist it. (Group B)
---
## Execution plan (multi-agent, loop mode)
- **Foundation (barrier):** `types.ts` — add `StepUpPolicy.required: boolean` + `AuthenticatedPrincipal.stepUpMethod?: AuthMethod|null` (+ Zod). Optional `stepUpMethod` ⇒ no ripple to other principal constructors; absent ⇒ treated as null ⇒ needs-step-up (fail-safe).
- **Fix groups (parallel, file-disjoint):**
- **Group A (F1,F2):** `stepup.ts`, `enforce/onUpgrade.ts`, `test/{stepup,enforce}.test.ts`, `test/tripwire/cross-tenant.test.ts`.
- **Group B (F3,F5):** `human/webauthn/{verifier,authenticate}.ts`, `test/webauthn.test.ts`.
- **Group C (F4):** `capability/{verify,issue}.ts`, `authz/decide.ts`, `test/capability.test.ts`.
- **Verify (loop until green):** `tsc --noEmit` + full `vitest run` in relay-auth; repair any breakage; repeat. Baseline to preserve/extend: relay-e2e 76, relay-auth 104.
- **Re-audit (parallel adversarial):** one verifier per finding re-reads the fixed code and confirms the specific exploit is closed.
Each fix ships with a **regression test** encoding the exploit (F2: STRICT+null→403; F1: fresh TOTP ≠ passkey; F3: credentialId mismatch→reject; F4: malformed-jwk DPoP→false+audited deny; F5: newSignCount surfaced).
---
## Second independent review (2026-07-02, Fable 5) — RECORD CORRECTION
A second review (fix-regression hunt + fresh-angle sweep + adversarial re-attack of refuted items),
run on a **different model** for reviewer diversity, returned:
- **Fix-regression: 0** — the 5 fixes (F1F5) survive independent scrutiny, no regressions.
- **Re-attack of refuted/D-i-D items: 0 survived** — mTLS EKU, SPIFFE domain, reattach host-scope, TOTP
replay, device-proof canonicalization are re-confirmed **non-exploitable**.
- **1 new HIGH** — which the first audit had **REFUTED in error** (a false-negative). Corrected below.
### F6 · HIGH — Recoverable `K_content` replay key reuses `(key, nonce)` across sealer generations
**⚠️ This corrects the earlier "relay-e2e PASS" verdict — relay-e2e's replay surface has a real nonce-reuse hazard.**
`relay-e2e/src/replay-key.ts` `deriveContentKey` keys `K_content = HKDF(hostContentSecret, salt=sessionId,
info=const)`**deterministic**, and *intended* to be recoverable/stable per `(host, sessionId)`. The
nonce is deterministic `f(seq)` and `sealReplayFrame` is stateless (no SequenceGuard). The sole real
consumer, `agent/src/e2e/replaySeal.ts:41` `createReplaySealer`, holds `let seq = 0n` **in memory** and
**resets to 0 on every reconstruction** (agent restart/redeploy, or a new attach), while `hostContentSecret`
is persisted 0600 (Keystore) and `sessionId` is stable (localStorage). Unlike the LIVE h2c path — safe
because each reconnect runs a fresh ECDH → brand-new keys — the replay path reuses the **same K_content**
with `seq` restarting at 0 ⇒ two generations of distinct plaintext sealed under identical `(key, nonce)`.
An untrusted relay (INV2 sees ciphertext) that observes both generations recovers plaintext-XOR (terminal
output: commands/tokens/code) and, for AES-256-GCM, recovers the GHASH auth-subkey → forgery, which
`openReplayCiphertext` (no cross-frame replay check) will accept.
**Why round 1 missed it:** the round-1 verifier located `let seq = 0n`, correctly noted it's an independent
counter, but wrongly concluded "safe" by analogy to the live path — missing that `K_content` (unlike the
live ephemeral keys) is STABLE across restarts, so the seq reset *does* collide. Round 2's dedicated
re-attack + orchestrator's direct read of `replaySeal.ts`/`hostEndpoint.ts` confirm the exploit.
**Fix options (design decision — touches a frozen contract and/or the agent package, both outside the two
originally-scoped packages):**
- **(A) Agent-side durable seq** — persist the replay `SequenceGuard` with the ring buffer / Keystore so
`seq` never resets for a given `(sessionId, K_content)`. Keeps `K_content` recoverable; blast radius =
`agent/` only. Requires durable monotonic-seq guarantee across restart.
- **(B) Per-generation epoch in `K_content`** — add an epoch/generation to `ReplayKeyParams` (frozen in
`relay-contracts/src/e2e/types.ts`), mixed into the HKDF salt, and carry it with each frame so the browser
re-derives the right key. Cryptographically robust (fresh key per generation) but changes a **frozen
contract** + `relay-e2e` + `relay-web` (browser re-derivation) + `agent`.
- Regression test (either path): two sealer generations for the same `(secret, sessionId)` must never emit
two frames sharing `(key, nonce)`.
**RESOLUTION — FIXED (option B, epoch-in-key), 2026-07-02.** Implemented across all 4 packages:
`ReplayKeyParams` (relay-contracts) gains a required `epoch: string`; `deriveContentKey` (relay-e2e) folds it
into the HKDF salt as `utf8(sessionId) ‖ 0x1F ‖ utf8(epoch)` (unit-separator, no concat aliasing);
`createReplaySealer` (agent) mints a fresh `randomUUID()` epoch per generation and exposes it; `ReplaySource`
(relay-web) carries `epoch` and the browser re-derives with it. A fresh epoch per generation ⇒ a fresh key,
so a `seq=0` reset after restart/re-attach can never collide with a prior generation's `(key, nonce)`;
recoverability within a generation (same epoch ⇒ same key) is preserved. **Verified:** all 4 packages
tsc-clean and green (relay-contracts 81, relay-e2e 78 [+2 F6 regressions], agent 133, relay-web 99); re-audit
confirms closure (regression proves two generations share the seq=0 nonce yet derive different keys).
**Residual (fail-closed, not reuse):** the ring-buffer transport (P1/P2) must persist each generation's epoch
and serve the epoch matching those exact frames; documented as a TODO at `relay-web/.../manage-page.ts`
(`loadReplay` is still a throwing stub — the replay path is not wired end-to-end yet, so F6 was latent).
---
## Dynamic end-to-end security harness (`e2e/`, 2026-07-02)
A cross-package harness (`e2e/`) now wires the **real** P5(auth) + P4(crypto) + P2(agent replay) exports
through in-memory seams and an untrusted-relay "RelaySpy" attacker vantage — every security check runs the
production code path; only true I/O boundaries (registries/buckets/revocation/audit/sockets) are faked.
`buildRelayWorld()` exposes the full flow (issue cap → upgrade/authz → handshake → sealed session via relay
→ reattach → revoke). **21 tests pass, tsc clean.** This upgrades the findings from *static + unit* validation
to *dynamic* validation — the attacker actually attempts each exploit:
| Attack (dynamic) | Asserted defense |
|---|---|
| **F1** step-up factor downgrade | fresh TOTP step-up → `needsStepUp` true + upgrade 403; passkey → allowed |
| **F2** step-up skipped (fail-open) | STRICT + `principal:null` → 403 `step_up_required` + audit; `required:false`+null → allowed |
| **F3** WebAuthn credential binding | `credentialId` mismatch → `WebAuthnError` even with a verifier that returns `verified:true` |
| **F4** DPoP unhandled throw | malformed thumbprint-matching jwk → `verifyDpopProof` resolves `false` (no throw) → clean audited `dpop_proof_failed` deny |
| **F6** replay `(key,nonce)` reuse | two sealer generations → same seq-0 nonce but different key ⇒ ciphertext+tag differ; cross-gen open throws; within-gen recovers |
| MITM | wrong `agentPubkey` / tampered `hostEphPub``FingerprintMismatchError`, no keys derived |
| reflection / replay / reorder | c2h→client.open rejects; dup/out-of-order `open` throws (SequenceGuard) |
| INV2 | RelaySpy ciphertext (live + replay path) never contains the plaintext marker |
| cross-tenant (INV1) | acct-A token at acct-B host → 403 `cross_tenant` + `cross-tenant-attempt` audit |
| capability single-use | same jti twice (fresh DPoP) → 2nd 403 `token_replayed` |
**Not dynamically covered:** F5 (signCount persistence) is a return-shape change, asserted by its relay-auth
unit regression. The harness fakes the P1 relay/transport (RelaySpy) rather than running term-relay's mux
and the control-plane HTTP — a true multi-process/browser e2e would need the (not-yet-built) run tooling.