src/release-attestation.js
Rama master · réplica de lectura del repositorio original.
/**
* D9 source-OTA attestation.
*
* `release-attestation.json` is a release asset generated by the release
* workflow. It binds one tag to the exact commit and tree that produced the
* published assets, plus the SHA-256 of every asset. The file is signed with
* the same cosign keyless identity as the assets themselves
* (`release-attestation.json.sigstore.json`).
*
* The dashboard `/self-update` path and `update.sh` both call
* `verifyReleaseAttestation()` before they touch Git. Policy:
*
* - an attestation that is present must name this tag and this commit.
* A mismatch is refused in every mode;
* - a present signature bundle must verify with the pinned release identity.
* A bad signature is refused in every mode;
* - missing material (no attestation, no bundle, no cosign binary, or a
* network failure) is refused only in `required` mode. The default `warn`
* mode logs the gap and continues, so an existing install without cosign
* keeps updating.
*
* The module is shared by `src/dashboard/api.js` and the two CLI scripts under
* `scripts/`; it has no dependency outside `node:*`.
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
createReadStream,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
export const ATTESTATION_FILENAME = 'release-attestation.json';
export const ATTESTATION_BUNDLE_FILENAME = `${ATTESTATION_FILENAME}.sigstore.json`;
/** The certificate identity is the workflow ref GitHub's OIDC token carries. */
export const DEFAULT_ATTESTATION_IDENTITY_REGEXP =
String.raw`^https://github\.com/dwgx/WindsurfAPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$`;
export const DEFAULT_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
export const DEFAULT_ATTESTATION_REPOSITORY = 'dwgx/WindsurfAPI';
/** Release-asset name -> build-tree path, used by the release job generator. */
export const DEFAULT_RELEASE_ASSETS = Object.freeze({
'SHA256SUMS': 'SHA256SUMS',
'windsurfapi-macos-arm64': 'dist-macos/windsurfapi-macos-arm64',
'windsurfapi-macos.zip': 'windsurfapi-macos.zip',
'windsurfapi-windows.zip': 'dist-windows/windsurfapi-windows.zip',
'windsurfapi.exe': 'dist-windows/windsurfapi.exe',
});
export const ATTESTATION_MODES = Object.freeze(['warn', 'required']);
export class ReleaseAttestationError extends Error {
constructor(code, message) {
super(message);
this.name = 'ReleaseAttestationError';
this.code = code;
}
}
export function isReleaseAttestationError(err) {
const code = err?.code;
return typeof code === 'string'
&& (code.startsWith('ERR_RELEASE_ATTESTATION_') || code === 'ERR_COSIGN_UNAVAILABLE');
}
export function normalizeAttestationMode(value) {
const mode = String(value ?? '').trim().toLowerCase();
if (!mode) return 'warn';
if (!ATTESTATION_MODES.includes(mode)) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_MODE',
`WINDSURFAPI_UPDATE_ATTESTATION must be "warn" or "required", got "${value}"`,
);
}
return mode;
}
function validObjectId(value) {
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(String(value || ''));
}
function validStableTag(value) {
return /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(String(value || ''));
}
function validAssetName(name) {
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
&& name !== '.'
&& name !== '..'
&& name !== '__proto__'
&& name !== 'constructor'
&& name !== 'prototype';
}
/**
* Validate and normalize an attestation object or JSON text. Unknown fields are
* ignored; the fields that decide an update are required and type-checked.
*/
export function parseReleaseAttestation(input) {
let value;
if (typeof input === 'string') {
try {
value = JSON.parse(input);
} catch (err) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_INVALID',
`release attestation is not valid JSON: ${err.message}`,
);
}
} else {
value = input;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'release attestation must be a JSON object');
}
if (value.schema !== 1) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_INVALID',
`unsupported release attestation schema: ${JSON.stringify(value.schema)}`,
);
}
const tag = String(value.tag ?? '');
if (!validStableTag(tag)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation tag is not a stable vX.Y.Z: ${tag || '(missing)'}`);
}
const commit = String(value.commit ?? '').toLowerCase();
if (!validObjectId(commit)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation commit is not a 40/64-hex object id: ${commit || '(missing)'}`);
}
const tree = String(value.tree ?? '').toLowerCase();
if (!validObjectId(tree)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation tree is not a 40/64-hex object id: ${tree || '(missing)'}`);
}
const repository = value.repository === undefined ? '' : String(value.repository);
if (repository && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation repository is not owner/name: ${repository}`);
}
if (!value.assets || typeof value.assets !== 'object' || Array.isArray(value.assets)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'release attestation assets must be an object');
}
const names = Object.keys(value.assets);
if (names.length === 0) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'release attestation assets must not be empty');
}
const assets = {};
for (const name of names.sort()) {
if (!validAssetName(name)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation asset name is unsafe: ${name}`);
}
const digest = String(value.assets[name] ?? '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(digest)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation asset ${name} is not a SHA-256: ${digest || '(missing)'}`);
}
assets[name] = digest;
}
return { schema: 1, repository, tag, commit, tree, assets };
}
/** Deterministic serialization: fixed field order and sorted asset names. */
export function serializeReleaseAttestation(attestation) {
const parsed = parseReleaseAttestation(attestation);
const assets = {};
for (const name of Object.keys(parsed.assets).sort()) assets[name] = parsed.assets[name];
return `${JSON.stringify({
schema: 1,
repository: parsed.repository,
tag: parsed.tag,
commit: parsed.commit,
tree: parsed.tree,
assets,
}, null, 2)}\n`;
}
export function sha256File(path) {
return new Promise((resolvePromise, reject) => {
const hash = createHash('sha256');
const stream = createReadStream(path);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolvePromise(hash.digest('hex')));
});
}
/**
* Build an attestation for a checkout. `assets` maps release-asset name to a
* path relative to `root`; the default map is the layout of the release job.
*/
export async function buildReleaseAttestation({
tag,
commit,
tree,
root = '.',
assets = DEFAULT_RELEASE_ASSETS,
repository = DEFAULT_ATTESTATION_REPOSITORY,
} = {}) {
if (!assets || typeof assets !== 'object' || Array.isArray(assets)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'assets must be a name -> path object');
}
const digests = {};
for (const name of Object.keys(assets).sort()) {
digests[name] = await sha256File(resolve(root, assets[name]));
}
return parseReleaseAttestation({
schema: 1,
repository,
tag,
commit,
tree,
assets: digests,
});
}
/** Extract owner/name from the GitHub remote forms Git accepts. */
export function parseGitHubRepository(originUrl) {
const value = String(originUrl || '').trim();
let match = /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(value);
if (!match) {
match = /^(?:ssh:\/\/)?git@github\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(value);
}
return match ? `${match[1]}/${match[2]}` : '';
}
function isFile(path) {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
async function fetchText(url, fetchImpl) {
const fetchFn = fetchImpl || globalThis.fetch;
if (typeof fetchFn !== 'function') {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_UNAVAILABLE',
'no fetch implementation is available to download the release attestation',
);
}
let response;
try {
const init = {
redirect: 'follow',
headers: { 'user-agent': 'windsurfapi-release-attestation' },
};
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
init.signal = AbortSignal.timeout(15_000);
}
response = await fetchFn(url, init);
} catch (err) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_UNAVAILABLE',
`could not fetch ${url}: ${err?.message || err}`,
);
}
if (response.status === 404) return null;
if (!response.ok) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_UNAVAILABLE',
`could not fetch ${url}: HTTP ${response.status}`,
);
}
return response.text();
}
/**
* Load attestation text from a pre-staged directory, or from the release
* assets of `repository` on GitHub. Returns null when the attestation is
* absent (a 404 / missing file), and throws when it exists but cannot be read.
*/
export async function loadAttestationMaterial({ tag, dir = '', repository = '', fetchImpl } = {}) {
if (dir) {
const directory = resolve(dir);
const attestationPath = join(directory, ATTESTATION_FILENAME);
if (!isFile(attestationPath)) return null;
const bundlePath = join(directory, ATTESTATION_BUNDLE_FILENAME);
return {
text: readFileSync(attestationPath, 'utf8'),
bundleText: isFile(bundlePath) ? readFileSync(bundlePath, 'utf8') : '',
dir: directory,
};
}
if (!repository) return null;
const base = `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}`;
const text = await fetchText(`${base}/${ATTESTATION_FILENAME}`, fetchImpl);
if (text === null) return null;
const bundleText = await fetchText(`${base}/${ATTESTATION_BUNDLE_FILENAME}`, fetchImpl);
return { text, bundleText: bundleText || '', dir: '' };
}
export function buildCosignVerifyArgs({ bundlePath, blobPath, identity, issuer }) {
return [
'verify-blob',
'--bundle',
bundlePath,
'--certificate-identity-regexp',
identity,
'--certificate-oidc-issuer',
issuer,
blobPath,
];
}
export function runCosignVerifyBlob({
cosign = process.env.COSIGN || 'cosign',
bundlePath,
blobPath,
identity = DEFAULT_ATTESTATION_IDENTITY_REGEXP,
issuer = DEFAULT_OIDC_ISSUER,
spawn = spawnSync,
} = {}) {
const args = buildCosignVerifyArgs({ bundlePath, blobPath, identity, issuer });
const result = spawn(cosign, args, { encoding: 'utf8', timeout: 60_000 });
if (result.error) {
if (result.error.code === 'ENOENT') {
throw new ReleaseAttestationError(
'ERR_COSIGN_UNAVAILABLE',
`cosign not found (${cosign}); install it from https://docs.sigstore.dev or pass --cosign <path>`,
);
}
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_INVALID',
`failed to run cosign: ${result.error.message}`,
);
}
if (result.status !== 0) {
const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_INVALID',
`cosign verification failed for ${blobPath}${detail ? `: ${detail}` : ''}`,
);
}
}
export function assertAttestationMatches(attestation, { tag, commit, repository = '' } = {}) {
if (attestation.tag !== tag) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_MISMATCH',
`release attestation tag ${attestation.tag} does not match selected tag ${tag}`,
);
}
if (attestation.commit !== String(commit || '').toLowerCase()) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_MISMATCH',
`release attestation commit ${attestation.commit} does not match selected commit ${String(commit || '').toLowerCase()}`,
);
}
if (repository && attestation.repository && attestation.repository.toLowerCase() !== repository.toLowerCase()) {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_MISMATCH',
`release attestation repository ${attestation.repository} does not match ${repository}`,
);
}
return attestation;
}
/**
* Verify the release attestation for one (tag, commit) pair.
*
* Returns {status:'verified'|'missing'|'unsigned'|'cosign-unavailable'|'unavailable'}.
* Throws ReleaseAttestationError on mismatch/invalid material, and on any
* missing material when mode === 'required'.
*/
export async function verifyReleaseAttestation(options = {}) {
const {
tag,
commit,
mode = 'warn',
dir = '',
repository = '',
fetchImpl,
cosign = process.env.COSIGN || 'cosign',
identity = DEFAULT_ATTESTATION_IDENTITY_REGEXP,
issuer = DEFAULT_OIDC_ISSUER,
runCosign,
log = () => {},
tempRoot,
} = options;
const normalizedMode = normalizeAttestationMode(mode);
const normalizedTag = String(tag || '');
if (!validStableTag(normalizedTag)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `selected tag is not a stable vX.Y.Z: ${normalizedTag || '(missing)'}`);
}
const normalizedCommit = String(commit || '').toLowerCase();
if (!validObjectId(normalizedCommit)) {
throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `selected commit is not a 40/64-hex object id: ${normalizedCommit || '(missing)'}`);
}
let material;
try {
material = await loadAttestationMaterial({ tag: normalizedTag, dir, repository, fetchImpl });
} catch (err) {
if (err?.code === 'ERR_RELEASE_ATTESTATION_UNAVAILABLE' && normalizedMode === 'warn') {
const warning = `release attestation unavailable for ${normalizedTag}: ${err.message}`;
log(warning);
return { status: 'unavailable', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, warning };
}
throw err;
}
if (!material) {
if (normalizedMode === 'required') {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_MISSING',
`no release attestation is available for ${normalizedTag}; WINDSURFAPI_UPDATE_ATTESTATION=required refuses an unverified update`,
);
}
const warning = `no release attestation is available for ${normalizedTag}; continuing because WINDSURFAPI_UPDATE_ATTESTATION=warn`;
log(warning);
return { status: 'missing', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, warning };
}
const attestation = parseReleaseAttestation(material.text);
assertAttestationMatches(attestation, {
tag: normalizedTag,
commit: normalizedCommit,
repository,
});
if (!material.bundleText) {
if (normalizedMode === 'required') {
throw new ReleaseAttestationError(
'ERR_RELEASE_ATTESTATION_UNSIGNED',
`release attestation for ${normalizedTag} has no .sigstore.json bundle; required mode refuses an unverified statement`,
);
}
const warning = `release attestation for ${normalizedTag} has no signature bundle; commit binding checked but the signature was not verified`;
log(warning);
return { status: 'unsigned', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, attestation, warning };
}
let workDir = material.dir;
let cleanup = null;
if (!workDir) {
workDir = mkdtempSync(join(tempRoot || tmpdir(), 'windsurfapi-attestation-'));
writeFileSync(join(workDir, ATTESTATION_FILENAME), material.text, 'utf8');
writeFileSync(join(workDir, ATTESTATION_BUNDLE_FILENAME), material.bundleText, 'utf8');
cleanup = () => rmSync(workDir, { recursive: true, force: true });
}
try {
const call = {
cosign,
bundlePath: join(workDir, ATTESTATION_BUNDLE_FILENAME),
blobPath: join(workDir, ATTESTATION_FILENAME),
identity,
issuer,
};
if (runCosign) await runCosign(call);
else runCosignVerifyBlob(call);
} catch (err) {
if (err?.code === 'ERR_COSIGN_UNAVAILABLE' && normalizedMode === 'warn') {
const warning = `cosign is unavailable; the release attestation signature was not verified: ${err.message}`;
log(warning);
return {
status: 'cosign-unavailable',
mode: normalizedMode,
tag: normalizedTag,
commit: normalizedCommit,
attestation,
warning,
};
}
throw err;
} finally {
if (cleanup) cleanup();
}
log(`verified release attestation for ${normalizedTag} at ${normalizedCommit}`);
return { status: 'verified', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, attestation };
}