Skip to content

Signed records & the hash chain

When a match ends, the server writes a record: the match header, every event, the server's revealed seed, and signatures. The record is built so that nobody, the server included, can change one byte of it without the change being detected. This page covers the structure, the hash chain, what gets signed and why the record does not need to keep every signature. The code is in src/core/chain.js and src/server/actor.js.

Record structure

js
{
  v: 1,
  header: {
    v: 1,
    matchId, createdAt, matchLength,
    rules: { crawford, jacoby, version: 'bg-rules-1', beavers, autoDoubleCap },
    players: [{ name, pub }, { name, pub }],     // Ed25519 public keys
    commitments: { server, p0, p1 },             // seed commitment + both chain tips
    serverKey,                                   // the server's Ed25519 public key
    dice: 'hmac-sha256/hashchain-reveal-v2',
    clock: { reserve, delay, mode },
    rated,
  },
  events: [ { n, a: { type, ... }, p, ... }, ... ],
  reveals: { server: seed },
  signatures: [ { n, by: 'server' | 'p0' | 'p1', sig }, ... ],
  chain: { h0, final, length },
  result: { score, matchWinner, games, reason },
}

Each event records the action (a), the acting seat (p, or null for server-driven steps) and whatever the state machine produced. For example, rolls carry the roll index r and dice d, plays carry normalised hops, and game-ending events carry the winner and points. The action types are newgame, open, roll, play, double, beaver, take, pass, resign, resign-accept, resign-decline and forfeit.

Records are stored gzipped as <matchId>.json.gz under RECORDS_DIR (plain JSON if RECORDS_GZIP=0). Anyone can download one from GET /records/<matchId>.json. A finished match is about 4 KB gzipped (measured, DEPLOY.md).

The hash chain

h0 = SHA-256( "hdr:" ‖ canonical(header) )
hn = SHA-256( "evt:" ‖ h(n-1) ‖ ":" ‖ canonical(event n) )

In code:

js
export const headerHash = (header) => sha256Hex('hdr:' + canonical(header));
export const eventHash = (prevHash, event) => sha256Hex(`evt:${prevHash}:` + canonical(event));

h(n-1) is the previous hash as a lowercase hex string. The separator : after it is part of the input. (The README and the comment at the top of chain.js leave out that colon. The code above is what runs.)

canonical() is deterministic JSON: object keys sorted, no whitespace, undefined properties dropped. The same object always produces the same bytes, in any language that implements those rules.

What this gives you:

  • h0 pins the commitments. The header contains the server's seed commitment and both players' chain tips, so every later hash depends on them.
  • Each hn commits to everything before it. Changing, inserting, reordering or deleting any event changes every hash from that point on, including the final one.
  • chain.final is the last hash. The verifier recomputes the whole chain from the header and events and compares.

The actor keeps only the latest hash in memory, not the whole list. The chain is a pure function of the header and events, so anyone can rebuild every intermediate hash.

What is signed, and by whom

All signatures are Ed25519 over the raw 32 bytes of a hash.

signersignswhen
each player's browserh0in its ack, before revealing any chain link
the serverevery event hash hnas it applies the event (MatchActor.step)
each player's browserevery event hash hnas each event arrives (sig message)

The players' live signatures are the attestation that matters while you are playing: your browser has seen and signed the exact chain the server is building. Signatures are checked as they arrive for form only: 128 hex characters, for an event index that exists. The verifier checks them cryptographically.

The server's signing key is persistent. It is loaded from server-key.json in KEY_DIR, or from the SERVER_KEY_PKCS8 environment variable. The public half is sent in every welcome message, served from /healthz and printed at boot, so anyone can pin it.

The server key must never change

If server-key.json is lost or regenerated, every record the server has signed becomes unverifiable against the key it now publishes. Put DATA_DIR on a persistent volume or supply SERVER_KEY_PKCS8 as a secret. If the key file exists but cannot be read, the server refuses to start rather than silently creating a new identity.

Why checkpoints are enough

Both browsers sign every hash, but the record does not keep every signature. A 9-point match runs to roughly 1000 events. Keeping three parties' 64-byte signatures for all of them was by far the largest thing a live actor held in memory. The actor comment records the measurement: 911 KB fell to 41 KB per live 9-point match after the change.

Instead the actor keeps (keepSignature / checkpointSignatures):

  • the latest signature from each party (server, p0, p1), and
  • a checkpoint of those latest signatures each time a new game starts and when the match finishes.

Signatures on h0 (index -1) are left out of the stored record.

This loses nothing, because each hash commits to its predecessor. A valid signature on h(k) is a signature on a value that could only be produced from exactly h0 ... h(k-1) and the events between them. So the latest signature from a party attests to everything up to that point. The per-game checkpoints add one thing: if a player stops signing partway through, for example because they disconnected and forfeited, the record still holds their signature at every earlier game boundary. The README sums it up: the same guarantee, about 1% of the bytes.

What the verifier requires

The verifier requires the server's signature on the final event hash, and at least one valid signature from each named player on some event. It does not require a player signature on the final hash. A player who disconnected before the end cannot provide one. See Verification.

Rules engine, fairness protocol, verifier, analysis and worker: MIT. Server and client: AGPL-3.0-or-later.