diff --git a/backend/app/ws.py b/backend/app/ws.py index 4eda54c..90f49d9 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -163,6 +163,17 @@ class SeanceState: passage_layer: str = passage.FIRST_LAYER passage_lied: bool = False passage_crossed: bool = False + # Which beats have ALREADY paid out for the current entity. Without + # this, essence is unbounded: `passage_start` rewinds the rite to + # `listen` at any time (and the UI offers exactly that button after a + # resisted release), so a client could walk listen/name/unbind/open for + # +28 essence, restart, and repeat forever off a single summon — the + # limiter caps the rate, not the total. A volatility collapse rewinds it + # the same way. So each layer pays the FIRST time it opens for a given + # presence and never again; re-walking it still reveals, still costs + # nothing already banked, but mints no new essence. Cleared only on a + # fresh summon, never by `passage_start` — that is the whole point. + passage_paid: set[str] = field(default_factory=set) # Per-session RNG for `tell` frames — unseeded (a session's tells should # vary run to run), but persistent across calls so the draw sequence # isn't restarted on every single message. @@ -572,7 +583,7 @@ async def _summon_locked(state: SeanceState) -> None: state.ritual_steps = 0 state.ritual_completed = False state.ritual_success = False - _reset_passage(state) + _reset_passage(state, new_entity=True) await state.send_queue.put( {"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new} ) @@ -915,6 +926,11 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None: return traits = state.entity.get("traits", {}) + # Pinned before any await, for the same reason as in the Passage handler + # below: the device-telemetry path can re-summon on this SeanceState + # mid-handler, and re-reading `state.entity` after the DB round-trip would + # mark whichever entity arrived last as at_peace instead of the judged one. + entity_id = state.entity["id"] outcome = judgment.judge_verdict( verdict, traits, @@ -941,7 +957,7 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None: credit_essence(user, outcome.essence_delta) if outcome.consequence == "crossed_over": - entity_row = await db.get(Entity, uuid.UUID(state.entity["id"])) + entity_row = await db.get(Entity, uuid.UUID(entity_id)) if entity_row is not None: entity_row.at_peace = True @@ -958,6 +974,15 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None: ) await db.commit() + if outcome.consequence == "crossed_over": + # Latch the same flag the Passage sets. The guard above already stops + # passage → cross_over double-paying; without this the REVERSE ran + # free: judge cross_over (+essence, +favor, entity at_peace), then walk + # the Passage to `release` on the same spirit and be paid the crossing + # a second time, favor included. One spirit, one crossing, whichever + # road got there first. + state.passage_crossed = True + await state.send_queue.put( { "type": "judgment_result", @@ -982,13 +1007,25 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None: # uses), the `at_peace` write, and the frames. -def _reset_passage(state: SeanceState) -> None: +def _reset_passage(state: SeanceState, *, new_entity: bool = False) -> None: state.passage_layer = passage.FIRST_LAYER state.passage_lied = False state.passage_crossed = False + if new_entity: + # Only a genuinely new presence re-opens the purse. A `passage_start` + # rewind must not, or the rite becomes an essence faucet (see + # `passage_paid` on SeanceState). + state.passage_paid = set() -def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict: +def _passage_frame( + state: SeanceState, outcome: passage.PassageOutcome, essence: int +) -> dict: + # `essence` is what was ACTUALLY credited, which is the layer's value only + # the first time it opens for this presence (see `passage_paid`). The + # client sums this field into its running total, so sending + # `outcome.essence` on a replay would show the seeker essence they did not + # receive. reveal = outcome.reveal return { "type": "passage_result", @@ -1001,7 +1038,7 @@ def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict: if reveal is not None else None ), - "essence": outcome.essence, + "essence": essence, "at_peace": outcome.at_peace, "next_layer": outcome.next_layer, # Deliberately NOT `outcome.lied` — that's this layer's own lie, and @@ -1052,6 +1089,12 @@ async def _handle_passage_layer(state: SeanceState) -> None: return traits = state.entity.get("traits", {}) + # Pinned HERE, before any await. `state.entity` can be replaced underneath + # this coroutine mid-handler: the HTTP device-telemetry path drives the + # same SeanceState and can summon (see `summon_lock`). Reading the id + # after the DB round-trip below would mark the WRONG entity at peace — + # the one that just arrived, not the one that actually crossed. + entity_id = state.entity["id"] layer = state.passage_layer # Both twists draw from the room's real physical noise, with distinct # contexts so a volatility collapse and a deceptive reveal can never be @@ -1074,8 +1117,15 @@ async def _handle_passage_layer(state: SeanceState) -> None: if outcome.at_peace: state.passage_crossed = True + # A layer pays once per presence. Replaying it — via `passage_start`, via + # a collapse rewind, or via a hand-rolled client spamming the frame — + # reveals the same thing again for free rather than minting essence again. + award = outcome.essence if layer not in state.passage_paid else 0 + if outcome.result in ("opened", "crossed"): + state.passage_paid.add(layer) + item = None - if outcome.essence or outcome.at_peace: + if award or outcome.at_peace: async with session_maker() as db: # Locked — same read-modify-write race as every other essence # credit in this file (see _reward_summon). @@ -1083,8 +1133,8 @@ async def _handle_passage_layer(state: SeanceState) -> None: select(User).where(User.id == state.user_id).with_for_update() ) if user is not None: - if outcome.essence: - credit_essence(user, outcome.essence) + if award: + credit_essence(user, award) if outcome.at_peace: # Completing the Passage is the most compassionate act # in the game, exactly as a correct cross_over verdict @@ -1093,7 +1143,7 @@ async def _handle_passage_layer(state: SeanceState) -> None: user.favor + judgment.FAVOR_CORRECT_CROSS_OVER ) if outcome.at_peace: - entity_row = await db.get(Entity, uuid.UUID(state.entity["id"])) + entity_row = await db.get(Entity, uuid.UUID(entity_id)) if entity_row is not None: entity_row.at_peace = True item = roll_item_drop("judgment") @@ -1108,7 +1158,7 @@ async def _handle_passage_layer(state: SeanceState) -> None: ) await db.commit() - await state.send_queue.put(_passage_frame(state, outcome)) + await state.send_queue.put(_passage_frame(state, outcome, award)) if item is not None: await state.send_queue.put({"type": "item_drop", "item": item}) diff --git a/backend/tests/test_ws_passage.py b/backend/tests/test_ws_passage.py new file mode 100644 index 0000000..63a1ded --- /dev/null +++ b/backend/tests/test_ws_passage.py @@ -0,0 +1,294 @@ +"""WS integration tests for the Passage handlers in app.ws. + +Why this file exists: `tests/test_passage.py` covers app/passage.py, which is +pure — it decides outcomes and returns them. It cannot see the handler that +banks the essence those outcomes describe, and that handler is where an +unbounded essence faucet lived undetected through a 400-test suite: nothing +exercised `passage_start` / `passage_layer` over a real connection. + +The faucet: `passage_start` rewinds the rite to `listen` at any time, and the +UI offers exactly that button after a resisted release (PassagePanel.tsx's +"walk it again"). Every replayed layer re-credited its essence, so one summon +funded an endless loop. The rate limiter caps how FAST that runs, never how +much it totals. + +These tests are written against the exploit path, not against the +implementation: they drive the same frames a browser sends and then read the +ledger straight out of the database. +""" + +import uuid + +import pytest + +import app.ws +from app.entities import fallback_profile +from app.inventory import SUMMON_ESSENCE_TRICKLE +from app.models.user import User +from app.passage import LAYERS, LAYER_ESSENCE +from app.rate_limit import RateLimiter + + +class FakeSpiritService: + async def mint_profile( + self, signature, channel, anomalies, language="en", entropy=None, sky=None + ): + return fallback_profile(signature) + + async def fragment(self, source, anomaly, language="en"): + return "listen" + + async def wire_whisper(self, telemetry, language="en"): + return "the wire hums" + + def chat_stream(self, entity, question, history, language="en"): + async def gen(): + yield "here." + + return gen() + + def ambient_ready(self): + return False + + +async def _fake_synth(text, voice, profile, instability=0.0): + return b"RIFFfake wav bytes" + + +@pytest.fixture(autouse=True) +def _fake_spirits(monkeypatch): + monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService()) + monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth) + # Module-level limiters are shared singletons that accumulate real hit + # counts across the whole session (precedent: test_ws_ritual_judgment.py). + # The passage limiter matters most here — the exploit these tests replay + # is deliberately high-volume, and the real 20/60s cap would mask it + # behind a rate limit rather than letting us observe the actual total. + for name in ( + "summon_limiter", + "summon_ip_limiter", + "question_limiter", + "question_ip_limiter", + "fragment_limiter", + "fragment_ip_limiter", + "ritual_limiter", + "ritual_ip_limiter", + "judgment_limiter", + "judgment_ip_limiter", + "passage_limiter", + "passage_ip_limiter", + ): + monkeypatch.setattr(app.ws, name, RateLimiter(max_requests=10_000, window_seconds=60)) + + +def _no_twists(monkeypatch): + """Pin both Passage draws to "no twist", leaving every other draw real. + + A PassageDraw of 1.0 never fires, since both twists trigger on + `draw < chance`. Only the `passage:` contexts are pinned — blanketing + `veil_float` would also steer the summon and manifest draws, and these + tests are about the ledger, not about the room's noise. + """ + real = app.ws.veil_float + + def selective(entropy, context, *args, **kwargs): + if context.startswith("passage:"): + return 1.0 + return real(entropy, context, *args, **kwargs) + + monkeypatch.setattr(app.ws, "veil_float", selective) + + +# The exploit never reaches `release`. Crossing latches `passage_crossed`, +# which correctly blocks replay — the faucet ran on the four beats BEFORE +# release, rewound by the "walk it again" button after a resisted release. +BEATS_BEFORE_RELEASE = len(LAYERS) - 1 + + +def _read_until(ws, msg_type, max_frames=80, **match): + for _ in range(max_frames): + frame = ws.receive_json() + if frame.get("type") != msg_type: + continue + if all(frame.get(key) == value for key, value in match.items()): + return frame + raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}") + + +def _login(sync_client, username): + sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"}) + sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"}) + return sync_client.cookies.get("qm_session") + + +def _ws_connect(sync_client, token): + return sync_client.websocket_connect( + "/ws/session", headers={"cookie": f"qm_session={token}"} + ) + + +def _summon(ws): + ws.send_json({"type": "summon"}) + frame = _read_until(ws, "entity") + _read_until(ws, "utterance", kind="greeting") + return frame + + +def _settle(ws): + """Force a full round trip so any post-frame DB write has landed. + + The essence credit runs before the frame is queued, but the sender task + drains that queue concurrently with the handler's remaining awaits. The + connection's message loop is sequential, so a pong is a hard guarantee + that the previous handler returned — not a poll-and-hope. + """ + ws.send_json({"type": "ping"}) + _read_until(ws, "pong") + + +def _walk(ws, beats): + """Send `beats` passage_layer frames, returning the result frames.""" + frames = [] + for _ in range(beats): + ws.send_json({"type": "passage_layer"}) + frames.append(_read_until(ws, "passage_result")) + _settle(ws) + return frames + + +async def _essence(db_session, user_id): + db_session.expire_all() + user = await db_session.get(User, user_id) + return user.essence + + +def _user_id(sync_client, token): + return uuid.UUID( + sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"] + ) + + +# --- the faucet ------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_replaying_the_rite_never_mints_essence_twice( + sync_client, db_session, monkeypatch +): + """The exploit, executed literally. + + Walk every layer, hit `passage_start` to rewind, walk them all again — + ten times over. The seeker must finish with exactly one rite's worth of + essence, because there was only ever one spirit. + + Before the fix this credited the full ladder on every pass; with the + ladder summing to 53, ten passes paid 530 instead of 53. + """ + # No collapses and no crossing: this test is about the ledger, and a + # random rewind or an early `crossed` would make the totals depend on + # the draw rather than on the replay guard. + _no_twists(monkeypatch) + + token = _login(sync_client, "faucet-walker") + user_id = _user_id(sync_client, token) + + with _ws_connect(sync_client, token) as ws: + _read_until(ws, "session") + _summon(ws) + baseline = await _essence(db_session, user_id) + + ws.send_json({"type": "passage_start"}) + _settle(ws) + _walk(ws, BEATS_BEFORE_RELEASE) + after_one_rite = await _essence(db_session, user_id) + + for _ in range(10): + ws.send_json({"type": "passage_start"}) + _settle(ws) + _walk(ws, BEATS_BEFORE_RELEASE) + + after_ten_replays = await _essence(db_session, user_id) + + earned = after_one_rite - baseline + assert earned > 0, "the rite paid nothing at all — the test proves nothing" + assert earned <= sum(LAYER_ESSENCE.values()) + assert after_ten_replays == after_one_rite, ( + f"replaying the rite minted {after_ten_replays - after_one_rite} extra essence; " + "the passage is an unbounded faucet again" + ) + + +@pytest.mark.asyncio +async def test_the_reported_essence_matches_what_was_actually_credited( + sync_client, db_session, monkeypatch +): + """The UI sums the frame's `essence` field into its running total. + + If a replayed layer reports the layer's face value while the ledger + credits nothing, the seeker watches a total climb that their account + never receives — the most corrosive kind of bug in a game about trust. + """ + _no_twists(monkeypatch) + + token = _login(sync_client, "honest-ledger") + user_id = _user_id(sync_client, token) + + with _ws_connect(sync_client, token) as ws: + _read_until(ws, "session") + _summon(ws) + baseline = await _essence(db_session, user_id) + + ws.send_json({"type": "passage_start"}) + _settle(ws) + first = _walk(ws, BEATS_BEFORE_RELEASE) + + ws.send_json({"type": "passage_start"}) + _settle(ws) + second = _walk(ws, BEATS_BEFORE_RELEASE) + + final = await _essence(db_session, user_id) + + assert sum(f["essence"] for f in first) == final - baseline + assert all(f["essence"] == 0 for f in second), ( + "a replayed layer advertised essence it did not pay" + ) + + +@pytest.mark.asyncio +async def test_a_genuinely_new_presence_reopens_the_purse( + sync_client, db_session, monkeypatch +): + """The guard must not overshoot: each spirit is worth its own rite. + + A `passage_paid` set that survived a summon would silently make every + spirit after the first worthless, which is a worse bug than the faucet. + """ + _no_twists(monkeypatch) + + token = _login(sync_client, "second-spirit") + user_id = _user_id(sync_client, token) + + with _ws_connect(sync_client, token) as ws: + _read_until(ws, "session") + _summon(ws) + baseline = await _essence(db_session, user_id) + + ws.send_json({"type": "passage_start"}) + _settle(ws) + _walk(ws, BEATS_BEFORE_RELEASE) + after_first = await _essence(db_session, user_id) + + _summon(ws) + ws.send_json({"type": "passage_start"}) + _settle(ws) + _walk(ws, BEATS_BEFORE_RELEASE) + after_second = await _essence(db_session, user_id) + + first_rite = after_first - baseline + # The second summon also pays its own trickle, so compare the rite only. + second_rite = after_second - after_first - SUMMON_ESSENCE_TRICKLE + assert first_rite > 0 + assert second_rite == first_rite, ( + "a fresh presence did not re-open the purse — every spirit after the " + "first is worth nothing" + )