Compare commits

...

No commits in common. "leora/fix-n1-resolve-recheck" and "main" have entirely different histories.

12 changed files with 372 additions and 57 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
@ -201,9 +204,15 @@ history, forges replacements, and rebuilds the chain — `verify_chain` passes.
So the head digest also gets **signed checkpoints**, by an enrolled agent whose So the head digest also gets **signed checkpoints**, by an enrolled agent whose
private key the database does not contain. `verify-chain` runs both checks, and private key the database does not contain. `verify-chain` runs both checks, and
the rewrite is caught by the signature the attacker cannot produce. A forged the rewrite is caught by the signature the attacker cannot produce. A forged
checkpoint fails signature verification, a checkpoint from a stranger is refused checkpoint fails signature verification, and a checkpoint from a stranger is
at signing time, and a rekey invalidates every earlier checkpoint — which is refused at signing time.
correct, and says so rather than failing quietly.
Public keys are kept in an append-only `agent_keys` history. Revoke and rekey
*retire* a key rather than destroy it, so honest pre-compromise checkpoints and
off-box anchors stay verifiable against the fingerprint they name. Checkpoints
timestamped after a key's `retired_at` are flagged (not failed). Prefer a
**dedicated checkpoint-signer identity** that is never a working agent, so a
routine agent compromise-revoke does not touch the tip-signing key at all.
What this still does not do is survive an attacker who can also delete the What this still does not do is survive an attacker who can also delete the
checkpoints, because they live in the same database. Set `anchor_path` / checkpoints, because they live in the same database. Set `anchor_path` /
@ -222,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

@ -13,6 +13,14 @@ enable platform glue until an integration review covers session minting.
Optional: agent private keys stay **off** the platform host whenever possible; checkpoint signer key preferably offline. Optional: agent private keys stay **off** the platform host whenever possible; checkpoint signer key preferably offline.
## Dedicated checkpoint-signer identity
Enrol a separate agent id used **only** for `checkpoint` / `publish_anchor`
(for example `sigil-checkpoint`). Do not use a working agent as the tip signer:
compromising and permanently revoking that agent would otherwise retire its key
(history still verifies, but ops alerting on "key retired" gets noisy). Never
store the checkpoint signer's private key in the Sigil DB — public history only.
## Ops loop ## Ops loop
1. Open with seal + anchor configured (CLI respects the env vars above). 1. Open with seal + anchor configured (CLI respects the env vars above).
@ -31,3 +39,8 @@ Optional: agent private keys stay **off** the platform host whenever possible; c
- 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

@ -36,6 +36,20 @@ CREATE TABLE IF NOT EXISTS agents (
revoked_reason TEXT revoked_reason TEXT
); );
-- Append-only public-key history. Revoke and rekey *retire* a key instead of
-- destroying it, so historical checkpoints and off-box anchors stay verifiable.
-- Never stores private keys.
CREATE TABLE IF NOT EXISTS agent_keys (
agent TEXT NOT NULL,
fingerprint TEXT NOT NULL,
pubkey BLOB NOT NULL,
active_from INTEGER NOT NULL,
retired_at INTEGER,
retired_reason TEXT,
PRIMARY KEY (agent, fingerprint)
);
CREATE INDEX IF NOT EXISTS agent_keys_agent ON agent_keys(agent, active_from);
-- Enrolment bootstrap secrets. Spent secrets are deleted, not marked, so a -- Enrolment bootstrap secrets. Spent secrets are deleted, not marked, so a
-- spent secret cannot be replayed even if the table is read. -- spent secret cannot be replayed even if the table is read.
CREATE TABLE IF NOT EXISTS bootstrap ( CREATE TABLE IF NOT EXISTS bootstrap (
@ -134,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),
@ -226,6 +243,17 @@ class Store:
} }
if "fingerprint" not in code_cols: if "fingerprint" not in code_cols:
self.conn.execute("ALTER TABLE codes ADD COLUMN fingerprint TEXT") self.conn.execute("ALTER TABLE codes ADD COLUMN fingerprint TEXT")
# Backfill agent_keys from current agents rows (live or wiped-empty skipped).
for row in self.conn.execute(
"SELECT agent, pubkey, fingerprint, enrolled_at FROM agents"
" WHERE fingerprint IS NOT NULL AND fingerprint != ''"
" AND length(pubkey) = 32"
):
self.conn.execute(
"INSERT OR IGNORE INTO agent_keys"
" (agent, fingerprint, pubkey, active_from) VALUES (?, ?, ?, ?)",
(row["agent"], row["fingerprint"], row["pubkey"], row["enrolled_at"]),
)
self.conn.commit() self.conn.commit()
def close(self) -> None: def close(self) -> None:
@ -262,12 +290,19 @@ class Store:
which preserves revocation columns. which preserves revocation columns.
""" """
with self._write_lock: with self._write_lock:
now = int(time.time())
cur = self.conn.execute( cur = self.conn.execute(
"INSERT OR IGNORE INTO agents" "INSERT OR IGNORE INTO agents"
" (agent, pubkey, fingerprint, enrolled_at)" " (agent, pubkey, fingerprint, enrolled_at)"
" VALUES (?, ?, ?, ?)", " VALUES (?, ?, ?, ?)",
(agent, pubkey, fingerprint, int(time.time())), (agent, pubkey, fingerprint, now),
) )
if cur.rowcount == 1:
self.conn.execute(
"INSERT OR IGNORE INTO agent_keys"
" (agent, fingerprint, pubkey, active_from) VALUES (?, ?, ?, ?)",
(agent, fingerprint, pubkey, now),
)
self.conn.commit() self.conn.commit()
return cur.rowcount == 1 return cur.rowcount == 1
@ -279,10 +314,33 @@ class Store:
used to un-revoke. An UPDATE of only the key columns keeps the ban intact. used to un-revoke. An UPDATE of only the key columns keeps the ban intact.
""" """
with self._write_lock: with self._write_lock:
now = int(time.time())
prev = self.conn.execute(
"SELECT pubkey, fingerprint, enrolled_at FROM agents WHERE agent = ?",
(agent,),
).fetchone()
cur = self.conn.execute( cur = self.conn.execute(
"UPDATE agents SET pubkey = ?, fingerprint = ? WHERE agent = ?", "UPDATE agents SET pubkey = ?, fingerprint = ? WHERE agent = ?",
(pubkey, fingerprint, agent), (pubkey, fingerprint, agent),
) )
if cur.rowcount == 1 and prev is not None:
old_fp = prev["fingerprint"] or ""
if old_fp and len(prev["pubkey"] or b"") == 32:
self.conn.execute(
"INSERT OR IGNORE INTO agent_keys"
" (agent, fingerprint, pubkey, active_from) VALUES (?, ?, ?, ?)",
(agent, old_fp, prev["pubkey"], prev["enrolled_at"] or now),
)
self.conn.execute(
"UPDATE agent_keys SET retired_at = ?, retired_reason = ?"
" WHERE agent = ? AND fingerprint = ? AND retired_at IS NULL",
(now, "rekey", agent, old_fp),
)
self.conn.execute(
"INSERT OR IGNORE INTO agent_keys"
" (agent, fingerprint, pubkey, active_from) VALUES (?, ?, ?, ?)",
(agent, fingerprint, pubkey, now),
)
self.conn.commit() self.conn.commit()
return cur.rowcount == 1 return cur.rowcount == 1
@ -294,20 +352,39 @@ 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.
""" """
with self._write_lock: with self._write_lock:
if until is None: if until is None:
# Permanent: wipe key material. Empty fingerprint means # Permanent: retire the public key into agent_keys (so historical
# "pending rekey" — see identity.has_live_key. # checkpoints stay verifiable), then wipe the live slot.
now = int(time.time())
prev = self.conn.execute(
"SELECT pubkey, fingerprint, enrolled_at FROM agents WHERE agent = ?",
(agent,),
).fetchone()
if prev is not None:
old_fp = prev["fingerprint"] or ""
if old_fp and len(prev["pubkey"] or b"") == 32:
self.conn.execute(
"INSERT OR IGNORE INTO agent_keys"
" (agent, fingerprint, pubkey, active_from)"
" VALUES (?, ?, ?, ?)",
(agent, old_fp, prev["pubkey"], prev["enrolled_at"] or now),
)
self.conn.execute(
"UPDATE agent_keys SET retired_at = ?, retired_reason = ?"
" WHERE agent = ? AND fingerprint = ? AND retired_at IS NULL",
(now, reason or "permanent revoke", agent, old_fp),
)
self.conn.execute( self.conn.execute(
"UPDATE agents SET revoked_at = ?, revoked_until = ?, revoked_reason = ?," "UPDATE agents SET revoked_at = ?, revoked_until = ?, revoked_reason = ?,"
" pubkey = ?, fingerprint = ? WHERE agent = ?", " pubkey = ?, fingerprint = ? WHERE agent = ?",
(int(time.time()), until, reason, b"", "", agent), (now, until, reason, b"", "", agent),
) )
else: else:
self.conn.execute( self.conn.execute(
@ -663,7 +740,26 @@ class Store:
} }
) )
return out return out
def get_key_by_fingerprint(self, agent: str, fingerprint: str):
"""Return a historical (or live) public key row for verify.
Prefers agent_keys history. Falls back to the live agents row when the
fingerprint still matches (covers DBs created before agent_keys).
Never returns private key material — pubkey only.
"""
row = self._one(
"SELECT * FROM agent_keys WHERE agent = ? AND fingerprint = ?",
(agent, fingerprint),
)
if row is not None:
return row
live = self.get_agent(agent)
if live is not None and (live["fingerprint"] or "") == fingerprint:
return live
return None
# -- checkpoints ------------------------------------------------------- # -- checkpoints -------------------------------------------------------
@staticmethod @staticmethod
def _checkpoint_payload(at: int, label: str, head: str, events: int, def _checkpoint_payload(at: int, label: str, head: str, events: int,
signer: str, fingerprint: str) -> bytes: signer: str, fingerprint: str) -> bytes:
@ -692,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(
@ -764,17 +864,20 @@ class Store:
if not notices or notices[-1].get("head") != tip.get("head"): if not notices or notices[-1].get("head") != tip.get("head"):
notices = list(notices) + [tip] notices = list(notices) + [tip]
results = [] results = []
warnings = []
for notice in notices: for notice in notices:
agent_row = self.get_agent(notice["signer"]) if self.get_agent(notice["signer"]) is None:
if agent_row is None:
results.append({"ok": False, "head": notice.get("head"), results.append({"ok": False, "head": notice.get("head"),
"reason": f"signer {notice['signer']} no longer enrolled"}) "reason": f"signer {notice['signer']} no longer enrolled"})
continue continue
if agent_row["fingerprint"] != notice.get("fingerprint"): key_row = self.get_key_by_fingerprint(
notice["signer"], notice.get("fingerprint") or ""
)
if key_row is None:
results.append({"ok": False, "head": notice.get("head"), results.append({"ok": False, "head": notice.get("head"),
"reason": "signer key changed since the tip was published"}) "reason": "signer key for this fingerprint is unknown"})
continue continue
public = _keys.from_public_bytes(agent_row["pubkey"]) public = _keys.from_public_bytes(key_row["pubkey"])
checked = _anchor.verify_notice(notice, public) checked = _anchor.verify_notice(notice, public)
if not checked["ok"]: if not checked["ok"]:
results.append({"ok": False, "head": notice.get("head"), results.append({"ok": False, "head": notice.get("head"),
@ -794,11 +897,17 @@ class Store:
"(database rewrite cannot erase the off-box tip)", "(database rewrite cannot erase the off-box tip)",
}) })
continue continue
results.append({"ok": True, "head": notice["head"], entry = {"ok": True, "head": notice["head"],
"events": notice["events"], "signer": notice["signer"]}) "events": notice["events"], "signer": notice["signer"]}
retired_at = key_row["retired_at"] if "retired_at" in key_row.keys() else None
if retired_at is not None and notice.get("at", 0) > retired_at:
entry["flagged"] = True
entry["reason"] = "notice timestamp is after the signing key was retired"
warnings.append(entry)
results.append(entry)
bad = [r for r in results if not r["ok"]] bad = [r for r in results if not r["ok"]]
return {"ok": not bad, "anchors": len(results), "failures": bad, return {"ok": not bad, "anchors": len(results), "failures": bad,
"tip": tip, "path": self.anchor_path} "warnings": warnings, "tip": tip, "path": self.anchor_path}
def checkpoints(self) -> List[Dict[str, Any]]: def checkpoints(self) -> List[Dict[str, Any]]:
rows = self.conn.execute("SELECT * FROM checkpoints ORDER BY id ASC").fetchall() rows = self.conn.execute("SELECT * FROM checkpoints ORDER BY id ASC").fetchall()
@ -814,22 +923,23 @@ class Store:
from . import keys as _keys from . import keys as _keys
results = [] results = []
warnings = []
for row in self.conn.execute("SELECT * FROM checkpoints ORDER BY id ASC"): for row in self.conn.execute("SELECT * FROM checkpoints ORDER BY id ASC"):
agent_row = self.get_agent(row["signer"]) if self.get_agent(row["signer"]) is None:
if agent_row is None:
results.append({"id": row["id"], "ok": False, results.append({"id": row["id"], "ok": False,
"reason": f"signer {row['signer']} no longer enrolled"}) "reason": f"signer {row['signer']} no longer enrolled"})
continue continue
if agent_row["fingerprint"] != row["fingerprint"]: key_row = self.get_key_by_fingerprint(row["signer"], row["fingerprint"])
if key_row is None:
results.append({"id": row["id"], "ok": False, results.append({"id": row["id"], "ok": False,
"reason": "signer key changed since the checkpoint was made"}) "reason": "signer key for this fingerprint is unknown"})
continue continue
payload = self._checkpoint_payload( payload = self._checkpoint_payload(
row["at"], row["label"], row["head"], row["events"], row["at"], row["label"], row["head"], row["events"],
row["signer"], row["fingerprint"], row["signer"], row["fingerprint"],
) )
try: try:
public = _keys.from_public_bytes(agent_row["pubkey"]) public = _keys.from_public_bytes(key_row["pubkey"])
# verify() returns None on success and raises on failure, so it # verify() returns None on success and raises on failure, so it
# must be called, not inspected. # must be called, not inspected.
public.verify(base64.b64decode(row["signature"]), payload) public.verify(base64.b64decode(row["signature"]), payload)
@ -847,9 +957,16 @@ class Store:
results.append({"id": row["id"], "ok": False, results.append({"id": row["id"], "ok": False,
"reason": "chain head at that position no longer matches"}) "reason": "chain head at that position no longer matches"})
continue continue
results.append({"id": row["id"], "ok": True}) entry = {"id": row["id"], "ok": True}
retired_at = key_row["retired_at"] if "retired_at" in key_row.keys() else None
if retired_at is not None and row["at"] > retired_at:
entry["flagged"] = True
entry["reason"] = "checkpoint timestamp is after the signing key was retired"
warnings.append(entry)
results.append(entry)
bad = [r for r in results if not r["ok"]] bad = [r for r in results if not r["ok"]]
return {"ok": not bad, "checkpoints": len(results), "failures": bad} return {"ok": not bad, "checkpoints": len(results),
"failures": bad, "warnings": warnings}
# -- housekeeping ------------------------------------------------------ # -- housekeeping ------------------------------------------------------
def sweep(self) -> Dict[str, int]: def sweep(self) -> Dict[str, int]:

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

@ -142,15 +142,16 @@ class TestSignedCheckpoints(unittest.TestCase):
self.assertIn("does not match enrolled key", str(ctx.exception)) self.assertIn("does not match enrolled key", str(ctx.exception))
self.assertEqual(len(self.store.checkpoints()), before) self.assertEqual(len(self.store.checkpoints()), before)
def test_checkpoint_after_rekey_is_flagged(self): def test_checkpoint_after_rekey_still_verifies_via_history(self):
# N2: historical checkpoints verify against retired public keys.
secret = identity.seed_bootstrap(self.store, "bigpickle") secret = identity.seed_bootstrap(self.store, "bigpickle")
identity.enrol_rekey( identity.enrol_rekey(
self.store, "bigpickle", self.store, "bigpickle",
keys.export_public_b64(keys.generate()[1]), secret, keys.export_public_b64(keys.generate()[1]), secret,
) )
result = self.store.verify_checkpoints() result = self.store.verify_checkpoints()
self.assertFalse(result["ok"]) self.assertTrue(result["ok"], result)
self.assertIn("key changed", result["failures"][0]["reason"]) self.assertEqual(result["failures"], [])
def test_empty_log_checkpoints_cleanly(self): def test_empty_log_checkpoints_cleanly(self):
store, private = helpers.fresh() store, private = helpers.fresh()
@ -491,3 +492,75 @@ class TestRedeemRateLimitIsolation(unittest.TestCase):
self.fail("successes must not share the fail bucket") self.fail("successes must not share the fail bucket")
except errors.SigilError: except errors.SigilError:
pass pass
class TestKeyHistoryKeepsCheckpointsVerifiable(unittest.TestCase):
"""N2: permanent revoke / rekey must not make honest checkpoints unverifiable."""
def test_checkpoints_verify_after_signer_permanent_revoke(self):
import tempfile, os
d = tempfile.mkdtemp()
anchor = os.path.join(d, "tip")
store = Store(":memory:", anchor_path=anchor)
private, _ = helpers.enrol(store, "signer")
store.record("identity.enrolled", actor="signer", subject="signer")
store.checkpoint(private, "signer", "pre-compromise")
before = store.verify_checkpoints()
self.assertTrue(before["ok"], before)
anchors_before = store.verify_external_anchors()
self.assertTrue(anchors_before["ok"], anchors_before)
identity.revoke(store, "signer", reason="compromise")
after = store.verify_checkpoints()
self.assertTrue(after["ok"], after)
self.assertEqual(after["failures"], [])
anchors_after = store.verify_external_anchors()
self.assertTrue(anchors_after["ok"], anchors_after)
# Live key wiped; history retained.
self.assertFalse(identity.has_live_key(store.get_agent("signer")))
hist = store.conn.execute(
"SELECT fingerprint, retired_at FROM agent_keys WHERE agent = ?",
("signer",),
).fetchall()
self.assertEqual(len(hist), 1)
self.assertIsNotNone(hist[0]["retired_at"])
def test_private_key_never_in_agent_keys(self):
store, private = helpers.fresh("carol")
cols = [r["name"] for r in store.conn.execute("PRAGMA table_info(agent_keys)")]
self.assertNotIn("private", cols)
self.assertNotIn("privkey", cols)
row = store.conn.execute("SELECT * FROM agent_keys WHERE agent = ?", ("carol",)).fetchone()
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())