sigil/tests/test_identity.py
bigpickle 4d9e101f54 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-05 20:43:50 +08:00

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()