/** * rekor_verify.ts — Verify Rekor entry round-trip for SZL DSSE receipts * * Fetches a previously submitted Rekor entry by UUID, re-validates the * inclusion proof against the current signed tree head (STH), and confirms * the payload hash in the entry matches the locally held DSSE envelope. * * Round-trip verification flow (per Rekor transparency log guarantees): * 1. Fetch entry by UUID from /api/v1/log/entries/ * 2. Decode the base64-encoded body and confirm `kind` + `apiVersion` * 3. Confirm SHA-256 hash in body matches local payload hash * 4. Verify the Signed Entry Timestamp (SET) signature against the * Rekor public key — this is the cryptographic timestamp proof * 5. Optionally verify the Merkle inclusion proof against a fetched STH * * Refs: * - Rekor verification: https://docs.sigstore.dev/logging/overview/ * - RFC 6962 (CT): https://www.rfc-editor.org/rfc/rfc6962 (Merkle proofs) * - Rekor public key: https://rekor.sigstore.dev/api/v1/log/publicKey */ import { createHash, createVerify } from "node:crypto"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface VerifyOptions { /** Rekor base URL (default: https://rekor.sigstore.dev) */ rekorBase?: string; /** Timeout in ms (default: 10000) */ timeoutMs?: number; /** If true, also verify Merkle inclusion proof (requires fetching STH) */ verifyMerkle?: boolean; } export interface VerifyResult { uuid: string; verified: boolean; logIndex: number; integratedTime: number; integratedTimeISO: string; payloadHashMatch: boolean; setSignatureValid: boolean; merkleProofValid: boolean | "skipped"; errorMessage?: string; rekorEntryUrl: string; } interface RawRekorEntry { body: string; integratedTime: number; logID: string; logIndex: number; verification: { inclusionProof?: { checkpoint: string; hashes: string[]; logIndex: number; rootHash: string; treeSize: number; }; signedEntryTimestamp: string; }; } interface HashedRekordSpec { data: { hash: { algorithm: string; value: string } }; signature: { content: string; publicKey: { content: string } }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const DEFAULT_REKOR_BASE = "https://rekor.sigstore.dev"; function decodeBase64(b64: string): Buffer { return Buffer.from(b64, "base64"); } function decodeBase64url(b64: string): Buffer { return Buffer.from(b64.replace(/-/g, "+").replace(/_/g, "/"), "base64"); } function sha256Hex(data: Buffer | string): string { return createHash("sha256").update(data).digest("hex"); } async function fetchWithTimeout( url: string, opts: RequestInit, timeoutMs: number ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...opts, signal: controller.signal }); } finally { clearTimeout(timer); } } // --------------------------------------------------------------------------- // Rekor public key fetcher // --------------------------------------------------------------------------- let _cachedRekorPublicKeyPem: string | null = null; /** * Fetch the Rekor instance's ECDSA public key (used to verify SET signatures). * Cached in-process after first fetch. */ async function fetchRekorPublicKey( base: string, timeoutMs: number ): Promise { if (_cachedRekorPublicKeyPem) return _cachedRekorPublicKeyPem; const resp = await fetchWithTimeout( `${base}/api/v1/log/publicKey`, { headers: { Accept: "application/x-pem-file" } }, timeoutMs ); if (!resp.ok) { throw new Error( `Failed to fetch Rekor public key: HTTP ${resp.status}` ); } const pem = await resp.text(); _cachedRekorPublicKeyPem = pem; return pem; } // --------------------------------------------------------------------------- // SET signature verification // --------------------------------------------------------------------------- /** * Verify the Signed Entry Timestamp (SET) against Rekor's ECDSA public key. * * The SET is a base64-encoded signature over the canonical JSON of the entry * (body + integratedTime + logID + logIndex). This is the primary * cryptographic timestamp proof — it proves Rekor's private key witnessed * the entry at `integratedTime`. * * SET format reference: https://github.com/sigstore/rekor/blob/main/pkg/api/entries.go */ async function verifySET( entry: RawRekorEntry, rekorPublicKeyPem: string ): Promise { try { // Canonical bytes signed by Rekor to produce the SET: const canonicalEntry = { body: entry.body, integratedTime: entry.integratedTime, logID: entry.logID, logIndex: entry.logIndex, }; const canonicalBytes = Buffer.from(JSON.stringify(canonicalEntry)); const setBytes = decodeBase64(entry.verification.signedEntryTimestamp); const verifier = createVerify("SHA256"); verifier.update(canonicalBytes); verifier.end(); return verifier.verify(rekorPublicKeyPem, setBytes); } catch { return false; } } // --------------------------------------------------------------------------- // Merkle inclusion proof verification (RFC 6962 §2.1.3) // --------------------------------------------------------------------------- /** * Verify a Merkle inclusion proof against the claimed root hash. * * Rekor provides the leaf hash and a sequence of sibling hashes * (audit path). We reconstruct the root hash bottom-up using the * RFC 6962 node hashing rules: * - Leaf node: SHA-256(0x00 || leaf_data) * - Inner node: SHA-256(0x01 || left_hash || right_hash) * * Ref: https://www.rfc-editor.org/rfc/rfc6962#section-2.1 */ function verifyMerkleInclusionProof( leafHash: Buffer, auditPath: string[], leafIndex: number, treeSize: number, expectedRootHash: string ): boolean { try { let hash = leafHash; let idx = leafIndex; let lastNode = treeSize - 1; for (const sibling of auditPath) { const siblingBytes = Buffer.from(sibling, "hex"); if (idx % 2 === 1 || idx === lastNode) { // Current node is on the right (or is the rightmost node at this level) if (idx % 2 === 1) { // Sibling is on the left hash = createHash("sha256") .update(Buffer.from([0x01])) .update(siblingBytes) .update(hash) .digest(); } else { // Rightmost node: promote without sibling hash = createHash("sha256") .update(Buffer.from([0x01])) .update(hash) .update(siblingBytes) .digest(); } } else { // Current node is on the left hash = createHash("sha256") .update(Buffer.from([0x01])) .update(hash) .update(siblingBytes) .digest(); } idx = Math.floor(idx / 2); lastNode = Math.floor(lastNode / 2); } return hash.toString("hex") === expectedRootHash; } catch { return false; } } // --------------------------------------------------------------------------- // Core: verify // --------------------------------------------------------------------------- /** * Verify a Rekor entry round-trips correctly against a local DSSE envelope. * * @param uuid - Rekor entry UUID (returned by rekor_submit.ts) * @param localPayloadB64 - base64url-encoded payload from the DSSE envelope * (envelope.payload field) * @param opts - verification options */ export async function verifyRekorEntry( uuid: string, localPayloadB64: string, opts: VerifyOptions = {} ): Promise { const base = opts.rekorBase ?? DEFAULT_REKOR_BASE; const timeoutMs = opts.timeoutMs ?? 10_000; const entryUrl = `${base}/api/v1/log/entries/${uuid}`; const result: VerifyResult = { uuid, verified: false, logIndex: -1, integratedTime: -1, integratedTimeISO: "", payloadHashMatch: false, setSignatureValid: false, merkleProofValid: opts.verifyMerkle ? false : "skipped", rekorEntryUrl: entryUrl, }; try { // 1. Fetch entry const resp = await fetchWithTimeout( entryUrl, { headers: { Accept: "application/json" } }, timeoutMs ); if (!resp.ok) { result.errorMessage = `HTTP ${resp.status} fetching entry`; return result; } const entryMap = (await resp.json()) as Record; const entry = entryMap[uuid]; if (!entry) { result.errorMessage = "UUID not found in response"; return result; } result.logIndex = entry.logIndex; result.integratedTime = entry.integratedTime; result.integratedTimeISO = new Date(entry.integratedTime * 1000).toISOString(); // 2. Decode body and extract hashedrekord spec const bodyJson = JSON.parse(decodeBase64(entry.body).toString("utf8")); if (bodyJson.kind !== "hashedrekord" || bodyJson.apiVersion !== "0.0.1") { result.errorMessage = `Unexpected entry type: ${bodyJson.kind}@${bodyJson.apiVersion}`; return result; } const spec: HashedRekordSpec = bodyJson.spec; // 3. Compare payload hash const localPayloadBytes = decodeBase64url(localPayloadB64); const localHash = sha256Hex(localPayloadBytes); const rekorHash = spec.data.hash.value; result.payloadHashMatch = localHash === rekorHash; // 4. Verify SET const rekorPubKey = await fetchRekorPublicKey(base, timeoutMs); result.setSignatureValid = await verifySET(entry, rekorPubKey); // 5. Verify Merkle proof (optional but recommended for audit) if (opts.verifyMerkle && entry.verification?.inclusionProof) { const proof = entry.verification.inclusionProof; // Leaf hash = SHA-256(0x00 || base64-decode(body)) const leafHash = createHash("sha256") .update(Buffer.from([0x00])) .update(decodeBase64(entry.body)) .digest(); result.merkleProofValid = verifyMerkleInclusionProof( leafHash, proof.hashes, proof.logIndex, proof.treeSize, proof.rootHash ); } result.verified = result.payloadHashMatch && result.setSignatureValid && (result.merkleProofValid === "skipped" || result.merkleProofValid === true); } catch (e) { result.errorMessage = e instanceof Error ? e.message : String(e); } return result; } // --------------------------------------------------------------------------- // Batch verifier — checks all Rekor UUIDs in a JSONL receipt chain // --------------------------------------------------------------------------- export interface BatchVerifyResult { totalReceipts: number; verified: number; failed: number; results: VerifyResult[]; } /** * Verify all Rekor attestations in a parsed JSONL receipt array. * Each receipt must have a `rekorAttestation.uuid` and `envelope.payload` field. */ export async function batchVerifyReceipts( receipts: Array<{ envelope?: DSSEEnvelopeRef; rekorAttestation?: { uuid: string }; }>, opts: VerifyOptions = {} ): Promise { const results: VerifyResult[] = []; for (const receipt of receipts) { if (!receipt.rekorAttestation?.uuid || !receipt.envelope?.payload) { continue; } const r = await verifyRekorEntry( receipt.rekorAttestation.uuid, receipt.envelope.payload, opts ); results.push(r); } return { totalReceipts: results.length, verified: results.filter((r) => r.verified).length, failed: results.filter((r) => !r.verified).length, results, }; } interface DSSEEnvelopeRef { payload: string; payloadType?: string; } // --------------------------------------------------------------------------- // CLI entry point // --------------------------------------------------------------------------- if (import.meta.url === `file://${process.argv[1]}`) { (async () => { const uuid = process.argv[2]; const payloadB64 = process.argv[3]; if (!uuid || !payloadB64) { console.error("Usage: tsx rekor_verify.ts [--merkle]"); process.exit(1); } const verifyMerkle = process.argv.includes("--merkle"); const result = await verifyRekorEntry(uuid, payloadB64, { verifyMerkle }); console.log(JSON.stringify(result, null, 2)); if (!result.verified) { process.exit(1); } })().catch((e) => { console.error(e); process.exit(1); }); }