Compare commits

...

No commits in common. "main" and "leora/fix-n4-n5-n6-docs-checkpoint-suspend" have entirely different histories.

9 changed files with 16 additions and 86 deletions

View File

@ -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

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 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
@ -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

@ -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

@ -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

@ -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"):])