@happy-technologies/audit
v0.2.0
Published
Tamper-proof audit hash-chain kernel shared across Happy Technologies products: keyed HMAC-SHA256 entry hashing, chain verification, and an optional Postgres single-writer helper. Domain-agnostic; products own their audit_log schema and migrations; app ow
Downloads
370
Readme
@happy-technologies/audit
Tamper-proof audit hash-chain kernel shared across Happy Technologies products. Each audit entry is HMAC-SHA256-hashed, keyed with an app-held secret, together with the previous entry's hash for the same tenant, forming a per-tenant hash chain: any content edit, insert, delete, or reorder breaks the chain and is detectable. Because the hash is keyed, a party who can only write to the database (no app secret) cannot forge a chain that re-verifies: this is tamper-proof, not merely tamper-evident.
0.2.0 is a breaking change from 0.1.x. The unkeyed, plain-SHA-256
computeEntryHash/verifyAuditChaintop-level exports are removed. Every consumer must callcreateAuditChain(secret)to get a keyed kernel. There is no data migration path baked into this package: because HMAC replaces plain SHA-256, chains computed under 0.1.x will not verify under 0.2.0. (No production audit data existed at the time of this change.)
Install
{
"dependencies": {
"@happy-technologies/audit": "^0.2.0"
}
}Usage
Root export: pure kernel
createAuditChain, stableStringify, and the types have no dependency
on Postgres or any database driver, so any TypeScript runtime can use them.
import { createAuditChain, type ChainRow } from "@happy-technologies/audit";
// secret comes from the app's own env/KMS -- see "Key management" below.
const chain = createAuditChain(process.env.AUDIT_HMAC_SECRET!);
const entryHash = chain.computeEntryHash(
{
id: "entry-1",
action: "document.created",
actorId: "user-1",
tenantId: "tenant-1",
resourceType: "document",
resourceId: "doc-1",
metadata: { source: "upload" },
ipAddress: "127.0.0.1",
userAgent: "Mozilla/5.0",
},
null, // prevHash, or the previous row's entryHash
);
const rows: ChainRow[] = []; // loaded from storage, ordered (timestamp ASC, id ASC)
const result = chain.verifyAuditChain(rows);
if (!result.ok) {
console.error(`chain broken at ${result.brokenAt} (${result.reason})`);
}createAuditChain returns a ChainKernel bound to one secret:
interface ChainKernel {
computeEntryHash(fields: AuditChainFields, prevHash: string | null): string;
verifyAuditChain(rows: readonly ChainRow[]): ChainVerification;
}./pg subpath: optional Postgres write helper
For consumers using pg directly, insertAuditEntry provides the enforced
single-writer path: it acquires a per-tenant pg_advisory_xact_lock, reads
the tenant's last entry_hash, computes the new (keyed) hash via the
ChainKernel you pass in, and inserts prev_hash + entry_hash in one
transaction.
import { createAuditChain } from "@happy-technologies/audit";
import { insertAuditEntry } from "@happy-technologies/audit/pg";
const chain = createAuditChain(process.env.AUDIT_HMAC_SECRET!); // create once, reuse
const client = await pool.connect();
try {
await insertAuditEntry(
client,
{
id: "entry-1",
timestamp: new Date(),
action: "document.created",
actorId: "user-1",
tenantId: "tenant-1",
resourceType: "document",
resourceId: "doc-1",
metadata: { source: "upload" },
ipAddress: "127.0.0.1",
userAgent: "Mozilla/5.0",
},
chain,
);
} finally {
client.release();
}pg is an optional peerDependency: only install it if you import from
@happy-technologies/audit/pg. The root export never imports pg.
Key management (consumer responsibility)
The app supplies the HMAC secret; this package never does.
- The secret is created and held by the CONSUMER application, sourced from
an environment variable or a KMS-backed secret store at process start.
It is passed to
createAuditChain(secret)once (typically at startup) and the returnedChainKernelreused across calls. - The secret MUST NOT be hardcoded, defaulted, or committed anywhere in this package's source, and MUST NOT be stored in the audit database itself (storing the verification key next to the data it protects would let a DB-write attacker forge new entries, defeating the entire point of the keyed chain).
createAuditChainthrows if given an empty/undefined secret; it never falls back to an unkeyed or default mode.- Losing the key does not lose data. If the secret is lost or rotated
without a deliberate re-chaining migration, existing chain rows remain
intact and readable, but
verifyAuditChaincan no longer confirm their integrity under the new/absent key. Treat secret loss as an availability incident for verification, not a data-loss incident. - Rotating the secret starts a new chain under the new key; consumers that need continuous verifiability across a rotation must design that migration themselves (out of scope for this package).
Canonical field order contract
ChainKernel#computeEntryHash computes the HMAC-SHA256 digest, keyed with
the kernel's secret, of a canonical JSON serialization of the tuple:
[prevHash ?? "", id, action, actorId, tenantId, resourceType, resourceId,
metadata ?? {}, ipAddress ?? null, userAgent ?? null]- The row
timestampis deliberately excluded from the hash. Ordering integrity comes fromprevHashlinkage, not from a hashed timestamp value; timestamp-value integrity is a documented non-goal. metadatais serialized withstableStringify, which sorts object keys recursively so key order (e.g. after a JSONB round-trip) never changes the hash.
This field order and serialization is a versioned contract. Changing it
breaks every chain computed under the old contract; adding a field must be a
deliberate, versioned change (e.g. a new computeEntryHashV2), not a silent
edit to computeEntryHash.
Non-goals
- This package does not define an
audit_logtable schema, the set of audited actions, or additive column migrations. Each consuming app owns its own schema and migration (e.g. happy-knowledge'sscripts/040_audit_hash_chain.sql). - This package does not absorb SDK-owned or purpose-specific chains
(e.g. happyhive's
governance_provenancetable) and does not assert a global cross-tenant chain; chains are per-tenant. - This package does not manage the HMAC secret's lifecycle (generation, storage, rotation) -- that is entirely the consuming application's responsibility. See "Key management" above.
- This package does not implement external anchoring (e.g. periodically publishing chain-head hashes to an external, independently-controlled ledger or timestamping service) to defend against an attacker who compromises both the database AND the app secret simultaneously. That would further strengthen the tamper-proofing guarantee beyond "attacker lacks the secret" to "attacker lacks the secret and cannot also alter an external record," but it is optional future hardening, not implemented here.
- TypeScript-only, using Node's
crypto.createHmac. No Rust implementation exists or is planned; there is no polyglot twin to keep in sync.
