DocsCore concepts

Client Seeds

The player's half of the randomness, the two rules a client seed must follow, and what Galabet's demo does when a player changes theirs.

A server that chose its seed alone could try seeds until it found one with a run of results it liked, commit to that one, and the commitment would verify perfectly. The client seed closes that door. It's a piece of text the player controls, it goes into every HMAC message next to the nonce, and the server has to commit before it knows what the text will be. To arrange a sequence in advance, the server would have to predict what the player is going to type.

That protection is only as real as the player's control. A site that assigns a client seed and offers no way to change it has a server seed and a second server seed.

What the client seed is not: a secret. It appears in every record in plain text. It doesn't need to be random or long, and galabet does the job as well as 32 hex characters, provided the player picked it after seeing the commitment.

Default and Rules

createClientSeed() returns 16 random bytes as 32 hex characters, for players who never type one. The rules for a seed the player does type are enforced by assertClientSeed, and there are two. It is a string of 1 to 64 UTF-16 code units. It contains no colon.

rules.mjs
import { assertClientSeed, createClientSeed, play } from '@galabet/fair';

console.log(/^[0-9a-f]{32}$/.test(await createClientSeed()));

const clover = '\u{1F340}'; // one four-leaf clover emoji
console.log(clover.length, clover.repeat(32).length, clover.repeat(33).length);

for (const seed of ['', 'x'.repeat(65), clover.repeat(33), 'lucky:seven']) {
  try {
    assertClientSeed(seed);
  } catch (error) {
    console.log(error.message);
  }
}

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
for (const clientSeed of ['galabet', 'galabet ', 'GALABET']) {
  const { result } = await play({ game: 'dice', serverSeed, clientSeed, nonce: 42 });
  console.log(JSON.stringify(clientSeed), result);
}
Output
true
2 64 66
client seed must be 1 to 64 characters
client seed must be 1 to 64 characters
client seed must be 1 to 64 characters
client seed must not contain ":" (reserved as the HMAC message separator)
"galabet" 56.12
"galabet " 47.86
"GALABET" 57.42

The second output line is the emoji case. One clover is a single character on screen and 2 in JavaScript's length, so 32 of them fill the limit and 33 are refused with a message that says "characters". A port has to count UTF-16 code units as well. Count code points or bytes and it will accept seeds the reference rejects, or reject seeds the reference accepts. What reaches the HMAC is the seed's UTF-8 encoding, so the limit is counted in one encoding and the hashing done in another. That is how 0.1.0 behaves, and a port should copy it.

The last three lines show that nothing is normalised. A trailing space is a different seed. So is a change of case. The library doesn't trim, fold case or apply Unicode normalisation, which means a front end that trims input must send the trimmed value to the server and display the trimmed value to the player, or the record won't match what the player believes they typed.

The Reserved Colon

The HMAC message is clientSeed:nonce:cursor. With the colon banned from the seed, that string always splits into exactly three fields, in any language, with no escaping rules to port. That's the whole reason. Since the nonce and cursor are plain digits, a message with extra colons could still be read from the right-hand end, so the ban is about keeping ports and log readers free of special cases, not about a known collision.

Changing the Client Seed in the Demo

A client seed change in Galabet's demo API does three things: the current server seed is retired, a fresh one is created and its commitment returned, and the nonce goes back to 0. If any bets were placed under the old server seed, it's revealed and kept so those bets can be verified. Sending the seed the session already has changes nothing, and the request is refused while a Mines board is in play, because revealing the server seed would reveal the board.

The nonce resets because a nonce counts bets under one pair of seeds, and a new client seed makes a new pair. The fresh server seed is the part people leave out. Here is what happens without it.

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

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const roll = async (clientSeed, nonce) => (await play({ game: 'dice', serverSeed, clientSeed, nonce })).result;

console.log('galabet     ', await roll('galabet', 0), await roll('galabet', 1));
console.log('my-new-seed ', await roll('my-new-seed', 0));
console.log('galabet     ', await roll('galabet', 0), await roll('galabet', 1));
Output
galabet      53.14 50.77
my-new-seed  21.09
galabet      53.14 50.77

The player bets twice, switches seed, bets once, switches back. With the server seed unchanged and the nonce restarted, the third line repeats the first, and this time the player knows both rolls before staking anything. Returning to an earlier client seed must not recreate results that have already been seen, and a new server seed on every change is what guarantees it: the pair is new even when the client half is old. The security pages treat this as one of the ways a nonce gets reused.

An operator could avoid the replay differently, by never resetting the nonce while a server seed lives. That works too. The demo's way has the side benefit that a client seed change doubles as a rotation. The response to the change carries only the new commitment, but the old seed is stored under its commitment and comes back attached to those bets in the session's history, so everything played so far can be checked.