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.
146 lines
6.0 KiB
Python
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() |