sigil/tests/test_anchor.py
leora 13802d8f59 Off-box audit chain tip: publish/verify outside the SQLite file
A signed checkpoint inside the same database is still only as strong as that
file. Store(..., anchor_path=) / SIGIL_ANCHOR now writes the signed tip to a
filesystem path outside the DB (atomic tip file + append-only .log).
verify_external_anchors catches a rewrite even after in-DB checkpoints are
deleted. 8 new tests in test_anchor.py; suite 119 green.

Part of Quarry 18a5d762 (Sigil prod build). Do not merge to main yet:
sealed store and independent crypto review still open.
2026-10-06 06:28:01 +08:00

138 lines
6.1 KiB
Python

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