Files
quickly-conf-sshd/scripts/verify-commit-signature.mjs
T

82 lines
5.8 KiB
JavaScript

// This verifier and the workflow are trusted repository code, not an immutable
// security boundary. Protect changes to both; trusted keys/pins live in Actions
// variables outside this commit. No npm packages or repository imports are used.
import {spawnSync} from 'node:child_process';
import {mkdtemp, writeFile, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
export function enabledFlag(value, name = 'REQUIRE_GPG_SIGNATURE') {
if (value === undefined || value === '' || value === 'false') return false;
if (value === 'true') return true;
throw Error(`${name} must be unset, false, or exactly true.`);
}
export function parseFingerprints(value) {
const values = typeof value === 'string' ? value.trim().split(/[\s,]+/).filter(Boolean) : [];
if (!values.length || values.some(value => !/^[0-9a-fA-F]{40}$/.test(value))) {
throw Error('GPG_ALLOWED_PRIMARY_FINGERPRINTS must contain full 40-hex OpenPGP v4 primary fingerprints.');
}
return new Set(values.map(value => value.toUpperCase()));
}
export function verifySignatureStatus(output, allowed, now = Math.floor(Date.now() / 1000)) {
const records = output.split(/\r?\n/).filter(line => line.startsWith('[GNUPG:] ')).map(line => line.slice(9).split(/ +/));
const rejected = new Set(['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']);
if (records.some(record => rejected.has(record[0]))) throw Error('Commit signature is invalid, expired, revoked, or unverifiable.');
const valid = records.filter(record => record[0] === 'VALIDSIG');
if (valid.length !== 1) throw Error('Expected exactly one valid OpenPGP commit signature.');
const [, signer, , timestamp, expires, version, , , hash, signatureClass, primary] = valid[0];
if (valid[0].length !== 11 || !/^[A-Fa-f0-9]{40}$/.test(signer) || !/^[A-Fa-f0-9]{40}$/.test(primary || '') || version !== '4' || !['8','9','10'].includes(hash) || signatureClass !== '00') {
throw Error('Commit requires an OpenPGP v4 SHA-256/384/512 signature with full primary fingerprint.');
}
if (!/^\d+$/.test(timestamp) || !/^\d+$/.test(expires) || (Number(expires) !== 0 && Number(expires) <= now)) throw Error('Commit signature has expired or invalid timestamps.');
if (!allowed.has(primary.toUpperCase())) throw Error('Commit signer primary fingerprint is not pinned.');
return {signerFingerprint:signer.toUpperCase(), primaryFingerprint:primary.toUpperCase()};
}
function run(command, args, {cwd, env, input} = {}) {
const result = spawnSync(command, args, {cwd, env, input, encoding:'utf8', timeout:30_000, maxBuffer:4 * 1024 * 1024});
// Never relay GPG key text/UIDs or arbitrary subprocess errors into CI logs.
if (result.error || result.status !== 0 || result.signal) throw Error(`${command === 'git' ? 'Git' : 'GPG'} verification command failed.`);
return result;
}
export async function verifyCommitSignature({env = process.env, directory = process.cwd()} = {}) {
const required = enabledFlag(env.REQUIRE_GPG_SIGNATURE);
const revision = env.GITHUB_SHA;
if (!/^[0-9a-f]{40}$/.test(revision || '')) throw Error('GITHUB_SHA must be the exact 40-hex commit SHA.');
const cleanEnv = {...env, GIT_CONFIG_NOSYSTEM:'1', GIT_CONFIG_GLOBAL:'/dev/null', GIT_NO_REPLACE_OBJECTS:'1'};
const head = run('git', ['-c','core.fsmonitor=false','rev-parse','--verify','HEAD'], {cwd:directory,env:cleanEnv}).stdout.trim();
if (head !== revision) throw Error('Checked-out HEAD does not match GITHUB_SHA.');
if (!required) return {required:false,revision};
const allowed = parseFingerprints(env.GPG_ALLOWED_PRIMARY_FINGERPRINTS);
const publicKeys = env.GPG_PUBLIC_KEYS;
if (typeof publicKeys !== 'string' || !publicKeys.includes('-----BEGIN PGP PUBLIC KEY BLOCK-----') || publicKeys.includes('PRIVATE KEY')) throw Error('GPG_PUBLIC_KEYS must contain externally configured armored public keys only.');
const home = await mkdtemp(join(tmpdir(), 'conf-sshd-gpg-'));
try {
const gpgEnv = {...cleanEnv, GNUPGHOME:home};
const options = ['--no-options','--homedir',home,'--batch','--no-tty','--no-autostart','--no-auto-key-retrieve','--auto-key-locate','clear'];
// Decode into an isolated legacy public keyring without importing keys or
// contacting gpg-agent. This also avoids starting secret-key services in CI.
const listing = run('gpg', [...options,'--with-colons','--show-keys'], {env:gpgEnv,input:publicKeys}).stdout;
if (!/^pub:/m.test(listing) || /^(?:sec|ssb):/m.test(listing)) throw Error('Only public verification keys are allowed.');
run('gpg', [...options,'--output',join(home,'public.gpg'),'--dearmor'], {env:gpgEnv,input:publicKeys});
const wrapper = join(home,'verify-gpg');
await writeFile(wrapper, '#!/bin/sh\nexec gpg --no-options --homedir "$GNUPGHOME" --no-default-keyring --keyring "$GNUPGHOME/public.gpg" --batch --no-tty --no-autostart --no-auto-key-retrieve --auto-key-locate clear "$@"\n', {mode:0o700});
const result = run('git', ['-c','core.fsmonitor=false','-c','gpg.format=openpgp','-c',`gpg.program=${wrapper}`,'-c',`gpg.openpgp.program=${wrapper}`,'verify-commit','--raw',revision], {cwd:directory,env:gpgEnv});
return {required:true,revision,...verifySignatureStatus(result.stderr,allowed)};
} finally {
await rm(home,{recursive:true,force:true});
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
if (process.argv.length !== 2) throw Error('Usage: node scripts/verify-commit-signature.mjs');
const result = await verifyCommitSignature();
console.log(result.required ? `Verified commit ${result.revision} with pinned primary ${result.primaryFingerprint}.` : `GPG commit gate disabled; checked exact commit ${result.revision}.`);
} catch (error) { console.error(error.message); process.exitCode = 1; }
}