From 9eea815805f1afaa6372e85ec6fd637ee69bcbf2 Mon Sep 17 00:00:00 2001 From: leora Date: Tue, 6 Oct 2026 06:10:42 +0800 Subject: [PATCH] Fix R1: invalidate view codes on revoke/rekey/suspend Outstanding view codes survived revoke and rekey, so a pre-issued code could mint a fresh human_via_agent session after compromise revoke or key rotation. Invalidate unused codes when sessions are revoked (covers revoke, suspend, and rekey), and refuse redeem when the agent is currently revoked. Regression tests cover all three paths. --- DESIGN.md | 7 ++++-- sigil/store.py | 20 +++++++++++++++++- sigil/viewcodes.py | 16 ++++++++++++++ tests/test_viewcodes.py | 47 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f2b05c2..366d9e3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -131,8 +131,11 @@ when the timer ran out, which is a revocation that silently fails at exactly the 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 and drops outstanding challenges, so a revocation -that races an in-flight authentication cannot be overtaken by it. +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. ### Existing controls stay diff --git a/sigil/store.py b/sigil/store.py index 319a404..b83a524 100644 --- a/sigil/store.py +++ b/sigil/store.py @@ -402,12 +402,30 @@ class Store: (int(time.time()), token_hash), ) + def invalidate_codes_for_agent(self, agent: str) -> int: + """Mark every unused view code for an agent as spent. + + Revoke, suspend, and rekey must close outstanding codes the same way they + kill sessions. Leaving a pre-issued code redeemable after compromise + revoke or key rotation would mint a fresh human_via_agent session while + the agent is supposed to be dead. + """ + with self._write_lock: + cur = self.conn.execute( + "UPDATE codes SET used_at = ? WHERE agent = ? AND used_at IS NULL", + (int(time.time()), agent), + ) + self.conn.commit() + return cur.rowcount + def revoke_sessions_for_agent(self, agent: str, reason: str) -> int: """Kill every live session for an agent. Used on revoke and rekey. Rotating a key must not leave old sessions usable, otherwise the new key - buys nothing after a compromise. + buys nothing after a compromise. Outstanding view codes are invalidated + in the same breath so a pre-issued code cannot mint a fresh session. """ + self.invalidate_codes_for_agent(agent) cur = self._exec( "UPDATE sessions SET revoked_at = ?, revoked_reason = ?" " WHERE on_behalf_of = ? AND revoked_at IS NULL", diff --git a/sigil/viewcodes.py b/sigil/viewcodes.py index 6fa6986..366eb09 100644 --- a/sigil/viewcodes.py +++ b/sigil/viewcodes.py @@ -155,6 +155,22 @@ 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. + from . import identity as _identity + agent_row = store.get_agent(agent) + if agent_row is None or _identity.is_revoked(agent_row): + 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"}, + ) + raise errors.CodeInvalid("that code is not redeemable") + # Known-agent attempts use the success quota (code_redeem), not the fail # bucket. Failures for this agent (expired / already used) charge a # per-agent fail bucket so one noisy human cannot lock every agent out. diff --git a/tests/test_viewcodes.py b/tests/test_viewcodes.py index c2f116d..3a2384d 100644 --- a/tests/test_viewcodes.py +++ b/tests/test_viewcodes.py @@ -142,4 +142,49 @@ class TestAuditTrail(Base): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() + +class TestRevokeAndRekeyCloseViewCodes(Base): + """R1: revoke / rekey / suspend must close outstanding view codes.""" + + def test_redeem_refused_after_permanent_revoke(self): + issued = helpers.issue(self.store, self.private) + identity.revoke(self.store, "bigpickle", reason="compromise") + with self.assertRaises((errors.CodeInvalid, errors.CodeUsed)): + viewcodes.redeem_code(self.store, issued["code"]) + + def test_redeem_refused_during_timed_suspension(self): + import time + issued = helpers.issue(self.store, self.private) + identity.revoke( + self.store, "bigpickle", until=int(time.time()) + 3600, reason="pause" + ) + with self.assertRaises((errors.CodeInvalid, errors.CodeUsed)): + viewcodes.redeem_code(self.store, issued["code"]) + + def test_redeem_refused_after_rekey(self): + issued = helpers.issue(self.store, self.private) + new_private, new_public = keys.generate() + secret = identity.seed_bootstrap(self.store, "bigpickle") + identity.enrol_rekey( + self.store, + "bigpickle", + keys.export_public_b64(new_public), + secret, + ) + # Invalidate-on-rekey marks the code used; redeem must not mint a session. + with self.assertRaises((errors.CodeInvalid, errors.CodeUsed)): + viewcodes.redeem_code(self.store, issued["code"]) + # New agent can still mint and redeem a fresh code. + fresh = helpers.issue(self.store, new_private) + session = viewcodes.redeem_code(self.store, fresh["code"]) + self.assertEqual(session["kind"], "human_via_agent") + + def test_unused_codes_marked_used_on_revoke(self): + issued = helpers.issue(self.store, self.private) + digest = viewcodes._hash_code(issued["code"]) + row = self.store.get_code(digest) + self.assertIsNone(row["used_at"]) + identity.revoke(self.store, "bigpickle") + row = self.store.get_code(digest) + self.assertIsNotNone(row["used_at"])