Problem/Motivation

resolveHashKey() returns an empty string when the configured Key entity is missing or resolves empty, and hashRow() then falls back to plain SHA-256. Nothing warns. A site that believes it has a signed, tamper-evident chain can have an unsigned one, and there is no way to tell from the data.

Steps to reproduce

Found on a real site while upgrading its consumer: hash_key was set to a Key entity that resolves correctly now, yet 1,997 of 2,002 rows verify under unkeyed SHA-256 and zero under HMAC. The key was almost certainly unresolvable in the environment where those rows were written, and every write quietly took the fallback.

Two consequences, the second worse than the first:

  1. The chain is not actually signed, so anyone with database access can edit a row and recompute its hash. That is the entire property this module claims to provide.
  2. Once the key does resolve, verify() recomputes every historical row with it and reports "BROKEN at row 1 — an entry has been inserted, deleted or edited". The diagnosis is wrong and alarming: nothing was tampered with, the rows were simply written unkeyed. Real tampering and a key that started resolving are indistinguishable.

Proposed resolution

Fail loud rather than silently downgrade. When hash_key is set but unresolvable: log an error naming the key on every write instead of falling through, and raise it on the status report via hook_requirements(). Whether to refuse the write outright needs discussion — dropping an audit entry is its own failure mode, so the safer shape is probably to write the row, mark it, and shout.

Record which key hashed each row. Without this, introducing or rotating a key makes all prior history unverifiable and indistinguishable from tampering. A key id or fingerprint column, consulted by verify(), would let a chain span a key change and still verify each segment under the key that produced it. This is the part that makes the guarantee survive normal operations.

Distinguish the failure in verify(). "Verifies under no key but not under the configured key" is a specific, recognisable state and should be reported as written unkeyed, not as tampering.

Remaining tasks

Worth noting that this exact class was already fixed once elsewhere: MCP Sentinel 1.13.0 made a declared-but-unresolvable webhook signing key refuse the delivery instead of silently sending unsigned. The same reasoning applies here, and arguably matters more — a webhook can be resent, a historical audit row cannot be rewritten.

The behaviour was inherited verbatim when the chain was extracted from MCP Sentinel 1.13, so any site upgrading to MCP Sentinel 2.0.0 with a keyed chain hits consequence 2 immediately.

Implementation notes: audit_chain issue 1.

Comments

jmcerda created an issue. See original summary.

jmcerda’s picture

Status: Active » Fixed

Fixed on the 1.x branch (GitHub PR #6).

- An unresolvable key now logs an error naming it on **every** write, and a new `hook_requirements()` reports the condition at ERROR. The entry is still written — dropping an audit record is a worse failure than an unsigned one.
- `verify()` separates `tampered` from `written_unkeyed` and reports how many rows and through which id. Both still exit non-zero, so the documented exit-code contract is unchanged; only the diagnosis is.
- Rows record which key hashed them (`key_id`), plus an update hook. The column is deliberately **advisory**: it is not covered by the row hash and cannot be without invalidating every earlier row, so verification treats it as a hint about which key to try first. Trusting it would let anyone able to write to the log blank it, recompute the row unkeyed, and have the edit accepted. A test pins that.
- Retired keys (`previous_hash_keys`) are accepted when verifying, so rotating the signing key no longer orphans everything written before it.

What the real chain turned out to be, walking all 2,002 rows:

```
total=2002 keyed=0 unkeyed=1997 neither=5 nohash=0 discontinuous=0
```

`discontinuous=0` with no gaps in ids 1..2002 — nothing was inserted, deleted or reordered. The chain was never tampered with; it ran unsigned. `keyed=0` rather than "mostly unkeyed": the key was unresolvable for the entire period, not intermittently.

The remaining 5 are a separate defect, now #3614138: Invalid UTF-8 in metadata destroys the row: json_encode returns FALSE and the cast makes it empty(https://www.drupal.org/project/audit_chain/issues/3614138) — malformed UTF-8 made `json_encode()` return FALSE and the cast hashed those rows over an empty canonical. They are unrecoverable.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

jmcerda’s picture

Version: 1.0.1 » 1.1.0

Fixed in 1.1.0 (GitHub #1 / PR #6). Unresolvable keys log and fail requirements; verify() reports written_unkeyed separately from tampering.

jmcerda’s picture

Status: Fixed » Closed (fixed)

Shipped in 1.1.0. Closing after release.