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.
167 lines
8.8 KiB
Markdown
167 lines
8.8 KiB
Markdown
# 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
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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.
|
|
|
|
```python
|
|
>>> 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 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.
|
|
- **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.
|
|
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`.
|