sigil/tests/test_viewcodes.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

146 lines
6.0 KiB
Python

"""One-time codes: single use, short redemption window, capped capability."""
import time
import unittest
from sigil import errors, sessions, viewcodes
from sigil.store import Store
from sigil import identity, keys
class Base(unittest.TestCase):
def setUp(self):
self.store = Store(":memory:")
_, public = keys.generate()
secret = identity.seed_bootstrap(self.store, "bigpickle")
identity.enrol(
self.store, "bigpickle", keys.export_public_b64(public), secret
)
class TestIssueCodes(Base):
def test_issue_returns_code_and_two_lifetimes(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
self.assertIn("-", issued["code"])
self.assertEqual(len(issued["code"].replace("-", "")), 8)
# the code window is strictly shorter than the session window
self.assertLess(issued["code_ttl"], issued["view_ttl"])
self.assertLess(issued["redeem_expires_at"], issued["session_expires_at"])
def test_code_ttl_must_be_shorter_than_view_ttl(self):
with self.assertRaises(errors.SigilError):
viewcodes.issue_code(
self.store, "bigpickle", code_ttl=3600, view_ttl=60
)
def test_cannot_issue_code_with_write_scope(self):
with self.assertRaises(errors.ScopeDenied):
viewcodes.issue_code(self.store, "bigpickle", scopes=["view:write"])
def test_cannot_issue_code_for_unknown_agent(self):
with self.assertRaises(errors.UnknownAgent):
viewcodes.issue_code(self.store, "ghost")
def test_codes_are_distinct(self):
codes = {viewcodes.issue_code(self.store, "bigpickle")["code"]
for _ in range(25)}
self.assertEqual(len(codes), 25)
def test_code_alphabet_has_no_ambiguous_characters(self):
for _ in range(200):
code = viewcodes.issue_code(self.store, "bigpickle")["code"]
self.assertNotIn("0", code)
self.assertNotIn("O", code)
self.assertNotIn("1", code)
self.assertNotIn("I", code)
class TestRedeemCodes(Base):
def test_happy_path(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
view = viewcodes.redeem_code(self.store, issued["code"])
self.assertEqual(view["kind"], "human_via_agent")
self.assertEqual(view["on_behalf_of"], "bigpickle")
def test_code_is_single_use(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
viewcodes.redeem_code(self.store, issued["code"])
with self.assertRaises(errors.CodeUsed):
viewcodes.redeem_code(self.store, issued["code"])
def test_expired_code_refused(self):
issued = viewcodes.issue_code(self.store, "bigpickle", code_ttl=1, view_ttl=600)
time.sleep(1.2)
with self.assertRaises(errors.CodeExpired):
viewcodes.redeem_code(self.store, issued["code"])
def test_unknown_code_refused(self):
with self.assertRaises(errors.CodeInvalid):
viewcodes.redeem_code(self.store, "ZZZZ-ZZZZ")
def test_code_is_case_and_space_insensitive(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
mangled = issued["code"].lower().replace("-", " ")
view = viewcodes.redeem_code(self.store, mangled)
self.assertEqual(view["on_behalf_of"], "bigpickle")
def test_expired_code_grants_no_session(self):
issued = viewcodes.issue_code(self.store, "bigpickle", code_ttl=1, view_ttl=600)
time.sleep(1.2)
before = len(self.store.sessions())
with self.assertRaises(errors.CodeExpired):
viewcodes.redeem_code(self.store, issued["code"])
self.assertEqual(len(self.store.sessions()), before)
def test_session_outlives_the_code_window(self):
"""The point of splitting the lifetimes: after the code dies the
session it opened is still good."""
issued = viewcodes.issue_code(self.store, "bigpickle", code_ttl=1, view_ttl=600)
view = viewcodes.redeem_code(self.store, issued["code"])
time.sleep(1.2)
session = sessions.resolve(self.store, view["token"])
self.assertEqual(session["kind"], "human_via_agent")
class TestAuditTrail(Base):
def test_issue_and_redeem_are_recorded(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
viewcodes.redeem_code(self.store, issued["code"])
kinds = [e["kind"] for e in self.store.events(limit=50)]
self.assertIn("view_code.issued", kinds)
self.assertIn("view_code.redeemed", kinds)
def test_human_appears_as_a_human_in_the_log(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
viewcodes.redeem_code(self.store, issued["code"])
redemption = [e for e in self.store.events(limit=50)
if e["kind"] == "view_code.redeemed"][0]
self.assertEqual(redemption["subject"], "bigpickle's human")
def test_replay_attempt_is_recorded(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
viewcodes.redeem_code(self.store, issued["code"])
with self.assertRaises(errors.CodeUsed):
viewcodes.redeem_code(self.store, issued["code"])
kinds = [e["kind"] for e in self.store.events(limit=50)]
self.assertIn("view_code.redeem_refused", kinds)
def test_replay_attempt_never_names_the_code(self):
issued = viewcodes.issue_code(self.store, "bigpickle")
viewcodes.redeem_code(self.store, issued["code"])
with self.assertRaises(errors.CodeUsed):
viewcodes.redeem_code(self.store, issued["code"])
blob = repr(self.store.events(limit=50))
self.assertNotIn(issued["code"], blob)
def test_secret_is_never_stored_in_the_clear(self):
fresh = Store(":memory:")
secret = identity.seed_bootstrap(fresh, "bigpickle")
_, public = keys.generate()
identity.enrol(fresh, "bigpickle", keys.export_public_b64(public), secret)
blob = repr(fresh.events(limit=100))
self.assertNotIn(secret, blob)
if __name__ == "__main__":
unittest.main()