A sealed record is a session log: entries from one or more people, chained in order, where each entry's digest commits to its own content and to the digest before it, and each enrolled entry carries an Ed25519 signature from its actor. The signers' public keys travel in the record, and the record lives outside the audio, naming files by hash. It exists so that anyone holding the record can check, without consulting any institution, which key signed each enrolled entry, in what order the entries were chained, and whether any entry's committed content was changed afterwards.
This package recomputes the hash chain, signatures, and time receipts of a sealed session record and reports whether it holds, and if not, at which entry it breaks.
This repository is the format specification and a reference reader: it does not produce records as a tool and holds no private keys, and the producer functions in the API are a reference to check a second producer against (see What this does not do).
One package, three ways to read a record: the library (import ... from "sealedrecord"), a command line (npx sealedrecord verify), and a web verifier served as static files from this repository (https://juanlentino.github.io/sealedrecord/). All three run the same code.
Specification: docs/FORMAT.md. How this relates to C2PA: COMPARISON.md.
Open https://juanlentino.github.io/sealedrecord/ and press "Load the example record" or "Load it with one character changed". The examples are the conformance vectors this library is tested against.
From a shell, without cloning. The vectors are not in the npm package, so fetch them first:
curl -sO https://raw.githubusercontent.com/juanlentino/sealedrecord/main/vectors/record.json
curl -sO https://raw.githubusercontent.com/juanlentino/sealedrecord/main/vectors/take.wav
npx sealedrecord verify record.json take.wavholds
9 of 9 entries read; signatures verified
9 of 9 receipts verify against the attestation key the record carries (the key holder's word on time, not a timestamp proof)
tracks: Lead Vox intact, Keys intact, Kick intact
take.wav: exact (entry 2)
Change one word in entry 4 and the record breaks there, with exit code 1:
sed 's/chorus wants a double/chorus wants a triple/' record.json > altered.json && npx sealedrecord verify altered.json; echo $?altered at entry 4
entry 4 (note) does not match its recorded digest: recomputed c056c813…, recorded a3442bc7…; its content was altered after signing
3 of 9 entries read; signatures verified
3 of 3 receipts verify against the attestation key the record carries (the key holder's word on time, not a timestamp proof)
tracks: Lead Vox intact, Keys intact, Kick pending
1
The example buttons on https://juanlentino.github.io/sealedrecord/ use the conformance vectors below. The page is this library served as static files: drop a record, get a reading. explain.html beside it says what is checked and what each verdict means, with no scripts at all. Whether the live page matches main and the registry is checked daily: . The page serves
build.json, a static stamp naming the library version it was assembled from and the commit that last touched its inputs; the page itself never reads it. The command line below does the same from a shell. The page imports the published npm tarball of the version it names, is rebuilt only by the release workflow, and makes no network request after it loads.
The commands take your own files, or the conformance vectors, which live in the repository, not the package.
npx sealedrecord verify record.json # reading and receipts
npx sealedrecord verify record.json take.wav # plus each file: exact, same audio, or no match
npx sealedrecord verify --json record.json # the reading object, for scriptsExit code 0 when the record holds with every signature checked, 1 when it is altered or malformed, 2 when the chain recomputes but was never sealed, 3 on usage or I/O errors, 4 when the chain holds but no signature was checked. No flags beyond --json.
Exit 4 exists so a pipeline keyed on exit 0 cannot accept a record whose signers were stripped, or a reading from a runtime without Ed25519. Per-track verdicts (pending, unverified, broken, intact; FORMAT.md 5.4) are printed on their own line and never move the exit code: a broken track is a fact the record commits to, that an unidentified person touched that lane, not a failed check. A pipeline that needs attribution reads reading.verdicts in --json. Such a record reads holds (signatures not checked) on the first line and "signed": false in --json. Receipts verify against the attestation key the record carries, which is the key holder's word on time; anything else under attestations (timestamp proofs, for instance) is listed as unchecked and not verified.
npm install sealedrecordimport { readFileSync } from "node:fs";
import { verifyPackage, verifyReceipts } from "sealedrecord";
const pkg = JSON.parse(readFileSync("vectors/record.json", "utf8"));
const reading = await verifyPackage(pkg);
console.log(reading.kind, reading.signed, reading.entries.length);
// holds true 9
const receipts = await verifyReceipts({ ...pkg, entries: reading.entries });
console.log(receipts);
// { total: 9, receipted: 9, verified: 9, problems: [] }A record that has been changed after sealing breaks at the changed entry. The reading names the entry, what was recomputed, and what the file claimed:
pkg.entries[3].note = "chorus wants a triple";
const broken = await verifyPackage(pkg);
console.log(broken.kind, broken.breakSeq);
// altered 4
console.log(broken.detail);
// entry 4 (note) does not match its recorded digest: recomputed c056c813…, recorded a3442bc7…; its content was altered after signing
console.log(broken.entries.length);
// 3 (the accepted prefix; nothing after the break is read)reading.kind is one of four values. holds: every digest and signature recomputes and the last entry seals the record. unsealed: the chain recomputes but was never sealed. altered: the chain fails at breakSeq. malformed: not a record this reader reads (wrong format tag, no entries, over the size limits).
Matching an audio file to the entry that anchored it:
import { hashFile, pcmHashFile, findAnchors, findPcmAnchors } from "sealedrecord";
const { sha256 } = await hashFile(file); // File or Blob
const exact = findAnchors(sha256, reading.entries); // same bytes
const sameAudio = exact.length ? exact : findPcmAnchors(await pcmHashFile(file), reading.entries);
// findPcmAnchors matches on the WAV sample data alone, so a retagged copy
// (new metadata chunks, same samples) still finds its entry.- No audio analysis, fingerprinting, similarity, or detection of any kind. The sample anchor is a SHA-256 over raw PCM bytes; one sample different, no match.
- No authorship inference. A verified signature proves that the holder of a key signed an entry. Who holds the key is outside the format.
- No key management, custody, backup, or revocation. The record carries public keys only.
- No signing user interface, storage, transport, or network access. The library takes bytes and returns a reading.
- No verification of timestamp proofs (OpenTimestamps or similar). They may travel in the record; this library does not check them.
- No reading or writing of C2PA manifests.
- No sample anchor for compressed audio. Only uncompressed WAV (PCM and IEEE float) has one; other files match by file hash only.
- No link between records.
derivedFromnames an earlier entry in the same record (FORMAT.md 4.5). The format defines no link from one record to another.
The normative specification is docs/FORMAT.md. It is written so that a second implementation can be built from the document alone: the digest preimage and its stringification rules, the check order, both anchors byte for byte, the receipt canonical form, limits, and the exact outcomes.
The design the format implements was published before this library existed:
- Provenance Over Detection. SSRN 6402298. https://papers.ssrn.com/abstract=6402298
- Provenance as Substrate. SSRN 6730343. https://papers.ssrn.com/abstract=6730343
- Provenance Without Institutions. SSRN 7456638. https://papers.ssrn.com/abstract=7456638
- Author ORCID: https://orcid.org/0009-0006-8151-5920
The papers argue for what the format commits to and why; the specification says how. Where they differ, the specification governs this implementation. COMPARISON.md states how the format relates to C2PA: different objects, different trust, and how they compose.
vectors/record.json is a signed record with three signers, nine entries, receipts on every entry, a derivation link, and anchored audio. vectors/take.wav matches entry 2 by file hash; vectors/take-retagged.wav has the same samples and an extra metadata chunk, so it matches entry 2 by sample anchor only. test/vectors.test.js runs against all three, including a tamper case that must fail at entry 4.
A second reader, in Go and written from the document alone, lives in conformance/go/ and runs against the same files in CI; every place it had to guess became a sentence in FORMAT.md. CI also runs a differential fuzz: seeded mutations of the vector through both readers, failing on any disagreement in outcome or any exception (FUZZ_N raises the count; 10,000 pass). Both readers share an author, so together with the fuzz they test whether the document determines behaviour, not whether a stranger reads it the same way. No third-party implementation exists; one can run against the same files. npm run vectors regenerates the set with fresh keys; the generator verifies its own output and refuses to write a record that does not hold.
- Zero runtime dependencies. One dev dependency (vitest).
- Requires WebCrypto with SHA-256 and Ed25519: Node 20 or later, or any browser whose
crypto.subtleimplements Ed25519. Where Ed25519 is missing,hasEd25519()returns false,verifyPackagechecks the hash chain only, the reading carriessigned: false, the CLI exits 4, and the web verifier shows "Intact, unsigned" with the limit stated inside the reading. - Every computation is SHA-256 or Ed25519 over UTF-8 strings or raw bytes, with lowercase hex output, so results do not depend on the runtime. The test suite itself runs under Node.
hashFilereads the whole file into memory and refuses files over 200 MiB (MAX_ARTIFACT_BYTES); the CLI and the page refuse record files over 32 MiB (MAX_RECORD_BYTES) before parsing. Limits of this reader, not of the format.
Reading:
verifyPackage(pkg): recompute the chain and signatures; returns the reading described above.verifyReceipts(pkg): verify time receipts against the key carried inpkg.attestations; returns counts, problems, andunchecked, the names of attestation members this reader does not verify.hashFile(file):{ name, size, sha256 }of a File or Blob.pcmHash(arrayBuffer): sample anchor of a WAV, ornullwhen the buffer is not one this reader understands.pcmHashFile(file): the same from a File or Blob.findAnchors(sha256, entries): entries whose artifact has this file hash.findPcmAnchors(pcmSha256, entries): entries whose artifact has this sample anchor; empty fornull.verdictOf(entries, laneId): per-track verdict (pending,unverified,broken,intact).
Format constants and primitives, for anyone writing their own reader or producer:
PKG_FORMAT: the format tag a record must carry.GENESIS: the 64-zero digest the chain starts from.entryHash(prev, index, entry): the entry digest, exactly as the specification defines it.hhmm(m): the display time derived from a session minute; the reader checks it.receiptCanonical({ sessionId, seq, hash, receivedAt }): the string a receipt signs.MAX_ARTIFACT_BYTES,MAX_RECORD_BYTES: the file-size limits (200 MiB for a checked file, 32 MiB for a record) the reference reader enforces before reading bytes. Reader policy, not format rules.
Producing, included as a reference so a second producer can be checked against it:
buildEvents(rawEvents, signerFor): order, sequence, digest, and sign a list of raw events. PasssignerForasnullfor a hash-only record; asignerForthat returns no key for an enrolled actor throws, so a record never claims an identity it cannot back.sealEntry({ prev, seq, raw, privateKey }): one entry onto an existing chain, identical to whatbuildEventswould produce.buildPackage(lanes, entries, meta, sealedAt, signers, attestations, note): the record object.packageText(...): the same, serialized with two-space indentation.
WebCrypto wrappers used throughout, exported so callers hash and sign the same way the reader verifies:
sha256Hex(string),signText(privateKey, string),verifyText(publicKey, hexSignature, string)generateSigningKey(),exportJwk(key),importPublicJwk(jwk),importPrivateJwk(jwk),hasEd25519()
Version 0.x, with 0.8 as the freeze candidate: the specification has been implemented twice from the document, fuzzed differentially, and reviewed; what remains before 1.0 is outside comment. The format tag is sealedrecord/package.v3; the digest rules have been stable across the tag's history, and the current constants are the ones intended to freeze. 1.0 will mean the format is frozen: any later change to a committed field, the preimage, or the check order gets a new tag, and this reader keeps reading v3. The change policy, including what a fork that alters the rules must do with the tag, is docs/FORMAT.md section 11. Security reports: SECURITY.md.
Apache-2.0. See CHANGELOG.md for what changed and when, and NOTES.md for ideas about the command line that are not promises.
The repository is open. NOTES.md lists ideas nobody has claimed; anyone may build one and open a pull request, or fork and take the library where they need it. CONTRIBUTING.md says what a change needs (tests first, a CHANGELOG line, the package's rules) and which changes need a conversation before code, which is anything that would alter a reader's outcome.
npm ci
npm test # library, CLI, page logic, assembled site, differential fuzz (needs go)
npm run lint # oxlint
cd conformance/go && go test ./... # the second reader against the vectors
node scripts/site-assemble.mjs --local # build the web verifier into dist-site/ from the working tree
npm run vectors # regenerate the conformance vectors with fresh keyssite/sealedrecord is a symlink to the checkout so the page's imports resolve during development; the assembler replaces it with the unpacked npm tarball, which is what Pages serves.
Page-only changes (site/, the assembler, the vectors) redeploy the verifier on push to main without a release; the page still runs the published tarball at package.json's version and the deploy fails if that version is not on the registry.
A release is a version bump, a CHANGELOG entry, and a vX.Y.Z tag. The release workflow then publishes to npm through trusted publishing (no token), rebuilds the web verifier from the published tarball once the registry serves it, and creates the GitHub Release with each deliverable attached as its own file: the tarball, the verifier as an offline zip, the vectors, FORMAT.md, and COMPARISON.md. Notes are the CHANGELOG section for that version. Nothing is published by hand.
To develop against a consuming project without editing its manifest, npm link here and npm link sealedrecord there.