Skip to content

Audit Hash Chain

HMAC-SHA256 hash chain on odp_activity records for tamper detection. If any record is modified or deleted, the chain breaks and verification detects it.

How It Works

Chain Structure

Record 1:  previous_hash = "GENESIS"     hash = HMAC(GENESIS + data₁)
Record 2:  previous_hash = hash₁         hash = HMAC(hash₁ + data₂)
Record 3:  previous_hash = hash₂         hash = HMAC(hash₂ + data₃)
...

Existing records (before the feature was enabled) have hash = NULL and are not part of the chain. The first record inserted after enabling the feature becomes the genesis point.

Hash Computation

Input:
  previous_hash:  hash of the previous record (or "GENESIS")
  record:         canonical JSON of { action, user, timestamp, ip, user_agent, collection, item, origin }

Algorithm:
  HMAC-SHA256(previous_hash + canonical_JSON, AUDIT_HMAC_KEY)

Output:
  64-character hex string

Canonical JSON uses sorted keys and excludes undefined values for deterministic serialization.

Scheduled Verification

A background job verifies the entire chain periodically:

  1. Read records in ID order (batch size: 1000)
  2. Recompute each record's hash from its data + previous record's hash
  3. Compare computed hash with stored hash
  4. If mismatch: emit AUDIT_CHAIN_BROKEN security event (severity: critical)

The job uses scheduleSynchronizedJob — only one instance runs verification in a cluster.

Concurrency Safety

DatabaseMechanism
PostgreSQLpg_advisory_xact_lock(9999) — serializes hash chain writes within transactions
SQLiteSingle-connection pool provides natural serialization

Overhead: ~2-3ms per activity insert (advisory lock + 1 SELECT for previous hash).

Key Rotation

The hash_version column supports key rotation:

  1. Deploy with new AUDIT_HMAC_KEY and increment HASH_VERSION
  2. New records use the new key and version
  3. Verification reads the correct key based on each record's hash_version
  4. Old records remain verifiable with the previous key

Database Changes

sql
-- Migration: 062-audit-hash-chain.ts
ALTER TABLE odp_activity
  ADD COLUMN previous_hash VARCHAR(64),
  ADD COLUMN hash VARCHAR(64),
  ADD COLUMN hash_version SMALLINT;

Configuration

VariableTypeDefaultDescription
AUDIT_HASH_ENABLEDbooleanfalseEnable hash chain on activity inserts
AUDIT_HMAC_KEYstring''HMAC signing key (must differ from SECRET)
AUDIT_VERIFY_SCHEDULEstring'0 * * * *'Cron schedule for chain verification (default: hourly)

WARNING

AUDIT_HMAC_KEY must be a strong random string (32+ characters) and must not be the same as SECRET. If AUDIT_HASH_ENABLED=true but AUDIT_HMAC_KEY is empty, hash computation is silently skipped.

Source Files

FilePurpose
src/security/audit/hash-chain.tscomputeActivityHash(), GENESIS_HASH, HASH_VERSION
src/security/audit/integrity.tsverifyChain() — batch verification
src/security/audit/scheduler.tsBackground verification job
src/services/activity.tsHash chain integration in createActivity()

ODP Internal API Documentation