Skip to content

verifyMerkleInclusion fails on every fresh /api/v1/log/entries/{uuid} response from rekor.sigstore.dev #1634

Description

@MauricioPerera

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:

  1. It's the obvious thing a downstream consumer tries.
  2. If it's a known limitation, it'd help to surface it in the package README or verifyMerkleInclusion jsdoc.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions