Merge security/relay-auth-audit-fixes: relay-auth/relay-e2e security audit
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled

Two-round multi-agent security audit of the rendezvous-relay E2E/auth core.
Fixes 6 confirmed findings (F1–F6) with regression tests, adds a cross-package
e2e adversarial security harness (dynamic re-validation), and the audit report.
534 tests green across the affected packages; tsc clean. See docs/REVIEW_RELAY_SECURITY.md.
This commit is contained in:
Yaojia Wang
2026-07-02 16:41:38 +02:00
41 changed files with 1804 additions and 93 deletions

View File

@@ -13,7 +13,19 @@
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim. * the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key, * `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
* NEVER logged, NEVER sent to the relay (INV2/INV9). * NEVER logged, NEVER sent to the relay (INV2/INV9).
*
* PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is
* byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal
* nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer
* GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD
* reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded
* into K_content derivation, so a restart / new generation yields a FRESH key even for the same
* (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on
* the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the
* browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same
* key) is preserved.
*/ */
import { randomUUID } from 'node:crypto'
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts' import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */ /** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
@@ -23,13 +35,20 @@ export interface ReplayCrypto {
} }
export interface ReplaySealer { export interface ReplaySealer {
/**
* The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it
* with the ring buffer / replay stream so the browser re-derives the matching key.
*/
readonly epoch: string
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */ /** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
seal(plaintext: Uint8Array): E2EEnvelope seal(plaintext: Uint8Array): E2EEnvelope
} }
/** /**
* Build a per-(host, session) replay sealer. K_content is derived ONCE from * Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13). * and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch };
* seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a
* NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6).
*/ */
export function createReplaySealer( export function createReplaySealer(
hostContentSecret: Uint8Array, hostContentSecret: Uint8Array,
@@ -37,9 +56,11 @@ export function createReplaySealer(
alg: AeadAlg, alg: AeadAlg,
crypto: ReplayCrypto, crypto: ReplayCrypto,
): ReplaySealer { ): ReplaySealer {
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg }) const epoch = randomUUID()
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch })
let seq = 0n let seq = 0n
return { return {
epoch,
seal(plaintext: Uint8Array): E2EEnvelope { seal(plaintext: Uint8Array): E2EEnvelope {
const env = crypto.sealReplayFrame(key, seq, plaintext) const env = crypto.sealReplayFrame(key, seq, plaintext)
seq += 1n seq += 1n

View File

@@ -107,6 +107,7 @@ describe('createE2ETransform (T15)', () => {
function fakeReplay(): ReplaySealer & { calls: number } { function fakeReplay(): ReplaySealer & { calls: number } {
const r = { const r = {
calls: 0, calls: 0,
epoch: 'test-epoch',
seal(_pt: Uint8Array) { seal(_pt: Uint8Array) {
r.calls += 1 r.calls += 1
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() } return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }

View File

@@ -2,14 +2,24 @@ import { describe, expect, it, vi } from 'vitest'
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts' import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js' import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
/** Fake AEAD: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */ /**
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } { * Fake AEAD. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on
* `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys.
* The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0]
* (a UUID's first char is a hex digit 0x300x66 → always nonzero, so the marker is always hidden).
*/
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } {
const derivations: ReplayKeyParams[] = [] const derivations: ReplayKeyParams[] = []
const keys: Uint8Array[] = []
return { return {
derivations, derivations,
keys,
deriveContentKey(params: ReplayKeyParams): AeadKey { deriveContentKey(params: ReplayKeyParams): AeadKey {
derivations.push(params) derivations.push(params)
const tag = new TextEncoder().encode(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`) const tag = new TextEncoder().encode(
`${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`,
)
keys.push(tag)
return tag as unknown as AeadKey return tag as unknown as AeadKey
}, },
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope { sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
@@ -23,12 +33,26 @@ function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
const SECRET = new Uint8Array([1, 2, 3, 4]) const SECRET = new Uint8Array([1, 2, 3, 4])
describe('createReplaySealer (T19, FIX 3)', () => { describe('createReplaySealer (T19, FIX 3)', () => {
it('derives K_content deterministically from (secret, sessionId, alg)', () => { it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
const c1 = fakeCrypto() const c = fakeCrypto()
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1) const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
const c2 = fakeCrypto() expect(c.derivations).toHaveLength(1)
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2) expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
expect(c1.derivations[0]).toEqual(c2.derivations[0]) expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape
})
it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => {
const c = fakeCrypto()
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer.
const browser = fakeCrypto()
const browserKey = browser.deriveContentKey({
hostContentSecret: SECRET,
sessionId: 'sess-1',
alg: 'aes-256-gcm',
epoch: sealer.epoch,
}) as unknown as Uint8Array
expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true)
}) })
it('a different sessionId → a different key (per-session separation)', () => { it('a different sessionId → a different key (per-session separation)', () => {
@@ -38,6 +62,23 @@ describe('createReplaySealer (T19, FIX 3)', () => {
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId) expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
}) })
it('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => {
const c = fakeCrypto()
const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// Fresh epoch per generation…
expect(gen2.epoch).not.toBe(gen1.epoch)
// …therefore distinct K_content even though (secret, sessionId, alg) are identical…
expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false)
// …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1
// (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key).
const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43]))
const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43]))
expect(s1.seq).toBe(0n)
expect(s2.seq).toBe(0n)
expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)…
})
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => { it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto()) const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
const marker = new TextEncoder().encode('SECRET-MARKER') const marker = new TextEncoder().encode('SECRET-MARKER')
@@ -50,7 +91,7 @@ describe('createReplaySealer (T19, FIX 3)', () => {
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => { it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto()) const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
// model a live seal with a different key byte // model a live seal with a different key byte (0x11 is below the 0x300x66 hex-digit epoch prefix)
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42])) const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
const rep = replay.seal(new Uint8Array([0x41, 0x42])) const rep = replay.seal(new Uint8Array([0x41, 0x42]))

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`,未提交) ### ✅ 桌面客户端 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://` + 开机自启。 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 持久化的最小依赖惯例)。 - **编排**: 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.

141
e2e/harness/dpop.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* P5 signing-key setup + capability-token / DPoP bundle builder. These wire the REAL
* relay-auth issue + DPoP primitives. `resetVerifyKeyForTest`, `resetDpopCacheForTest`,
* `buildDpopProof`, `jwkThumbprint`, and the WebCrypto Ed25519 helpers are TEST-ONLY / internal,
* so they are deep-imported from `relay-auth/src/...` (relay-auth has no `exports` map, so subpath
* imports resolve through the e2e node_modules symlink).
*/
import { randomUUID } from 'node:crypto'
import { issueCapabilityToken, type DpopContext } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import { encodeBase64UrlBytes } from 'relay-contracts'
import {
configureVerifyKey,
resetVerifyKeyForTest,
} from 'relay-auth/src/config/keys.js'
import {
generateEd25519KeyPair,
exportEd25519PublicRaw,
} from 'relay-auth/src/crypto/ed25519.js'
import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js'
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
import { principal } from './fakes.js'
export { resetDpopCacheForTest, jwkThumbprint }
export interface P5Key {
readonly signingKey: CryptoKey
readonly publicRaw: Uint8Array
}
/** Configure the P5 verifying key globally and return the matching private signing key. */
export async function setupP5SigningKey(): Promise<P5Key> {
resetVerifyKeyForTest()
const pair = await generateEd25519KeyPair()
configureVerifyKey(pair.publicKey)
const publicRaw = await exportEd25519PublicRaw(pair.publicKey)
return { signingKey: pair.privateKey, publicRaw }
}
export interface Ephemeral {
readonly privateKey: CryptoKey
readonly publicRaw: Uint8Array
}
export async function makeEphemeral(): Promise<Ephemeral> {
const pair = await generateEd25519KeyPair()
return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) }
}
export interface IssueOpts {
readonly accountId: string
readonly host: string
readonly aud: string
readonly rights?: readonly CapabilityRight[]
readonly ttl?: number
readonly now: number
readonly htu?: string
readonly htm?: string
}
/**
* A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof
* (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct
* DPoP (single-use jti / revocation tests) without tripping the DPoP replay cache.
*/
export interface CapBundle {
readonly raw: string
readonly dpop: DpopContext
readonly newDpop: (at?: number) => Promise<DpopContext>
}
export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise<CapBundle> {
const eph = await makeEphemeral()
const cnfJkt = await jwkThumbprint(eph.publicRaw)
const raw = await issueCapabilityToken(
{
principal: principal(o.accountId),
aud: o.aud,
host: o.host,
rights: o.rights ?? ['attach'],
ttlSeconds: o.ttl ?? 45,
cnfJkt,
},
signingKey,
o.now,
)
const htu = o.htu ?? `https://${o.aud}/ws`
const htm = o.htm ?? 'GET'
const newDpop = async (at: number = o.now): Promise<DpopContext> => ({
proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, {
htu,
htm,
jti: randomUUID(),
iat: at,
}),
htu,
htm,
})
return { raw, dpop: await newDpop(o.now), newDpop }
}
/**
* F4 crafting: a capability whose `cnf.jkt` is the thumbprint of a NON-32-byte blob, plus a DPoP
* proof whose `header.jwk.x` decodes to that same blob. The thumbprint check therefore PASSES and
* execution reaches `importEd25519PublicRaw(blob)`, which throws on the bad length — exercising the
* fail-safe (must resolve to false, never reject).
*/
export interface MalformedOpts {
readonly accountId: string
readonly host: string
readonly aud: string
readonly now: number
readonly blobLen?: number
}
export async function craftMalformedDpopBundle(
signingKey: CryptoKey,
o: MalformedOpts,
): Promise<{ raw: string; dpop: DpopContext }> {
const blob = new Uint8Array(o.blobLen ?? 16).fill(7)
const cnfJkt = await jwkThumbprint(blob) // 43-char base64url regardless of blob length
const raw = await issueCapabilityToken(
{
principal: principal(o.accountId),
aud: o.aud,
host: o.host,
rights: ['attach'],
ttlSeconds: 45,
cnfJkt,
},
signingKey,
o.now,
)
const htu = `https://${o.aud}/ws`
const enc = (x: unknown): string =>
encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(x)))
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(blob) } })
const p = enc({ htu, htm: 'GET', jti: randomUUID(), iat: o.now })
const s = encodeBase64UrlBytes(new Uint8Array(64)) // arbitrary signature bytes
return { raw, dpop: { proofJws: `${h}.${p}.${s}`, htu, htm: 'GET' } }
}

132
e2e/harness/fakes.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* In-memory port fakes — the ONLY seams that stand in for the true I/O boundaries P1/P3 own
* (host registry / session registry / revocation store / token buckets / audit sink / revocation
* bus). Every security decision still runs through the REAL relay-auth exports; these fakes only
* supply storage. Adapted from `relay-auth/test/_helpers.ts` (same shapes, bare import paths).
*/
import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts'
import type {
AuthenticatedPrincipal,
AuditEvent,
AuditSink,
HostRegistryPort,
SessionRegistryPort,
RevocationStore,
TokenBucketStore,
} from 'relay-auth'
/** An authenticated principal (the ONLY source of accountId, INV3). */
export function principal(
accountId: string,
overrides: Partial<AuthenticatedPrincipal> = {},
): AuthenticatedPrincipal {
return {
kind: 'human',
accountId,
principalId: 'cred-' + accountId,
amr: ['passkey'],
authAt: 1000,
stepUpAt: null,
...overrides,
}
}
/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */
export function makeHostRecord(
accountId: string,
hostId: string,
status: HostRecord['status'] = 'online',
): HostRecord {
return {
hostId,
accountId,
subdomain: 'sub-' + hostId,
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr-' + hostId,
status,
lastSeen: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
}
}
export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort {
const map = new Map(hosts.map((h) => [h.hostId, h]))
return { getById: async (id) => map.get(id) ?? null }
}
export interface MutableSessionRegistry extends SessionRegistryPort {
add(entry: { sessionId: string; hostId: string; accountId: string }): void
}
export function fakeSessionRegistry(
seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [],
): MutableSessionRegistry {
const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }]))
return {
getById: async (id) => map.get(id) ?? null,
add: (e) => {
map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId })
},
}
}
export interface RevocationFake extends RevocationStore {
readonly revoked: Set<string>
readonly consumed: Set<string>
}
export function fakeRevocationStore(): RevocationFake {
const revoked = new Set<string>()
const consumed = new Set<string>()
return {
revoked,
consumed,
isRevoked: async (jti) => revoked.has(jti),
revokeJti: async (jti) => {
revoked.add(jti)
},
consumeOnce: async (jti) => {
if (consumed.has(jti)) return false
consumed.add(jti)
return true
},
}
}
export interface TokenBucketFake extends TokenBucketStore {
readonly blocked: Set<string>
readonly calls: string[]
}
/** Token bucket that always allows unless a key is added to `blocked`. */
export function fakeTokenBucket(): TokenBucketFake {
const blocked = new Set<string>()
const calls: string[] = []
return {
blocked,
calls,
take: async (key) => {
calls.push(key)
return !blocked.has(key)
},
}
}
export interface AuditFake extends AuditSink {
readonly events: AuditEvent[]
}
export function fakeAuditSink(): AuditFake {
const events: AuditEvent[] = []
return { events, append: async (e) => void events.push(e) }
}
export interface RevocationBusFake extends RevocationBus {
readonly published: KillSignal[]
}
export function fakeRevocationBus(): RevocationBusFake {
const published: KillSignal[] = []
return { published, publish: async (s) => void published.push(s) }
}

34
e2e/harness/spy.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* RelaySpy — the untrusted-relay / attacker vantage. A passthrough that records every ciphertext
* byte it forwards (exactly what a malicious relay can see). INV2: a known plaintext marker must
* NEVER appear in `captured`. Mirrors the `relay-e2e/test/integration.test.ts` spy.
*/
import { MAX_FRAME_BYTES } from 'relay-e2e'
export class RelaySpy {
readonly captured: Uint8Array[] = []
/** Forward a sealed payload, recording a copy of the ciphertext the relay observes. */
forward(payload: Uint8Array): Uint8Array {
if (payload.length > MAX_FRAME_BYTES) {
throw new Error(`payload exceeds MAX_FRAME_BYTES (${payload.length} > ${MAX_FRAME_BYTES})`)
}
this.captured.push(payload.slice())
return payload
}
/** Latin1 concatenation of everything the relay saw — for substring canary scans. */
snapshot(): string {
return this.captured.map((b) => Buffer.from(b).toString('latin1')).join(' ')
}
/** True if `marker` appears in ANY captured frame (utf8 or latin1) — INV2 tripwire. */
contains(marker: string): boolean {
if (this.snapshot().includes(marker)) return true
return this.captured.some(
(b) =>
Buffer.from(b).toString('utf8').includes(marker) ||
Buffer.from(b).toString('latin1').includes(marker),
)
}
}

332
e2e/harness/world.ts Normal file
View File

@@ -0,0 +1,332 @@
/**
* buildRelayWorld() — composes the REAL relay-auth (P5) + relay-e2e (P4) + agent (P2 replay)
* exports through the in-memory seams in ./fakes and exposes a clean flow API plus the untrusted
* "RelaySpy" attacker vantage. Only the true I/O boundaries are faked; every security check is the
* production code path. Pass an explicit `now` everywhere (no Date.now in assertions).
*
* The host identity used for the §4.4 handshake transcript signature is a real WebCrypto Ed25519
* key (relay-auth's own crypto, deep-imported) so the harness needs no @noble import of its own and
* no relay-e2e deep import (relay-e2e's `exports` map blocks subpaths).
*/
import { randomUUID, randomBytes } from 'node:crypto'
import type { AeadAlg, E2EEnvelope, E2ESession, HostHello, HostRecord } from 'relay-contracts'
import {
createClientHandshake,
createHostHandshake,
createE2ESession,
MemoryDevicePinStore,
deriveContentKey,
sealReplayFrame,
openReplayCiphertext,
encodeEnvelope,
} from 'relay-e2e'
import {
onUpgrade,
onReattach,
revoke,
revokeToken,
signDeviceAuthProof,
verifyDeviceProof,
type AuthzOutcome,
type EnforceDeps,
type UpgradeContext,
type StepUpPolicy,
type RevocationScope,
} from 'relay-auth'
import {
generateEd25519KeyPair,
exportEd25519PublicRaw,
importEd25519PublicRaw,
signEd25519,
verifyEd25519,
} from 'relay-auth/src/crypto/ed25519.js'
import { createReplaySealer, type ReplaySealer } from 'agent'
import {
fakeAuditSink,
fakeHostRegistry,
fakeRevocationBus,
fakeRevocationStore,
fakeSessionRegistry,
fakeTokenBucket,
makeHostRecord,
principal,
type AuditFake,
type MutableSessionRegistry,
type RevocationBusFake,
type RevocationFake,
type TokenBucketFake,
} from './fakes.js'
import {
craftMalformedDpopBundle,
issueCapBundle,
resetDpopCacheForTest,
setupP5SigningKey,
type CapBundle,
} from './dpop.js'
import { RelaySpy } from './spy.js'
export const DEFAULT_NOW = 1_700_000_000
const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305'
export const NO_STEP_UP: StepUpPolicy = {
required: false,
maxAgeSeconds: Number.MAX_SAFE_INTEGER,
requiredMethod: 'passkey',
}
export const STRICT_PASSKEY: StepUpPolicy = {
required: true,
maxAgeSeconds: 300,
requiredMethod: 'passkey',
}
/** A seeded host: authz identity (hostId/accountId/aud) + §4.4 e2e identity + replay secret. */
export interface HostFixture {
readonly label: string
readonly hostId: string
readonly accountId: string
readonly aud: string
readonly origin: string
readonly agentPubkey: Uint8Array
readonly hostContentSecret: Uint8Array
readonly replayAlg: AeadAlg
/** WebCrypto private key backing the handshake transcript signer. */
readonly signingPriv: CryptoKey
}
async function makeHostFixture(label: string, accountId: string): Promise<HostFixture> {
const kp = await generateEd25519KeyPair()
const agentPubkey = await exportEd25519PublicRaw(kp.publicKey)
const sub = `${label}.term.example.com`
return {
label,
hostId: randomUUID(),
accountId,
aud: sub,
origin: `https://${sub}`,
agentPubkey,
hostContentSecret: new Uint8Array(randomBytes(32)),
replayAlg: REPLAY_ALG,
signingPriv: kp.privateKey,
}
}
export interface UpgradeArgs {
readonly raw: string
readonly dpop: UpgradeContext['dpop']
readonly host: string
readonly aud: string
readonly origin: string
readonly now: number
readonly principal?: UpgradeContext['principal']
readonly requiredRight?: UpgradeContext['requiredRight']
readonly activeSessionCount?: number
readonly remoteAddrHash?: string
}
export interface ReattachArgs extends UpgradeArgs {
readonly sessionId: string
}
export interface HandshakeOpts {
readonly host?: HostFixture
readonly now?: number
/** MITM: pubkey the client verifies host_hello against (default = the real agentPubkey). */
readonly verifyAgainstPubkey?: Uint8Array
/** MITM: mutate the host_hello the client receives (tampered transcript / hostEphPub). */
readonly tamperHostHello?: (h: HostHello) => HostHello
}
export interface EstablishedSessions {
readonly client: E2ESession
readonly host: E2ESession
readonly spy: RelaySpy
readonly agentPubkey: Uint8Array
}
export interface RelayWorld {
readonly now: number
readonly signingKey: CryptoKey
readonly publicRaw: Uint8Array
readonly hostA: HostFixture
readonly hostB: HostFixture
readonly allowedOrigins: readonly string[]
readonly deps: EnforceDeps
readonly audit: AuditFake
readonly revocation: RevocationFake
readonly buckets: TokenBucketFake
readonly bus: RevocationBusFake
readonly sessions: MutableSessionRegistry
principal(accountId: string, over?: Parameters<typeof principal>[1]): ReturnType<typeof principal>
setStepUpPolicy(policy: StepUpPolicy): void
issueCap(o: {
accountId: string
host: string
aud: string
rights?: readonly UpgradeContext['requiredRight'][]
ttl?: number
now?: number
}): Promise<CapBundle>
craftMalformedDpop(o: {
accountId: string
host: string
aud: string
now?: number
blobLen?: number
}): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }>
upgrade(a: UpgradeArgs): Promise<AuthzOutcome>
reattach(a: ReattachArgs): Promise<AuthzOutcome>
addSession(e: { sessionId: string; hostId: string; accountId: string }): void
establishSession(opts?: HandshakeOpts): Promise<EstablishedSessions>
newReplaySealer(sessionId: string, host?: HostFixture): ReplaySealer
/** Browser-side re-derivation + open of a replay frame (throws on AEAD failure / wrong epoch). */
openReplay(sessionId: string, epoch: string, env: E2EEnvelope, host?: HostFixture): Uint8Array
revokeToken(jti: string, exp?: number): Promise<void>
revoke(scope: RevocationScope): Promise<void>
}
export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise<RelayWorld> {
const { signingKey, publicRaw } = await setupP5SigningKey()
resetDpopCacheForTest()
const hostA = await makeHostFixture('alice', 'acct-A')
const hostB = await makeHostFixture('bob', 'acct-B')
const hostRecords: HostRecord[] = [
makeHostRecord(hostA.accountId, hostA.hostId),
makeHostRecord(hostB.accountId, hostB.hostId),
]
const hosts = fakeHostRegistry(hostRecords)
const sessions = fakeSessionRegistry([])
const revocation = fakeRevocationStore()
const buckets = fakeTokenBucket()
const audit = fakeAuditSink()
const bus = fakeRevocationBus()
const allowedOrigins = [hostA.origin, hostB.origin]
let stepUpPolicy: StepUpPolicy = NO_STEP_UP
const deps: EnforceDeps = {
hosts,
sessions,
revocation,
buckets,
audit,
stepUpPolicyFor: () => stepUpPolicy,
}
function ctxFor(a: UpgradeArgs): UpgradeContext {
return {
capabilityRaw: a.raw,
originHeader: a.origin,
expectedAud: a.aud,
requestedHostId: a.host,
requiredRight: a.requiredRight ?? 'attach',
remoteAddrHash: a.remoteAddrHash ?? 'ip-hash',
activeSessionCount: a.activeSessionCount ?? 0,
dpop: a.dpop,
principal: a.principal ?? null,
}
}
const replayCrypto = { deriveContentKey, sealReplayFrame }
return {
now,
signingKey,
publicRaw,
hostA,
hostB,
allowedOrigins,
deps,
audit,
revocation,
buckets,
bus,
sessions,
principal,
setStepUpPolicy(policy) {
stepUpPolicy = policy
},
issueCap(o) {
return issueCapBundle(signingKey, {
accountId: o.accountId,
host: o.host,
aud: o.aud,
rights: o.rights,
ttl: o.ttl,
now: o.now ?? now,
})
},
craftMalformedDpop(o) {
return craftMalformedDpopBundle(signingKey, {
accountId: o.accountId,
host: o.host,
aud: o.aud,
now: o.now ?? now,
blobLen: o.blobLen,
})
},
upgrade(a) {
return onUpgrade(ctxFor(a), deps, allowedOrigins, a.now)
},
reattach(a) {
return onReattach({ ...ctxFor(a), sessionId: a.sessionId }, deps, allowedOrigins, a.now)
},
addSession(e) {
sessions.add(e)
},
async establishSession(opts: HandshakeOpts = {}): Promise<EstablishedSessions> {
const h = opts.host ?? hostA
const hsNow = opts.now ?? now
const client = createClientHandshake({
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
deviceAuthProofProvider: {
proofFor: (_hostId, binding) =>
signDeviceAuthProof(principal(h.accountId), binding, signingKey, hsNow),
},
verifier: {
verify: async (pub, transcript, sig) =>
verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript),
},
pinStore: new MemoryDevicePinStore(),
hostId: h.hostId,
})
const host = createHostHandshake({
signer: { sign: (transcript) => signEd25519(h.signingPriv, transcript) },
agentPubkey: h.agentPubkey,
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow),
})
const clientHello = await client.start()
const rawHostHello = await host.onClientHello(clientHello)
const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello
const clientResult = await client.onHostHello(
hostHello,
opts.verifyAgainstPubkey ?? h.agentPubkey,
)
return {
client: createE2ESession('client', clientResult),
host: createE2ESession('host', host.result!),
spy: new RelaySpy(),
agentPubkey: h.agentPubkey,
}
},
newReplaySealer(sessionId, host = hostA) {
return createReplaySealer(host.hostContentSecret, sessionId, host.replayAlg, replayCrypto)
},
openReplay(sessionId, epoch, env, host = hostA) {
const key = deriveContentKey({
hostContentSecret: host.hostContentSecret,
sessionId,
alg: host.replayAlg,
epoch,
})
return openReplayCiphertext(key, encodeEnvelope(env))
},
async revokeToken(jti, exp = now + 60) {
await revokeToken(jti, exp, revocation)
},
async revoke(scope) {
await revoke(scope, revocation, hosts, bus, now)
},
}
}

12
e2e/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "relay-e2e-suite",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Cross-package end-to-end + adversarial security test harness for the rendezvous-relay service. Wires the REAL P1/P2/P4/P5/P6 exports through in-memory seams and an untrusted-relay attacker vantage; dynamically re-validates the audit findings F1-F6 plus MITM/replay/reflect/INV2/cross-tenant.",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
}
}

View File

@@ -0,0 +1,226 @@
/**
* Auth (P5) attack matrix — dynamically re-validates the confirmed findings F1F4 plus cross-tenant
* isolation and capability single-use. Each case ASSERTS the defense holds through the REAL
* relay-auth enforcement path. Pass an explicit `now` everywhere.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
needsStepUp,
recordStepUp,
verifyCapabilityToken,
verifyDpopProof,
finishAuthentication,
WebAuthnError,
type WebAuthnVerifier,
type AuthenticationResponse,
type WebAuthnCredential,
} from 'relay-auth'
import {
buildRelayWorld,
DEFAULT_NOW,
STRICT_PASSKEY,
NO_STEP_UP,
type RelayWorld,
} from '../harness/world.js'
const NOW = DEFAULT_NOW
describe('adversarial — auth (P5)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
// ── F2 · mandatory step-up is fail-closed on the new-session path ──────────────────────────────
describe('F2 (dynamic): host-driven step-up gate is fail-closed', () => {
it('STRICT policy + principal:null → DENIED 403 step_up_required', async () => {
const { hostA } = world
world.setStepUpPolicy(STRICT_PASSKEY)
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: null,
now: NOW,
})
expect(out).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
expect(world.audit.events.some((e) => e.action === 'stepup' && e.outcome === 'deny')).toBe(true)
})
it('required:false policy + principal:null → allowed (non-step-up host preserved)', async () => {
const { hostA } = world
world.setStepUpPolicy(NO_STEP_UP)
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: null,
now: NOW,
})
expect(out.ok).toBe(true)
})
})
// ── F1 · step-up factor must be method-bound (a passkey requirement ≠ a TOTP step-up) ───────────
describe('F1 (dynamic): step-up freshness is bound to the required method', () => {
it('a fresh TOTP step-up does NOT satisfy a STRICT passkey policy; a passkey step-up DOES', async () => {
const { hostA } = world
world.setStepUpPolicy(STRICT_PASSKEY)
const totp = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'totp', NOW)
expect(needsStepUp(totp, STRICT_PASSKEY, NOW)).toBe(true)
const capT = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const deniedTotp = await world.upgrade({
raw: capT.raw,
dpop: capT.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: totp,
now: NOW,
})
expect(deniedTotp).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
const passkey = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'passkey', NOW)
expect(needsStepUp(passkey, STRICT_PASSKEY, NOW)).toBe(false)
const capP = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const allowedPasskey = await world.upgrade({
raw: capP.raw,
dpop: capP.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: passkey,
now: NOW,
})
expect(allowedPasskey.ok).toBe(true)
})
})
// ── F4 · a thumbprint-matching but malformed DPoP key must fail-safe (resolve false, never throw) ─
describe('F4 (dynamic): verifyDpopProof is totally fail-safe', () => {
it('a proof whose jwk.x is a non-32-byte blob RESOLVES false and yields a clean denied outcome', async () => {
const { hostA } = world
const craft = await world.craftMalformedDpop({
accountId: hostA.accountId,
host: hostA.hostId,
aud: hostA.aud,
})
// Direct: the verifier resolves to false — it never throws / rejects (audit-evasion + DoS fix).
const tok = await verifyCapabilityToken(craft.raw, hostA.aud, NOW)
await expect(verifyDpopProof(tok, craft.dpop, NOW)).resolves.toBe(false)
// Via the full enforcement path: a clean denied AuthzOutcome, not an exception.
const out = await world.upgrade({
raw: craft.raw,
dpop: craft.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(out).toMatchObject({ ok: false, reason: 'dpop_proof_failed' })
// Exactly one audited deny was emitted (no unhandled rejection, no audit evasion).
expect(world.audit.events.some((e) => e.outcome === 'deny')).toBe(true)
})
})
// ── F3 · WebAuthn assertion must be bound to the stored credential id ───────────────────────────
describe('F3: an assertion whose credentialId ≠ the stored credential is rejected', () => {
const RP_ID = 'alice.term.example.com'
const ORIGIN = 'https://alice.term.example.com'
const cred: WebAuthnCredential = {
credentialId: 'cred-1',
accountId: 'acct-A',
publicKey: new Uint8Array([1, 2, 3]),
signCount: 5,
transports: ['internal'],
createdAt: '2026-01-01T00:00:00.000Z',
}
// A permissive verifier that would "verify" ANY assertion — the credentialId cross-check must
// still reject before the verifier's verdict is trusted.
const forgivingVerifier: WebAuthnVerifier = {
verifyRegistration: async () => ({
verified: true,
credentialId: 'cred-1',
publicKey: new Uint8Array([1, 2, 3]),
signCount: 0,
transports: ['internal'],
}),
verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({
verified: true,
newSignCount: stored + 1,
}),
}
it('rejects with WebAuthnError even though the mock verifier returns verified:true', async () => {
const resp: AuthenticationResponse = {
clientChallenge: 'chal',
origin: ORIGIN,
rpId: RP_ID,
credentialId: 'other-cred', // ≠ cred.credentialId
}
await expect(
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
).rejects.toBeInstanceOf(WebAuthnError)
await expect(
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
).rejects.toThrow('credential id mismatch')
})
})
// ── Cross-tenant · a token for account A can never reach a host owned by account B ──────────────
describe('cross-tenant isolation (INV1)', () => {
it('an A-account token presented at host B (owned by B) is DENIED 403', async () => {
const { hostA, hostB } = world
// token.host === requestedHostId (hostB) so host-scope passes; the registry then shows hostB
// belongs to acct-B ≠ token.sub (acct-A) → the cross_tenant gate fires.
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostB.hostId, aud: hostB.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostB.hostId,
aud: hostB.aud,
origin: hostB.origin,
now: NOW,
})
expect(out).toMatchObject({ ok: false, status: 403 })
if (!out.ok) expect(['cross_tenant', 'host_scope_mismatch']).toContain(out.reason)
expect(world.audit.events.some((e) => e.action === 'cross-tenant-attempt' && e.outcome === 'deny')).toBe(true)
})
})
// ── Capability single-use · a token+jti may be upgraded exactly once ────────────────────────────
describe('capability single-use (consumeOnce)', () => {
it('the same token+jti upgraded twice → the second is denied token_replayed', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const first = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(first.ok).toBe(true)
const second = await world.upgrade({
raw: cap.raw,
dpop: await cap.newDpop(NOW), // fresh DPoP so the jti burn (not the DPoP cache) is what denies
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_replayed' })
})
})
})

View File

@@ -0,0 +1,115 @@
/**
* Transport (P4) attack matrix, run from the untrusted-relay / attacker vantage (the RelaySpy).
* Each case ASSERTS the defense holds. Wires the REAL relay-e2e handshake/session + agent replay
* sealer through the harness. Pass an explicit `now` everywhere.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { FingerprintMismatchError } from 'relay-e2e'
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
const NOW = DEFAULT_NOW
function flipByte(b: Uint8Array, i = 0): Uint8Array {
const c = b.slice()
c[i] = (c[i]! ^ 0xff) & 0xff
return c
}
describe('adversarial — transport (P4)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
describe('MITM: host_hello must verify against the registry key', () => {
it('rejects when the client verifies host_hello against a WRONG agentPubkey (no keys derived)', async () => {
// The malicious relay swaps in a key it controls; the client checks against hostB's real key.
await expect(
world.establishSession({ verifyAgainstPubkey: world.hostB.agentPubkey }),
).rejects.toBeInstanceOf(FingerprintMismatchError)
})
it('rejects a tampered host_hello (flipped hostEphPub breaks the transcript signature)', async () => {
await expect(
world.establishSession({
tamperHostHello: (h) => ({ ...h, hostEphPub: flipByte(h.hostEphPub) }),
}),
).rejects.toBeInstanceOf(FingerprintMismatchError)
})
})
it('reflection: a c2h frame fed back into the clients own open is rejected (direction split)', async () => {
const { client } = await world.establishSession()
const c2h = client.seal(utf8('sudo rm -rf /'))
// Reflecting the client's own ciphertext back to it must fail: the client opens with the h2c
// read key + h2c aad label, so the tag over the c2h direction never verifies.
expect(() => client.open(c2h)).toThrow()
})
it('replay: delivering the same valid frame twice → the second open throws (SequenceGuard)', async () => {
const { client, host, spy } = await world.establishSession()
const f0 = spy.forward(client.seal(utf8('whoami')))
expect(fromUtf8(host.open(f0))).toBe('whoami')
expect(() => host.open(f0)).toThrow() // seq 0 already consumed → strict-successor guard
})
it('reorder: delivering seq 1 before seq 0 → open throws (strict successor)', async () => {
const { client, host } = await world.establishSession()
const f0 = client.seal(utf8('cmd-0'))
const f1 = client.seal(utf8('cmd-1'))
expect(() => host.open(f1)).toThrow() // expected seq 0, got 1
// and the in-order pair still works on a fresh pair (sanity)
expect(fromUtf8(host.open(f0))).toBe('cmd-0')
expect(fromUtf8(host.open(f1))).toBe('cmd-1')
})
it('INV2: the RelaySpy ciphertext never contains the plaintext marker', async () => {
const { client, host, spy } = await world.establishSession()
const marker = `TOP_SECRET_${crypto.randomUUID()}`
for (let i = 0; i < 3; i++) {
const wire = spy.forward(client.seal(utf8(`${marker}#${i}`)))
expect(fromUtf8(host.open(wire))).toBe(`${marker}#${i}`)
}
expect(spy.contains(marker)).toBe(false)
expect(spy.captured.length).toBe(3)
})
describe('F6 (dynamic): recoverable K_content must not reuse (key, nonce) across sealer generations', () => {
it('two generations for the same (secret, sessionId) get different epochs → different keys at seq 0', () => {
const sessionId = 'sess-restart-1'
const gen1 = world.newReplaySealer(sessionId)
const gen2 = world.newReplaySealer(sessionId) // simulates an agent restart / re-attach
expect(gen2.epoch).not.toBe(gen1.epoch)
const pt = utf8('IDENTICAL PLAINTEXT AT SEQ 0')
const e1 = gen1.seal(pt)
const e2 = gen2.seal(pt)
// Same deterministic nonce (seq 0) …
expect(e1.seq).toBe(0n)
expect(e2.seq).toBe(0n)
expect(Buffer.from(e1.nonce).equals(Buffer.from(e2.nonce))).toBe(true)
// … yet a FRESH key per generation ⇒ ciphertext + tag differ (no (key, nonce) reuse).
expect(Buffer.from(e1.ciphertext).equals(Buffer.from(e2.ciphertext))).toBe(false)
expect(Buffer.from(e1.tag).equals(Buffer.from(e2.tag))).toBe(false)
// Cross-generation open fails: gen1's epoch-derived key cannot open gen2's frame.
expect(() => world.openReplay(sessionId, gen1.epoch, e2)).toThrow()
// Recoverability WITHIN a generation is preserved (same epoch ⇒ same key).
expect(fromUtf8(world.openReplay(sessionId, gen1.epoch, e1))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
expect(fromUtf8(world.openReplay(sessionId, gen2.epoch, e2))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
})
it('the replay ciphertext itself never contains the plaintext marker (INV2 on the replay path)', () => {
const sealer = world.newReplaySealer('sess-inv2')
const marker = 'REPLAY_MARKER_ABC'
const env = sealer.seal(utf8(marker))
expect(Buffer.from(env.ciphertext).toString('latin1')).not.toContain(marker)
expect(Buffer.from(env.ciphertext).toString('utf8')).not.toContain(marker)
})
})
})

99
e2e/tests/flow.test.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Happy-path full flow, end-to-end across P5 (auth) + P4 (E2E crypto) + P2 (replay), asserting each
* stage. Everything runs through the REAL package exports; only registries/buckets/sockets are faked.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
const NOW = DEFAULT_NOW
describe('relay flow (happy path)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
it('issueCap → upgrade allows (origin ok, DPoP bound, deny-by-default satisfied)', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(out.ok).toBe(true)
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'attach' })
})
it('handshake establishes MATCHING session keys on both sides (client↔host round-trip)', async () => {
const { client, host } = await world.establishSession()
// Matching keys ⇒ bidirectional plaintext round-trips through the direction split.
const c2h = host.open(client.seal(utf8('ls -la')))
expect(fromUtf8(c2h)).toBe('ls -la')
const h2c = client.open(host.seal(utf8('total 0')))
expect(fromUtf8(h2c)).toBe('total 0')
})
it('seals client→host and host→client through the RelaySpy; the RelaySpy never sees plaintext (INV2)', async () => {
const { client, host, spy } = await world.establishSession()
const marker = `PLAINTEXT_${crypto.randomUUID()}`
const up = spy.forward(client.seal(utf8(marker)))
expect(fromUtf8(host.open(up))).toBe(marker)
const down = spy.forward(host.seal(utf8(`reply_${marker}`)))
expect(fromUtf8(client.open(down))).toBe(`reply_${marker}`)
expect(spy.contains(marker)).toBe(false)
expect(spy.captured.length).toBe(2)
})
it('reattach to an own-account session is allowed', async () => {
const { hostA } = world
const sessionId = crypto.randomUUID()
world.addSession({ sessionId, hostId: hostA.hostId, accountId: hostA.accountId })
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.reattach({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
sessionId,
now: NOW,
})
expect(out.ok).toBe(true)
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'reattach' })
})
it('revokeToken then re-presenting the same jti (fresh DPoP) is denied', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const first = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(first.ok).toBe(true)
if (!first.ok) return
await world.revokeToken(first.jti)
const second = await world.upgrade({
raw: cap.raw,
dpop: await cap.newDpop(NOW), // fresh DPoP jti so we reach the revocation gate, not the DPoP cache
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_revoked' })
})
})

21
e2e/tests/smoke.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
// Cross-package resolution smoke test: prove the harness can bare-import the REAL exports of
// every relay package (each resolves via its own node_modules/relay-contracts symlink transitively).
import { issueCapabilityToken, onUpgrade, needsStepUp } from 'relay-auth'
import { createClientHandshake, createHostHandshake, createE2ESession, deriveContentKey } from 'relay-e2e'
import { createReplaySealer } from 'agent'
import { CapabilityTokenSchema } from 'relay-contracts'
describe('cross-package import smoke', () => {
it('resolves the real exports of relay-auth / relay-e2e / agent / relay-contracts', () => {
expect(typeof issueCapabilityToken).toBe('function')
expect(typeof onUpgrade).toBe('function')
expect(typeof needsStepUp).toBe('function')
expect(typeof createClientHandshake).toBe('function')
expect(typeof createHostHandshake).toBe('function')
expect(typeof createE2ESession).toBe('function')
expect(typeof deriveContentKey).toBe('function')
expect(typeof createReplaySealer).toBe('function')
expect(CapabilityTokenSchema).toBeDefined()
})
})

15
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["harness/**/*.ts", "tests/**/*.ts"]
}

8
e2e/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
})

View File

@@ -54,9 +54,15 @@ async function coreAuthorize(
} catch { } catch {
return { outcome: deny(401, 'invalid_capability_token') } return { outcome: deny(401, 'invalid_capability_token') }
} }
// verifyDpopProof is fail-safe, but guard defensively so an unexpected throw becomes a single
// audited deny (emitted once by onUpgrade) rather than an unhandled exception (Finding-4).
try {
if (!(await verifyDpopProof(token, dpop, now))) { if (!(await verifyDpopProof(token, dpop, now))) {
return { outcome: deny(401, 'dpop_proof_failed') } return { outcome: deny(401, 'dpop_proof_failed') }
} }
} catch {
return { outcome: deny(401, 'dpop_proof_failed') }
}
if (await revocation.isRevoked(token.jti)) return { outcome: deny(403, 'token_revoked') } if (await revocation.isRevoked(token.jti)) return { outcome: deny(403, 'token_revoked') }
if (!token.rights.includes(req.requiredRight)) return { outcome: deny(403, 'insufficient_rights') } if (!token.rights.includes(req.requiredRight)) return { outcome: deny(403, 'insufficient_rights') }
if (token.host !== req.requestedHostId) return { outcome: deny(403, 'host_scope_mismatch') } if (token.host !== req.requestedHostId) return { outcome: deny(403, 'host_scope_mismatch') }

View File

@@ -11,6 +11,7 @@ export type CapabilityErrorReason =
| 'bad_signature' | 'bad_signature'
| 'replayed' | 'replayed'
| 'no_cnf' | 'no_cnf'
| 'bad_cnf'
export class CapabilityError extends Error { export class CapabilityError extends Error {
readonly reason: CapabilityErrorReason readonly reason: CapabilityErrorReason

View File

@@ -49,6 +49,8 @@ export async function issueCapabilityToken(
for (const r of a.rights) CapabilityRightSchema.parse(r) for (const r of a.rights) CapabilityRightSchema.parse(r)
if (a.rights.length === 0) throw new CapabilityError('malformed', 'rights must be non-empty') if (a.rights.length === 0) throw new CapabilityError('malformed', 'rights must be non-empty')
if (a.cnfJkt.length === 0) throw new CapabilityError('no_cnf') if (a.cnfJkt.length === 0) throw new CapabilityError('no_cnf')
// A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-] (hardening, Finding-4).
if (!/^[A-Za-z0-9_-]{43}$/.test(a.cnfJkt)) throw new CapabilityError('bad_cnf')
const ttl = Math.max(a.ttlSeconds, CONNECT_TOKEN_MIN_TTL_SEC) const ttl = Math.max(a.ttlSeconds, CONNECT_TOKEN_MIN_TTL_SEC)
const body: TokenBody = { const body: TokenBody = {

View File

@@ -116,6 +116,10 @@ export async function verifyDpopProof(
dpop: DpopContext, dpop: DpopContext,
now: number, now: number,
): Promise<boolean> { ): Promise<boolean> {
// TOTALLY fail-safe (Finding-4): any thrown error — from readCnfJkt, jwkThumbprint,
// importEd25519PublicRaw, or any decode — becomes `false`, never a rejected promise, so the
// INV15 gate always denies through an audited path instead of an unhandled exception.
try {
const expectedJkt = readCnfJkt(token) const expectedJkt = readCnfJkt(token)
const parts = dpop.proofJws.split('.') const parts = dpop.proofJws.split('.')
if (parts.length !== 3) return false if (parts.length !== 3) return false
@@ -148,6 +152,9 @@ export async function verifyDpopProof(
} }
if (!(await verifyEd25519(pubKey, sig, signed))) return false if (!(await verifyEd25519(pubKey, sig, signed))) return false
return rememberDpop(payload.jti, payload.iat + DPOP_MAX_AGE_SEC, now) return rememberDpop(payload.jti, payload.iat + DPOP_MAX_AGE_SEC, now)
} catch {
return false
}
} }
/** Build a DPoP proof (client-side helper; also used by tests). Signs with the ephemeral key. */ /** Build a DPoP proof (client-side helper; also used by tests). Signs with the ephemeral key. */

View File

@@ -124,10 +124,14 @@ export async function runEnforcement(
await emitDeny(deps, ctx, base, 'too_many_sessions', now) await emitDeny(deps, ctx, base, 'too_many_sessions', now)
return deny(403, 'too_many_sessions') return deny(403, 'too_many_sessions')
} }
// 5. Step-up gate (Finding-3, v0.10) — only when a session principal is present. // 5. Step-up gate (Finding-3/F1/F2) — HOST-DRIVEN and FAIL-CLOSED on connect AND reattach.
if (ctx.principal !== null) { // When a host REQUIRES step-up, DENY unless an authenticated principal proves a FRESH step-up
// of the required method. A missing host, a null principal, or a stale/wrong-method step-up all
// fail closed. When the policy does not require step-up, this gate is a no-op.
const host = await deps.hosts.getById(outcome.hostId) const host = await deps.hosts.getById(outcome.hostId)
if (host !== null && needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)) { const policy = deps.stepUpPolicyFor(host as HostRecord)
if (policy.required) {
if (host === null || ctx.principal === null || needsStepUp(ctx.principal, policy, now)) {
await emitDeny(deps, ctx, 'stepup', 'step_up_required', now) await emitDeny(deps, ctx, 'stepup', 'step_up_required', now)
return deny(403, 'step_up_required') return deny(403, 'step_up_required')
} }

View File

@@ -14,14 +14,22 @@ import type { AuthMethod, AuthenticatedPrincipal, StepUpPolicy } from '../../typ
export const DEFAULT_STEPUP_MAX_AGE_SECONDS = 300 as const export const DEFAULT_STEPUP_MAX_AGE_SECONDS = 300 as const
/** Per-host step-up freshness requirement (default: recent passkey step-up). */ /** Per-host step-up freshness requirement (default: recent passkey step-up REQUIRED). */
export function policyForHost(_host: HostRecord): StepUpPolicy { export function policyForHost(_host: HostRecord): StepUpPolicy {
return { maxAgeSeconds: DEFAULT_STEPUP_MAX_AGE_SECONDS, requiredMethod: 'passkey' } return { required: true, maxAgeSeconds: DEFAULT_STEPUP_MAX_AGE_SECONDS, requiredMethod: 'passkey' }
}
/** v0.9 "never required" default: step-up gate is off (used until per-host tiers exist). */
export const NO_STEPUP_POLICY: StepUpPolicy = {
required: false,
maxAgeSeconds: DEFAULT_STEPUP_MAX_AGE_SECONDS,
requiredMethod: 'passkey',
} }
/** /**
* true when step-up is required: never stepped up, OR the last step-up is stale, OR the required * true when step-up is required: never stepped up, OR the last step-up is stale, OR the LAST step-up
* method is not present in `amr` — i.e. a fresh login alone does NOT satisfy it. * was not performed with the required method (method-binding, F1) — i.e. neither a fresh login nor a
* weaker (e.g. TOTP) step-up satisfies a passkey requirement.
*/ */
export function needsStepUp( export function needsStepUp(
principal: AuthenticatedPrincipal, principal: AuthenticatedPrincipal,
@@ -30,16 +38,19 @@ export function needsStepUp(
): boolean { ): boolean {
if (principal.stepUpAt === null) return true if (principal.stepUpAt === null) return true
if (now - principal.stepUpAt > policy.maxAgeSeconds) return true if (now - principal.stepUpAt > policy.maxAgeSeconds) return true
if (!principal.amr.includes(policy.requiredMethod)) return true if ((principal.stepUpMethod ?? null) !== policy.requiredMethod) return true
return false return false
} }
/** Returns a NEW principal with `stepUpAt = now` and `method` added to `amr` (immutable). */ /**
* Returns a NEW principal recording a fresh step-up: `stepUpAt = now`, `stepUpMethod = method`, and
* `method` appended to `amr` (audit trail). Immutable — the original is untouched.
*/
export function recordStepUp( export function recordStepUp(
principal: AuthenticatedPrincipal, principal: AuthenticatedPrincipal,
method: AuthMethod, method: AuthMethod,
now: number, now: number,
): AuthenticatedPrincipal { ): AuthenticatedPrincipal {
const amr = principal.amr.includes(method) ? principal.amr : [...principal.amr, method] const amr = principal.amr.includes(method) ? principal.amr : [...principal.amr, method]
return { ...principal, amr, stepUpAt: now } return { ...principal, amr, stepUpAt: now, stepUpMethod: method }
} }

View File

@@ -32,16 +32,19 @@ export async function finishAuthentication(
origin: string, origin: string,
verifier: WebAuthnVerifier, verifier: WebAuthnVerifier,
now: number, now: number,
): Promise<AuthenticatedPrincipal> { ): Promise<{ principal: AuthenticatedPrincipal; newSignCount: number }> {
if (resp.clientChallenge !== expectedChallenge) throw new WebAuthnError('challenge mismatch') if (resp.clientChallenge !== expectedChallenge) throw new WebAuthnError('challenge mismatch')
if (resp.origin !== origin) throw new WebAuthnError('origin mismatch') if (resp.origin !== origin) throw new WebAuthnError('origin mismatch')
if (resp.rpId !== rpId) throw new WebAuthnError('rpId mismatch') if (resp.rpId !== rpId) throw new WebAuthnError('rpId mismatch')
if (resp.credentialId !== cred.credentialId) throw new WebAuthnError('credential id mismatch')
const result = await verifier.verifyAuthentication( const result = await verifier.verifyAuthentication(
resp, resp,
expectedChallenge, expectedChallenge,
rpId, rpId,
origin, origin,
cred.publicKey,
cred.credentialId,
cred.signCount, cred.signCount,
) )
if (!result.verified) throw new WebAuthnError('assertion not verified') if (!result.verified) throw new WebAuthnError('assertion not verified')
@@ -49,7 +52,7 @@ export async function finishAuthentication(
throw new WebAuthnError('signCount regression (cloned authenticator)') throw new WebAuthnError('signCount regression (cloned authenticator)')
} }
return { const principal: AuthenticatedPrincipal = {
kind: 'human', kind: 'human',
accountId: cred.accountId, accountId: cred.accountId,
principalId: cred.credentialId, principalId: cred.credentialId,
@@ -57,4 +60,5 @@ export async function finishAuthentication(
authAt: now, authAt: now,
stepUpAt: null, stepUpAt: null,
} }
return { principal, newSignCount: result.newSignCount }
} }

View File

@@ -28,6 +28,7 @@ export interface AuthenticationResponse {
readonly clientChallenge: string readonly clientChallenge: string
readonly origin: string readonly origin: string
readonly rpId: string readonly rpId: string
readonly credentialId: string
} }
export interface RegistrationVerification { export interface RegistrationVerification {
@@ -54,6 +55,8 @@ export interface WebAuthnVerifier {
expectedChallenge: string, expectedChallenge: string,
rpId: string, rpId: string,
origin: string, origin: string,
credentialPublicKey: Uint8Array,
expectedCredentialId: string,
storedSignCount: number, storedSignCount: number,
): Promise<AuthenticationVerification> ): Promise<AuthenticationVerification>
} }

View File

@@ -34,6 +34,7 @@ export interface AuthenticatedPrincipal {
readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8) readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8)
readonly authAt: number // epoch seconds of last successful auth (login freshness) readonly authAt: number // epoch seconds of last successful auth (login freshness)
readonly stepUpAt: number | null // epoch seconds of last step-up (T8 freshness); null = never readonly stepUpAt: number | null // epoch seconds of last step-up (T8 freshness); null = never
readonly stepUpMethod?: AuthMethod | null // method used for the LAST step-up (F1 method-binding); absent/null ⇒ none
} }
/** /**
@@ -44,6 +45,7 @@ export interface AuthenticatedPrincipal {
export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const
export interface StepUpPolicy { export interface StepUpPolicy {
readonly required: boolean
readonly maxAgeSeconds: number readonly maxAgeSeconds: number
readonly requiredMethod: AuthMethod readonly requiredMethod: AuthMethod
} }
@@ -141,11 +143,13 @@ export const AuthenticatedPrincipalSchema = z
amr: z.array(AuthMethodSchema).readonly(), amr: z.array(AuthMethodSchema).readonly(),
authAt: z.number().int().nonnegative(), authAt: z.number().int().nonnegative(),
stepUpAt: z.number().int().nonnegative().nullable(), stepUpAt: z.number().int().nonnegative().nullable(),
stepUpMethod: AuthMethodSchema.nullable().optional(),
}) })
.strict() .strict()
export const StepUpPolicySchema = z export const StepUpPolicySchema = z
.object({ .object({
required: z.boolean(),
maxAgeSeconds: z.number().int().nonnegative(), maxAgeSeconds: z.number().int().nonnegative(),
requiredMethod: AuthMethodSchema, requiredMethod: AuthMethodSchema,
}) })

View File

@@ -11,6 +11,7 @@ import {
} from '../src/capability/verify.js' } from '../src/capability/verify.js'
import { jwkThumbprint } from '../src/crypto/thumbprint.js' import { jwkThumbprint } from '../src/crypto/thumbprint.js'
import { CapabilityError } from '../src/capability/errors.js' import { CapabilityError } from '../src/capability/errors.js'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js' import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js'
const NOW = 1_700_000_000 const NOW = 1_700_000_000
@@ -150,5 +151,31 @@ describe('capability token (§4.3)', () => {
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true) expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true)
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false) expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false)
}) })
// F4: a proof whose jwk.x decodes to a NON-32-byte blob makes importEd25519PublicRaw throw.
// verifyDpopProof must be TOTALLY fail-safe and RESOLVE to false, never reject.
it('resolves false (never throws) when the proof key is not a valid 32-byte Ed25519 key', async () => {
// A 16-byte "key" — importEd25519PublicRaw will reject this raw length.
const shortBlob = new Uint8Array(16).fill(7)
// cnf.jkt is computed over the SAME 16-byte blob so the thumbprint check passes and
// execution reaches the risky importEd25519PublicRaw call.
const cnfJkt = await jwkThumbprint(shortBlob)
const raw = await issueFor(signingKey, { cnfJkt })
const tok = await verifyCapabilityToken(raw, AUD, NOW)
const htu = 'https://alice.term.example.com/ws'
const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o)))
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(shortBlob) } })
const p = enc({ htu, htm: 'GET', jti: uuid(), iat: NOW })
const s = encodeBase64UrlBytes(new Uint8Array(64)) // any signature bytes
const proofJws = `${h}.${p}.${s}`
const verify = verifyDpopProof(tok, { proofJws, htu, htm: 'GET' }, NOW)
await expect(verify).resolves.toBe(false)
})
})
it('rejects a malformed cnf.jkt at issue (not a 43-char base64url thumbprint)', async () => {
await expect(issueFor(signingKey, { cnfJkt: 'short' })).rejects.toMatchObject({ reason: 'bad_cnf' })
}) })
}) })

View File

@@ -20,7 +20,7 @@ const NOW = 1_700_000_000
const AUD_A = 'alice.term.example.com' const AUD_A = 'alice.term.example.com'
const ORIGIN_A = `https://${AUD_A}` const ORIGIN_A = `https://${AUD_A}`
const ALLOWED = [ORIGIN_A] const ALLOWED = [ORIGIN_A]
const NEVER_REQUIRED: StepUpPolicy = { maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' } const NEVER_REQUIRED: StepUpPolicy = { required: false, maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' }
describe('enforcement onUpgrade/onReattach (T12)', () => { describe('enforcement onUpgrade/onReattach (T12)', () => {
let signingKey: CryptoKey let signingKey: CryptoKey
@@ -126,8 +126,8 @@ describe('enforcement onUpgrade/onReattach (T12)', () => {
expect(out).toMatchObject({ ok: false, status: 403, reason: 'cross_tenant_session' }) expect(out).toMatchObject({ ok: false, status: 403, reason: 'cross_tenant_session' })
}) })
describe('v0.10 step-up augmentation (Finding-3)', () => { describe('v0.10 step-up augmentation (Finding-3, F1/F2)', () => {
const STRICT: StepUpPolicy = { maxAgeSeconds: 300, requiredMethod: 'passkey' } const STRICT: StepUpPolicy = { required: true, maxAgeSeconds: 300, requiredMethod: 'passkey' }
it('fresh login but stale step-up → 403 step_up_required at onUpgrade', async () => { it('fresh login but stale step-up → 403 step_up_required at onUpgrade', async () => {
deps = { ...deps, stepUpPolicyFor: () => STRICT } deps = { ...deps, stepUpPolicyFor: () => STRICT }
@@ -141,10 +141,26 @@ describe('enforcement onUpgrade/onReattach (T12)', () => {
it('after a fresh step-up the same request → ok:true', async () => { it('after a fresh step-up the same request → ok:true', async () => {
deps = { ...deps, stepUpPolicyFor: () => STRICT } deps = { ...deps, stepUpPolicyFor: () => STRICT }
const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW }) const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW })
const steppedUp = principal('acct-A', { authAt: NOW, stepUpAt: NOW, amr: ['passkey', 'stepup'] }) const steppedUp = principal('acct-A', { authAt: NOW, stepUpAt: NOW, amr: ['passkey', 'stepup'], stepUpMethod: 'passkey' })
expect(needsStepUp(steppedUp, STRICT, NOW)).toBe(false) expect(needsStepUp(steppedUp, STRICT, NOW)).toBe(false)
const out = await onUpgrade(ctxFor(b, { principal: steppedUp }), deps, ALLOWED, NOW) const out = await onUpgrade(ctxFor(b, { principal: steppedUp }), deps, ALLOWED, NOW)
expect(out.ok).toBe(true) expect(out.ok).toBe(true)
}) })
it('STRICT (required) policy + principal:null → 403 step_up_required (F2 fail-closed)', async () => {
deps = { ...deps, stepUpPolicyFor: () => STRICT }
const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW })
const out = await onUpgrade(ctxFor(b, { principal: null }), deps, ALLOWED, NOW)
expect(out).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
expect(audit.events.some((e) => e.action === 'stepup' && e.outcome === 'deny')).toBe(true)
})
it('required:false policy + principal:null → allow (non-step-up host preserved)', async () => {
deps = { ...deps, stepUpPolicyFor: () => NEVER_REQUIRED }
const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW })
const out = await onUpgrade(ctxFor(b, { principal: null }), deps, ALLOWED, NOW)
expect(out.ok).toBe(true)
expect(audit.events.some((e) => e.outcome === 'allow')).toBe(true)
})
}) })
}) })

View File

@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { needsStepUp, recordStepUp, policyForHost } from '../src/human/stepup/stepup.js' import { needsStepUp, recordStepUp, policyForHost, NO_STEPUP_POLICY } from '../src/human/stepup/stepup.js'
import type { StepUpPolicy } from '../src/types.js' import type { StepUpPolicy } from '../src/types.js'
import { principal, makeHost, uuid } from './_helpers.js' import { principal, makeHost, uuid } from './_helpers.js'
const NOW = 1_700_000_000 const NOW = 1_700_000_000
const POLICY: StepUpPolicy = { maxAgeSeconds: 300, requiredMethod: 'passkey' } const POLICY: StepUpPolicy = { required: true, maxAgeSeconds: 300, requiredMethod: 'passkey' }
describe('step-up before session open (T8, Finding-3)', () => { describe('step-up before session open (T8, Finding-3)', () => {
it('fresh login but stepUpAt=null → needsStepUp true (login ≠ step-up)', () => { it('fresh login but stepUpAt=null → needsStepUp true (login ≠ step-up)', () => {
@@ -13,34 +13,61 @@ describe('step-up before session open (T8, Finding-3)', () => {
}) })
it('stale step-up (older than maxAgeSeconds) → true', () => { it('stale step-up (older than maxAgeSeconds) → true', () => {
const p = principal('acct-A', { stepUpAt: NOW - 301, amr: ['passkey', 'stepup'] }) const p = principal('acct-A', { stepUpAt: NOW - 301, amr: ['passkey', 'stepup'], stepUpMethod: 'passkey' })
expect(needsStepUp(p, POLICY, NOW)).toBe(true) expect(needsStepUp(p, POLICY, NOW)).toBe(true)
}) })
it('required method absent from amr → true', () => { it('required method not the LAST step-up method → true', () => {
const p = principal('acct-A', { stepUpAt: NOW, amr: ['totp'] }) const p = principal('acct-A', { stepUpAt: NOW, amr: ['totp'], stepUpMethod: 'totp' })
expect(needsStepUp(p, POLICY, NOW)).toBe(true) expect(needsStepUp(p, POLICY, NOW)).toBe(true)
}) })
it('fresh passkey step-up → false', () => { it('fresh passkey step-up satisfies passkey policy → false (F1)', () => {
const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey'] }) const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey'], stepUpMethod: 'passkey' })
expect(needsStepUp(p, POLICY, NOW)).toBe(false) expect(needsStepUp(p, POLICY, NOW)).toBe(false)
}) })
it('fresh TOTP step-up does NOT satisfy passkey policy → true (F1 factor-downgrade regression)', () => {
// A weaker (TOTP) step-up performed just now must not satisfy a passkey requirement, even though
// it is fresh — the required method itself must be the LAST step-up.
const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey', 'totp'], stepUpMethod: 'totp' })
expect(needsStepUp(p, POLICY, NOW)).toBe(true)
})
it('login-time passkey in amr but no step-up (stepUpMethod null) → true (login ≠ step-up)', () => {
const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey'], stepUpMethod: null })
expect(needsStepUp(p, POLICY, NOW)).toBe(true)
})
it('recordStepUp returns a NEW principal (immutable), original untouched', () => { it('recordStepUp returns a NEW principal (immutable), original untouched', () => {
const p = principal('acct-A', { stepUpAt: null, amr: ['totp'] }) const p = principal('acct-A', { stepUpAt: null, amr: ['totp'], stepUpMethod: 'totp' })
const stepped = recordStepUp(p, 'passkey', NOW) const stepped = recordStepUp(p, 'passkey', NOW)
expect(stepped).not.toBe(p) expect(stepped).not.toBe(p)
expect(p.stepUpAt).toBeNull() expect(p.stepUpAt).toBeNull()
expect(p.amr).toEqual(['totp']) expect(p.amr).toEqual(['totp'])
expect(p.stepUpMethod).toBe('totp')
expect(stepped.stepUpAt).toBe(NOW) expect(stepped.stepUpAt).toBe(NOW)
expect(stepped.amr).toContain('passkey') expect(stepped.amr).toContain('passkey')
expect(needsStepUp(stepped, POLICY, NOW)).toBe(false) expect(needsStepUp(stepped, POLICY, NOW)).toBe(false)
}) })
it('policyForHost yields a passkey freshness requirement', () => { it('recordStepUp stamps stepUpMethod with the method used (F1)', () => {
const p = principal('acct-A', { stepUpAt: null, amr: ['passkey'], stepUpMethod: null })
const stepped = recordStepUp(p, 'passkey', NOW)
expect(stepped.stepUpMethod).toBe('passkey')
expect(stepped.stepUpAt).toBe(NOW)
})
it('policyForHost yields a REQUIRED passkey freshness requirement', () => {
const policy = policyForHost(makeHost('acct-A', uuid())) const policy = policyForHost(makeHost('acct-A', uuid()))
expect(policy.required).toBe(true)
expect(policy.requiredMethod).toBe('passkey') expect(policy.requiredMethod).toBe('passkey')
expect(policy.maxAgeSeconds).toBeGreaterThan(0) expect(policy.maxAgeSeconds).toBeGreaterThan(0)
}) })
it('NO_STEPUP_POLICY is the v0.9 never-required default', () => {
expect(NO_STEPUP_POLICY.required).toBe(false)
expect(NO_STEPUP_POLICY.requiredMethod).toBe('passkey')
expect(NO_STEPUP_POLICY.maxAgeSeconds).toBeGreaterThan(0)
})
}) })

View File

@@ -29,7 +29,7 @@ const NOW = 1_700_000_000
const AUD_A = 'alice.term.example.com' const AUD_A = 'alice.term.example.com'
const AUD_B = 'bob.term.example.com' const AUD_B = 'bob.term.example.com'
const ORIGIN_A = `https://${AUD_A}` const ORIGIN_A = `https://${AUD_A}`
const NEVER: StepUpPolicy = { maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' } const NEVER: StepUpPolicy = { required: false, maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' }
describe('PERMANENT TRIPWIRE — device A can never reach host B (INV1)', () => { describe('PERMANENT TRIPWIRE — device A can never reach host B (INV1)', () => {
let signingKey: CryptoKey let signingKey: CryptoKey

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import {
AuthenticatedPrincipalSchema,
StepUpPolicySchema,
type AuthenticatedPrincipal,
type StepUpPolicy,
} from '../src/types.js'
const basePrincipal = {
kind: 'human' as const,
accountId: 'acct-A',
principalId: 'cred-1',
amr: ['passkey' as const],
authAt: 1_700_000_000,
stepUpAt: null,
}
describe('StepUpPolicy.required (host-driven, fail-closed)', () => {
it('parses a policy with required=true', () => {
const policy: StepUpPolicy = { required: true, maxAgeSeconds: 300, requiredMethod: 'passkey' }
expect(StepUpPolicySchema.parse(policy)).toEqual(policy)
})
it('parses a policy with required=false', () => {
const policy: StepUpPolicy = { required: false, maxAgeSeconds: 300, requiredMethod: 'passkey' }
expect(StepUpPolicySchema.parse(policy)).toEqual(policy)
})
it('rejects a policy missing required', () => {
const bad = { maxAgeSeconds: 300, requiredMethod: 'passkey' }
expect(StepUpPolicySchema.safeParse(bad).success).toBe(false)
})
it('rejects a non-boolean required', () => {
const bad = { required: 'yes', maxAgeSeconds: 300, requiredMethod: 'passkey' }
expect(StepUpPolicySchema.safeParse(bad).success).toBe(false)
})
})
describe('AuthenticatedPrincipal.stepUpMethod (F1 method-binding, optional)', () => {
it('parses a principal that omits stepUpMethod (backward compatible)', () => {
const parsed = AuthenticatedPrincipalSchema.parse(basePrincipal)
expect(parsed.stepUpMethod).toBeUndefined()
})
it('parses a principal with an explicit stepUpMethod', () => {
const p: AuthenticatedPrincipal = { ...basePrincipal, stepUpAt: 1_700_000_100, stepUpMethod: 'passkey' }
expect(AuthenticatedPrincipalSchema.parse(p).stepUpMethod).toBe('passkey')
})
it('parses a principal with stepUpMethod=null (no step-up)', () => {
const parsed = AuthenticatedPrincipalSchema.parse({ ...basePrincipal, stepUpMethod: null })
expect(parsed.stepUpMethod).toBeNull()
})
it('rejects an invalid stepUpMethod value', () => {
const bad = { ...basePrincipal, stepUpMethod: 'sms' }
expect(AuthenticatedPrincipalSchema.safeParse(bad).success).toBe(false)
})
})

View File

@@ -21,14 +21,14 @@ const verifier: WebAuthnVerifier = {
signCount: 0, signCount: 0,
transports: ['internal'], transports: ['internal'],
}), }),
verifyAuthentication: async (_r, _c, _rp, _o, stored) => ({ verified: true, newSignCount: stored + 1 }), verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({ verified: true, newSignCount: stored + 1 }),
} }
function regResp(over: Partial<RegistrationResponse> = {}): RegistrationResponse { function regResp(over: Partial<RegistrationResponse> = {}): RegistrationResponse {
return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, ...over } return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, ...over }
} }
function authResp(over: Partial<AuthenticationResponse> = {}): AuthenticationResponse { function authResp(over: Partial<AuthenticationResponse> = {}): AuthenticationResponse {
return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, ...over } return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, credentialId: 'cred-1', ...over }
} }
const cred: WebAuthnCredential = { const cred: WebAuthnCredential = {
credentialId: 'cred-1', credentialId: 'cred-1',
@@ -68,16 +68,32 @@ describe('WebAuthn / Passkey (T5, primary)', () => {
it('happy path auth yields a principal with amr:[passkey]', async () => { it('happy path auth yields a principal with amr:[passkey]', async () => {
const startOpts = await startAuthentication('acct-A', RP_ID) const startOpts = await startAuthentication('acct-A', RP_ID)
expect(startOpts.rpId).toBe(RP_ID) expect(startOpts.rpId).toBe(RP_ID)
const p = await finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, verifier, NOW) const { principal: p } = await finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, verifier, NOW)
expect(p.accountId).toBe('acct-A') expect(p.accountId).toBe('acct-A')
expect(p.amr).toEqual(['passkey']) expect(p.amr).toEqual(['passkey'])
expect(p.authAt).toBe(NOW) expect(p.authAt).toBe(NOW)
}) })
it('F3: rejects an assertion whose credentialId does not match the stored credential', async () => {
const forgiving: WebAuthnVerifier = {
...verifier,
verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({ verified: true, newSignCount: stored + 1 }),
}
await expect(
finishAuthentication(cred, authResp({ credentialId: 'other-cred' }), 'chal', RP_ID, ORIGIN, forgiving, NOW),
).rejects.toThrow('credential id mismatch')
})
it('F5: returns the verifier-advanced newSignCount so the caller can persist it', async () => {
const { principal: p, newSignCount } = await finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, verifier, NOW)
expect(p.accountId).toBe('acct-A')
expect(newSignCount).toBe(cred.signCount + 1)
})
it('rejects a signCount regression (cloned authenticator signal)', async () => { it('rejects a signCount regression (cloned authenticator signal)', async () => {
const regressing: WebAuthnVerifier = { const regressing: WebAuthnVerifier = {
...verifier, ...verifier,
verifyAuthentication: async () => ({ verified: true, newSignCount: 3 }), // < stored 5 verifyAuthentication: async () => ({ verified: true, newSignCount: 3 }), // < stored 5 (adapts to {principal,newSignCount} return)
} }
await expect( await expect(
finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, regressing, NOW), finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, regressing, NOW),

View File

@@ -117,10 +117,23 @@ export interface AuthorizedDeviceContext {
readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged/sent to relay readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged/sent to relay
} }
/** FIX 3 replay content-key derivation params (§4.4). */ /**
* FIX 3 replay content-key derivation params (§4.4).
*
* FIX 3b / F6 (anti-nonce-reuse): K_content = HKDF(hostContentSecret, salt=sessionId, info=const)
* was byte-identical for a given (host, sessionId), yet the agent-side sealer resets its
* deterministic-nonce seq to 0 on every reconstruction (restart / re-attach). Two sealer
* GENERATIONS would therefore seal distinct plaintext under the SAME (key, nonce) — a
* catastrophic AEAD reuse. The per-generation `epoch` is folded into K_content so a restart /
* new generation yields a FRESH key even for the same (hostContentSecret, sessionId); seq=0 can
* never collide across generations. Recoverability WITHIN one generation (same epoch ⇒ same key)
* is preserved.
*/
export interface ReplayKeyParams { export interface ReplayKeyParams {
readonly hostContentSecret: Uint8Array readonly hostContentSecret: Uint8Array
readonly sessionId: string readonly sessionId: string
readonly alg: AeadAlg readonly alg: AeadAlg
readonly epoch: string // FIX 3b / F6: fresh per-sealer-generation diversifier folded into K_content;
// NON-SECRET, carried with the ring buffer so the browser re-derives the key.
} }
export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const

View File

@@ -21,12 +21,36 @@ import { openFrame, sealFrame } from './session.js'
const encoder = new TextEncoder() const encoder = new TextEncoder()
/** HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO) → per-session, per-host content key. */ /**
* ASCII unit separator (0x1f). Domain-separates sessionId ‖ epoch inside the HKDF salt so
* distinct (sessionId, epoch) pairs can never alias via concatenation. Neither field contains
* it: sessionId is an opaque id and epoch is a generated non-secret diversifier.
*/
const SALT_FIELD_SEPARATOR = 0x1f
/** salt = utf8(sessionId) ‖ 0x1f ‖ utf8(epoch) — unambiguous per-session, per-generation binding. */
function replaySalt(sessionId: string, epoch: string): Uint8Array {
const sessionBytes = encoder.encode(sessionId)
const epochBytes = encoder.encode(epoch)
const salt = new Uint8Array(sessionBytes.length + 1 + epochBytes.length)
salt.set(sessionBytes, 0)
salt[sessionBytes.length] = SALT_FIELD_SEPARATOR
salt.set(epochBytes, sessionBytes.length + 1)
return salt
}
/**
* HKDF(hostContentSecret, salt=sessionId ‖ 0x1f ‖ epoch, info=REPLAY_KDF_INFO) → per-session,
* per-host, per-generation content key (F6). A fresh `epoch` per sealer generation yields a fresh
* key even for the same (hostContentSecret, sessionId), so a seq=0 reset after restart/re-attach
* can never collide with a prior generation's (key, nonce). Recoverability WITHIN one generation is
* preserved: same (secret, sessionId, alg, epoch) ⇒ byte-identical key.
*/
export function deriveContentKey(p: ReplayKeyParams): AeadKey { export function deriveContentKey(p: ReplayKeyParams): AeadKey {
const raw = hkdf( const raw = hkdf(
sha256, sha256,
p.hostContentSecret, p.hostContentSecret,
encoder.encode(p.sessionId), replaySalt(p.sessionId, p.epoch),
encoder.encode(REPLAY_KDF_INFO), encoder.encode(REPLAY_KDF_INFO),
AEAD_KEY_BYTES, AEAD_KEY_BYTES,
) )

View File

@@ -37,8 +37,8 @@ describe('T11 multi-device adapters (authenticated channel, NOT a QR)', () => {
expect(result.aead).toBe('xchacha20-poly1305') expect(result.aead).toBe('xchacha20-poly1305')
expect(label).toBeTruthy() expect(label).toBeTruthy()
} }
const kA = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) const kA = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-0' })
const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-0' })
expect(bytesToHex(unwrapAeadKey(kA).raw)).toBe(bytesToHex(unwrapAeadKey(kB).raw)) expect(bytesToHex(unwrapAeadKey(kA).raw)).toBe(bytesToHex(unwrapAeadKey(kB).raw))
}) })

View File

@@ -6,10 +6,16 @@ import { AeadOpenError } from '../src/errors.js'
import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from '../src/replay-key.js' import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from '../src/replay-key.js'
import { bytesToHex, fromUtf8, utf8 } from './helpers.js' import { bytesToHex, fromUtf8, utf8 } from './helpers.js'
const params = (secret: Uint8Array, sessionId: string, alg: AeadAlg = 'aes-256-gcm'): ReplayKeyParams => ({ const params = (
secret: Uint8Array,
sessionId: string,
alg: AeadAlg = 'aes-256-gcm',
epoch = 'gen-0',
): ReplayKeyParams => ({
hostContentSecret: secret, hostContentSecret: secret,
sessionId, sessionId,
alg, alg,
epoch,
}) })
describe('T10 recoverable replay content-key', () => { describe('T10 recoverable replay content-key', () => {
@@ -48,6 +54,29 @@ describe('T10 recoverable replay content-key', () => {
expect(() => openReplayCiphertext(deriveContentKey(params(secretA, 's2')), wire)).toThrow(AeadOpenError) expect(() => openReplayCiphertext(deriveContentKey(params(secretA, 's2')), wire)).toThrow(AeadOpenError)
}) })
it('F6: SAME (secret, sessionId) but DIFFERENT epoch ⇒ different raw key bytes (no seq=0 (key,nonce) reuse across generations)', () => {
const secret = new Uint8Array(32).fill(0x66)
// Two sealer generations (e.g. before/after an agent restart) that both reset seq to 0.
const kA = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-A'))
const kB = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-B'))
// Raw key bytes MUST differ, so sealFrame(kA, 0n) and sealFrame(kB, 0n) never share (key, nonce).
expect(bytesToHex(unwrapAeadKey(kA).raw)).not.toBe(bytesToHex(unwrapAeadKey(kB).raw))
// And a frame sealed under one epoch cannot be opened with the other epoch's key.
const wireA = encodeEnvelope(sealReplayFrame(kA, 0n, utf8('generation A, seq 0')))
expect(() => openReplayCiphertext(kB, wireA)).toThrow(AeadOpenError)
})
it('F6: SAME (secret, sessionId, epoch) ⇒ identical key — recoverability WITHIN a generation preserved', () => {
const secret = new Uint8Array(32).fill(0x67)
const kSeal = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-E'))
const wire = encodeEnvelope(sealReplayFrame(kSeal, 0n, utf8('same-epoch replay')))
// Browser re-derives the key for the SAME epoch E carried with the ring buffer.
const kReDerived = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-E'))
expect(bytesToHex(unwrapAeadKey(kSeal).raw)).toBe(bytesToHex(unwrapAeadKey(kReDerived).raw))
expect(fromUtf8(openReplayCiphertext(kReDerived, wire))).toBe('same-epoch replay')
})
it('revocation (INV12): a revoked wrap yields no secret → no key derivable for that host going forward', () => { it('revocation (INV12): a revoked wrap yields no secret → no key derivable for that host going forward', () => {
// The unwrap mechanism is P5/P2; P4 asserts derivation is GATED on having the unwrapped secret. // The unwrap mechanism is P5/P2; P4 asserts derivation is GATED on having the unwrapped secret.
const revokedUnwrap = (): Uint8Array => { const revokedUnwrap = (): Uint8Array => {

View File

@@ -16,11 +16,23 @@ import { createApiClient } from '../api-client'
import { mountPreviewGrid } from '../preview-grid' import { mountPreviewGrid } from '../preview-grid'
import type { ReplaySource } from '../preview-client' import type { ReplaySource } from '../preview-client'
/**
* FIX 3b / F6 placeholder epoch. The real per-generation `epoch` MUST be served by P1/P2 alongside
* the ciphertext ring buffer (the agent stamps a fresh epoch each time it reconstructs the sealer and
* resets seq to 0). This constant only keeps the `ReplaySource` shape explicit until those endpoints
* land; the stub still throws, so no frame is ever decrypted under it.
*/
const PLACEHOLDER_EPOCH = 'PENDING_P1_P2_EPOCH'
async function loadReplay( async function loadReplay(
_hostId: string, _hostId: string,
): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> { ): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> {
// TODO(P5/P1): fetch ciphertext replay + hostContentSecret (post auth/step-up). Not yet available. // TODO(P5/P1/P2): fetch ciphertext replay + hostContentSecret (post auth/step-up) AND the real
throw new Error('replay source not yet wired (pending P5/P1 endpoints)') // per-generation `epoch` (FIX 3b / F6) that the frames were sealed under. Until then the shape is:
// { replay: { sessionId, alg, epoch: PLACEHOLDER_EPOCH, frames }, hostContentSecret }
// Not yet available → throw so a card shows "unavailable" rather than a fabricated screen.
void PLACEHOLDER_EPOCH
throw new Error('replay source not yet wired (pending P5/P1/P2 endpoints)')
} }
function boot(): void { function boot(): void {

View File

@@ -6,10 +6,11 @@
* Ring-buffer replay survives a reload because the agent (P2) sealed each stored frame with * Ring-buffer replay survives a reload because the agent (P2) sealed each stored frame with
* `sealReplayFrame` under the RECOVERABLE content key `K_content` — NOT the ephemeral live * `sealReplayFrame` under the RECOVERABLE content key `K_content` — NOT the ephemeral live
* `DirectionalKeys` (which are re-derived per handshake and lost on reload). The browser re-derives * `DirectionalKeys` (which are re-derived per handshake and lost on reload). The browser re-derives
* the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg })` (§4.4 FIX 3; * the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg, epoch })` (§4.4 FIX 3;
* `hostContentSecret` obtained via P5 after auth/step-up) and decrypts each payload with * `hostContentSecret` obtained via P5 after auth/step-up) and decrypts each payload with
* `openReplayCiphertext`. A wrong/ephemeral key throws (AEAD tag) → the card shows "unavailable", * `openReplayCiphertext`. A wrong/ephemeral key — or a `ReplaySource.epoch` that doesn't match the
* never a torn/garbled screen (cross-host/session isolation, INV1). * generation the frames were sealed under (FIX 3b / F6) — throws (AEAD tag) → the card shows
* "unavailable", never a torn/garbled screen (cross-host/session isolation, INV1).
* *
* The §4.4 crypto (`deriveContentKey`/`openReplayCiphertext`) is imported from `relay-e2e` and * The §4.4 crypto (`deriveContentKey`/`openReplayCiphertext`) is imported from `relay-e2e` and
* injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in * injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in
@@ -17,10 +18,17 @@
*/ */
import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts' import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts'
/** Ciphertext ring-buffer replay for one host/session. */ /** Ciphertext ring-buffer replay for one host/session (one sealer generation). */
export interface ReplaySource { export interface ReplaySource {
readonly sessionId: string readonly sessionId: string
readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay) readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay)
// FIX 3b / F6: the per-generation `epoch` under which THESE frames were sealed. The agent resets
// its deterministic-nonce seq to 0 on every reconstruction (restart/re-attach); folding a fresh
// epoch into K_content gives each generation a fresh key, so a seq=0 reset can never collide with
// a prior generation's (key, nonce). NON-SECRET — carried with the ring buffer so the browser
// re-derives the matching key. Frames from a DIFFERENT epoch derive a different key → AEAD tag
// fails → "unavailable" (never a torn screen).
readonly epoch: string
readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope
} }
@@ -85,6 +93,7 @@ export function mountPreviewClient(
hostContentSecret, hostContentSecret,
sessionId: replay.sessionId, sessionId: replay.sessionId,
alg: replay.alg, alg: replay.alg,
epoch: replay.epoch, // F6: bind the key to THIS generation; a mismatched epoch → wrong key.
}) })
} catch { } catch {
showUnavailable() showUnavailable()

View File

@@ -52,10 +52,12 @@ describe('default (real-xterm) loaders behind the DI seam', () => {
it('mountPreviewClient renders via the default read-only xterm loader', async () => { it('mountPreviewClient renders via the default read-only xterm loader', async () => {
const secret = randomBytes(32) const secret = randomBytes(32)
const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) const epoch = 'gen-1' // FIX 3b / F6: seal + declare under the same generation.
const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch })
const replay: ReplaySource = { const replay: ReplaySource = {
sessionId: 's', sessionId: 's',
alg: 'aes-256-gcm', alg: 'aes-256-gcm',
epoch,
frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))], frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))],
} }
const card = document.createElement('div') const card = document.createElement('div')

View File

@@ -207,7 +207,7 @@ describe('preview-client unavailable path', () => {
const card = document.createElement('div') const card = document.createElement('div')
const client = mountPreviewClient( const client = mountPreviewClient(
card, card,
{ sessionId: 's', alg: 'aes-256-gcm', frames: [new Uint8Array([1])] }, { sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-1', frames: [new Uint8Array([1])] },
new Uint8Array(32), new Uint8Array(32),
{ cols: 80, rows: 24 }, { cols: 80, rows: 24 },
{ {

View File

@@ -11,16 +11,38 @@ import {
const HOST_SECRET = randomBytes(32) const HOST_SECRET = randomBytes(32)
const SESSION_ID = 'sess-1' const SESSION_ID = 'sess-1'
const ALG = 'aes-256-gcm' as const const ALG = 'aes-256-gcm' as const
const EPOCH = 'gen-1' // FIX 3b / F6: the sealer generation these fixtures seal + declare under.
/** Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. */ interface SealOpts {
function sealedReplay(lines: readonly string[], secret = HOST_SECRET): ReplaySource { readonly secret?: Uint8Array
const k = deriveContentKey({ hostContentSecret: secret, sessionId: SESSION_ID, alg: ALG }) /** Epoch the FRAMES are actually sealed under (the key that encrypts). */
readonly sealEpoch?: string
/** Epoch the ReplaySource DECLARES (the key the browser will re-derive). Defaults to sealEpoch. */
readonly declaredEpoch?: string
}
/**
* Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. Recoverability
* holds because the frames are sealed with the SAME (secret, sessionId, alg, epoch) the returned
* ReplaySource declares. For the F6 regression, `sealEpoch` and `declaredEpoch` are set apart so the
* browser derives a different key than the frames were sealed under.
*/
function sealedReplay(lines: readonly string[], opts: SealOpts = {}): ReplaySource {
const secret = opts.secret ?? HOST_SECRET
const sealEpoch = opts.sealEpoch ?? EPOCH
const declaredEpoch = opts.declaredEpoch ?? sealEpoch
const k = deriveContentKey({
hostContentSecret: secret,
sessionId: SESSION_ID,
alg: ALG,
epoch: sealEpoch,
})
const frames = lines.map((line, i) => const frames = lines.map((line, i) =>
// sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens. // sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens.
// openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec. // openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec.
encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))), encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))),
) )
return { sessionId: SESSION_ID, alg: ALG, frames } return { sessionId: SESSION_ID, alg: ALG, epoch: declaredEpoch, frames }
} }
// The replay codec: relay-e2e's openReplayCiphertext expects the encoded envelope bytes. // The replay codec: relay-e2e's openReplayCiphertext expects the encoded envelope bytes.
@@ -67,6 +89,35 @@ describe('mountPreviewClient (T9)', () => {
expect(term.written).toEqual([]) // never wrote a torn screen expect(term.written).toEqual([]) // never wrote a torn screen
}) })
it('F6: a ReplaySource epoch that does NOT match the sealed generation → "unavailable", never a torn screen', async () => {
const card = document.createElement('div')
const term = mockTerminal()
// Frames sealed under generation "gen-1", but the ReplaySource declares a DIFFERENT epoch
// ("gen-2") — as if the ring buffer were mislabeled with the wrong generation. The browser
// re-derives a different K_content → AEAD tag fails → unavailable (no garbled render).
const replay = sealedReplay(['stale-screen'], { sealEpoch: 'gen-1', declaredEpoch: 'gen-2' })
const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, {
...realDeps,
createTerminal: () => term,
})
await client.render()
expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable')
expect(term.written).toEqual([]) // never wrote a torn screen from the wrong generation
})
it('F6: a matching per-generation epoch preserves recoverability (frames render)', async () => {
const card = document.createElement('div')
const term = mockTerminal()
// sealEpoch === declaredEpoch (both "gen-7") ⇒ same key within one generation ⇒ replay recovers.
const replay = sealedReplay(['line-A', 'line-B'], { sealEpoch: 'gen-7', declaredEpoch: 'gen-7' })
const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, {
...realDeps,
createTerminal: () => term,
})
await client.render()
expect(term.written).toEqual(['line-A', 'line-B'])
})
it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => { it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => {
const card = document.createElement('div') const card = document.createElement('div')
const term = mockTerminal() const term = mockTerminal()

View File

@@ -27,9 +27,16 @@ function host(sub: string): HostRecord {
} }
} }
const EPOCH = 'gen-1' // FIX 3b / F6: per-generation key diversifier (seal + declare under the same one).
function sealed(sessionId: string, secret: Uint8Array, line: string): ReplaySource { function sealed(sessionId: string, secret: Uint8Array, line: string): ReplaySource {
const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG }) const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG, epoch: EPOCH })
return { sessionId, alg: ALG, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))] } return {
sessionId,
alg: ALG,
epoch: EPOCH,
frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))],
}
} }
function fakeApi(hosts: readonly HostRecord[]): ApiClient { function fakeApi(hosts: readonly HostRecord[]): ApiClient {