Hash seat tokens at rest (security review finding)

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 00:03:36 -04:00
co-authored by Claude Fable 5
parent ce8b6a315b
commit 0221e91ba3
3 changed files with 48 additions and 17 deletions
+35 -13
View File
@@ -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<PlayerId, string>;
seed: number;
expansion: boolean;
@@ -41,6 +41,20 @@ const rooms = new Map<string, Room>();
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<string, PendingTransfer>();
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<RoomLine, { kind: "meta" }>;
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}`);