Chronicle.Integrity (chronicle v0.1.2)

Copy Markdown

Hash-chain and HMAC primitives for tamper-evident audit ledgers.

Every entry carries three separate commitments, and keeping them separate is the design. One combined digest would detect tampering just as reliably; it would not tell anyone what kind of tampering happened.

  • content_digest commits to what the record says. Anyone holding the content can recompute it, so it proves the content is unmodified and nothing else.
  • digest — the chain digest — commits to where that content sits in history: the ledger, the sequence number, the preceding entry's digest, and the content digest itself. This is the link that makes a chain.
  • signature is an HMAC over the chain digest. It cannot be produced without the key, so it is the only one of the three that says this application wrote the entry, rather than someone holding a database connection.

The digests nest, and verification exploits that

Each digest is computed over the one before it, so a single act of tampering invalidates all three: editing a payload changes the content digest, which changes the chain digest containing it, which changes the signature over that. verify_entry/5 therefore checks them innermost first, so the mismatch it reports is the root cause rather than the outermost symptom. Checking the signature first would be equally sound cryptographically and would answer :signature_mismatch for every possible attack, which tells an operator nothing about what was done to their data.

For the same reason the cheap structural checks — sequence number, previous digest — run ahead of the rebuild that recomputes all three.

Every hashed tuple leads with a tag

The values handed to SHA-256 and HMAC are tuples whose first element names what the digest is for. That tag is domain separation: it guarantees a digest computed for one purpose can never verify as a digest computed for another, even where the remaining fields would encode identically. Without it, an attacker who could get a content digest accepted where a chain digest is expected would hold a chain that verifies over a history that never happened.

The algorithm is inside the hash, not beside it

The algorithm string and the canonical encoding version are hashed into all three tuples as well as being stored on the entry. Because the digests cover them, an entry cannot be relabelled as using a weaker scheme and still verify. Verification uses the stored pair to select a frozen implementation from a closed registry. A known historical pair is always checked under the rules that wrote it; an unknown pair fails loudly with :unsupported_algorithm or :unsupported_canonical_version.

Summary

Functions

Computes all three commitments for one entry at a known ledger position.

Rebuilds an entry from a stored row, accepting atom or string keys.

Flattens an entry into a map for insertion alongside the audit row.

Resolves a verification key and enforces its configured sequence epoch.

Verifies content, chain position, and HMAC for one entry.

Types

integrity_options()

@type integrity_options() :: keyword()

Functions

build(kind, record_id, payload, sequence, previous_digest, opts)

@spec build(
  :event | :group,
  String.t(),
  term(),
  pos_integer(),
  String.t() | nil,
  integrity_options()
) :: {:ok, Chronicle.Integrity.Entry.t()} | {:error, term()}

Computes all three commitments for one entry at a known ledger position.

The caller supplies the position — sequence and the preceding entry's previous_digest — because an entry is in no position to vouch for where it sits. Establishing that order is the ledger's job, under a lock; this function only commits to what it is told.

The signing key is fetched through the keyring using sequence, so a key configured for one epoch cannot sign outside it. There is no path here that produces an entry without a key: if one cannot be resolved, this returns an error and the surrounding write is expected to fail with it, rather than proceeding unsigned.

entry_from_row(row)

@spec entry_from_row(map()) :: Chronicle.Integrity.Entry.t()

Rebuilds an entry from a stored row, accepting atom or string keys.

Rows reach this function both from Ecto schemas and from raw queries, hence the tolerance for either key type. Nothing is validated beyond kind: a row written under an older algorithm or canonical version reconstructs quite happily here and is rejected by verify_entry/5, which is the layer that knows what "supported" means. Keeping the judgement in one place is why this function looks credulous.

entry_row(entry)

@spec entry_row(Chronicle.Integrity.Entry.t()) :: map()

Flattens an entry into a map for insertion alongside the audit row.

The inserted_at stamped here sits outside every digest, and that is worth knowing before anyone relies on it. It records when the row reached the database, which is useful to an operator and worthless as evidence — a clock is not a witness, and whoever can edit the row can edit the timestamp. Order is established by sequence and previous_digest, which are signed.

verification_key(key_id, sequence \\ nil, opts)

@spec verification_key(String.t(), pos_integer() | nil, integrity_options()) ::
  {:ok, binary()} | {:error, term()}

Resolves a verification key and enforces its configured sequence epoch.

Every lookup goes through the keyring, so a key can never authenticate an entry outside its declared epoch. Configure a :keys map with :key_epochs, the single :key_id and :key pair, or a custom :keyring.

There is deliberately no way to hand a key directly to verification. An entry names the key that signed it, and an attacker who can edit that column could otherwise nominate a key of their choosing; binding the lookup to the sequence number is what stops a compromised or retired key from being used to re-sign history it was never valid for.

verify_entry(entry, payload, expected_previous, expected_sequence, opts)

@spec verify_entry(
  Chronicle.Integrity.Entry.t(),
  term(),
  String.t() | nil,
  pos_integer(),
  integrity_options()
) :: :ok | {:error, term()}

Verifies content, chain position, and HMAC for one entry.

expected_previous and expected_sequence come from the caller walking the ledger in order, never from the entry itself. That is the whole point: an entry that supplied its own expectations would verify happily after being renumbered or moved, and a chain of such entries would agree with itself about a history nobody wrote.

The checks run cheapest and innermost first, so the error names the specific layer that failed — :content_digest_mismatch for edited content, :chain_digest_mismatch for content that is intact but relocated, :signature_mismatch for a chain rebuilt without the key. See the module documentation for why that ordering is load-bearing rather than tidy.

The key is always selected from the configured keyring using the entry's signed key_id and sequence. Raw key material is not an accepted argument. This makes epoch enforcement a property of the operation rather than a convention each caller has to remember.