@noidme/ledger
v0.1.0-beta.0
Published
Tamper-evident consent ledger + enforcement evidence. Append each consent action (grant/deny/withdraw/gpc) to a hash-chained record (GDPR Art. 7, 100% unsampled); pass a server-held HMAC sealKey to make the chain forgery-resistant. Plus per-vendor enforce
Maintainers
Readme
@noidme/ledger
Zero-dependency tamper-evident consent ledger + enforcement evidence. Append every
consent action to a hash-chained record (GDPR Art. 7, 100% unsampled); pass a server-held HMAC
sealKey to make the chain forgery-resistant. Aggregate per-vendor enforcement counts with
a confidence-interval helper for honest sampled reporting.
Extracted from noidme.js; usable standalone.
Install
npm i @noidme/ledgerConsent ledger
import { ConsentLedger } from '@noidme/ledger';
const ledger = new ConsentLedger();
await ledger.record({
subjectId: 'anon-8f3a', // pseudonymous — hashed into the immutable chain, never raw PII
property: 'example.com',
region: 'EU',
policyVersion: 3,
purposes: { analytics: true, advertising: false },
action: 'grant', // 'grant' | 'deny' | 'withdraw' | 'gpc'
mechanism: 'banner', // 'banner' | 'api' | 'gpc' | 'import'
});
await ledger.verify(); // true — chain intact (nothing altered or internally reordered)
ledger.query({ subjectId: 'anon-8f3a' });
const dsar = ledger.export(); // JSON string, DSAR-readyIntegrity model — read this, it has three tiers
- Plain SHA-256 chain (no key) detects accidental corruption and internal reordering. It is not forgery-proof: anyone who can edit the records can recompute a consistent chain, because the hash binds no secret.
- HMAC seal (server-side) — pass a
sealKeythe client never sees, and the chain becomes keyed → unforgeable without the key. This is the tamper-evidence you can stand behind. - Head anchor — a hash chain cannot detect truncation of its own tail or total erasure:
a prefix of a valid chain is itself a valid chain, and
[]verifies as intact. To catch a dropped withdrawal you must commit to the head out of band: persistledger.head()and pass it asexpectedtoverifyChain.
// On the backend, holding an HMAC key the client never sees:
const sealKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const ledger = new ConsentLedger({ sealKey });
// ...record()...
const head = ledger.head(); // { count, lastHash } — persist this in YOUR database
// Later, verifying an imported/DSAR chain:
await ConsentLedger.verifyChain(records, {
sealKey, // forgery-resistant
expected: head, // catches truncation + total erasure
maxRecords: 100_000, // refuse (→ false) an oversized untrusted array
});Pseudonymize before recording.
record()hashes every field (subjectId,region,purposes, …) verbatim into the immutable chain — the ledger is append-only, so it cannot be your erasure point. Keep raw identity in a separate mapping you can delete, and record only a pseudonymoussubjectId.
Enforcement evidence
import { EnforcementEvidence, withConfidenceInterval } from '@noidme/ledger';
const ev = new EnforcementEvidence();
ev.record('doubleclick.net', 'block'); // 'allow' | 'block' | 'quarantine' | 'override'
ev.total; // { allow, block, quarantine, override }
ev.export(0.1); // per-vendor counts + CI'd population estimates (10% sample)
withConfidenceInterval(100, 0.1); // { estimate: 1000, ciLow, ciHigh } — 95% Wald CIsampleRate must be in (0, 1] — 1 is a full census (exact). A zero observed count
returns the one-sided 95% Poisson upper bound (not a false-precision [0, 0]). Invalid input
(observed not a non-negative integer, sampleRate outside (0, 1] or NaN) throws — a silent
NaN in a regulator-facing figure is worse than an error.
API
class ConsentLedger—record(input),verify(),head(),query(filter),all(),export(),size; staticConsentLedger.verifyChain(records, opts?)whereopts = { sealKey?, expected?: LedgerHead, maxRecords? }.class EnforcementEvidence—record(vendor, action)(throws on an unknown action),total,export(sampleRate?).withConfidenceInterval(observed, sampleRate)→{ estimate, ciLow, ciHigh }.
record() validates its input and throws on anything that wouldn't hash losslessly (non-finite
policyVersion, non-boolean purpose values, unknown action/mechanism). Committed records are
frozen — you can't mutate one out from under the chain.
Uses WebCrypto (crypto.subtle) — available in modern browsers and Node 18+.
License
MIT
