diff --git a/deploy/hash-tokens.ts b/deploy/hash-tokens.ts new file mode 100644 index 0000000..f9b8043 --- /dev/null +++ b/deploy/hash-tokens.ts @@ -0,0 +1,44 @@ +// One-time migration: replace each seat line's raw token with its SHA-256, +// so no ledger on disk or in a backup holds a live seat key. Idempotent; a +// line already hashed is left alone. Run ON the droplet from the server +// directory (tsx is installed there): +// cd /opt/waving-hands/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/waving-hands/rooms +// Browsers keep their raw tokens; the server hashes what they send and +// compares, so nobody loses a seat. + +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync, renameSync, statSync, writeFileSync, chownSync } from 'node:fs'; +import { join } from 'node:path'; + +const dir = process.argv[2]; +if (!dir) { + console.error('usage: hash-tokens.ts '); + process.exit(2); +} + +let files = 0; +let lines = 0; +for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) { + const path = join(dir, file); + const raw = readFileSync(path, 'utf8'); + let changed = 0; + const out = raw + .split('\n') + .map((line) => { + if (!line.trim()) return line; + const entry = JSON.parse(line) as Record; + if (entry.t !== 'seat' || typeof entry.token !== 'string') return line; + const { token, ...rest } = entry; + changed += 1; + return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' }); + }) + .join('\n'); + if (!changed) continue; + const { uid, gid } = statSync(path); + writeFileSync(path + '.tmp', out); + chownSync(path + '.tmp', uid, gid); + renameSync(path + '.tmp', path); + files += 1; + lines += changed; +} +console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`); diff --git a/deploy/replay-ledgers.ts b/deploy/replay-ledgers.ts index ba4cbd7..4daba91 100644 --- a/deploy/replay-ledgers.ts +++ b/deploy/replay-ledgers.ts @@ -13,7 +13,6 @@ import { createGame, type GameState, type TurnInput, type WizardId } from '../sr interface Seat { id: WizardId; name: string; - token: string; bot: boolean; } @@ -51,9 +50,9 @@ async function main(): Promise { continue; } let verdict = state ? `${turns} turns, ${state.over ? 'over' : 'in play'}` : 'not started'; - const human = seats.find((s) => !s.bot); - if (host && state && human) { - const res = await fetch(`${host}/api/rooms/${code}?token=${encodeURIComponent(human.token)}`); + if (host && state) { + // The gallery's view carries every wizard's health and the outcome, which is all the comparison needs. + const res = await fetch(`${host}/api/rooms/${code}`); if (!res.ok) { verdict += `, server ${res.status}`; } else { diff --git a/server/src/index.ts b/server/src/index.ts index 152347a..8d5e479 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,7 +3,7 @@ // // POST /api/rooms {name, size?} create a duel and take seat A // POST /api/rooms/:id/join {name} take the next seat -// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins) +// POST /api/rooms/:id/bot {token} the host seats a bot, before the duel begins // POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far // POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins // GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view @@ -183,8 +183,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (parts.length === 2 && req.method === 'POST') { if (!doors.allow(clientOf(req))) throw new RoomError('Too many duels opened from here just now; try again later.', 429); const body = await readBody(req); - const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2)); - return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); + const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2)); + return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) }); } const room = rooms.get(parts[2] ?? ''); @@ -198,8 +198,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const body = await readBody(req); if (action === 'join') { if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429); - const seat = rooms.join(room, String(body.name ?? '')); - return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); + const { seat, token: minted } = rooms.join(room, String(body.name ?? '')); + return send(res, 200, { seat: seat.id, token: minted, view: rooms.view(room, seat.id) }); } if (action === 'bot') { rooms.addBot(room, typeof body.token === 'string' ? body.token : undefined); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index ce812d8..c58ecae 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -3,7 +3,7 @@ // same place. Inputs wait in memory until every seat that must move has moved; // then the turn resolves at once, bots included. -import { randomBytes } from 'node:crypto'; +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { chooseBotTurn } from '../../src/lib/game/bot'; import { resolveTurn } from '../../src/lib/game/resolve'; import { GESTURES, type Gesture } from '../../src/lib/game/spells'; @@ -23,10 +23,15 @@ const CHAT_KEEP = 200; export interface Seat { id: WizardId; name: string; - token: string; + /** SHA-256 of the seat's token, hex; empty for a bot. */ + tokenHash: string; bot: boolean; } +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + export interface Room { id: string; size: number; @@ -113,25 +118,26 @@ export class Rooms { return this.rooms.size; } - /** The seat a token unlocks. */ + /** The seat a token unlocks, compared by hash in constant time. */ seatOf(room: Room, token: string | undefined): Seat { - const seat = room.seats.find((s) => !s.bot && s.token === token); + if (!token) throw new RoomError('That token opens no seat at this duel.', 403); + const given = Buffer.from(hashToken(token), 'hex'); + const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given)); if (!seat) throw new RoomError('That token opens no seat at this duel.', 403); return seat; } - create(name: string, size: number): { room: Room; seat: Seat } { + create(name: string, size: number): { room: Room; seat: Seat; token: string } { if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`); let id = newCode(); - while (this.rooms.has(id)) id = newCode(); + while (this.rooms.has(id) || this.store.exists(id)) id = newCode(); const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() }; this.rooms.set(id, room); this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt }); - const seat = this.sit(room, name, false); - return { room, seat }; + return { room, ...this.sit(room, name, false) }; } - join(room: Room, name: string): Seat { + join(room: Room, name: string): { seat: Seat; token: string } { if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409); return this.sit(room, name, false); } @@ -145,23 +151,27 @@ export class Rooms { this.commit(room, { t: 'start', seed: newSeed(), rules: CURRENT_RULES }); } + /** The host seats a bot in an empty chair. */ addBot(room: Room, token: string | undefined): Seat { - this.seatOf(room, token); + const host = this.seatOf(room, token); + if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may seat a bot.', 403); if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409); const taken = new Set(room.seats.map((s) => s.name.toLowerCase())); const pool = BOT_NAMES.filter((n) => !taken.has(n.toLowerCase())); const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct'; - return this.sit(room, name, true); + return this.sit(room, name, true).seat; } - private sit(room: Room, rawName: string, bot: boolean): Seat { + /** Take the next free seat. The token is returned once and never stored; the ledger keeps its hash. */ + private sit(room: Room, rawName: string, bot: boolean): { seat: Seat; token: string } { if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409); const name = cleanName(rawName); if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409); const id = SEAT_IDS.find((s) => !room.seats.some((taken) => taken.id === s))!; - const seat: Seat = { id, name, token: bot ? '' : newId(18), bot }; - this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot }); - return seat; + const token = bot ? '' : newId(18); + const seat: Seat = { id, name, tokenHash: bot ? '' : hashToken(token), bot }; + this.commit(room, { t: 'seat', id: seat.id, name, tokenHash: seat.tokenHash, bot }); + return { seat, token }; } /** The host sends a bot away before the duel begins. People leave by not coming back. */ @@ -174,17 +184,14 @@ export class Rooms { this.commit(room, { t: 'unseat', id: seat.id }); } - /** Record a seat's move; resolve the turn once nobody else is awaited. */ + /** Record a seat's move on the ledger, so a restart keeps it; resolve the turn once nobody else is awaited. */ submit(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); if (!room.state) throw new RoomError('The duel has not begun: seats are still empty.', 409); if (room.state.over) throw new RoomError('The duel is over.', 409); if (!waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409); - room.pending[seat.id] = cleanInput(raw); - room.updatedAt = Date.now(); - room.seq += 1; + this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input: cleanInput(raw) }); if (waitingOn(room).length === 0) this.resolve(room); - this.notify(room); } private resolve(room: Room): void { @@ -286,7 +293,10 @@ function apply(room: Room, line: LedgerLine): void { case 'room': break; case 'seat': - room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot }); + room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot }); + break; + case 'input': + room.pending[line.id] = line.input; break; case 'unseat': room.seats = room.seats.filter((s) => s.id !== line.id); @@ -314,7 +324,7 @@ function replay(id: string, lines: LedgerLine[]): Room | null { const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt }; for (const line of lines) { apply(room, line); - if (line.t === 'turn') room.updatedAt = line.at; + if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at; } return room; } diff --git a/server/src/store.ts b/server/src/store.ts index 63489a2..39fd48a 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -8,7 +8,10 @@ import type { TurnInput, WizardId } from '../../src/lib/game/state'; export type LedgerLine = | { t: 'room'; id: string; seats: number; createdAt: number } - | { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean } + /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */ + | { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean } + /** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */ + | { t: 'input'; at: number; id: WizardId; input: TurnInput } /** A bot sent away by the host before the duel began; its seat id is free again. */ | { t: 'unseat'; id: WizardId } /** Ledgers from before revisions were recorded resolve under rules 1. */ @@ -41,6 +44,11 @@ export class Store { .map((l) => JSON.parse(l) as LedgerLine); } + /** Whether a ledger exists on disk, loaded or not; a code must not be reissued over one. */ + exists(roomId: string): boolean { + return existsSync(this.path(roomId)); + } + roomIds(): string[] { return readdirSync(this.dir) .filter((f) => f.endsWith('.jsonl')) diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index fca9946..c4b63cc 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -180,7 +180,7 @@ {#if !watching}
- {#if r.seats.length < r.size} + {#if r.seats.length < r.size && room.isHost} {/if} {#if room.isHost}