DocsRecords
Signing Records
Ed25519 signatures over records, the key layout, and what to do about a bet-time signature that stops verifying once the server seed is revealed.
A commitment stops a casino from changing its seed. It does nothing to stop a casino from denying that a record is theirs. "We never issued that" is a complete answer to an unsigned JSON object, and a signature takes that answer away: the record carries an Ed25519 signature, the operator's public key is published somewhere, and anyone can check one against the other.
Signing is optional in GFS 1.0. Players never sign anything. If a record you were given has signature and signer fields, the verifier checks them along with everything else, and the last section of this page says what a pass is worth.
Keys
import { readFile } from 'node:fs/promises';
import { generateKeyPair } from '@galabet/fair';
const fresh = await generateKeyPair();
console.log(fresh.publicKey.length, fresh.secretKey.length);
console.log(fresh.secretKey.slice(64) === fresh.publicKey);
const vectors = JSON.parse(await readFile('vectors/gfs-1.0-sign.json', 'utf8'));
console.log(vectors.testSecretKey.slice(0, 64));
console.log(vectors.testSecretKey.slice(64));
console.log(vectors.publicKey);
64 128
true
9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
The public key is 32 bytes, 64 hex characters. The secret key is 64 bytes: the 32-byte Ed25519 seed, then the same 32-byte public key again. That's the layout libsodium uses, and the reason for carrying the public half around is practical. Web Crypto imports a private key as a JWK that wants both halves, and with this layout neither has to be derived from the other.
The second key in that output is fixed. It lives in vectors/gfs-1.0-sign.json for tests, and the rest of this page uses it so that the signatures printed here are the ones you'll get. Its seed, 9d61b19d…, is the first test key in RFC 8032, which means any Ed25519 library can be checked against the same bytes. It is public. Never sign anything real with it.
Signing goes through Web Crypto's Ed25519, and sign.ts lists where that exists: Node 18.4+, Chrome 113+, Firefox 130+, Safari 17+, Bun, Deno and Workers. On anything older, Web Crypto has no Ed25519 and the calls fail. inspectRecord catches that and marks the Signature check unsupported instead of failing the record.
Signing at Bet Time
import { readFile } from 'node:fs/promises';
import { signRecord, signingPayload, verifyRecordSignature } from '@galabet/fair';
const { testSecretKey } = JSON.parse(await readFile('vectors/gfs-1.0-sign.json', 'utf8'));
const bet = {
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
};
const signed = await signRecord(bet, testSecretKey);
console.log(signingPayload(signed));
console.log(signed.signature);
console.log(signed.signer);
console.log(await verifyRecordSignature(signed));
console.log(await verifyRecordSignature({ ...signed, result: 99.99 }));
console.log(await verifyRecordSignature(bet));
{"at":1790000000000,"clientSeed":"galabet","commitment":"ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7","cursor":0,"game":"dice","nonce":42,"params":{},"profile":"single-player","result":56.12,"spec":"GFS/1.0"}
60d6d82da0407fddce221e11b4959468b14b48d939ce80bcf36da70e5f48acec44d3cb7b61fa4de90a8fd5e7e7e39e3b3b5bde3e1744d96d31a9a9b67a81f702
d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
true
false
false
signRecord returns a copy with two fields added. signer is the public half of the key you passed. signature is 128 hex characters over the first line of that output, the signing payload: the record as canonical JSON with signature and signer left out. It's signed as UTF-8 text. It is not hashed first, because Ed25519 does its own hashing.
Ed25519 is deterministic. Same key, same payload, same 64 bytes, every time and in every correct implementation. So the hex above isn't a sample. Run the example and you'll get that string, and a port in another language should too. The vectors file holds two signed records for checking one, a Dice record and a Mines record. It's listed with the other files on Test Vectors.
verifyRecordSignature answers true or false and doesn't throw. Changing the result gives false. So does an unsigned record, the last line, which is worth remembering before writing if (!await verifyRecordSignature(r)) reject() in a system where signing is optional.
The Reveal Problem
A record is signed when the bet is placed. Later the seed rotates, serverSeed is added to the record, and the player can finally verify the roll. The signature now fails.
import { readFile } from 'node:fs/promises';
import { inspectRecord, recordHash, signRecord, verifyRecord, verifyRecordSignature } from '@galabet/fair';
const { testSecretKey } = JSON.parse(await readFile('vectors/gfs-1.0-sign.json', 'utf8'));
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const signed = await signRecord({
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
}, testSecretKey);
const revealed = { ...signed, serverSeed };
console.log(await verifyRecordSignature(signed), await verifyRecordSignature(revealed));
console.log((await recordHash(signed)).slice(0, 16), (await recordHash(revealed)).slice(0, 16));
const outcome = await verifyRecord(revealed);
console.log(outcome.computed, outcome.ok, outcome.reasons);
console.log((await inspectRecord(revealed)).status);
true false
9b0d37656b565a66 4bea13903c9ef849
56.12 false [ 'signature does not verify under signer' ]
mismatch
Nothing was forged. The signing payload is the whole record minus the two signature fields, serverSeed is a field, and at bet time it wasn't there. Add it and the payload is a different string with a different hash, so a signature over the old string can't match. Every tool in 0.1.0 checks the signature over the record exactly as it's handed over. verifyRecord reports a genuine 56.12 as not ok, and the verifier page would show this record to a player as a mismatch.
This is how 0.1.0 behaves, and the library doesn't pick a fix for you. There are two that work.
Verify the Unrevealed Form
Take serverSeed back out before checking the signature. Check the seeds separately.
import { readFile } from 'node:fs/promises';
import { signRecord, verifyRecord, verifyRecordSignature } from '@galabet/fair';
async function verifyRevealed(record) {
const { serverSeed, ...atBetTime } = record;
const { signature, signer, ...unsigned } = record;
const seeds = await verifyRecord(unsigned);
return { seedsOk: seeds.ok, signatureOk: await verifyRecordSignature(atBetTime), reasons: seeds.reasons };
}
const { testSecretKey } = JSON.parse(await readFile('vectors/gfs-1.0-sign.json', 'utf8'));
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const signed = await signRecord({
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
}, testSecretKey);
console.log(await verifyRevealed({ ...signed, serverSeed }));
console.log(await verifyRevealed({ ...signed, serverSeed, result: 12.34 }));
console.log(await verifyRevealed({ ...signed, serverSeed: 'f'.repeat(64) }));
{ seedsOk: true, signatureOk: true, reasons: [] }
{
seedsOk: false,
signatureOk: false,
reasons: [ 'result does not match seeds' ]
}
{
seedsOk: false,
signatureOk: true,
reasons: [
'server seed does not match commitment',
'result does not match seeds'
]
}
The second call fails both ways, because result is inside the signed payload and 12.34 is not what the operator signed.
Does leaving the seed outside the signature weaken anything? No. The signature covers commitment, and the commitment is the SHA-256 of the seed, so a seed that passes the commitment check is the one the operator signed for, at one remove. The third call shows it: a different seed fails on the commitment although the signature is intact.
The operator's key isn't needed at rotation, and there's one signature per bet. The cost is that your verifier has to know the rule, and the stock tools don't. A player who pastes the revealed, signed record into a 0.1.0 verifier gets a mismatch. If you go this way, give players the revealed record without its signature fields for seed checking, and the signed bet-time record as the receipt.
Sign Again at Reveal
Or produce a second signature over the revealed record.
import { readFile } from 'node:fs/promises';
import { signRecord, verifyRecord } from '@galabet/fair';
const { testSecretKey } = JSON.parse(await readFile('vectors/gfs-1.0-sign.json', 'utf8'));
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const atBetTime = await signRecord({
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
}, testSecretKey);
const atReveal = await signRecord({ ...atBetTime, serverSeed }, testSecretKey);
console.log(atReveal.signature === atBetTime.signature);
const outcome = await verifyRecord(atReveal);
console.log(outcome.ok, outcome.signatureOk);
false
true true
signRecord ignores any signature already on the record, so passing the signed bet-time record with the seed added is fine. The result verifies everywhere with no special handling.
You pay for that at rotation. Every record under the rotated seed needs a signing operation, which means the key has to be reachable by whatever job does the reveal, and a key that's reachable by more jobs is a key with more ways to leak. Keep the bet-time signed record as well. The reveal-time signature was made after the outcome was known to everyone, so on its own it can't show that the operator stood behind 56.12 when the bet was placed. The first signature is the one the player was holding while the seed was still secret.
Sign and verifySignature
Both record functions sit on two general ones. sign(message, secretKey) takes a string, which it encodes as UTF-8, or a Uint8Array. verifySignature(message, signature, publicKey) takes the same.
import { readFile } from 'node:fs/promises';
import { sign, verifySignature } from '@galabet/fair';
const { testSecretKey, publicKey } = JSON.parse(await readFile('vectors/gfs-1.0-sign.json', 'utf8'));
const signature = await sign('', testSecretKey);
console.log(signature);
console.log(await verifySignature('', signature, publicKey));
console.log(await verifySignature(new Uint8Array(0), signature, publicKey));
console.log(await verifySignature('', signature, publicKey.toUpperCase()));
try {
await sign('', testSecretKey.slice(0, 64));
} catch (error) {
console.log(error.message);
}
e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b
true
true
false
secret key must be 128 hex chars (seed || public key)
That first line is the signature RFC 8032 lists for its first test, an empty message under this key.
The two functions fail differently. verifySignature returns false for anything malformed, an uppercase key included, since a verifier is fed hostile input and "not valid" is the right answer to all of it. sign throws when the secret key isn't 128 lowercase hex characters. Passing the bare 32-byte seed, as the example does, is the likely way to meet that message if your key came from another tool.
What a Signature Does Not Establish
verifyRecordSignature checks the signature against the signer field, and the signer field is part of the record. Whoever wrote the record chose it.
import { generateKeyPair, signRecord, verifyRecordSignature } from '@galabet/fair';
const stranger = await generateKeyPair();
const forged = await signRecord({
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment: '0'.repeat(64), clientSeed: 'galabet', nonce: 42, cursor: 0, result: 100, at: 0,
}, stranger.secretKey);
console.log(await verifyRecordSignature(forged));
true
true, for a record invented on the spot with a key made a line earlier. The function has told you the record is signed by the holder of signer. It hasn't told you who that is, and it can't.
So the comparison that matters happens outside the library: is signer the key this operator publishes? Publish it where players will look and where a forger can't write, which rules out the record itself and any page the record links to. The operator's own site over HTTPS is the obvious place. When a key is replaced, keep the old public key listed with the dates it was in use, or every record signed under it becomes unverifiable to a newcomer.
A signature also says nothing about time. at is a number the operator wrote, and signing it proves the operator wrote it. What gives a bet-time signature its value is that the player received it at bet time. That is a fact about delivery and not about cryptography, and a player who never saved their signed record has nothing to show.
