Merge pull request 'Fix N1: resolve re-checks agent state; codes bind fingerprint' (#12) from leora/fix-n1-resolve-recheck into sigil-initial
This commit is contained in:
commit
668577f3ea
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