sigil/sigil/sessions.py
leora 096a7dd072 Fix N1: resolve re-checks agent; codes bind key fingerprint
In-flight verify/issue_code could insert a session or code after revoke
or rekey. sessions.resolve now refuses if the agent is revoked, has no
live key, or (agent sessions) the fingerprint no longer matches.
View codes store the issuing fingerprint and redeem refuses after
rekey/wipe; issue_code re-checks agent state. DESIGN matches the choke
point. Regression tests plant race-surviving sessions and stale codes.
2026-10-06 06:21:18 +08:00

259 lines
8.4 KiB
Python

"""Sessions: short-lived, scoped, and typed.
Two kinds, and the difference is load-bearing:
agent issued only by an Ed25519 signature. Acts as the agent.
human_via_agent issued by redeeming a one-time code the agent handed out.
Looks at things on the agent's behalf. Cannot act as it.
Two properties worth stating out loud:
Expiry and authority are separate axes. A long session is not a privileged
session. Scopes come from the issuer and never widen with time.
Capability stays flat. A human_via_agent session carries the human scope set
and nothing else, however long it lives.
"""
from __future__ import annotations
import hashlib
import json
import secrets
import time
from typing import List, Optional
from . import errors
from .store import Store
AGENT = "agent"
HUMAN_VIA_AGENT = "human_via_agent"
# The only scope that permits acting as the agent. Not grantable to a human
# session under any path in this module.
AGENT_ACT = "agent:act"
# What a human gets through a view session: look, and nothing else.
HUMAN_VIEW_SCOPES = ("view:read",)
# Scopes an agent may hold for itself.
AGENT_SCOPES = (AGENT_ACT, "view:read", "view:write", "secret:use")
DEFAULT_AGENT_TTL = 900 # 15 minutes. Short enough to be boring.
def hash_token(token: str) -> str:
"""Digest a bearer token for storage.
Session tokens are stored hashed, like one-time codes. A stolen database
must not hand an attacker live sessions; the plaintext token exists only in
the caller's hands, which is where a bearer token belongs.
"""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _token() -> str:
return secrets.token_urlsafe(32)
def issue_agent_session(
store: Store,
agent: str,
scopes: Optional[List[str]] = None,
ttl: int = DEFAULT_AGENT_TTL,
fingerprint: Optional[str] = None,
) -> dict:
"""Mint an agent session. Caller must have verified a signature already."""
granted = list(scopes if scopes is not None else AGENT_SCOPES)
unknown = [s for s in granted if s not in AGENT_SCOPES]
if unknown:
raise errors.ScopeDenied(f"unknown agent scopes: {unknown}")
token = _token()
expires = int(time.time()) + ttl
store.put_session(
hash_token(token), AGENT, agent, agent, granted, expires,
granted_by=None, fingerprint=fingerprint,
)
store.record(
"session.issued",
actor=agent,
subject=agent,
fingerprint=fingerprint,
detail={"kind": AGENT, "scopes": granted, "expires_at": expires},
)
return {
"token": token,
"kind": AGENT,
"subject": agent,
"on_behalf_of": agent,
"scopes": granted,
"expires_at": expires,
"ttl": ttl,
}
class GrantedCode:
"""Proof that a one-time view code was redeemed.
Constructed only by viewcodes.redeem_code(), and only from a code that
actually matched. Minting a human session now requires one of these, so the
session minting path cannot be reached by passing an agent name.
This is a guardrail against wiring mistakes, not a security boundary: any
code running in this process can construct it. The boundary that matters is
that no network-reachable route reaches this function without either an
agent session (issue_code) or a valid code (redeem_code).
"""
__slots__ = ("agent", "scopes", "ttl", "fingerprint", "_sigil_internal")
def __init__(
self,
agent: str,
scopes: List[str],
ttl: int,
_internal=None,
fingerprint: Optional[str] = None,
):
if _internal is not _ISSUER:
raise errors.SigilError(
"GrantedCode cannot be constructed directly; it comes from"
" viewcodes.redeem_code()"
)
self.agent = agent
self.scopes = list(scopes)
self.ttl = ttl
self.fingerprint = fingerprint
self._sigil_internal = _internal
def __repr__(self) -> str:
return f"<GrantedCode agent={self.agent!r}>"
_ISSUER = object()
def issue_view_session(store: Store, grant: "GrantedCode") -> dict:
"""Mint a human_via_agent session from a redeemed code.
Takes a GrantedCode rather than an agent name, so the only way to reach a
human session is to have redeemed a code. `granted_by` is the agent that
approved it, recorded so the audit trail can answer "who let this human in"
and not merely "which agent's things did they see".
"""
agent = grant.agent
granted = list(grant.scopes)
ttl = grant.ttl
extra = [s for s in granted if s not in HUMAN_VIEW_SCOPES]
if extra:
# Defence in depth: even if a caller tries to widen a human session,
# the write and act scopes are not on offer here.
raise errors.ScopeDenied(f"human sessions cannot hold: {extra}")
token = _token()
expires = int(time.time()) + ttl
store.put_session(
hash_token(token),
HUMAN_VIA_AGENT,
f"{agent}'s human",
agent,
granted,
expires,
granted_by=agent,
fingerprint=grant.fingerprint,
)
store.record(
"session.issued",
actor=agent,
subject=f"{agent}'s human",
detail={
"kind": HUMAN_VIA_AGENT,
"scopes": granted,
"expires_at": expires,
"granted_by": agent,
},
)
return {
"token": token,
"kind": HUMAN_VIA_AGENT,
"subject": f"{agent}'s human",
"on_behalf_of": agent,
"granted_by": agent,
"scopes": granted,
"expires_at": expires,
"ttl": ttl,
}
def resolve(store: Store, token: str) -> dict:
"""Look up a session and reject it if revoked, expired, or the agent died.
The session row alone is not enough: an in-flight verify/issue_code can
insert a session or code after revoke/rekey has already run. Re-check the
agent on every resolve so a race-surviving bearer cannot keep acting.
"""
from . import identity as _identity
row = store.get_session(hash_token(token))
if row is None:
raise errors.SessionUnknown("no such session")
if row["revoked_at"] is not None:
raise errors.SessionExpired("session revoked")
if time.time() >= row["expires_at"]:
raise errors.SessionExpired("session expired")
agent = row["on_behalf_of"]
agent_row = store.get_agent(agent)
if agent_row is None or _identity.is_revoked(agent_row):
raise errors.SessionExpired("agent revoked")
if not _identity.has_live_key(agent_row):
raise errors.SessionExpired("agent has no live key")
# Agent sessions are bound to the key epoch they were minted against.
# After rekey, old agent:act bearers must die even if revoke_sessions raced.
if row["kind"] == AGENT:
sess_fp = row["fingerprint"] or ""
live_fp = agent_row["fingerprint"] or ""
if not sess_fp or sess_fp != live_fp:
raise errors.SessionExpired("session key epoch no longer current")
return {
"token": token,
"kind": row["kind"],
"subject": row["subject"],
"on_behalf_of": row["on_behalf_of"],
"granted_by": row["granted_by"],
"fingerprint": row["fingerprint"],
"revoked_reason": row["revoked_reason"],
"scopes": json.loads(row["scopes"]),
"expires_at": row["expires_at"],
}
def require_scope(session: dict, scope: str) -> None:
if scope not in session["scopes"]:
raise errors.ScopeDenied(
f"{session['kind']} session lacks scope {scope!r}"
)
def assert_may_act_as_agent(session: dict) -> str:
"""The invariant.
Only an agent session may act as the agent. A human_via_agent session is
refused here, whatever scopes or lifetime it carries.
Returns the agent id on success so callers can use it directly.
"""
if session["kind"] != AGENT:
raise errors.AgentImpersonationDenied(
f"{session['kind']} session may not act as "
f"{session['on_behalf_of']!r}: only an agent signature can"
)
require_scope(session, AGENT_ACT)
return session["on_behalf_of"]
def revoke(store: Store, token: str) -> None:
store.revoke_session(hash_token(token))
# A digest, not a prefix of the token: the log outlives the session.
store.record("session.revoked", actor="system",
detail={"token_ref": "tok:" + hash_token(token)[:12]})