sigil/tests/test_hardening.py
leora d70ee8b63c Fix N7/N8: human session fingerprint epoch; drop duplicate view_ttl
resolve() now requires sess_fp == live_fp for every session that carries
a fingerprint (including human_via_agent), so a redeem-vs-rekey race
cannot leave a view:read bearer alive. Drop the duplicate view_ttl key
in issue_code's return dict. Regression tests cover both.
2026-10-06 06:28:01 +08:00

575 lines
22 KiB
Python

"""Hardening tests: the findings from the self-review.
Each test here corresponds to a defect that was found and fixed. If any of
these regress, the corresponding vulnerability is back.
"""
import base64
import os
import tempfile
import threading
import time
import unittest
from sigil import errors, identity, keys, sessions, viewcodes
from sigil.store import Store
import helpers
def enrolled(store=None, agent="bigpickle"):
store = store or Store(":memory:")
private, _ = helpers.enrol(store, agent)
return store, private, None
sign = helpers.sign
def issue(store, private, agent="bigpickle", **kw):
return helpers.issue(store, private, agent, **kw)
class TestSingleUseUnderRace(unittest.TestCase):
"""Finding: check-then-update let one code mint several sessions.
Measured before the fix: a single-use code succeeded 753 times across 200
trials. These tests hold the invariant under real concurrency.
"""
TRIALS = 40
THREADS = 8
def _race(self, setup, threads=THREADS):
"""Set up one reusable credential per trial, then race for it.
setup returns (store, shared, private). `shared` is the single-use
credential the threads all compete for, created once outside the
threads, so this genuinely tests the same-secret-twice path.
"""
for _ in range(self.TRIALS):
store, shared, private = setup()
barrier = threading.Barrier(threads)
outcomes = []
def go():
barrier.wait()
try:
work(store, shared, private)
outcomes.append(1)
except Exception:
outcomes.append(0)
pool = [threading.Thread(target=go) for _ in range(threads)]
for t in pool:
t.start()
for t in pool:
t.join()
yield sum(outcomes)
def test_one_code_yields_at_most_one_session(self):
def setup():
store, private, _ = enrolled()
return store, helpers.issue(store, private)["code"], private
def work(store, code, private):
viewcodes.redeem_code(store, code)
for successes in self._race(setup):
self.assertLessEqual(successes, 1, "one code minted multiple sessions")
def test_one_bootstrap_secret_enrols_at_most_once(self):
def setup():
store, private, _ = enrolled()
return store, identity.seed_bootstrap(store, "racer"), private
def work(store, secret, private):
identity.enrol(
store, "racer", keys.export_public_b64(keys.generate()[1]), secret
)
for successes in self._race(setup, threads=6):
self.assertLessEqual(successes, 1, "one secret enrolled multiple keys")
def test_one_nonce_verifies_at_most_once(self):
def setup():
store, private, _ = enrolled()
nonce = identity.challenge(store, "bigpickle")["nonce"]
return store, nonce, private
def work(store, nonce, private):
identity.verify(store, nonce, sign(private, nonce))
for successes in self._race(setup, threads=6):
self.assertLessEqual(successes, 1, "one nonce produced multiple sessions")
class TestTokensNotStoredInClear(unittest.TestCase):
"""Finding: session tokens were stored verbatim, so a database leak
handed an attacker live sessions."""
def setUp(self):
self.store, self.private, self.public = enrolled()
def _agent_token(self):
chal = identity.challenge(self.store, "bigpickle")
return identity.verify(self.store, chal["nonce"], sign(self.private, chal["nonce"]))["token"]
def test_agent_token_appears_nowhere_in_the_database_file(self):
import sqlite3
path = tempfile.mktemp(suffix=".db")
store = Store(path)
private, public = keys.generate()
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol(store, "bigpickle", keys.export_public_b64(public), secret)
chal = identity.challenge(store, "bigpickle")
token = identity.verify(
store, chal["nonce"], sign(private, chal["nonce"])
)["token"]
store.close()
with open(path, "rb") as handle:
blob = handle.read()
os.unlink(path)
self.assertNotIn(token.encode(), blob, "session token stored in the clear")
def test_human_token_appears_nowhere_in_the_database_file(self):
import sqlite3
path = tempfile.mktemp(suffix=".db")
store = Store(path)
private, public = keys.generate()
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol(store, "bigpickle", keys.export_public_b64(public), secret)
code = helpers.issue(store, private)["code"]
token = viewcodes.redeem_code(store, code)["token"]
store.close()
with open(path, "rb") as handle:
blob = handle.read()
os.unlink(path)
self.assertNotIn(token.encode(), blob, "view session token stored in the clear")
def test_hashed_token_still_resolves(self):
token = self._agent_token()
self.assertEqual(
sessions.resolve(self.store, token)["on_behalf_of"], "bigpickle"
)
class TestAuditChain(unittest.TestCase):
"""The log is an attestation substrate, so it must be tamper-evident."""
def test_empty_log_verifies(self):
self.assertTrue(Store(":memory:").verify_chain()["ok"])
def test_intact_log_verifies(self):
store, private, public = enrolled()
for _ in range(5):
identity.challenge(store, "bigpickle")
result = store.verify_chain()
self.assertTrue(result["ok"])
self.assertEqual(result["events"], len(store.events(limit=50)))
def test_edited_detail_is_detected(self):
store, private, public = enrolled()
identity.challenge(store, "bigpickle")
store.conn.execute(
"UPDATE events SET detail = '{\"tampered\":true}' WHERE id = 1"
)
store.conn.commit()
result = store.verify_chain()
self.assertFalse(result["ok"])
self.assertEqual(result["broken_at"], 1)
def test_deleted_event_is_detected(self):
store, private, public = enrolled()
identity.challenge(store, "bigpickle")
store.conn.execute("DELETE FROM events WHERE id = 1")
store.conn.commit()
self.assertFalse(store.verify_chain()["ok"])
def test_reordered_events_are_detected(self):
store, private, public = enrolled()
for _ in range(3):
identity.challenge(store, "bigpickle")
store.conn.execute("UPDATE events SET kind = 'tampered' WHERE id = 2")
store.conn.commit()
self.assertFalse(store.verify_chain()["ok"])
class TestKeyRotationKillsSessions(unittest.TestCase):
"""Finding: rekey replaced the key but left existing sessions alive, so
rotating after a compromise bought nothing."""
def setUp(self):
self.store, self.private, self.public = enrolled()
def _token(self):
chal = identity.challenge(self.store, "bigpickle")
return identity.verify(
self.store, chal["nonce"], sign(self.private, chal["nonce"])
)["token"]
def test_rekey_revokes_existing_sessions(self):
token = self._token()
sessions.resolve(self.store, token) # alive before
secret = identity.seed_bootstrap(self.store, "bigpickle")
identity.enrol_rekey(
self.store, "bigpickle",
keys.export_public_b64(keys.generate()[1]), secret,
)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(self.store, token)
def test_old_key_cannot_authenticate_after_rekey(self):
secret = identity.seed_bootstrap(self.store, "bigpickle")
identity.enrol_rekey(
self.store, "bigpickle",
keys.export_public_b64(keys.generate()[1]), secret,
)
chal = identity.challenge(self.store, "bigpickle")
with self.assertRaises(errors.BadSignature):
identity.verify(self.store, chal["nonce"], sign(self.private, chal["nonce"]))
def test_rotation_is_recorded(self):
secret = identity.seed_bootstrap(self.store, "bigpickle")
identity.enrol_rekey(
self.store, "bigpickle",
keys.export_public_b64(keys.generate()[1]), secret,
)
kinds = [e["kind"] for e in self.store.events(limit=50)]
self.assertIn("identity.rekeyed", kinds)
class TestRateLimits(unittest.TestCase):
"""Finding: challenge() was unmetered, an open signing oracle."""
def setUp(self):
self.store, self.private, self.public = enrolled()
def test_challenge_is_rate_limited(self):
raised = False
for _ in range(200):
try:
identity.challenge(self.store, "bigpickle")
except errors.RateLimited:
raised = True
break
self.assertTrue(raised, "challenge() was never rate limited")
def test_code_issue_is_rate_limited(self):
raised = False
for _ in range(200):
try:
helpers.issue(self.store, self.private)
except errors.RateLimited:
raised = True
break
self.assertTrue(raised, "issue_code() was never rate limited")
def test_redemption_guessing_is_rate_limited(self):
refused = 0
for i in range(500):
try:
viewcodes.redeem_code(self.store, f"ZZZZ-{i:04d}")
except errors.RateLimited:
break
except errors.SigilError:
refused += 1
else:
self.fail("code guessing was not rate limited")
def test_limits_are_per_agent(self):
for _ in range(40):
try:
identity.challenge(self.store, "bigpickle")
except errors.RateLimited:
break
# a different agent is unaffected
self.store2, _, _ = enrolled(self.store, agent="second")
identity.challenge(self.store2, "second")
class TestKeyEncryptionAtRest(unittest.TestCase):
"""Finding: keys were written with NoEncryption by default."""
def test_encrypted_key_needs_passphrase(self):
private, _ = keys.generate()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "k.pem")
keys.save_private(private, path, "a-long-enough-passphrase")
self.assertTrue(keys.is_encrypted(path))
with self.assertRaises(ValueError):
keys.load_private(path)
def test_wrong_passphrase_refuses(self):
private, _ = keys.generate()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "k.pem")
keys.save_private(private, path, "a-long-enough-passphrase")
with self.assertRaises(Exception):
keys.load_private(path, "the-wrong-passphrase")
def test_correct_passphrase_roundtrips(self):
private, _ = keys.generate()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "k.pem")
keys.save_private(private, path, "a-long-enough-passphrase")
loaded = keys.load_private(path, "a-long-enough-passphrase")
self.assertEqual(loaded.sign(b"m"), private.sign(b"m"))
def test_short_passphrase_refused(self):
private, _ = keys.generate()
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(ValueError):
keys.save_private(private, os.path.join(tmp, "k.pem"), "short")
def test_plaintext_key_still_loads(self):
private, _ = keys.generate()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "k.pem")
keys.save_private(private, path)
self.assertFalse(keys.is_encrypted(path))
keys.load_private(path)
class TestAgentIdValidation(unittest.TestCase):
"""Finding: agent ids were unvalidated, so an id carrying a newline
could forge audit lines."""
def test_bad_ids_refused(self):
store = Store(":memory:")
for bad in ["", "Big Pickle", "a" * 65, "with\nnewline", "with space",
"-leading", "with/slash", "with;drop"]:
with self.assertRaises(errors.SigilError, msg=f"accepted {bad!r}"):
identity.seed_bootstrap(store, bad)
def test_good_ids_accepted(self):
store = Store(":memory:")
for good in ["bigpickle", "agent-2", "a.b_c", "x" * 64]:
private, public = keys.generate()
secret = identity.seed_bootstrap(store, good)
identity.enrol(store, good, keys.export_public_b64(public), secret)
self.assertIn(good, store.agents())
def test_newline_id_cannot_reach_the_audit_log(self):
store = Store(":memory:")
with self.assertRaises(errors.SigilError):
identity.seed_bootstrap(store, "evil\nfake-event")
if __name__ == "__main__":
unittest.main()
class TestClaimCodeExpiryCAS(unittest.TestCase):
"""R6: claim_code CAS must include redeem_expires_at predicate."""
def test_claim_code_after_expiry_returns_false(self):
store, private = helpers.fresh()
issued = helpers.issue(store, private, code_ttl=1, view_ttl=60)
digest = viewcodes._hash_code(issued["code"])
# Force expiry in the row.
store.conn.execute(
"UPDATE codes SET redeem_expires_at = ? WHERE code_hash = ?",
(int(time.time()) - 1, digest),
)
store.conn.commit()
self.assertFalse(store.claim_code(digest))
row = store.get_code(digest)
self.assertIsNone(row["used_at"])
class TestEnrolRekeyRateLimit(unittest.TestCase):
"""N3: enrol_rekey uses a dedicated rekey: bucket; junk cannot lock ops."""
def test_exhausting_enrol_bucket_does_not_block_rekey(self):
store, _ = helpers.fresh("limited")
for _ in range(20):
store.allow("enrol:limited")
secret = identity.seed_bootstrap(store, "limited")
# Valid rekey must succeed even if enrol: is exhausted.
identity.enrol_rekey(
store, "limited", keys.export_public_b64(keys.generate()[1]), secret
)
def test_junk_rekey_fills_rekey_bucket_not_enrol(self):
store, _ = helpers.fresh("victim")
for _ in range(15):
try:
identity.enrol_rekey(
store, "victim", keys.export_public_b64(keys.generate()[1]), "junk"
)
except (errors.EnrolmentFailed, errors.RateLimited):
pass
# enrol: bucket still usable
self.assertTrue(store.allow("enrol:victim"))
# Valid ops rekey still works: failures charge rekey: only after bad secret,
# and a valid secret bypasses the limiter.
secret = identity.seed_bootstrap(store, "victim")
identity.enrol_rekey(
store, "victim", keys.export_public_b64(keys.generate()[1]), secret
)
def test_failed_rekey_eventually_rate_limited(self):
store, _ = helpers.fresh("hammer")
hit = False
for _ in range(30):
try:
identity.enrol_rekey(
store, "hammer", keys.export_public_b64(keys.generate()[1]), "nope"
)
except errors.RateLimited:
hit = True
break
except errors.EnrolmentFailed:
pass
self.assertTrue(hit, "junk rekey attempts should eventually RateLimited")
class TestBootstrapRowDeletedOnSpend(unittest.TestCase):
"""R8: spent bootstrap secrets are deleted, not retained as hashes."""
def test_spend_deletes_the_row(self):
store = Store(":memory:")
secret = identity.seed_bootstrap(store, "dave")
digest = __import__("hashlib").sha256(secret.encode()).hexdigest()
self.assertIsNotNone(store.conn.execute(
"SELECT 1 FROM bootstrap WHERE secret_hash = ?", (digest,)
).fetchone())
owner = store.spend_bootstrap(digest)
self.assertEqual(owner, "dave")
self.assertIsNone(store.conn.execute(
"SELECT 1 FROM bootstrap WHERE secret_hash = ?", (digest,)
).fetchone())
class TestResolveRechecksAgentState(unittest.TestCase):
"""N1: resolve must refuse sessions that outran revoke/rekey."""
def _plant_agent_session(self, store, agent, fingerprint):
"""Insert a live session as if put_session won a race against revoke."""
import time as _t
token = "race-survivor-token"
store.put_session(
sessions.hash_token(token),
sessions.AGENT,
agent,
agent,
list(sessions.AGENT_SCOPES),
int(_t.time()) + 900,
fingerprint=fingerprint,
)
return token
def test_resolve_refuses_race_survivor_after_permanent_revoke(self):
store, private = helpers.fresh()
fp = store.get_agent("bigpickle")["fingerprint"]
identity.revoke(store, "bigpickle", reason="compromise")
# Session inserted *after* revoke (race); not marked revoked_at.
token = self._plant_agent_session(store, "bigpickle", fp)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, token)
def test_resolve_refuses_stale_fingerprint_after_rekey(self):
store, private = helpers.fresh()
old_fp = store.get_agent("bigpickle")["fingerprint"]
new_pub = keys.generate()[1]
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(new_pub), secret
)
token = self._plant_agent_session(store, "bigpickle", old_fp)
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, token)
def test_redeem_refuses_code_after_rekey_even_if_not_invalidated(self):
"""Fingerprint binding: plant a code with a stale fingerprint."""
store, private = helpers.fresh()
agent_row = store.get_agent("bigpickle")
old_fp = agent_row["fingerprint"]
# Issue normally, then surgically restore used_at and keep old fp after rekey
issued = helpers.issue(store, private)
digest = viewcodes._hash_code(issued["code"])
new_pub = keys.generate()[1]
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(new_pub), secret
)
# Rekey invalidated the code; un-burn it to simulate put_code after invalidate.
store.conn.execute(
"UPDATE codes SET used_at = NULL, fingerprint = ? WHERE code_hash = ?",
(old_fp, digest),
)
store.conn.commit()
with self.assertRaises(errors.CodeInvalid):
viewcodes.redeem_code(store, issued["code"])
def test_redeem_refuses_without_live_key(self):
store, private = helpers.fresh()
issued = helpers.issue(store, private)
digest = viewcodes._hash_code(issued["code"])
identity.revoke(store, "bigpickle", reason="compromise")
identity.reinstate(store, "bigpickle") # ban lifted, key still wiped
store.conn.execute(
"UPDATE codes SET used_at = NULL WHERE code_hash = ?", (digest,)
)
store.conn.commit()
with self.assertRaises(errors.CodeInvalid):
viewcodes.redeem_code(store, issued["code"])
def test_verify_then_revoke_session_unusable(self):
"""Session minted just before revoke must not resolve."""
store, private = helpers.fresh()
chal = identity.challenge(store, "bigpickle")
sig = helpers.sign(private, chal["nonce"])
sess = identity.verify(store, chal["nonce"], sig)
identity.revoke(store, "bigpickle", reason="compromise")
with self.assertRaises(errors.SessionExpired):
sessions.resolve(store, sess["token"])
class TestHumanSessionFingerprintEpoch(unittest.TestCase):
"""N7: rekey must kill race-surviving human_via_agent sessions too."""
def test_resolve_refuses_stale_human_fingerprint_after_rekey(self):
store, private = helpers.fresh()
old_fp = store.get_agent("bigpickle")["fingerprint"]
# Plant a human session as if redeem's put_session won a race against rekey.
import time as _t
token = "human-race-survivor"
store.put_session(
sessions.hash_token(token),
sessions.HUMAN_VIA_AGENT,
"bigpickle's human",
"bigpickle",
list(sessions.HUMAN_VIEW_SCOPES),
int(_t.time()) + 3600,
granted_by="bigpickle",
fingerprint=old_fp,
)
new_pub = keys.generate()[1]
secret = identity.seed_bootstrap(store, "bigpickle")
identity.enrol_rekey(
store, "bigpickle", keys.export_public_b64(new_pub), secret
)
# Clear revoked_at that rekey set, to isolate the fingerprint check.
store.conn.execute(
"UPDATE sessions SET revoked_at = NULL, revoked_reason = NULL"
" WHERE token_hash = ?",
(sessions.hash_token(token),),
)
store.conn.commit()
with self.assertRaises(errors.SessionExpired) as ctx:
sessions.resolve(store, token)
self.assertIn("key epoch", str(ctx.exception))
def test_issue_code_return_has_single_view_ttl(self):
"""N8: issue_code return dict must not duplicate view_ttl."""
import inspect
from sigil import viewcodes as vc
src = inspect.getsource(vc.issue_code)
# Count literal "view_ttl" keys in the return dict construction region.
self.assertEqual(src.count('"view_ttl":'), 1, src[src.rfind("return"):])