Cryptographic chain of custody for digital evidence — entirely client-side.
Status: reference implementation. Published to be read, forked and built on. Not a maintained product, no support commitment. If you intend to rely on it, fork it and take ownership — that is the intended use.
Beweiskette (German: "chain of evidence") is a zero-dependency web application that builds a tamper-evident record of digital files. Every file you register is hashed, described, and linked to the previous record in a hash chain. Alter or reorder any past record and verification fails, at the exact entry.
Your files never leave the browser. All SHA-256 hashing happens client-side using the Web Crypto API. There is no backend, no analytics and no telemetry. The app makes no network request on its own; the single exception is an optional trusted-timestamp query you trigger deliberately, which sends a 32-byte digest of your chain head and nothing else.
MIT licensed. Fork it, extend it, build on it.
This section is the important one. Most of the value in a chain-of-custody tool comes from knowing exactly where its guarantees stop.
It proves, for a chain you already hold: that no record was altered, that none was reordered, that none was removed from the middle, and that the records all belong to the same chain. Verification recomputes every hash rather than comparing stored values.
It does not prove when, on its own. timestamp_registered comes from the
registering machine's clock, which the person making the records controls, so
a chain dated last year is indistinguishable from one built this morning on a
back-dated laptop. Fetching the time from an API would not fix this — an
unsigned assertion of time is unverifiable whatever its origin, because the
verifier cannot tell "the app fetched this" from "the producer typed it in".
What does fix it is a value signed by the time source: request an RFC 3161
timestamp and the authority signs your head hash together with its clock
reading, which anyone can then check.
It does not prove completeness. Delete the last three entries from an exported chain and the rest still verifies as INTACT — nothing inside the file commits to how long it was supposed to be.
It does not prove authenticity. There are no signatures, and the construction is published. Anyone can generate a complete, internally valid chain with any contents. "CHAIN INTACT" means this file is internally consistent, not this file is genuine.
It does not prove the records are true. Custodian, case reference and notes are free text, asserted by whoever typed them.
The first three gaps are closed by anchoring: publish a short head receipt somewhere you do not control the timeline of, and the chain becomes bound to a moment you cannot silently revise. See docs/ANCHORING.md. The full analysis is in docs/THREAT_MODEL.md; read it before relying on this for anything that matters.
- Forensic investigators documenting evidence handling
- Journalists establishing when they received source material
- Lawyers maintaining file integrity for case documentation
- Security researchers timestamping vulnerability discoveries
In each case the tool provides a defensible internal record. Pair it with a published anchor if the timing needs to hold up against someone who disputes it.
-
Register — Drop a file in. The browser computes its SHA-256, extracts EXIF metadata (JPEG only), and records the custody information you provide.
-
Chain — Each entry commits, via SHA-256 over a canonical encoding, to its own contents, its position (
seq), its chain identity (chain_id), and the previous entry's hash. The first entry links toGENESIS. -
Anchor — Copy the head receipt and publish it. This is what converts "internally consistent" into "existed by a specific moment".
-
Verify — Import a chain and every hash is recomputed and every link checked. Paste a published receipt to additionally detect entries removed from the end.
-
Export — JSON (the record), CSV (a lossy summary, not verifiable — it omits metadata), or a self-contained HTML report that carries its own verification code and runs offline.
Entries are hashed over a canonical JSON encoding — keys sorted by Unicode code point, no insignificant whitespace:
{
"schema_version": 3,
"chain_id": "3f2a1b8c-4d5e-4f60-8a1b-2c3d4e5f6071",
"seq": 0,
"id": "…",
"timestamp_registered": "2026-07-24T09:31:04.117Z",
"evidence": { "file_hash": "…", "file_name": "…", "file_size": 204800, … },
"metadata": { "camera_make": "Canon", "gps_lat": "52.520008", … },
"custody": { "custodian": "…", "case_reference": "…", "notes": "…" },
"time_bound": { "beacon": { "source": "…", "pulse_index": 1234567,
"chain_index": 1, "output_value": "…",
"pulse_time": "2026-07-24T09:30:00.000Z" } },
"prev_hash": "GENESIS"
}entry_hash is the digest of exactly those fields and is not part of its own
preimage. The list is a whitelist, not "everything except entry_hash" — a
blacklist would let any unexpected key an entry happens to carry silently join
the digest, making what a hash commits to depend on the object's shape rather
than on the format. The encoder throws rather than guess at any value it
cannot represent unambiguously; a canonicalizer that silently coerces is a
collision generator.
time_bound is v3's addition: a randomness-beacon pulse establishing no
earlier than. It is inside the digest deliberately — a bound attached after
the fact proves nothing, because it could be chosen once the desired answer was
known. It is null when no pulse was recorded.
v2 chains still verify.
HASHED_FIELDS_BY_VERSIONinsrc/chain.jsmaps each version to its own field list, and every entry is hashed under the rules it was created with. That is what stamping a version in-band was for; a second breaking migration would have been the easy option.v1 chains are not readable.
schema_version,seqandchain_idare covered by the digest, so v1 entries cannot be upgraded — recomputing their hashes under later rules would be indistinguishable from forging them. Existing v1 entries remain in the store and can be exported.
| Property | Status |
|---|---|
| Client-side only | Files never leave the browser. No network request happens on its own; the only one that exists is the optional timestamp query you trigger, carrying a 32-byte digest. |
| Content integrity | Any change to any entry breaks verification at that entry. Hashes are recomputed, not compared. |
| Ordering integrity | seq is covered by the digest; reordering and mid-chain deletion are detected directly. |
| Chain identity | chain_id prevents splicing entries from two chains into one file. |
| Injective encoding | The canonical encoder rejects values it cannot represent unambiguously, rather than collapsing them onto each other. |
| Output escaping | All values from imported chains are HTML-escaped by default via an auto-escaping template. |
| CSV formula injection | Fields beginning =, +, -, @ are neutralized; all fields are quoted. |
| Single verifier | The exported report inlines the app's canonicalizer rather than a copy, so the two cannot disagree. |
| Strict CSP | default-src 'none', no inline scripts, no inline styles, no unsafe-eval. See public/_headers. |
| Tail truncation | Not detected without a published anchor receipt. |
| Whole-chain forgery | Not detected. No signatures. Use an anchor. |
| Trusted timestamp | Optional RFC 3161 anchoring. Tokens are verified in-browser — message imprint binding, signed messageDigest, timeStamping EKU, validity window, and the CMS signature — against pinned signer keys. Tokens minted with openssl ts can be pasted in, which is the path that works against authorities that send no CORS headers. |
| Beacon lower bound | A NIST randomness-beacon pulse recorded in time_bound proves an entry was made no earlier than that pulse. Combined with an anchor's no later than, an entry is bracketed rather than merely bounded. |
| Self-asserted time | timestamp_registered proves nothing on its own. The clock belongs to whoever makes the records. |
On constant-time comparison: constantTimeEqual is used consistently, but it
should not be read as a security property here. There is no secret — both
operands are already public — and JavaScript cannot guarantee constant-time
execution anyway. It is hygiene.
src/
├── main.js App shell, tab routing, storage-durability check
├── crypto.js Hashing entry points; picks buffered vs streaming
├── sha256.js Incremental SHA-256, for files too large to buffer
├── canonical.js Canonical JSON encoder — the exact bytes that get hashed
├── chain.js Chain construction, validation, verification
├── anchor.js Head receipts, anchor records, coverage
├── asn1.js Strict DER parser/encoder (DER only, rejects BER)
├── rfc3161.js Timestamp requests and CMS signature verification
├── beacon.js Randomness-beacon lower bound
├── settings.js Timestamp authority URL and pinned keys
├── store.js IndexedDB persistence (raw API)
├── exif.js Minimal JPEG EXIF parser
├── report.js CSV and self-contained HTML report generation
├── utils.js Formatting, HTML escaping
└── ui/ register · chain-view · verify · export
canonical.js is deliberately self-contained: report.js embeds its source
verbatim so the offline report and the app cannot drift apart.
Files up to 64 MB are hashed with the Web Crypto API. Above that they are
streamed through sha256.js, because crypto.subtle.digest() needs the whole
input in memory at once and evidence files run large. Both paths are verified
to produce identical digests, against the FIPS 180-4 vectors and directly
against Web Crypto.
Zero runtime dependencies. Vanilla JavaScript, vanilla CSS. Vite is a dev server and build tool only; the tests use Node's built-in runner. Production output is plain static files.
Requires Node 20 or newer. The test suite needs globalThis.File and a
globalThis.crypto that survives into the test runner's child processes;
neither holds on Node 18. CI runs the suite on 20, 22 and 24, so the declared
floor stays tested rather than assumed.
git clone https://github.com/Be11aMer/beweiskette.git
cd beweiskette
npm install
npm run dev # http://localhost:5173
npm test # node:test — no test framework dependency
npm run build # static output in dist/Run npm test before deploying. The suite covers the injection vectors, the
canonicalization collisions, and app/report verifier parity — the things that
break silently.
npm run deploy # Cloudflare Workers static assets, via wrangler.jsoncDeployed as a Worker that serves static assets, with no Worker script — there is no server-side code, which is the point.
public/_headers carries the CSP and related headers; Workers static assets
consume that file rather than serving it, verified with wrangler dev. If you
deploy somewhere else, port it — and confirm the headers are actually being
sent, rather than assuming.
Beweiskette is the evidence-chain counterpart to Zeitkette, a CLI tool for cryptographically verifiable work hour logging.
| Zeitkette | Beweiskette | |
|---|---|---|
| Domain | Work hours | Digital evidence |
| Interface | Python CLI | Web app |
| Storage | JSONL file + git | IndexedDB + JSON export |
| Anchoring | Git commits | Published head receipts |
The two share a canonical encoding. test/vectors/canonical.json is the shared
fixture: 13 vectors covering the cases where implementations actually diverge
(non-ASCII values, non-ASCII keys, astral-plane key ordering, escaping). Any
implementation claiming compatibility must reproduce every canonical string
and sha256 exactly.
The Python side must use:
json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False)ensure_ascii=False is required and is not the Python default. With the
default, Python escapes non-ASCII as \uXXXX while JavaScript emits raw UTF-8,
and a custodian named "Müller" hashes differently in the two tools.
Beweiskette creates evidence of integrity, not legal certainty. A hash-chained log demonstrates that records were created in sequence and have not been modified since. It does not establish when they were created, that they are complete, or that their contents are true.
- This tool does not constitute legal advice.
- Evidentiary value depends on jurisdiction, context, and the specifics of any proceeding.
- Consult a qualified legal professional for specific situations.
Issues and pull requests are welcome, though see the status note at the top — responses may be slow. Security reports: SECURITY.md. If you are attacking it deliberately, docs/PENTEST.md says what is worth your time and which limitations are already known.
MIT — see LICENSE.