Chronicle.Ecto.Integrity (chronicle v0.1.2)

Copy Markdown

Verifies the Ecto audit ledger and returns an anchorable checkpoint.

Verification recomputes every content digest from the stored audit rows, validates every chain link and HMAC, checks for sequence gaps, and compares the terminal entry with the mutable ledger head.

Counting is doing security work here

The chain proves that the entries which exist are mutually consistent. It cannot, on its own, prove that an entry exists for every audit row, or that no row was added without one — a fabricated row with no ledger entry breaks nothing, because there is no link for it to break.

That is the gap verify_coverage/3 closes, and why several of the checks in this module are count queries rather than cryptography:

  • an audit row with no ledger entry is an unsigned record, caught by comparing the two counts;
  • two entries naming the same record are caught by the duplicate query, since that would let one record's signature be quietly replaced while the original lingered;
  • a group child whose root no longer exists is caught as an orphan, because children are covered by their root's signature and a child without one is covered by nothing.

A group's event_count is itself signed on the root, so removing or adding a child is detected when the payload is reassembled, not by a count query.

Enumerating ledgers from three tables is deliberate

ledger_names/2 unions the ledger column across heads, entries, and events rather than reading whichever one seems authoritative. A ledger that exists in only one of them is precisely the interesting case: rows written under a ledger name that appears nowhere else would never be selected for verification, and so would never be checked at all. A nil ledger is rejected outright — a row belonging to no chain cannot be covered by one.

verify_all/2 additionally refuses to proceed if a checkpoint names a ledger that no longer exists. Without that, deleting an entire ledger would verify cleanly, since what remains is perfectly consistent with itself.

Anchoring is positional, not "at least as far as"

An external checkpoint passes only if the walk actually crossed the exact sequence it names and found the digest it expected there. Comparing current position against the anchor's would be satisfied by an attacker who truncated the ledger and rewrote forward past it, which is the specific attack anchoring exists to catch.

Summary

Functions

Verifies every ledger and rejects unsigned or unowned audit rows.

Functions

checkpoint(repo, opts \\ [])

@spec checkpoint(
  Ecto.Repo.t(),
  keyword()
) :: {:ok, Chronicle.Integrity.Checkpoint.t()} | {:error, term()}

verify(repo, opts \\ [])

@spec verify(
  Ecto.Repo.t(),
  keyword()
) :: {:ok, Chronicle.Integrity.Checkpoint.t()} | {:error, term()}

verify_all(repo, opts \\ [])

@spec verify_all(
  Ecto.Repo.t(),
  keyword()
) ::
  {:ok, %{required(String.t()) => Chronicle.Integrity.Checkpoint.t()}}
  | {:error, term()}

Verifies every ledger and rejects unsigned or unowned audit rows.

Use this for scheduled whole-store verification. verify/2 intentionally verifies one named ledger so it can be used for sharded workloads.