The value of a signed bundle is that you do not have to ask us whether it is genuine. That only holds if the check is small enough to read. This post walks through it step by step, so you can see that it is.
You need three things: the bundle, the public key set, and a runtime with SHA-256 and Ed25519. Node 20 or later has both in WebCrypto. Nothing else is installed.
Step 0: get the bundle and the keys#
A bundle is a JSON file. You get it from ledger-bundles.snapshot, from the dashboard download, or from whoever handed it to you. It carries its own records, so it is self-contained.
The public keys are published openly:
curl -sS https://intramedial.com/.well-known/ledger-bundles-keys.json -o ledger-bundles-keys.jsonThe file is { "keys": [ ... ] }, one entry per signing jurisdiction. Fetch it once, from a network you trust, and keep it. After that, verification needs no network at all. Signing keys explains how to check each entry against itself before you pin it.
Step 1: check the shape#
A bundle must carry eleven signed members: bundleId, workspaceId, jurisdiction, scope, rangeStart, rangeEnd, recordCount, merkleRoot, firstRecordId, lastRecordId and snapshotAt. It also carries a records array of { id, sha256 }, a signingKeyId and an ed25519Signature.
const CORE = ["bundleId", "workspaceId", "jurisdiction", "scope", "rangeStart", "rangeEnd",
"recordCount", "merkleRoot", "firstRecordId", "lastRecordId", "snapshotAt"];
if (!Array.isArray(bundle.records) || CORE.some((k) => !(k in bundle))) return "malformed";firstRecordId and lastRecordId are null for an empty bundle. They must stay null, not be left out, because they are part of what was signed.
Step 2: rebuild the Merkle root#
The records travel inside the bundle. Rebuild the tree over their hashes, in order, and compare the result with the signed merkleRoot.
Two details matter, and both are about not being fooled:
- Leaves and nodes are hashed differently. A leaf is the SHA-256 of one
0x00byte followed by the record'ssha256text as UTF-8. A node is the SHA-256 of one0x01byte followed by the raw 32 bytes of each child. The prefixes stop a node from being passed off as a leaf. - An odd node is carried up, not duplicated. Duplicating the last node is a common choice elsewhere, and it lets two different record lists share a root.
const enc = new TextEncoder();
const sha256 = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", b));
const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
async function merkleRoot(leaves) {
if (leaves.length === 0) return hex(await sha256(new Uint8Array()));
let level = await Promise.all(leaves.map((l) => sha256(Uint8Array.from([0, ...enc.encode(l)]))));
while (level.length > 1) {
const next = [];
for (let i = 0; i < level.length; i += 2) {
next.push(i + 1 < level.length ? await sha256(Uint8Array.from([1, ...level[i], ...level[i + 1]])) : level[i]);
}
level = next;
}
return hex(level[0]);
}
if (bundle.records.length !== bundle.recordCount) return "root_mismatch";
if ((await merkleRoot(bundle.records.map((r) => r.sha256))) !== bundle.merkleRoot) return "root_mismatch";If a record was added, removed, reordered or changed, this is where it shows.
Step 3: find the key#
The bundle names its key in signingKeyId. Find the entry with that kid in the key set you pinned.
const key = keySet.keys.find((k) => k.kid === bundle.signingKeyId);
if (!key) return "unknown_key";There are two signing jurisdictions today: a European Union key (WEUR) and a key for every other region (ENAM). Check that the entry's jurisdiction matches the bundle's.
Step 4: rebuild the signed bytes#
The signature covers the eleven core members, and nothing else, in RFC 8785 canonical form. The core holds only strings, one integer and nulls, so canonical form comes down to three rules: sort the keys, write no whitespace, write each value as JSON does.
function jcs(v) {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(jcs).join(",") + "]";
return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + jcs(v[k])).join(",") + "}";
}
const core = Object.fromEntries(CORE.map((k) => [k, bundle[k]]));
const signed = enc.encode(jcs(core));Build the core from the named members. Do not take the whole bundle and strip what you do not recognise; that is how a verifier ends up checking something other than what was signed.
Step 5: verify the signature#
const pub = await crypto.subtle.importKey("jwk", { kty: key.kty, crv: key.crv, x: key.x },
{ name: "Ed25519" }, false, ["verify"]);
const sig = Uint8Array.from(Buffer.from(bundle.ed25519Signature, "base64url"));
const ok = await crypto.subtle.verify({ name: "Ed25519" }, pub, sig, signed);
return ok ? "valid" : "signature_invalid";That Boolean is the whole verdict. There is no step 6.
The complete program, with the file handling and exit codes, is on Verify in your own code, together with a Python version and notes for porting to other languages.
Test that your verifier can say no#
A verifier that always says valid passes every honest bundle. Use the worked test vector and check that yours fails when it should:
| Change to the vector | Expected answer |
|---|---|
| None | valid |
One character of a record's sha256 | root_mismatch |
workspaceId changed | signature_invalid |
signingKeyId changed | unknown_key |
Also change one character of the signature, and one of the key. Both must fail.
The same check with the offline verifier#
If you would rather not write it, Intramedial has an offline verifier, intramedial-verify. It is MIT licensed and its source is in the verifier/ folder of the Intramedial repository. It is not published on a package registry yet. It runs the same verification code as the public route.
intramedial-verify bundle.json --keys ledger-bundles-keys.jsonThe exit code is 0 verified, 1 not verified and 2 for a usage error, so it drops into a script or a CI job. It also checks an exported audit chain, which needs no keys because the chain proves itself:
intramedial-verify audit-chain.jsonA break is reported at a specific chainSeq, with the reason.
If the bundle carries an OpenTimestamps proof, add --btc-headers headers.json with block headers from a Bitcoin source you trust. The verifier then confirms the anchor against those headers, without asking a third-party API. That check is separate from the signature and never changes its verdict.
And when you cannot run code#
There is a public route, POST /api/v1/public/ledger-bundles/verify. It needs no credential and keeps nothing. But it runs on our side, so its answer is our verdict, not an independent one. Use it for convenience, and the steps above when the answer matters.
What you have proved#
A valid answer means Intramedial sealed this exact range of this workspace's audit chain, at the time shown, with the key named, and nothing has changed since. It does not mean the records are true, that nothing else happened, or that the key is uncompromised. What a ledger bundle proves, and what it does not goes through each of those limits.
