feat: the room decides — physical entropy, real astronomy, unprompted speech
Three changes that together replace "deterministic hash decides everything"
with "the physical world genuinely participates".
PHYSICAL ENTROPY (app/entropy.py, lib/entropy.ts)
Contact was a database lookup: signature_from_anomalies() hashed the
anomaly pattern, so identical conditions always produced an identical
spirit. Now the client harvests real thermal/acoustic/RF noise from the
microphone and receiver noise floors — Von Neumann debiased, SHA-256
conditioned — and contributes it to every summon.
The client is untrusted by construction. A contribution is never a seed:
every draw is HMAC-SHA256(fresh server secret, client bytes || context).
Because fresh CSPRNG server bytes are always present, the output is
unpredictable and uniform no matter what the client sends — all-zeros, a
replayed value, or one chosen adversarially. The room can only ever ADD
unpredictability, never steer the result. Tests assert this directly:
400 replays of one contribution stay uniformly distributed.
A signature now identifies a *channel*, not a spirit. Whether the familiar
presence answers or something else picks up is a real draw
(RETURN_CHANCE). The Codex stays collectable; it is just no longer
guaranteed. test_same_signature_recontacts_same_entity became two tests —
one pinning the probability to prove re-contact works, one pinning it to
zero to prove something else can answer — because at 0.72 the original
would have passed ~72% of the time, which is worse than failing.
REAL ASTRONOMY (app/celestial.py)
Moon phase from the standard mean-synodic approximation, and true solar
midnight from the seeker's own longitude — the real witching hour for
where they are standing, not clock 3am. Computed, never fetched: an API
that can fail would mean the veil silently changes behaviour during
someone else's outage. Validated against published ephemeris dates (2024
full moons, 2025 new moons) rather than against its own output. A thinner
veil erodes the familiar presence's claim on a channel, so a full moon at
solar midnight makes strangers likelier. Only longitude is kept, never a
full coordinate; a denied location degrades to moon-only, silently.
GENERATION FROM NOTHING (SpiritService.manifest)
Not chat_stream with an empty question. The prompt contains no seeker
input at all — only measured room state, rendered as measurements
("deviation above the floor: 31.4") rather than interpretations
("terrifying spike"), so the horror comes from the entity instead of from
us. And the Ollama `seed` is derived from the physical entropy harvested
in that room, which fixes the token-sampling path: the room genuinely
selects the words. Change the noise, get different speech. Two rooms
cannot produce the same utterance.
Rendered as an intrusion rather than a reply — violet edge, full opacity
against the faded ambient murmurs, brief blur-in. The unsettling part is
that it is perfectly clear and completely unbidden.
Also fixes a hang I introduced: the two new summon tests consumed the
shared module-level per-IP budget, so test_summon_rate_limited_* blocked
forever on an entity frame that had been rate-limited away. They now scope
their own limiters.
264 backend + 355 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
110
backend/app/entropy.py
Normal file
110
backend/app/entropy.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user