Harden Sigil: sigil/viewcodes.py

This commit is contained in:
bigpickle 2026-10-05 21:24:48 +08:00
parent a3c6c8d5c6
commit 01a8f5bbe2

View File

@ -53,17 +53,32 @@ def normalise(code: str) -> str:
def issue_code( def issue_code(
store: Store, store: Store,
agent: str, session_token: str,
scopes: Optional[List[str]] = None, scopes: Optional[List[str]] = None,
code_ttl: int = DEFAULT_CODE_TTL, code_ttl: int = DEFAULT_CODE_TTL,
view_ttl: int = DEFAULT_VIEW_TTL, view_ttl: int = DEFAULT_VIEW_TTL,
limit: Optional[int] = None,
window: Optional[int] = None,
) -> dict: ) -> 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 caller must present a live agent session. There is deliberately no
the audit trail shows a human was let in by that agent, not that the agent `agent` argument: the agent is derived from the session, so a code can
itself was present. 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) agent_row = store.get_agent(agent)
if agent_row is None: if agent_row is None:
raise errors.UnknownAgent(f"{agent} is not enrolled") raise errors.UnknownAgent(f"{agent} is not enrolled")
@ -87,7 +102,7 @@ def issue_code(
agent, agent,
granted, granted,
redeem_expires_at=now + code_ttl, redeem_expires_at=now + code_ttl,
session_expires_at=now + view_ttl, view_ttl=view_ttl,
) )
store.record( store.record(
"view_code.issued", "view_code.issued",
@ -98,7 +113,6 @@ def issue_code(
"code_prefix": code[:5], "code_prefix": code[:5],
"scopes": granted, "scopes": granted,
"redeem_expires_at": now + code_ttl, "redeem_expires_at": now + code_ttl,
"session_expires_at": now + view_ttl,
}, },
) )
return { return {
@ -108,12 +122,17 @@ def issue_code(
"code_ttl": code_ttl, "code_ttl": code_ttl,
"view_ttl": view_ttl, "view_ttl": view_ttl,
"redeem_expires_at": now + code_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: def redeem_code(store: Store, code: str) -> dict:
"""Trade a one-time code for a human_via_agent session.""" """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)) digest = _hash_code(normalise(code))
row = store.get_code(digest) row = store.get_code(digest)
if row is None: if row is None:
@ -124,15 +143,6 @@ def redeem_code(store: Store, code: str) -> dict:
agent = row["agent"] 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"]: if time.time() >= row["redeem_expires_at"]:
store.record( store.record(
"view_code.redeem_refused", "view_code.redeem_refused",
@ -142,20 +152,31 @@ def redeem_code(store: Store, code: str) -> dict:
) )
raise errors.CodeExpired("that code has expired") raise errors.CodeExpired("that code has expired")
# Consume before issuing, so a race cannot mint two sessions. # Claim the code before issuing anything. Compare-and-set, so a code cannot
store.mark_code_used(digest) # 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( store.record(
"view_code.redeemed", actor=agent, subject=f"{agent}'s human", detail={} "view_code.redeemed", actor=agent, subject=f"{agent}'s human", detail={}
) )
import json as _json 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( return sessions.issue_view_session(
store, store,
agent, agent,
scopes=_json.loads(row["scopes"]), scopes=_json.loads(row["scopes"]),
ttl=remaining, ttl=row["view_ttl"],
granted_by=agent, granted_by=agent,
) )
@ -163,8 +184,8 @@ def redeem_code(store: Store, code: str) -> dict:
def sweep(store: Store) -> int: def sweep(store: Store) -> int:
"""Report how many codes and sessions have lapsed. Housekeeping aid.""" """Report how many codes and sessions have lapsed. Housekeeping aid."""
now = int(time.time()) now = int(time.time())
codes = [r for r in store.conn.execute("SELECT * FROM codes").fetchall() codes = [r for r in store.lapsed_codes()
if r["used_at"] is None and now >= r["redeem_expires_at"]] if now >= r["redeem_expires_at"]]
tokens = [r for r in store.sessions() if r["revoked_at"] is None tokens = [r for r in store.sessions() if r["revoked_at"] is None
and now >= r["expires_at"]] and now >= r["expires_at"]]
return len(codes) + len(tokens) return len(codes) + len(tokens)