Compare commits

...

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

12 changed files with 32 additions and 183 deletions

View File

@ -123,11 +123,9 @@ 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
calls `enrol_rekey` (then `reinstate` if still banned). `enrol` will not work — rekeys (or re-enrols). `reinstate` alone lifts the ban flags but does **not**
the agent row still exists — and would only burn the seed. `reinstate` alone restore the compromised key — that was the footgun. `revoke <agent> --until
lifts the ban flags but does **not** restore the compromised key — that was the <unix>` is a pause that keeps the key, and it lapses on its own.
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
@ -141,12 +139,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 +228,7 @@ because the review looked for it.
## Status ## Status
158 passing tests, and two hardening passes that closed real defects. The worst were 106 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
``` ```
158 tests. They cover the refusals, not just the happy paths: replayed 130 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` — `cryptography>=44.0.1,<45`; see `requirements.lock.txt` / `constraints.txt`. `requirements.txt` — pinned `cryptography>=43.0.0,<45`.

View File

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

View File

@ -39,8 +39,3 @@ 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`.

View File

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

View File

@ -78,10 +78,6 @@ 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
@ -289,6 +285,8 @@ 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")
@ -299,10 +297,6 @@ 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,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")

View File

@ -148,9 +148,6 @@ 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),
@ -352,9 +349,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 + enrol_rekey cannot restore a compromised identity — ops must bootstrap + rekey
before the agent can authenticate again. Timed suspension keeps the (or enrol) before the agent can authenticate again. Timed suspension
key: when the pause lapses, the same key works. keeps the key: when the pause lapses, the same key works.
Returns how many live sessions were killed. Returns how many live sessions were killed.
""" """
@ -788,13 +785,9 @@ 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,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,
} }

View File

@ -381,51 +381,18 @@ class TestClaimCodeExpiryCAS(unittest.TestCase):
class TestEnrolRekeyRateLimit(unittest.TestCase): class TestEnrolRekeyRateLimit(unittest.TestCase):
"""N3: enrol_rekey uses a dedicated rekey: bucket; junk cannot lock ops.""" """R7: enrol_rekey must share the enrol rate-limit bucket."""
def test_exhausting_enrol_bucket_does_not_block_rekey(self): def test_enrol_rekey_is_rate_limited(self):
store, _ = helpers.fresh("limited") store, _ = helpers.fresh("limited")
for _ in range(20): # Exhaust enrol bucket.
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")
# Valid rekey must succeed even if enrol: is exhausted. with self.assertRaises(errors.RateLimited):
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):
@ -528,47 +495,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"):])

View File

@ -531,36 +531,3 @@ 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())