Summary
filter_state_for_viewer redacts hidden zones from the GameState delivered to each player, but it does not strip the deterministic RNG seed. Every filtered snapshot sent to a client (or P2P guest) still contains rng_seed. Because all engine randomness is drawn from a ChaCha20 stream seeded solely by that value, a participant who knows the seed can reproduce the game's entire random stream — future coin flips, die rolls, random discards/targets, and shuffles — and can potentially reconstruct hidden library order that the redaction is meant to protect.
Affected code
crates/engine/src/types/game_state.rs — rng_seed: u64 is a serialized field. The live rng: ChaCha20Rng stream is #[serde(skip)], but the seed that fully determines it is not.
crates/engine/src/game/visibility.rs — filter_state_for_viewer(state, viewer) clones and redacts hands, libraries, face-down identities, choice payloads, and deck pools, but leaves rng_seed intact on the returned state.
- Delivery paths that ship the filtered state to untrusted clients:
- Server-authoritative:
crates/phase-server/src/main.rs broadcasts filter_state_for_player(&raw_state, pid) as a StateUpdate to each seat.
- P2P host-authoritative: the host's
getViewerSnapshot(pid) → engine-wasm get_viewer_snapshot_js runs filter_state_for_viewer and sends the result to each guest.
Impact
All engine randomness draws deterministically from state.rng, a ChaCha20 keystream seeded only by rng_seed. A participant who knows the seed and can infer how many RNG values have already been consumed can:
- Predict every future randomized outcome (coin flips, die rolls, random targets/discards, shuffles).
- Replay the initial library shuffle to reconstruct the ordering of hidden libraries — defeating the confidentiality that
visibility.rs otherwise enforces for library contents and order.
This affects both multiplayer modes. It is an information-disclosure / competitive-integrity issue: an otherwise-legal but modified client can obtain hidden information and foresee randomness — i.e. cheat. Hidden-zone confidentiality is a security property the engine deliberately implements; exposing the seed undermines it.
Attack complexity / caveats
Exploitation requires reconstructing the exact number of prior RNG consumptions to align the keystream position (the stream offset itself is not serialized). This is non-trivial but tractable for a determined attacker who can observe the action/event history. No integrity or availability impact, and the attacker must be a participant in the game — which is why this is rated moderate rather than high.
Suggested remediation
- Strip
rng_seed from client-facing state in filter_state_for_viewer (e.g. zero it for all viewers) so it is never serialized to any client or guest.
- Keep the seed strictly server/host-internal. Persistence/resume paths already re-seed on restore, so removing it from client snapshots should be low-risk.
- Optionally, derive the gameplay keystream from a value that is never client-derivable, so even a leaked persisted seed cannot reproduce in-game randomness.
Summary
filter_state_for_viewerredacts hidden zones from theGameStatedelivered to each player, but it does not strip the deterministic RNG seed. Every filtered snapshot sent to a client (or P2P guest) still containsrng_seed. Because all engine randomness is drawn from a ChaCha20 stream seeded solely by that value, a participant who knows the seed can reproduce the game's entire random stream — future coin flips, die rolls, random discards/targets, and shuffles — and can potentially reconstruct hidden library order that the redaction is meant to protect.Affected code
crates/engine/src/types/game_state.rs—rng_seed: u64is a serialized field. The liverng: ChaCha20Rngstream is#[serde(skip)], but the seed that fully determines it is not.crates/engine/src/game/visibility.rs—filter_state_for_viewer(state, viewer)clones and redacts hands, libraries, face-down identities, choice payloads, and deck pools, but leavesrng_seedintact on the returned state.crates/phase-server/src/main.rsbroadcastsfilter_state_for_player(&raw_state, pid)as aStateUpdateto each seat.getViewerSnapshot(pid)→engine-wasmget_viewer_snapshot_jsrunsfilter_state_for_viewerand sends the result to each guest.Impact
All engine randomness draws deterministically from
state.rng, a ChaCha20 keystream seeded only byrng_seed. A participant who knows the seed and can infer how many RNG values have already been consumed can:visibility.rsotherwise enforces for library contents and order.This affects both multiplayer modes. It is an information-disclosure / competitive-integrity issue: an otherwise-legal but modified client can obtain hidden information and foresee randomness — i.e. cheat. Hidden-zone confidentiality is a security property the engine deliberately implements; exposing the seed undermines it.
Attack complexity / caveats
Exploitation requires reconstructing the exact number of prior RNG consumptions to align the keystream position (the stream offset itself is not serialized). This is non-trivial but tractable for a determined attacker who can observe the action/event history. No integrity or availability impact, and the attacker must be a participant in the game — which is why this is rated moderate rather than high.
Suggested remediation
rng_seedfrom client-facing state infilter_state_for_viewer(e.g. zero it for all viewers) so it is never serialized to any client or guest.