Skip to main content

From HMAC-SHA256 to Verifiable Results: A JavaScript Walkthrough

Recompute a seed commitment, decode four digest bytes, and check an open-source verifier against an independent implementation.

Updated 8 min readMarkdown

A result can look random while still being reproducible. That is the useful idea behind a provably fair protocol: commit to a secret input, combine it with public inputs through a documented algorithm, and reveal the secret later so someone else can repeat the calculation.

This tutorial follows the actual provably-fair-verifier implementation. Its Dice and Limbo paths use HMAC-SHA256 with the server seed as the key. We will reproduce its example vector using Node's crypto module, then compare it with the repository's Web Crypto implementation. These are demonstration seeds, not records of real play. No account, deposit, or wager is needed.

Start with a commitment

Before a round, the server publishes SHA256(serverSeed). After the seed is retired, it reveals the seed itself. Hashing that revealed string must reproduce the commitment saved earlier.

The timing matters. A hash obtained only after the result gives no evidence that the server committed beforehand. Save the original commitment alongside the client seed, nonce, recorded result, and algorithm version. Record the selected game and formula source too, since the same seed data can be interpreted through several incompatible result mappings.

A hash is not encryption, and verification does not decrypt it. Under the usual SHA-256 security assumptions, finding a different seed that satisfies the saved commitment is computationally infeasible. Concealing the original seed also depends on its entropy: a predictable seed can be guessed and checked against its hash.

Commitment restricts later substitution; it does not establish that the seed was selected without bias. Choosing an unpredictable client seed after the server commitment helps constrain advance selection against known client inputs. The order of events remains part of the protocol being audited.

Specify the exact HMAC message

The shorthand HMAC-SHA256(server seed, client seed:nonce) describes a family of schemes, not a universal wire format. In this repository, Dice and Limbo use the UTF-8 message clientSeed:nonce:0. The final zero identifies the first HMAC output block in the documented byte generator. Omitting it produces a different digest. See the operator's implementation documentation for that serialization.

The server seed is the HMAC key; the client seed and nonce are message components. HMAC is the keyed construction described in RFC 2104, with separate inner and outer hashing stages. Concatenating a secret and a message and hashing once does not implement HMAC.

For SHA-256, HMAC works with a 64-byte key block. Longer keys are hashed first; shorter ones are padded with zeros. The inner stage hashes the key block XORed with a repeated inner pad, followed by the message. The outer stage hashes a differently padded key block followed by that inner digest. A standard crypto library handles these details, so application code only needs to supply the correct key bytes and message bytes.

For a fixed key and message, HMAC is deterministic. With an unpredictable secret key, its output is intended to behave like pseudorandom bytes. Incrementing a nonce changes the message; it does not make the algorithm nondeterministic or promise that outcomes cannot repeat.

Encoding is equally important. Although the example server seed looks hexadecimal, this Dice/Limbo implementation hashes and keys with its literal UTF-8 characters. Decoding it into binary first changes the calculation. Preserve client seed spelling, case, spaces, and punctuation. Do not append a newline.

Recompute the bytes independently

With Node.js 18 or later, run this complete terminal command. It uses only built-in modules and needs no package installation:

node --input-type=module <<'JS'
import { createHash, createHmac } from 'node:crypto';

const server = 'e3f1b5c2a9d84f6e7b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6';
const client = 'example-client-seed';
const nonce = 3;
const message = `${client}:${nonce}:0`;
const bytes = createHmac('sha256', server).update(message, 'utf8').digest();
const value = bytes.readUInt32BE(0) / 2 ** 32;
const dice = Math.floor(value * 10001) / 100;
const limbo = Math.max(Math.floor(1e8 / (value * 1e8) * 0.99 * 100) / 100, 1);

console.log('commitment=' + createHash('sha256').update(server, 'utf8').digest('hex'));
console.log('message=' + message);
console.log('digest=' + bytes.toString('hex'));
console.log('first4=' + [...bytes.subarray(0, 4)].join(','));
console.log('value=' + value);
console.log('dice=' + dice.toFixed(2));
console.log('limbo=' + limbo.toFixed(2));
JS

Actual output, checked with Node.js 20.19.2:

commitment=1c7bc4411405303e0f84ab57dea9691c6376ee6891b289a65edac408519e6009
message=example-client-seed:3:0
digest=6d1e1f338f1fdebd53787f1a0149cf4db00e3ee20a292c692ea92ef45364ce5b
first4=109,30,31,51
value=0.42624087329022586
dice=42.62
limbo=2.32

Turn four bytes into a result

SHA-256 outputs 32 bytes, but this calculation consumes only the first four. If those bytes are b0, b1, b2, and b3, their big-endian integer is N = b0 × 256³ + b1 × 256² + b2 × 256 + b3.

Dividing by 2³² gives the same value as the repository's sum b0/256 + b1/256² + b2/256³ + b3/256⁴. It lies in [0, 1): zero is possible; one is excluded. The denominator is 4,294,967,296, not the largest unsigned 32-bit integer.

Here, the prefix 6d1e1f33 represents the integer 1,830,690,611. Dividing that integer by the denominator reproduces the printed value. This also explains why byte order matters: treating the same four bytes as a little-endian integer changes their positional weights. Keep hexadecimal digest text separate from the raw bytes it represents; the first four bytes occupy eight hexadecimal characters.

Dice maps this value using floor(value × 10001) / 100. That produces hundredth-sized outcomes from 0.00 through 100.00. Integer division does not partition all 2³² possible inputs into 10,001 exactly equal groups, so “perfectly uniform” would overstate the discrete mapping.

Limbo instead scales an inverse: approximately 0.99/value, rounded down to two decimal places and bounded below by 1. The snippet preserves the repository's operation order. Its 0.99 coefficient encodes the documented 1% edge; a cryptographic commitment does not remove it. The game-event documentation describes the conversions.

There is also an implementation edge case: if all four bytes are zero, the current Limbo calculation returns JavaScript Infinity. The repository does not supply a finite maximum for that case. That is a limitation to review, rather than silently replace with an invented cap.

Check the repository implementation

Download the source from GitHub or GitLab, and open a terminal in the repository root. It is an MIT-licensed JavaScript module with no external dependencies. The following command imports the real exported function and explicitly supplies Node's Web Crypto object:

node --input-type=module <<'JS'
import { webcrypto } from 'node:crypto';
import { fairResult } from './src/index.js';

const input = {
  game: 'dice',
  server: 'e3f1b5c2a9d84f6e7b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6',
  hash: '1c7bc4411405303e0f84ab57dea9691c6376ee6891b289a65edac408519e6009',
  client: 'example-client-seed',
  nonce: '3',
  salt: ''
};
const dice = await fairResult(input, webcrypto);
const limbo = await fairResult({ ...input, game: 'limbo' }, webcrypto);
const next = await fairResult({ ...input, nonce: '4' }, webcrypto);
const badHash = await fairResult({ ...input, hash: '0'.repeat(64) }, webcrypto);

console.log(`dice: commitment=${dice.matches}; result=${dice.result}`);
console.log(`limbo: commitment=${limbo.matches}; result=${limbo.result}`);
console.log('saved roll matches=' + (dice.result === 42.62));
console.log(`nonce 4: commitment=${next.matches}; result=${next.result}`);
console.log(`wrong hash: commitment=${badHash.matches}; result=${badHash.result}`);
JS

Actual output:

dice: commitment=true; result=42.62
limbo: commitment=true; result=2.32
saved roll matches=true
nonce 4: commitment=true; result=68.78
wrong hash: commitment=false; result=42.62

The changed nonce illustrates a crucial API distinction: matches checks only the server seed commitment. It does not compare an observed result. A wrong nonce can therefore produce matches: true while recomputing the wrong round. Conversely, a wrong commitment does not prevent the function from returning a number. Treat a failed commitment as a failed verification.

For your own records, supply the revealed seed, previously saved hash, exact client seed, and recorded nonce. Check the commitment, then compare the calculated outcome with the recorded outcome under the correct formula. The module validates nonnegative safe-integer nonces and normalizes their numeric representation, so '003' becomes 3 in its message.

When a result differs, compare intermediate values before changing anything. A commitment mismatch points to the saved hash, revealed seed, or encoding. A matching commitment but different digest points to the HMAC key, message serialization, or nonce. An identical digest but different outcome points to byte selection, byte order, rounding, or the game conversion. Keep the original inputs intact while investigating; silently trimming or repairing them can hide the actual discrepancy.

You can cross-check those inputs in CryptoPlay Guide's browser verifier; the step-by-step verification guide provides another worked example. Both are resources from the project publisher.

Keep the conclusion narrow

The repository also implements Crash, but through a separate path: it hashes decoded game-hash bytes for the commitment, uses the public salt as the HMAC key, and derives a value from the first 52 digest bits. Do not apply the Dice message format to it or assume every operator uses identical Crash rules.

A successful check establishes consistency between supplied inputs and a specified computation, provided the commitment was recorded at the right time. It does not prove unbiased seed generation, complete round history, payout reliability, or positive expected returns. The practical skill is preserving evidence and reproducing a calculation precisely enough to know what has actually been checked.

No comments yet. Be the first to comment.