Merge security/relay-auth-audit-fixes: relay-auth/relay-e2e security audit
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
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:
@@ -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
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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 0x30–0x66 → 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 0x30–0x66 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]))
|
||||||
|
|||||||
@@ -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.95–0.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 持久化的最小依赖惯例)。
|
||||||
|
|||||||
169
docs/REVIEW_RELAY_SECURITY.md
Normal file
169
docs/REVIEW_RELAY_SECURITY.md
Normal 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 (F1–F5) 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
141
e2e/harness/dpop.ts
Normal 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
132
e2e/harness/fakes.ts
Normal 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
34
e2e/harness/spy.ts
Normal 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
332
e2e/harness/world.ts
Normal 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
12
e2e/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
e2e/tests/adversarial-auth.test.ts
Normal file
226
e2e/tests/adversarial-auth.test.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* Auth (P5) attack matrix — dynamically re-validates the confirmed findings F1–F4 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
115
e2e/tests/adversarial-transport.test.ts
Normal file
115
e2e/tests/adversarial-transport.test.ts
Normal 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 client’s 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
99
e2e/tests/flow.test.ts
Normal 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
21
e2e/tests/smoke.test.ts
Normal 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
15
e2e/tsconfig.json
Normal 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
8
e2e/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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') }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
60
relay-auth/test/types.test.ts
Normal file
60
relay-auth/test/types.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user