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:
parent
9eea815805
commit
a474882f61
@ -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,
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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()
|
||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user