diff --git a/DESIGN.md b/DESIGN.md index 85470ab..f1f493a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. Revoking kills live sessions, drops outstanding challenges, and invalidates -unused view codes, so a revocation that races an in-flight authentication -cannot be overtaken by it, and a pre-issued code cannot mint a fresh human -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. +unused view codes. 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 diff --git a/sigil/sessions.py b/sigil/sessions.py index 15bd820..8ee0c64 100644 --- a/sigil/sessions.py +++ b/sigil/sessions.py @@ -105,9 +105,16 @@ class GrantedCode: 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: raise errors.SigilError( "GrantedCode cannot be constructed directly; it comes from" @@ -116,6 +123,7 @@ class GrantedCode: self.agent = agent self.scopes = list(scopes) self.ttl = ttl + self.fingerprint = fingerprint self._sigil_internal = _internal def __repr__(self) -> str: @@ -151,6 +159,7 @@ def issue_view_session(store: Store, grant: "GrantedCode") -> dict: granted, expires, granted_by=agent, + fingerprint=grant.fingerprint, ) store.record( "session.issued", @@ -176,7 +185,14 @@ def issue_view_session(store: Store, grant: "GrantedCode") -> 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)) if row is None: raise errors.SessionUnknown("no such session") @@ -184,6 +200,21 @@ def resolve(store: Store, token: str) -> dict: 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"], diff --git a/sigil/store.py b/sigil/store.py index 88182ce..22b9f4c 100644 --- a/sigil/store.py +++ b/sigil/store.py @@ -77,7 +77,11 @@ CREATE TABLE IF NOT EXISTS codes ( 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 -- 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 @@ -217,6 +221,11 @@ class Store: self.conn.execute( "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() def close(self) -> None: @@ -495,10 +504,11 @@ class Store: scopes: List[str], redeem_expires_at: int, view_ttl: int, + fingerprint: Optional[str] = None, ) -> None: self._exec( "INSERT INTO codes (code_hash, agent, scopes, issued_at, redeem_expires_at," - " view_ttl) VALUES (?, ?, ?, ?, ?, ?)", + " view_ttl, fingerprint) VALUES (?, ?, ?, ?, ?, ?, ?)", ( code_hash, agent, @@ -506,6 +516,7 @@ class Store: int(time.time()), redeem_expires_at, view_ttl, + fingerprint, ), ) diff --git a/sigil/viewcodes.py b/sigil/viewcodes.py index 366eb09..0ba58d9 100644 --- a/sigil/viewcodes.py +++ b/sigil/viewcodes.py @@ -91,9 +91,18 @@ def issue_code( if not store.allow(f"code_issue:{agent}", limit=limit, window=window): raise errors.RateLimited(f"too many view codes for {agent}") + from . import identity as _identity + agent_row = store.get_agent(agent) if agent_row is None: 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) # Refuse to bake write or act scopes into a human code at issue time. @@ -115,12 +124,13 @@ def issue_code( granted, redeem_expires_at=now + code_ttl, view_ttl=view_ttl, + fingerprint=live_fp, ) store.record( "view_code.issued", actor=agent, subject=f"{agent}'s human", - fingerprint=agent_row["fingerprint"], + fingerprint=live_fp, detail={ "code_ref": _code_ref(code), "scopes": granted, @@ -155,19 +165,25 @@ def redeem_code(store: Store, code: str) -> dict: agent = row["agent"] - # Defense in depth: revoke/rekey already invalidate unused codes, but refuse - # here too if the agent is currently revoked or missing so a missed - # invalidation cannot mint a human_via_agent session. + # Defense in depth: refuse if agent revoked, wiped, or rekeyed since issue. from . import identity as _identity 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}"): raise errors.RateLimited("too many redemption attempts") store.record( "view_code.redeem_refused", actor=agent, 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") @@ -216,6 +232,7 @@ def redeem_code(store: Store, code: str) -> dict: _json.loads(row["scopes"]), row["view_ttl"], sessions._ISSUER, + fingerprint=live_fp, ) return sessions.issue_view_session(store, grant) diff --git a/tests/test_hardening.py b/tests/test_hardening.py index fb901d2..f1a9054 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -410,3 +410,88 @@ class TestBootstrapRowDeletedOnSpend(unittest.TestCase): self.assertIsNone(store.conn.execute( "SELECT 1 FROM bootstrap WHERE secret_hash = ?", (digest,) ).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"])