Signing threat intelligence: Merkle trees, Ed25519 and one float
How I turn attack logs into verifiable dossiers, and the bugs that taught me what verifiable means
I publish threat-intelligence reports built from the attacks on my own network: SSH honeypot sessions, web scanning, the infrastructure behind them. By October 2026 there were more than 800 of them, and every one carries a cryptographic seal that anyone can check in their browser.
The first time the browser verifier ran against real reports, it called three of them TAMPERED. Nobody had touched them. The cause was the number 90.0. Around the same time I found the opposite problem: a verifier that would have shown a green “authentic” badge for a report whose content had been swapped. This post is the method, from attack logs to signed dossier, and the bugs that taught me what “verifiable” actually requires.
Contents · 11 sections
TL;DR #
- Each dossier is built from structured parts: actors, evidence, findings, connections, infrastructure. Each part becomes a leaf in a Merkle tree; the root is signed with Ed25519 and timestamped with OpenTimestamps in Bitcoin.
- A public manifest describes the report; sensitive indicators can stay sealed, provable without being published.
- Canonical JSON must be identical in every language that hashes it. Python writes
90.0, JavaScript writes90. Normalise numbers before signing. - A verifier must recompute what it displays from the signed data. Comparing two fields that come from the same bundle proves nothing.
- Corrections go into the investigation first, then the report is resealed, so the signature covers the correction.
- The hardest part is not cryptography but honesty: query the data before naming a cause, and check the tense of every claim.
Why sign a threat report at all? #
Threat intelligence gets copied, scraped and summarised, increasingly by AI systems. Three things matter to me:
- Integrity: a reader can confirm that the report they see is the one I published.
- Time: I can prove the content existed at a point in time, which matters for “who saw this campaign first”.
- Attribution: a copy is visibly derived from the signed original.
A signature on the HTML page would cover integrity, badly. HTML changes for harmless reasons: a new footer, a theme update. So the seal covers the data behind the report, and the page is just one rendering of it.
From logs to an investigation #
The raw material comes from two pipelines in my lab: the SSH honeypot (sessions, credentials tried, commands, downloads, client fingerprints) and the web-threats service (every request to my public sites, classified by intent and verified crawler identity). An enrichment layer adds ASN, registration and passive DNS data.
A report starts as an investigation with a fixed structure:
- a hypothesis and a summary (the publisher refuses to publish without both);
- evidence items, each with a source;
- findings, each tied to evidence;
- actors and the connections between them;
- infrastructure: addresses, networks, domains.
That structure is what makes a dossier checkable. A finding without evidence is visible as such, and a later correction is a new finding that explicitly supersedes an old one.
Before anything is signed, a reconciliation pass compares the investigation with the wider data. On the first run it found and fixed 10 problems, including 9 addresses of my own infrastructure or non-routable ranges that had slipped into “attacker infrastructure”, and one of my addresses in a report’s prose. The publisher now rejects internal addresses in infrastructure lists outright. Signing a mistake only makes it permanent.
How the seal works #
A deterministic manifest #
The builder turns the investigation into a manifest: a JSON document with a fixed field order and a version number. Each actor, evidence item, finding, connection and infrastructure record becomes a leaf: the hash of its canonical JSON.
One example from August: a dossier with 50 leaves, made of 4 actors, 10 evidence items, 8 findings, 6 connections and 22 infrastructure records.
A Merkle tree #
Leaves are hashed in pairs, then the pairs, up to a single Merkle root. Change any leaf and the root changes. The useful property is selective disclosure: I can prove that one specific leaf, say a sealed list of indicators, is part of the signed report by publishing only the hashes along its path, without revealing the other leaves.
Signature, timestamp, log #
The root and the public manifest summary are signed with an Ed25519 key held by a small attestor service. The signature is then anchored with OpenTimestamps, which commits a hash into a Bitcoin transaction and later yields a proof that the hash existed by that block’s time. Every signing event is also appended to a hash-chained, signed transparency log, so a quiet re-signing of an old report would be visible.
When the system went live in July 2026 it backfilled the existing archive: 636 dossiers in the log, then 700 of 700 sealed two days later, with 685 already anchored in Bitcoin and 15 waiting for confirmation.
What the reader gets #
Each published report has three surfaces:
- the article, with the seal and schema.org JSON-LD metadata embedded;
- an evidence JSON bundle: the public manifest, the leaves, the signature and the timestamp proof;
- a self-contained verify page that uses the browser’s WebCrypto API to check everything locally, with no server call.
Bug 1: TAMPERED, because of a float #
The verify page runs several checks. Check 5 asks: does the intelligence displayed on the page match the signed commitment? On three reports it said TAMPERED.
The signer is Python. A threat score was stored as 90.0, a float, and Python’s JSON encoder writes 90.0. The browser parses that as the number 90, and JSON.stringify writes it back as 90. Same value, different bytes, different hash.
def norm_num(x):
"""Make numbers serialise the same way in Python and JavaScript."""
if isinstance(x, float) and x.is_integer():
return int(x)
if isinstance(x, dict):
return {k: norm_num(v) for k, v in x.items()}
if isinstance(x, list):
return [norm_num(v) for v in x]
return x
canonical = json.dumps(norm_num(manifest), sort_keys=True,
separators=(",", ":"), ensure_ascii=False)
The repository already had this function. The deployed image did not: the attestor was running an older build. Rebuilding, re-signing and resealing cleared it. Two rules came out of it: canonicalisation must be tested across every language that hashes the data, and the image that signs must be the image you reviewed.
If you need a standard rather than a home-made rule, look at the JSON Canonicalization Scheme (RFC 8785), which defines number serialisation precisely for this reason.
Bug 2: a green badge for swapped content #
The more serious bug was the reverse. Reviewing the verifier, I found that it tied the displayed intelligence to the signature only through a field called public_summary_sha256, and it never recomputed that hash. An attacker who replaced the public manifest in a bundle, but kept the original signature and summary hash, would still get a green “attested and authentic” result. The key-fingerprint check was circular too: it compared two fields from the same bundle.
The fix:
- the verifier now recomputes the manifest hash from the manifest it displays and checks it against the signed value;
- the expected signing-key fingerprint is pinned in the verifier, not read from the bundle.
One limitation remains by nature, and it applies to anyone who does this. A verifier served from the same website as the report cannot protect you if that website itself is compromised: whoever can change the report can change the verifier and its pinned key. The real defence is to check the key fingerprint out of band, or to verify with an independent tool. A browser verifier is a convenience, not a root of trust.
Corrections without breaking the chain #
Reports get things wrong. Two of my mistakes are worth describing honestly, because they are the most common failures in threat intelligence:
- One report said, in the present tense, that my site was “serving 403s” to an AI crawler and blamed “emergent complexity” in my defences. In fact it was a deliberately named proxy rule, and the block had ended a month earlier. The request records carried the name of the routing rule that answered each request; I had not queried it. Lesson: query the field that names the responsible component, and bound every condition in time with the first and last timestamp before you describe it as current.
- Another report asserted at high confidence that a set of records could not have crossed a network, and dismissed the explanation that turned out to be correct. My own engineering notes had documented the mechanism, down to the exact request count, more than a week earlier. Lesson: search your own records before theorising.
The correction workflow keeps the seal meaningful:
- add the corrective evidence and a superseding finding to the investigation;
- republish, which rebuilds the manifest and reseals it as a new version;
- the transparency log shows both versions.
Done in that order, the new signature covers the correction, not the claim it replaces. One report has taken two corrections this way and still verifies.
Never edit a signed file #
In October I found that every evidence bundle contained a link to an internal dashboard, 1,634 times across the archive. The link sat inside the signed manifest, so editing the files would have broken every signature.
The rule: change the builder, never the output. The field was removed in the builder, the manifest version was bumped to 2, and the signer was taught to re-sign anything with an older manifest version. One job re-signed and resealed 810 dossiers, and three that the job skipped were re-signed by hand.
Checklist #
- Structure reports as evidence, findings, actors, connections, infrastructure, with hypothesis and summary required.
- Reconcile before signing; reject internal addresses automatically.
- Hash canonical JSON; normalise numbers; test Python and JavaScript produce identical bytes.
- Sign the Merkle root; timestamp it; append to a hash-chained log.
- The verifier recomputes every hash it relies on and pins the key fingerprint.
- Publish the key fingerprint out of band for independent checks.
- After each publish, check: all three surfaces exist, leaf counts add up, signature and timestamp are present, no whole-number floats.
- Correct by superseding inside the investigation, then reseal.
- Never hand-edit signed output; bump the manifest version and reseal.
- Before naming a cause or a current state, query the data and the time bounds.
Frequently asked questions #
Why use a Merkle tree instead of signing the whole report? #
A Merkle tree lets you prove that a single item belongs to the signed report without revealing the rest. That allows sensitive indicators to stay sealed while their existence and integrity are still provable.
What does OpenTimestamps add to a signature? #
A signature proves who signed something; it does not prove when. OpenTimestamps commits a hash of the data into the Bitcoin blockchain, which gives an independent proof that the data existed no later than a given block.
Why did my signature verify in Python but fail in the browser? #
Most likely the two sides serialised the JSON differently. Whole-number floats are a common cause: Python writes 90.0, JavaScript writes 90. Normalise numbers before signing, or use a canonicalisation standard such as RFC 8785.
Can a browser-based verifier be trusted? #
It can detect changes to the data it checks, if it recomputes the hashes itself and pins the expected key. It cannot protect against a compromised website that also serves a modified verifier. For that, compare the key fingerprint through an independent channel.
The lesson #
Cryptography was the easy part. Every serious bug was about meaning: a number written two ways, a check that compared a value with itself, a sentence in the present tense about something that had ended. A seal proves that a report has not changed since I signed it. It cannot prove the report was right. That part is still discipline: structure, evidence, queries before conclusions, and corrections made in the open.