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
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
the session fingerprint (when present — always for agent sessions, and for
human_via_agent sessions minted with a 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.
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
@ -231,7 +230,7 @@ because the review looked for it.
## 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
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

View File

@ -55,7 +55,7 @@ python3 sigil.py sweep # prune stale rows
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
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
@ -163,4 +163,4 @@ and its reasoning, and what is deferred.
`docs/PROD_RUNBOOK.md` — production checklist: require `SIGIL_SEAL_PASS` and
`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.
- Domain-separated challenge payloads (`sigil-challenge-v1` prefix) — backward-incompatible without a protocol version.
- 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).
# Lower bound 44.0.1: older 43.x/44.0.0 wheels ship OpenSSL affected by
# 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
# Pin a known-good lower bound; upper bound avoids surprise major breaks in CI.
cryptography>=43.0.0,<45

View File

@ -207,14 +207,11 @@ def resolve(store: Store, token: str) -> dict:
raise errors.SessionExpired("agent revoked")
if not _identity.has_live_key(agent_row):
raise errors.SessionExpired("agent has no live key")
# Every session that carries a fingerprint is bound to that key epoch.
# After rekey, race-surviving agent:act *and* human_via_agent bearers die
# even if revoke_sessions missed them (N7).
sess_fp = 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.
# 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")

View File

@ -144,6 +144,7 @@ def issue_code(
"code_ttl": code_ttl,
"view_ttl": view_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")
with self.assertRaises(errors.SessionExpired):
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"):])