Your audit log's hash chain doesn't prove what you think it does
A SHA-256 hash chain makes an audit log tamper-evident against casual edits — not against forgery. How we found the gap in our own claim and fixed it with HMAC checkpoints in an offline-first password manager.
- security
- audit-log
- rust
- cryptography
The common pattern
When I built audit logging for OxidVault, I followed the textbook approach. Each event appends to a log file. Every entry carries two hashes: prev_hash, the digest of the prior entry, and entry_hash, a SHA-256 over the entry payload plus that previous link. Entry one points at a fixed genesis value; entry two points at entry one’s hash; and so on. Change any line in the middle and every subsequent hash breaks. The chain is simple, easy to explain, and looks like the kind of thing compliance auditors expect to see.
We shipped it, documented it, and wrote that tampering is detectable. I said the same thing in my r/rust post announcing the project. On paper it sounded solid. A hash chain is a well-known pattern. SHA-256 is a well-known function. What could go wrong?
The flaw
Plenty, as it turns out — but not where I first looked.
A hash chain without a secret is tamper-evident only against naive edits. It is not evidence against forgery. An attacker with write access to the log file can rewrite any entries they want and recompute the entire chain so it validates cleanly. The genesis value is public. The hash function is public. The chaining rule is public. Nothing in the structure is unknown to someone who can modify the file. They delete the incriminating events, invent replacements, walk forward from genesis, and hand you back a log that passes a structural integrity check.
The threat model this actually covers is someone opening the file in a text editor and changing a line without understanding the consequences. It does not cover someone who wants to forge the log. I had conflated “detects casual edits” with “proves the log was not manipulated.” That is a meaningful gap, and it was our gap — in our docs, in our marketing language, and in how I described the feature publicly.
Once I saw it clearly, I could not un-see it. We had to fix the claim and the implementation.
The options
OxidVault is offline-first. There is no server in the loop, no HSM, no always-on signing service. Vaults live on local disks or SMB shares that the customer’s IT team controls. Any solution had to work inside that constraint, and I wanted to compare the realistic options honestly rather than pick the one that sounded best in a slide deck.
HMAC with a secret key was the strongest fit cryptographically. A keyed digest at checkpoints means an attacker who lacks the key cannot produce valid checkpoints over a rewritten chain. But the key has to live somewhere. In a cloud product with an always-on HSM or a dedicated signing service, key custody is a solved problem with its own trade-offs. In an offline desktop app, the key is derived when the user unlocks the vault and disappears when they lock it. That is workable, but it defines a precise threat model — one I had to state plainly rather than hand-wave.
Digital signatures solve a similar problem with more machinery: key pairs, signature formats, rotation policies. They do not remove the storage question. The private key still has to exist on the client while the vault is in use, and an attacker who obtains that key can sign a forged log as convincingly as they can forge HMAC checkpoints.
External anchoring — timestamping services, transparency logs, periodic uploads to a witness — can bind a chain head to a point in time that an attacker cannot rewrite retroactively. That contradicts offline-first. Some customers want air-gapped or network-optional deployments. Anchoring is valuable when you accept the dependency; it was not the default for us.
WORM storage and OS-level ACLs are useful outer layers. They raise the cost of tampering and align with how SMBs already protect sensitive directories. They are not cryptographic proof. A privileged insider or a compromised admin account can still replace files. I treat ACLs as necessary defense in depth, not as a substitute for keyed integrity checks.
Our solution
We added HMAC checkpoints on top of the existing hash chain rather than replacing it. The chain still gives a fast, keyless structural check — useful for spotting accidental corruption and for legacy vaults that predate the new machinery. Checkpoints add a keyed layer that an attacker without vault access cannot forge.
The audit HMAC key is derived from material already present in an unlocked vault. Every authorised user shares the same Data Encryption Key; only the KEK wrappings differ per user and survive password rotation. That means every user derives the same audit key while the vault is open, without introducing a separate secret file on disk:
let audit_hmac_key = hkdf_expand_sha256(
ikm: &vault_dek,
salt: None,
info: b"oxidvault-audit-v1",
len: 32,
)?;
The DEK exists in memory only while the vault is unlocked. On lock, the derived audit key is zeroized along with the other sensitive material. There is no long-lived audit.key sitting next to the log waiting for a copy operation.
Checkpoint entries are written after unlock, on lock, and every fifty events. Each checkpoint carries an HMAC-SHA256 over the chain head — the entry_hash of the latest event — keyed with audit_hmac_key. An on-disk checkpoint line looks like this:
[2026-07-03T10:15:00Z] [Checkpoint] [-] prev_hash=a3f9…c812 entry_hash=5d17…9b04 hmac=7e2b…41d0
Events that occur while the vault is locked — failed authentication attempts are the common case — cannot carry an HMAC because the key is not available. They still append to the hash chain. The next checkpoint after unlock covers them: its HMAC binds the chain head that includes those locked-state events. Verification is deliberately two-tier. The structural pass walks prev_hash links and recomputes entry_hash values without secrets. The keyed pass verifies every checkpoint HMAC against a freshly derived audit key in an unlocked session. Our compliance dashboard reports both results separately.
That separation is deliberate: an operator should be able to distinguish “chain intact but checkpoints unverifiable because the vault is locked” from “chain broken” from “chain intact and checkpoints valid” — instead of collapsing everything into a single green checkmark.
What it still cannot do
I am not going to soften this section. HMAC checkpoints upgraded our claim; they did not make the log tamper-proof.
An attacker who obtains the DEK — meaning they can unlock the vault — can forge the log including valid checkpoints. They have the same key material we use to sign checkpoints. Cryptography cannot distinguish their forged log from a legitimate one if they had full access at the time of forgery. OS ACLs, file permissions, and monitoring on the vault directory remain the outer defense against someone reaching the log and the unlocked session in the first place.
Legacy v1 vaults without a DEK only receive the structural chain check. There is no shared encryption key from which to derive an audit HMAC key, so we cannot retrofit keyed checkpoints without a migration that introduces a DEK. Those vaults still detect casual edits. They do not detect forgery by someone with write access who understands the format.
The honest framing is this: we moved from “detects casual edits” to “detects forgery by anyone without an unlocked vault session.” That is a real improvement for the threat model this product is built for — insider tampering after the fact, an attacker with filesystem access but no credentials, a restored backup paired with an edited log. It is not proof against a fully compromised vault. No offline client-side design I evaluated could claim that without external anchoring or hardware we were not willing to mandate.
Takeaway
When you write “tamper-evident,” state the threat model explicitly: who can modify the file, what secret material they would need, and what your design does when that material is available. A hash chain alone answers a narrow question. Keyed checkpoints answer a broader one, but only if the key stays out of the attacker’s hands.
This shipped in OxidVault v2.4.0; the audit module is open source in the AGPL community edition on GitHub.