"""The veil's randomness — mixing physical entropy from the seeker's room with server-side secrets. Design premise: contact should be genuinely unpredictable, and the unpredictability should come from the physical world the seeker is standing in (their microphone's noise floor, the RF noise between stations, sensor jitter) rather than from a deterministic hash of their session. Before this module, `_summon` derived everything from `signature_from_anomalies()` — a SHA-1 of the anomaly pattern — which meant identical conditions always produced an identical spirit. That is the opposite of channeling. SECURITY — why client entropy is never used alone: The client is untrusted. A malicious seeker could send a fixed "entropy" string and re-roll until they hit a mythic entity, or one with traits they want, grinding the rarity table and the drop economy. So client contributions are only ever *mixed in*, never used as the seed. Every draw is HMAC-SHA256(server_secret_bytes, client_bytes || context), where the server bytes come from `secrets.token_bytes()` on every single call. Because a fresh cryptographically-secure server contribution is always present, the output is unpredictable and uniformly distributed *no matter what the client sends* — including all-zeros, a replayed value, or a value chosen adversarially. The client's contribution therefore can only ever *add* unpredictability from the room; it can never subtract any or steer the result. That is exactly the property we want: the physical world genuinely participates, but it cannot be forged into an advantage. This mirrors how real hardware RNGs are used: physical noise is a source that gets conditioned and mixed into a CSPRNG, never trusted raw. """ import hashlib import hmac import random import secrets # A client contribution is a SHA-256 hex digest (see frontend # lib/entropy.ts). Anything longer is truncated rather than rejected, so a # future client that sends a larger pool still works; anything that isn't # valid hex is discarded entirely rather than silently coerced. MAX_CONTRIBUTION_CHARS = 512 def normalize_contribution(raw: object) -> bytes: """Coerce whatever the client sent into bytes worth mixing. Returns empty bytes for anything unusable. Empty is completely safe — the server contribution alone still produces a strong draw — so this never needs to raise, and a malformed payload degrades to "no physical entropy this time" rather than failing the summon. """ if not isinstance(raw, str): return b"" text = raw.strip()[:MAX_CONTRIBUTION_CHARS] if not text: return b"" try: return bytes.fromhex(text) except ValueError: # Not hex — still mix it as UTF-8 rather than throwing it away. # It cannot hurt (see the security note above) and a client with a # different encoding still contributes real noise. return text.encode("utf-8", "ignore") def veil_seed(contribution: object = None, context: str = "") -> bytes: """One unpredictable 32-byte seed. `context` domain-separates independent draws made from the same contribution (e.g. "which entity" vs. "what traits"), so they can't be correlated with each other. """ client_bytes = normalize_contribution(contribution) # Fresh server entropy on every call — this is what makes the result # unpredictable regardless of client behaviour. server_bytes = secrets.token_bytes(32) return hmac.new( server_bytes, client_bytes + b"|" + context.encode("utf-8", "ignore"), hashlib.sha256, ).digest() def veil_random(contribution: object = None, context: str = "") -> random.Random: """A `random.Random` seeded from mixed physical + server entropy. Returned rather than a raw int so callers keep using the ordinary random API (`.random()`, `.choice()`, `.gauss()`) they already use with signature-seeded generators, making this a drop-in replacement at every existing call site. """ return random.Random(veil_seed(contribution, context)) def veil_float(contribution: object = None, context: str = "") -> float: """A single unpredictable float in [0, 1).""" return veil_random(contribution, context).random() def contribution_bits(raw: object) -> int: """How many bits of physical entropy the client actually supplied. Used only for display ("the air is thick") and telemetry — never to gate or weight the draw, since a client can lie about it freely. """ return len(normalize_contribution(raw)) * 8