Compare commits
No commits in common. "main" and "leora/fix-r11-cryptography-pin" have entirely different histories.
main
...
leora/fix-
13
DESIGN.md
13
DESIGN.md
@ -141,12 +141,11 @@ close every door the old key could open.
|
|||||||
|
|
||||||
A narrow check-then-insert race can still insert a session or code after the
|
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
|
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 that
|
use re-checks that the agent is not revoked, still has a live key, and (for
|
||||||
the session fingerprint (when present — always for agent sessions, and for
|
agent sessions) that the session fingerprint matches the current key epoch.
|
||||||
human_via_agent sessions minted with a fingerprint) matches the current key
|
View codes store the issuing fingerprint and refuse redeem after rekey or key
|
||||||
epoch. View codes store the issuing fingerprint and refuse redeem after rekey
|
wipe. That is what makes revocation overtake in-flight verify/issue/redeem,
|
||||||
or key wipe. That is what makes revocation overtake in-flight
|
not the first-pass invalidation alone.
|
||||||
verify/issue/redeem, not the first-pass invalidation alone.
|
|
||||||
|
|
||||||
### Existing controls stay
|
### Existing controls stay
|
||||||
|
|
||||||
@ -231,7 +230,7 @@ because the review looked for it.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
158 passing tests, and two hardening passes that closed real defects. The worst were
|
156 passing tests, and two hardening passes that closed real defects. The worst were
|
||||||
concurrency: one-time codes were redeemable repeatedly (753 sessions from 200
|
concurrency: one-time codes were redeemable repeatedly (753 sessions from 200
|
||||||
codes under load), and a bootstrap secret could enrol more than one key. Those
|
codes under load), and a bootstrap secret could enrol more than one key. Those
|
||||||
are now atomic compare-and-set operations, each covered by a test that races
|
are now atomic compare-and-set operations, each covered by a test that races
|
||||||
|
|||||||
@ -55,7 +55,7 @@ python3 sigil.py sweep # prune stale rows
|
|||||||
python3 -m unittest discover -s tests -v
|
python3 -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
158 tests. They cover the refusals, not just the happy paths: replayed
|
156 tests. They cover the refusals, not just the happy paths: replayed
|
||||||
signatures, wrong keys, expired nonces, single-use codes, scope escalation, and
|
signatures, wrong keys, expired nonces, single-use codes, scope escalation, and
|
||||||
the invariant below. `test_hardening.py`, `test_production.py`, `test_anchor.py` and
|
the invariant below. `test_hardening.py`, `test_production.py`, `test_anchor.py` and
|
||||||
`test_sealed.py` have one test per defect found in review, so any of them
|
`test_sealed.py` have one test per defect found in review, so any of them
|
||||||
|
|||||||
@ -207,14 +207,11 @@ def resolve(store: Store, token: str) -> dict:
|
|||||||
raise errors.SessionExpired("agent revoked")
|
raise errors.SessionExpired("agent revoked")
|
||||||
if not _identity.has_live_key(agent_row):
|
if not _identity.has_live_key(agent_row):
|
||||||
raise errors.SessionExpired("agent has no live key")
|
raise errors.SessionExpired("agent has no live key")
|
||||||
# Every session that carries a fingerprint is bound to that key epoch.
|
# Agent sessions are bound to the key epoch they were minted against.
|
||||||
# After rekey, race-surviving agent:act *and* human_via_agent bearers die
|
# After rekey, old agent:act bearers must die even if revoke_sessions raced.
|
||||||
# even if revoke_sessions missed them (N7).
|
if row["kind"] == AGENT:
|
||||||
sess_fp = row["fingerprint"] or ""
|
sess_fp = row["fingerprint"] or ""
|
||||||
live_fp = agent_row["fingerprint"] or ""
|
live_fp = agent_row["fingerprint"] or ""
|
||||||
if sess_fp or row["kind"] == AGENT:
|
|
||||||
# Agent sessions always require a matching fingerprint; human sessions
|
|
||||||
# require a match whenever a fingerprint was recorded at mint time.
|
|
||||||
if not sess_fp or sess_fp != live_fp:
|
if not sess_fp or sess_fp != live_fp:
|
||||||
raise errors.SessionExpired("session key epoch no longer current")
|
raise errors.SessionExpired("session key epoch no longer current")
|
||||||
|
|
||||||
|
|||||||
@ -144,6 +144,7 @@ def issue_code(
|
|||||||
"code_ttl": code_ttl,
|
"code_ttl": code_ttl,
|
||||||
"view_ttl": view_ttl,
|
"view_ttl": view_ttl,
|
||||||
"redeem_expires_at": now + code_ttl,
|
"redeem_expires_at": now + code_ttl,
|
||||||
|
"view_ttl": view_ttl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -528,47 +528,3 @@ class TestResolveRechecksAgentState(unittest.TestCase):
|
|||||||
identity.revoke(store, "bigpickle", reason="compromise")
|
identity.revoke(store, "bigpickle", reason="compromise")
|
||||||
with self.assertRaises(errors.SessionExpired):
|
with self.assertRaises(errors.SessionExpired):
|
||||||
sessions.resolve(store, sess["token"])
|
sessions.resolve(store, sess["token"])
|
||||||
|
|
||||||
|
|
||||||
class TestHumanSessionFingerprintEpoch(unittest.TestCase):
|
|
||||||
"""N7: rekey must kill race-surviving human_via_agent sessions too."""
|
|
||||||
|
|
||||||
def test_resolve_refuses_stale_human_fingerprint_after_rekey(self):
|
|
||||||
store, private = helpers.fresh()
|
|
||||||
old_fp = store.get_agent("bigpickle")["fingerprint"]
|
|
||||||
# Plant a human session as if redeem's put_session won a race against rekey.
|
|
||||||
import time as _t
|
|
||||||
token = "human-race-survivor"
|
|
||||||
store.put_session(
|
|
||||||
sessions.hash_token(token),
|
|
||||||
sessions.HUMAN_VIA_AGENT,
|
|
||||||
"bigpickle's human",
|
|
||||||
"bigpickle",
|
|
||||||
list(sessions.HUMAN_VIEW_SCOPES),
|
|
||||||
int(_t.time()) + 3600,
|
|
||||||
granted_by="bigpickle",
|
|
||||||
fingerprint=old_fp,
|
|
||||||
)
|
|
||||||
new_pub = keys.generate()[1]
|
|
||||||
secret = identity.seed_bootstrap(store, "bigpickle")
|
|
||||||
identity.enrol_rekey(
|
|
||||||
store, "bigpickle", keys.export_public_b64(new_pub), secret
|
|
||||||
)
|
|
||||||
# Clear revoked_at that rekey set, to isolate the fingerprint check.
|
|
||||||
store.conn.execute(
|
|
||||||
"UPDATE sessions SET revoked_at = NULL, revoked_reason = NULL"
|
|
||||||
" WHERE token_hash = ?",
|
|
||||||
(sessions.hash_token(token),),
|
|
||||||
)
|
|
||||||
store.conn.commit()
|
|
||||||
with self.assertRaises(errors.SessionExpired) as ctx:
|
|
||||||
sessions.resolve(store, token)
|
|
||||||
self.assertIn("key epoch", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_issue_code_return_has_single_view_ttl(self):
|
|
||||||
"""N8: issue_code return dict must not duplicate view_ttl."""
|
|
||||||
import inspect
|
|
||||||
from sigil import viewcodes as vc
|
|
||||||
src = inspect.getsource(vc.issue_code)
|
|
||||||
# Count literal "view_ttl" keys in the return dict construction region.
|
|
||||||
self.assertEqual(src.count('"view_ttl":'), 1, src[src.rfind("return"):])
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user