v2.8 · RNG PRE-SUBMISSION PACKAGE v1.2
RAIN RNG · PRE-SUBMISSION PACKAGE v1.2 · DOCUMENT

RNG-DESCRIPTION — GLI-19 §3 TECHNICAL DESCRIPTION

Source sdk-v2/lab/RNG-DESCRIPTION.md · commit 96368332 · GLI CSR §2.2: RNG description & documentation · ← package index · raw on GitHub ↗

RAIN RNG v2.1 — RNG TECHNICAL DESCRIPTION (GLI-19 v3.0 Chapter 3 order and vocabulary)

Submission document per GLI Composite Submission Requirements v2.0 §2.2 "RNG Description and Documentation" and "Technical Source Code Description". Written for a test laboratory; every claim points to a file and line in this repository at the commit recorded in lab/FINGERPRINTS.json. Companion documents: lab/REPORT-STATISTICAL.md (evidence), lab/SCALING-INVENTORY.md (every reduction), lab/BUILD.md (software verification), docs/RNG.md (protocol whitepaper).

0. Identification of the RNG under test

Item Value
Name RAIN RNG v2.1 — draw layer drbg(r, gameId) over the two-party commit-reveal round value r
Packages @rain/rng-core 2.1.0 (generator, scaling), @rain/rng-session 2.1.0 (ceremony: seeding/mixing), @rain/rng-node 2.0.0 (operator service running party B, self-tests)
Source files on the certified path packages/rng-core/src/{sha256,keccak,rng,drbg,bytes}.ts (~700 lines, zero third-party dependencies), packages/rng-session/src/index.ts, packages/rng-node/src/{house,server,selftest,store/*}.ts; Python mirror packages/rng-py/rain_rng/*
Certified configuration mechanism "hmac-drbg" (default) and "chacha20" — two implementations of the draw layer, each evaluated separately (GLI-19 §4.5.1); scaling intBelow (rejection sampling), shuffle (Fisher–Yates), float (53-bit)
Out of scope (legacy, documented in SCALING-INVENTORY.md) outcomeMod = uint256(r) % N used by the pre-v2.1 on-chain quick games; the vendored game engines' own reductions
Positioning statement The draw layer is a deterministic pseudo-random function built from the SP 800-90A HMAC_DRBG (SHA-256) mechanism, instantiated from a protocol-derived 256-bit seed, or alternatively the RFC 8439 ChaCha20 block function, keyed per (round, game). It is not claimed to be an SP 800-90C RBG: the seed r is not an SP 800-90B entropy source but a cryptographic combination of two parties' CSPRNG outputs (§2) fixed by commitments before either party can act on it (§3).

1. RNG type (GLI-19 §3.1.1)

Software-based RNG (§3.1.1(a)). No hardware or mechanical RNG is used; §3.3.3 (dynamic monitoring of hardware RNGs) does not apply. All randomness derives from (i) the operating-system CSPRNG behind crypto.getRandomValues() at seeding time and (ii) recognised cryptographic algorithms (keccak-256, HMAC-SHA-256, ChaCha20) at generation time.

2. Entropy sources and seeding (GLI-19 §3.3.2(b); GLI-11 §3.3.2)

2.1 Secrets and where they come from

Secret Holder Generation Size Lifetime
houseSeed party B (house / RAIN node) randomBytes32() → globalThis.crypto.getRandomValues(32 bytes) (Node ≥ 19: OpenSSL RAND_bytes CSPRNG seeded from the OS; browsers: WebCrypto) — rng-core/src/rng.ts:197-201. Throws if no CSPRNG is available; there is no fallback to Math.random or time. 256 bits one session
chainSecret (house) party B same 256 bits one session (65 536 rounds default, rotated at 90 %)
playerSeed, chainSecret (player) party A (player / operator server) randomBytes32() in @rain/rng-session RainRngClient; in the on-chain casino, derived from a wallet signature keccak(personal_sign(...)) (docs/RNG.md §2.4) — not under house control in either case 256 bits each one session
sessionId contract / node keccak256 of (manager, player, operator, nonce, chainId) on-chain, or the node's termsId-derived id 256 bits one session

Seeding is never from time alone, never from a fixed value, and never from a single party (§3.3.2(b): "shall not be seeded from a time value alone… games will not have the same initial seed"). Two sessions can only share a seed if two independent CSPRNG draws of 512 bits collide (probability 2^-256 per pair) — GLI-11 §3.3.2 "will not synchronize, even when powered-on or booted simultaneously" is satisfied because no seed depends on boot state or clock.

2.2 Session seed (mixing of the two parties' entropy)

houseSeedCommit = keccak256(abi.encode(houseSeed))                      // published by B before A commits
houseChainRoot  = hashChain(chainSecret_B, L)[0]                        // c[L] = keccak(secret); c[i-1] = keccak(abi.encode(c[i]))
playerChainRoot = hashChain(chainSecret_A, L)[0]
sessionSeed     = keccak256(abi.encode(houseSeed, playerSeed, sessionId))

Order of events (rng-session/src/index.ts RainRngHouse.terms/open, RainRngClient.open; on-chain ChannelManagerAA.openChannel): B publishes houseSeedCommit + houseChainRoot → A, having seen them, publishes playerSeed + playerChainRoot and signs a statement binding both → B reveals houseSeed; A (and the contract) checks keccak256(abi.encode(houseSeed)) == houseSeedCommit. Result: sessionSeed has ≥ 256 bits of entropy unknown to either party alone at the moment that party committed.

2.3 Round value r_k (the generator's seed) — re-seeded every round

r_k = keccak256(abi.encode(pRev_k, hRev_k, sessionSeed, sessionId, uint256 k))      // rng-core/src/rng.ts outcome()

where pRev_k = c_A[k], hRev_k = c_B[k] are the k-th elements of the two hash chains (keccak256(abi.encode(c[k])) == c[k-1], verified by both sides and by the contract in disputes). r_k is a fresh 256-bit value per round; the draw layer is instantiated from r_k and never carries state across rounds.

3. Cryptographic RNG attacks (GLI-19 §3.3.1, §3.3.2 (a)(b)(c)) — explicit answers

Stand-alone one-page version of the §3.3.2(c) answer, with the attacker-with-snapshot table: STATE-MODIFICATION-3.3.2c.md.

§3.3.1 Cryptographically strong. Every step is a recognised primitive with a published reference and known-answer tests in the repository: keccak-256 (FIPS 202; Ethereum test vectors rng-core/test/vectors.json), SHA-256 / HMAC-SHA-256 (FIPS 180-4 / RFC 4231 vectors), HMAC_DRBG (SP 800-90A; NIST CAVP no-reseed vectors, 32 cases, rng-core/test/cavp-hmac-drbg-sha256.json), ChaCha20 (RFC 8439 §2.3.2, §2.4.2, A.1 vectors). An attacker with the full source code (this repository is public) gains nothing without the unrevealed chain elements.

(a) Direct cryptanalytic attack — past outputs ⇏ future outputs. Within a round, draw(i) = HMAC_DRBG.Generate under key HMAC(r_k, "RAIN-RNG-v2.1|"+gameId) (or ChaCha20 block i); predicting draw(j) from draw(i) requires inverting HMAC-SHA-256 / ChaCha20. Across rounds, r_{k+1} requires hRev_{k+1} (the keccak pre-image of hRev_k, held only by B) and pRev_{k+1} (held only by A). Knowing every past r_1..r_k, every past reveal and sessionSeed gives no information about r_{k+1} under the pre-image resistance of keccak-256 (Appendix A, Claim 1).

(b) Known-input attack — state after seeding is not determinable. The "state" is the pair of unrevealed chain tails. They are 256-bit CSPRNG outputs (§2.1); neither is derived from time, counters or public data. sessionSeed and sessionId are public, but they are inputs to the hash alongside the two secrets, so knowing them does not reduce the search space below 2^256. Seeding does not "compromise the cryptographic strength": no seed is truncated (contrast the deprecated seed32Compat, §9).

(c) State-compromise extension attack — periodic modification with external entropy. GLI-19 asks that the RNG "periodically modify its state through the use of external entropy, limiting the effective duration of any potential exploit". RAIN's answer, in three layers:

  1. Every round injects entropy external to the house. pRev_k is the player's secret, revealed only after the player has committed the bet and is unknown to the house until then; from the house's perspective it is fresh external entropy on every round — not periodically, but continuously. Symmetrically hRev_k is external to the player. A compromise of B's whole chain (all hRev) does not let the attacker bias or predict r_k: for any fixed hRev_k, r_k is a uniform function of pRev_k (Appendix A, Claim 2). A compromise of both parties' chains is a compromise of both principals, outside any RNG's threat model (it is the equivalent of stealing the seed of a conventional RNG and the game server).
  2. Chain rotation. Sessions are bounded: default chain length 65 536, and at 90 % consumption both parties generate fresh houseSeed/chainSecret/playerSeed/chainSecret from the CSPRNG and run a new commit-reveal (localRotate, RainRngHouse.rotate, rng-session/src/index.ts:117,242); on-chain channels use 4 096-element chains and are re-opened. So even a hypothetical compromise of one party's chain expires within one session.
  3. Re-seeding of the DRBG every round. The draw layer's DRBG instance lives for one round only (drbg(r_k, …) is a pure function); there is no long-lived DRBG state to extend.

Wording for the certificate: "The RNG state is re-keyed on every draw round from a 256-bit value that depends on fresh secret input from each of two independent parties; the secret material of each party is replaced from the OS CSPRNG at least every 65 536 rounds."

4. Mixing and the generator (GLI-19 §3.2.1 source-code review scope)

key       = HMAC-SHA-256(key = r_k, msg = utf8("RAIN-RNG-v2.1|" + gameId))                     // drbg.ts deriveDrawKey — domain separation per game
hmac-drbg : draw(i) = HMAC_DRBG_SHA-256.Instantiate(entropy_input = key, nonce = "", pers = "")
                        .Generate(32 bytes, additional_input = uint64be(i))                        // SP 800-90A §10.1.2; fresh instance per draw (clone of the instantiated state)
chacha20  : draw(i) = ChaCha20_block(key, counter = i, nonce = 0^96)[0..32)                        // RFC 8439 §2.3
uint32(i) = draw(i)[0..4) big-endian        uint53(i) = first 53 bits        float(i) = uint53(i) / 2^53
  • draw(i) is a pure function of (r, gameId, i, mechanism) — index-addressable, replayable by player, operator and lab in TypeScript and Python (drbg-vectors.json: 12 streams × 7 indices shared across languages).
  • HMAC_DRBG parameters: security strength 256; entropy input 256 bits (= key, full-entropy derived from r_k); nonce empty (permitted when the entropy input carries ≥ security_strength bits — SP 800-90A §8.6.7 alternative; we state this choice explicitly); personalization empty; additional_input = 8-byte big-endian draw index; max_number_of_bits_per_request enforced at 2^19 (drbg.ts:48); reseed never invoked (each round is a new instantiation, so reseed_counter never exceeds 2).
  • Undisclosed switches (§3.2.1): none. The only parameter is opts.mechanism ∈ {"hmac-drbg","chacha20"}, chosen by the integrator at build time and recorded in the fairness panel (stream.mechanism). See §9 for every environment variable that exists in the node.

5. Range and period (GLI-19 §3.2.5)

  • Range of the generator: draw(i) ∈ {0,1}^256; uint32(i) ∈ [0, 2^32) with every value equally likely; float(i) takes each multiple of 2^-53 in [0,1) with equal probability.
  • Period: there is no cycling state, so "period" in the LCG/Mersenne-Twister sense does not exist. Within a round the index space is 2^53 (hmac-drbg; safe-integer limit of uint64be) or 2^32 (chacha20) independent PRF outputs — a game consuming 10^6 draws per round would exhaust neither. Across rounds the seed space is 2^256 fresh values. Equivalent statement for the report: the output sequence cannot repeat unless keccak-256 or HMAC-SHA-256 collides (probability < 2^-128 over any practical number of rounds).
  • Availability of every outcome on every draw: intBelow(i, n) can return every value in [0, n) on every call with probability exactly 1/n regardless of history (Appendix B.1); nothing is excluded by design or by previous selections (§3.2.4(a)) — except the intended without-replacement behaviour of shuffle.

6. Scaling, mapping and shuffling (GLI-19 §3.2.3; GLI-11 §3.2.3 "entirely free of bias"; NJ 13:69E-1.28G(b)6; UKGC RTS 7A)

Stand-alone one-page proof (certified intBelow exactly uniform; legacy % N bias bound with numeric examples): SCALING-PROOF.md.

Method Code Bias Proof
intBelow(i, n), 1 ≤ n ≤ 2^32 limit = 2^32 − (2^32 mod n); take uint32(i), uint32(i+1), … until v < limit; return v mod n (drbg.ts:163-169) zero (rejection sampling; "the discard of RNG values is permissible", §3.2.3(a)) App. B.1
shuffle(i, arr) Fisher–Yates/Durstenfeld with intBelow(j+1) for j = len−1 … 1 (drbg.ts:170-174) zero — every permutation has probability 1/len! App. B.1
float(i) uint53 / 2^53 zero on the 2^53-point grid trivial
cursor() sequential index wrapper used by game engines; intBelow advances by the number of words consumed (rejections included), so replay is deterministic — —
legacy outcomeMod = uint256(r) % N rng.ts:117 < N / 2^256 (< 2^-192 for N ≤ 2^64) App. B.2 — not in the certified path; kept for byte-identity with the deployed Solidity verifiers

Expected rejection rate of intBelow: (2^32 mod n)/2^32 < n/2^32 — for n = 100 it is 2.3·10^-8, for n = 2^31+1 it is ≈ 0.5 (worst case, ≈ 2 words per value). A whole 52-card shuffle consumes 51 words in expectation plus ≈ 10^-6 rejections.

Game-level use. The certified layer is consumed by a game engine through drbg(r_k, gameId).cursor(): intBelow(N) for a dice/roulette/reel-stop, shuffle(deck) for cards, float() for continuous mechanics. lab/tools/collect-outcomes.mjs is the GLI "final outcome collection tool" and exercises exactly these calls for dice (N=100), roulette (37), coin (2), five-reel slot (20 each, interplay-tested), 52-card shuffle; results in lab/REPORT-STATISTICAL.md §5. Game engines must consume the cursor in order and may not discard values based on outcome (§4.5.2(b), RTS 7B) — the cursor exposes index so an engine's trace can prove it (slot-engine-v2 traces record rngDrawStart/End per step).

7. Independence (GLI-19 §3.2.4)

(a) Source-code property: drbg() has no memory of previous selections; intBelowEx looks only at the current word(s); shuffle is the intended without-replacement case. Nothing conditions on past outcomes (there is no RAIN_CRASH_AT-style outcome switch in the generator; that variable only crashes the node process in tests and is refused in production, server.ts:38). (b) Statistical evidence: serial correlation lags 1–8, runs above/below median, blocked-χ² uniformity, interplay 2-D χ² between reels, adjacency test on shuffled decks — all in REPORT-STATISTICAL.md §5, at 99 % confidence, on ≥ 10^8 outcomes per small-N game; Dieharder / NIST STS / TestU01 SmallCrush on the raw words (§3–4 there). (c) Between players/sessions: different sessionId, sessionSeed and chains ⇒ unrelated hash inputs. Between games in one round: gameId domain separation in the key derivation.

8. Abort handling, replay and "no re-draw" (GLI-19 §4.5.2; RTS 7B; AGCO 4.25)

  • Decision before reveal. Party A commits (decision, pRev_k) and B persists the commit durably before revealing hRev_k (RainRngHouse.reveal requires the persist hook; rng-node WAL/Postgres — house.ts "persist-before-reveal", crash test rng-node/test/crash.test.mjs). Bets are therefore committed before the outcome exists (AGCO 4.25).
  • One reveal per k, ever. A duplicate commit for the same k with the same (decision, pRev) is answered with the same hRev (idempotent replay, house.ts:85-104); a different (decision, pRev) for an already-committed k is refused with HTTP 409 — no party can obtain a second outcome for the same round.
  • Client abort. RainRngClient.abort() (rng-session/src/index.ts:328) abandons in-flight rounds whose pRev are already public; those k are burned (the next commit uses committed+1), never re-used, and B's persisted commit for them remains servable via GET /v2/reveal/:sid/:k — the aborted round is logged and visible to both parties and to /verify. An abort cannot un-place a bet in the escrow protocol (the dual-signed BET state stands), so abort gives no selection advantage.
  • House stall. Refusing to reveal is a liveness fault, not a safety one: the outcome for k is fixed by the chain and the persisted commit; the escrow protocol converts stalls into HouseFault compensation (docs/RNG.md §3). rng-node surfaces persisted-but-unrevealed commits at startup (server.ts "persisted commit(s) without a reveal record").
  • Channel characteristics (§4.5.2(f)): no outcome depends on timing, bandwidth or message arrival order; k is a strict cursor.

9. State exposure, switches and deprecated paths (GLI-19 §3.2.1 "undisclosed switches")

Item Effect on randomness Production status
opts.mechanism ("hmac-drbg"|"chacha20") Selects the certified mechanism; recorded per stream Both certified; disclosed
seed32Compat(r) Deprecated 32-bit truncation for legacy engines; warns once per process Not imported by any production engine (grep); to be removed in 3.0
RAIN_CRASH_AT (env) Crashes the node process at a chosen point (test) Refused when NODE_ENV=production (server.ts:38)
RAIN_KAT_FAULT (env) Makes one KAT compare against a wrong digest to test the error state Refused when NODE_ENV=production (server.ts)
RAIN_KAT_FAIL_EXIT=0 (env) On KAT failure, start in inhibited mode (503 everywhere) instead of exiting Disclosed; either behaviour inhibits output
RAIN_FINGERPRINTS, RAIN_FINGERPRINTS_STRICT Artifact self-verification manifest path / strictness Required in a certified deployment
all other RAIN_* env (rain-rng-node --help) Networking, storage, key source, anchoring, attribution, health thresholds No influence on r_k or draw(i)

Secret state at rest: B's chainSecret/houseSeed per session are stored in the node's WAL or Postgres in plaintext (docs/HOUSE-NODE.md §4); disk/TDE encryption and access control are deployment controls (lab/process/KEY-MANAGEMENT.md). Their exposure does not enable bias (§3(c)) but does remove the "two independent parties" argument for a same-operator deployment — hence anchoring (RngAnchor.sol) or a third-party node is recommended in that configuration.

10. Runtime self-tests (SP 800-90A §11.3; GLI-19 §2.3.2) — packages/rng-node/src/selftest.ts

  • Before first use, then every 24 h and on demand (GET /selftest), the node runs 11 known-answer tests: SHA-256 (FIPS 180-4), HMAC-SHA-256 (RFC 4231 #2), 4 NIST CAVP HMAC_DRBG SHA-256 no-reseed cases (pers ∈ {0,256} × additional_input ∈ {0,256}), ChaCha20 (RFC 8439 §2.3.2 and A.1), keccak-256, and the RAIN stream vectors (key, draw(0), draw(1), uint32(2), intBelow(3,37), shuffle) for both mechanisms.
  • On failure: the node refuses to start (default) or, with RAIN_KAT_FAIL_EXIT=0, starts in an error state in which POST /v2/terms, /v2/open, /v2/reveal return 503 selftest_failed — output inhibited; /healthz returns 503 with kat: "fail" and selfTest.failed[]. Bits generated during KATs are never output (the KAT code path never touches the store or the transport).
  • Artifact self-verification: SHA-256 of every dist file of rng-core, rng-session, rng-node compared with lab/FINGERPRINTS.json at startup and every 24 h; /healthz.selfTest.fingerprints ∈ {ok, fail, skipped}; RAIN_FINGERPRINTS_STRICT=1 makes a mismatch inhibit output.
  • Tests: packages/rng-node/test/selftest.test.mjs (pass path, fault-injected error state, fingerprint ok/fail/strict).

11. Multiple implementations (GLI-19 §4.5.1)

TypeScript (@rain/rng-core) and Python (rain_rng) share drbg-vectors.json; both mechanisms are separate implementations of the draw layer and are evaluated separately in REPORT-STATISTICAL.md. The Solidity verifiers implement only the legacy outcome()/% N path (npm run test:live checks byte-identity against Arbitrum One).


Appendix A — Independence and unpredictability (one page)

Model. Let H = keccak-256 be modelled as a random oracle (the standard assumption for hash-based commit-reveal; the weaker pre-image/second-pre-image resistance suffices for Claims 1–2 with the obvious hybrid). Let chain A be a_L = H(s_A), a_{i-1} = H(enc(a_i)), and chain B likewise; roots a_0, b_0 are published before the session; r_k = H(enc(a_k, b_k, S, id, k)).

Claim 1 (unpredictability across rounds, §3.3.2(a)). An adversary knowing S, id, a_0..a_k, b_0..b_k, r_1..r_k and holding neither s_A nor s_B cannot compute r_{k+1} with advantage over guessing. Proof. r_{k+1} requires the oracle query enc(a_{k+1}, b_{k+1}, S, id, k+1); a_{k+1} is a pre-image of a_k, b_{k+1} of b_k. Finding either is a pre-image search over 256-bit strings — expected 2^255 queries. Without both, the query is unknown and r_{k+1} is a fresh uniform oracle value. ∎

Claim 2 (neither party can bias, §3.3.2(c), AGCO 4.26 "impervious to the player or the operator"). Fix any strategy of party A choosing s_A (even adaptively, seeing b_0, S). Conditioned on A's view at the moment A reveals a_k, b_k is a uniformly random 256-bit value (it is the unique pre-image chain of the committed b_0 derived from B's CSPRNG secret, unknown to A). Hence the oracle input contains a 256-bit component uniform and independent of A's choice, so r_k is uniform for every choice of a_k. The same holds with the roles swapped. Consequence: a player using low-entropy or repeated secrets (the "adversarial" runs in REPORT-STATISTICAL.md §5.3 — zero, fixed, grind) cannot alter the distribution of outcomes; a player can only add unpredictability against the house, never subtract it. ∎

Claim 3 (no information from one round about another, §3.2.4). r_k and r_j (k ≠ j) are oracle outputs on distinct inputs (they differ in k), hence independent uniform values. Within a round, draw(i) and draw(j) are PRF outputs under the same key on distinct inputs (additional_input/counter) — independent up to PRF distinguishing advantage (HMAC-SHA-256: ≤ q²/2^256; ChaCha20: PRF security of the block function). ∎

Claim 4 (commit binding). A cannot change a_k after seeing b_k: a_k must satisfy H(enc(a_k)) = a_{k-1}, fixed earlier; finding a second pre-image costs 2^256. B likewise; and B cannot swap houseSeed because A's open signature covers houseSeedCommit. ∎

What is not claimed. Fairness of RTP/pay tables (game math, out of RNG scope); liveness (a party may stall — handled by the escrow protocol); resistance to a compromise of both parties' secrets.

Appendix B — Scaling proofs

B.1 Rejection sampling is exactly uniform. Let U be uniform on [0, 2^32), m = ⌊2^32 / n⌋, limit = m·n. intBelow outputs V = U mod n conditioned on U < limit. For each v ∈ [0,n) the set {u < limit : u mod n = v} has exactly m elements, so P(V = v) = m / limit = 1/n. Rejections are independent trials with P(reject) = (2^32 − limit)/2^32 < n/2^32 ≤ 1/2 (for n ≤ 2^32; worst case n = 2^31+1 → 0.5); the number of words consumed is geometric and independent of the value returned. Fisher–Yates: at step j the element for position j is chosen uniformly among the j+1 remaining, so each permutation has probability ∏_{j=1}^{len−1} 1/(j+1) = 1/len!. ∎

B.2 Modulo bias bound for the legacy path. For R uniform on [0, 2^256) and V = R mod N, values v < 2^256 mod N have probability (⌊2^256/N⌋+1)/2^256, the rest ⌊2^256/N⌋/2^256; the maximum deviation from 1/N is < 1/2^256 absolutely and < N/2^256 relatively. For N = 100: relative bias < 7.9·10^-77. To detect a relative bias δ in a cell of probability p at 99 % confidence one needs ≈ (2.58)²·(1−p)/(p·δ²) samples — here ≈ 10^155 draws. No statistical test at any feasible sample size can distinguish the legacy reduction from uniform; the certified path nevertheless uses rejection sampling so the standard's wording ("entirely free of bias") is met literally. For the 64-bit fields in step-games-engine (§3.3 of the inventory) the bound is < 10^6/2^64 ≈ 5.4·10^-14, detection ≈ 10^28 draws. For the 32-bit nextInt of Slot Engine V2 the bound is n/2^32 (≤ 2.3·10^-5 for n = 10^5) — the one case where migration to intBelow is recommended. ∎

← Back to the package index · Rendered 2026-09-14 09:34 UTC from the repository copy; the markdown in the zip / repo is the document of record.