diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 729d014b..fca46908 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -847,6 +847,35 @@ jobs: # in people's files. This diffs only what goes on the wire. run: node scripts/test-relay-protocol.ts + - name: relay authorization rig + # Drives the real Room durable object with fake sockets and storage. + # What it guards: the relay stamps a fanned-out frame with exactly what + # it verified and never with what it did not; a socket that merely + # hash-matches the room's public key is challenged and handed no blob + # write ticket, cannot latch the room, and cannot answer with a forged, + # replayed or cross-room proof; revocation re-mints the ticket to + # proven sockets only; and a snapshot claiming a seq the room has not + # reached is refused rather than allowed to prune the op log. Every + # one of these is silent when it regresses — the relay keeps working + # and simply trusts more than it should. + run: node scripts/test-relay-auth.ts + + - name: client vouch rig — the other half of read-only + # Drives a REAL OnlineTransport (kernel's and dash's twin) through a + # fake socket in a signed room. Every copy holds the room key, so a + # reader can encrypt an op batch and the blind relay fans it out + # unstamped; the client's `vouched()` is the only thing between that + # frame and every live editor applying it. With `vouched` mutated to + # `return true`, every other rig in the tree stayed green — security + # found that by mutation, and this rig is the answer: unstamped ops and + # snapshots refused, stamped ones applied, presence unaffected, legacy + # r-rooms permissive. Bundled because the kernel transport uses + # parameter properties, which node's strip-only loader rejects. + run: | + slides/node_modules/.bin/esbuild scripts/test-sync-vouch.ts --bundle --platform=node --format=esm \ + --outfile="$RUNNER_TEMP/test-sync-vouch.mjs" + node "$RUNNER_TEMP/test-sync-vouch.mjs" + - name: dash validator rig # 109 checks over the shapes that read as DATA rather than as damage — # a column shorter than the sheet has rows, a dictionary index past the diff --git a/dash/src/sync/online.ts b/dash/src/sync/online.ts index 919251ce..aa4f4b47 100644 --- a/dash/src/sync/online.ts +++ b/dash/src/sync/online.ts @@ -68,13 +68,17 @@ export async function signFrame(key: CryptoKey, i: string, d: string): Promise { - const key = await importSignKey(privB64) +/** Sign an arbitrary string with an already-imported signing key. */ +export async function signWith(key: CryptoKey, text: string): Promise { const sig = await crypto.subtle.sign(SIGN_ALG, key, new TextEncoder().encode(text)) return b64u.enc(new Uint8Array(sig)) } +/** Sign an arbitrary string with a b64url PKCS#8 private key (cert chains). */ +export async function signText(privB64: string, text: string): Promise { + return signWith(await importSignKey(privB64), text) +} + async function mintKeypair(): Promise<{ pub: string; priv: string }> { const kp = (await crypto.subtle.generateKey(EC, true, ['sign', 'verify'])) as CryptoKeyPair return { @@ -270,19 +274,40 @@ export class OnlineTransport implements Transport { this.onFrame = onFrame this.hooks = hooks this.auth = auth + this.writeReadyP = new Promise((r) => { this.resolveWriteReady = r }) void this.init(room) } /** Credentials a blob layer would need: the relay origin, room name, the * possession-proof token, and the raw room key. dash does not offload - * assets yet; the accessor exists so that layer needs no transport change. */ + * assets yet; the accessor exists so that layer needs no transport change. + * `tok` is the write ticket when this socket proved its key, else the read + * token — the same rule as the kernel transport, so the day dash offloads + * assets it inherits the ticketed path without a wire change. */ blobCreds(): { base: string; room: string; tok: string; rawKey: Uint8Array } | null { if (!this.roomName || !this.tokValue) return null - return { base: this.originValue, room: this.roomName, tok: this.tokValue, rawKey: b64u.dec(this.keyB64) } + return { + base: this.originValue, + room: this.roomName, + tok: this.writeTicket || this.tokValue, + rawKey: b64u.dec(this.keyB64), + } + } + /** Resolves once uploads may proceed: the ticket arrived, or `ready` came + * without a challenge (older relay, or we are a reader). Mirrors the kernel. */ + writeReady(): Promise { + return this.writeReadyP } + private writeReadyP: Promise + private resolveWriteReady: () => void = () => {} private roomName = '' private tokValue = '' private originValue = '' + /** relay-issued blob write ticket; null until a PROVEN socket is handed one */ + private writeTicket: string | null = null + /** `w`-room: the relay's stamps mean something; unstamped content frames are + * refused. Legacy `r` rooms sign nothing and stay on the older trust model. */ + private signedRoom = false private async init(room: string) { const raw = b64u.dec(this.keyB64) @@ -294,6 +319,7 @@ export class OnlineTransport implements Transport { this.originValue = u.origin this.roomName = u.pathname.replace(/^\/d\//, '') this.tokValue = tok + this.signedRoom = this.roomName[0] === 'w' } catch { /* malformed room url — blobs simply stay unavailable */ } // Writers sign op frames; readers omit auth and the relay drops their // writes. Two writer shapes: DIRECT (the presented key hash-matches the @@ -304,14 +330,18 @@ export class OnlineTransport implements Transport { if (a?.kind === 'direct') { if (a.priv) { try { this.signKey = await importSignKey(a.priv) } catch { this.signKey = null } } this.myPub = a.pub - this.url = `${room}?tok=${tok}&w=${a.pub}` + // bt=1: we understand the blob write ticket. The relay only starts + // REQUIRING it for uploads once a PROVEN writer has said so — the kernel + // transport sends it too; the two clients must spell the wire the same + // (scripts/test-relay-protocol.ts). + this.url = `${room}?tok=${tok}&bt=1&w=${a.pub}` } else if (a?.kind === 'chain') { const id = await deviceIdentity(this.docId) try { this.signKey = await importSignKey(id.priv) } catch { this.signKey = null } this.myPub = id.pub const iv = a.invite const dg = await signText(iv.priv, `dlg.${id.pub}`) - this.url = `${room}?tok=${tok}&w=${id.pub}&o=${a.owner}` + + this.url = `${room}?tok=${tok}&bt=1&w=${id.pub}&o=${a.owner}` + `&ivp=${iv.pub}&ivr=${iv.role}&ive=${iv.exp ?? 0}&ivs=${iv.sig}&dg=${dg}` } else { this.url = `${room}?tok=${tok}` @@ -400,6 +430,12 @@ export class OnlineTransport implements Transport { * the sender re-sent the identical doomed frame forever. */ private handleRefusal(env: RefusedEnv) { + if ((env.code as string) === 'snap-ahead') { + // the relay refused a snapshot claiming a seq it has not reached — nothing + // lost, the log is intact; loud because this copy's counter drifted + console.warn('[bento-sync] relay refused a snapshot ahead of the room’s seq — nothing lost', env) + return + } if (env.code === 'rate-limited') { this.throttle(typeof env.retryInMs === 'number' ? env.retryInMs : 10_000) return @@ -479,7 +515,15 @@ export class OnlineTransport implements Transport { } private async onEnvelope(text: string) { - let env: { i?: string; d?: string; q?: number; snap?: number; ctl?: string; p?: string } & RefusedEnv + let env: { + i?: string; d?: string; q?: number; snap?: number; ctl?: string; p?: string + /** the writer signature the relay VERIFIED before fanning this out */ + g?: string + /** blob write ticket — only ever sent to a socket that proved its key */ + wt?: string + /** possession nonce on `ready`: sign it to prove the `?w=` key is ours */ + c?: string + } & RefusedEnv try { env = JSON.parse(text) } catch { @@ -491,7 +535,18 @@ export class OnlineTransport implements Transport { if (env.p && env.p === this.myPub) { console.info('[bento-sync] this copy’s access was revoked by the owner') this.close() + return } + // a removal re-mints the room's blob ticket; the relay sends the + // replacement to PROVEN sockets only + if (typeof env.wt === 'string') this.writeTicket = env.wt + return + } + // The ticket arrives on its own frame, after `prove` — never on `ready`. + // See the kernel transport for why a hash-match on ?w= is not possession. + if (env.ctl === 'wt') { + if (typeof env.wt === 'string') this.writeTicket = env.wt + this.resolveWriteReady() return } if (env.ctl === 'refused') { @@ -505,6 +560,14 @@ export class OnlineTransport implements Transport { this.maybeSnapshot(env.q) } if (env.ctl === 'ready') { + // `c` is the relay's possession challenge. Answer it and the ticket + // follows on its own `wt` frame; no `c` = older relay or a reader, and + // uploads take the room token, so writes are released now. + if (typeof env.c === 'string' && this.signKey && this.roomName) { + void this.prove(env.c) + } else { + this.resolveWriteReady() + } this.inReplay = false const wantSnap = this.hooks.onReady(this.replaySeen, env.q ?? 0) this.replaySeen = new Set() @@ -528,17 +591,43 @@ export class OnlineTransport implements Transport { } if (typeof env.q === 'number' && env.q > this.seq) this.seq = env.q if (env.snap === 1) { + if (!this.vouched(env)) return const s = payload as { doc: DashDoc; state: SyncStateJSON } if (s && s.doc && s.state) this.hooks.onSnap(s.doc, s.state) return } const frame = payload as Frame + // Decrypting proves the sender holds the READ key, which every copy does. + // Authorship is the relay's to vouch for; refuse content frames without + // its stamp. Same rule and same reasons as the kernel transport. + if ((frame.t === 'ops' || frame.t === 'snap') && !this.vouched(env)) return if (this.inReplay && frame.t === 'ops') { for (const op of frame.ops) this.replaySeen.add(`${op.a}:${op.s}`) } this.onFrame(frame) } + /** In a signed room the relay stamps exactly what it checked: `q` on a + * persisted frame, an echoed `g` on a verified ephemeral one. Unstamped + * content-bearing frames came from someone with the room key and nothing + * more. Legacy `r` rooms have no signatures to check. */ + private vouched(env: { q?: number; g?: string }): boolean { + return !this.signedRoom || typeof env.q === 'number' || typeof env.g === 'string' + } + + /** Answer the relay's possession challenge: `prove..` signed + * with the key presented as `?w=`. Room name in the text → no cross-room + * replay; nonce single-use → no same-room replay. */ + private async prove(nonce: string) { + if (!this.signKey || !this.ws) return + try { + const g = await signWith(this.signKey, `prove.${nonce}.${this.roomName}`) + this.ws.send(JSON.stringify({ ctl: 'prove', g })) + } catch { + this.resolveWriteReady() + } + } + /** every SNAP_EVERY persisted ops, upload a fresh encrypted snapshot */ private maybeSnapshot(q: number) { if (q === 0 || q % SNAP_EVERY !== 0) return @@ -586,6 +675,10 @@ export class OnlineTransport implements Transport { // sign the CIPHERTEXT so the relay verifies authorship while blind if (this.signKey) env.g = await signFrame(this.signKey, enc.i, enc.d) ops = frame.ops + } else if (frame.t === 'snap' && this.signKey) { + // the fork snapshot is ephemeral but replaces what every peer holds; + // sign it so the relay can vouch for it (peers refuse an unstamped one) + env = { ...enc, g: await signFrame(this.signKey, enc.i, enc.d) } } this.write({ id, text: JSON.stringify(env), ops, bytes: enc.i.length + enc.d.length, tries: 0 }) })() diff --git a/kernel/src/sync/online.ts b/kernel/src/sync/online.ts index d8fbfbab..6dc642ff 100644 --- a/kernel/src/sync/online.ts +++ b/kernel/src/sync/online.ts @@ -58,13 +58,17 @@ export async function signFrame(key: CryptoKey, i: string, d: string): Promise { - const key = await importSignKey(privB64) +/** Sign an arbitrary string with an already-imported signing key. */ +export async function signWith(key: CryptoKey, text: string): Promise { const sig = await crypto.subtle.sign(SIGN_ALG, key, new TextEncoder().encode(text)) return b64u.enc(new Uint8Array(sig)) } +/** Sign an arbitrary string with a b64url PKCS#8 private key (cert chains). */ +export async function signText(privB64: string, text: string): Promise { + return signWith(await importSignKey(privB64), text) +} + async function mintKeypair(): Promise<{ pub: string; priv: string }> { const kp = (await crypto.subtle.generateKey(EC, true, ['sign', 'verify'])) as CryptoKeyPair return { @@ -242,6 +246,7 @@ export class OnlineTransport implements Transport { private auth?: AuthSpec, ) { this.docId = docId + this.writeReadyP = new Promise((r) => { this.resolveWriteReady = r }) this.init(room) } @@ -255,11 +260,35 @@ export class OnlineTransport implements Transport { * Null until init() has derived the token. */ blobCreds(): { base: string; room: string; tok: string; rawKey: Uint8Array } | null { if (!this.roomName || !this.tokValue) return null - return { base: this.originValue, room: this.roomName, tok: this.tokValue, rawKey: b64u.dec(this.keyB64) } + // The write ticket (relay: Room.writeTicket) when this socket earned one, + // else the read token. Uploads authorized by the token alone let any + // read-only copy fill the room's blob quota, so the relay hands writers a + // separate credential over the certified socket; reads accept either. + return { + base: this.originValue, + room: this.roomName, + tok: this.writeTicket || this.tokValue, + rawKey: b64u.dec(this.keyB64), + } + } + /** True once this socket can upload: it holds the write ticket, or the relay + * finished `ready` without offering one (an older relay, or a reader — either + * way the room token is what uploads take there). Callers that PUT before this + * resolves would send the token into a ticket-latched room and 403. */ + writeReady(): Promise { + return this.writeReadyP } + private writeReadyP: Promise + private resolveWriteReady: () => void = () => {} private roomName = '' private tokValue = '' private originValue = '' + /** relay-issued blob write ticket; null until a PROVEN socket is handed one */ + private writeTicket: string | null = null + /** `w`-room: the relay verifies writer signatures, so its stamps mean + * something and unstamped content-bearing frames can be refused. Legacy + * `r` rooms sign nothing — gating there would drop every legitimate frame. */ + private signedRoom = false private async init(room: string) { const raw = b64u.dec(this.keyB64) @@ -276,6 +305,7 @@ export class OnlineTransport implements Transport { this.originValue = u.origin this.roomName = u.pathname.replace(/^\/d\//, '') this.tokValue = tok + this.signedRoom = this.roomName[0] === 'w' } catch { /* malformed room url — blobs simply stay unavailable */ } // Writers sign op frames; readers omit auth and the relay drops their // writes. Two writer shapes: DIRECT (the presented `w` key hash-matches the @@ -286,14 +316,18 @@ export class OnlineTransport implements Transport { if (a?.kind === 'direct') { if (a.priv) { try { this.signKey = await importSignKey(a.priv) } catch { this.signKey = null } } this.myPub = a.pub - this.url = `${room}?tok=${tok}&w=${a.pub}` + // bt=1: we understand the blob write ticket. The relay only starts + // REQUIRING it for uploads once a PROVEN writer has said so, which is + // what lets the relay ship ahead of clients without 403ing their asset + // offload. Sent by every writer shape; honoured only after `prove`. + this.url = `${room}?tok=${tok}&bt=1&w=${a.pub}` } else if (a?.kind === 'chain') { const id = await deviceIdentity(this.docId) try { this.signKey = await importSignKey(id.priv) } catch { this.signKey = null } this.myPub = id.pub const iv = a.invite const dg = await signText(iv.priv, `dlg.${id.pub}`) - this.url = `${room}?tok=${tok}&w=${id.pub}&o=${a.owner}` + + this.url = `${room}?tok=${tok}&bt=1&w=${id.pub}&o=${a.owner}` + `&ivp=${iv.pub}&ivr=${iv.role}&ive=${iv.exp ?? 0}&ivs=${iv.sig}&dg=${dg}` } else { this.url = `${room}?tok=${tok}` @@ -398,6 +432,19 @@ export class OnlineTransport implements Transport { this.throttle(typeof env.retryInMs === 'number' ? env.retryInMs : 10_000) return } + // Not in RefusalCode on purpose: it never reaches SyncNotice (nothing was + // lost, so there is nothing to tell the user), and widening the exported + // union would make every app's notice switch non-exhaustive for a code it + // will never see. + if ((env.code as string) === 'snap-ahead') { + // We uploaded a snapshot claiming to cover a seq the room has not reached. + // The relay refused it rather than prune ops it still needs — nothing was + // lost, the op log is intact, and the next snapshot cadence will retry + // with a `q` the room agrees with. Loud, because it means this copy's + // sequence counter drifted ahead of the room, which is a bug to find. + console.warn('[bento-sync] relay refused a snapshot ahead of the room’s seq — nothing lost', env) + return + } if (env.code !== 'too-large' && env.code !== 'storage-failed' && env.code !== 'room-full') { // a code from a newer relay: no recovery we can invent is better than // leaving the op in the log, where `need` will retry it honestly @@ -483,7 +530,15 @@ export class OnlineTransport implements Transport { } private async onEnvelope(text: string) { - let env: { i?: string; d?: string; q?: number; snap?: number; ctl?: string; p?: string } & RefusedEnv + let env: { + i?: string; d?: string; q?: number; snap?: number; ctl?: string; p?: string + /** the writer signature the relay VERIFIED before fanning this out */ + g?: string + /** blob write ticket — only ever sent to a socket that proved its key */ + wt?: string + /** possession nonce on `ready`: sign it to prove the `?w=` key is ours */ + c?: string + } & RefusedEnv try { env = JSON.parse(text) } catch { @@ -495,7 +550,21 @@ export class OnlineTransport implements Transport { if (env.p && env.p === this.myPub) { console.info('[bento-sync] this copy’s access was revoked by the owner') this.close() + return } + // a removal re-mints the room's blob ticket (the removed copy still holds + // the old one); the relay sends the replacement to PROVEN sockets only + if (typeof env.wt === 'string') this.writeTicket = env.wt + return + } + // The ticket arrives on its own frame, after `prove` — never on `ready`. + // Presenting the owner's PUBLIC key hash-matches the room name, and every + // reader copy carries that key; a ticket issued on the hash-match alone + // would go to any reader. Only a socket that signed the relay's nonce with + // the matching PRIVATE key is handed one. + if (env.ctl === 'wt') { + if (typeof env.wt === 'string') this.writeTicket = env.wt + this.resolveWriteReady() return } // the relay would not take a frame and said so (v1.0.9 relay and later; @@ -512,6 +581,17 @@ export class OnlineTransport implements Transport { this.maybeSnapshot(env.q) } if (env.ctl === 'ready') { + // `c` is the relay's possession challenge, present only for a socket + // whose `?w=` hash-matched the room. Answer it and the ticket follows on + // its own `wt` frame; a reader (no signing key) cannot answer and never + // gets one. No `c` at all = an older relay, or we joined as a reader — + // in both cases uploads take the room token and there is nothing to + // wait for, so writes are released now. + if (typeof env.c === 'string' && this.signKey && this.roomName) { + void this.prove(env.c) + } else { + this.resolveWriteReady() + } this.inReplay = false const wantSnap = this.hooks.onReady(this.replaySeen, env.q ?? 0) this.replaySeen = new Set() @@ -535,11 +615,17 @@ export class OnlineTransport implements Transport { } if (typeof env.q === 'number') this.saveSeq(env.q) if (env.snap === 1) { + if (!this.vouched(env)) return const s = payload as { doc: SyncDoc; state: SyncStateJSON } if (s && s.doc && s.state) this.hooks.onSnap(s.doc, s.state) return } const frame = payload as Frame + // Decrypting a frame proves the sender holds the READ key — which every + // copy carries, read-only ones included. Authorship is a separate question + // and only the relay can answer it, so content-bearing frames are refused + // without its stamp. + if ((frame.t === 'ops' || frame.t === 'snap') && !this.vouched(env)) return if (this.inReplay && frame.t === 'ops') { for (const op of frame.ops) this.replaySeen.add(`${op.a}:${op.s}`) } @@ -548,6 +634,38 @@ export class OnlineTransport implements Transport { private inReplay = true + /** + * Did the relay vouch for this envelope? In a signed room it stamps exactly + * what it checked: `q` on a frame it persisted (verified before storage) and + * an echoed `g` on a signed frame it fanned out. An unstamped frame reached + * us because someone encrypted it with the room key — a read-only copy can + * do that, and a blind relay cannot tell the ciphertext of an op batch from + * the ciphertext of a presence beat, so it forwards both. Refusing the + * unstamped ones HERE is what makes read-only hold for live peers and not + * just for the persisted log. + * + * Legacy `r` rooms have no signatures at all: gating them would drop every + * frame, so they stay on the pre-signing trust model. + */ + private vouched(env: { q?: number; g?: string }): boolean { + return !this.signedRoom || typeof env.q === 'number' || typeof env.g === 'string' + } + + /** Answer the relay's possession challenge: sign `prove..` with + * the key we presented as `?w=`. The room name is in the signed text so a + * signature can never be replayed into another room, and the nonce is + * per-socket and single-use so it cannot be replayed into this one. */ + private async prove(nonce: string) { + if (!this.signKey || !this.ws) return + try { + const g = await signWith(this.signKey, `prove.${nonce}.${this.roomName}`) + this.ws.send(JSON.stringify({ ctl: 'prove', g })) + } catch { + // cannot sign → we are effectively a reader; do not hold writes forever + this.resolveWriteReady() + } + } + private snapInFlight = false /** every SNAP_EVERY persisted ops, upload a fresh encrypted snapshot */ @@ -597,6 +715,12 @@ export class OnlineTransport implements Transport { // sign the ciphertext so the relay verifies authorship while blind. if (this.signKey) env.g = await signFrame(this.signKey, enc.i, enc.d) ops = frame.ops + } else if (frame.t === 'snap' && this.signKey) { + // A rejoining fork's snapshot is ephemeral (never persisted) but it + // REPLACES what every live peer holds — the same authority as an op + // batch, and it used to travel unsigned. Sign it so the relay can + // vouch for it; peers now refuse a `snap` it hasn't stamped. + env = { ...enc, g: await signFrame(this.signKey, enc.i, enc.d) } } // remember what rode in the frame so a refusal can name it this.write({ id, text: JSON.stringify(env), ops, bytes: enc.i.length + enc.d.length, tries: 0 }) diff --git a/scripts/test-relay-auth.ts b/scripts/test-relay-auth.ts new file mode 100644 index 00000000..e0c8d872 --- /dev/null +++ b/scripts/test-relay-auth.ts @@ -0,0 +1,410 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// bento-sync relay authorization rig. +// +// node scripts/test-relay-auth.ts (Node ≥ 23.6 strips types natively) +// +// WHAT THIS PROVES. `w`-rooms exist so that read-only means read-only: the room +// name commits to the owner pubkey and the relay drops writes it cannot verify. +// That held for STORAGE and only for storage. Two holes followed from it, and +// both are the same mistake — treating the READ capability as if it granted +// writes, because every copy of a file carries it: +// +// 1. Fan-out was unauthenticated. A frame without `p:1` was forwarded to +// every live peer without a signature check, so a read-only copy (which +// holds `collab.key`, hence the room token) could encrypt an op batch — +// or a whole-document `t:'snap'` — and push it into every open editor. +// Legitimate writers then persisted the injected content over their own +// signed stream. For the public guestbook, whose key is public BY DESIGN, +// that was unauthenticated write access to every live viewer. +// 2. Blob PUT was authorized by the room token alone, so the same copy could +// fill the room's 256 MB blob quota and squat the content-addressed keys +// real assets were about to land on. +// +// A blind relay cannot fix (1) by itself: an op batch and a presence beat are +// both opaque {i,d}, so it cannot refuse to forward one without silencing +// read-only viewers. What it CAN do is stamp what it verified — `q` on a frame +// it persisted, an echoed `g` on a signed frame it fanned out — and never stamp +// anything else. The client half (online.ts `vouched`) refuses content-bearing +// frames that carry no stamp. This rig pins the relay half. +// +// It drives the real Durable Object class against fake storage/sockets, so the +// assertions are about worker.js's actual control flow. Everything here fails +// against the pre-fix relay except the cases marked as regression guards. + +// The DO returns a 101 for the WebSocket upgrade; undici's Response refuses any +// status outside 200–599, so the rig supplies a shim before importing. +class FakeResponse { + status: number + body: unknown + webSocket: unknown + headers: Map + constructor(body: unknown, init: { status?: number; headers?: Record; webSocket?: unknown } = {}) { + this.body = body + this.status = init.status ?? 200 + this.webSocket = init.webSocket + this.headers = new Map(Object.entries(init.headers ?? {})) + } + async text() { + return typeof this.body === 'string' ? this.body : '' + } +} +;(globalThis as Record).Response = FakeResponse + +type Sock = { + sent: string[] + closed: boolean + send(t: string): void + close(): void + serializeAttachment(a: unknown): void + deserializeAttachment(): Record | null +} + +function mkSocket(att: Record | null = null): Sock { + let attachment = att + return { + sent: [], + closed: false, + send(t) { this.sent.push(t) }, + close() { this.closed = true }, + serializeAttachment(a) { attachment = JSON.parse(JSON.stringify(a)) }, + deserializeAttachment() { return attachment }, + } +} + +let lastPair: { client: Sock; server: Sock } | null = null +;(globalThis as Record).WebSocketPair = function () { + const client = mkSocket() + const server = mkSocket() + lastPair = { client, server } + return { 0: client, 1: server } +} + +function mkState() { + const store = new Map() + const sockets: Sock[] = [] + return { + store, + sockets, + storage: { + async get(k: string) { return store.get(k) }, + async put(k: string, v: unknown) { store.set(k, v) }, + async delete(k: string | string[]) { + for (const one of Array.isArray(k) ? k : [k]) store.delete(one) + }, + async list({ start, end, prefix }: { start?: string; end?: string; prefix?: string } = {}) { + const out = new Map() + for (const [k, v] of [...store.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) { + if (prefix && !k.startsWith(prefix)) continue + if (start && k < start) continue + if (end && k >= end) continue + out.set(k, v) + } + return out + }, + async deleteAll() { store.clear() }, + async setAlarm() { /* expiry is not what this rig is about */ }, + }, + acceptWebSocket(ws: Sock) { sockets.push(ws) }, + getWebSockets() { return sockets }, + setWebSocketAutoResponse() { /* keepalive, not auth */ }, + } +} + +const req = (url: string, headers: Record = {}) => ({ + url, + method: 'GET', + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, +}) + +const { Room } = await import('../server/sync-worker/src/worker.js') + +// --- key material ----------------------------------------------------------- +const EC = { name: 'ECDSA', namedCurve: 'P-256' } as const +const SIGN = { name: 'ECDSA', hash: 'SHA-256' } as const +const b64u = { + enc(bytes: Uint8Array) { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + }, +} + +async function mintKeys() { + const kp = (await crypto.subtle.generateKey(EC, true, ['sign', 'verify'])) as CryptoKeyPair + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey)) + const commit = new Uint8Array(await crypto.subtle.digest('SHA-256', raw as BufferSource)) + return { + pub: b64u.enc(raw), + room: 'w' + b64u.enc(commit), + async sign(text: string) { + return b64u.enc(new Uint8Array(await crypto.subtle.sign(SIGN, kp.privateKey, new TextEncoder().encode(text)))) + }, + } +} + +const owner = await mintKeys() +const stranger = await mintKeys() // a key the room does NOT commit to +const TOK = 'tok0123456789abcd' + +let failures = 0 +let checks = 0 +function ok(cond: boolean, msg: string) { + checks++ + if (!cond) { + failures++ + console.error(` ✗ ${msg}`) + } +} + +/** a frame body the relay will never read — it only ever sees ciphertext */ +const IV = 'aXZpdmluaXY' +const CT = 'Y2lwaGVydGV4dA' +const parse = (s: string) => JSON.parse(s) as Record + +// --------------------------------------------------------------------------- +// Fan-out authentication (hole 1) +// --------------------------------------------------------------------------- +{ + console.log('signed-room fan-out stamps only what the relay verified…') + const state = mkState() + const room = new Room(state, {}) + await state.storage.put('name', owner.room) + await state.storage.put('tok', TOK) + + const writer = mkSocket({ count: 0, windowStart: Date.now(), signed: true, w: owner.pub }) + const reader = mkSocket({ count: 0, windowStart: Date.now(), signed: true, w: null }) + const peer = mkSocket({ count: 0, windowStart: Date.now(), signed: true, w: null }) + state.sockets.push(writer, reader, peer) + + const g = await owner.sign(`${IV}.${CT}`) + + await room.onMessage(writer, JSON.stringify({ i: IV, d: CT, g })) + ok(peer.sent.length === 1, 'a signed ephemeral frame reaches peers') + ok(parse(peer.sent[0]).g === g, 'the verified signature is echoed to peers') + + peer.sent.length = 0 + await room.onMessage(reader, JSON.stringify({ i: IV, d: CT })) + ok(peer.sent.length === 1, 'a reader’s unsigned frame still fans out (presence must work)') + const seen = parse(peer.sent[0]) + ok(seen.g === undefined && seen.q === undefined, 'nothing vouches for it — no q, no g') + + peer.sent.length = 0 + await room.onMessage(reader, JSON.stringify({ i: IV, d: CT, g: 'bm90YXNpZ25hdHVyZQ' })) + ok(peer.sent.length === 0, 'a forged signature drops the frame instead of riding along') + + peer.sent.length = 0 + await room.onMessage(writer, JSON.stringify({ i: IV, d: CT, g: await owner.sign('some.other.frame') })) + ok(peer.sent.length === 0, 'a signature over other bytes drops the frame') + + peer.sent.length = 0 + await room.onMessage(writer, JSON.stringify({ i: IV, d: CT, g: await stranger.sign(`${IV}.${CT}`) })) + ok(peer.sent.length === 0, 'a signature from a key this socket did not certify drops the frame') + + // storage-side enforcement, unchanged — the regression guard for the old fix + peer.sent.length = 0 + await room.onMessage(reader, JSON.stringify({ p: 1, i: IV, d: CT })) + ok(peer.sent.length === 0 && (await state.storage.get('seq')) === undefined, 'an unsigned op batch is still dropped') + + peer.sent.length = 0 + await room.onMessage(writer, JSON.stringify({ p: 1, i: IV, d: CT, g })) + const persisted = parse(peer.sent[0] ?? '{}') + ok((await state.storage.get('seq')) === 1, 'a signed op batch persists') + ok(persisted.q === 1 && persisted.g === g, 'a persisted frame carries both stamps') +} + +{ + console.log('legacy r-rooms stay permissive…') + const state = mkState() + const room = new Room(state, {}) + await state.storage.put('name', 'r' + b64u.enc(new Uint8Array([1, 2, 3]))) + const a = mkSocket({ count: 0, windowStart: Date.now(), signed: false, w: null }) + const peer = mkSocket({ count: 0, windowStart: Date.now(), signed: false, w: null }) + state.sockets.push(a, peer) + + await room.onMessage(a, JSON.stringify({ i: IV, d: CT, g: 'bm90YXNpZ25hdHVyZQ' })) + ok(peer.sent.length === 1, 'an r-room fans out unverifiable frames as it always did') + ok(parse(peer.sent[0]).g === undefined, 'but never stamps one it could not verify') +} + +// --------------------------------------------------------------------------- +// Blob write ticket (hole 2) +// --------------------------------------------------------------------------- + +/** the call the blob route makes into the DO: reads omit size/bkey */ +const authz = async (room: InstanceType, tok: string, put?: { size: number; bkey: string }) => + (await room.fetch(req( + `https://do/authz?tok=${tok}` + (put ? `&size=${put.size}&bkey=${put.bkey}` : ''), + ))).status + +const connect = async (room: InstanceType, query: string) => { + const res = await room.fetch(req(`https://relay/d/${owner.room}?tok=${TOK}${query}`, { upgrade: 'websocket' })) + const server = lastPair!.server + const ready = server.sent.map(parse).find((f) => f.ctl === 'ready') + return { status: res.status, server, ready } +} + + +/** Answer the relay's possession challenge the way a real writer does: sign + * `prove..` with the private key, and read back the `wt` frame. + * Returns null when the relay hands out nothing — which is the assertion in + * half the checks below. */ +const prove = async ( + room: InstanceType, + c: { server: Sock; ready?: Record }, + signer: { sign(text: string): Promise }, + opts: { wrongRoom?: boolean } = {}, +) => { + const nonce = c.ready?.c + if (typeof nonce !== 'string') return null + const text = `prove.${nonce}.${opts.wrongRoom ? 'wSOMEOTHERROOM' : owner.room}` + const before = c.server.sent.length + await room.onMessage(c.server, JSON.stringify({ ctl: 'prove', g: await signer.sign(text) })) + const wtFrame = c.server.sent.slice(before).map(parse).find((f) => f.ctl === 'wt') + return (wtFrame?.wt as string | undefined) ?? null +} + +// --------------------------------------------------------------------------- +// The write ticket: hash-match certifies, only PROOF issues (review §2.1) +// --------------------------------------------------------------------------- +{ + console.log('the write ticket goes only to sockets that PROVE the key…') + const state = mkState() + const room = new Room(state, {}) + + const r = await connect(room, '') + ok(r.status === 101 && !!r.ready, 'a reader connects') + ok(r.ready!.c === undefined, 'a reader is not challenged') + ok(r.ready!.wt === undefined, 'a reader is issued no write ticket on ready') + + // THE ATTACK THE RECOVERED CODE MISSED. The owner's public key is in every + // copy of the file — reader copies too — so a reader can present it as ?w= + // and hash-match the room. Before this change that socket was handed the + // write ticket on `ready`; the original rig's "certified writer" check was + // exactly this socket, and it passed. + const imp = await connect(room, `&bt=1&w=${owner.pub}`) + ok(imp.status === 101, 'a reader presenting the owner’s PUBLIC key still connects (the op channel verifies per frame)') + ok(imp.ready!.wt === undefined, 'but is issued NO ticket on ready') + ok(typeof imp.ready!.c === 'string', 'it is challenged instead') + ok((await state.storage.get('wtReq')) === undefined, 'and its bt=1 did not latch the room') + ok((await state.storage.get('wt')) === undefined, 'no ticket has even been minted yet') + + // it cannot answer the challenge: it has the public key only + const forged = await prove(room, imp, stranger) + ok(forged === null, 'a signature from a key that is not the private half earns nothing') + ok((await state.storage.get('wtReq')) === undefined, 'and still does not latch') + // and the nonce is consumed — a second attempt with the RIGHT key is refused + ok(await prove(room, imp, owner) === null, 'the nonce is single-use: a later correct answer on a spent nonce earns nothing') + + const w = await connect(room, `&bt=1&w=${owner.pub}`) + ok(w.ready!.wt === undefined, 'a real writer is not handed the ticket on ready either') + const ticket = await prove(room, w, owner) + ok(typeof ticket === 'string', 'a real writer that proves the key is handed one on its own wt frame') + ok(ticket === (await state.storage.get('wt')), 'and it is the room’s ticket') + ok((await state.storage.get('wtReq')) === 1, 'a PROVEN bt=1 writer latches the room') + + // the signed text binds the room name, so a proof for another room is void + const w2 = await connect(room, `&bt=1&w=${owner.pub}`) + ok(await prove(room, w2, owner, { wrongRoom: true }) === null, 'a proof signed for a different room name is refused') +} + +// --------------------------------------------------------------------------- +// Blob writes behind the ticket, and the latch (review §2.4) +// --------------------------------------------------------------------------- +{ + console.log('blob writes need the ticket once a PROVEN ticket-capable writer has joined…') + const state = mkState() + const room = new Room(state, {}) + + // Pre-latch: exactly the pre-fix behaviour, which is what makes this relay + // safe to deploy ahead of the clients — every shipped client PUTs with the + // room token and has never heard of a ticket. + const old = await connect(room, `&w=${owner.pub}`) // an older writer: no bt=1 + await prove(room, old, owner) + ok(await authz(room, TOK, { size: 1000, bkey: 'k1' }) === 200, 'an older client still uploads with the room token') + ok((await state.storage.get('wtReq')) === undefined, 'a proven writer WITHOUT bt=1 does not latch the room') + + const w = await connect(room, `&bt=1&w=${owner.pub}`) + const ticket = (await prove(room, w, owner)) as string + ok(await authz(room, TOK, { size: 1000, bkey: 'k2' }) === 403, 'the room token no longer authorizes an upload') + ok(await authz(room, ticket, { size: 1000, bkey: 'k2' }) === 200, 'the write ticket does') + ok(await authz(room, TOK) === 200, 'reads still take the room token — viewers see the assets') + ok(await authz(room, 'wrongtokenwrong') === 403, 'a stranger gets neither') + + // quota accounting must follow the credential that was accepted, not a + // second unmetered path + ok((await state.storage.get('blobBytes')) === 1000 + 1000, 'accepted uploads are metered once each') +} + +// --------------------------------------------------------------------------- +// Revocation re-mints the ticket, to PROVEN sockets only (review §2.6) +// --------------------------------------------------------------------------- +{ + console.log('revocation re-mints the ticket…') + const state = mkState() + const room = new Room(state, {}) + const member = await mintKeys() + + const w = await connect(room, `&bt=1&w=${owner.pub}`) + const stale = (await prove(room, w, owner)) as string + const ownerSock = w.server + const memberSock = mkSocket({ count: 0, windowStart: Date.now(), signed: true, w: member.pub, proven: true }) + const readerSock = mkSocket({ count: 0, windowStart: Date.now(), signed: true, w: null }) + // a reader that presented the owner's public key: certified (pinned w), NOT proven + const impSock = mkSocket({ count: 0, windowStart: Date.now(), signed: true, w: owner.pub, proven: false }) + state.sockets.push(memberSock, readerSock, impSock) + ownerSock.sent.length = 0 + + await room.onMessage(ownerSock, JSON.stringify({ + ctl: 'revoke', p: member.pub, o: owner.pub, g: await owner.sign(`rev.${member.pub}`), + })) + + const fresh = (await state.storage.get('wt')) as string + ok(fresh !== stale, 'the ticket the removed member holds is replaced') + ok(await authz(room, stale, { size: 10, bkey: 'k9' }) === 403, 'the stale ticket buys nothing') + ok(memberSock.closed, 'the removed member’s socket is closed') + ok(parse(memberSock.sent[0]).wt === undefined, 'and is not handed the replacement') + ok(parse(readerSock.sent[0]).wt === undefined, 'readers are not handed a write capability either') + ok(parse(impSock.sent[0]).wt === undefined, 'a CERTIFIED-but-unproven socket (reader with the owner’s pubkey) is not handed one') + ok(parse(ownerSock.sent[0]).wt === fresh, 'still-proven writers get the replacement without reconnecting') +} + +// --------------------------------------------------------------------------- +// Snapshot q is clamped to the room's seq (review §2.2) +// --------------------------------------------------------------------------- +{ + console.log('a snapshot cannot claim to cover ops the room has not seen…') + const state = mkState() + const room = new Room(state, {}) + const w = await connect(room, `&w=${owner.pub}`) + const sock = w.server + + // three real persisted ops + for (let n = 0; n < 3; n++) { + const i = IV + n, d = CT + n + await room.onMessage(sock, JSON.stringify({ p: 1, i, d, g: await owner.sign(`${i}.${d}`) })) + } + ok((await state.storage.get('seq')) === 3, 'three ops persisted') + const opsBefore = (await state.storage.list({ start: 'op:', end: 'op;' })).size + sock.sent.length = 0 + + // a snapshot claiming q far beyond seq — with a valid signature, from a + // real writer: the trust the writer has is to EDIT, not to wipe the log + const si = IV + 'snap', sd = CT + 'snap' + await room.onMessage(sock, JSON.stringify({ snap: 1, q: 1_000_000, k: 'k-snap', i: si, d: sd, g: await owner.sign(`${si}.${sd}`) })) + const refusal = sock.sent.map(parse).find((f) => f.ctl === 'refused') + ok(refusal?.code === 'snap-ahead', 'it is refused with a CODE, not silently dropped') + ok(refusal?.k === 'k-snap', 'and the refusal names the frame') + ok((await state.storage.get('snap')) === undefined, 'no snapshot was stored') + ok((await state.storage.list({ start: 'op:', end: 'op;' })).size === opsBefore, 'and no op was pruned') + + // a snapshot at exactly seq is fine and prunes what it covers + sock.sent.length = 0 + await room.onMessage(sock, JSON.stringify({ snap: 1, q: 3, i: si, d: sd, g: await owner.sign(`${si}.${sd}`) })) + ok((await state.storage.get('snap'))?.q === 3, 'a snapshot at seq is accepted') + ok((await state.storage.list({ start: 'op:', end: 'op;' })).size === 0, 'and the ops it covers are pruned') +} + +console.log(failures === 0 ? `\nALL PASS (${checks} checks)` : `\n${failures} FAILURES of ${checks} checks`) +process.exit(failures ? 1 : 0) diff --git a/scripts/test-relay-protocol.ts b/scripts/test-relay-protocol.ts index 3ef8fe02..9b9506e9 100644 --- a/scripts/test-relay-protocol.ts +++ b/scripts/test-relay-protocol.ts @@ -94,7 +94,12 @@ const same = (re: RegExp, what: string): void => { console.log('the signature chain the relay verifies') // `inv.${pub}.${role}.${exp}` — an owner blessing an invite key. // `rev.${pub}` — an owner revoking one. `${i}.${d}` — a frame signature. -same(/`(inv|rev|mem|own)\.[^`]*`/g, 'signature texts') +// Every text a client signs and the relay verifies. `dlg.` (the invite-signed +// delegation of a device key) was on the wire and NOT in this list; `prove.` +// (the possession challenge) joined it in the relay-auth change. A text that +// is not in this alternation is a text the two transports can spell +// differently without anything going red. +same(/`(inv|rev|mem|own|dlg|prove)\.[^`]*`/g, 'signature texts') console.log('\nthe crypto') same(/name: '[A-Za-z-]+'/g, 'algorithm names') @@ -102,8 +107,19 @@ same(/namedCurve: '[^']+'/g, 'curve') same(/hash: '[^']+'/g, 'hash') console.log('\nthe wire') -// The possession proof in the query string, and the frame envelope keys. -same(/[?&](tok|room|pub|sig|inv|role|exp)=/g, 'query parameters') +// The query string the relay parses at connect. This list used to name +// `room|pub|sig|inv|role|exp`, none of which is on the wire — the real +// parameters are the token, the writer key, the owner key, the five invite +// fields, the delegation, the replay cursor, and (relay-auth) the +// ticket-capable flag. With the wrong names the guard matched `tok=` alone +// and reported the two transports identical while they could differ on +// everything else. These are the names the worker reads. +same(/[?&](tok|w|o|ivp|ivr|ive|ivs|dg|since|bt)=/g, 'query parameters') +// Control frames the relay emits or consumes by name, and the envelope keys +// that carry its stamps. A client that spells one of these differently is a +// client the relay cannot talk to. +same(/ctl === '(ready|ack|refused|revoked|revoke|wt|prove)'/g, 'control frames read') +same(/ctl: '(revoke|prove)'/g, 'control frames sent') same(/'(ping|pong)'/g, 'keepalive frames') console.log('\nthe timings a relay and a client have to agree about') diff --git a/scripts/test-sync-vouch.ts b/scripts/test-sync-vouch.ts new file mode 100644 index 00000000..acc0bf45 --- /dev/null +++ b/scripts/test-sync-vouch.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// THE CLIENT HALF OF READ-ONLY — a real OnlineTransport, driven through a fake +// socket, in both transports that speak to the relay. +// +// slides/node_modules/.bin/esbuild scripts/test-sync-vouch.ts --bundle --platform=node --format=esm \ +// --outfile="$TMPDIR/test-sync-vouch.mjs" && node "$TMPDIR/test-sync-vouch.mjs" +// +// Bundled, not run directly: kernel/src/sync/online.ts uses constructor +// parameter properties, which node's strip-only loader rejects — the reason +// dash's twin avoids that syntax, and the reason no rig had ever driven the +// kernel transport before this one. Same pattern as test-sanitize.ts. +// +// WHAT THIS PROVES, and why it exists as its own rig. The relay stamps a +// fanned-out frame with exactly what it verified (scripts/test-relay-auth.ts +// proves that half). But every copy of a file holds the room key, so a +// read-only copy can encrypt a well-formed op batch and the blind relay — which +// cannot tell that ciphertext from a presence beat's — fans it out unstamped. +// The ONLY thing between that frame and every live editor applying it is the +// client's `vouched()` check. Before this rig, setting `vouched` to +// `return true` left every rig in the tree green: the relay rig cannot see a +// client decision, and the session rigs never drive the online transport. +// Security found that by mutation, which is the correct way to find it, and +// this file is the answer: each check below must go red under that mutation. +// +// The same check lives in TWO transports — kernel/src/sync/online.ts and +// dash's deliberate twin — so this drives both, with the same frames. + +import { webcrypto } from 'node:crypto' + +// --- the world the transport expects --------------------------------------- +// A WebSocket constructor the kernel's net chokepoint will `new`. Each instance +// records what the client sent and lets the rig deliver frames as the relay. +type Listener = (ev: unknown) => void +class FakeSocket { + static last: FakeSocket | null = null + readyState = 0 + sent: string[] = [] + private ls = new Map() + onopen: Listener | null = null + onmessage: Listener | null = null + onclose: Listener | null = null + onerror: Listener | null = null + url: string + constructor(url: string) { this.url = url; FakeSocket.last = this } + addEventListener(t: string, fn: Listener) { this.ls.set(t, [...(this.ls.get(t) ?? []), fn]) } + removeEventListener(t: string, fn: Listener) { this.ls.set(t, (this.ls.get(t) ?? []).filter((f) => f !== fn)) } + send(s: string) { this.sent.push(s) } + close() { this.readyState = 3; this.fire('close', {}) } + fire(t: string, ev: Record) { + const h = (this as unknown as Record)[`on${t}`] + if (h) h(ev) + for (const fn of this.ls.get(t) ?? []) fn(ev) + } + open() { this.readyState = 1; this.fire('open', {}) } + /** the relay speaks: deliver one envelope */ + deliver(env: Record) { this.fire('message', { data: JSON.stringify(env) }) } +} +;(globalThis as unknown as { WebSocket: unknown }).WebSocket = FakeSocket + +const { OnlineTransport: KernelTransport } = await import('../kernel/src/sync/online.ts') +const { OnlineTransport: DashTransport } = await import('../dash/src/sync/online.ts') + +// --- key material ------------------------------------------------------------- +const b64u = { + enc(bytes: Uint8Array): string { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + }, +} +const rawKey = new Uint8Array(32) +webcrypto.getRandomValues(rawKey) +const keyB64 = b64u.enc(rawKey) +const aes = await webcrypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt']) + +/** Encrypt a frame the way a peer holding the room key would — which is the + * whole point: a READER holds this key too. */ +async function seal(frame: unknown): Promise<{ i: string; d: string }> { + const iv = new Uint8Array(12) + webcrypto.getRandomValues(iv) + const ct = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, aes, new TextEncoder().encode(JSON.stringify(frame))) + return { i: b64u.enc(iv), d: b64u.enc(new Uint8Array(ct)) } +} + +let failures = 0 +let checks = 0 +function ok(cond: boolean, msg: string): void { + checks++ + if (!cond) { failures++; console.error(` ✗ ${msg}`) } +} + +const OPS = { t: 'ops', a: 'reader', ops: [{ a: 'reader', s: 1, k: 'set', n: 'x', v: 1 }] } +const SNAP = { t: 'snap', a: 'fork', doc: { docId: 'd' }, state: { v: 2 } } +const PRESENCE = { t: 'p', a: 'reader', p: { name: 'r' } } + +/** Build a transport in `room`, open its socket, replay nothing, and return the + * socket plus what the transport applied. */ +async function boot( + Transport: typeof KernelTransport | typeof DashTransport, + room: string, +) { + const applied: string[] = [] + let snaps = 0 + FakeSocket.last = null + const tr = new (Transport as typeof KernelTransport)( + room, keyB64, 'doc-1', + (f) => { applied.push((f as { t: string }).t) }, + { + onSnap: () => { snaps++ }, + getSnapshot: () => ({ doc: { docId: 'doc-1' }, state: { v: 2 } as never }), + onOpen: () => {}, + onReady: () => false, + }, + // no auth: a READER transport. vouched() is about what we ACCEPT, and a + // reader is the copy most likely to be on the receiving end. + undefined, + ) + // init() is async (key import, URL parse) and only then constructs the socket + for (let i = 0; i < 50 && !FakeSocket.last; i++) await new Promise((r) => setTimeout(r, 2)) + const ws = FakeSocket.last! + ok(!!ws, `${Transport.name}: the transport opened a socket`) + ws.open() + ws.deliver({ ctl: 'ready', q: 0 }) + await new Promise((r) => setTimeout(r, 5)) + return { tr, ws, applied, snaps: () => snaps } +} + +const tick = () => new Promise((r) => setTimeout(r, 10)) + +for (const [name, Transport] of [['kernel', KernelTransport], ['dash', DashTransport]] as const) { + console.log(`${name} transport — a signed room accepts only what the relay vouched for…`) + const W_ROOM = 'wss://relay.test/d/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + const { ws, applied, snaps } = await boot(Transport, W_ROOM) + + // 1. an unstamped op batch — exactly what a reader can send and a blind + // relay will fan out — must NOT be applied + ws.deliver({ ...(await seal(OPS)) }) + await tick() + ok(!applied.includes('ops'), `${name}: an UNSTAMPED op batch is refused`) + + // 2. the same batch, carrying the relay's persisted-frame stamp, is applied + ws.deliver({ q: 1, ...(await seal(OPS)) }) + await tick() + ok(applied.filter((t) => t === 'ops').length === 1, `${name}: a q-stamped op batch is applied`) + + // 3. a fork snapshot (ephemeral t:'snap') with the relay's echoed signature + // is applied; without it, refused. `g` is opaque to the client — it is + // the relay's mark that it verified the sender, not something the + // client re-checks. + ws.deliver({ ...(await seal(SNAP)) }) + await tick() + ok(!applied.includes('snap'), `${name}: an UNSTAMPED fork snapshot is refused`) + ws.deliver({ g: 'cmVsYXktdmVyaWZpZWQ', ...(await seal(SNAP)) }) + await tick() + ok(applied.includes('snap'), `${name}: a g-stamped fork snapshot is applied`) + + // 4. a persisted snapshot frame ({snap:1}) follows the same rule + const before = snaps() + ws.deliver({ snap: 1, ...(await seal({ doc: { docId: 'doc-1' }, state: { v: 2 } })) }) + await tick() + ok(snaps() === before, `${name}: an unstamped {snap:1} is refused`) + ws.deliver({ snap: 1, q: 5, ...(await seal({ doc: { docId: 'doc-1' }, state: { v: 2 } })) }) + await tick() + ok(snaps() === before + 1, `${name}: a q-stamped {snap:1} is applied`) + + // 5. presence is not content and must keep flowing unstamped — otherwise + // every reader vanishes from the People panel + ws.deliver({ ...(await seal(PRESENCE)) }) + await tick() + ok(applied.includes('p'), `${name}: an unstamped PRESENCE frame still flows`) + + // 6. a legacy r-room has no signatures to check and stays permissive + console.log(`${name} transport — a legacy r-room stays on the older model…`) + const R_ROOM = 'wss://relay.test/d/rLEGACYROOM' + const r = await boot(Transport, R_ROOM) + r.ws.deliver({ ...(await seal(OPS)) }) + r.ws.deliver({ ...(await seal(SNAP)) }) + await tick() + ok(r.applied.includes('ops') && r.applied.includes('snap'), `${name}: an r-room applies unstamped ops and snapshots`) +} + +console.log(failures === 0 ? `\nALL PASS (${checks} checks)` : `\n${failures} FAILURES of ${checks} checks`) +process.exit(failures ? 1 : 0) diff --git a/server/sync-worker/src/worker.js b/server/sync-worker/src/worker.js index 7295127b..8261bd50 100644 --- a/server/sync-worker/src/worker.js +++ b/server/sync-worker/src/worker.js @@ -20,9 +20,13 @@ // { i, d } ephemeral (presence, hello, need) // { p:1, i, d } persist an op batch // { snap:1, q, i, d } encrypted snapshot covering seq ≤ q -// server → clients: same frames fanned out, ops stamped with { q: seq }; +// server → clients: same frames fanned out, ops stamped with { q: seq } and +// signed frames re-stamped with the { g } this relay +// VERIFIED (never one it didn't — that stamp is what tells +// a peer the sender was allowed to write); // on join: snapshot (if any) + ops since ?since= then -// { ctl:'ready', q: latest } +// { ctl:'ready', q: latest, wt? } — wt is the blob write +// ticket, issued only to certified writer sockets const IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // The binding constraint is DURABLE OBJECT STORAGE, not the WebSocket message @@ -93,6 +97,15 @@ const rawFrameId = (raw) => // pubkey but not the private half — their writes are dropped, so read-only is // ENFORCED here while the relay stays blind to content. 'r' rooms are legacy // and stay permissive. +// +// Blindness has a cost the enforcement above does not cover: the relay cannot +// tell an op batch from a presence beat, because both are opaque {i,d}. So it +// cannot refuse to FAN OUT a reader's frame — a read-only copy holds the room +// key and can encrypt anything. What the relay can do is say what it checked: +// `q` on a frame it persisted (signature-verified before storage) and the +// echoed `g` on a signed frame it fanned out. A client refuses content-bearing +// frames (op batches, whole-document fork snapshots) that carry neither, so +// read-only holds for live peers too and not merely for the stored log. const b64uDec = (s) => { const b = atob(s.replace(/-/g, '+').replace(/_/g, '/')) const out = new Uint8Array(b.length) @@ -174,11 +187,16 @@ export default { * how a client skips re-uploading an asset a peer already sent — content * addressing means an identical asset has an identical key). * - * Auth is the room token, same possession proof as the socket. That is + * READS take the room token, same possession proof as the socket. That is * deliberately no stronger than the room itself: anyone who can read the * room's frames can already read its assets, and the bytes are ciphertext * either way. * + * WRITES take the room's write ticket instead (Room.writeTicket) — the token + * is the READ capability, and authorizing an upload with it let a read-only + * copy burn the room's ROOM_BLOB_CAP. Same field, so the wire is unchanged: + * the DO decides which credential a request satisfied. + * * R2 is OPTIONAL. Without the binding these routes answer 501 and clients * keep inlining small assets, so a self-hoster who hasn't set up a bucket * still has a working relay. */ @@ -283,6 +301,25 @@ export class Room { return this.verifyWith(meta.w, f.g, `${f.i}.${f.d}`) } + /** The room's blob WRITE ticket — a capability handed out over the socket, + * and only to a socket whose writer key this room certified. Blob PUTs are + * otherwise authorized by the room token alone, which every copy carries + * including read-only ones, so a viewer could fill the room's ROOM_BLOB_CAP + * and squat the content-addressed keys real assets will land on. + * + * Minted lazily and stable thereafter; a revocation re-mints it, because a + * removed member's held ticket would otherwise outlive their access. */ + async writeTicket() { + let t = await this.state.storage.get('wt') + if (!t) { + const b = new Uint8Array(24) + crypto.getRandomValues(b) + t = b64uEnc(b) // 32 chars — inside the blob route's token charset/length + await this.state.storage.put('wt', t) + } + return t + } + async fetch(req) { // Token check for the blob routes — the DO is the only holder of the // room's token, so blob auth asks it rather than duplicating the rule. @@ -292,12 +329,23 @@ export class Room { if (u0.pathname === '/authz') { const saved = await this.state.storage.get('tok') const given = u0.searchParams.get('tok') || '' - if (saved === undefined || saved !== given) return new Response(null, { status: 403 }) + const ticket = await this.state.storage.get('wt') + const byTok = saved !== undefined && saved === given + const byTicket = !!ticket && given === ticket + if (!byTok && !byTicket) return new Response(null, { status: 403 }) // Blob accounting rides on the same call the blob route already makes. // `size` present = a PUT asking to reserve quota; absent = a read. const size = parseInt(u0.searchParams.get('size') || '0', 10) || 0 const bkey = u0.searchParams.get('bkey') || '' if (!size || !bkey) return new Response(null, { status: 200 }) + // A WRITE needs the write ticket — but only once this room has actually + // seen a ticket-capable writer (?bt=1). Shipped clients PUT with the room + // token and know nothing about tickets, so without that latch this relay + // could not be deployed ahead of them: every existing file's asset + // offload would start 403ing the moment it went live. + if (!byTicket && (await this.state.storage.get('wtReq'))) { + return new Response(null, { status: 403 }) + } // Already counted? Then this is a re-upload of identical content // (content-addressed keys) — admit it without double-charging. if (await this.state.storage.get(BKEY(bkey))) { @@ -375,9 +423,38 @@ export class Room { // Per-socket rate-limit state rides on the socket's serialized attachment // (in-memory Maps don't survive hibernation). this.state.acceptWebSocket(server) - server.serializeAttachment({ count: 0, windowStart: Date.now(), signed, w: sockW }) - await this.replay(server, since) + // CERTIFIED IS NOT PROVEN. The direct path above accepts a socket whose + // `?w=` hash-matches the room name — and that key is the owner's PUBLIC + // key, which every copy of the file carries, read-only copies included. A + // hash-match therefore says "this socket knows the owner's public key", + // which every reader does. It does NOT say the socket holds the private + // half. The op channel never needed it to: every persisted frame carries + // its own signature and is verified against `w` before storage, so a + // reader presenting `?w=` can be certified all day and still write nothing. + // + // The blob write TICKET is different. It is a bearer capability issued over + // the socket, and issuing it on the hash-match alone handed it to any reader + // — who could then fill ROOM_BLOB_CAP, squat content-addressed keys, and + // (via `bt=1`) latch the room so every older client's upload 403s. So the + // ticket, and the latch, wait for PROOF: `ready` carries a per-socket nonce, + // the client signs `prove..` with the private key, and only a + // socket that answers is marked `proven` and handed a ticket (on its own + // `wt` frame). The chain path is proof already (`dg` needs the invite's + // private key) but is challenged the same way — one rule, no exceptions. + let nonce = null + let wantsTicket = false + if (sockW) { + const b = new Uint8Array(16) + crypto.getRandomValues(b) + nonce = b64uEnc(b) + wantsTicket = url.searchParams.get('bt') === '1' + } + server.serializeAttachment({ + count: 0, windowStart: Date.now(), signed, w: sockW, + nonce, bt: wantsTicket, proven: false, + }) + await this.replay(server, since, nonce) await this.state.storage.setAlarm(Date.now() + IDLE_TTL_MS) return new Response(null, { status: 101, webSocket: client }) } @@ -391,7 +468,9 @@ export class Room { } webSocketError() { /* the runtime drops the socket; nothing to clean up */ } - async replay(ws, since) { + /** `nonce` is the possession challenge for a certified socket — sent on + * `ready` as `c`; the ticket itself never rides here (see `prove`). */ + async replay(ws, since, nonce = null) { const seq = (await this.state.storage.get('seq')) || 0 const snap = await this.state.storage.get('snap') let from = since @@ -409,7 +488,7 @@ export class Room { ws.send(JSON.stringify({ q: parseInt(key.slice(3), 10), i: f.i, d: f.d })) } } - ws.send(JSON.stringify({ ctl: 'ready', q: seq })) + ws.send(JSON.stringify(nonce ? { ctl: 'ready', q: seq, c: nonce } : { ctl: 'ready', q: seq })) } catch { /* socket died mid-replay */ } @@ -447,6 +526,29 @@ export class Room { } catch { return } + // Possession proof (see the connect path for why a hash-match is not one). + // A certified socket signs `prove..` with the private half of + // the key it presented; the name is in the text so the signature cannot be + // replayed into another room, and the nonce is consumed on first use so it + // cannot be replayed into this one. Only a proven socket is handed the + // blob write ticket, and only a proven socket that asked (`bt=1`) latches + // the room into requiring it. + if (f.ctl === 'prove') { + if (!meta.w || !meta.nonce || meta.proven || typeof f.g !== 'string') return + const name = (await this.state.storage.get('name')) || '' + const ok = await this.verifyWith(meta.w, f.g, `prove.${meta.nonce}.${name}`) + // consumed either way — a wrong answer does not get a second try + meta.nonce = null + if (!ok) { ws.serializeAttachment(meta); return } + meta.proven = true + ws.serializeAttachment(meta) + const ticket = await this.writeTicket() + if (meta.bt && !(await this.state.storage.get('wtReq'))) { + await this.state.storage.put('wtReq', 1) + } + try { ws.send(JSON.stringify({ ctl: 'wt', wt: ticket })) } catch { /* gone */ } + return + } // owner-signed revocation: cut off ONE member key (or a whole invite // lineage) without re-keying the room. Plaintext control frame — it names // only pubkeys, never content. Live sockets on the revoked key are closed. @@ -456,11 +558,25 @@ export class Room { if (!(await this.verifyWith(f.o, f.g, `rev.${f.p}`))) return const rev = (await this.state.storage.get('rev')) || [] if (!rev.includes(f.p)) await this.state.storage.put('rev', [...rev, f.p]) + // Closing the socket and refusing reconnects revokes the OP channel; the + // blob write ticket is a bearer capability the removed member already + // holds, so it has to be re-minted or removal leaks a write path. The + // fresh one goes only to sockets that are still certified — sending it + // in the note everyone gets would hand it to every reader in the room. + await this.state.storage.delete('wt') + const fresh = await this.writeTicket() const note = JSON.stringify({ ctl: 'revoked', p: f.p }) + const noteW = JSON.stringify({ ctl: 'revoked', p: f.p, wt: fresh }) for (const peer of this.state.getWebSockets()) { const m = peer.deserializeAttachment() || {} - try { peer.send(note) } catch { /* gone */ } - if (m.w === f.p) { try { peer.close(1008, 'revoked') } catch { /* gone */ } } + if (m.w === f.p) { + try { peer.send(note) } catch { /* gone */ } + try { peer.close(1008, 'revoked') } catch { /* gone */ } + continue + } + // PROVEN, not merely certified: a reader that presented the owner's + // public key has a pinned `w` too, and must not receive the fresh ticket + try { peer.send(m.proven ? noteW : note) } catch { /* gone */ } } return } @@ -475,11 +591,18 @@ export class Room { // Signed rooms: a persisted frame (op batch / snapshot) must carry a valid // writer signature, else DROP it — this is what enforces read-only. A // reader (no private key) can still send ephemeral frames (presence). - if (meta.signed && (f.p === 1 || f.snap === 1)) { + // + // A frame that merely fans out is verified too WHEN IT CLAIMS a signature: + // the fork snapshot is ephemeral yet replaces a peer's whole document, so + // it signs itself, and the echo below must never carry a `g` this relay + // did not check — a stamp you don't verify is worse than no stamp. + const claimed = typeof f.g === 'string' + if (meta.signed && (f.p === 1 || f.snap === 1 || claimed)) { if (!(await this.verifySig(f, ws))) return } const out = { i: f.i, d: f.d } + if (meta.signed && claimed) out.g = f.g const weight = (f.i?.length || 0) + (f.d?.length || 0) if (f.p === 1) { // Per-room storage ceiling. Room creation is unauthenticated by design @@ -509,7 +632,19 @@ export class Room { /* gone */ } } else if (f.snap === 1 && typeof f.q === 'number') { - // client-produced encrypted snapshot: keep the newest, prune covered ops + // client-produced encrypted snapshot: keep the newest, prune covered ops. + // + // CLAMPED to the room's own seq. `q` is the client's claim of what the + // snapshot covers, and the prune below deletes every op up to it — so a + // snapshot claiming q = 10^9 would wipe the whole log with one frame and + // leave every later joiner with a snapshot and no ops. A writer is + // trusted to edit, not to destroy the log. Refused with a code, not + // silently dropped: a client that produces one has a drifted counter, + // and silence is how that kind of bug stays hidden. + const seq = (await this.state.storage.get('seq')) || 0 + if (f.q > seq) { + return refuse(ws, 'snap-ahead', { q: f.q, seq, k: f.k }) + } const cur = await this.state.storage.get('snap') if (!cur || f.q > cur.q) { // A snapshot supersedes every op it covers, so it RELIEVES pressure —