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.
105 lines
3.2 KiB
Python
Executable File
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)) |