Merge pull request 'Fix R2: bootstrap hygiene — one live seed, clear siblings on enrol' (#6) from leora/fix-r2-bootstrap-hygiene into sigil-initial

This commit is contained in:
leora 2026-10-06 06:12:25 +08:00
commit 2e0900e15b
4 changed files with 110 additions and 9 deletions

View File

@ -88,6 +88,8 @@ def enrol(
fingerprint = keys.fingerprint(public) fingerprint = keys.fingerprint(public)
if not store.put_agent(agent, keys.public_bytes(public), fingerprint): if not store.put_agent(agent, keys.public_bytes(public), fingerprint):
raise errors.EnrolmentFailed(f"{agent} is already enrolled") 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( store.record(
"identity.enrolled", "identity.enrolled",
actor=agent, 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 # A new key must not leave old sessions alive, or rotating after a
# compromise buys nothing. # compromise buys nothing.
killed = store.revoke_sessions_for_agent(agent, "key rotated") 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( store.record(
"identity.rekeyed", "identity.rekeyed",
actor=agent, actor=agent,

View File

@ -312,10 +312,44 @@ class Store:
# -- bootstrap --------------------------------------------------------- # -- bootstrap ---------------------------------------------------------
def put_bootstrap(self, secret_hash: str, agent: str) -> None: def put_bootstrap(self, secret_hash: str, agent: str) -> None:
self._exec( """Register one unspent bootstrap secret for an agent.
"INSERT INTO bootstrap (secret_hash, agent, created_at) VALUES (?, ?, ?)",
(secret_hash, agent, int(time.time())), 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]: def spend_bootstrap(self, secret_hash: str) -> Optional[str]:
"""Atomically consume an enrolment secret. Returns the agent or None. """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 import errors, identity, keys
from sigil.store import Store from sigil.store import Store
import helpers
class Base(unittest.TestCase): class Base(unittest.TestCase):
def setUp(self): 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -345,10 +345,17 @@ class TestDualBootstrapEnrolRace(unittest.TestCase):
import threading import threading
store = Store(":memory:") store = Store(":memory:")
secrets = [ # Plant two live secrets directly: put_bootstrap now keeps only one
identity.seed_bootstrap(store, "racer"), # live seed, so the dual-enrol race must bypass that hygiene.
identity.seed_bootstrap(store, "racer"), 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)] pubs = [keys.generate()[1] for _ in range(2)]
results = [] results = []
lock = threading.Lock() lock = threading.Lock()