@ujexdev/audit-chain
v0.2.0
Published
Hash-chained append-only audit log primitive. sha256(prevHash || payloadHash || ts || seq), canonical-JSON deterministic across Node, browsers, and Python.
Maintainers
Readme
@ujexdev/audit-chain
Tamper-evident hash-chained append-only log. SHA-256 chain with a canonical-JSON
payload hash. Same wire format as the Python companion ujex-audit-chain — a
chain serialized in one language verifies in the other.
Why
Extracted from the Ujex audit log so other products can share a single primitive without copy-pasting hash logic. This package is the library — it does not impose any storage schema on its callers.
Install
npm install @ujexdev/audit-chainESM only. Target runtime: Node 18+, modern browsers, Deno.
Usage
import { Chain, sha256Hex, type Entry, type VerifyResult } from "@ujexdev/audit-chain";
const chain = new Chain();
const entry: Entry = await chain.append({
actor: "agent-hello",
action: "postbox.send",
meta: { to: "alice" },
});
// entry = { seq, timestamp, payloadHash, prevHash, entryHash, payload }
const result: VerifyResult = await chain.verify();
// { ok: true } or { ok: false, brokenAt: 42, reason: "payloadHash mismatch" }
const snapshot = chain.serialize(); // Entry[]
const restored = Chain.fromEntries(snapshot);append() accepts either { payload, timestamp? } or a flat object whose
fields become the payload (minus the reserved timestamp key).
For command-line bundle verification, use ujex audit verify from
@ujexdev/cli, or python -m ujex_audit_chain verify from ujex-audit-chain.
On-wire format (stable, cross-language)
payloadHash = sha256_hex(canonical_json(payload))
entryHash = sha256_hex(prevHash || payloadHash || timestamp_ms || seq)prevHashis the entryHash of the previous entry, or the empty string for the genesis entry.- Numbers in the
entryHashpreimage are stringified viaString(n)with no separators between fields:"" + payloadHash + "1700000000000" + "1". - Hex digests are lowercase, 64 characters.
Canonical JSON
Matches Python json.dumps(value, sort_keys=True, separators=(',', ':'),
ensure_ascii=False):
- Object keys sorted lexicographically by UTF-16 code unit order.
- No whitespace. Separators are
,and:. - Strings use RFC 8259 short escapes (
\",\\,\b,\f,\n,\r,\t) and\u00XXfor remaining control chars. Non-ASCII characters are emitted literally. undefinedfields are dropped.- For bit-exact cross-language round-trips, stick to JSON-safe types: strings, ints, booleans, null, arrays, and objects. Non-integer floats are supported but subject to the usual IEEE-754 / stringification quirks.
Crypto path
- Node:
node:crypto(createHash('sha256')). - Browser / Deno:
crypto.subtlevia dynamic import.
Runtime is auto-detected. Both paths are covered by tests and produce identical digests.
Memory
verify() does not allocate intermediate arrays or clone entries. It walks
the stored array in-order. Each iteration allocates two strings (the
canonical-JSON payload and the entryHash preimage) plus whatever the
sha256 implementation needs internally. No per-chain, per-verify caches.
Tampering
Any byte change to payload, timestamp, seq, prevHash, or entryHash causes
verify() to return { ok: false, brokenAt: <seq>, reason }. Deleting an
entry is caught by the prevHash mismatch on its successor.
License
Apache-2.0.
