a11oy / src /sigstore /rekor_submit.ts
betterwithage's picture
sync(space): complete build context — fix BUILD_ERROR (CTO)
518343a verified
Raw
History Blame
13.8 kB
/**
* rekor_submit.ts — Submit SZL DSSE envelopes to Sigstore Rekor v1 transparency log
*
* Implements the Rekor "hashedrekord" entry type (v0.0.1) per the Sigstore
* transparency log API documented at https://docs.sigstore.dev/logging/overview/
* and the OpenAPI spec at https://rekor.sigstore.dev/api/v1.
*
* Every DSSE receipt produced by the SZL organ layer (sentra/amaru/rosie) is
* independently timestamped via Rekor's Merkle-tree signed tree head (STH).
* The returned UUID (logIndex + treeID) is stored alongside the original JSONL
* receipt so that any third party can reproduce the inclusion proof without
* trusting SZL infrastructure.
*
* STAGED-ADVISORY: Production use requires a Fulcio OIDC token (GitHub Actions
* OIDC or workload-identity token). The `signingKey` path below accepts a
* plain ECDSA P-256 key for local development; swap for
* `@sigstore/sign` Fulcio flow for keyless CI.
*
* Refs:
* - Rekor OpenAPI: https://rekor.sigstore.dev/api/v1
* - hashedrekord: https://github.com/sigstore/rekor/tree/main/pkg/types/hashedrekord
* - DSSE spec: https://github.com/secure-systems-lab/dsse/blob/master/protocol.md
* - RFC 8785 (JCS): https://www.rfc-editor.org/rfc/rfc8785
*/
import { createHash, createSign, createVerify, generateKeyPairSync } from "node:crypto";
import { readFileSync } from "node:fs";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Canonical DSSE envelope as produced by SZL organ layer (sentra/amaru/rosie) */
export interface DSSEEnvelope {
payload: string; // raw body, base64-encoded (NOT the PAE; per DSSE
// the PAE is computed at sign time from this body
// and is never stored here). See PhD_CRYPTO_VERDICT.md A3.
payloadType: string; // e.g. "application/vnd.szl.receipt.v1+json"
signatures: Array<{
sig: string; // base64url HMAC-SHA256 or ECDSA signature
keyid?: string;
}>;
}
/** Rekor hashedrekord v0.0.1 body (base64-encoded JSON) */
export interface HashedRekordBody {
apiVersion: "0.0.1";
kind: "hashedrekord";
spec: {
data: {
hash: {
algorithm: "sha256";
value: string; // hex SHA-256 of the canonical payload bytes
};
};
signature: {
content: string; // base64-encoded DER signature
publicKey: {
content: string; // base64-encoded PEM public key
};
};
};
}
/** Rekor log entry as returned by POST /api/v1/log/entries */
export interface RekorEntry {
[uuid: string]: {
body: string; // base64-encoded HashedRekordBody JSON
integratedTime: number; // Unix epoch seconds
logID: string;
logIndex: number;
verification: {
inclusionProof: {
checkpoint: string;
hashes: string[];
logIndex: number;
rootHash: string;
treeSize: number;
};
signedEntryTimestamp: string; // base64-encoded RFC 3161 SET
};
};
}
/** SZL-specific Rekor submission result, stored alongside DSSE receipt JSONL */
export interface RekorSubmitResult {
uuid: string;
logIndex: number;
integratedTime: number;
treeID: string;
inclusionProofRootHash: string;
rekorEntryUrl: string;
payloadHash: string;
submittedAt: string; // ISO-8601
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const REKOR_BASE = "https://rekor.sigstore.dev";
const REKOR_STAGING_BASE = "https://rekor.sigstore.dev"; // staging shares prod URL; use --staging flag in rekor-cli
const API_VERSION = "v1";
export interface RekorSubmitOptions {
/** Use staging endpoint (same host, different tree in practice via CLI --staging) */
staging?: boolean;
/** Timeout in ms (default 10000) */
timeoutMs?: number;
/** PEM-encoded ECDSA P-256 private key for signing (dev mode).
* In production, leave undefined and provide `oidcToken` for Fulcio keyless. */
signingKeyPem?: string;
/** PEM-encoded public key matching signingKeyPem */
publicKeyPem?: string;
/** GitHub OIDC token for Fulcio keyless (STAGED-ADVISORY: wires into Fulcio CA) */
oidcToken?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Canonical payload bytes: UTF-8 encode the DSSE payload field (base64url → raw).
* We hash the *decoded* payload so the hash is algorithm-agnostic and matches
* what `rekor-cli` produces for the same artifact.
*/
function decodeBase64url(b64: string): Buffer {
// base64url → base64 standard
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(std, "base64");
}
/** SHA-256 hex digest of bytes */
function sha256Hex(data: Buffer | string): string {
return createHash("sha256").update(data).digest("hex");
}
/**
* Build a minimal self-signed ECDSA P-256 key pair for dev/test purposes.
* In production this is replaced by a Fulcio-issued ephemeral cert chain.
*/
export function generateDevKeyPair(): { privateKeyPem: string; publicKeyPem: string } {
const { privateKey, publicKey } = generateKeyPairSync("ec", {
namedCurve: "P-256",
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
return { privateKeyPem: privateKey as string, publicKeyPem: publicKey as string };
}
/**
* Sign the canonical artifact bytes with ECDSA-P256-SHA256.
* Returns a base64-encoded DER signature (not base64url) because the Rekor
* hashedrekord spec uses standard base64 for the `signature.content` field.
*/
function signArtifact(artifactBytes: Buffer, privateKeyPem: string): string {
const signer = createSign("SHA256");
signer.update(artifactBytes);
signer.end();
return signer.sign(privateKeyPem, "base64");
}
/**
* Encode a HashedRekordBody object to the base64 string that Rekor expects
* as the `body` field in the POST request.
*/
function encodeBody(body: HashedRekordBody): string {
return Buffer.from(JSON.stringify(body)).toString("base64");
}
// ---------------------------------------------------------------------------
// Core: submit
// ---------------------------------------------------------------------------
/**
* Submit a DSSE envelope to the Sigstore Rekor public transparency log.
*
* Flow:
* 1. Decode DSSE payload bytes
* 2. Compute SHA-256 hash of payload bytes
* 3. Sign payload bytes with ECDSA-P256 (dev key) or Fulcio (keyless)
* 4. Build hashedrekord body
* 5. POST to /api/v1/log/entries
* 6. Extract UUID, logIndex, inclusionProof
* 7. Return RekorSubmitResult for JSONL side-car
*
* @param envelope - SZL DSSE receipt envelope
* @param opts - signing + endpoint options
* @returns - Rekor submission metadata
*/
export async function submitDSSEToRekor(
envelope: DSSEEnvelope,
opts: RekorSubmitOptions = {}
): Promise<RekorSubmitResult> {
const base = opts.staging ? REKOR_STAGING_BASE : REKOR_BASE;
const url = `${base}/api/${API_VERSION}/log/entries`;
// 1. Decode payload bytes (DSSE payload field is base64url)
const payloadBytes = decodeBase64url(envelope.payload);
// 2. Hash
const hashHex = sha256Hex(payloadBytes);
// 3. Sign
let sigB64: string;
let pubKeyPem: string;
if (opts.signingKeyPem && opts.publicKeyPem) {
sigB64 = signArtifact(payloadBytes, opts.signingKeyPem);
pubKeyPem = opts.publicKeyPem;
} else {
// STAGED-ADVISORY: Fulcio keyless path — requires real OIDC token
// In a GitHub Actions environment this token is obtained via:
// const { getIDToken } = await import("@actions/core");
// const oidcToken = await getIDToken("sigstore");
// Then exchanged via https://fulcio.sigstore.dev/api/v2/signingCert
// for an ephemeral X.509 cert. The cert's SAN encodes the workflow identity.
throw new Error(
"STAGED-ADVISORY: Fulcio keyless signing not yet wired. " +
"Provide signingKeyPem + publicKeyPem for dev mode, or set oidcToken for CI."
);
}
// 4. Build hashedrekord body
const body: HashedRekordBody = {
apiVersion: "0.0.1",
kind: "hashedrekord",
spec: {
data: {
hash: {
algorithm: "sha256",
value: hashHex,
},
},
signature: {
content: sigB64,
publicKey: {
// Rekor expects base64-encoded PEM (the PEM string itself, base64-encoded)
content: Buffer.from(pubKeyPem).toString("base64"),
},
},
},
};
// 5. POST to Rekor
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
opts.timeoutMs ?? 10_000
);
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
body: encodeBody(body),
}),
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
if (!response.ok) {
const text = await response.text();
throw new Error(
`Rekor submission failed: HTTP ${response.status}${text}`
);
}
const entry = (await response.json()) as RekorEntry;
// 6. Extract UUID (the single key in the response object)
const uuid = Object.keys(entry)[0];
if (!uuid) throw new Error("Rekor returned empty entry object");
const entryData = entry[uuid];
const logIndex = entryData.logIndex;
const integratedTime = entryData.integratedTime;
// Extract treeID from the logID (treeID is embedded in Rekor's logID for sharded logs)
// logID is the hex-encoded SHA-256 of the log's public key — not the treeID.
// For the public instance treeID is in the /api/v1/log response.
// We embed the uuid (which encodes treeID+logIndex) as the canonical reference.
const treeID = uuid.split("").slice(0, 16).join(""); // first 16 hex chars = treeID portion
const rootHash =
entryData.verification?.inclusionProof?.rootHash ?? "pending";
const result: RekorSubmitResult = {
uuid,
logIndex,
integratedTime,
treeID,
inclusionProofRootHash: rootHash,
rekorEntryUrl: `${base}/api/${API_VERSION}/log/entries/${uuid}`,
payloadHash: hashHex,
submittedAt: new Date().toISOString(),
};
return result;
}
// ---------------------------------------------------------------------------
// JSONL side-car emitter
// ---------------------------------------------------------------------------
/**
* Augment an existing SZL receipt JSONL line with Rekor attestation metadata.
*
* The augmented record is suitable for appending to the JSONL chain and acts
* as an independently auditable anchor: any party holding the receipt can
* reproduce the Rekor inclusion proof via:
* rekor-cli get --uuid <uuid> --rekor_server https://rekor.sigstore.dev
*/
export function augmentReceiptWithRekor(
receipt: Record<string, unknown>,
rekorResult: RekorSubmitResult
): Record<string, unknown> {
return {
...receipt,
rekorAttestation: {
uuid: rekorResult.uuid,
logIndex: rekorResult.logIndex,
integratedTime: rekorResult.integratedTime,
inclusionProofRootHash: rekorResult.inclusionProofRootHash,
entryUrl: rekorResult.rekorEntryUrl,
payloadHash: rekorResult.payloadHash,
submittedAt: rekorResult.submittedAt,
// Verification command for auditors:
verifyCmd: `rekor-cli get --uuid ${rekorResult.uuid} --rekor_server https://rekor.sigstore.dev`,
},
};
}
// ---------------------------------------------------------------------------
// Pepr policy hook (exported for use in Pepr admission controller)
// ---------------------------------------------------------------------------
/**
* Pepr-compatible policy hook. Drop this into a Pepr When().Mutate() chain.
*
* Usage in Pepr policy file:
* import { emitRekorAttestation } from "./sigstore/rekor_submit";
*
* When(SZLReceipt).IsCreated().Mutate(async (receipt) => {
* const result = await emitRekorAttestation(receipt.Raw.spec.envelope, peprOpts);
* receipt.SetAnnotation("szl.io/rekor-uuid", result.uuid);
* receipt.SetAnnotation("szl.io/rekor-log-index", String(result.logIndex));
* });
*/
export async function emitRekorAttestation(
envelope: DSSEEnvelope,
opts: RekorSubmitOptions
): Promise<RekorSubmitResult> {
return submitDSSEToRekor(envelope, opts);
}
// ---------------------------------------------------------------------------
// CLI entry point (ts-node / tsx)
// ---------------------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
(async () => {
const envelopePath = process.argv[2];
if (!envelopePath) {
console.error("Usage: tsx rekor_submit.ts <envelope.json> [--staging]");
process.exit(1);
}
const envelope: DSSEEnvelope = JSON.parse(readFileSync(envelopePath, "utf8"));
const { privateKeyPem, publicKeyPem } = generateDevKeyPair();
const staging = process.argv.includes("--staging");
console.log("Submitting DSSE envelope to Rekor...");
const result = await submitDSSEToRekor(envelope, {
signingKeyPem: privateKeyPem,
publicKeyPem,
staging,
});
console.log(JSON.stringify(result, null, 2));
console.log(`\nVerify with:\n ${result.rekorEntryUrl}`);
})().catch((e) => {
console.error(e);
process.exit(1);
});
}