Fix R2: bootstrap hygiene — one live seed, clear siblings on enrol #6
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user