From 23cc531711c23c8004dc76bb16a56600845a5ccc Mon Sep 17 00:00:00 2001 From: leora Date: Tue, 6 Oct 2026 06:12:24 +0800 Subject: [PATCH] =?UTF-8?q?Fix=20R2:=20bootstrap=20hygiene=20=E2=80=94=20o?= =?UTF-8?q?ne=20live=20seed,=20clear=20siblings=20on=20enrol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unspent bootstrap secrets accumulated and remained usable for enrol_rekey after enrol. put_bootstrap now spends prior unspent seeds for the agent (one live secret), and successful enrol / enrol_rekey clear any siblings. Regression tests cover leftover-after-enrol, one-live-secret, and leftover-after-rekey. --- sigil/identity.py | 4 +++ sigil/store.py | 42 ++++++++++++++++++++++++++--- tests/test_identity.py | 58 +++++++++++++++++++++++++++++++++++++++- tests/test_production.py | 15 ++++++++--- 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/sigil/identity.py b/sigil/identity.py index 54d4c0d..fc588c7 100644 --- a/sigil/identity.py +++ b/sigil/identity.py @@ -88,6 +88,8 @@ def enrol( fingerprint = keys.fingerprint(public) if not store.put_agent(agent, keys.public_bytes(public), fingerprint): raise errors.EnrolmentFailed(f"{agent} is already enrolled") + # Spend any sibling seeds left over from double-seed / unused chat leaks. + store.clear_bootstrap_for_agent(agent) store.record( "identity.enrolled", actor=agent, @@ -270,6 +272,8 @@ def enrol_rekey(store: Store, agent: str, public_b64: str, secret: str) -> str: # A new key must not leave old sessions alive, or rotating after a # compromise buys nothing. killed = store.revoke_sessions_for_agent(agent, "key rotated") + # Leftover bootstrap seeds must not remain a standing rekey capability. + store.clear_bootstrap_for_agent(agent) store.record( "identity.rekeyed", actor=agent, diff --git a/sigil/store.py b/sigil/store.py index b83a524..e0aeab8 100644 --- a/sigil/store.py +++ b/sigil/store.py @@ -312,10 +312,44 @@ class Store: # -- bootstrap --------------------------------------------------------- def put_bootstrap(self, secret_hash: str, agent: str) -> None: - self._exec( - "INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)", - (secret_hash, agent, int(time.time())), - ) + """Register one unspent bootstrap secret for an agent. + + Any prior unspent secrets for the same agent are spent first so only + one live seed exists at a time (ops double-seed or a chat leak of an + unused seed must not leave a standing rekey capability). + """ + with self._write_lock: + now = int(time.time()) + self.conn.execute( + "UPDATE bootstrap SET spent_at = ? WHERE agent = ? AND spent_at IS NULL", + (now, agent), + ) + self.conn.execute( + "INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)", + (secret_hash, agent, now), + ) + self.conn.commit() + + def clear_bootstrap_for_agent(self, agent: str, *, keep_hash: Optional[str] = None) -> int: + """Spend (or delete) every unspent bootstrap secret for an agent. + + After a successful enrol / enrol_rekey, leftover seeds must not remain + usable. keep_hash, when set, is left alone (already consumed by CAS). + """ + with self._write_lock: + if keep_hash is None: + cur = self.conn.execute( + "UPDATE bootstrap SET spent_at = ? WHERE agent = ? AND spent_at IS NULL", + (int(time.time()), agent), + ) + else: + cur = self.conn.execute( + "UPDATE bootstrap SET spent_at = ? WHERE agent = ? AND spent_at IS NULL" + " AND secret_hash != ?", + (int(time.time()), agent, keep_hash), + ) + self.conn.commit() + return cur.rowcount def spend_bootstrap(self, secret_hash: str) -> Optional[str]: """Atomically consume an enrolment secret. Returns the agent or None. diff --git a/tests/test_identity.py b/tests/test_identity.py index b8f1510..8dec5d9 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -6,6 +6,8 @@ import unittest from sigil import errors, identity, keys from sigil.store import Store +import helpers + class Base(unittest.TestCase): def setUp(self): @@ -144,5 +146,59 @@ class TestRekey(Base): ) +class TestBootstrapHygiene(unittest.TestCase): + """R2: only one live bootstrap secret; leftovers unusable after enrol.""" + + def test_leftover_seed_fails_after_enrol(self): + store = Store(":memory:") + private, public = keys.generate() + s1 = identity.seed_bootstrap(store, "alice") + # Direct insert of a second hash simulates a pre-hygiene double-seed + # (put_bootstrap now clears siblings; plant one after s1 is recorded). + import hashlib + s2 = "leftover-seed-that-must-die" + store.conn.execute( + "INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)", + (hashlib.sha256(s2.encode()).hexdigest(), "alice", 1), + ) + store.conn.commit() + identity.enrol(store, "alice", keys.export_public_b64(public), s1) + with self.assertRaises(errors.EnrolmentFailed): + identity.enrol_rekey( + store, "alice", keys.export_public_b64(public), s2 + ) + + def test_put_bootstrap_keeps_only_one_live_secret(self): + store = Store(":memory:") + s1 = identity.seed_bootstrap(store, "bob") + s2 = identity.seed_bootstrap(store, "bob") + private, public = keys.generate() + with self.assertRaises(errors.EnrolmentFailed): + identity.enrol(store, "bob", keys.export_public_b64(public), s1) + # Only the latest seed works. + identity.enrol(store, "bob", keys.export_public_b64(public), s2) + + def test_leftover_seed_fails_after_rekey(self): + store = Store(":memory:") + helpers.enrol(store, "carol") + import hashlib + leftover = "another-unused-seed" + new_pub = keys.generate()[1] + secret = identity.seed_bootstrap(store, "carol") + # Plant a sibling after the live seed (simulates a concurrent leak). + store.conn.execute( + "INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)", + (hashlib.sha256(leftover.encode()).hexdigest(), "carol", 2), + ) + store.conn.commit() + identity.enrol_rekey( + store, "carol", keys.export_public_b64(new_pub), secret + ) + newer_pub = keys.generate()[1] + with self.assertRaises(errors.EnrolmentFailed): + identity.enrol_rekey( + store, "carol", keys.export_public_b64(newer_pub), leftover + ) + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_production.py b/tests/test_production.py index 927497b..2108715 100644 --- a/tests/test_production.py +++ b/tests/test_production.py @@ -345,10 +345,17 @@ class TestDualBootstrapEnrolRace(unittest.TestCase): import threading store = Store(":memory:") - secrets = [ - identity.seed_bootstrap(store, "racer"), - identity.seed_bootstrap(store, "racer"), - ] + # Plant two live secrets directly: put_bootstrap now keeps only one + # live seed, so the dual-enrol race must bypass that hygiene. + import hashlib, time as _time + secrets = ["race-secret-alpha", "race-secret-beta"] + now = int(_time.time()) + for s in secrets: + store.conn.execute( + "INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)", + (hashlib.sha256(s.encode()).hexdigest(), "racer", now), + ) + store.conn.commit() pubs = [keys.generate()[1] for _ in range(2)] results = [] lock = threading.Lock() -- 2.47.3