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.
138 lines
6.1 KiB
Python
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()
|