Provably-fair · Криптографическая честность

Почему мы не можем подкрутить кости

NeuroGammon реализует схему commit-reveal на HMAC-SHA256 — ту же, что используют честные крипто-казино. Сервер математически связан обещанием до первого броска, поэтому не может реагировать на позицию игрока. Эту страницу стоит прочитать один раз — и любые подозрения «кости подкручивают» снимаются проверкой в браузере.

В двух словах

Перед партией сервер запечатывает в конверт случайное число S и отдаёт игроку хэш этого конверта. Игрок добавляет своё число C. Все 30+ бросков партии однозначно вычисляются из пары (S, C) функцией HMAC-SHA256. После окончания партии сервер раскрывает S — игрок проверяет, что хэш совпадает, и пересчитывает каждый бросок. Если хэш совпал, сервер не мог менять S в процессе, значит, броски были предопределены ещё до 1-го хода — ни AI, ни сервер не могли знать позицию вперёд.

01Аналогия с конвертом

Представь, что перед игрой судья кладёт в конверт лист с 60 числами от 1 до 6. Заклеивает его, ставит свою печать и показывает тебе фото печати. Конверт лежит на столе. Каждый раз, когда нужен бросок, судья достаёт следующее число из конверта. После партии конверт раскрывают — ты видишь все 60 чисел и проверяешь, что печать была твоя.

Если печать совпадает — судья не мог подменить конверт посреди партии. Значит, числа были выбраны до того, как кто-то начал двигать шашки.

В цифровом мире «конверт» — это случайный server_seed (32 байта), «печать» — это SHA-256 хэш от него (математическая функция, для которой невозможно найти другой вход, дающий тот же выход). Хэш ты видишь до 1-го броска. Сам seed — только после конца партии.

02Что происходит технически

  1. Старт партии. Сервер генерит случайные 32 байта — server_seed — через системный криптографический источник (secrets.token_bytes, тот же, что используют SSL-библиотеки). Считает SHA-256(server_seed) и отдаёт тебе только хэш. Сам seed остаётся в БД, скрытый.
  2. Твоя соль. Браузер генерит client_seed — 16 случайных байт через crypto.getRandomValues. Этот seed знаешь только ты, но он отправляется серверу вместе со стартом партии. Без твоего seed детерминированный поток восстановить невозможно.
  3. Каждый бросок. Алгоритм один и тот же:
    msg = utf8(client_seed + ":" + counter) digest = HMAC-SHA256(server_seed, msg) d1, d2 = (byte % 6) + 1 для первых двух байт digest со значением < 252 counter += 1 HMAC — «доказуемо честная» функция: даже зная digest, нельзя восстановить ни ключ, ни сообщение. И наоборот — изменив seed на 1 бит, получишь совершенно другие броски для всех counter.
  4. Окончание партии. Сервер раскрывает server_seed в открытом виде. Кнопка «✓ Проверить» в твоём браузере делает три вещи:
    • Считает SHA-256(server_seed) и сравнивает с хэшем, который ты видел в начале — должны совпасть до символа.
    • Запускает алгоритм броска для counter = 0, 1, 2 … и выводит все полученные пары костей. Они должны совпадать с тем, что выпадало в партии.
    • Если хоть один бит разошёлся — это либо баг (маловероятно), либо доказанный фрод. В любом случае ты узнаешь.

03Почему это математически невозможно подделать

Допустим, сервер хочет «подкрутить» бросок №14, чтобы AI получил нужный дубль. Для этого ему пришлось бы найти другой server_seed S′ такой, что:

Найти такое S′ — это нарушить SHA-256 на коллизии, что считается нерешённой задачей с 2002 года. У злоумышленника с гипер-вычислительными ресурсами на это уйдёт ~ 2¹²⁸ операций — больше, чем атомов в наблюдаемой Вселенной. На рабочем сервере за время одной партии — невозможно.

04Как проверить вручную

Если веришь больше своему коду, чем нашей кнопке «Проверить» — открой DevTools console (F12) на странице любой партии и скопируй:

// Получаем seeds от сервера (партия должна быть закончена) const r = await fetch(`/api/game/${gameId}/fairness`).then(r => r.json()) const serverSeed = Uint8Array.from(r.server_seed.match(/../g).map(b => parseInt(b, 16))) // 1. Проверяем, что хэш совпадает const hashBuf = await crypto.subtle.digest('SHA-256', serverSeed) const hashHex = Array.from(new Uint8Array(hashBuf), b => b.toString(16).padStart(2, '0')).join('') console.log('Hash match:', hashHex === r.server_seed_hash) // 2. Считаем все броски const key = await crypto.subtle.importKey('raw', serverSeed, {name:'HMAC',hash:'SHA-256'}, false, ['sign']) for (let i = 0; i < r.roll_counter; i++) { const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${r.client_seed}:${i}`))) const dice = [] for (const b of sig) if (b < 252) { dice.push((b % 6) + 1); if (dice.length === 2) break } console.log(i, dice) }

Это тот же алгоритм, что использует наш сервер в longgammon/game.py. Если хэш совпал и броски совпадают с теми, что ты видел в партии — всё честно.

05Внешние ресурсы

На случай, если хочешь убедиться, что схема — стандартная индустриальная, а не наша выдумка:

Wikipedia: Commitment scheme en.wikipedia.org/wiki/Commitment_scheme
Wikipedia: Provably fair en.wikipedia.org/wiki/Provably_fair
Wikipedia: HMAC en.wikipedia.org/wiki/HMAC
RFC 2104 — HMAC спецификация (1997, IETF) datatracker.ietf.org/doc/html/rfc2104
Wikipedia: SHA-2 (включает SHA-256) en.wikipedia.org/wiki/SHA-2

06Что мы НЕ делаем (и не сможем)

Если что-то нашёл — несовпадение хэша, странные броски, баг в проверке — напиши на dudiin@vk.com. Мы открыты к аудиту: можем показать любую часть кода, который касается генерации костей.