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:
co-authored by
Claude Fable 5
parent
ce8b6a315b
commit
0221e91ba3
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user