Compare commits

..

No commits in common. "leora/fix-n2-key-history" and "main" have entirely different histories.

12 changed files with 183 additions and 32 deletions

View File

@ -123,9 +123,11 @@ means a future route cannot pass a string where a proof belongs.
`revoke <agent>` with no end time is permanent: the enrolled public key is `revoke <agent>` with no end time is permanent: the enrolled public key is
cleared and the identity stays dead until someone bootstraps a fresh secret and cleared and the identity stays dead until someone bootstraps a fresh secret and
rekeys (or re-enrols). `reinstate` alone lifts the ban flags but does **not** calls `enrol_rekey` (then `reinstate` if still banned). `enrol` will not work —
restore the compromised key — that was the footgun. `revoke <agent> --until the agent row still exists — and would only burn the seed. `reinstate` alone
<unix>` is a pause that keeps the key, and it lapses on its own. lifts the ban flags but does **not** restore the compromised key — that was the
footgun. `revoke <agent> --until <unix>` is a pause that keeps the key, and it
lapses on its own.
The distinction matters because the two failure modes are different. A key The distinction matters because the two failure modes are different. A key
compromise wants death — a lapsing revocation would hand the attacker back in compromise wants death — a lapsing revocation would hand the attacker back in
@ -139,11 +141,12 @@ 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 (for use re-checks that the agent is not revoked, still has a live key, and that
agent sessions) that the session fingerprint matches the current key epoch. the session fingerprint (when present — always for agent sessions, and for
View codes store the issuing fingerprint and refuse redeem after rekey or key human_via_agent sessions minted with a fingerprint) matches the current key
wipe. That is what makes revocation overtake in-flight verify/issue/redeem, epoch. View codes store the issuing fingerprint and refuse redeem after rekey
not the first-pass invalidation alone. 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
@ -228,7 +231,7 @@ because the review looked for it.
## Status ## Status
106 passing tests, and two hardening passes that closed real defects. The worst were 158 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

View File

@ -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
``` ```
130 tests. They cover the refusals, not just the happy paths: replayed 158 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
@ -163,4 +163,4 @@ and its reasoning, and what is deferred.
`docs/PROD_RUNBOOK.md` — production checklist: require `SIGIL_SEAL_PASS` and `docs/PROD_RUNBOOK.md` — production checklist: require `SIGIL_SEAL_PASS` and
`SIGIL_ANCHOR` on separate storage, verify-chain alerts, integration sharp edges. `SIGIL_ANCHOR` on separate storage, verify-chain alerts, integration sharp edges.
`requirements.txt` — pinned `cryptography>=43.0.0,<45`. `requirements.txt` — `cryptography>=44.0.1,<45`; see `requirements.lock.txt` / `constraints.txt`.

3
constraints.txt Normal file
View File

@ -0,0 +1,3 @@
# Exact pin matching requirements.lock.txt floor. Use with:
# pip install -r requirements.txt -c constraints.txt
cryptography==44.0.1

View File

@ -39,3 +39,8 @@ store the checkpoint signer's private key in the Sigil DB — public history onl
- SQLCipher / page-level encryption for multi-tenant co-resident hosts. - SQLCipher / page-level encryption for multi-tenant co-resident hosts.
- Domain-separated challenge payloads (`sigil-challenge-v1` prefix) — backward-incompatible without a protocol version. - Domain-separated challenge payloads (`sigil-challenge-v1` prefix) — backward-incompatible without a protocol version.
- Shared cross-process rate limiter. - Shared cross-process rate limiter.
## Dependencies
Install with `pip install -r requirements.txt -c constraints.txt`
(or `--require-hashes -r requirements.lock.txt`). Floor is `cryptography>=44.0.1`.

13
requirements.lock.txt Normal file
View File

@ -0,0 +1,13 @@
# Hash-pinned lock for Sigil runtime crypto deps.
# Install: pip install --require-hashes -r requirements.lock.txt
# Floor cryptography==44.0.1 addresses CVE-2024-12797 in older wheels.
cryptography==44.0.1 \
--hash=sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41
cffi==2.1.1 \
--hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2
pycparser==3.0 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992

View File

@ -1,3 +1,5 @@
# Runtime dependency for Ed25519 + AES-GCM (SealedStore / optional key files). # Runtime dependency for Ed25519 + AES-GCM (SealedStore / optional key files).
# Pin a known-good lower bound; upper bound avoids surprise major breaks in CI. # Lower bound 44.0.1: older 43.x/44.0.0 wheels ship OpenSSL affected by
cryptography>=43.0.0,<45 # CVE-2024-12797 (GHSA-79v4-65xg-pq4g). Upper bound <45 keeps upgrades deliberate.
# For hash-pinned install see requirements.lock.txt.
cryptography>=44.0.1,<45

View File

@ -78,6 +78,10 @@ def enrol(
_validate_agent_id(agent) _validate_agent_id(agent)
if not store.allow(f"enrol:{agent}"): if not store.allow(f"enrol:{agent}"):
raise errors.RateLimited(f"too many enrolment attempts for {agent}") raise errors.RateLimited(f"too many enrolment attempts for {agent}")
# Already enrolled (e.g. after permanent revoke): refuse *before* spending
# the seed so ops following a wrong "re-enrol" path do not burn it.
if store.get_agent(agent) is not None:
raise errors.EnrolmentFailed(f"{agent} is already enrolled; use enrol_rekey")
# Spend the secret first so a burned secret cannot be retried. The agent # Spend the secret first so a burned secret cannot be retried. The agent
# row insert below is INSERT OR IGNORE: if two secrets race for the same # row insert below is INSERT OR IGNORE: if two secrets race for the same
# unenrolled agent, exactly one put_agent wins and the loser fails closed # unenrolled agent, exactly one put_agent wins and the loser fails closed
@ -285,8 +289,6 @@ def enrol_rekey(store: Store, agent: str, public_b64: str, secret: str) -> str:
Rekey does not clear revocation. A revoked agent stays revoked until an Rekey does not clear revocation. A revoked agent stays revoked until an
explicit reinstate; rotating the key must not be a back door back in. explicit reinstate; rotating the key must not be a back door back in.
""" """
if not store.allow(f"enrol:{agent}"):
raise errors.RateLimited(f"too many enrolment attempts for {agent}")
row = store.get_agent(agent) row = store.get_agent(agent)
if row is None: if row is None:
raise errors.UnknownAgent(f"{agent} is not enrolled") raise errors.UnknownAgent(f"{agent} is not enrolled")
@ -297,6 +299,10 @@ def enrol_rekey(store: Store, agent: str, public_b64: str, secret: str) -> str:
raise errors.UnknownAgent(f"{agent} is revoked") raise errors.UnknownAgent(f"{agent} is revoked")
owner = store.spend_bootstrap(_hash_secret(secret)) owner = store.spend_bootstrap(_hash_secret(secret))
if owner != agent: if owner != agent:
# Rate-limit only failed attempts so junk cannot burn the ops path, and
# use a dedicated rekey: bucket so exhausting enrol: cannot lock rekey.
if not store.allow(f"rekey:{agent}"):
raise errors.RateLimited(f"too many rekey attempts for {agent}")
raise errors.EnrolmentFailed("bootstrap secret invalid or already spent") raise errors.EnrolmentFailed("bootstrap secret invalid or already spent")
public = keys.import_public_b64(public_b64) public = keys.import_public_b64(public_b64)
fingerprint = keys.fingerprint(public) fingerprint = keys.fingerprint(public)

View File

@ -207,11 +207,14 @@ 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")
# Agent sessions are bound to the key epoch they were minted against. # Every session that carries a fingerprint is bound to that key epoch.
# After rekey, old agent:act bearers must die even if revoke_sessions raced. # After rekey, race-surviving agent:act *and* human_via_agent bearers die
if row["kind"] == AGENT: # even if revoke_sessions missed them (N7).
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")

View File

@ -148,6 +148,9 @@ RATE_LIMITS = {
"challenge": (30, 60), # 30 per 60s "challenge": (30, 60), # 30 per 60s
"verify": (60, 60), "verify": (60, 60),
"enrol": (10, 300), "enrol": (10, 300),
# Separate from enrol: junk pre-auth enrol_rekey must not lock out ops
# rotating a compromised agent during incident response.
"rekey": (10, 300),
"code_issue": (20, 300), "code_issue": (20, 300),
"code_redeem": (20, 60), "code_redeem": (20, 60),
"code_redeem_fail": (10, 300), "code_redeem_fail": (10, 300),
@ -349,9 +352,9 @@ class Store:
"""Suspend an agent. `until` of None is permanent. """Suspend an agent. `until` of None is permanent.
A permanent revoke clears the enrolled public key so `reinstate` alone A permanent revoke clears the enrolled public key so `reinstate` alone
cannot restore a compromised identity — ops must bootstrap + rekey cannot restore a compromised identity — ops must bootstrap + enrol_rekey
(or enrol) before the agent can authenticate again. Timed suspension before the agent can authenticate again. Timed suspension keeps the
keeps the key: when the pause lapses, the same key works. key: when the pause lapses, the same key works.
Returns how many live sessions were killed. Returns how many live sessions were killed.
""" """
@ -785,9 +788,13 @@ class Store:
public = _keys.public_from_private(private) public = _keys.public_from_private(private)
fingerprint = _keys.fingerprint(public) fingerprint = _keys.fingerprint(public)
from . import identity as _identity
agent_row = self.get_agent(signer) agent_row = self.get_agent(signer)
if agent_row is None: if agent_row is None:
raise ValueError(f"{signer} is not enrolled, so cannot sign a checkpoint") raise ValueError(f"{signer} is not enrolled, so cannot sign a checkpoint")
if _identity.is_revoked(agent_row):
raise ValueError(f"{signer} is revoked, so cannot sign a checkpoint")
enrolled_fp = agent_row["fingerprint"] or "" enrolled_fp = agent_row["fingerprint"] or ""
if not enrolled_fp or fingerprint != enrolled_fp: if not enrolled_fp or fingerprint != enrolled_fp:
raise ValueError( raise ValueError(

View File

@ -144,7 +144,6 @@ 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,
} }

View File

@ -381,18 +381,51 @@ class TestClaimCodeExpiryCAS(unittest.TestCase):
class TestEnrolRekeyRateLimit(unittest.TestCase): class TestEnrolRekeyRateLimit(unittest.TestCase):
"""R7: enrol_rekey must share the enrol rate-limit bucket.""" """N3: enrol_rekey uses a dedicated rekey: bucket; junk cannot lock ops."""
def test_enrol_rekey_is_rate_limited(self): def test_exhausting_enrol_bucket_does_not_block_rekey(self):
store, _ = helpers.fresh("limited") store, _ = helpers.fresh("limited")
# Exhaust enrol bucket. for _ in range(20):
for i in range(20):
store.allow("enrol:limited") store.allow("enrol:limited")
secret = identity.seed_bootstrap(store, "limited") secret = identity.seed_bootstrap(store, "limited")
with self.assertRaises(errors.RateLimited): # Valid rekey must succeed even if enrol: is exhausted.
identity.enrol_rekey( identity.enrol_rekey(
store, "limited", keys.export_public_b64(keys.generate()[1]), secret store, "limited", keys.export_public_b64(keys.generate()[1]), secret
) )
def test_junk_rekey_fills_rekey_bucket_not_enrol(self):
store, _ = helpers.fresh("victim")
for _ in range(15):
try:
identity.enrol_rekey(
store, "victim", keys.export_public_b64(keys.generate()[1]), "junk"
)
except (errors.EnrolmentFailed, errors.RateLimited):
pass
# enrol: bucket still usable
self.assertTrue(store.allow("enrol:victim"))
# Valid ops rekey still works: failures charge rekey: only after bad secret,
# and a valid secret bypasses the limiter.
secret = identity.seed_bootstrap(store, "victim")
identity.enrol_rekey(
store, "victim", keys.export_public_b64(keys.generate()[1]), secret
)
def test_failed_rekey_eventually_rate_limited(self):
store, _ = helpers.fresh("hammer")
hit = False
for _ in range(30):
try:
identity.enrol_rekey(
store, "hammer", keys.export_public_b64(keys.generate()[1]), "nope"
)
except errors.RateLimited:
hit = True
break
except errors.EnrolmentFailed:
pass
self.assertTrue(hit, "junk rekey attempts should eventually RateLimited")
class TestBootstrapRowDeletedOnSpend(unittest.TestCase): class TestBootstrapRowDeletedOnSpend(unittest.TestCase):
@ -495,3 +528,47 @@ 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"):])

View File

@ -531,3 +531,36 @@ class TestKeyHistoryKeepsCheckpointsVerifiable(unittest.TestCase):
self.assertNotIn("privkey", cols) self.assertNotIn("privkey", cols)
row = store.conn.execute("SELECT * FROM agent_keys WHERE agent = ?", ("carol",)).fetchone() row = store.conn.execute("SELECT * FROM agent_keys WHERE agent = ?", ("carol",)).fetchone()
self.assertEqual(len(row["pubkey"]), 32) self.assertEqual(len(row["pubkey"]), 32)
class TestEnrolAfterPermanentRevoke(unittest.TestCase):
"""N4: enrol must not burn the seed when the agent row still exists."""
def test_enrol_after_permanent_revoke_does_not_burn_seed(self):
store, private = helpers.fresh()
identity.revoke(store, "bigpickle", reason="compromise")
identity.reinstate(store, "bigpickle")
secret = identity.seed_bootstrap(store, "bigpickle")
with self.assertRaises(errors.EnrolmentFailed) as ctx:
identity.enrol(
store, "bigpickle", keys.export_public_b64(keys.generate()[1]), secret
)
self.assertIn("enrol_rekey", str(ctx.exception))
# Seed still usable for the correct path.
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(keys.generate()[1]), secret
)
class TestSuspendedSignerCannotCheckpoint(unittest.TestCase):
"""N5: timed-suspended signer must not checkpoint."""
def test_timed_suspend_refuses_checkpoint(self):
import time
store, private = helpers.fresh()
identity.revoke(
store, "bigpickle", until=int(time.time()) + 3600, reason="pause"
)
with self.assertRaises(ValueError) as ctx:
store.checkpoint(private, "bigpickle")
self.assertIn("revoked", str(ctx.exception).lower())