diff --git a/.changeset/fix-unseeded-room-rng.md b/.changeset/fix-unseeded-room-rng.md new file mode 100644 index 0000000..d9f7efe --- /dev/null +++ b/.changeset/fix-unseeded-room-rng.md @@ -0,0 +1,5 @@ +--- +"kit": patch +--- + +Fix every unseeded wasm room sharing one deterministic RNG stream. The guest runtime seeds the SDK room PRNG (`r.Rand()` in Go, `rng_u64` in Rust) from the CallContext seed verbatim, but for a room without an operator-set seed the host encoded the raw config zero — so every room of every game drew the identical "random" sequence (identical blackjack shoes across rooms; casino outcomes learnable by replaying a throwaway room and then betting on the known stream). The host now derives a per-room seed from the host CSPRNG when `SeedSet` is false and carries it in the room's config, so the Ctx (and the WASI entropy source, which previously used the guessable room-start timestamp) are seeded per room. Host-side only: existing game binaries pick up the fix without a rebuild. Explicitly seeded runs (`--seed` / `-seed`, conformance, hibernation restore) are unchanged and stay deterministic. diff --git a/ABI.md b/ABI.md index d7a069e..ed869a4 100644 --- a/ABI.md +++ b/ABI.md @@ -157,6 +157,13 @@ u16 memberCount on `leave`, includes the departed entry u8 settled 0/1 ``` +**Seed.** The guest runtime seeds the SDK room PRNG from `seed` verbatim, so +the host MUST populate it with a per-room unpredictable value when no seed was +requested — never a shared constant (every room would deal the identical +"random" stream). `seedSet=1` means an operator explicitly requested this seed +(deterministic dev/test runs, hibernation restore); the value is equally +binding either way. + **Roster-epoch member-section forms (minor addition).** For a guest whose meta declares `CtxFeatRosterEpoch` (§4.2), the host MAY replace the member section with one of two sentinel forms keyed on `memberCount` (real rosters diff --git a/host/gameabi/host.go b/host/gameabi/host.go index dbbc089..f5d03c7 100644 --- a/host/gameabi/host.go +++ b/host/gameabi/host.go @@ -2,8 +2,10 @@ package gameabi import ( "context" - "errors" + crand "crypto/rand" "crypto/sha256" + "encoding/binary" + "errors" "fmt" "io" "log/slog" @@ -529,7 +531,20 @@ func (h *wasmHandler) OnStart(r sdk.Room) { h.nowNanos = r.Now().UnixNano() h.seed = h.cfg.Seed if !h.cfg.SeedSet { - h.seed = h.nowNanos + // The guest runtime seeds its SDK PRNG (r.Rand()) from the CallContext + // seed verbatim, so an unseeded room MUST carry a per-room seed in its + // cfg — left at the zero value, every room of every game would share + // one deterministic stream (identical blackjack shoes; casino outcomes + // learnable from a throwaway room). Derived from the host CSPRNG, not + // the room clock: a start timestamp is guessable to within a small + // search window, which a wagering game cannot afford. + var b [8]byte + if _, err := crand.Read(b[:]); err == nil { + h.seed = int64(binary.LittleEndian.Uint64(b[:])) + } else { + h.seed = h.nowNanos // CSPRNG unreachable; degraded, not fatal + } + h.cfg.Seed = h.seed } inst, err := h.game.compiled.Instance(context.Background(), diff --git a/host/gameabi/seed_test.go b/host/gameabi/seed_test.go new file mode 100644 index 0000000..6032657 --- /dev/null +++ b/host/gameabi/seed_test.go @@ -0,0 +1,55 @@ +package gameabi + +import ( + "log/slog" + "strings" + "testing" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// readGameRand starts a fixture room with cfg, presses 'g', and returns the +// guest's logged r.Rand() value — the SDK room PRNG the guest runtime seeds +// from the CallContext seed (NOT the 'r' WASI entropy source). +func readGameRand(t *testing.T, cfg sdk.RoomConfig) string { + t.Helper() + g := loadFixture(t, Options{}) + cap := &logCapture{} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: slog.New(cap)}) + tr.Start() + tr.Join(p1) + tr.Input(p1, runeIn('g')) + line, ok := cap.findLine("fixture: game_rand=") + if !ok { + t.Fatalf("no game_rand log; got %v", cap.lines()) + } + return strings.TrimPrefix(line, "fixture: game_rand=") +} + +// TestUnseededRoomsGetDistinctGameRand: two production-shaped rooms (no +// explicit seed: Seed=0, SeedSet=false) must NOT share an r.Rand() stream. +// The guest runtime seeds its PRNG from the CallContext seed verbatim, so the +// host must place a per-room derived seed there when the operator did not set +// one — otherwise every room of every game deals the identical "random" +// sequence (and casino outcomes are learnable by replaying a fresh room). +func TestUnseededRoomsGetDistinctGameRand(t *testing.T) { + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1} + a, b := readGameRand(t, cfg), readGameRand(t, cfg) + if a == b { + t.Fatalf("two unseeded rooms produced the identical r.Rand() stream: %s", a) + } +} + +// TestExplicitSeedKeepsGameRandDeterministic: the dev/--seed contract is +// untouched by the unseeded-room derivation — the same explicit seed still +// reproduces the same r.Rand() stream. +func TestExplicitSeedKeepsGameRandDeterministic(t *testing.T) { + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + if s1, s2 := readGameRand(t, cfg), readGameRand(t, cfg); s1 != s2 { + t.Fatalf("same explicit seed produced different r.Rand() streams: %s vs %s", s1, s2) + } + other := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 8, SeedSet: true} + if s1, s3 := readGameRand(t, cfg), readGameRand(t, other); s1 == s3 { + t.Fatalf("different explicit seeds produced the identical r.Rand() stream: %s", s1) + } +} diff --git a/host/gameabi/testdata/fixture/fixture.wasm b/host/gameabi/testdata/fixture/fixture.wasm index c440906..d7402b7 100644 Binary files a/host/gameabi/testdata/fixture/fixture.wasm and b/host/gameabi/testdata/fixture/fixture.wasm differ diff --git a/host/gameabi/testdata/fixture/main.go b/host/gameabi/testdata/fixture/main.go index d99c4c6..d592e31 100644 --- a/host/gameabi/testdata/fixture/main.go +++ b/host/gameabi/testdata/fixture/main.go @@ -13,6 +13,7 @@ // 'c' config_get("greeting"), logged // 't' log the guest's own time.Now().UnixNano() (== the room clock) // 'r' log 8 bytes from crypto/rand (the WASI-virtualized entropy source) +// 'g' log the next value from r.Rand() (the SDK room PRNG, Ctx-seeded) // 'd' arm a 250ms countdown deadline; each wake renders remaining ms, then // when CallContext time passes the deadline it renders BOOM and ends // @@ -149,6 +150,10 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { // rooms with the same seed log identical entropy. _, _ = crand.Read(rm.entropy[:]) r.Log("fixture: rand=" + hex8(rm.entropy)) + case 'g': + // The SDK room PRNG — seeded by the guest runtime from the CallContext + // seed, distinct from the 'r' WASI entropy source. + r.Log("fixture: game_rand=" + strconv.FormatInt(r.Rand().Int63(), 10)) case 'd': // Arm a countdown anchored to the room clock; the host drives it forward // with wakes (CallContext time), no host-side timer involved.