sigil/sigil/viewcodes.py

191 lines
6.2 KiB
Python

"""One-time codes: a human looking at the new internet through their agent.
The flow a human actually uses:
human: "agent, what's happening with the Gitea PR?"
agent: hands back a URL and a code
human: opens the URL, types the code, looks around
Two lifetimes, deliberately not the same number:
code redemption window short (5 min) - the code is a key
session lifetime long (1 hour) - the session is the door
The code travels in a chat log, so it is made worthless quickly. The hour is
comfortable for reading, and buys no extra capability: a human_via_agent
session holds view scopes only, whatever its lifetime.
"""
from __future__ import annotations
import hashlib
import secrets
import time
from typing import List, Optional
from . import errors, sessions
from .store import Store
DEFAULT_CODE_TTL = 300 # 5 minutes to redeem
DEFAULT_VIEW_TTL = 3600 # 1 hour of access
# Unambiguous alphabet: no 0/O or 1/I. Codes get read aloud and retyped.
_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def _format_code() -> str:
raw = "".join(secrets.choice(_ALPHABET) for _ in range(8))
return f"{raw[:4]}-{raw[4:]}"
def _hash_code(code: str) -> str:
return hashlib.sha256(normalise(code).encode("utf-8")).hexdigest()
def normalise(code: str) -> str:
"""Canonical form: upper case, letters and digits only.
Humans retype these codes, so the dash and any spacing they drop or add
must not matter. Both issuing and redeeming hash this same form.
"""
return "".join(ch for ch in code.upper() if ch.isalnum())
def issue_code(
store: Store,
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 for the agent that owns `session_token`.
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")
granted = list(scopes if scopes is not None else sessions.HUMAN_VIEW_SCOPES)
# Refuse to bake write or act scopes into a human code at issue time.
illegal = [s for s in granted if s not in sessions.HUMAN_VIEW_SCOPES]
if illegal:
raise errors.ScopeDenied(f"human view codes cannot carry: {illegal}")
if code_ttl >= view_ttl:
raise errors.SigilError(
"code_ttl must be shorter than view_ttl: an unspent code must not "
"outlive the window it can open"
)
code = _format_code()
now = int(time.time())
store.put_code(
_hash_code(code),
agent,
granted,
redeem_expires_at=now + code_ttl,
view_ttl=view_ttl,
)
store.record(
"view_code.issued",
actor=agent,
subject=f"{agent}'s human",
fingerprint=agent_row["fingerprint"],
detail={
"code_prefix": code[:5],
"scopes": granted,
"redeem_expires_at": now + code_ttl,
},
)
return {
"code": code,
"agent": agent,
"scopes": granted,
"code_ttl": code_ttl,
"view_ttl": view_ttl,
"redeem_expires_at": now + code_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:
store.record(
"view_code.redeem_refused", actor="unknown", detail={"reason": "unrecognised"}
)
raise errors.CodeInvalid("that code is not recognised")
agent = row["agent"]
if time.time() >= row["redeem_expires_at"]:
store.record(
"view_code.redeem_refused",
actor=agent,
subject=f"{agent}'s human",
detail={"reason": "redemption window closed"},
)
raise errors.CodeExpired("that code has expired")
# 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
# 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=row["view_ttl"],
granted_by=agent,
)
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.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)