From 71801d31cbc5aa068c582892734867ba65974997 Mon Sep 17 00:00:00 2001 From: bigpickle Date: Mon, 5 Oct 2026 21:24:48 +0800 Subject: [PATCH] Harden Sigil: sigil/viewcodes.py --- sigil/viewcodes.py | 67 ++++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/sigil/viewcodes.py b/sigil/viewcodes.py index 44046f9..8e8c8c5 100644 --- a/sigil/viewcodes.py +++ b/sigil/viewcodes.py @@ -53,17 +53,32 @@ def normalise(code: str) -> str: def issue_code( store: Store, - agent: str, + session_token: str, scopes: Optional[List[str]] = None, code_ttl: int = DEFAULT_CODE_TTL, view_ttl: int = DEFAULT_VIEW_TTL, + limit: Optional[int] = None, + window: Optional[int] = None, ) -> dict: - """Mint a one-time view code on behalf of an agent. + """Mint a one-time view code for the agent that owns `session_token`. - Called only from an agent session. The agent is recorded as the grantor so - the audit trail shows a human was let in by that agent, not that the agent - itself was present. + The caller must present a live agent session. There is deliberately no + `agent` argument: the agent is derived from the session, so a code can + never be minted for some other agent, and a route that forgets to + authenticate fails closed instead of minting a session for whoever asked. + + The code lives code_ttl seconds; the session it mints lives view_ttl + seconds from the moment it is redeemed. + + The agent is recorded as the grantor so the audit trail shows a human was + let in by that agent, not that the agent itself was present. """ + principal = sessions.resolve(store, session_token) + agent = sessions.assert_may_act_as_agent(principal) + + if not store.allow(f"code_issue:{agent}", limit=limit, window=window): + raise errors.RateLimited(f"too many view codes for {agent}") + agent_row = store.get_agent(agent) if agent_row is None: raise errors.UnknownAgent(f"{agent} is not enrolled") @@ -87,7 +102,7 @@ def issue_code( agent, granted, redeem_expires_at=now + code_ttl, - session_expires_at=now + view_ttl, + view_ttl=view_ttl, ) store.record( "view_code.issued", @@ -98,7 +113,6 @@ def issue_code( "code_prefix": code[:5], "scopes": granted, "redeem_expires_at": now + code_ttl, - "session_expires_at": now + view_ttl, }, ) return { @@ -108,12 +122,17 @@ def issue_code( "code_ttl": code_ttl, "view_ttl": view_ttl, "redeem_expires_at": now + code_ttl, - "session_expires_at": now + view_ttl, + "view_ttl": view_ttl, } def redeem_code(store: Store, code: str) -> dict: """Trade a one-time code for a human_via_agent session.""" + # Rate-limit before the lookup so a guessing attacker cannot use response + # differences to probe for valid codes for free. + if not store.allow("code_redeem_fail"): + raise errors.RateLimited("too many redemption attempts") + digest = _hash_code(normalise(code)) row = store.get_code(digest) if row is None: @@ -124,15 +143,6 @@ def redeem_code(store: Store, code: str) -> dict: agent = row["agent"] - if row["used_at"] is not None: - store.record( - "view_code.redeem_refused", - actor=agent, - subject=f"{agent}'s human", - detail={"reason": "already used"}, - ) - raise errors.CodeUsed("that code has already been used") - if time.time() >= row["redeem_expires_at"]: store.record( "view_code.redeem_refused", @@ -142,20 +152,31 @@ def redeem_code(store: Store, code: str) -> dict: ) raise errors.CodeExpired("that code has expired") - # Consume before issuing, so a race cannot mint two sessions. - store.mark_code_used(digest) + # Claim the code before issuing anything. Compare-and-set, so a code cannot + # mint two sessions under a race, and an expired or replayed code is refused + # before any session exists. + if not store.claim_code(digest): + store.record( + "view_code.redeem_refused", + actor=agent, + subject=f"{agent}'s human", + detail={"reason": "already used or claimed"}, + ) + raise errors.CodeUsed("that code has already been used") + store.record( "view_code.redeemed", actor=agent, subject=f"{agent}'s human", detail={} ) import json as _json - remaining = max(0, row["session_expires_at"] - int(time.time())) + # The session window starts here, at redemption, not at issue. Waiting for a + # code must not eat into the hour the human was given. return sessions.issue_view_session( store, agent, scopes=_json.loads(row["scopes"]), - ttl=remaining, + ttl=row["view_ttl"], granted_by=agent, ) @@ -163,8 +184,8 @@ def redeem_code(store: Store, code: str) -> dict: def sweep(store: Store) -> int: """Report how many codes and sessions have lapsed. Housekeeping aid.""" now = int(time.time()) - codes = [r for r in store.conn.execute("SELECT * FROM codes").fetchall() - if r["used_at"] is None and now >= r["redeem_expires_at"]] + codes = [r for r in store.lapsed_codes() + if now >= r["redeem_expires_at"]] tokens = [r for r in store.sessions() if r["revoked_at"] is None and now >= r["expires_at"]] return len(codes) + len(tokens) \ No newline at end of file