Description
@sigstore/verify's verifyMerkleInclusion (from dist/tlog/merkle.js) fails on every Rekor entry I've tested when the entry is constructed directly from a fresh GET /api/v1/log/entries/{uuid} response. Reproduces 5/5 against UUIDs from two distinct tree IDs (108e9186e8c5677a... and 24296fb24b8ad77a...).
I suspect this is a documentation/expected-input gap rather than a verifier bug — the public Rekor API response shape doesn't directly match what verifyMerkleInclusion expects in canonicalizedBody. Filing because:
- It's the obvious thing a downstream consumer tries.
- If it's a known limitation, it'd help to surface it in the package README or
verifyMerkleInclusion jsdoc.
- If it isn't expected, it's a real bug in widely-deployed code.
Reproducer (zero-config, drops into a temp dir):
mkdir repro && cd repro
npm init -y && npm install @sigstore/verify
cat > repro.mjs <<'JS'
import { verifyMerkleInclusion } from "@sigstore/verify/dist/tlog/merkle.js";
const idx = await fetch("https://rekor.sigstore.dev/api/v1/index/retrieve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "billy@chainguard.dev" }),
});
const uuids = await idx.json();
console.log(`fetched ${uuids.length} entries`);
let pass = 0, fail = 0;
for (let i = 0; i < 5; i++) {
const r = await fetch(`https://rekor.sigstore.dev/api/v1/log/entries/${uuids[i]}`);
const obj = await r.json();
const e = Object.values(obj)[0];
const tlogEntry = {
inclusionProof: {
logIndex: BigInt(e.verification.inclusionProof.logIndex),
treeSize: BigInt(e.verification.inclusionProof.treeSize),
rootHash: Buffer.from(e.verification.inclusionProof.rootHash, "hex"),
hashes: e.verification.inclusionProof.hashes.map(h => Buffer.from(h, "base64")),
},
canonicalizedBody: Buffer.from(e.body, "base64"),
};
const checkpoint = {
logSize: BigInt(e.verification.inclusionProof.treeSize),
logHash: Buffer.from(e.verification.inclusionProof.rootHash, "hex"),
};
try {
verifyMerkleInclusion(tlogEntry, checkpoint);
console.log(` ${i} ${uuids[i].slice(0,16)}...: PASS`);
pass++;
} catch (err) {
console.log(` ${i} ${uuids[i].slice(0,16)}...: FAIL — ${err.message}`);
fail++;
}
}
console.log(`\n${pass}/5 verify, ${fail}/5 fail`);
JS
node repro.mjs
Observed (today, 2026-04-28):
fetched 9483 entries
0 108e9186e8c5677a...: FAIL — calculated root hash does not match inclusion proof
1 24296fb24b8ad77a...: FAIL — calculated root hash does not match inclusion proof
2 24296fb24b8ad77a...: FAIL — calculated root hash does not match inclusion proof
3 108e9186e8c5677a...: FAIL — calculated root hash does not match inclusion proof
4 108e9186e8c5677a...: FAIL — calculated root hash does not match inclusion proof
0/5 verify, 5/5 fail
What I confirmed manually (so this isn't trivially the wrong inputs):
- The leaf hash I compute from
entry.body via SHA-256(0x00 || base64Decode(body)) matches the suffix of the entry UUID. Rekor encodes leaf hashes directly into UUIDs as <treeID-prefix><leafHash>, so my leaf material and leaf-hash framing are the ones Rekor uses.
- JSON canonicalization (RFC 8785, via
canonicalize from @sigstore/core) produces bytes identical to base64Decode(body) — the body Rekor returns is already canonical JSON. So canonicalizedBody = base64Decode(body) should be equivalent to "canonicalize the parsed body and re-encode as bytes."
- Hand-rolling the RFC 6962 inclusion-proof walk (both the recursive odd/even-edge formulation and the
transparency-dev/merkle inner+border split formulation) produces the same wrong root as verifyMerkleInclusion. So my reproducer isn't picking the wrong proof bytes — the algorithm and inputs match what's published.
Hypothesis (would love confirmation either way):
Rekor v2 may have moved to a different leaf encoding than the JSON body bytes — e.g., a protobuf-encoded leaf that includes logId, integratedTime, or a tree-shard wrapper not exposed in the public API response. If that's the case, verifyMerkleInclusion is correct given a properly-constructed Bundle but doesn't accept raw API-shape inputs. That'd be worth a one-paragraph note in the README or jsdoc to redirect downstream consumers to the right input flow (probably "build a Bundle via cosign / gitsign first, then verify the bundle, don't go directly from API response to verifier").
If it's a real verifier bug, happy to provide more reproducers / specific UUIDs / a Wireshark capture / whatever helps.
Version
@sigstore/verify@3.1.0 (latest at time of report)
@sigstore/core@3.1.0
@sigstore/bundle@4.x
@sigstore/protobuf-specs@0.5.x
node@22
Context
Filing this from the agent-skills-cli project, where I was scoping client-side Sigstore Level 4 verification for git-tag signatures (the gitsign use case). Hit this 0/5 wall trying to validate against real Rekor entries; surfacing here so the path forward is clearer to others doing the same.
Thanks for the work on this stack — sigstore-js's tree of @sigstore/* packages is genuinely clean compared to the alternatives. Just want to close the gap between the public-facing API and the verifier's expected inputs.
Description
@sigstore/verify'sverifyMerkleInclusion(fromdist/tlog/merkle.js) fails on every Rekor entry I've tested when the entry is constructed directly from a freshGET /api/v1/log/entries/{uuid}response. Reproduces 5/5 against UUIDs from two distinct tree IDs (108e9186e8c5677a...and24296fb24b8ad77a...).I suspect this is a documentation/expected-input gap rather than a verifier bug — the public Rekor API response shape doesn't directly match what
verifyMerkleInclusionexpects incanonicalizedBody. Filing because:verifyMerkleInclusionjsdoc.Reproducer (zero-config, drops into a temp dir):
Observed (today, 2026-04-28):
What I confirmed manually (so this isn't trivially the wrong inputs):
entry.bodyviaSHA-256(0x00 || base64Decode(body))matches the suffix of the entry UUID. Rekor encodes leaf hashes directly into UUIDs as<treeID-prefix><leafHash>, so my leaf material and leaf-hash framing are the ones Rekor uses.canonicalizefrom@sigstore/core) produces bytes identical tobase64Decode(body)— the body Rekor returns is already canonical JSON. SocanonicalizedBody = base64Decode(body)should be equivalent to "canonicalize the parsed body and re-encode as bytes."transparency-dev/merkleinner+border split formulation) produces the same wrong root asverifyMerkleInclusion. So my reproducer isn't picking the wrong proof bytes — the algorithm and inputs match what's published.Hypothesis (would love confirmation either way):
Rekor v2 may have moved to a different leaf encoding than the JSON body bytes — e.g., a protobuf-encoded leaf that includes
logId,integratedTime, or a tree-shard wrapper not exposed in the public API response. If that's the case,verifyMerkleInclusionis correct given a properly-constructed Bundle but doesn't accept raw API-shape inputs. That'd be worth a one-paragraph note in the README or jsdoc to redirect downstream consumers to the right input flow (probably "build a Bundle via cosign / gitsign first, then verify the bundle, don't go directly from API response to verifier").If it's a real verifier bug, happy to provide more reproducers / specific UUIDs / a Wireshark capture / whatever helps.
Version
Context
Filing this from the agent-skills-cli project, where I was scoping client-side Sigstore Level 4 verification for git-tag signatures (the gitsign use case). Hit this 0/5 wall trying to validate against real Rekor entries; surfacing here so the path forward is clearer to others doing the same.
Thanks for the work on this stack — sigstore-js's tree of
@sigstore/*packages is genuinely clean compared to the alternatives. Just want to close the gap between the public-facing API and the verifier's expected inputs.