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

104 lines
4.2 KiB
Python

"""Sessions: scoping, expiry, and the invariant that matters."""
import base64
import unittest
from sigil import errors, identity, keys, sessions, viewcodes
from sigil.store import Store
class Base(unittest.TestCase):
def setUp(self):
self.store = Store(":memory:")
self.private, self.public = keys.generate()
secret = identity.seed_bootstrap(self.store, "bigpickle")
identity.enrol(
self.store, "bigpickle", keys.export_public_b64(self.public), secret
)
def agent_session(self, **kw):
chal = identity.challenge(self.store, "bigpickle")
sig = base64.b64encode(self.private.sign(chal["nonce"].encode())).decode()
return identity.verify(self.store, chal["nonce"], sig, **kw)
class TestAgentSessions(Base):
def test_agent_may_act_as_agent(self):
session = sessions.resolve(self.store, self.agent_session()["token"])
self.assertEqual(sessions.assert_may_act_as_agent(session), "bigpickle")
def test_expired_session_refused(self):
token = self.agent_session(ttl=-1)["token"]
with self.assertRaises(errors.SessionExpired):
sessions.resolve(self.store, token)
def test_revoked_session_refused(self):
token = self.agent_session()["token"]
sessions.revoke(self.store, token)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(self.store, token)
def test_unknown_token_refused(self):
with self.assertRaises(errors.SessionUnknown):
sessions.resolve(self.store, "nope")
def test_scope_denied(self):
session = sessions.resolve(self.store, self.agent_session()["token"])
with self.assertRaises(errors.ScopeDenied):
sessions.require_scope(session, "secret:admin")
def test_cannot_mint_session_with_invented_scope(self):
with self.assertRaises(errors.ScopeDenied):
sessions.issue_agent_session(self.store, "bigpickle", scopes=["root:all"])
class TestHumanSessions(Base):
def test_human_session_cannot_act_as_agent(self):
code = viewcodes.issue_code(self.store, "bigpickle")
view = viewcodes.redeem_code(self.store, code["code"])
session = sessions.resolve(self.store, view["token"])
with self.assertRaises(errors.AgentImpersonationDenied):
sessions.assert_may_act_as_agent(session)
def test_human_session_is_typed_and_attributed(self):
code = viewcodes.issue_code(self.store, "bigpickle")
view = viewcodes.redeem_code(self.store, code["code"])
session = sessions.resolve(self.store, view["token"])
self.assertEqual(session["kind"], "human_via_agent")
self.assertEqual(session["on_behalf_of"], "bigpickle")
self.assertEqual(session["granted_by"], "bigpickle")
self.assertEqual(session["subject"], "bigpickle's human")
def test_human_session_can_view(self):
code = viewcodes.issue_code(self.store, "bigpickle")
view = viewcodes.redeem_code(self.store, code["code"])
session = sessions.resolve(self.store, view["token"])
sessions.require_scope(session, "view:read")
def test_view_session_cannot_be_minted_with_write_scope(self):
with self.assertRaises(errors.ScopeDenied):
sessions.issue_view_session(
self.store, "bigpickle", scopes=["view:read", "view:write"]
)
def test_view_session_cannot_be_minted_with_agent_act_scope(self):
with self.assertRaises(errors.ScopeDenied):
sessions.issue_view_session(
self.store, "bigpickle", scopes=["agent:act"]
)
def test_long_session_grants_no_extra_capability(self):
"""Expiry and authority are separate axes."""
short = viewcodes.issue_code(self.store, "bigpickle")
short_session = viewcodes.redeem_code(self.store, short["code"])
long_session = sessions.issue_view_session(
self.store, "bigpickle", ttl=86400
)
self.assertEqual(
sorted(sessions.resolve(self.store, short_session["token"])["scopes"]),
sorted(sessions.resolve(self.store, long_session["token"])["scopes"]),
)
if __name__ == "__main__":
unittest.main()