Fix N1: resolve re-checks agent state; codes bind fingerprint #12
14
DESIGN.md
14
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
|
||||
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user