Files
web-terminal/docs/REVIEW_RELAY_SECURITY.md
Yaojia Wang 0ad7c31549 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.
2026-07-02 16:41:19 +02:00

170 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.