API index and search · Build metadata
Source snapshot
packages/room/src/token.ts
1/**2 * The room's **self-authorizing signed lease token** (RINDLE-REALTIME-DESIGN.md §10.1).3 *4 * The API server is the authority (§4): it authenticates the user, resolves the named5 * query to an approved AST, and signs this token. The token then IS the lease — the6 * room materializes on first presentation, so no pre-placement `/materialize` control7 * call ever touches it (the §10.1 inversion; on the DO shell the Worker verifies the8 * same signature statelessly before `get(id)`).9 *10 * Shape: `rt1.<base64url(payload)>.<base64url(hmac-sha256(prefix.payload))>` with11 * payload `{ v: 1, doc, ast, sub, iat, exp, kid }`:12 *13 * - `doc` — the room/document id the token authorizes (a token for one room presented14 * to another is refused);15 * - `ast` — the approved wire `Ast` (the client never composes this; §4's "ASTs never16 * cross the public wire" holds because the token is opaque TO THE CLIENT — it carries17 * it, it cannot mint or alter it);18 * - `sub` — the subject (user id): the revocation key (§4.1);19 * - `iat`/`exp` — issued-at / expiry, ms epoch. Renewal is re-authorization: clients20 * obtain a fresh token through the API server, never extend this one. `iat` is what21 * lets a revocation refuse pre-revocation tokens while a genuine re-grant (a newer22 * token) passes immediately;23 * - `kid` — which shared secret signed it (rotation).24 *25 * HMAC via WebCrypto (`crypto.subtle`) so the exact same code verifies in Node (the26 * test shell) and in a Cloudflare Worker/DO (P4) — no `node:crypto` import.27 */2829export interface RoomTokenPayload {30 v: 1;31 doc: string;32 ast: unknown;33 sub: string;34 iat: number;35 exp: number;36 kid: string;37 /** A short fingerprint of the room's compiled scope specs at mint time38 * ({@link scopeSpecsHash}). Advisory, not a credential: the room's §3.3 gate is the39 * contract regardless. It lets the shell detect SCOPE SKEW — a room profile edited40 * while a room is already live arms the gate with the OLD specs (a one-shot at boot)41 * while fresh leases prove against the NEW ones, so every routed write silently42 * deopt-loops. Optional so a pre-stamp api-server / older token still verifies. */43 scopesHash?: string;44}4546export interface MintRoomTokenOptions {47 doc: string;48 ast: unknown;49 /** The subject (user id) this token authorizes — the §4.1 revocation key. */50 sub: string;51 /** Key id + its secret (utf-8; give every room the same `keys` map). */52 kid: string;53 key: string;54 /** Expiry, ms from `now`. Keep short (minutes) — the §4.1 TTL backstop. */55 ttlMs: number;56 /** Mint time; defaults to `Date.now()`. Injectable for tests. */57 now?: number;58 /** The room's {@link scopeSpecsHash} for the profile this lease serves — stamped so the59 * shell can flag scope skew (see {@link RoomTokenPayload.scopesHash}). Omit to not stamp. */60 scopesHash?: string;61}6263const PREFIX = "rt1";6465/** Canonical JSON: object keys sorted recursively (arrays keep order), so structurally66 * identical specs serialize identically regardless of key INSERTION order across code67 * versions — a cosmetic reorder must not read as a scope change. `undefined`-valued keys68 * are dropped, matching `JSON.stringify` and the `footprintWhere?`/`where?` optionals. */69function canonicalJson(v: unknown): string {70 if (v === undefined) return "null";71 if (v === null || typeof v !== "object") return JSON.stringify(v);72 if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;73 const o = v as Record<string, unknown>;74 const keys = Object.keys(o)75 .filter((k) => o[k] !== undefined)76 .sort();77 return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;78}7980/** A stable short fingerprint of the compiled scope specs — the scope-skew tripwire.81 * NOT security (the gate re-proves every write): FNV-1a-32 over the {@link canonicalJson}82 * form, 8 hex chars. Both the api-server (stamping the lease token) and the room shell83 * (hashing the boot-wire scopes it armed the gate with) run this over the SAME compiler84 * output, so an unchanged profile ⇒ equal hash and a profile edited under a live room ⇒85 * mismatch. A collision only costs a missed diagnostic, never correctness. Accepts either86 * wire's spec array (`RoomScopeSpec[]` / `RoomTableSpec[]` — structurally identical). */87export function scopeSpecsHash(specs: readonly unknown[]): string {88 const json = canonicalJson(specs);89 let h = 0x811c9dc5;90 for (let i = 0; i < json.length; i++) {91 h ^= json.charCodeAt(i);92 h = Math.imul(h, 0x01000193);93 }94 return (h >>> 0).toString(16).padStart(8, "0");95}9697function b64url(bytes: Uint8Array): string {98 let bin = "";99 for (const b of bytes) bin += String.fromCharCode(b);100 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");101}102103// Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit104// `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s105// `BufferSource` under consumers compiling this source with newer lib types (the DO shell).106function unb64url(s: string) {107 const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));108 const out = new Uint8Array(bin.length);109 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);110 return out;111}112113// Return type inferred: this package's tsconfig has no DOM lib, so the WebCrypto114// interface names aren't ambient — but `crypto.subtle`'s own types carry through.115async function hmacKey(secret: string, usage: "sign" | "verify") {116 return crypto.subtle.importKey(117 "raw",118 new TextEncoder().encode(secret),119 { name: "HMAC", hash: "SHA-256" },120 false,121 [usage],122 );123}124125/** Sign a room lease token. This runs on the API-server side (or a test playing it). */126export async function mintRoomToken(opts: MintRoomTokenOptions): Promise<string> {127 const now = opts.now ?? Date.now();128 const payload: RoomTokenPayload = {129 v: 1,130 doc: opts.doc,131 ast: opts.ast,132 sub: opts.sub,133 iat: now,134 exp: now + opts.ttlMs,135 kid: opts.kid,136 // Stamped only when supplied — an unstamped mint keeps the pre-scopesHash byte shape.137 ...(opts.scopesHash !== undefined ? { scopesHash: opts.scopesHash } : {}),138 };139 const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));140 const signed = `${PREFIX}.${body}`;141 const key = await hmacKey(opts.key, "sign");142 const sig = new Uint8Array(143 await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)),144 );145 return `${signed}.${b64url(sig)}`;146}147148/** Why a token was refused. The `reason` is terse and safe to echo in a queryError. */149export class RoomTokenError extends Error {150 readonly reason: string;151152 constructor(reason: string) {153 super(`lease token refused: ${reason}`);154 this.reason = reason;155 }156}157158export interface VerifyRoomTokenOptions {159 /** The room's own doc id — a token for any other doc is refused. */160 doc: string;161 /** kid → shared secret. Unknown kids are refused (never "try them all"). */162 keys: Record<string, string>;163 /** Verification time; defaults to `Date.now()`. Injectable for tests. */164 now?: number;165}166167/**168 * Verify a token's signature and claims; returns the payload (with the approved AST)169 * or throws {@link RoomTokenError}. Signature is checked FIRST — no claim is trusted170 * (not even `kid`'s existence beyond the key lookup) before the MAC passes.171 */172export async function verifyRoomToken(173 token: string,174 opts: VerifyRoomTokenOptions,175): Promise<RoomTokenPayload> {176 const now = opts.now ?? Date.now();177 const parts = token.split(".");178 if (parts.length !== 3 || parts[0] !== PREFIX) {179 throw new RoomTokenError("not a room token");180 }181 const [, body, sig] = parts;182183 // Parse only far enough to find `kid` (the signature covers everything, so a lying184 // kid can only select a key that then fails the MAC).185 let payload: RoomTokenPayload;186 try {187 payload = JSON.parse(new TextDecoder().decode(unb64url(body))) as RoomTokenPayload;188 } catch {189 throw new RoomTokenError("malformed payload");190 }191 const secret = typeof payload.kid === "string" ? opts.keys[payload.kid] : undefined;192 if (secret === undefined) {193 throw new RoomTokenError("unknown key id");194 }195 const key = await hmacKey(secret, "verify");196 const ok = await crypto.subtle.verify(197 "HMAC",198 key,199 unb64url(sig),200 new TextEncoder().encode(`${PREFIX}.${body}`),201 );202 if (!ok) {203 throw new RoomTokenError("bad signature");204 }205206 if (payload.v !== 1) throw new RoomTokenError("unknown version");207 if (payload.doc !== opts.doc) throw new RoomTokenError("token is for another doc");208 if (typeof payload.sub !== "string" || payload.sub.length === 0) {209 throw new RoomTokenError("missing subject");210 }211 if (typeof payload.exp !== "number" || now >= payload.exp) {212 throw new RoomTokenError("expired lease");213 }214 if (typeof payload.iat !== "number") throw new RoomTokenError("missing iat");215 if (payload.ast === undefined || payload.ast === null) {216 throw new RoomTokenError("missing ast");217 }218 return payload;219}220