npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sealed-audit-chain

v0.1.0

Published

Tamper-evident, HMAC-sealed audit chain with byte-identical TypeScript and Python implementations, checked by shared test vectors and a cross-language parity suite.

Readme

audit-chain

A tamper-evident audit log, implemented in TypeScript and in Python. Each entry is sealed with SHA-256 and an HMAC and linked to the entry before it, so changing, reordering, inserting or forging an entry, or deleting one before the last, makes verification fail at that entry; cutting entries off the end is caught against a head you store elsewhere. The two implementations are byte-identical: the same record seals to the same entry in both, and either one verifies a chain the other wrote, which shared test vectors and a cross-language parity suite check.

Status: 0.1.0. Not yet published to npm or PyPI: install it from GitHub (below). No runtime dependencies in either language. It was extracted from a private project in which a Node service and a Python service each keep a chain, and each must be able to check the other's.

Why the hard part is the bytes

A hash is computed over bytes, and JSON gives two languages many ways to turn the same value into different bytes: key order, -0, 1.0 versus 1, integers above 2^53, lone surrogates, __proto__, integer-like keys, nesting depth. Here each of those is a spec question with a committed answer in vectors/, which both implementations must reproduce byte for byte.

Install

npm install github:Spundu/audit-chain                    # Node >= 22.12; import from 'sealed-audit-chain'
pip install "git+https://github.com/Spundu/audit-chain"  # Python >= 3.12; import audit_chain

Quick start

TypeScript or JavaScript (examples/quickstart.mjs):

import { randomBytes } from 'node:crypto';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { InMemoryKeyProvider, JsonlChainWriter, readChain, verifyJsonl } from 'sealed-audit-chain';

// A random 32-byte HMAC key. In production, load it from your secret manager.
const keys = new InMemoryKeyProvider({ k1: randomBytes(32) }, 'k1');
const file = join(mkdtempSync(join(tmpdir(), 'audit-')), 'audit.jsonl');

// Append records of any JSON shape. Keep the head somewhere the log's writers cannot rewrite.
const writer = new JsonlChainWriter(file, keys);
writer.append({ event: 'user.login', user: 'alice' });
const last = writer.append({ event: 'payment.refund', user: 'alice', amount_pence: 1900 });
const head = { count: last.seq + 1, lastHash: last.entry_hash };

// Later, an auditor: every entry verifies, and the file ends exactly at the stored head.
console.log(verifyJsonl(readFileSync(file), keys, head));
console.log(readChain(file, keys, head).map((entry) => entry.event));

// Change one name in the history: verification now fails at that entry.
writeFileSync(file, readFileSync(file, 'utf8').replace('alice', 'mallory'));
console.log(verifyJsonl(readFileSync(file), keys, head));

It prints:

{ ok: true, lastSeq: 1, brokenAt: null, reason: null }
[ 'user.login', 'payment.refund' ]
{
  ok: false,
  lastSeq: -1,
  brokenAt: 0,
  reason: 'entry_hash mismatch (content tampered)'
}

Python (examples/quickstart.py):

import os
import secrets
import tempfile
from pathlib import Path

from audit_chain import ChainAnchor, append_entry, parse_jsonl, to_jsonl_line, verify_jsonl

key = secrets.token_bytes(32)  # a random HMAC key; in production, load it from your secret manager
keyring = {"k1": key}
path = Path(tempfile.mkdtemp()) / "audit.jsonl"


def append(record: dict) -> dict:
    data = path.read_bytes() if path.exists() else b""
    result = verify_jsonl(data, keyring)  # never seal onto a chain that does not verify
    if not result.ok:
        raise RuntimeError(f"refusing to append: {result.error}")
    entries = parse_jsonl(data).entries
    entry = append_entry(entries[-1] if entries else None, record, key, writer_key_id="k1")
    with path.open("ab") as f:  # bytes: a text-mode writer could change the line endings
        f.write(to_jsonl_line(entry))
        f.flush()
        os.fsync(f.fileno())  # on disk before the head is stored anywhere
    return entry


append({"event": "user.login", "user": "alice"})
last = append({"event": "payment.refund", "user": "alice", "amount_pence": 1900})
head = ChainAnchor(last["seq"] + 1, last["entry_hash"])

# Later, an auditor: every entry verifies, and the file ends exactly at the stored head.
print(verify_jsonl(path.read_bytes(), keyring, head))

# Change one name in the history: verification now fails at that entry.
path.write_bytes(path.read_bytes().replace(b"alice", b"mallory", 1))
print(verify_jsonl(path.read_bytes(), keyring, head))

It prints:

VerifyResult(ok=True, last_seq=1, error=None)
VerifyResult(ok=False, last_seq=-1, error='entry_hash mismatch at index 0')

CI runs both examples and fails if they stop printing exactly this.

How it works

An entry is any JSON object: there is no record schema, so check your records' shape before you append if they have one. The chain relies on three fields, which the writer sets:

| field | meaning | |---|---| | seq | the entry's position in the chain, from 0 | | prev_hash | the previous entry's entry_hash (64 zeros for entry 0) | | writer_key_id | which HMAC key sealed the entry (keys can rotate; old entries stay verifiable) |

Sealing adds two more:

bytes      = UTF-8( canonical(the entry without entry_hash and entry_hmac) )
entry_hash = lowercase_hex( SHA-256(bytes) )
entry_hmac = lowercase_hex( HMAC-SHA-256(key[writer_key_id], bytes) )

canonical is RFC 8785 (JCS), restricted to a strict domain: safe integers only (no floats), well-formed Unicode, at most 100 levels of nesting and 1 MiB. On disk a chain is canonical JSONL: one entry per line, each line exactly canonical(entry), and verification compares the bytes, so another spelling of the same value (extra whitespace, 1.0, a duplicate key) is refused too. Verification walks the entries in order and stops at the first one that fails. It never throws on file content: a malformed chain is a failed result, not an exception. The full rules are in SPEC.md.

What it protects against, and what it doesn't

Against anyone without the HMAC key, verifyJsonl fails at the first changed, reordered, inserted, duplicated or forged entry, and at a deletion from the head or the middle. On its own it does not catch:

  • entries cut off the end, or a file rolled back to an older copy. Store the head (count, lastHash) somewhere the log's writers cannot rewrite, and pass it as the anchor: then those fail too;
  • anyone who holds the key. HMAC is symmetric, so a key holder can rewrite and re-seal everything. For proof of who wrote what, sign the head with an asymmetric key;
  • lies written at the time. The chain proves the log was not changed afterwards, not that it was true.

Key rotation, keeping two chains apart, and the limits of value-level verification are in THREAT-MODEL.md.

One spec, two implementations

  • vectors/ is the spec. It pins canonical forms, seals, and the exact verdict for 723 chains and 907 canonical-JSONL files, which five ordering rules expand into 35,473 verification cases. Both test suites assert every one, the generators refuse to write a vector the TypeScript implementation disagrees with, and CI regenerates the files and fails on any difference.
  • The parity suite gives the same raw JSON texts and files to both languages, each parsing them with its own parser, and requires identical canonical bytes, identical entries from both writers and identical verdicts, over more than 2,000 texts and thousands of attacked chains and files.
  • CI runs it all on Node 22, 24 and 26 and Python 3.12, 3.13 and 3.14, and installs the packed npm tarball and the built Python wheel into clean environments and runs them there. Details in TESTING.md.

API

| | TypeScript (sealed-audit-chain) | Python (audit_chain) | |---|---|---| | append to a file | new JsonlChainWriter(path, keys, checkpoint?), then .append(record) | verify, then append_entry(prev, record, key, writer_key_id=...) and to_jsonl_line(entry) (see the quick start) | | read a verified file | readChain(path, keys, anchor?) | verify_jsonl(data, keyring, anchor), then parse_jsonl(data).entries | | verify a file's bytes | verifyJsonl(bytes, keys, anchor?) → { ok, lastSeq, brokenAt, reason } | verify_jsonl(data, keyring, anchor=None) → VerifyResult(ok, last_seq, error) | | verify parsed entries | verifyChain(entries, keys, anchor?) | verify_chain(entries, keyring, anchor=None) | | verify any other source | verifyEntries(next, keys, anchor?) | verify_entries(steps, keyring, anchor=None) | | seal one entry | appendEntry(prev, record, keys), sealEntry(core, key) | append_entry(...), seal_core(core, key) | | keys | a KeyProvider: getKey(id) and activeKeyId() | a mapping {key_id: bytes} | | anchor | { count, lastHash } | ChainAnchor(count, last_hash) | | canonical JSON | canonicalize(value), and parseJson(text) to parse | canonicalize(value) |

On failure lastSeq / last_seq is the last entry the verifier vouches for (-1 for none). Keys are random bytes, at least 16 of them and preferably 32. A key provider must return null (None) for an unknown key id and never throw: the id comes from the file, so anyone who can write the file chooses it. InMemoryKeyProvider holds keys that are already in memory (test keys, or keys loaded from a secret manager at startup); to fetch keys from a KMS, implement KeyProvider. Python's None is JSON null in a record, as in TypeScript.

Notes

  • The TypeScript side never uses JSON.parse on untrusted text. V8 12.9 and later (Node 23 to 26 at least; reproduced here on Node 24.14) sometimes returns a wrong key for an escaped one-character key, depending on what the process parsed before: after {"a":1,"\\":2}, the text {"a":1,"\n":2} can come back with a backslash as its second key. scripts/v8-json-parse-repro.mjs reproduces it; no upstream report of it was found as of October 2026. The library parses with its own strict parser, parseJson, and the parity suite checks it against Python's.
  • One writer per file. JsonlChainWriter takes no lock, so run one writer per chain. Each append is flushed to disk (fsync) before it returns, together with the directory of a file it creates where the platform allows that (Windows does not). A restarted writer verifies the file before it appends anything.

Development

npm ci && npm run typecheck && npm run lint && npm test        # TypeScript
pip install -e ".[test,lint]" && pytest && ruff check python examples   # Python
npm run test:parity    # both languages together, and the README examples (after the pip install)
npm run vectors        # regenerate vectors/ from scripts/

CLAUDE.md has the rules for changing the code, and TESTING.md explains the suites.

How this was built

Written with Claude Code (every commit says so) and directed by the author, who chose what to extract and publish, and required an independent review, and the fixes it called for, before publishing. It has had no independent human review yet. Three rounds of cold review by AI agents attacked it: about 100,000 mutated JSON texts, chains and files produced no accepted forgery, and the two verifiers never disagreed beyond two documented value-level parser differences. Of 85 realistic bugs planted in the code, the tests caught 82; two of the others cannot change any output, and the third is caught now. The reviews did find real problems, including the two writers disagreeing on null. Everything they confirmed is fixed, each with a test that fails on the code before the fix, and the last round found nothing serious. The details are in TESTING.md.

Layout

src/            TypeScript: canonicalize, json (strict parser), jsonl, seal, verify, keys, writer,
                hex and stack (internal helpers), index (the package entry)
test/           vitest: unit and vector tests; test/parity/ is the cross-language suite
python/         Python: audit_chain (canonical, jsonl, seal, verify), the pytest tests and the parity worker
examples/       the two quick starts above, run in CI
vectors/        the normative spec (generated; do not hand-edit)
scripts/        the vector generators (npm run vectors), consumer-smoke.mjs (CI installs the packed
                tarball and runs every vector through it) and v8-json-parse-repro.mjs

License

MIT