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.
This commit is contained in:
parent
df1ed31bdb
commit
558d313783
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.
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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"])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user