"""Off-box audit chain anchor. Finding: a signed checkpoint inside the same SQLite file is still only as strong as that file. An attacker who can rewrite the database can delete the checkpoints table and recompute the chain. Publishing the signed tip to a path outside the database means rewriting the DB alone cannot erase the tip. """ from __future__ import annotations import json import os import tempfile import unittest from sigil import identity, keys from sigil.store import Store, GENESIS from sigil import anchor as anchor_mod from tests import helpers class TestOffBoxAnchor(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.db = os.path.join(self.tmp.name, "sigil.db") self.tip = os.path.join(self.tmp.name, "offbox", "chain.tip") self.store = Store(self.db, anchor_path=self.tip) self.private, _ = helpers.enrol(self.store, "bigpickle") identity.challenge(self.store, "bigpickle") # leaves an event def test_checkpoint_publishes_signed_tip_outside_the_db(self): result = self.store.checkpoint(self.private, "bigpickle", "daily") self.assertEqual(result["anchor_path"], self.tip) self.assertTrue(os.path.exists(self.tip)) # Tip must not live inside the SQLite file. with open(self.db, "rb") as handle: raw = handle.read() tip_text = open(self.tip, "r", encoding="utf-8").read() notice = json.loads(tip_text) self.assertNotIn(notice["signature"].encode(), raw) self.assertEqual(notice["head"], result["head"]) self.assertEqual(notice["signer"], "bigpickle") self.assertTrue(self.store.verify_external_anchors()["ok"]) def test_publish_anchor_refuses_without_path(self): bare, private = helpers.fresh() with self.assertRaises(ValueError): bare.publish_anchor(private, "bigpickle") def test_deleting_in_db_checkpoints_does_not_erase_the_tip(self): self.store.checkpoint(self.private, "bigpickle", "kept-offbox") self.store.conn.execute("DELETE FROM checkpoints") self.store.conn.commit() # In-DB check has nothing left to verify. self.assertTrue(self.store.verify_checkpoints()["ok"]) self.assertEqual(self.store.verify_checkpoints()["checkpoints"], 0) # Off-box tip still present and valid against the live chain. external = self.store.verify_external_anchors() self.assertTrue(external["ok"], external) self.assertGreaterEqual(external["anchors"], 1) self.assertTrue(os.path.exists(self.tip)) def test_rewriting_the_db_is_caught_by_the_offbox_tip(self): self.store.checkpoint(self.private, "bigpickle", "baseline") tip_before = json.loads(open(self.tip).read()) # Attacker deletes history, forges an event, rebuilds hashes, and # clears the in-DB checkpoints so verify_checkpoints is vacuously ok. self.store.conn.execute("DELETE FROM events") self.store.conn.execute("DELETE FROM checkpoints") self.store.conn.commit() self.store.record("forged", "attacker", subject="bigpickle", detail={"note": "never happened"}) rows = self.store.conn.execute("SELECT * FROM events ORDER BY id").fetchall() prev = GENESIS for row in rows: digest = Store._event_hash( prev, row["at"], row["kind"], row["actor"], row["subject"], row["fingerprint"], row["detail"], ) self.store.conn.execute( "UPDATE events SET prev_hash = ?, hash = ? WHERE id = ?", (prev, digest, row["id"]), ) prev = digest self.store.conn.commit() self.assertTrue(self.store.verify_chain()["ok"]) self.assertTrue(self.store.verify_checkpoints()["ok"]) # Tip file still holds the pre-attack head. tip_after = json.loads(open(self.tip).read()) self.assertEqual(tip_after["head"], tip_before["head"]) external = self.store.verify_external_anchors() self.assertFalse(external["ok"], external) self.assertIn("off-box tip", external["failures"][0]["reason"]) def test_forged_offbox_signature_is_rejected(self): self.store.checkpoint(self.private, "bigpickle", "honest") notice = json.loads(open(self.tip).read()) notice["signature"] = __import__("base64").b64encode(b"\x00" * 64).decode() open(self.tip, "w").write(json.dumps(notice)) # Truncate the log so verify only sees the forged tip. log = self.tip + ".log" if os.path.exists(log): os.unlink(log) external = self.store.verify_external_anchors() self.assertFalse(external["ok"]) self.assertIn("signature", external["failures"][0]["reason"]) def test_missing_configured_tip_fails_closed(self): # Configure a path that was never written. lonely = Store(self.db, anchor_path=os.path.join(self.tmp.name, "missing.tip")) external = lonely.verify_external_anchors() self.assertFalse(external["ok"]) self.assertIn("missing", external["failures"][0]["reason"]) def test_no_anchor_path_skips_cleanly(self): store, private = helpers.fresh() store.checkpoint(private, "bigpickle", "in-db-only") external = store.verify_external_anchors() self.assertTrue(external["ok"]) self.assertTrue(external.get("skipped")) def test_anchor_log_keeps_prior_tips(self): first = self.store.checkpoint(self.private, "bigpickle", "one") identity.challenge(self.store, "bigpickle") second = self.store.checkpoint(self.private, "bigpickle", "two") self.assertNotEqual(first["head"], second["head"]) history = anchor_mod.read_log(self.tip) self.assertGreaterEqual(len(history), 2) self.assertEqual(history[-1]["head"], second["head"]) tip = anchor_mod.read_tip(self.tip) self.assertEqual(tip["head"], second["head"]) self.assertTrue(self.store.verify_external_anchors()["ok"]) if __name__ == "__main__": unittest.main()