Check email signatures. Stamped tells a journalist whether a message is the message that was signed, offline, on their own machine.
German name: Poststempel.
A leaked mailbox arrives and the first question is always the same: is this real? Email carries an answer to part of that question already. Most mail is signed by the sending domain with DKIM, and that signature covers the body and a list of header fields. If the signature still verifies, the message is byte-for-byte what left the domain's servers. If the body hash no longer matches, somebody changed it afterwards.
Verifying it by hand is possible and nobody does it under deadline. The web services that do it for you want the message uploaded, which is not a thing one does with a leak.
So Stamped is local and offline by default. Public keys come from a store you fill
(stamped keys add), because a DNS lookup would tell the signing domain which of its mail is
being examined. stamped keys fetch can ask archive.prove.email or DNS for missing keys, but only
after it has listed the exact domains and selectors and you have confirmed (--yes skips the
question). Nothing else touches the network.
"Valid or invalid" is the wrong vocabulary, and collapsing into it is how a true story gets spiked. Stamped says one of:
| Verdict | Means |
|---|---|
| confirmed | A signature verified. The message is what was signed. |
| confirmed with caveats | It verified, but covers less than it appears to. See below. |
| body altered | The body hash does not match: the body was changed after signing. |
| headers altered | The body is intact but a signed header field is not what was signed. |
| unverifiable | A signature is present and nothing here can check it (usually: no key). |
| unsigned | No DKIM signature. Says nothing about the message either way. |
unverifiable is not invalid. It is the absence of a finding, and reporting it as forgery
would be a false accusation. That is why it is its own word.
A confirmation gets caveats when it is real but narrower than the word suggests: rsa-sha1
(SHA-1 collisions are practical), an RSA key under 1024 bits, an l= tag that signs only the
start of the body, Subject/To/Date missing from the signed headers, or a public key that
came from the archive under examination rather than from the DNS or from you. That proves
internal consistency, not authenticity.
Every signature is evaluated on its own. A message whose author domain's signature broke while a mailing list's still verifies is a finding, not an average.
Alongside the verdict, Stamped prints the evidence it came from: domain, selector, algorithm, key size and provenance, which headers are signed, and both body hashes when they differ. A claim a journalist cannot defend to an editor is not worth making.
stamped import <case> <source…> # an mbox, a Maildir, a .eml, a folder of those
stamped import --case <case> <folder> # a folder of mailboxes, a leak as it is handed over
A leaked archive is not a file. It is a folder: one folder per account, each holding its
mailbox in pieces (part-01.mbox, part-02.mbox, …), with a README and a licence next to them.
Stamped reads that tree as it lies:
- A folder is a mailbox, named after the folder it came from. The layout is the only naming
there is. Nobody writes a manifest, and a manifest written by the leaker is a manifest we
would have to trust. Two folders of the same name get a number on the end
(
ada@example.com,ada@example.com (2)), so a name never means two things. - Only mail is mail.
.mboxand.emlfiles are read. Everything else, a README or a licence, is left alone and counted. A file that is not mail never becomes a message. - Each part is read on its own, streamed, so the size of the archive is not a limit and the largest single message is.
- A link out of the tree is not followed. The archive is evidence; evidence that reaches into the operator's own disk reads files that were never part of the leak.
- One damaged file does not cost the tree. A part that cannot be read is reported by name, the rest of the archive is imported, and the run ends with exit code 2. A partial import must not look like a clean one to a script.
--progress=jsonl prints one JSON object per mailbox, each naming the mailbox it belongs to,
also for a mailbox that held no message at all, and the closing line naming the mailbox the run
ended in. A long import can be watched and a stalled one can be located.
Early. The verification core, a CLI, and a libadwaita window that imports an archive and searches it (SQLite FTS5).
stamped verify <file.eml> [--key <selector>._domainkey.<domain>=<record or .pem>] [--json]
Exit code 0 means a report was produced, whatever it says; 2 means Stamped could not produce one. A broken signature is a result, not a program failure.
The toolchain is gjsify: a TypeScript monorepo that runs on
GJS. Use gjsify install, never npm install.
gjsify install
gjsify foreach -A check # type-check app + every package
gjsify workspace stamped-cli test # the suite, on gjs AND node
gjsify workspace stamped-cli build # → app/dist/stamped.gjs.mjs
gjsify run app/dist/stamped.gjs.mjs verify app/tests/fixtures/confirmed.eml \
--key t2048._domainkey.example.com=app/tests/fixtures/keys/t2048._domainkey.example.com.pub.pemStamped is multilingual from the first string: English msgids, po/de.po, dev/i18n.sh extract
and compile.
The AppImage and the macOS .app carry the embedding model — EmbeddingGemma 2 as a Q8_0
GGUF, ~296 MiB — so semantic search works offline from the first start, with nothing to download
and nothing to configure. The model sits next to the app's own data
(share/models/embeddinggemma-2-Q8_0.gguf), and its licence and notice travel with it.
The .deb, the .rpm and the Windows artifacts do not carry it: 296 MiB inside a
distribution package is a distribution decision, not ours.
The model is never committed. app/scripts/fetch-model.mts fetches it at a pinned revision and
verifies its sha256, and the release caches it by that hash:
node --experimental-strip-types app/scripts/fetch-model.mts fetchStamped finds the model at runtime through app/src/lib/model-path.ts: an explicit
STAMPED_EMBED_MODEL wins, then the file the artifact carries, then the development cache. A
missing model is a normal state and not an error — the resolver says so and the caller decides
what to do without it.
Every .eml under app/tests/fixtures/ is generated by tools/make-fixtures.mjs: messages in
reserved example domains, signed by keys made on the spot, one per verdict and caveat. No real
message may ever be committed here. A committed mail can burn a source, and this is a tool for
leaks.
The generator implements RFC 6376 canonicalization independently of the verifier, so the
suite is two implementations agreeing rather than one agreeing with itself. Regenerate with
node tools/make-fixtures.mjs from the repo root; it makes new keys too, so every fixture
changes together.
The archive fixtures come from tools/make-archive-fixtures.mjs, a second generator, because
those files carry no signature and must stay byte-stable across runs. It writes the mbox and
Maildir quirks and archives/leak/, the tree of mailboxes a leaked archive arrives in: one
folder per account, each holding its mailbox in pieces, with the files that are not mail next to
them. Synthetic like everything else: reserved example domains, invented bodies.
- docs/time-checks.md describes the "Zeit" layer beside the six verdicts: what it compares, which values a verified signature protects, and the fixed tolerances.
- docs/spikes/dkim-and-fts5.md holds what was measured before it was decided: why mailauth and not an own verifier, how it is configured and why, FTS5 under gjsify.
- docs/spikes/query-worker.md holds the same for the worker thread that answers every index query: what a Worker can and cannot do under gjsify, and how long the window stopped repainting before it existed.
- AGENTS.md holds the conventions, for humans and AI agents both.
AGPL-3.0-or-later. © CORRECTIV.
The redistributed third-party material — the embedding model and its licence — is listed in THIRD-PARTY-NOTICES.md.