Compare commits

...

No commits in common. "leora/fix-r3-reinstate-requires-rekey" and "main" have entirely different histories.

14 changed files with 755 additions and 81 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
@ -134,10 +136,17 @@ wrong moment. A noisy neighbour or a holiday wants a pause, and re-enrolling for
that would destroy the audit history that says it was deliberate. that would destroy the audit history that says it was deliberate.
Revoking kills live sessions, drops outstanding challenges, and invalidates Revoking kills live sessions, drops outstanding challenges, and invalidates
unused view codes, so a revocation that races an in-flight authentication unused view codes. Rekey invalidates codes the same way: rotating a key must
cannot be overtaken by it, and a pre-issued code cannot mint a fresh human close every door the old key could open.
session after the agent is suspended or dead. Rekey invalidates codes the same
way: rotating a key must 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.
### Existing controls stay ### Existing controls stay
@ -195,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` /
@ -216,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
@ -229,7 +244,7 @@ sessions mintable from a bare agent name, no revocation operation at all, an
unkeyed chain that a database-level attacker could rebuild, and SQLite without unkeyed chain that a database-level attacker could rebuild, and SQLite without
a busy timeout. a busy timeout.
Leora's contracted prod pass (Quarry 18a5d762) closed three remaining review blockers on this branch: rekey no longer un-revokes, dual-bootstrap enrol is exactly-one under race, and redeem rate limits are per-agent (anonymous fails isolated from success). Off-box checkpoint tip publish/verify ships when `anchor_path` / `SIGIL_ANCHOR` is set. SealedStore / `SIGIL_SEAL_PASS` seals the DB file with AES-256-GCM (incremental path short of SQLCipher). Independent review and platform glue remain. Leora's contracted prod pass (Quarry 18a5d762) closed three remaining review blockers on this branch: rekey no longer un-revokes, dual-bootstrap enrol is exactly-one under race, and redeem rate limits are per-agent (anonymous fails isolated from success). Off-box checkpoint tip publish/verify ships when `anchor_path` / `SIGIL_ANCHOR` is set. SealedStore / `SIGIL_SEAL_PASS` seals the DB file with AES-256-GCM (incremental path short of SQLCipher). Independent review returned GO-WITH-FIXES; R1–R8/R11 fixes on this branch. Platform glue remains off.
**Still not production ready**, and the reason is not a to-do list: **Still not production ready**, and the reason is not a to-do list:
@ -241,7 +256,8 @@ Leora's contracted prod pass (Quarry 18a5d762) closed three remaining review blo
rewrite that in-DB checkpoints alone would miss after deletion. Leave it unset rewrite that in-DB checkpoints alone would miss after deletion. Leave it unset
and the tip stays in-DB only. and the tip stays in-DB only.
- **The service is unwritten.** TLS, CSRF, transport, routes. DB file seal is - **The service is unwritten.** TLS, CSRF, transport, routes. DB file seal is
available (`SealedStore`); SQLCipher page-level encryption is not. available (`SealedStore`, plaintext working copy `0600`); SQLCipher
page-level encryption is deferred (not yet available).
- **Rate limits are per-process**, so N processes means N times the quota. - **Rate limits are per-process**, so N processes means N times the quota.
- **No key recovery.** Rotate and old sessions die. Deliberate. - **No key recovery.** Rotate and old sessions die. Deliberate.
- **Humans remain unattributable** to themselves, which is accepted and is a - **Humans remain unattributable** to themselves, which is accepted and is a
@ -251,11 +267,27 @@ The tests show the logic is correct. They do not make it safe.
## Next ## Next
1. Independent security review before this guards anything real. Two hardening 1. ~~Independent security review~~ — independent review returned GO-WITH-FIXES
passes made it reviewable; neither substituted for one. (tip c0027bb); fix PRs for R1–R8/R11 landed on `sigil-initial`. Platform glue
remains off until integration review. See `docs/PROD_RUNBOOK.md`.
2. ~~Anchor the checkpoint head digest outside the database~~ — done via 2. ~~Anchor the checkpoint head digest outside the database~~ — done via
`Store(..., anchor_path=)` / `SIGIL_ANCHOR` (signed tip file + `.log`). `Store(..., anchor_path=)` / `SIGIL_ANCHOR` (signed tip file + `.log`).
**Prod must set it** (see runbook).
3. Secret broker on top — scoped, short-lived leases bound to a key fingerprint. 3. Secret broker on top — scoped, short-lived leases bound to a key fingerprint.
Its issuance and redemption logs *are* the work-attestation substrate, so it Its issuance and redemption logs *are* the work-attestation substrate, so it
is the natural second piece. is the natural second piece.
4. Deprecate passcode login across the platform, then rotate live credentials. 4. Deprecate passcode login across the platform, then rotate live credentials.
### Info / follow-ups (no code change required yet)
- **R9 — `issue_agent_session`:** library helper mints `agent:act` from a name
after the caller claims to have verified. Keep internal; integration must
never mint from headers/names. Consider renaming to `_issue_agent_session`.
- **R10 — challenge domain separation:** signatures currently cover
`nonce.encode()` with no context prefix. Adding
`b"sigil-challenge-v1\0" + nonce` is not backward-compatible with in-flight
challenges; schedule with a protocol version bump (follow-up).
- **R12 — multi-process rate limits:** quotas are per-`Store` / per-process.
Single writer until a shared limiter exists (documented in README + runbook).
- **R13 — opt-in anchor:** without `SIGIL_ANCHOR`, deleting in-DB checkpoints
allows silent history rewrite. Runbook requires the anchor on separate storage.

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
@ -137,7 +137,9 @@ Still open, and these are the reasons not to ship it yet:
- **The service around this is unwritten.** TLS, CSRF, session transport and the - **The service around this is unwritten.** TLS, CSRF, session transport and the
HTTP routes. DB encryption at rest is available via `SealedStore` / HTTP routes. DB encryption at rest is available via `SealedStore` /
`SIGIL_SEAL_PASS` (AES-256-GCM file seal; not SQLCipher page-level). The `SIGIL_SEAL_PASS` (AES-256-GCM file seal; not SQLCipher page-level). The
library refuses to mint authority from a name, so a route that forgets to plaintext working copy is mode `0600` in a private temp dir while open;
SQLCipher remains deferred for hostile co-resident hosts. The library
refuses to mint authority from a name, so a route that forgets to
authenticate fails closed, but that is only as good as the integration. authenticate fails closed, but that is only as good as the integration.
- **Off-box anchor is opt-in.** Set `SIGIL_ANCHOR` (or `Store(..., anchor_path=)`) - **Off-box anchor is opt-in.** Set `SIGIL_ANCHOR` (or `Store(..., anchor_path=)`)
and every checkpoint also writes a signed tip outside the SQLite file. and every checkpoint also writes a signed tip outside the SQLite file.
@ -157,3 +159,8 @@ The tests assert the logic is correct. They do not make it safe.
`DESIGN.md` — the design page: rationale, flows, session stamps, the invariant `DESIGN.md` — the design page: rationale, flows, session stamps, the invariant
and its reasoning, and what is deferred. 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`.

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

46
docs/PROD_RUNBOOK.md Normal file
View File

@ -0,0 +1,46 @@
# Sigil production runbook
Short checklist before Sigil guards anything real. Library-only today — do **not**
enable platform glue until an integration review covers session minting.
## Required environment
| Variable | Purpose | Notes |
|----------|---------|--------|
| `SIGIL_DB` | Path to the store file | Prefer a dedicated volume; backups are sensitive (roster + audit). |
| `SIGIL_SEAL_PASS` | AES-256-GCM seal passphrase | **Required in prod.** ≥12 chars. Without it the DB file is cleartext on disk. Seal-on still uses a plaintext `0600` working copy while the process is open — not SQLCipher. |
| `SIGIL_ANCHOR` | Off-box signed tip path | **Required in prod.** Must be on storage **separate** from `SIGIL_DB` (different volume/host). Every checkpoint publishes here; `verify_external_anchors` / `verify-chain` must alert on failure. |
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
1. Open with seal + anchor configured (CLI respects the env vars above).
2. Periodically run chain verify **and** external-anchor verify; page on failure.
3. After permanent revoke (compromise): bootstrap + `enrol_rekey`, then `reinstate`. Reinstate alone does not restore a wiped key.
4. Single writer process until a shared rate-limit store exists (in-process quotas multiply across processes).
## Integration sharp edges (do not ignore)
- Never mint `issue_agent_session` from a client-supplied name or header. Only after a verified challenge-response. Prefer keeping that helper internal to verified paths.
- Human view codes are ~40 bits; keep TTL short; never log raw codes (already hashed in audit).
- Challenge signatures today sign the raw nonce (no domain separation). Changing the signed payload needs a coordinated version bump — tracked as a follow-up, not a silent break.
## Deferred
- 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`.

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

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
# 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

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

@ -11,6 +11,11 @@ working copy is plaintext in memory / a temp file while open). It is a real
working seal: the file on disk is ciphertext, and tests assert a stolen seal working seal: the file on disk is ciphertext, and tests assert a stolen seal
has no SQLite header and no recoverable agent rows without the passphrase. has no SQLite header and no recoverable agent rows without the passphrase.
The plaintext working copy is created mode 0600 inside a private temp dir.
SQLCipher (or equivalent page-level encryption) remains a deferred prod
follow-up for multi-tenant / hostile co-resident hosts — never treat seal-on
as "encrypted DB" while the process is running.
Format on disk (version 1): Format on disk (version 1):
b"SIGILSEAL1" + 16-byte salt + 12-byte nonce + AESGCM(ciphertext) b"SIGILSEAL1" + 16-byte salt + 12-byte nonce + AESGCM(ciphertext)
@ -83,6 +88,30 @@ def is_sealed_file(path: str) -> bool:
return handle.read(len(MAGIC)) == MAGIC return handle.read(len(MAGIC)) == MAGIC
def _write_private_file(path: str, data: bytes) -> None:
"""Write `data` to `path` with mode 0600, fsync, unlink on failure."""
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
fd = os.open(path, flags, 0o600)
try:
with os.fdopen(fd, "wb") as handle:
fd = -1 # ownership transferred
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.chmod(path, 0o600)
except Exception:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(path)
except OSError:
pass
raise
class SealedStore(Store): class SealedStore(Store):
"""Store whose on-disk file is AES-GCM sealed. """Store whose on-disk file is AES-GCM sealed.
@ -107,6 +136,8 @@ class SealedStore(Store):
self.seal_path = path self.seal_path = path
self._passphrase = passphrase self._passphrase = passphrase
self._tmpdir = tempfile.TemporaryDirectory(prefix="sigil-seal-") self._tmpdir = tempfile.TemporaryDirectory(prefix="sigil-seal-")
# TemporaryDirectory is already 0700; pin the dir mode explicitly.
os.chmod(self._tmpdir.name, 0o700)
self._plain_path = os.path.join(self._tmpdir.name, "store.db") self._plain_path = os.path.join(self._tmpdir.name, "store.db")
if os.path.exists(path): if os.path.exists(path):
with open(path, "rb") as handle: with open(path, "rb") as handle:
@ -117,9 +148,16 @@ class SealedStore(Store):
"refusing to treat a cleartext DB as sealed" "refusing to treat a cleartext DB as sealed"
) )
plain = unseal_bytes(blob, passphrase) plain = unseal_bytes(blob, passphrase)
with open(self._plain_path, "wb") as handle: _write_private_file(self._plain_path, plain)
handle.write(plain) else:
# Touch an empty 0600 file so SQLite does not create a world-readable DB.
_write_private_file(self._plain_path, b"")
super().__init__(self._plain_path, anchor_path=anchor_path) super().__init__(self._plain_path, anchor_path=anchor_path)
# Re-pin after SQLite open (WAL/SHM sidecars inherit umask otherwise).
try:
os.chmod(self._plain_path, 0o600)
except OSError:
pass
def flush(self) -> str: def flush(self) -> str:
"""Checkpoint WAL, read plaintext bytes, write sealed blob to seal_path.""" """Checkpoint WAL, read plaintext bytes, write sealed blob to seal_path."""
@ -130,14 +168,24 @@ class SealedStore(Store):
dest_path = os.path.join(self._tmpdir.name, "export.db") dest_path = os.path.join(self._tmpdir.name, "export.db")
if os.path.exists(dest_path): if os.path.exists(dest_path):
os.unlink(dest_path) os.unlink(dest_path)
# Pre-create 0600 so SQLite backup does not leave a world-readable export.
_write_private_file(dest_path, b"")
dest = sqlite3.connect(dest_path) dest = sqlite3.connect(dest_path)
try: try:
self.conn.backup(dest) self.conn.backup(dest)
dest.commit() dest.commit()
finally: finally:
dest.close() dest.close()
try:
os.chmod(dest_path, 0o600)
except OSError:
pass
with open(dest_path, "rb") as handle: with open(dest_path, "rb") as handle:
plain = handle.read() plain = handle.read()
try:
os.unlink(dest_path)
except OSError:
pass
blob = seal_bytes(plain, self._passphrase) blob = seal_bytes(plain, self._passphrase)
directory = os.path.dirname(os.path.abspath(self.seal_path)) directory = os.path.dirname(os.path.abspath(self.seal_path))
if directory: if directory:

View File

@ -105,9 +105,16 @@ class GrantedCode:
agent session (issue_code) or a valid code (redeem_code). agent session (issue_code) or a valid code (redeem_code).
""" """
__slots__ = ("agent", "scopes", "ttl", "_sigil_internal") __slots__ = ("agent", "scopes", "ttl", "fingerprint", "_sigil_internal")
def __init__(self, agent: str, scopes: List[str], ttl: int, _internal=None): def __init__(
self,
agent: str,
scopes: List[str],
ttl: int,
_internal=None,
fingerprint: Optional[str] = None,
):
if _internal is not _ISSUER: if _internal is not _ISSUER:
raise errors.SigilError( raise errors.SigilError(
"GrantedCode cannot be constructed directly; it comes from" "GrantedCode cannot be constructed directly; it comes from"
@ -116,6 +123,7 @@ class GrantedCode:
self.agent = agent self.agent = agent
self.scopes = list(scopes) self.scopes = list(scopes)
self.ttl = ttl self.ttl = ttl
self.fingerprint = fingerprint
self._sigil_internal = _internal self._sigil_internal = _internal
def __repr__(self) -> str: def __repr__(self) -> str:
@ -151,6 +159,7 @@ def issue_view_session(store: Store, grant: "GrantedCode") -> dict:
granted, granted,
expires, expires,
granted_by=agent, granted_by=agent,
fingerprint=grant.fingerprint,
) )
store.record( store.record(
"session.issued", "session.issued",
@ -176,7 +185,14 @@ def issue_view_session(store: Store, grant: "GrantedCode") -> dict:
def resolve(store: Store, token: str) -> dict: def resolve(store: Store, token: str) -> dict:
"""Look up a session and reject it if revoked or expired.""" """Look up a session and reject it if revoked, expired, or the agent died.
The session row alone is not enough: an in-flight verify/issue_code can
insert a session or code after revoke/rekey has already run. Re-check the
agent on every resolve so a race-surviving bearer cannot keep acting.
"""
from . import identity as _identity
row = store.get_session(hash_token(token)) row = store.get_session(hash_token(token))
if row is None: if row is None:
raise errors.SessionUnknown("no such session") raise errors.SessionUnknown("no such session")
@ -184,6 +200,24 @@ def resolve(store: Store, token: str) -> dict:
raise errors.SessionExpired("session revoked") raise errors.SessionExpired("session revoked")
if time.time() >= row["expires_at"]: if time.time() >= row["expires_at"]:
raise errors.SessionExpired("session expired") raise errors.SessionExpired("session expired")
agent = row["on_behalf_of"]
agent_row = store.get_agent(agent)
if agent_row is None or _identity.is_revoked(agent_row):
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.
if not sess_fp or sess_fp != live_fp:
raise errors.SessionExpired("session key epoch no longer current")
return { return {
"token": token, "token": token,
"kind": row["kind"], "kind": row["kind"],

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 (
@ -77,7 +91,11 @@ CREATE TABLE IF NOT EXISTS codes (
redeem_expires_at INTEGER NOT NULL, -- short: the code is a key redeem_expires_at INTEGER NOT NULL, -- short: the code is a key
view_ttl INTEGER NOT NULL, -- the door opens a full hour, but only view_ttl INTEGER NOT NULL, -- the door opens a full hour, but only
-- once the key is used -- once the key is used
used_at INTEGER used_at INTEGER,
-- Fingerprint of the agent key at issue time. Redeem refuses if the agent
-- has since rekeyed (or been wiped), so an in-flight issue_code cannot
-- outlive a revoke/rekey that lands before put_code commits.
fingerprint TEXT
); );
-- Append-only, hash-chained audit log. The chain is what makes this an -- Append-only, hash-chained audit log. The chain is what makes this an
@ -130,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),
@ -217,6 +238,22 @@ class Store:
self.conn.execute( self.conn.execute(
"UPDATE codes SET view_ttl = 3600 WHERE view_ttl IS NULL" "UPDATE codes SET view_ttl = 3600 WHERE view_ttl IS NULL"
) )
code_cols = {
row["name"] for row in self.conn.execute("PRAGMA table_info(codes)")
}
if "fingerprint" not in code_cols:
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:
@ -253,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
@ -270,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
@ -285,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(
@ -335,8 +421,8 @@ class Store:
with self._write_lock: with self._write_lock:
now = int(time.time()) now = int(time.time())
self.conn.execute( self.conn.execute(
"UPDATE bootstrap SET spent_at = ? WHERE agent = ? AND spent_at IS NULL", "DELETE FROM bootstrap WHERE agent = ? AND spent_at IS NULL",
(now, agent), (agent,),
) )
self.conn.execute( self.conn.execute(
"INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)", "INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)",
@ -345,7 +431,7 @@ class Store:
self.conn.commit() self.conn.commit()
def clear_bootstrap_for_agent(self, agent: str, *, keep_hash: Optional[str] = None) -> int: def clear_bootstrap_for_agent(self, agent: str, *, keep_hash: Optional[str] = None) -> int:
"""Spend (or delete) every unspent bootstrap secret for an agent. """Delete every unspent bootstrap secret for an agent.
After a successful enrol / enrol_rekey, leftover seeds must not remain After a successful enrol / enrol_rekey, leftover seeds must not remain
usable. keep_hash, when set, is left alone (already consumed by CAS). usable. keep_hash, when set, is left alone (already consumed by CAS).
@ -353,14 +439,14 @@ class Store:
with self._write_lock: with self._write_lock:
if keep_hash is None: if keep_hash is None:
cur = self.conn.execute( cur = self.conn.execute(
"UPDATE bootstrap SET spent_at = ? WHERE agent = ? AND spent_at IS NULL", "DELETE FROM bootstrap WHERE agent = ? AND spent_at IS NULL",
(int(time.time()), agent), (agent,),
) )
else: else:
cur = self.conn.execute( cur = self.conn.execute(
"UPDATE bootstrap SET spent_at = ? WHERE agent = ? AND spent_at IS NULL" "DELETE FROM bootstrap WHERE agent = ? AND spent_at IS NULL"
" AND secret_hash != ?", " AND secret_hash != ?",
(int(time.time()), agent, keep_hash), (agent, keep_hash),
) )
self.conn.commit() self.conn.commit()
return cur.rowcount return cur.rowcount
@ -368,23 +454,26 @@ class Store:
def spend_bootstrap(self, secret_hash: str) -> Optional[str]: def spend_bootstrap(self, secret_hash: str) -> Optional[str]:
"""Atomically consume an enrolment secret. Returns the agent or None. """Atomically consume an enrolment secret. Returns the agent or None.
Compare-and-set: the UPDATE only matches an unspent row, so exactly one Compare-and-set then delete: exactly one concurrent caller can win, and
concurrent caller can win. Check-then-update would let a secret enrol the spent row is removed so a table dump cannot offline-crack a
more than one key. low-entropy custom secret. Matches the schema comment.
""" """
with self._write_lock: with self._write_lock:
row = self.conn.execute(
"SELECT agent FROM bootstrap WHERE secret_hash = ? AND spent_at IS NULL",
(secret_hash,),
).fetchone()
if row is None:
return None
agent = row["agent"]
cur = self.conn.execute( cur = self.conn.execute(
"UPDATE bootstrap SET spent_at = ? WHERE secret_hash = ?" "DELETE FROM bootstrap WHERE secret_hash = ? AND spent_at IS NULL",
" AND spent_at IS NULL", (secret_hash,),
(int(time.time()), secret_hash),
) )
self.conn.commit() self.conn.commit()
if cur.rowcount != 1: if cur.rowcount != 1:
return None return None
row = self.conn.execute( return agent
"SELECT agent FROM bootstrap WHERE secret_hash = ?", (secret_hash,)
).fetchone()
return row["agent"] if row else None
# -- nonces ------------------------------------------------------------ # -- nonces ------------------------------------------------------------
def put_nonce(self, nonce: str, agent: str, expires_at: int) -> None: def put_nonce(self, nonce: str, agent: str, expires_at: int) -> None:
@ -492,10 +581,11 @@ class Store:
scopes: List[str], scopes: List[str],
redeem_expires_at: int, redeem_expires_at: int,
view_ttl: int, view_ttl: int,
fingerprint: Optional[str] = None,
) -> None: ) -> None:
self._exec( self._exec(
"INSERT INTO codes (code_hash, agent, scopes, issued_at, redeem_expires_at," "INSERT INTO codes (code_hash, agent, scopes, issued_at, redeem_expires_at,"
" view_ttl) VALUES (?, ?, ?, ?, ?, ?)", " view_ttl, fingerprint) VALUES (?, ?, ?, ?, ?, ?, ?)",
( (
code_hash, code_hash,
agent, agent,
@ -503,6 +593,7 @@ class Store:
int(time.time()), int(time.time()),
redeem_expires_at, redeem_expires_at,
view_ttl, view_ttl,
fingerprint,
), ),
) )
@ -510,14 +601,17 @@ class Store:
return self._one("SELECT * FROM codes WHERE code_hash = ?", (code_hash,)) return self._one("SELECT * FROM codes WHERE code_hash = ?", (code_hash,))
def claim_code(self, code_hash: str) -> bool: def claim_code(self, code_hash: str) -> bool:
"""Consume a one-time code exactly once. False if already used. """Consume a one-time code exactly once. False if already used or expired.
Atomic, so a code cannot mint two sessions under a race. Atomic CAS includes the expiry predicate so a race at the TTL boundary
cannot mint a session slightly after redeem_expires_at.
""" """
with self._write_lock: with self._write_lock:
now = int(time.time())
cur = self.conn.execute( cur = self.conn.execute(
"UPDATE codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL", "UPDATE codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL"
(int(time.time()), code_hash), " AND redeem_expires_at > ?",
(now, code_hash, now),
) )
self.conn.commit() self.conn.commit()
return cur.rowcount == 1 return cur.rowcount == 1
@ -646,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:
@ -675,8 +788,19 @@ class Store:
public = _keys.public_from_private(private) public = _keys.public_from_private(private)
fingerprint = _keys.fingerprint(public) fingerprint = _keys.fingerprint(public)
if self.get_agent(signer) is None: from . import identity as _identity
agent_row = self.get_agent(signer)
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 ""
if not enrolled_fp or fingerprint != enrolled_fp:
raise ValueError(
f"private key fingerprint does not match enrolled key for {signer}; "
"refusing to sign a checkpoint with a stranger key"
)
with self._write_lock: with self._write_lock:
last = self.conn.execute( last = self.conn.execute(
"SELECT id, hash FROM events ORDER BY id DESC LIMIT 1" "SELECT id, hash FROM events ORDER BY id DESC LIMIT 1"
@ -740,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"),
@ -770,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()
@ -790,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)
@ -823,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

@ -91,9 +91,18 @@ def issue_code(
if not store.allow(f"code_issue:{agent}", limit=limit, window=window): if not store.allow(f"code_issue:{agent}", limit=limit, window=window):
raise errors.RateLimited(f"too many view codes for {agent}") raise errors.RateLimited(f"too many view codes for {agent}")
from . import identity as _identity
agent_row = store.get_agent(agent) agent_row = store.get_agent(agent)
if agent_row is None: if agent_row is None:
raise errors.UnknownAgent(f"{agent} is not enrolled") raise errors.UnknownAgent(f"{agent} is not enrolled")
# Re-check after resolve: revoke/rekey can land between resolve and put_code.
if _identity.is_revoked(agent_row) or not _identity.has_live_key(agent_row):
raise errors.UnknownAgent(f"{agent} is revoked or has no live key")
live_fp = agent_row["fingerprint"] or ""
sess_fp = principal.get("fingerprint") or ""
if not live_fp or sess_fp != live_fp:
raise errors.SessionExpired("session key epoch no longer current")
granted = list(scopes if scopes is not None else sessions.HUMAN_VIEW_SCOPES) granted = list(scopes if scopes is not None else sessions.HUMAN_VIEW_SCOPES)
# Refuse to bake write or act scopes into a human code at issue time. # Refuse to bake write or act scopes into a human code at issue time.
@ -115,12 +124,13 @@ def issue_code(
granted, granted,
redeem_expires_at=now + code_ttl, redeem_expires_at=now + code_ttl,
view_ttl=view_ttl, view_ttl=view_ttl,
fingerprint=live_fp,
) )
store.record( store.record(
"view_code.issued", "view_code.issued",
actor=agent, actor=agent,
subject=f"{agent}'s human", subject=f"{agent}'s human",
fingerprint=agent_row["fingerprint"], fingerprint=live_fp,
detail={ detail={
"code_ref": _code_ref(code), "code_ref": _code_ref(code),
"scopes": granted, "scopes": granted,
@ -134,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,
} }
@ -155,19 +164,25 @@ def redeem_code(store: Store, code: str) -> dict:
agent = row["agent"] agent = row["agent"]
# Defense in depth: revoke/rekey already invalidate unused codes, but refuse # Defense in depth: refuse if agent revoked, wiped, or rekeyed since issue.
# here too if the agent is currently revoked or missing so a missed
# invalidation cannot mint a human_via_agent session.
from . import identity as _identity from . import identity as _identity
agent_row = store.get_agent(agent) agent_row = store.get_agent(agent)
if agent_row is None or _identity.is_revoked(agent_row): code_fp = row["fingerprint"] if "fingerprint" in row.keys() else None
live_fp = (agent_row["fingerprint"] or "") if agent_row is not None else ""
bad = (
agent_row is None
or _identity.is_revoked(agent_row)
or not _identity.has_live_key(agent_row)
or (code_fp is not None and code_fp != live_fp)
)
if bad:
if not store.allow(f"code_redeem_fail:{agent}"): if not store.allow(f"code_redeem_fail:{agent}"):
raise errors.RateLimited("too many redemption attempts") raise errors.RateLimited("too many redemption attempts")
store.record( store.record(
"view_code.redeem_refused", "view_code.redeem_refused",
actor=agent, actor=agent,
subject=f"{agent}'s human", subject=f"{agent}'s human",
detail={"reason": "agent revoked or unknown"}, detail={"reason": "agent revoked, wiped, or rekeyed"},
) )
raise errors.CodeInvalid("that code is not redeemable") raise errors.CodeInvalid("that code is not redeemable")
@ -216,6 +231,7 @@ def redeem_code(store: Store, code: str) -> dict:
_json.loads(row["scopes"]), _json.loads(row["scopes"]),
row["view_ttl"], row["view_ttl"],
sessions._ISSUER, sessions._ISSUER,
fingerprint=live_fp,
) )
return sessions.issue_view_session(store, grant) return sessions.issue_view_session(store, grant)

View File

@ -360,3 +360,215 @@ class TestAgentIdValidation(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class TestClaimCodeExpiryCAS(unittest.TestCase):
"""R6: claim_code CAS must include redeem_expires_at predicate."""
def test_claim_code_after_expiry_returns_false(self):
store, private = helpers.fresh()
issued = helpers.issue(store, private, code_ttl=1, view_ttl=60)
digest = viewcodes._hash_code(issued["code"])
# Force expiry in the row.
store.conn.execute(
"UPDATE codes SET redeem_expires_at = ? WHERE code_hash = ?",
(int(time.time()) - 1, digest),
)
store.conn.commit()
self.assertFalse(store.claim_code(digest))
row = store.get_code(digest)
self.assertIsNone(row["used_at"])
class TestEnrolRekeyRateLimit(unittest.TestCase):
"""N3: enrol_rekey uses a dedicated rekey: bucket; junk cannot lock ops."""
def test_exhausting_enrol_bucket_does_not_block_rekey(self):
store, _ = helpers.fresh("limited")
for _ in range(20):
store.allow("enrol:limited")
secret = identity.seed_bootstrap(store, "limited")
# Valid rekey must succeed even if enrol: is exhausted.
identity.enrol_rekey(
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):
"""R8: spent bootstrap secrets are deleted, not retained as hashes."""
def test_spend_deletes_the_row(self):
store = Store(":memory:")
secret = identity.seed_bootstrap(store, "dave")
digest = __import__("hashlib").sha256(secret.encode()).hexdigest()
self.assertIsNotNone(store.conn.execute(
"SELECT 1 FROM bootstrap WHERE secret_hash = ?", (digest,)
).fetchone())
owner = store.spend_bootstrap(digest)
self.assertEqual(owner, "dave")
self.assertIsNone(store.conn.execute(
"SELECT 1 FROM bootstrap WHERE secret_hash = ?", (digest,)
).fetchone())
class TestResolveRechecksAgentState(unittest.TestCase):
"""N1: resolve must refuse sessions that outran revoke/rekey."""
def _plant_agent_session(self, store, agent, fingerprint):
"""Insert a live session as if put_session won a race against revoke."""
import time as _t
token = "race-survivor-token"
store.put_session(
sessions.hash_token(token),
sessions.AGENT,
agent,
agent,
list(sessions.AGENT_SCOPES),
int(_t.time()) + 900,
fingerprint=fingerprint,
)
return token
def test_resolve_refuses_race_survivor_after_permanent_revoke(self):
store, private = helpers.fresh()
fp = store.get_agent("bigpickle")["fingerprint"]
identity.revoke(store, "bigpickle", reason="compromise")
# Session inserted *after* revoke (race); not marked revoked_at.
token = self._plant_agent_session(store, "bigpickle", fp)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, token)
def test_resolve_refuses_stale_fingerprint_after_rekey(self):
store, private = helpers.fresh()
old_fp = store.get_agent("bigpickle")["fingerprint"]
new_pub = keys.generate()[1]
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(new_pub), secret
)
token = self._plant_agent_session(store, "bigpickle", old_fp)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, token)
def test_redeem_refuses_code_after_rekey_even_if_not_invalidated(self):
"""Fingerprint binding: plant a code with a stale fingerprint."""
store, private = helpers.fresh()
agent_row = store.get_agent("bigpickle")
old_fp = agent_row["fingerprint"]
# Issue normally, then surgically restore used_at and keep old fp after rekey
issued = helpers.issue(store, private)
digest = viewcodes._hash_code(issued["code"])
new_pub = keys.generate()[1]
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(new_pub), secret
)
# Rekey invalidated the code; un-burn it to simulate put_code after invalidate.
store.conn.execute(
"UPDATE codes SET used_at = NULL, fingerprint = ? WHERE code_hash = ?",
(old_fp, digest),
)
store.conn.commit()
with self.assertRaises(errors.CodeInvalid):
viewcodes.redeem_code(store, issued["code"])
def test_redeem_refuses_without_live_key(self):
store, private = helpers.fresh()
issued = helpers.issue(store, private)
digest = viewcodes._hash_code(issued["code"])
identity.revoke(store, "bigpickle", reason="compromise")
identity.reinstate(store, "bigpickle") # ban lifted, key still wiped
store.conn.execute(
"UPDATE codes SET used_at = NULL WHERE code_hash = ?", (digest,)
)
store.conn.commit()
with self.assertRaises(errors.CodeInvalid):
viewcodes.redeem_code(store, issued["code"])
def test_verify_then_revoke_session_unusable(self):
"""Session minted just before revoke must not resolve."""
store, private = helpers.fresh()
chal = identity.challenge(store, "bigpickle")
sig = helpers.sign(private, chal["nonce"])
sess = identity.verify(store, chal["nonce"], sig)
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"):])

View File

@ -133,15 +133,25 @@ class TestSignedCheckpoints(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
stranger.checkpoint(self.private, "not-enrolled") stranger.checkpoint(self.private, "not-enrolled")
def test_checkpoint_after_rekey_is_flagged(self): def test_checkpoint_refuses_enrolled_name_with_wrong_key(self):
# R4: signer name is enrolled, but private key is not the enrolled one.
before = len(self.store.checkpoints())
other_private, _ = keys.generate()
with self.assertRaises(ValueError) as ctx:
self.store.checkpoint(other_private, "bigpickle")
self.assertIn("does not match enrolled key", str(ctx.exception))
self.assertEqual(len(self.store.checkpoints()), before)
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()
@ -482,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())

View File

@ -124,3 +124,30 @@ class TestSealedStore(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class TestPlaintextWorkingCopyHardening(unittest.TestCase):
"""R5: plaintext tmp must be 0600; SQLCipher remains deferred."""
def test_plaintext_working_copy_is_owner_only(self):
import tempfile
from sigil.sealed import SealedStore
from sigil import identity, keys
d = tempfile.mkdtemp()
path = os.path.join(d, "sealed.db")
store = SealedStore(path, "test-passphrase-ok")
try:
mode = os.stat(store._plain_path).st_mode & 0o777
self.assertEqual(mode, 0o600, oct(mode))
dir_mode = os.stat(store._tmpdir.name).st_mode & 0o777
self.assertEqual(dir_mode, 0o700, oct(dir_mode))
secret = identity.seed_bootstrap(store, "alice")
_, pub = keys.generate()
identity.enrol(store, "alice", keys.export_public_b64(pub), secret)
store.flush()
# export.db must not linger world-readable after flush
export = os.path.join(store._tmpdir.name, "export.db")
self.assertFalse(os.path.exists(export))
finally:
store.close()