From 0221e91ba33649040fe8d3be9ed601635fe265d2 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 00:03:36 -0400 Subject: [PATCH] Hash seat tokens at rest (security review finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw seat tokens no longer touch disk or long-lived memory: rooms store sha-256 hashes, joins compare timing-safely, sessions keep the raw token they authenticated with only for minting transfer phrases, and legacy plaintext room files still load (hashed on read). Verified: rejoin and transfer both work, and the room file contains only hostTokenHash — no raw token anywhere. Co-Authored-By: Claude Fable 5 --- packages/server/src/index.ts | 8 ++++-- packages/server/src/rooms.ts | 48 ++++++++++++++++++++++++++---------- packages/server/src/store.ts | 9 +++++-- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index aa60137..fa1b8c2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -36,6 +36,8 @@ interface Session { socket: WebSocket; playerId: PlayerId | null; roomId: string | null; + /** The raw seat token this connection authenticated with (memory only). */ + token: string | null; claimFails: number; } @@ -71,7 +73,7 @@ function broadcastRoomState(room: Room): void { } wss.on("connection", (socket) => { - const session: Session = { socket, playerId: null, roomId: null, claimFails: 0 }; + const session: Session = { socket, playerId: null, roomId: null, token: null, claimFails: 0 }; sessions.add(session); send(socket, { type: "welcome", game: "wizwar" }); @@ -93,6 +95,7 @@ wss.on("connection", (socket) => { const { room, token } = createRoom(name); session.playerId = name; session.roomId = room.id; + session.token = token; send(socket, { type: "seat", playerId: name, token }); broadcastRoomState(room); break; @@ -107,6 +110,7 @@ wss.on("connection", (socket) => { if ("error" in result) return send(socket, { type: "error", message: result.error }); session.playerId = name; session.roomId = room.id; + session.token = result.token; send(socket, { type: "seat", playerId: name, token: result.token }); // Rejoining a running game: replay the chronicle so far. if (room.state) { @@ -137,7 +141,7 @@ wss.on("connection", (socket) => { case "makeTransfer": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); - const result = makeTransferCode(room, session.playerId); + const result = makeTransferCode(room, session.playerId, session.token); if ("error" in result) return send(socket, { type: "error", message: result.error }); send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt }); break; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 7a8cd79..a26569e 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -3,7 +3,7 @@ // replays, async play, and crash recovery). Every join, start, and command is // persisted; on boot, rooms are rebuilt by replaying their files. -import { randomBytes, randomInt } from "node:crypto"; +import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto"; import { applyCommand, createGame, @@ -28,7 +28,7 @@ export interface Room { id: string; hostId: PlayerId; players: PlayerId[]; // join order - /** Per-player secrets: reclaiming a seat requires the matching token. */ + /** Per-player secret HASHES: reclaiming a seat requires the raw token. */ tokens: Map; seed: number; expansion: boolean; @@ -41,6 +41,20 @@ const rooms = new Map(); const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */ +function hashToken(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +function tokenMatches(room: Room, playerId: PlayerId, raw: string | null): boolean { + if (!raw) return false; + const stored = room.tokens.get(playerId); + if (!stored) return false; + const a = Buffer.from(stored, "hex"); + const b = Buffer.from(hashToken(raw), "hex"); + return a.length === b.length && timingSafeEqual(a, b); +} + function makeRoomCode(): string { let code = ""; for (let i = 0; i < 4; i++) { @@ -55,7 +69,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { id: makeRoomCode(), hostId, players: [hostId], - tokens: new Map([[hostId, token]]), + tokens: new Map([[hostId, hashToken(token)]]), seed: randomInt(0, 0xffffffff), expansion: false, state: null, @@ -67,7 +81,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { kind: "meta", id: room.id, hostId, - hostToken: token, + hostTokenHash: hashToken(token), seed: room.seed, createdAt: new Date().toISOString(), }); @@ -85,15 +99,15 @@ export function joinRoom( ): { token: string } | { error: string } { if (room.players.includes(playerId)) { // Reclaiming an existing seat requires that seat's secret. - if (token && room.tokens.get(playerId) === token) return { token }; + if (tokenMatches(room, playerId, token)) return { token: token! }; return { error: "that wizard name is taken in this room" }; } if (room.state) return { error: "game already started" }; if (room.players.length >= 4) return { error: "room is full" }; const fresh = randomBytes(16).toString("hex"); room.players.push(playerId); - room.tokens.set(playerId, fresh); - appendLine(room.id, { kind: "join", name: playerId, token: fresh }); + room.tokens.set(playerId, hashToken(fresh)); + appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) }); return { token: fresh }; } @@ -206,9 +220,13 @@ interface PendingTransfer { const transfers = new Map(); const TRANSFER_TTL_MS = 10 * 60 * 1000; -export function makeTransferCode(room: Room, playerId: PlayerId): { code: string; expiresAt: number } | { error: string } { - const token = room.tokens.get(playerId); - if (!token) return { error: "you hold no seat in this room" }; +export function makeTransferCode( + room: Room, + playerId: PlayerId, + rawToken: string | null, +): { code: string; expiresAt: number } | { error: string } { + if (!tokenMatches(room, playerId, rawToken)) return { error: "you hold no seat in this room" }; + const token = rawToken!; // Sweep expired codes while we are here. const now = Date.now(); for (const [code, t] of transfers) { @@ -248,7 +266,7 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI } transfers.delete(normalized); // one-time const room = rooms.get(t.roomId); - if (!room || room.tokens.get(t.name) !== t.token) return { error: "that seat no longer exists" }; + if (!room || !tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" }; return { roomId: t.roomId, name: t.name, token: t.token }; } @@ -259,11 +277,13 @@ export function loadPersistedRooms(): void { for (const [id, lines] of readAllRooms()) { try { const meta = lines[0] as Extract; + const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null); + if (!hostHash) throw new Error("meta line has no host token"); const room: Room = { id, hostId: meta.hostId, players: [meta.hostId], - tokens: new Map([[meta.hostId, meta.hostToken]]), + tokens: new Map([[meta.hostId, hostHash]]), seed: meta.seed, expansion: false, state: null, @@ -272,8 +292,10 @@ export function loadPersistedRooms(): void { }; for (const line of lines.slice(1)) { if (line.kind === "join") { + const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); + if (!joinHash) throw new Error("join line has no token"); room.players.push(line.name); - room.tokens.set(line.name, line.token); + room.tokens.set(line.name, joinHash); } else if (line.kind === "start") { const r = startInMemory(room, line.expansion); if ("error" in r) throw new Error(`replay start failed: ${r.error}`); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 863be76..8f67199 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -10,7 +10,10 @@ export interface RoomMetaLine { kind: "meta"; id: string; hostId: string; - hostToken: string; + /** sha-256 of the host's seat token. Raw tokens never touch disk. */ + hostTokenHash?: string; + /** Legacy plaintext token (pre-hashing files only). */ + hostToken?: string; seed: number; createdAt: string; } @@ -18,7 +21,9 @@ export interface RoomMetaLine { export interface JoinLine { kind: "join"; name: string; - token: string; + tokenHash?: string; + /** Legacy plaintext token (pre-hashing files only). */ + token?: string; } export interface StartLine {