Fix R2: bootstrap hygiene — one live seed, clear siblings on enrol

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.
This commit is contained in:
leora 2026-10-06 06:12:24 +08:00
parent d4af46cc1b
commit 23cc531711
4 changed files with 110 additions and 9 deletions

View File

@ -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,

View File

@ -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.

View File

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

View File

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