自己验证一手牌——以及一个「可验证公平」验证器必须拒绝什么
「可验证公平」如果你没法亲手跑那个证明,就一文不值。这里是验证我们一手牌的方式——导出后,在网页或命令行里核对,背后是同一个验证器——以及真正值得抄走的那部分:让人信服的检查,是那些让验证器学会拒绝的检查。
别把「可验证公平」当信仰
「可验证公平」是一句话,而一句话什么都证明不了。它只有在你能亲手把那个证明跑一遍、亲眼看它通过或失败时才算数——不用信我们,也不用信那个给你显示结果的网页。
所以这篇把整件事从头讲到尾:怎么验证我们的一手牌——导出证明,然后在浏览器里、或者直接在命令行里核对——以及怎么读懂验证器打出来的每一行。还有一条,如果你哪天也要做这类东西,就是真正值得抄走的那部分:真正让人信服的检查,是那些让验证器学会拒绝的检查。一个只会说 OK 的验证器,是摆设。
底层机制是标准的 commit-reveal(承诺—揭示):发牌前,服务器公布一个对秘密种子的承诺;打完这手,它揭示那个种子。任何人都能用揭示出来的秘密复算出那副牌,并确认两件事——种子和发牌之前就向全桌公布的承诺对得上(你的客户端会显示这个承诺,也可以把它留存下来),而这个种子生成的牌,跟你被发到的牌逐字节一致。发牌前已承诺,发牌后未改牌。
什么在何时被固定:①的承诺先于②的随机数与③的发牌;验证则从揭示出发,重放③–⑤。
先拿到证明,再验证它——一个验证器,两道门
验证一手 commit-reveal 牌,跑的是一个很小的 Rust 函数 verify_hand(它跟支撑加密发牌的那套 Mental-Poker transcript 验证器是分开的)。app.bluffking.ai/fairness 网页和命令行,是通往这同一个函数的两道门;而复盘页那个按钮,是你把证明文件拿到手、喂给它们的方式。这两道门并不等价,而这个不对称正是重点:网页是「图省事」的那道门——它把你的记录交给我们的服务器,由服务器跑同一个 verify_hand,再把结论传回来;命令行才是「谁都不用信」的那道门——同一套检查,离线跑,全程不联网。
1 —— 作为玩家:导出证明
打完一手牌,进复盘页,点 导出本手公平性证明。它会原样导出这手的记录,文件名是 bluffking-hand-<id8>.json(名字带上 hand id 的前 8 位)。这个按钮只负责导出——之后你用下面两种方式之一去验证这个文件。
2 —— 在浏览器里:图省事的那道门
打开 app.bluffking.ai/fairness,把那份 JSON 上传或粘贴进去;我们的服务器跑完检查,把结果传回来。VERIFIED 的结论会带上 hand id、复算出来的 deck seed,以及核对了几个座位的底牌、几张公共牌、几个 client seed;REJECTED 则给出原因;而一份根本不是 commit-reveal 的记录(没有 server_seed)会得到中性的 SKIPPED——不是拒绝。省事归省事,这个结论终究还是我们服务器的一面之词。(验证器是开源的,记录也是自包含的,所以任何人都可以架起同样的检查;而下一道门,连「架」都不需要。)
3 —— 在命令行里,谁的服务器都不用信
同一份证明文件,走命令行、全程不联网:
cargo run -p mental-poker --bin pf_verify -- --hand ~/Downloads/bluffking-hand-<id8>.json
你甚至不需要一手真牌就能看它跑起来。仓库自带一个自包含的 demo,它发一手真实的引擎牌局再验证它,所以下面这段输出你可以逐字节复现:
cargo run -p mental-poker --bin pf_demo_hand | cargo run -p mental-poker --bin pf_verify -- -
OK: deck reproduced, commit verified ✓ (provably fair)
hand_id : abababab-abab-abab-abab-abababababab
recomputed deck_seed: e83b041b1bb63da0a1a90b6aee6650566ec76f8b33dee10f348b011519ca0ab3
hole seats checked : 3
board cards checked: 5
client seeds mixed : 0 (anti-grind when >0)
每一行,都是一条你可以拿去较真的断言:
| 行 | 它断言了什么 |
|---|---|
deck reproduced | 验证器用证明文件重新洗牌、重新发牌,复算的结果和记录对上了。 |
commit verified | 揭示出来的 server_seed,哈希后确实等于发牌前公布的 seed_commit。 |
hole seats checked | 有几个座位的两张底牌可以拿来比对。你自己导出的通常是 1(你的座位);弃牌未亮牌的对手底牌不会随手放进 JSON。 |
board cards checked | 比对了几张公共牌——翻牌前就结束是 0,到河牌最多 5。 |
client seeds mixed | 0 表示仅服务器承诺;>0 表示记录里带有 client seed 条目。这个数字为什么有分量、又为什么可能诚实地是 0,见「诚实的边界」一节。 |
如果记录被改过,它会改打 REJECTED ✗ 并给出原因(退出码 1);一份不是 commit-reveal 的记录会打出 SKIPPED(同样退出码 1);而如果输入根本不是一份合法的手牌记录,那是解析错误、不是判决(退出码 2)。只要不是 OK,命令行都以非零码退出,方便你写进脚本。
验证器到底做了什么
三步检查,按顺序来。每一步都不信任任何存下来的结果;每一步都从头重算,再比对。
- 承诺是绑死的。它把揭示出来的
server_seed哈希一遍,检查它是否等于发牌前就公布的seed_commit。如果有人看完牌之后再把种子换掉,承诺就对不上了,直接拒绝。这是「发牌后未改牌」的那一半。 - deck seed 是确定性地重新推导出来的。验证器把 server seed、按座位排序的每一个 client seed 和 hand id 作为分段输入,送入一个带域分隔的哈希,得到 deck seed。同样的输入,永远得到同样的种子——而把 hand id 混进去,意味着同一个 server seed 没法在两手不同的牌里复现出同一副牌。
- 发牌被重跑一遍。它用同一套引擎洗牌、同一个种子、同样的发牌顺序(不烧牌),把每一张复算出来的底牌和公共牌跟记录逐张比对。有一张对不上,就是拒绝。
验证器重建牌堆用的是生产引擎自己的洗牌——服务器用的就是这同一套 PokerRng 和 Deck——所以不存在「另一套洗牌」可能悄悄和第一套不一致。而它接着套用的发牌顺序,是从引擎独立镜像出来的一份,由下一节讲的 fail-closed 守卫锁死;证明里带的每一对底牌、每一张公共牌,它都会逐张核对。
真正值得抄走的:一个不会失败的验证器,不是验证器
有意思的工程不在顺利那条路上。它在那些「诚实的答案是『这个我拒绝认证』」的情形里——而把这些做错,正是「可验证公平」系统悄悄沦为表演的方式。有两个是我们特意去堵的:
- 一份没有东西可比的记录,绝不能通过。底牌和公共牌都是可选字段。所以一份既没有底牌、公共牌也为空的记录,会顺顺当当穿过上面三步检查——比了零次,零处不匹配——然后打出「provably fair」,可它其实什么都没证明。补救是一个 fail-closed 的守卫:只要一张牌都没真正比过,就拒绝。「已验证」必须意味着「我比了真牌而且对上了」,绝不能是「我没发现不匹配」。
- 一旦要靠猜,就拒绝。引擎是按座位顺序发牌的,而在有空位的桌子上(比如只坐了 2 号和 5 号),发牌的位次并不等于座位号。记录里带着真实的发牌座位顺序,好让验证器把每个座位映射回它的位次。如果这个映射在一手需要它的牌里缺失了,验证器不会去猜——猜可能让被改过的牌蒙混过关,也可能冤枉一手诚实的牌。它会拒绝,并说明原因。
把它一般化,就是你做任何 commit-reveal 或「可验证公平」系统时——彩票、RNG 审计、抽签——都值得带走的那一条:让验证器默认拒绝。绑死承诺,确定性地重新推导,一旦没有东西可比、或你不得不去假设,就 fail closed。真正赢得用户信任的检查,恰恰是那些允许验证器说「不」的检查。
诚实的边界:这不是「服务器盲发牌」
要对一个绿色结果到底证明了什么说得精确,因为它很容易被夸大。一份普通桌的证明通过了,意味着发牌前已承诺、发牌后未改牌。它不意味着服务器看不见你的牌——在普通桌上服务器是能看到整副牌的,因为发牌、教练、机器人都由它来跑。commit-reveal 证明的是牌没被掉包;它不证明没人看过。
这正是 client seeds mixed 派上用场的地方。在零个 client seed 时,证明仍然显示牌在承诺之后没被改过——但它排除不了这样一种服务器:在承诺之前,把许多候选种子挨个搓一遍,悄悄挑一副自己喜欢的。可只要有哪怕一个真人客户端在承诺之后混入了自己的种子,这种「承诺前挑牌」就崩塌了:服务器早在看到那个最终定牌的随机数之前就已经承诺。这就是为什么这个数字 >0 是 anti-grind(防搓牌)的信号,也是为什么真正混进了一个真人种子的牌,比没混进的更强。有别人同桌通常能过这道线——但保证跟着的是这个数字,不是人头:收集随机数的窗口是尽力而为、且非致命的,所以要是没有合格的 client seed 及时到达,这手牌就诚实地退回到「仅服务器承诺」(client seeds mixed : 0),依然完全可验证。
有两条注脚,让上面的说法保持诚实。验证器只是数了数 client seed 条目——它没法认证是谁发来的——所以 anti-grind 这个论证,最硬的版本是你自己的:如果你贡献过种子,就去查你那颗种子是否原样出现在记录里。另外,导出的文件本身是我们服务器写的,所以密码学绑死的是记录内部的种子与承诺;把承诺钉在发牌之前的,是你的实时客户端——它在发牌时就显示、也可以留存 seed_commit;而把这份记录钉在你这手牌上的,是你自己——拿记录里的牌,对照你在屏幕上看到的。
更强的 server-blind 保证需要另一套机制(加密的、Mental-Poker 式的发牌),我们在 高级加密发牌让服务器看不见什么? 里讲过。在 ADR-101 下,该模式现已在房主开启的全真人、无 AI 桌上开放(这一手底牌保密,但非对抗运营者的无信任结算);而默认牌桌——也就是这个验证器针对的牌桌——仍是服务器可见的 commit-reveal 发牌。两个不同的问题,两份不同的证明。
总结
- 别把「可验证公平」当信仰——去跑它。导出一手牌,然后在浏览器 app.bluffking.ai/fairness 里验证,或用
pf_verify离线验证。两道门调的是同一个 Rust 函数——但网页那道图省事(检查由我们的服务器来跑),命令行那道图独立(不联网,也不必信我们)。 - 通过证明了什么:揭示的种子和发牌前的承诺对得上,而这个种子复现出的牌,正是你被发到的那副。发牌前已承诺,发牌后未改牌。
- 可复用的一课:一个验证器的可信程度,只等于它拒绝了什么。没有东西可比、或不得不去猜时,就 fail closed。「已验证」必须意味着「比过且对上了」,而不是「没发现不匹配」。
- 诚实的边界:commit-reveal 不是服务器盲发牌。一个真人 client seed(
client seeds mixed > 0)额外干掉了承诺前搓牌——这个论断,要靠你在记录里找到自己贡献的那颗种子才算真正拿到手;而更强的「服务器看不见牌」保证,是另一套独立的加密机制。