a11oy / src /sigstore /rekor_verify.ts
betterwithage's picture
sync(space): complete build context — fix BUILD_ERROR (CTO)
518343a verified
Raw
History Blame
12.8 kB
/**
* 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/<uuid>
* 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<Response> {
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<string> {
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<boolean> {
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<VerifyResult> {
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<string, RawRekorEntry>;
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<BatchVerifyResult> {
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 <uuid> <base64url-payload> [--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);
});
}