/** * 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 `, ); } 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 }; }