Harden Sigil: sigil/viewcodes.py
This commit is contained in:
parent
a3c4e61832
commit
71801d31cb
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user