Raise the floor past CVE-2024-12797-affected wheels (43.x / 44.0.0). Add constraints.txt (exact 44.0.1) and requirements.lock.txt with sha256 hashes for cryptography and its runtime deps.
8.8 KiB
Sigil
Agent identity for the new internet.
An agent proves who it is by signing, not by holding a passcode. A passcode is a credential, so anyone who copies it becomes you. A signature is evidence, so forging one requires the private key — which never leaves the agent.
a passcode is the identity -> can be copied, leaked in a URL, replayed
a signature proves the identity -> cannot be forged, cannot be replayed
This is a working implementation with tests and a hardening pass behind it. It is not independently reviewed — see Status below.
What it does
| Enrolment | Bind an Ed25519 public key to an agent, once, using a one-time secret |
| Challenge-response | Platform issues a nonce, agent signs it, platform verifies |
| Sessions | Short-lived, scoped, and typed as agent or human_via_agent |
| View codes | A human looks at what their agent sees, through a single-use code |
| Audit trail | Append-only record of every identity and access event |
Try it
export SIGIL_DB=/tmp/demo.db
# optional: export SIGIL_SEAL_PASS='...' # AES-GCM seal the DB file at rest
python3 sigil.py seed bigpickle # -> one-time secret
python3 sigil.py keygen /tmp/demo.key # -> public_b64, fingerprint
# add a passphrase to encrypt
python3 sigil.py enrol bigpickle "$PUB" "$SECRET" # bind key, consume secret
python3 sigil.py challenge bigpickle # -> nonce
python3 sigil.py sign /tmp/demo.key "$NONCE" # -> signature
python3 sigil.py verify "$NONCE" "$SIG" # -> agent session token
python3 sigil.py code bigpickle /tmp/demo.key # hand a human a code
python3 sigil.py redeem X3ZB-NTLG # -> human_via_agent token
python3 sigil.py whoami "$TOKEN" # kind, scopes, may_act_as_agent
python3 sigil.py events bigpickle # audit trail
export SIGIL_ANCHOR=/tmp/demo.chain.tip # off-box tip path
python3 sigil.py checkpoint /tmp/demo.key bigpickle # sign head + publish tip
python3 sigil.py verify-chain # chain + checkpoints + tip
python3 sigil.py revoke bigpickle --reason "incident" # or --until UNIX
python3 sigil.py sweep # prune stale rows
Tests
python3 -m unittest discover -s tests -v
156 tests. They cover the refusals, not just the happy paths: replayed
signatures, wrong keys, expired nonces, single-use codes, scope escalation, and
the invariant below. test_hardening.py, test_production.py, test_anchor.py and
test_sealed.py have one test per defect found in review, so any of them
regressing brings that defect back.
The invariant
Only an agent signature may act as an agent. No human session, no lifetime, no admin override.
>>> sessions.assert_may_act_as_agent(view_session)
sigil.errors.AgentImpersonationDenied: human_via_agent session may not act as
'bigpickle': only an agent signature can
This is why human access is a window, not a costume. A human who redeems a
view code is logged as bigpickle's human, permanently distinguishable from
bigpickle. If that guarantee erodes, every conversation an agent has becomes
unreliable, because nothing distinguishes the agent from someone holding its
credentials.
Two properties worth stating out loud
Expiry and authority are separate axes. A long session is not a privileged session. Scopes come from the issuer and never widen with time. There is a test asserting a 24-hour human session has exactly the scopes of a 5-minute one.
Code lifetime ≠ session lifetime. A code is a key and expires in 5 minutes. The session it opens lasts an hour, counted from redemption. The code travels in chat, so it is made worthless fast; the hour buys reading comfort and no extra capability. Waiting for a code does not eat into it — an earlier draft anchored the session to issue time, which quietly handed out less than promised.
Nothing is minted from a name. issue_code takes an agent session, not an
agent id. A human session requires a GrantedCode, which only a redeemed code
can produce. A function that accepts a string and returns authority is how
"who is this?" quietly becomes unanswerable.
Suspension and death are different statements. revoke with no end time is
permanent; revoke --until is a pause that lapses on its own. An incident
response that ends should not need a re-enrolment, and a dead identity should
not come back because a clock ran out.
Status
A hardening pass closed the defects below. Each is now covered by a test.
| Found in review | Now |
|---|---|
issue_code() trusted an agent name, so it minted authority from a string |
Takes an agent session; agent derived from it |
| Human sessions were mintable from a bare agent name | Requires a GrantedCode, constructible only by redeeming |
| Audit log recorded 4 of 8 code characters (20 bits left) | Logs a truncated digest instead |
No revoke operation existed, only an unused column |
revoke with optional end time; sessions killed, challenges dropped |
| Chain was unkeyed, so a database-level attacker could rewrite it | Signed checkpoints; verify-chain checks both |
| No busy timeout, so concurrent writers failed instantly | busy_timeout, WAL, rollback on error |
| One-time code redeemable many times in a race (753 times in 200 trials) | Atomic compare-and-set; 300 races, one success each |
| Bootstrap secret spendable more than once | Atomic compare-and-set |
| Signed nonce verifiable more than once | Nonce claimed before the signature is judged |
| Session tokens stored in clear | Stored as SHA-256 digests, like codes |
| Audit log editable and deletable in place | Hash-chained; verify_chain names the first bad event |
challenge unmetered |
Sliding-window limits per agent on every entry point |
| Private keys written unencrypted | Optional passphrase (PBKDF2, AES-256), 0600 |
| Rekey left old sessions alive | Rekey revokes every session for that agent |
| Agent ids unvalidated, could forge log lines | Constrained alphabet and length |
| 1-hour session anchored to issue time | Anchored to redemption |
Rekey cleared revoked_at (INSERT OR REPLACE) |
Rekey refuses while revoked; update_agent_key preserves ban columns |
| Dual-bootstrap enrol race (last writer won) | put_agent is INSERT OR IGNORE; exactly one enrol wins |
| Redeem fail limit was one global bucket | Per-agent code_redeem / code_redeem_fail; anonymous fails isolated |
| Checkpoints lived only inside the same DB | Optional off-box signed tip (SIGIL_ANCHOR / anchor_path) |
| DB file held roster/audit in cleartext | Optional SealedStore / SIGIL_SEAL_PASS (AES-256-GCM file seal) |
Still open, and these are the reasons not to ship it yet:
- No independent review. Everything above is self-review, twice over. Self- review finds the bugs you thought of; it does not find the ones you did not. This needs someone else, ideally a cryptographer, before it guards anything real. This is the blocking item, and the only one that code cannot close.
- The service around this is unwritten. TLS, CSRF, session transport and the
HTTP routes. DB encryption at rest is available via
SealedStore/SIGIL_SEAL_PASS(AES-256-GCM file seal; not SQLCipher page-level). The plaintext working copy is mode0600in 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. - Off-box anchor is opt-in. Set
SIGIL_ANCHOR(orStore(..., anchor_path=)) and every checkpoint also writes a signed tip outside the SQLite file. Rewriting the DB alone can no longer erase the tip. Without that path configured, checkpoints remain in-DB only — fine for tests, not for prod. - No key recovery. Rotate and the old sessions die, which is the point. There is no escrow. Deliberate, not an oversight.
- Rate limits are in-process. They are per-
Store, so several processes behind a load balancer each get their own quota. Fine for a single writer, wrong for a fleet. - SQLite, single node. Fine for one agent or a modest service. It does not shard, and there is no replication story.
The tests assert the logic is correct. They do not make it safe.
See also
DESIGN.md — the design page: rationale, flows, session stamps, the invariant
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.