遊戲 RNG 正式環境要猜不到、測試要能重播:在 Go 裡把兩件事切開
之前一個專案把機率模組和遊戲伺服器用的 PRNG 統一換成 AES-CTR 實作,上線後,舊的機率套件發生 deadlock,用到它的遊戲都停止回應。新 RNG 套件在同一條呼叫路徑上拿了同一把鎖兩次,而 Go 的 sync.Mutex 不可重入。當下先切回舊版 RNG,新版另外測過穩定性才重新上線。
這次問題出在 RNG 的呼叫方式,測試也沒跑到那條路徑。正式環境要猜不到,測試和爭議處理則要能重播,把兩種需求塞進同一個 RNG 實例,通常兩邊都顧不好。這篇的做法是讓遊戲邏輯只認一個 Source 介面,正式環境注入不可推導的來源,測試時換成用 HMAC 推導 seed 的 ChaCha8。爭議重播另存每局實際取用的原始值,不靠重跑 RNG。
正式環境可以直接用 crypto/rand,應用程式不必持有或管理 seed,也可以用一套向作業系統取 seed 的 CSPRNG 套件,兩者的取捨放在第二節。
seed 空間、取模偏差、拒絕取樣和 GLI-11 的條文,在〈洗牌只有 8,640 萬種:遊戲 RNG 認證在檢查什麼〉講過,這裡不再重複。範例都在 Go 1.24.7 上跑過,寫這篇時 Go 最新版是 1.27.2,用到的 API 在新版都還在,Go 1.26 才有的功能會另外註明。
一、同一把鎖鎖兩次
把這個問題縮成最小範例,底層用 AES-256-CTR 的 keystream 產生亂數,key 和 IV 向作業系統取,每次 Uint64 取下一段 8 bytes,這個型別本身不帶鎖:
type AESCTR struct {
stream cipher.Stream
buf [8]byte
}
func NewAESCTR() (*AESCTR, error) {
var key [32]byte
var iv [aes.BlockSize]byte
rand.Read(key[:]) // Go 1.24 起保證不回傳錯誤,失敗時程式直接終止
rand.Read(iv[:])
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, fmt.Errorf("new aes cipher: %w", err)
}
return &AESCTR{stream: cipher.NewCTR(block, iv[:])}, nil
}
func (a *AESCTR) Uint64() uint64 {
clear(a.buf[:])
a.stream.XORKeyStream(a.buf[:], a.buf[:])
return binary.LittleEndian.Uint64(a.buf[:])
}
套件在外面包一把鎖,保護整個內部狀態。公開方法 Uint64 會上鎖,另一個公開方法 Draw 一次取好幾個值,自己先上鎖,再去呼叫 Uint64:
type LockedRNG struct {
mu sync.Mutex
src *AESCTR
}
func (r *LockedRNG) Uint64() uint64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.src.Uint64()
}
// Draw 一次取 k 個值(例如一次轉動的 5 個轉軸),先上鎖,
// 再呼叫同樣會上鎖的 Uint64。
func (r *LockedRNG) Draw(k int) []uint64 {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]uint64, k)
for i := range out {
out[i] = r.Uint64()
}
return out
}
func main() {
src, err := NewAESCTR()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
r := &LockedRNG{src: src}
fmt.Println("uint64:", r.Uint64())
fmt.Println("draw:", r.Draw(5))
}
uint64: 9135510418585151819
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.Mutex.Lock]:
...
sync.(*Mutex).Lock(...)
/usr/local/go1.24.7/src/sync/mutex.go:46
main.(*LockedRNG).Uint64(0x4d1c20?)
aes/locked.go:12 +0x4a
main.(*LockedRNG).Draw(0xc0000120f0, 0x5)
aes/locked.go:24 +0xa5
main.main()
aes/main.go:16 +0x114
exit status 2
sync.Mutex 文件寫的是「鎖已經被使用時,呼叫的 goroutine 會阻塞到鎖可用為止」,它不記得是誰拿著鎖,同一個 goroutine 再鎖一次也照樣等,而它等的人就是自己。單獨跑的時候 runtime 會直接報 fatal error,看起來很好抓。
放進伺服器就沒這麼好抓了,runtime 只在所有 goroutine 都睡著時才判定 deadlock,伺服器還有 goroutine 在等網路連線,程式不會結束,每個碰到這把鎖的請求卻都停在那裡。把同一個 LockedRNG 掛到 HTTP server 上,兩個 handler 共用一個實例,client 設 2 秒 timeout,依序打 /coin、/slot、/coin:
src, err := NewAESCTR()
if err != nil {
return fmt.Errorf("new shared rng: %w", err)
}
shared := &LockedRNG{src: src} // 所有遊戲共用同一個實例
mux.HandleFunc("/slot", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, shared.Draw(5))
})
mux.HandleFunc("/coin", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, shared.Uint64()&1)
})
/coin 0
/slot timeout after 2s
/coin timeout after 2s
goroutines blocked in sync.Mutex.Lock: 2
第一個 /coin 正常回應,/slot 進了 Draw 之後拿著鎖停在裡面,接著連只呼叫 Uint64 的 /coin 也排在同一把鎖後面。一條呼叫路徑發生 deadlock,所有共用這個實例的遊戲就跟著停住。process 本身還活著,只檢查 port 有沒有開的 health check 也看不出異狀。
一種修法是讓公開方法負責上鎖,內部呼叫不上鎖的版本,並用命名標示呼叫前提:
func (r *LockedRNG) Draw(k int) []uint64 {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]uint64, k)
for i := range out {
out[i] = r.uint64Locked()
}
return out
}
// uint64Locked 呼叫前必須已經持有 r.mu。
func (r *LockedRNG) uint64Locked() uint64 { return r.src.Uint64() }
也可以每局各建一個不帶鎖的 RNG,只給處理這一局的 goroutine 使用。如果只是把原本會重複上鎖的物件改成每局一份,同一個 goroutine 照樣會鎖死自己,所以這裡每局直接建一個不帶鎖的 AESCTR,key 和 IV 各局獨立:
// 每一局自己一個不帶鎖的 AESCTR,只在處理這一局的 goroutine 裡使用。
round, err := NewAESCTR()
if err != nil {
return fmt.Errorf("new round rng: %w", err)
}
兩種修法都用 8 個 goroutine、各跑 1 萬局,開 -race,設 30 秒期限,時間到還沒跑完就當成可能發生 deadlock:
$ go run -race .
done: 8 goroutines x 10000 rounds
照 Security > Simplicity > Correctness > Performance 的順序,我會選第二種。沒有共用狀態就不需要鎖,也不用處理鎖的順序,一局的 RNG 狀態不會被其他局觀察到。第一種得靠每個維護者都記得命名約定,套件越改越大,很難一直保證。
換 RNG 等於一次換掉所有遊戲共用的依賴,認證前要檢查套件的併發設計,上線前至少跑一組多 goroutine 同時呼叫每個公開方法的測試。-race 只能抓 data race,抓不到 deadlock,還得設期限,在時間內跑完才算通過。上面那段 8 個 goroutine 的程式就是最小版本。功能也要整個回歸一次,那段時間測試環境還出現過一進遊戲就收到空的盤面恢復訊息,最後從 log 查到問題在帳號狀態和場景恢復邏輯。
二、math/rand/v2 和 crypto/rand 各管什麼
Go 1.22 加了 math/rand/v2,這是標準函式庫第一個 v2 套件,先看會影響 RNG 用法的改動。
頂層函式(rand.IntN、rand.Float64 這些)預設用 ChaCha8,自動取 seed,v2 拿掉了頂層的 Seed 函式,沒辦法讓它們重現。v1 的 rand.Seed 在 Go 1.20 就已經標成 deprecated。
需要重現就自己建 ChaCha8 或 PCG:NewChaCha8(seed [32]byte)、NewPCG(seed1, seed2 uint64),兩者都實作 encoding.BinaryMarshaler,狀態可以存下來再還原。ChaCha8 的 Read 方法是 Go 1.23 加的。
套件文件說「不應用於安全相關的用途」,ChaCha8 型別的說明卻寫它是 cryptographically strong。Go 團隊介紹 ChaCha8Rand 的文章講得比較清楚:它讓誤用 math/rand 不再是安全災難,真正需要安全亂數時,仍建議用 crypto/rand。
crypto/rand 在 Go 1.24 也有一個改變:Read 保證不會失敗,一律回傳 nil,真的讀不到時程式直接 crash,不會讓呼叫端拿著一段沒填好的 buffer 繼續跑。同一版在 Linux 6.11 以上改用 vDSO 的 getrandom,release notes 說小量讀取快了好幾倍。
把可重現和不可重現放在一起跑一次:
func first3(src mrand.Source) [3]uint64 {
return [3]uint64{src.Uint64(), src.Uint64(), src.Uint64()}
}
func main() {
seed := [32]byte{1, 2, 3}
fmt.Println("chacha8 a:", first3(mrand.NewChaCha8(seed)))
fmt.Println("chacha8 b:", first3(mrand.NewChaCha8(seed)))
fmt.Println("pcg a:", first3(mrand.NewPCG(1, 2)))
fmt.Println("pcg b:", first3(mrand.NewPCG(1, 2)))
// 中途把狀態存下來,再還原,之後的輸出完全相同
c := mrand.NewChaCha8(seed)
c.Uint64()
state, err := c.MarshalBinary()
if err != nil {
fmt.Fprintln(os.Stderr, "marshal:", err)
os.Exit(1)
}
var restored mrand.ChaCha8
if err := restored.UnmarshalBinary(state); err != nil {
fmt.Fprintln(os.Stderr, "unmarshal:", err)
os.Exit(1)
}
fmt.Println("state bytes:", len(state))
fmt.Println("original :", first3(c))
fmt.Println("restored :", first3(&restored))
for i := 0; i < 2; i++ {
var b [8]byte
rand.Read(b[:])
fmt.Printf("crypto/rand %d: %x\n", i, b)
}
}
chacha8 a: [18307998996574457915 11107991961950960290 3581772083949013674]
chacha8 b: [18307998996574457915 11107991961950960290 3581772083949013674]
pcg a: [14192431797130687760 11371241257079532652 14470142590855381128]
pcg b: [14192431797130687760 11371241257079532652 14470142590855381128]
state bytes: 48
original : [11107991961950960290 3581772083949013674 17560204186880880572]
restored : [11107991961950960290 3581772083949013674 17560204186880880572]
crypto/rand 0: 305916f32fc2d894
crypto/rand 1: 6684342646fec2db
再跑一次,crypto/rand 那兩行變成 62f8c833295b5f98 和 7ac6a41bfdf266bd,其餘完全相同。固定 seed 能重現測試結果,正式環境可得防止 seed 或內部狀態外洩,這裡只要拿到 48 bytes 的狀態,就能把之後的輸出全部算出來。能被序列化的東西,都可能跑進 log、snapshot 或除錯介面。
GLI-19(線上博弈系統的技術標準,v3.0)第 3 章對這件事的要求很直接:3.3.1 要求決定遊戲結果的 RNG 必須是 cryptographically strong;3.3.2 要求它在攻擊者知道原始碼的情況下仍無法被攻破,不可以只用時間值當 seed,不同遊戲不可以有相同的初始 seed,並且要定期透過外部熵改變狀態。正式環境要用哪種來源,常見有兩種做法:
- 直接用
crypto/rand。應用程式因此少了自行管理亂數狀態的工作,沒有 seed 要保存,也沒有重新注入熵的排程要寫。代價是演算法和熵來源由 Go 與作業系統決定,送審時要說明部署環境中的 Go 與作業系統如何產生亂數。 - 用一套自己控制的 CSPRNG 套件(例如以 AES-CTR 為核心的實作),seed 向作業系統取,定期重新注入熵。演算法和重新注入的頻率都寫在自己的程式碼裡,送審時容易逐行說明。這也表示 seed、狀態、重新注入和併發都得自己管,第一節的 deadlock 就出在這一層。
選了標準函式庫或知名演算法,還不能據此宣稱符合 GLI 的要求。部署環境的底層實作和送審證據都要核對,能不能通過認證,以實驗室的判定為準。ChaCha8、PCG 這類可以指定 seed 的產生器,則留給單元測試、e2e 或離線機率模擬等需要重現的場合。
三、遊戲邏輯只認 Source
要讓同一段遊戲邏輯在正式環境用不可推導的來源、在測試用固定 seed,最簡單的做法是讓它只依賴 math/rand/v2 的 Source 介面(只有一個 Uint64() uint64 方法),亂數從哪裡來由呼叫端決定:
// CryptoSource 是正式環境用的來源:每次都向作業系統拿,沒有可保存的 seed。
type CryptoSource struct{}
func (CryptoSource) Uint64() uint64 {
var b [8]byte
rand.Read(b[:]) // Go 1.24 起保證回傳 nil,失敗時程式直接終止
return binary.LittleEndian.Uint64(b[:])
}
// Spin 是遊戲邏輯:只認 Source 介面,不知道亂數從哪來。
func Spin(src mrand.Source, reelLen []int) []int {
r := mrand.New(src)
stops := make([]int, len(reelLen))
for i, n := range reelLen {
stops[i] = r.IntN(n)
}
return stops
}
這裡用 crypto/rand 示範正式來源,換成自己的 CSPRNG 套件,只要實作 Uint64() uint64 就能接上。CryptoSource 沒有欄位,應用程式這一層沒有狀態,可以在多個 goroutine 之間共用,也就沒有第一節的鎖問題。
Spin 把輸出到轉軸位置的 mapping 交給 rand.New(src).IntN。若要自己寫,取模偏差那些問題就得照前一篇的方式逐行檢查。
四、測試的 seed 由誰持有
我做的遊戲商系統 prototype 裡,有個 e2e 測試檢查「同一個 idempotency key 重送,會得到同一個 round」。只確認 round ID 相同,測試就能過;再往下檢查這一局的派彩、餘額變化,正式的亂數來源每次跑出的結果都不一樣。示範用的 slot 遊戲一開始就是這樣,e2e 靠運氣,偶爾還會撞出 DUPLICATED_OPERATION,不知道是測試寫錯還是程式真的有問題。
後來我讓 slot 遊戲在測試環境改用推導出來的重播 seed:secret 由環境變數提供,加上 round 和 idempotency key 推導出 seed,e2e 才變成每次結果都一樣。下面的範例用 seed = HMAC-SHA256(secret, round_id) 示範這個推導,輸出剛好 32 bytes,直接餵給 NewChaCha8。同一個 secret、同一個 round ID 永遠得到同一局結果,換一個 round ID 就是另一局。
結果能重現,idempotency 仍得另外驗證:重送之後只有一筆 round、只扣一次款。重複執行也可能得到相同結果,兩種斷言都要寫,免得測試把問題藏起來。下面只示範推導 seed 的部分,轉軸設定是 var reels = []int{32, 32, 40, 40, 48}:
// replaySecret 從環境變數讀;沒設定就隨機產生並印出來,
// 失敗時把印出的值設回環境變數就能重跑同一組結果。
func replaySecret(t *testing.T) []byte {
t.Helper()
if s := os.Getenv("RNG_REPLAY_SECRET"); s != "" {
b, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("decode RNG_REPLAY_SECRET: %v", err)
}
if len(b) < 32 {
t.Fatalf("RNG_REPLAY_SECRET: need at least 32 bytes, got %d", len(b))
}
return b
}
b := make([]byte, 32)
rand.Read(b)
t.Logf("RNG_REPLAY_SECRET=%x", b)
return b
}
// deriveSeed 只存在於測試:同一個 secret 與 round ID 一定得到同一個 seed。
func deriveSeed(secret []byte, roundID string) [32]byte {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(roundID))
var seed [32]byte
copy(seed[:], mac.Sum(nil))
return seed
}
func TestSameRoundSameResult(t *testing.T) {
secret := replaySecret(t)
a := Spin(mrand.NewChaCha8(deriveSeed(secret, "round-0001")), reels)
b := Spin(mrand.NewChaCha8(deriveSeed(secret, "round-0001")), reels)
c := Spin(mrand.NewChaCha8(deriveSeed(secret, "round-0002")), reels)
t.Logf("round-0001: %v %v", a, b)
t.Logf("round-0002: %v", c)
if !slices.Equal(a, b) {
t.Fatalf("same round, different result: %v vs %v", a, b)
}
}
不設環境變數跑一次,secret 隨機產生並印出來;設了同一個 secret 之後,在同樣的轉軸設定下每次跑的結果都一樣(第二段命令的 secret 省略了後段,實際要貼完整的 64 個十六進位字元):
$ go test -v -run SameRound .
slot_test.go:46: RNG_REPLAY_SECRET=0c522080a19191276ebc9a045763d7b8c779081f7c5bfd3b6030811b1803375e
slot_test.go:50: round-0001: [10 20 13 16 5] [10 20 13 16 5]
slot_test.go:51: round-0002: [7 0 3 6 10]
--- PASS: TestSameRoundSameResult (0.00s)
$ RNG_REPLAY_SECRET=4464eb15... go test -v -run SameRound . # 連跑兩次輸出相同
slot_test.go:50: round-0001: [22 28 8 22 14] [22 28 8 22 14]
slot_test.go:51: round-0002: [26 31 30 24 24]
--- PASS: TestSameRoundSameResult (0.00s)
這個來源只能編進測試,secret 也得按測試用途管理:
deriveSeed寫在_test.go裡,正式的 binary 根本編不進這段程式,正式環境沒有任何一條路徑能從 round ID 推出 seed。如果 e2e 要打一個跑起來的服務,推導 seed 的 Source 就要放在只有測試 build 才會編進去的地方(例如 build tag),並且讓服務啟動時在 log 明確標示自己正在用測試來源。- 用 HMAC 而不是直接雜湊 round ID,是因為 round ID 會出現在注單、歷史紀錄、API 回應裡,誰都看得到。HMAC 的保護前提是 secret 夠長、沒有外洩,範例因此要求至少 32 bytes。不過範例會把 secret 印進測試 log,只適合測試環境,誤接到共用環境時不能靠它保證安全。
- secret 沒寫死在程式碼裡,CI 每次跑可以換,失敗時 log 裡有足夠的資訊重跑。
如果程式碼直接呼叫 crypto/rand,沒有經過 Source 介面,Go 1.26 新增的 testing/cryptotest.SetGlobalRandom(t, seed) 可以在測試期間把 crypto/rand 和 crypto/... 套件內部用到的亂數換成 deterministic 來源。它影響整個 process,不能用在 parallel 測試裡,文件也提醒,密碼學演算法怎麼使用亂數並沒有保證,未來版本的輸出可能改變。這個函式沒有在本文的 Go 1.24.7 環境實測,用之前以 pkg.go.dev 上的說明為準。
五、爭議重播:存下抽到的值,不存 seed
測試能重播,因為手上有 seed,正式環境的 seed 則不該留下來。玩家對某一局有爭議,要重現當時的結果,可以在 Source 這一層記下遊戲邏輯實際取用的原始值,跟著這一局一起存,重播時再依序餵回同一段遊戲邏輯:
// Recorder 記下遊戲邏輯實際取用的每一個原始值,跟著這一局一起存。
type Recorder struct {
Src mrand.Source
Draws []uint64
}
func (r *Recorder) Uint64() uint64 {
v := r.Src.Uint64()
r.Draws = append(r.Draws, v)
return v
}
// Replay 依序吐出存下來的值。值用完時不能回傳假值:
// IntN 的拒絕取樣遇到固定的 0 會一直重抽,永遠不會結束。
type Replay struct {
Draws []uint64
pos int
}
type errExhausted struct{ n int }
func (r *Replay) Uint64() uint64 {
if r.pos >= len(r.Draws) {
panic(errExhausted{len(r.Draws)})
}
v := r.Draws[r.pos]
r.pos++
return v
}
// ReplaySpin 用存下來的值重跑同一段遊戲邏輯,取用次數不符就回傳錯誤。
func ReplaySpin(draws []uint64, reelLen []int) (stops []int, err error) {
rp := &Replay{Draws: draws}
defer func() {
if v := recover(); v != nil {
e, ok := v.(errExhausted)
if !ok {
panic(v)
}
stops, err = nil, fmt.Errorf("replay exhausted after %d draws", e.n)
}
}()
stops = Spin(rp, reelLen)
if rp.pos != len(draws) {
return nil, fmt.Errorf("replay used %d of %d draws", rp.pos, len(draws))
}
return stops, nil
}
func TestRecordAndReplay(t *testing.T) {
rec := &Recorder{Src: CryptoSource{}} // 正式環境的來源
got := Spin(rec, reels)
again, err := ReplaySpin(rec.Draws, reels)
if err != nil {
t.Fatal(err)
}
t.Logf("live: %v replay: %v draws stored: %d", got, again, len(rec.Draws))
if !slices.Equal(got, again) {
t.Fatalf("replay mismatch: %v vs %v", got, again)
}
_, err = ReplaySpin(rec.Draws[:2], reels)
t.Logf("too few draws: %v", err)
_, err = ReplaySpin(append(slices.Clone(rec.Draws), 42), reels)
t.Logf("too many draws: %v", err)
}
=== RUN TestRecordAndReplay
slot_test.go:65: live: [2 11 2 20 15] replay: [2 11 2 20 15] draws stored: 5
slot_test.go:71: too few draws: replay exhausted after 2 draws
slot_test.go:73: too many draws: replay used 5 of 6 draws
--- PASS: TestRecordAndReplay (0.00s)
只記最後的轉軸位置不夠,原始值得在 Source 這一層記。IntN 為了消除偏差,有時會丟掉一個值重抽,取用次數不一定等於轉軸數,要記下原始值,重播才能走完全相同的路徑。
值用完時,Replay 不能回傳 0 之類的假值。IntN 的拒絕取樣只要一直拿到同一個會被拒絕的值,就會無限重抽。實測只給 2 個值、讓耗盡時回傳 0,前兩個轉軸長度 32 是 2 的冪次,直接用遮罩取值,到了長度 40 的第三個轉軸就一直重抽,測試跑到 5 秒的 timeout 都沒有回來。現在的寫法是用完就 panic,由 ReplaySpin recover 成錯誤,多出來的值也回報錯誤。
取用次數對得上,仍可能重播錯誤,轉軸配置或派彩邏輯改了,不一定會多抽或少抽。因此每局除了原始值,也要記下當時的遊戲邏輯版本和賠率表,重播時用同一版。IntN 本身的 mapping 倒不用太擔心,Go 團隊介紹 v2 的文章寫明 rand.Rand 的 API 已經鎖定在這組輸出序列上,即使之後找到更快的演算法也不會換。
這些值來自不可推導的來源,知道過去的值也推不出後續亂數,但能不能公開,還得看它們會透露哪些遊戲資訊。共用 PRNG 的 seed 或狀態一旦外洩,後續各局的亂數就能被推算出來。GLI-19 2.8.2 要求每一局的遊戲資訊都要保存並備份,原始值可以放在同一筆紀錄裡。
六、把 RNG 拆成可以單獨驗證的模組
GLI-19 有兩條和模組切分直接相關:2.3.2 要求系統至少每 24 小時、以及在需要時,確認關鍵程式元件與核准版本一致,雜湊演算法的摘要至少 128 位元;2.3.3 要求每個關鍵元件都能由獨立第三方用某種方法驗證。RNG 屬於決定遊戲結果的關鍵元件,如果它和遊戲邏輯、機率混在一起,每次比對雜湊或做第三方驗證,範圍都得包含整包程式,很難說清楚哪一段才是 RNG。把 RNG 和機率拆成獨立、單純的功能性模組,驗證時就能只看這一塊。
照前面幾節的做法,RNG 套件不依賴任何遊戲,只輸出 Source,遊戲邏輯透過 Source 取得亂數,固定亂數序列和其他輸入後就能重現結果。測試用的推導來源和重播工具都在正式 binary 之外,這樣在原始碼層面,改一個遊戲的賠率不會動到 RNG 的檔案,換 RNG 實作也只換一個模組。
送審產物還得另外處理。如果 RNG 和遊戲仍編進同一個 Go 執行檔,改任何一個遊戲都會改變那個檔案的雜湊。想讓 RNG 的雜湊保持不變,就得拆成獨立的服務或產物。
依賴也要鎖住。我當時送審前用 go mod vendor 把所有依賴複製進 vendor/,送審版本和部署版本用的就是同一份依賴原始碼。依 Go 的 module 文件,go.mod 的 go 版本在 1.14 以上、vendor 目錄存在時,build 會預設使用 vendor;go.mod 改了而 vendor/modules.txt 沒更新,go 指令會直接報錯,不會悄悄改用別的版本。
補充筆記
- Go 的
sync.Mutex不可重入,RNG 套件裡公開方法互相呼叫就可能自己鎖死自己;在伺服器裡不會出現fatal error,只會讓所有共用這個實例的遊戲停住。 - 修法是拆出不上鎖的內部函式,或者每局各自一個不帶鎖的 RNG;換 RNG 實作前,用多 goroutine 跑過每個公開方法,開
-race抓 data race,設期限抓 deadlock。 math/rand/v2的ChaCha8、PCG適合需要重現的場合;正式環境可以直接用crypto/rand,或用自己控制的 CSPRNG 套件,是否符合認證以送審證據為準。- 遊戲邏輯只依賴
Source介面,正式環境注入不可推導的來源,測試注入以HMAC-SHA256(secret, round_id)推導 seed 的 ChaCha8,推導程式只放在測試裡。 - 爭議重播存每局實際取用的原始值和遊戲邏輯版本,不存 seed 或 PRNG 狀態;重播來源用完時要報錯,回傳假值會讓
IntN無限重抽。 - RNG 和機率拆成獨立模組,日後才能單獨驗證;要讓雜湊範圍明確,送審產物也要分開。
延伸閱讀
- Russ Cox〈Evolving the Go Standard Library with math/rand/v2〉:v2 為什麼拿掉頂層
Seed、為什麼新增ChaCha8和PCG。 - Russ Cox、Filippo Valsorda〈Secure Randomness in Go 1.22〉:ChaCha8Rand 的設計、runtime 怎麼取 seed 和定期換鑰,以及為什麼仍建議用
crypto/rand。 - math/rand/v2 套件文件:
NewChaCha8、NewPCG、Source介面與併發安全的說明。 - Go 1.24 Release Notes:
crypto/rand.Read保證不失敗、Linux vDSOgetrandom。 - Go 1.26 Release Notes 與 testing/cryptotest 套件文件:
SetGlobalRandom的用法與限制。 - sync 套件文件:
Mutex.Lock的阻塞行為,以及鎖和 goroutine 沒有綁定關係。 - Gaming Laboratories International〈GLI-19 Interactive Gaming Systems, v3.0〉:本文引用的 2.3.2、2.3.3、2.8.2、3.3.1、3.3.2,適用版本以各司法管轄區採用的為準。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Go 1.24.7 實測。