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;
|
socket: WebSocket;
|
||||||
playerId: PlayerId | null;
|
playerId: PlayerId | null;
|
||||||
roomId: string | null;
|
roomId: string | null;
|
||||||
|
/** The raw seat token this connection authenticated with (memory only). */
|
||||||
|
token: string | null;
|
||||||
claimFails: number;
|
claimFails: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ function broadcastRoomState(room: Room): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wss.on("connection", (socket) => {
|
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);
|
sessions.add(session);
|
||||||
send(socket, { type: "welcome", game: "wizwar" });
|
send(socket, { type: "welcome", game: "wizwar" });
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ wss.on("connection", (socket) => {
|
|||||||
const { room, token } = createRoom(name);
|
const { room, token } = createRoom(name);
|
||||||
session.playerId = name;
|
session.playerId = name;
|
||||||
session.roomId = room.id;
|
session.roomId = room.id;
|
||||||
|
session.token = token;
|
||||||
send(socket, { type: "seat", playerId: name, token });
|
send(socket, { type: "seat", playerId: name, token });
|
||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
break;
|
break;
|
||||||
@@ -107,6 +110,7 @@ wss.on("connection", (socket) => {
|
|||||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||||
session.playerId = name;
|
session.playerId = name;
|
||||||
session.roomId = room.id;
|
session.roomId = room.id;
|
||||||
|
session.token = result.token;
|
||||||
send(socket, { type: "seat", playerId: name, token: result.token });
|
send(socket, { type: "seat", playerId: name, token: result.token });
|
||||||
// Rejoining a running game: replay the chronicle so far.
|
// Rejoining a running game: replay the chronicle so far.
|
||||||
if (room.state) {
|
if (room.state) {
|
||||||
@@ -137,7 +141,7 @@ wss.on("connection", (socket) => {
|
|||||||
case "makeTransfer": {
|
case "makeTransfer": {
|
||||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
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 });
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||||
send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt });
|
send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// replays, async play, and crash recovery). Every join, start, and command is
|
// replays, async play, and crash recovery). Every join, start, and command is
|
||||||
// persisted; on boot, rooms are rebuilt by replaying their files.
|
// 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 {
|
import {
|
||||||
applyCommand,
|
applyCommand,
|
||||||
createGame,
|
createGame,
|
||||||
@@ -28,7 +28,7 @@ export interface Room {
|
|||||||
id: string;
|
id: string;
|
||||||
hostId: PlayerId;
|
hostId: PlayerId;
|
||||||
players: PlayerId[]; // join order
|
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>;
|
tokens: Map<PlayerId, string>;
|
||||||
seed: number;
|
seed: number;
|
||||||
expansion: boolean;
|
expansion: boolean;
|
||||||
@@ -41,6 +41,20 @@ const rooms = new Map<string, Room>();
|
|||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
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 {
|
function makeRoomCode(): string {
|
||||||
let code = "";
|
let code = "";
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
@@ -55,7 +69,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
|||||||
id: makeRoomCode(),
|
id: makeRoomCode(),
|
||||||
hostId,
|
hostId,
|
||||||
players: [hostId],
|
players: [hostId],
|
||||||
tokens: new Map([[hostId, token]]),
|
tokens: new Map([[hostId, hashToken(token)]]),
|
||||||
seed: randomInt(0, 0xffffffff),
|
seed: randomInt(0, 0xffffffff),
|
||||||
expansion: false,
|
expansion: false,
|
||||||
state: null,
|
state: null,
|
||||||
@@ -67,7 +81,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
|||||||
kind: "meta",
|
kind: "meta",
|
||||||
id: room.id,
|
id: room.id,
|
||||||
hostId,
|
hostId,
|
||||||
hostToken: token,
|
hostTokenHash: hashToken(token),
|
||||||
seed: room.seed,
|
seed: room.seed,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -85,15 +99,15 @@ export function joinRoom(
|
|||||||
): { token: string } | { error: string } {
|
): { token: string } | { error: string } {
|
||||||
if (room.players.includes(playerId)) {
|
if (room.players.includes(playerId)) {
|
||||||
// Reclaiming an existing seat requires that seat's secret.
|
// 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" };
|
return { error: "that wizard name is taken in this room" };
|
||||||
}
|
}
|
||||||
if (room.state) return { error: "game already started" };
|
if (room.state) return { error: "game already started" };
|
||||||
if (room.players.length >= 4) return { error: "room is full" };
|
if (room.players.length >= 4) return { error: "room is full" };
|
||||||
const fresh = randomBytes(16).toString("hex");
|
const fresh = randomBytes(16).toString("hex");
|
||||||
room.players.push(playerId);
|
room.players.push(playerId);
|
||||||
room.tokens.set(playerId, fresh);
|
room.tokens.set(playerId, hashToken(fresh));
|
||||||
appendLine(room.id, { kind: "join", name: playerId, token: fresh });
|
appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) });
|
||||||
return { token: fresh };
|
return { token: fresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,9 +220,13 @@ interface PendingTransfer {
|
|||||||
const transfers = new Map<string, PendingTransfer>();
|
const transfers = new Map<string, PendingTransfer>();
|
||||||
const TRANSFER_TTL_MS = 10 * 60 * 1000;
|
const TRANSFER_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
export function makeTransferCode(room: Room, playerId: PlayerId): { code: string; expiresAt: number } | { error: string } {
|
export function makeTransferCode(
|
||||||
const token = room.tokens.get(playerId);
|
room: Room,
|
||||||
if (!token) return { error: "you hold no seat in this 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.
|
// Sweep expired codes while we are here.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [code, t] of transfers) {
|
for (const [code, t] of transfers) {
|
||||||
@@ -248,7 +266,7 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
|
|||||||
}
|
}
|
||||||
transfers.delete(normalized); // one-time
|
transfers.delete(normalized); // one-time
|
||||||
const room = rooms.get(t.roomId);
|
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 };
|
return { roomId: t.roomId, name: t.name, token: t.token };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,11 +277,13 @@ export function loadPersistedRooms(): void {
|
|||||||
for (const [id, lines] of readAllRooms()) {
|
for (const [id, lines] of readAllRooms()) {
|
||||||
try {
|
try {
|
||||||
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
|
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 = {
|
const room: Room = {
|
||||||
id,
|
id,
|
||||||
hostId: meta.hostId,
|
hostId: meta.hostId,
|
||||||
players: [meta.hostId],
|
players: [meta.hostId],
|
||||||
tokens: new Map([[meta.hostId, meta.hostToken]]),
|
tokens: new Map([[meta.hostId, hostHash]]),
|
||||||
seed: meta.seed,
|
seed: meta.seed,
|
||||||
expansion: false,
|
expansion: false,
|
||||||
state: null,
|
state: null,
|
||||||
@@ -272,8 +292,10 @@ export function loadPersistedRooms(): void {
|
|||||||
};
|
};
|
||||||
for (const line of lines.slice(1)) {
|
for (const line of lines.slice(1)) {
|
||||||
if (line.kind === "join") {
|
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.players.push(line.name);
|
||||||
room.tokens.set(line.name, line.token);
|
room.tokens.set(line.name, joinHash);
|
||||||
} else if (line.kind === "start") {
|
} else if (line.kind === "start") {
|
||||||
const r = startInMemory(room, line.expansion);
|
const r = startInMemory(room, line.expansion);
|
||||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ export interface RoomMetaLine {
|
|||||||
kind: "meta";
|
kind: "meta";
|
||||||
id: string;
|
id: string;
|
||||||
hostId: 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;
|
seed: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -18,7 +21,9 @@ export interface RoomMetaLine {
|
|||||||
export interface JoinLine {
|
export interface JoinLine {
|
||||||
kind: "join";
|
kind: "join";
|
||||||
name: string;
|
name: string;
|
||||||
token: string;
|
tokenHash?: string;
|
||||||
|
/** Legacy plaintext token (pre-hashing files only). */
|
||||||
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartLine {
|
export interface StartLine {
|
||||||
|
|||||||
Reference in New Issue
Block a user