From 74e66aadb437920dc2009689df5ce154b8cc1e18 Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:59:25 +0100 Subject: [PATCH 1/5] sync: the relay stamps what it verified, and blob uploads take a proven ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovers the relay authorization work from 07263b3 (issue #270) onto today's tree, with the kernel review's items applied (handoffs/kernel-review-270.md). The relay now stamps a fanned-out frame with exactly what it checked: `q` on a frame it persisted, and the sender's `g` echoed on a signed ephemeral frame it verified. It never stamps a frame it did not check. In a signed room the client refuses content-bearing frames (op batches, fork snapshots) that carry neither stamp, so read-only holds for live peers and not only for the stored log. Legacy `r` rooms stay on the pre-signing model. The fork snapshot is signed on the way out for the same reason op batches are. Blob uploads take a write ticket rather than the room token; reads still take the token. The ticket is issued over the socket — and only to a socket that PROVES the key it presented: `ready` carries a nonce, the client signs `prove..`, and the ticket follows on its own frame. A hash-match on `?w=` alone certifies a socket for per-frame verification but is not proof of possession, since the key it matches is public and in every copy. The per-room latch that turns uploads ticket-only is set by a proven, ticket- capable writer, so the relay deploys ahead of every shipped client without breaking their asset offload. A snapshot claiming a seq the room has not reached is refused with a code (`snap-ahead`) rather than stored, since storing it would prune the whole op log. Nothing is lost on refusal, so it never reaches SyncNotice. Client half is in kernel/src/sync/online.ts (slides' file is a facade). It reconnects on an older relay without change: no nonce means no proof, uploads take the token as before, and a fork snapshot degrades to log convergence until the relay is deployed. Rigs: test-relay-auth 49 (recovered 29 + the review's negatives: a socket presenting the owner's public key is challenged, not handed a ticket, and cannot latch; a wrong or replayed proof earns nothing; snap-ahead refused with its frame id and nothing pruned). Mutation-tested — removing the clamp or the proof check turns it red. test-relay-protocol extended to the parameters and signature texts actually on the wire; it listed several that were not and was matching `tok=` alone. test-sync 45368, session/spaces/type/shape/equiv/flat/ parent-text/offline/blobs/export-secrets green. slides, spaces, type typecheck. Deploy order: the relay first, then the client through the update channel. --- kernel/src/sync/online.ts | 138 ++++++++++- scripts/test-relay-auth.ts | 410 +++++++++++++++++++++++++++++++ scripts/test-relay-protocol.ts | 22 +- server/sync-worker/src/worker.js | 159 +++++++++++- 4 files changed, 707 insertions(+), 22 deletions(-) create mode 100644 scripts/test-relay-auth.ts 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/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 — From f17087937ad1355e869a6dd0deaf0adbf12f8d43 Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:01:05 +0100 Subject: [PATCH 2/5] dash: the relay-auth wire, in the twin transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DASH ZONE, from the kernel branch, and flagged as such on the board. The kernel brief says a kernel PR hands the app half to the zone owner; this commit is that half, for the dash owner to accept or replace, landed here because (1) dash's transport is a deliberate twin of the kernel's rather than a facade — it runs a different CRDT engine (scripts/test-relay-protocol.ts says why at length) — so it cannot inherit the change; (2) that guard now compares the parameters and signature texts actually on the wire and blocks merge until the two match; (3) the dash session is down; and (4) the change has to reach both apps in the same release, since one deployed relay verifies both clients. What moved, mirroring the kernel commit before this one: `bt=1` on both writer URLs; the `ready` challenge answered with `prove..` and the ticket taken from its own `wt` frame (never from `ready`); `wt` re-minted on `revoked`; `vouched` — in a `w` room, op batches and fork snapshots are applied only when the relay stamped them (`q` or echoed `g`); the fork snapshot signed on the way out; `snap-ahead` recognised and logged. dash does not offload assets yet, so `blobCreds()` and `writeReady()` are the accessor the day it does, not a behaviour change today. test-relay-protocol 19/19 (the two clients spell the wire identically), test-dash-sync 23107, test-dash-canvassync 284; dash typechecks. --- dash/src/sync/online.ts | 109 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 8 deletions(-) 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 }) })() From 077986ace0c1d9e488446db74e4194ef19700843 Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:03:02 +0100 Subject: [PATCH 3/5] docs: the relay's stamps, the possession proof, and the snapshot clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collab-design.md: the "Relay enforcement" block and the Phase 1 wire section now describe the shipped mechanism — the nonce on ready, `prove`, the ticket on its own frame, the latch set only by a proven writer, the snapshot clamp — and no longer say the ticket rides on `ready`, which it never must. The possession signature text joins the list the wire-parity guard holds both clients to. DECISIONS.md: the three rules, why the latch stays, the deploy order, and the finding that the parity guard had been matching `tok=` alone. ci.yml: test-relay-auth.ts registered, beside the protocol guard. --- .github/workflows/ci.yml | 13 +++++++++ docs/DECISIONS.md | 58 ++++++++++++++++++++++++++++++++++++++ docs/collab-design.md | 60 +++++++++++++++++++++++++++++----------- 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 729d014b..2d56e78d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -847,6 +847,19 @@ 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: 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/docs/DECISIONS.md b/docs/DECISIONS.md index 3c143b54..df89c40e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6390,3 +6390,61 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40 mapped, 1 correctly absent, run by bento-team-slides. Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8 + +## 2026-09-13 — The relay vouches for what it verified; a hash-match certifies but does not prove + +**Decision.** Three rules for the sync relay and its two client transports +(`kernel/src/sync/online.ts`; `dash/src/sync/online.ts`, a deliberate twin +held to the same wire by `scripts/test-relay-protocol.ts`): + +1. **The relay stamps a fanned-out frame with exactly what it checked** — `q` + on a frame it persisted, the sender's `g` echoed on a signed ephemeral + frame it verified — and never with what it did not. In a `w` room a client + applies op batches and fork snapshots only when stamped; legacy `r` rooms, + which have no signatures, stay on the older model. The fork snapshot is + signed on the way out so it can earn the stamp. This is the client half of + the read-only guarantee; relay enforcement alone covers only what the relay + *persists*, and a blind relay cannot tell an op batch from a presence beat. + +2. **A hash-match on `?w=` certifies a socket; it does not prove possession.** + The key it matches is the owner's *public* key, in every copy of the file. + That is harmless for the op channel, where each persisted frame carries its + own signature. It is not harmless for anything issued *over the socket* as + a bearer capability. So the blob write ticket — and the per-room latch that + makes uploads ticket-only — wait for a possession proof: `ready` carries a + nonce, the client signs `prove..`, the ticket follows on its + own `wt` frame. Room name in the text, nonce single-use. The ticket is + re-minted on revocation to proven sockets only. Reads keep the room token. + +3. **A snapshot cannot claim to cover ops the room has not seen.** `snap` with + `q > seq` is refused with a code (`snap-ahead`), because storing it prunes + the log up to `q`. Writers are trusted to edit, not to destroy the log. + Refusal loses nothing, so it never becomes a user-facing notice. + +**Why the latch stays.** It was proposed to drop the per-room latch as +"anonymously triggerable". Without it the ticket either breaks every shipped +file's asset offload on deploy day (require it everywhere) or protects +nothing (require it nowhere, forever — files that never update exist). The +latch flips a room to ticket-only the first time a *proven*, ticket-capable +writer joins it; the objection was real only while a hash-match could trip +it. The public guestbook is latchable by anyone by design (public writer +invite) and that is acceptable: its room re-mints every 30 minutes and the +guestbook deck is a current client. + +**Deploy order.** Relay first, then clients through the update channel, both +apps in the same release. A client newer than the relay degrades to the older +behaviour on every path but one — its fork snapshot, unstamped by an old +relay, is refused live and the fork converges through the persisted log +instead. Bounded, not lossy. + +**The wire-parity guard was checking almost nothing.** `test-relay-protocol.ts` +listed query parameters that are not on the wire and omitted most that are; +its signature-text alternation lacked `dlg.`. It reported the two transports +identical while matching `tok=` alone. Now it compares the parameters, the +signature texts, and the control-frame names the worker actually reads. + +Pointers: `server/sync-worker/src/worker.js` (the connect path comment on +certified-vs-proven, the `prove` handler, the clamp), `kernel/src/sync/ +online.ts` (`vouched`, `prove`, `writeReady`), `docs/collab-design.md` "Relay +enforcement" and "Phase 1 wire format", `scripts/test-relay-auth.ts` (49 +checks; the review's negatives are named as such). diff --git a/docs/collab-design.md b/docs/collab-design.md index ba62df8f..a8ceaf90 100644 --- a/docs/collab-design.md +++ b/docs/collab-design.md @@ -320,13 +320,26 @@ already gives key-holders content integrity; `g` adds *authorization*. ### Relay enforcement (`w`-rooms only) - on connect (w-room): require ?w, verify "w"+b64url(sha256(w)) == name, - store `w` in DO storage (like `tok`; commitment-safe) - on {p:1} / {snap:1}: require valid `g` over `${i}.${d}` vs stored `w`, - else DROP the frame (no persist, no fan-out) - on ephemeral frames: unchanged + on connect (w-room): require ?w, verify "w"+b64url(sha256(w)) == name + (or the invite chain), PIN `w` on the socket; issue a + per-socket nonce `c` on `ready` + on {ctl:'prove', g}: verify `g` over `prove.${c}.${name}` vs the pinned `w`; + nonce consumed either way; on success mark the socket + PROVEN and send {ctl:'wt', wt} + on {p:1} / {snap:1}: require valid `g` over `${i}.${d}` vs pinned `w`, + else DROP the frame (no persist, no fan-out); + {snap:1} with q > seq → refuse `snap-ahead` + on ephemeral + `g`: verify the same way; DROP if bad; ECHO `g` if good + on ephemeral, no `g`: fan out unstamped (presence must work) on r-rooms: unchanged (permissive) — legacy files keep working + A hash-match on `?w=` CERTIFIES a socket (its frames are verified against + that key) but is NOT proof of possession: the key it matches is the owner's + PUBLIC key and every copy of the file carries it, reader copies included. + Per-frame signatures make that harmless on the op channel. The blob write + ticket is a bearer capability issued over the socket, so it — and the latch + that turns a room ticket-only — wait for `prove`. + ### Rollout & backward-compat (the reason to ship now) - **Client first, relay second, no breakage window.** New clients mint `w` @@ -389,10 +402,12 @@ the read capability.* ## Phase 1 wire format (as shipped) - Room id: `w` + b64url(SHA-256(ownerPubRaw)) — commits to the OWNER key. -- Writer connect params: `?w=` plus, for members, the chain +- Writer connect params: `?w=&bt=1` plus, for members, the chain `&o=&ivp=&ivr=writer&ive=&ivs=&dg=`. Signature texts: invite = `inv.${ivp}.${ivr}.${ive}`, delegation = - `dlg.${memberPub}`, revocation = `rev.${pub}`. + `dlg.${memberPub}`, revocation = `rev.${pub}`, possession = + `prove.${nonce}.${roomName}`. `scripts/test-relay-protocol.ts` holds the + kernel and dash transports to the same spelling of every one of these. - The relay pins the verified key PER SOCKET and checks every persisted frame's `g` against it. Direct (hash-matching) keys cover the owner and legacy 1.0.2 shared-writer rooms unchanged. @@ -401,15 +416,28 @@ the read capability.* its sequence number, and a signed frame it fanned out comes back with its signature echoed. In a signed room the client accepts only stamped frames — that is the client half of the read-only guarantee described under *Threat - model / limits*, and it is why the stamp exists at all. (The relay is being - changed concurrently; treat the stamp fields in `sync/online.ts` as the - authority for their exact names.) -- **Blob write tickets**: a writer that understands them announces `bt=1` on - connect, and the relay — which only mints a ticket once it has seen such a - writer — hands one back on the room's ready message (`wt`), reissuing it when - a member is revoked. The client uses it to authorize blob uploads, so blob - writes are gated by the same per-socket verification as ops instead of by - mere possession of the room key. + model / limits*, and it is why the stamp exists at all. The stamps are `q` + (persisted; the sequence number) and `g` (the echoed signature); the fork + snapshot is signed on the way out precisely so it can earn the second one. +- **Possession proof and blob write tickets.** A certified socket's `ready` + carries a nonce `c`; the client answers `{ctl:'prove', g}` with `g` over + `prove.${c}.${name}` under the key it presented as `?w=`; on success the + relay sends `{ctl:'wt', wt}` — the ticket never rides on `ready`. The room + name in the signed text stops cross-room replay; the nonce is single-use. + A writer that will USE the ticket announces `bt=1` on connect, and a room + latches into ticket-only uploads (`wtReq`) the first time a PROVEN `bt=1` + writer joins — never on a hash-match alone, or any reader holding the + owner's public key could latch a room and 403 every older client's upload. + Until a room latches, uploads take the room token exactly as shipped + clients send it, which is what lets the relay deploy first. The ticket is + re-minted on revocation and the replacement goes to proven sockets only. + Reads still take the room token. The public guestbook is latchable by + anyone (its writer invite is public); acceptable, since the room re-mints + every 30 minutes and the guestbook deck is a current client. +- **Snapshot clamp.** `{snap:1, q}` with `q` beyond the room's `seq` is refused + with `snap-ahead` (naming the frame) rather than stored, because storing it + prunes every op up to `q`. A writer is trusted to edit, not to wipe the log. + Nothing is lost on refusal; the client logs it and does not surface it. - Revocation: owner sends `{ctl:'revoke', p, o, g}`; the relay stores `p` in a `rev` set, closes matching sockets, fans out `{ctl:'revoked', p}` (clients seeing their own key stand down permanently), and refuses future connects/ From f29d21cd77fc63e86bda5061af2cf18a4fca0ee2 Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:11:31 +0100 Subject: [PATCH 4/5] =?UTF-8?q?sync:=20the=20client=20vouch=20rig=20?= =?UTF-8?q?=E2=80=94=20the=20other=20half,=20which=20nothing=20had=20been?= =?UTF-8?q?=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/test-sync-vouch.ts drives a real OnlineTransport — the kernel's and dash's twin, with the same frames — through a fake socket in a signed room. An unstamped op batch and an unstamped fork snapshot are refused; the same frames carrying the relay's `q` or echoed `g` are applied; a persisted {snap:1} follows the same rule; presence keeps flowing unstamped; a legacy r-room applies everything. 20 checks. Why it exists: with `vouched()` mutated to `return true`, every rig in the tree stayed green — the relay rig cannot see a client decision, and no session rig drives the online transport. Security found that by mutation. Under the same mutation this rig fails 10 of 20, five per transport. Bundled with esbuild, as test-sanitize is: kernel/src/sync/online.ts uses constructor parameter properties, which node's strip-only loader rejects — which is why dash's twin avoids that syntax, and why no rig had ever driven the kernel transport before this one. --- .github/workflows/ci.yml | 16 ++++ scripts/test-sync-vouch.ts | 185 +++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 scripts/test-sync-vouch.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d56e78d..fca46908 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -860,6 +860,22 @@ jobs: # 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/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) From dae68d22408732036194f169088691b847e3d8ea Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:22:26 +0100 Subject: [PATCH 5/5] docs: hold the two design-doc changes until the release that ships the client half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DECISIONS.md and collab-design.md are reverted to main on this branch. Their content is parked, verbatim, in the private team repo and is promoted to docs/ with the release — a mechanical copy at that point. --- docs/DECISIONS.md | 58 ----------------------------------------- docs/collab-design.md | 60 ++++++++++++------------------------------- 2 files changed, 16 insertions(+), 102 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index df89c40e..3c143b54 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6390,61 +6390,3 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40 mapped, 1 correctly absent, run by bento-team-slides. Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8 - -## 2026-09-13 — The relay vouches for what it verified; a hash-match certifies but does not prove - -**Decision.** Three rules for the sync relay and its two client transports -(`kernel/src/sync/online.ts`; `dash/src/sync/online.ts`, a deliberate twin -held to the same wire by `scripts/test-relay-protocol.ts`): - -1. **The relay stamps a fanned-out frame with exactly what it checked** — `q` - on a frame it persisted, the sender's `g` echoed on a signed ephemeral - frame it verified — and never with what it did not. In a `w` room a client - applies op batches and fork snapshots only when stamped; legacy `r` rooms, - which have no signatures, stay on the older model. The fork snapshot is - signed on the way out so it can earn the stamp. This is the client half of - the read-only guarantee; relay enforcement alone covers only what the relay - *persists*, and a blind relay cannot tell an op batch from a presence beat. - -2. **A hash-match on `?w=` certifies a socket; it does not prove possession.** - The key it matches is the owner's *public* key, in every copy of the file. - That is harmless for the op channel, where each persisted frame carries its - own signature. It is not harmless for anything issued *over the socket* as - a bearer capability. So the blob write ticket — and the per-room latch that - makes uploads ticket-only — wait for a possession proof: `ready` carries a - nonce, the client signs `prove..`, the ticket follows on its - own `wt` frame. Room name in the text, nonce single-use. The ticket is - re-minted on revocation to proven sockets only. Reads keep the room token. - -3. **A snapshot cannot claim to cover ops the room has not seen.** `snap` with - `q > seq` is refused with a code (`snap-ahead`), because storing it prunes - the log up to `q`. Writers are trusted to edit, not to destroy the log. - Refusal loses nothing, so it never becomes a user-facing notice. - -**Why the latch stays.** It was proposed to drop the per-room latch as -"anonymously triggerable". Without it the ticket either breaks every shipped -file's asset offload on deploy day (require it everywhere) or protects -nothing (require it nowhere, forever — files that never update exist). The -latch flips a room to ticket-only the first time a *proven*, ticket-capable -writer joins it; the objection was real only while a hash-match could trip -it. The public guestbook is latchable by anyone by design (public writer -invite) and that is acceptable: its room re-mints every 30 minutes and the -guestbook deck is a current client. - -**Deploy order.** Relay first, then clients through the update channel, both -apps in the same release. A client newer than the relay degrades to the older -behaviour on every path but one — its fork snapshot, unstamped by an old -relay, is refused live and the fork converges through the persisted log -instead. Bounded, not lossy. - -**The wire-parity guard was checking almost nothing.** `test-relay-protocol.ts` -listed query parameters that are not on the wire and omitted most that are; -its signature-text alternation lacked `dlg.`. It reported the two transports -identical while matching `tok=` alone. Now it compares the parameters, the -signature texts, and the control-frame names the worker actually reads. - -Pointers: `server/sync-worker/src/worker.js` (the connect path comment on -certified-vs-proven, the `prove` handler, the clamp), `kernel/src/sync/ -online.ts` (`vouched`, `prove`, `writeReady`), `docs/collab-design.md` "Relay -enforcement" and "Phase 1 wire format", `scripts/test-relay-auth.ts` (49 -checks; the review's negatives are named as such). diff --git a/docs/collab-design.md b/docs/collab-design.md index a8ceaf90..ba62df8f 100644 --- a/docs/collab-design.md +++ b/docs/collab-design.md @@ -320,26 +320,13 @@ already gives key-holders content integrity; `g` adds *authorization*. ### Relay enforcement (`w`-rooms only) - on connect (w-room): require ?w, verify "w"+b64url(sha256(w)) == name - (or the invite chain), PIN `w` on the socket; issue a - per-socket nonce `c` on `ready` - on {ctl:'prove', g}: verify `g` over `prove.${c}.${name}` vs the pinned `w`; - nonce consumed either way; on success mark the socket - PROVEN and send {ctl:'wt', wt} - on {p:1} / {snap:1}: require valid `g` over `${i}.${d}` vs pinned `w`, - else DROP the frame (no persist, no fan-out); - {snap:1} with q > seq → refuse `snap-ahead` - on ephemeral + `g`: verify the same way; DROP if bad; ECHO `g` if good - on ephemeral, no `g`: fan out unstamped (presence must work) + on connect (w-room): require ?w, verify "w"+b64url(sha256(w)) == name, + store `w` in DO storage (like `tok`; commitment-safe) + on {p:1} / {snap:1}: require valid `g` over `${i}.${d}` vs stored `w`, + else DROP the frame (no persist, no fan-out) + on ephemeral frames: unchanged on r-rooms: unchanged (permissive) — legacy files keep working - A hash-match on `?w=` CERTIFIES a socket (its frames are verified against - that key) but is NOT proof of possession: the key it matches is the owner's - PUBLIC key and every copy of the file carries it, reader copies included. - Per-frame signatures make that harmless on the op channel. The blob write - ticket is a bearer capability issued over the socket, so it — and the latch - that turns a room ticket-only — wait for `prove`. - ### Rollout & backward-compat (the reason to ship now) - **Client first, relay second, no breakage window.** New clients mint `w` @@ -402,12 +389,10 @@ the read capability.* ## Phase 1 wire format (as shipped) - Room id: `w` + b64url(SHA-256(ownerPubRaw)) — commits to the OWNER key. -- Writer connect params: `?w=&bt=1` plus, for members, the chain +- Writer connect params: `?w=` plus, for members, the chain `&o=&ivp=&ivr=writer&ive=&ivs=&dg=`. Signature texts: invite = `inv.${ivp}.${ivr}.${ive}`, delegation = - `dlg.${memberPub}`, revocation = `rev.${pub}`, possession = - `prove.${nonce}.${roomName}`. `scripts/test-relay-protocol.ts` holds the - kernel and dash transports to the same spelling of every one of these. + `dlg.${memberPub}`, revocation = `rev.${pub}`. - The relay pins the verified key PER SOCKET and checks every persisted frame's `g` against it. Direct (hash-matching) keys cover the owner and legacy 1.0.2 shared-writer rooms unchanged. @@ -416,28 +401,15 @@ the read capability.* its sequence number, and a signed frame it fanned out comes back with its signature echoed. In a signed room the client accepts only stamped frames — that is the client half of the read-only guarantee described under *Threat - model / limits*, and it is why the stamp exists at all. The stamps are `q` - (persisted; the sequence number) and `g` (the echoed signature); the fork - snapshot is signed on the way out precisely so it can earn the second one. -- **Possession proof and blob write tickets.** A certified socket's `ready` - carries a nonce `c`; the client answers `{ctl:'prove', g}` with `g` over - `prove.${c}.${name}` under the key it presented as `?w=`; on success the - relay sends `{ctl:'wt', wt}` — the ticket never rides on `ready`. The room - name in the signed text stops cross-room replay; the nonce is single-use. - A writer that will USE the ticket announces `bt=1` on connect, and a room - latches into ticket-only uploads (`wtReq`) the first time a PROVEN `bt=1` - writer joins — never on a hash-match alone, or any reader holding the - owner's public key could latch a room and 403 every older client's upload. - Until a room latches, uploads take the room token exactly as shipped - clients send it, which is what lets the relay deploy first. The ticket is - re-minted on revocation and the replacement goes to proven sockets only. - Reads still take the room token. The public guestbook is latchable by - anyone (its writer invite is public); acceptable, since the room re-mints - every 30 minutes and the guestbook deck is a current client. -- **Snapshot clamp.** `{snap:1, q}` with `q` beyond the room's `seq` is refused - with `snap-ahead` (naming the frame) rather than stored, because storing it - prunes every op up to `q`. A writer is trusted to edit, not to wipe the log. - Nothing is lost on refusal; the client logs it and does not surface it. + model / limits*, and it is why the stamp exists at all. (The relay is being + changed concurrently; treat the stamp fields in `sync/online.ts` as the + authority for their exact names.) +- **Blob write tickets**: a writer that understands them announces `bt=1` on + connect, and the relay — which only mints a ticket once it has seen such a + writer — hands one back on the room's ready message (`wt`), reissuing it when + a member is revoked. The client uses it to authorize blob uploads, so blob + writes are gated by the same per-socket verification as ops instead of by + mere possession of the room key. - Revocation: owner sends `{ctl:'revoke', p, o, g}`; the relay stores `p` in a `rev` set, closes matching sockets, fans out `{ctl:'revoked', p}` (clients seeing their own key stand down permanently), and refuses future connects/