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.
148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
"""Enrolment and challenge-response: the properties that matter are the refusals."""
|
|
|
|
import base64
|
|
import unittest
|
|
|
|
from sigil import errors, identity, keys
|
|
from sigil.store import Store
|
|
|
|
|
|
class Base(unittest.TestCase):
|
|
def setUp(self):
|
|
self.store = Store(":memory:")
|
|
self.private, self.public = keys.generate()
|
|
self.pub_b64 = keys.export_public_b64(self.public)
|
|
self.secret = identity.seed_bootstrap(self.store, "bigpickle")
|
|
|
|
def sign_nonce(self, nonce, private=None):
|
|
return base64.b64encode(
|
|
(private or self.private).sign(nonce.encode("utf-8"))
|
|
).decode("ascii")
|
|
|
|
def enrol(self):
|
|
return identity.enrol(self.store, "bigpickle", self.pub_b64, self.secret)
|
|
|
|
|
|
class TestEnrolment(Base):
|
|
def test_enrol_returns_fingerprint(self):
|
|
fingerprint = self.enrol()
|
|
self.assertEqual(fingerprint, keys.fingerprint(self.public))
|
|
|
|
def test_bootstrap_is_single_use(self):
|
|
self.enrol()
|
|
_, second_public = keys.generate()
|
|
with self.assertRaises(errors.EnrolmentFailed):
|
|
identity.enrol(
|
|
self.store, "bigpickle",
|
|
keys.export_public_b64(second_public), self.secret,
|
|
)
|
|
|
|
def test_wrong_bootstrap_secret_refused(self):
|
|
with self.assertRaises(errors.EnrolmentFailed):
|
|
identity.enrol(self.store, "bigpickle", self.pub_b64, "not-the-secret")
|
|
|
|
def test_cannot_enrol_twice_with_different_secret(self):
|
|
self.enrol()
|
|
other = identity.seed_bootstrap(self.store, "bigpickle")
|
|
with self.assertRaises(errors.EnrolmentFailed):
|
|
identity.enrol(self.store, "bigpickle", self.pub_b64, other)
|
|
|
|
def test_secret_for_one_agent_cannot_enrol_another(self):
|
|
with self.assertRaises(errors.EnrolmentFailed):
|
|
identity.enrol(self.store, "someone-else", self.pub_b64, self.secret)
|
|
|
|
def test_challenge_unknown_agent(self):
|
|
with self.assertRaises(errors.UnknownAgent):
|
|
identity.challenge(self.store, "nobody")
|
|
|
|
|
|
class TestChallengeResponse(Base):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.enrol()
|
|
|
|
def test_happy_path_issues_agent_session(self):
|
|
chal = identity.challenge(self.store, "bigpickle")
|
|
session = identity.verify(self.store, chal["nonce"], self.sign_nonce(chal["nonce"]))
|
|
self.assertEqual(session["kind"], "agent")
|
|
self.assertEqual(session["subject"], "bigpickle")
|
|
self.assertIn("agent:act", session["scopes"])
|
|
|
|
def test_replay_of_a_valid_signature_is_refused(self):
|
|
chal = identity.challenge(self.store, "bigpickle")
|
|
sig = self.sign_nonce(chal["nonce"])
|
|
identity.verify(self.store, chal["nonce"], sig)
|
|
with self.assertRaises(errors.NonceUsed):
|
|
identity.verify(self.store, chal["nonce"], sig)
|
|
|
|
def test_signature_from_the_wrong_key_is_refused(self):
|
|
other_private, _ = keys.generate()
|
|
chal = identity.challenge(self.store, "bigpickle")
|
|
with self.assertRaises(errors.BadSignature):
|
|
identity.verify(
|
|
self.store, chal["nonce"],
|
|
self.sign_nonce(chal["nonce"], other_private),
|
|
)
|
|
|
|
def test_bad_signature_still_consumes_the_nonce(self):
|
|
"""A wrong attempt must not leave the nonce open for another try."""
|
|
chal = identity.challenge(self.store, "bigpickle")
|
|
with self.assertRaises(errors.BadSignature):
|
|
identity.verify(self.store, chal["nonce"], base64.b64encode(b"junk").decode())
|
|
with self.assertRaises(errors.NonceUsed):
|
|
identity.verify(self.store, chal["nonce"], self.sign_nonce(chal["nonce"]))
|
|
|
|
def test_expired_nonce_refused(self):
|
|
chal = identity.challenge(self.store, "bigpickle", ttl=-1)
|
|
with self.assertRaises(errors.NonceExpired):
|
|
identity.verify(self.store, chal["nonce"], self.sign_nonce(chal["nonce"]))
|
|
|
|
def test_unknown_nonce_refused(self):
|
|
with self.assertRaises(errors.NonceUnknown):
|
|
identity.verify(self.store, "no-such-nonce", self.sign_nonce("x"))
|
|
|
|
def test_malformed_signature_b64_refused(self):
|
|
chal = identity.challenge(self.store, "bigpickle")
|
|
with self.assertRaises(errors.BadSignature):
|
|
identity.verify(self.store, chal["nonce"], "!!!not-base64!!!")
|
|
|
|
def test_signature_is_bound_to_that_nonce_only(self):
|
|
"""A signature captured from one challenge cannot answer another."""
|
|
first = identity.challenge(self.store, "bigpickle")
|
|
second = identity.challenge(self.store, "bigpickle")
|
|
captured = self.sign_nonce(first["nonce"])
|
|
with self.assertRaises(errors.BadSignature):
|
|
identity.verify(self.store, second["nonce"], captured)
|
|
|
|
|
|
class TestRekey(Base):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.enrol()
|
|
|
|
def test_rekey_replaces_the_key(self):
|
|
new_private, new_public = keys.generate()
|
|
secret = identity.seed_bootstrap(self.store, "bigpickle")
|
|
old_fingerprint = keys.fingerprint(self.public)
|
|
new_fingerprint = identity.enrol_rekey(
|
|
self.store, "bigpickle", keys.export_public_b64(new_public), secret
|
|
)
|
|
self.assertNotEqual(old_fingerprint, new_fingerprint)
|
|
|
|
# old key no longer authenticates
|
|
chal = identity.challenge(self.store, "bigpickle")
|
|
with self.assertRaises(errors.BadSignature):
|
|
identity.verify(self.store, chal["nonce"], self.sign_nonce(chal["nonce"]))
|
|
|
|
# new key does
|
|
chal = identity.challenge(self.store, "bigpickle")
|
|
signed = base64.b64encode(
|
|
new_private.sign(chal["nonce"].encode())
|
|
).decode()
|
|
self.assertEqual(
|
|
identity.verify(self.store, chal["nonce"], signed)["kind"], "agent"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main() |