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.
104 lines
4.2 KiB
Python
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() |