Compare commits

...

2 Commits

Author SHA1 Message Date
668577f3ea Merge pull request 'Fix N1: resolve re-checks agent state; codes bind fingerprint' (#12) from leora/fix-n1-resolve-recheck into sigil-initial 2026-10-06 06:21:19 +08:00
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
5 changed files with 165 additions and 15 deletions

View File

@ -134,10 +134,16 @@ wrong moment. A noisy neighbour or a holiday wants a pause, and re-enrolling for
that would destroy the audit history that says it was deliberate. that would destroy the audit history that says it was deliberate.
Revoking kills live sessions, drops outstanding challenges, and invalidates Revoking kills live sessions, drops outstanding challenges, and invalidates
unused view codes, so a revocation that races an in-flight authentication unused view codes. Rekey invalidates codes the same way: rotating a key must
cannot be overtaken by it, and a pre-issued code cannot mint a fresh human close every door the old key could open.
session after the agent is suspended or dead. Rekey invalidates codes the same
way: rotating a key must close every door the old key could open. A narrow check-then-insert race can still insert a session or code after the
revoke/rekey statement has run. The choke point is `sessions.resolve`: every
use re-checks that the agent is not revoked, still has a live key, and (for
agent sessions) that the session fingerprint matches the current key epoch.
View codes store the issuing fingerprint and refuse redeem after rekey or key
wipe. That is what makes revocation overtake in-flight verify/issue/redeem,
not the first-pass invalidation alone.
### Existing controls stay ### Existing controls stay

View File

@ -105,9 +105,16 @@ class GrantedCode:
agent session (issue_code) or a valid code (redeem_code). agent session (issue_code) or a valid code (redeem_code).
""" """
__slots__ = ("agent", "scopes", "ttl", "_sigil_internal") __slots__ = ("agent", "scopes", "ttl", "fingerprint", "_sigil_internal")
def __init__(self, agent: str, scopes: List[str], ttl: int, _internal=None): def __init__(
self,
agent: str,
scopes: List[str],
ttl: int,
_internal=None,
fingerprint: Optional[str] = None,
):
if _internal is not _ISSUER: if _internal is not _ISSUER:
raise errors.SigilError( raise errors.SigilError(
"GrantedCode cannot be constructed directly; it comes from" "GrantedCode cannot be constructed directly; it comes from"
@ -116,6 +123,7 @@ class GrantedCode:
self.agent = agent self.agent = agent
self.scopes = list(scopes) self.scopes = list(scopes)
self.ttl = ttl self.ttl = ttl
self.fingerprint = fingerprint
self._sigil_internal = _internal self._sigil_internal = _internal
def __repr__(self) -> str: def __repr__(self) -> str:
@ -151,6 +159,7 @@ def issue_view_session(store: Store, grant: "GrantedCode") -> dict:
granted, granted,
expires, expires,
granted_by=agent, granted_by=agent,
fingerprint=grant.fingerprint,
) )
store.record( store.record(
"session.issued", "session.issued",
@ -176,7 +185,14 @@ def issue_view_session(store: Store, grant: "GrantedCode") -> dict:
def resolve(store: Store, token: str) -> dict: def resolve(store: Store, token: str) -> dict:
"""Look up a session and reject it if revoked or expired.""" """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)) row = store.get_session(hash_token(token))
if row is None: if row is None:
raise errors.SessionUnknown("no such session") raise errors.SessionUnknown("no such session")
@ -184,6 +200,21 @@ def resolve(store: Store, token: str) -> dict:
raise errors.SessionExpired("session revoked") raise errors.SessionExpired("session revoked")
if time.time() >= row["expires_at"]: if time.time() >= row["expires_at"]:
raise errors.SessionExpired("session expired") 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 { return {
"token": token, "token": token,
"kind": row["kind"], "kind": row["kind"],

View File

@ -77,7 +77,11 @@ CREATE TABLE IF NOT EXISTS codes (
redeem_expires_at INTEGER NOT NULL, -- short: the code is a key redeem_expires_at INTEGER NOT NULL, -- short: the code is a key
view_ttl INTEGER NOT NULL, -- the door opens a full hour, but only view_ttl INTEGER NOT NULL, -- the door opens a full hour, but only
-- once the key is used -- once the key is used
used_at INTEGER used_at INTEGER,
-- Fingerprint of the agent key at issue time. Redeem refuses if the agent
-- has since rekeyed (or been wiped), so an in-flight issue_code cannot
-- outlive a revoke/rekey that lands before put_code commits.
fingerprint TEXT
); );
-- Append-only, hash-chained audit log. The chain is what makes this an -- Append-only, hash-chained audit log. The chain is what makes this an
@ -217,6 +221,11 @@ class Store:
self.conn.execute( self.conn.execute(
"UPDATE codes SET view_ttl = 3600 WHERE view_ttl IS NULL" "UPDATE codes SET view_ttl = 3600 WHERE view_ttl IS NULL"
) )
code_cols = {
row["name"] for row in self.conn.execute("PRAGMA table_info(codes)")
}
if "fingerprint" not in code_cols:
self.conn.execute("ALTER TABLE codes ADD COLUMN fingerprint TEXT")
self.conn.commit() self.conn.commit()
def close(self) -> None: def close(self) -> None:
@ -495,10 +504,11 @@ class Store:
scopes: List[str], scopes: List[str],
redeem_expires_at: int, redeem_expires_at: int,
view_ttl: int, view_ttl: int,
fingerprint: Optional[str] = None,
) -> None: ) -> None:
self._exec( self._exec(
"INSERT INTO codes (code_hash, agent, scopes, issued_at, redeem_expires_at," "INSERT INTO codes (code_hash, agent, scopes, issued_at, redeem_expires_at,"
" view_ttl) VALUES (?, ?, ?, ?, ?, ?)", " view_ttl, fingerprint) VALUES (?, ?, ?, ?, ?, ?, ?)",
( (
code_hash, code_hash,
agent, agent,
@ -506,6 +516,7 @@ class Store:
int(time.time()), int(time.time()),
redeem_expires_at, redeem_expires_at,
view_ttl, view_ttl,
fingerprint,
), ),
) )

View File

@ -91,9 +91,18 @@ def issue_code(
if not store.allow(f"code_issue:{agent}", limit=limit, window=window): if not store.allow(f"code_issue:{agent}", limit=limit, window=window):
raise errors.RateLimited(f"too many view codes for {agent}") raise errors.RateLimited(f"too many view codes for {agent}")
from . import identity as _identity
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")
# Re-check after resolve: revoke/rekey can land between resolve and put_code.
if _identity.is_revoked(agent_row) or not _identity.has_live_key(agent_row):
raise errors.UnknownAgent(f"{agent} is revoked or has no live key")
live_fp = agent_row["fingerprint"] or ""
sess_fp = principal.get("fingerprint") or ""
if not live_fp or sess_fp != live_fp:
raise errors.SessionExpired("session key epoch no longer current")
granted = list(scopes if scopes is not None else sessions.HUMAN_VIEW_SCOPES) 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. # Refuse to bake write or act scopes into a human code at issue time.
@ -115,12 +124,13 @@ def issue_code(
granted, granted,
redeem_expires_at=now + code_ttl, redeem_expires_at=now + code_ttl,
view_ttl=view_ttl, view_ttl=view_ttl,
fingerprint=live_fp,
) )
store.record( store.record(
"view_code.issued", "view_code.issued",
actor=agent, actor=agent,
subject=f"{agent}'s human", subject=f"{agent}'s human",
fingerprint=agent_row["fingerprint"], fingerprint=live_fp,
detail={ detail={
"code_ref": _code_ref(code), "code_ref": _code_ref(code),
"scopes": granted, "scopes": granted,
@ -155,19 +165,25 @@ def redeem_code(store: Store, code: str) -> dict:
agent = row["agent"] agent = row["agent"]
# Defense in depth: revoke/rekey already invalidate unused codes, but refuse # Defense in depth: refuse if agent revoked, wiped, or rekeyed since issue.
# here too if the agent is currently revoked or missing so a missed
# invalidation cannot mint a human_via_agent session.
from . import identity as _identity from . import identity as _identity
agent_row = store.get_agent(agent) agent_row = store.get_agent(agent)
if agent_row is None or _identity.is_revoked(agent_row): code_fp = row["fingerprint"] if "fingerprint" in row.keys() else None
live_fp = (agent_row["fingerprint"] or "") if agent_row is not None else ""
bad = (
agent_row is None
or _identity.is_revoked(agent_row)
or not _identity.has_live_key(agent_row)
or (code_fp is not None and code_fp != live_fp)
)
if bad:
if not store.allow(f"code_redeem_fail:{agent}"): if not store.allow(f"code_redeem_fail:{agent}"):
raise errors.RateLimited("too many redemption attempts") raise errors.RateLimited("too many redemption attempts")
store.record( store.record(
"view_code.redeem_refused", "view_code.redeem_refused",
actor=agent, actor=agent,
subject=f"{agent}'s human", subject=f"{agent}'s human",
detail={"reason": "agent revoked or unknown"}, detail={"reason": "agent revoked, wiped, or rekeyed"},
) )
raise errors.CodeInvalid("that code is not redeemable") raise errors.CodeInvalid("that code is not redeemable")
@ -216,6 +232,7 @@ def redeem_code(store: Store, code: str) -> dict:
_json.loads(row["scopes"]), _json.loads(row["scopes"]),
row["view_ttl"], row["view_ttl"],
sessions._ISSUER, sessions._ISSUER,
fingerprint=live_fp,
) )
return sessions.issue_view_session(store, grant) return sessions.issue_view_session(store, grant)

View File

@ -410,3 +410,88 @@ class TestBootstrapRowDeletedOnSpend(unittest.TestCase):
self.assertIsNone(store.conn.execute( self.assertIsNone(store.conn.execute(
"SELECT 1 FROM bootstrap WHERE secret_hash = ?", (digest,) "SELECT 1 FROM bootstrap WHERE secret_hash = ?", (digest,)
).fetchone()) ).fetchone())
class TestResolveRechecksAgentState(unittest.TestCase):
"""N1: resolve must refuse sessions that outran revoke/rekey."""
def _plant_agent_session(self, store, agent, fingerprint):
"""Insert a live session as if put_session won a race against revoke."""
import time as _t
token = "race-survivor-token"
store.put_session(
sessions.hash_token(token),
sessions.AGENT,
agent,
agent,
list(sessions.AGENT_SCOPES),
int(_t.time()) + 900,
fingerprint=fingerprint,
)
return token
def test_resolve_refuses_race_survivor_after_permanent_revoke(self):
store, private = helpers.fresh()
fp = store.get_agent("bigpickle")["fingerprint"]
identity.revoke(store, "bigpickle", reason="compromise")
# Session inserted *after* revoke (race); not marked revoked_at.
token = self._plant_agent_session(store, "bigpickle", fp)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, token)
def test_resolve_refuses_stale_fingerprint_after_rekey(self):
store, private = helpers.fresh()
old_fp = store.get_agent("bigpickle")["fingerprint"]
new_pub = keys.generate()[1]
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(new_pub), secret
)
token = self._plant_agent_session(store, "bigpickle", old_fp)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, token)
def test_redeem_refuses_code_after_rekey_even_if_not_invalidated(self):
"""Fingerprint binding: plant a code with a stale fingerprint."""
store, private = helpers.fresh()
agent_row = store.get_agent("bigpickle")
old_fp = agent_row["fingerprint"]
# Issue normally, then surgically restore used_at and keep old fp after rekey
issued = helpers.issue(store, private)
digest = viewcodes._hash_code(issued["code"])
new_pub = keys.generate()[1]
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(new_pub), secret
)
# Rekey invalidated the code; un-burn it to simulate put_code after invalidate.
store.conn.execute(
"UPDATE codes SET used_at = NULL, fingerprint = ? WHERE code_hash = ?",
(old_fp, digest),
)
store.conn.commit()
with self.assertRaises(errors.CodeInvalid):
viewcodes.redeem_code(store, issued["code"])
def test_redeem_refuses_without_live_key(self):
store, private = helpers.fresh()
issued = helpers.issue(store, private)
digest = viewcodes._hash_code(issued["code"])
identity.revoke(store, "bigpickle", reason="compromise")
identity.reinstate(store, "bigpickle") # ban lifted, key still wiped
store.conn.execute(
"UPDATE codes SET used_at = NULL WHERE code_hash = ?", (digest,)
)
store.conn.commit()
with self.assertRaises(errors.CodeInvalid):
viewcodes.redeem_code(store, issued["code"])
def test_verify_then_revoke_session_unusable(self):
"""Session minted just before revoke must not resolve."""
store, private = helpers.fresh()
chal = identity.challenge(store, "bigpickle")
sig = helpers.sign(private, chal["nonce"])
sess = identity.verify(store, chal["nonce"], sig)
identity.revoke(store, "bigpickle", reason="compromise")
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, sess["token"])