sigil/sigil.py
bigpickle f4a044f1d6 Sigil: agent identity for the new internet
Replace passcode identity with Ed25519 challenge-response.

- Enrolment consumes a one-time bootstrap secret, after which no static
  secret is used on the wire again.
- Nonces are single-use and consumed before the signature is judged, so a
  captured or bad signature cannot be retried.
- Sessions are short-lived, scoped, and typed agent vs human_via_agent.
- Human view access uses one-time codes with a 5 minute redemption window
  and a 1 hour session, deliberately different numbers: the code is a key,
  the session is the door.
- Invariant: only an agent signature may act as an agent. Enforced and tested.
- Append-only audit trail.

54 tests, covering refusals rather than only the happy path.

Reference implementation, not reviewed or hardened. See README Status.
2026-10-06 06:28:01 +08:00

105 lines
3.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Command line for Sigil.
sigil.py seed <agent> one-time enrolment secret
sigil.py keygen <path> write an Ed25519 private key (0600)
sigil.py enrol <agent> <pubkey.b64> <secret>
sigil.py challenge <agent> nonce to sign
sigil.py sign <keypath> <nonce> signature, base64
sigil.py verify <nonce> <sig.b64> agent session token
sigil.py code <agent> one-time code for a human to view
sigil.py redeem <code> human_via_agent session token
sigil.py whoami <token> describe a session
sigil.py events [actor] audit trail
Uses SIGIL_DB (default ./sigil.db) and SIGIL_KEY (default ./agent.key).
"""
from __future__ import annotations
import base64
import json
import os
import sys
from sigil import errors, identity, keys, sessions, viewcodes
from sigil.store import Store
DB = os.environ.get("SIGIL_DB", "./sigil.db")
KEY = os.environ.get("SIGIL_KEY", "./agent.key")
def out(obj) -> None:
print(json.dumps(obj, indent=2, sort_keys=True))
def main(argv) -> int:
if len(argv) < 2:
print(__doc__)
return 2
cmd = argv[1]
store = Store(DB)
try:
if cmd == "seed":
agent = argv[2]
out({"agent": agent, "secret": identity.seed_bootstrap(store, agent)})
elif cmd == "keygen":
path = argv[2] if len(argv) > 2 else KEY
private, public = keys.generate()
keys.save_private(private, path)
out({
"private_key": path,
"fingerprint": keys.fingerprint(public),
"public_b64": keys.export_public_b64(public),
})
elif cmd == "enrol":
out({"fingerprint": identity.enrol(store, argv[2], argv[3], argv[4])})
elif cmd == "challenge":
out(identity.challenge(store, argv[2]))
elif cmd == "sign":
private = keys.load_private(argv[2])
nonce = argv[3]
out({"nonce": nonce, "signature": base64.b64encode(
keys.sign(private, nonce.encode("utf-8"))).decode("ascii")})
elif cmd == "verify":
out(identity.verify(store, argv[2], argv[3]))
elif cmd == "code":
out(viewcodes.issue_code(store, argv[2]))
elif cmd == "redeem":
out(viewcodes.redeem_code(store, argv[2]))
elif cmd == "whoami":
session = sessions.resolve(store, argv[2])
may_act = True
try:
sessions.assert_may_act_as_agent(session)
except errors.AgentImpersonationDenied:
may_act = False
session["may_act_as_agent"] = may_act
out(session)
elif cmd == "events":
out(store.events(argv[2] if len(argv) > 2 else None,
int(argv[3]) if len(argv) > 3 else 50))
else:
print(__doc__)
return 2
except errors.SigilError as exc:
print(json.dumps({"error": type(exc).__name__, "message": str(exc)}))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))