DocsCore concepts
Server Seeds and Commitments
What the server seed is, how its SHA-256 commitment is calculated and checked, and what the commitment cannot prove without the operator's help.
The server seed is the operator's secret: 32 random bytes written as 64 lowercase hex characters. The commitment is the SHA-256 hash of that seed, and it is the only thing about the seed a player sees while the seed is in use. When the seed is retired it gets published, the player hashes it, and the hash has to equal the commitment they were shown before betting. If it does, the seed was fixed before the bets were.
If you're a player, that paragraph is most of what you need, plus one habit. Copy the commitment somewhere of your own before you bet. A commitment you only ever saw on the casino's page, after the fact, is the casino's word.
| Server seed | Commitment | |
|---|---|---|
| Length | 64 hex characters | 64 hex characters |
| Made by | createServerSeed() | commit(serverSeed) |
| Public | Only after rotation | From before the first bet |
| Public example | 5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d | ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7 |
They look alike. Records keep them in separate fields, serverSeed and commitment, and a support agent pasting one where the other belongs is a mistake worth designing a form against.
Creating a Seed
import { assertServerSeed, commit, createServerSeed } from '@galabet/fair';
const serverSeed = await createServerSeed();
const { commitment, publishedAt } = await commit(serverSeed);
console.log(serverSeed.length, /^[0-9a-f]{64}$/.test(serverSeed));
console.log(commitment.length, commitment !== serverSeed);
console.log(typeof publishedAt);
assertServerSeed('0'.repeat(64));
console.log('sixty-four zeros pass the format check');
64 true
64 true
number
sixty-four zeros pass the format check
createServerSeed takes its bytes from Web Crypto's getRandomValues, the platform's cryptographic generator. Use it, or something of the same grade. The last two lines of the example are there as a warning: assertServerSeed checks the format of a seed and nothing about where it came from. A seed built from a timestamp, a counter or a hashed username passes, and a player who can guess how it was built can hash candidates until one equals the commitment. At that point they hold the seed while it is live and can work out every result before betting.
The Hash Covers the Hex String
The 64 characters go into SHA-256 as 64 bytes of text. They are not decoded to 32 bytes first. The HMAC that produces game results is keyed the same way, so the rule is the same in both places: the seed is a string and stays one.
import { createHash } from 'node:crypto';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
console.log(createHash('sha256').update(serverSeed).digest('hex'));
console.log(createHash('sha256').update(Buffer.from(serverSeed, 'hex')).digest('hex'));
ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7
540b43c46f9cfffa62176b3a9e4dbb7d8f7e5452468acafdddf8d45b6a462be3
The first line is the commitment. The second is what a port gets when it treats the seed as binary, and no record will ever verify against it. The Dice page shows the same mistake made with the HMAC key, where it turns a roll of 56.12 into 52.21.
Lowercase Only
Hashing text means case is part of the input. 5C1F… and 5c1f… decode to the same 32 bytes, but they are different strings with different hashes, so GFS allows one spelling and the library refuses the other.
import { createHash } from 'node:crypto';
import { commit } from '@galabet/fair';
const upper = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d'.toUpperCase();
try {
await commit(upper);
} catch (error) {
console.log(error.message);
}
console.log(createHash('sha256').update(upper).digest('hex'));
server seed must be 64 lowercase hex characters
588e2fbd14b9ac1502ca38035ee1d246f236fc1f77bc9815cec6cd14228ae81e
That second line is the commitment the uppercase string would have had if the library accepted it. It shares nothing with ab3772…. Throwing is the safer behaviour here: quietly lowercasing the seed inside commit would hide the problem from an operator whose database or admin tool is changing the case of stored seeds, and that operator's HMAC results would be wrong too.
If a seed reaches you through a form or a spreadsheet, lowercase it yourself before calling anything. The same error, server seed must be 64 lowercase hex characters, covers a wrong length, a stray space and a 0x prefix.
Verifying a Commitment
import { verifyCommitment } from '@galabet/fair';
const commitment = 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7';
const revealed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const swapped = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2e';
console.log(await verifyCommitment(revealed, commitment));
console.log(await verifyCommitment(swapped, commitment));
true
false
swapped differs from the real seed in its final character, e for d. That is a well-formed seed, so nothing throws. The function answers false, and a false here means the operator revealed a seed other than the one it committed to. There is no innocent version of that result short of a copy-and-paste slip, which is why the first thing to do with a mismatch is check that you compared the right pair.
verifyCommitment lowercases the commitment before comparing and does not lowercase the seed. A commitment of the wrong length returns false. A malformed seed throws. The API reference runs each of those cases.
Players don't need any of this code. The verifier makes the same comparison in the browser and reports it as a check of its own, named Commitment and marked Matches or Mismatch. verifyRecord makes it before it recalculates anything, and puts server seed does not match commitment in reasons when it fails.
publishedAt Is the Local Clock
commit returns { commitment, publishedAt }, and the second field invites more trust than it has earned. It is Date.now() on the machine that ran the function, read at the moment of the call. Nothing was published. No player saw anything. The name describes what the operator is expected to do next, which is to store that time beside the commitment and show both wherever the player sees the commitment.
Even stored and displayed, it's the operator's own statement about the operator's own clock. What gives a commitment its force is evidence the operator doesn't control: the player's saved copy, a screenshot, a page archived by a third party. GFS 1.0 has no mechanism for timestamping a commitment independently, and a verified record says nothing about when its commitment first appeared. What verification proves goes through that limit.
Galabet's demo API is a fair example of how this slips. It calls commit, keeps commitment and drops publishedAt. The session stores a createdAt of its own, which covers the first seed, but the commitments created later by rotation carry no time at all. For a demo whose credits are worth nothing that's tolerable. An operator handling money should persist the time for every commitment, including the ones made at rotation.
One Seed, Many Bets
A server seed isn't used once. It serves every bet a player makes until rotation, and the nonce is what keeps those bets apart. The demo creates one seed per session and replaces it when the player rotates, when the player changes their client seed, or never, if the session expires first.
How long a seed should live is a trade. While it's live the player can verify nothing, because the seed is hidden, and the operator can compute upcoming results, because it holds every input. Shorter lives shrink both problems. They can't remove the second one. That needs an input neither side controls, which is what the planned GFS 1.1 beacon is for and what 1.0 does not have.
Keeping the seed secret while it's live is an operations problem: where it's stored, who can read it, what gets logged. None of that is in the library. Protecting the server seed is the page for it.
