DocsSecurity

Protecting the Server Seed

The server seed is the only secret in a single-player round, and this page covers what it is worth to an attacker, where it leaks from, and how to test that it hasn't.

leak.mjs
import { play } from '@galabet/fair';

// Suppose this string turned up in a log file while the seed was still live.
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';

// The player already sees these two in their own session.
const clientSeed = 'galabet';
const nextNonce = 43;

for (let nonce = nextNonce; nonce < nextNonce + 3; nonce++) {
  const dice = await play({ game: 'dice', serverSeed, clientSeed, nonce });
  const limbo = await play({ game: 'limbo', params: { houseEdge: 0.01 }, serverSeed, clientSeed, nonce });
  const mines = await play({ game: 'mines', params: { mines: 3 }, serverSeed, clientSeed, nonce });
  console.log(nonce, dice.result, limbo.result, mines.result);
}
Output
43 46.37 2.13 [ 7, 8, 15 ]
44 71.51 1.38 [ 1, 14, 19 ]
45 35.97 2.75 [ 3, 16, 23 ]

That is the whole attack. No cryptography is broken, because none needs to be. The client seed and the nonce are shown to the player by design, the arithmetic is published, and the server seed is the HMAC key. Whoever holds it can list every result the session will produce, for every game, at every nonce, until the seed is rotated. They choose Dice over or under accordingly, stop Limbo below the multiplier they already know, and step round three mines they can see.

For a player the rule is short. You should never be able to see the server seed for a bet you can still make. If it's in a response, in the page source or in your browser's storage before rotation, the results under it are predictable by anyone who looks, and that includes you.

The bets such an attacker places verify perfectly afterwards. Nothing was altered. The threat model counts this among the things GFS 1.0 does nothing about, which leaves it to the operator's handling of one 64-character string.

Where the Seed Comes From

createServerSeed in packages/fair/src/seeds.ts asks for 32 bytes and hex-encodes them. The bytes come from randomBytes in src/crypto.ts, which calls crypto.getRandomValues on the platform's Web Crypto object: globalThis.crypto where it exists, and node:crypto's webcrypto on a Node 18 that doesn't expose the global. There is no fallback to Math.random and no seeding from the clock. If neither source exists the call throws.

That gives 256 bits nobody can enumerate. An operator who replaces it with something home-made, such as a hash of the user id and a timestamp, or a counter run through a master key, has swapped an unguessable value for one that's only as strong as its weakest input. Deriving seeds from a master key is a defensible design, but it turns that key into a secret worth every seed it will ever produce, which is the position Crash is already in.

Places the Seed Must Not Appear

PlaceHow it usually gets there
API responses before rotationA handler returns the stored session object instead of a view of it
History endpointsRecords joined to their seeds without checking the seed has been rotated out
LogsA debug line that prints the session, or request logging that captures a service's return value
Error messages and error trackersAn exception that interpolates the session, or a tracker that attaches local variables to the stack
Analytics and APMEvent payloads or trace attributes built by spreading the session object
Client-side stateServer-rendered pages that serialise the whole session into hydration data
Support and admin toolsA "view session" screen that shows every column
Backups, replicas and exportsCopied wholesale, then given looser access than the primary

Every row has the same cause. The seed lives in the same object as the fields that are meant to be public, so any code path that handles the object handles the secret. DemoSession in Galabet's demo API has that layout: serverSeed sits beside commitment and clientSeed in one JSON value.

The Demo's Public View

apps/api/src/demo/demo.service.ts
/** Public view: everything except the server seed, plus the live nonce. */
private async publicView(s: DemoSession) {
  const { serverSeed: _hidden, ...rest } = s;
  return { ...rest, nonce: await this.store.peekNonce(s.id) };
}

create, view, setClientSeed and the next half of rotate all return through this function. The one place a seed is returned on purpose is rotate's revealed object, and by then the session has already been moved to a new seed under the same lock.

It's a denylist: it removes one named field and passes on everything else. That's compact, and it fails open. Add a previousServerSeed or a signingKey field to the session next year and publicView will publish it without complaint. An allowlist that names the public fields fails closed, and the test further down catches either kind of slip.

Notice also that load on the same service returns the full session, seed included, and is a public method. Mines calls it only to confirm the session exists. It would take one new controller returning load's value to undo the rest.

Leaks Found in the Demo API

This project's closest call wasn't a log line. It came from two features each working as designed, found during a hardening pass on the demo API, which is not deployed.

Mines derives its board when the round starts and has to hide it until the round ends. The early version pushed the complete record, board included, into the player's history at bet time, so GET on the history endpoint answered the question the game exists to ask. Separately, nothing stopped a player rotating the seed while a board was active. Rotation reveals the server seed, and the seed plus the public client seed and nonce is the board. The seed would have left through the front door, at the wrong moment.

Both are fixed. The record is published to history only when the round is over, any older record for a still-active board is redacted on the way out, and rotation and client seed changes are refused until the board is finished. The Mines page goes through the list.

Two things carry over to any backend. A value derived from the seed and not yet shown to the player is as sensitive as the seed for that round. And a reveal is a leak if its timing is wrong, so the reveal path needs the same scrutiny as the paths that are supposed to stay silent.

Testing That a Response Carries No Live Seed

Searching a response for 64 hex characters isn't enough, because the commitment is also 64 hex characters and is meant to be there. So is a seed that has been rotated out. What identifies the live seed is that it hashes to the live commitment, and a test can use that without being told the seed.

no-seed.mjs
import { commit, createClientSeed, createServerSeed, sha256Hex } from '@galabet/fair';

async function newSeed() {
  const serverSeed = await createServerSeed();
  return { serverSeed, commitment: (await commit(serverSeed)).commitment };
}

// Allowlist: a field reaches the client only if it is named here.
function publicView(session, nonce) {
  const { id, commitment, clientSeed } = session;
  return { id, commitment, clientSeed, nonce };
}

// True if any 64-hex string anywhere in `body` is the preimage of `liveCommitment`.
async function carriesLiveSeed(body, liveCommitment) {
  const text = typeof body === 'string' ? body : JSON.stringify(body);
  for (const candidate of text.match(/[0-9a-f]{64}/g) ?? []) {
    if ((await sha256Hex(candidate)) === liveCommitment) return true;
  }
  return false;
}

const old = await newSeed();
const session = { id: 'ana', clientSeed: await createClientSeed(), betsUnderSeed: 3, ...(await newSeed()) };
const live = session.commitment;

const view = publicView(session, 3);
console.log('fields sent:', Object.keys(view).join(', '));
console.log('public view:', await carriesLiveSeed(view, live));

console.log('whole session:', await carriesLiveSeed(session, live));

const error = new Error(`settlement failed for ${JSON.stringify(session)}`);
console.log('error message:', await carriesLiveSeed(error.message, live));

const history = [{ game: 'dice', commitment: old.commitment, serverSeed: old.serverSeed, nonce: 0, result: 12.5 }];
console.log('history with a rotated seed:', await carriesLiveSeed(history, live));
Output
fields sent: id, commitment, clientSeed, nonce
public view: false
whole session: true
error message: true
history with a rotated seed: false

The last line is false for the right reason. The old seed is in the response, it's 64 hex characters, and it's allowed, because it hashes to a commitment that is no longer live. A plain pattern match would have flagged that response, and the public view as well, since a commitment looks exactly like a seed.

Run a check like carriesLiveSeed against every route's response in your integration tests, with the live commitment taken from the session under test. It costs one SHA-256 per candidate string. The same function works on a captured log file or an error tracker's payload.

Storage, Backups and Access

The demo keeps each session as plaintext JSON in Redis, under demo:session:<id>, live seed included. For play chips that's a reasonable trade. With money on the table, some things change.

Anyone with read access to the store can play the insider. That's a wider group than it sounds: engineers with production access, a support tool with a broad query, a read replica feeding a reporting database, the BI export that takes every column. Encrypting the seed column with a key held only by the betting service narrows the group to that service and the people who can deploy to it. It doesn't remove it.

A backup taken while seeds are live contains live seeds, and it usually outlives them. Once those seeds have been rotated the backup is harmless on this count, since a revealed seed is public. Until then it deserves the access rules of the primary. Restoring one brings a second hazard, a rewound nonce counter, which Nonce Reuse and Replay covers.

Revealed seeds and live seeds want different handling, and the storage page keeps them under different keys for that reason.

Short seed lives limit what any leak is worth. A seed that was rotated an hour ago gives an attacker nothing, and rotation is also the response to a suspected leak: the old seed becomes public, which it was going to anyway, and the next bet is under a key nobody has seen. The demo lets the player rotate whenever they like. It has no rotation on the operator's initiative.

The Crash Chain Secret

A single-player seed covers one player's bets until rotation. A Crash chain secret covers every round of a shared game for the life of the chain, and in Galabet's multiplayer engine a chain is 10,000 games (CHAIN_LENGTH in apps/api/src/crash/crash.service.ts). It can't be rotated early without abandoning the published terminating hash.

The service keeps the secret in Redis under crash:secret:<id> and only the public parts in Postgres: the terminating hash, the length and the salt reference. Its one log line on chain creation prints the chain id, the terminating hash and the salt block, all public. It also caches the fully expanded chain as a Redis list, crash:chain:<id>, so that each round is one LINDEX. That cache is every future game hash in order. It's worth exactly what the secret is worth and sits in the same Redis, so the two need the same protection.

A partial leak is still serious, because the chain only runs one way.

chain-leak.mjs
import { createCrashChain, expandCrashChain, sha256Hex } from '@galabet/fair';

const chain = await createCrashChain(1000);
const all = await expandCrashChain(chain);
console.log('entries:', all.length);

// Rounds are played in order 1, 2, 3... Suppose round 400 is next and the hash for round 600 leaks.
let hash = all[600];
const recovered = new Map([[600, hash]]);
for (let index = 599; index >= 400; index--) {
  hash = await sha256Hex(hash);
  recovered.set(index, hash);
}

console.log('rounds recovered:', recovered.size);
console.log('round 400 correct:', recovered.get(400) === all[400]);
console.log('round 601 recoverable:', (await sha256Hex(all[600])) === all[601]);
Output
entries: 1001
rounds recovered: 201
round 400 correct: true
round 601 recoverable: false

One hash from the future gives away every round between now and then. Once the salt is public, each of those hashes is a crash point, and a player who knows the crash point cashes out one tick before it. Rounds beyond the leaked hash stay safe, since getting to them means inverting SHA-256.