Files
quickly-conf-sshd/tests/deployment-security.test.mjs
T

173 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
import {readFile, mkdtemp, mkdir, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {verifyCommitSignature, enabledFlag, parseFingerprints, verifySignatureStatus} from '../scripts/verify-commit-signature.mjs';
import {notifyDeployment, deploymentPayload} from '../scripts/notify-deployment.mjs';
import {openPgpFixture} from './helpers/openpgp-fixture.mjs';
const primary = 'A'.repeat(40), subkey = 'B'.repeat(40);
const validStatus = (fingerprint = primary, signer = fingerprint) => `[GNUPG:] GOODSIG 1234567890ABCDEF Test fixture\n[GNUPG:] VALIDSIG ${signer} 2026-01-01 1767225600 0 4 0 22 8 00 ${fingerprint}\n`;
test('GPG opt-in is strict and only accepts complete primary fingerprint pins', () => {
for (const value of [undefined,'','false']) assert.equal(enabledFlag(value),false);
assert.equal(enabledFlag('true'),true);
for (const value of ['TRUE','yes','1',' false ','False',null]) assert.throws(() => enabledFlag(value));
assert.deepEqual(parseFingerprints(primary.toLowerCase()+',\n'+subkey),new Set([primary,subkey]));
for (const value of ['',undefined,'1234ABCD','A'.repeat(64),primary+' invalid']) assert.throws(() => parseFingerprints(value));
});
test('machine status requires a pinned primary and rejects bad, expired and revoked signatures', () => {
const pins = new Set([primary]);
assert.deepEqual(verifySignatureStatus(validStatus(primary,subkey),pins),{signerFingerprint:subkey,primaryFingerprint:primary});
assert.throws(() => verifySignatureStatus(validStatus(subkey),pins),/not pinned/);
assert.throws(() => verifySignatureStatus('[GNUPG:] GOODSIG 123 Test fixture\n',pins),/exactly one/);
assert.throws(() => verifySignatureStatus(validStatus()+validStatus(),pins),/exactly one/);
for (const code of ['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']) {
assert.throws(() => verifySignatureStatus(validStatus()+`[GNUPG:] ${code} rejected\n`,pins),/invalid, expired, revoked/);
}
assert.throws(() => verifySignatureStatus(validStatus().replace('1767225600 0 4','1767225600 1767225601 4'),pins,1767225602),/expired/);
assert.throws(() => verifySignatureStatus(validStatus().replace('22 8 00','22 2 00'),pins),/SHA-256/);
assert.throws(() => verifySignatureStatus(validStatus().replace(' 4 0 22',' 5 0 22'),pins),/v4/);
assert.throws(() => verifySignatureStatus(validStatus().replace(` 00 ${primary}`,' 00'),pins),/primary fingerprint/);
});
function command(bin,args,options = {}) {
const result = spawnSync(bin,args,{encoding:'utf8',timeout:30_000,...options});
assert.equal(result.error,undefined,`${bin} failed to execute`);
assert.equal(result.status,0,`${bin} failed: ${result.stderr}`);
return result.stdout;
}
test('GPG gate verifies real ephemeral Git commits in an isolated keyring', {timeout:120_000}, async t => {
const directory = await mkdtemp(join(tmpdir(),'conf-sshd-signature-test-'));
const repo = join(directory,'repo');await mkdir(repo);
const env = {...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null'};
const git = (args,options = {}) => command('git',args,{cwd:repo,env,...options});
const commit = (name,fixture) => {
const tree = git(['hash-object','-t','tree','-w','--stdin'],{input:''}).trim();
const headers = `tree ${tree}\nauthor Security fixture <[email protected]> 1767225600 +0000\ncommitter Security fixture <[email protected]> 1767225600 +0000`;
const body = `${headers}\n\n${name}\n`;
const signed = fixture ? `${headers}\ngpgsig ${fixture.sign(body).trimEnd().replaceAll('\n','\n ')}\n\n${name}\n` : body;
const sha = git(['hash-object','-t','commit','-w','--stdin'],{input:signed}).trim();
git(['update-ref','HEAD',sha]);return sha;
};
const gateEnv = (sha,pin,key) => ({...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'true',GPG_ALLOWED_PRIMARY_FINGERPRINTS:pin,GPG_PUBLIC_KEYS:key});
try {
git(['init','-q']);
const good = openPgpFixture(), other = openPgpFixture(), sub = openPgpFixture({subkeys:2});
let sha = commit('unsigned fixture');
await t.test('disabled still pins HEAD; enabled unsigned/missing config/incorrect SHA fail',async () => {
assert.equal((await verifyCommitSignature({env:{...env,GITHUB_SHA:sha},directory:repo})).required,false);
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:'0'.repeat(40)},directory:repo}),/does not match/);
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'invalid'},directory:repo}),/exactly true/);
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'true'},directory:repo}),/FINGERPRINTS/);
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,''),directory:repo}),/public keys/);
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo}),/Git verification command failed/);
});
sha = commit('approved signature fixture',good);
await t.test('approved primary passes; wrong pin, missing public signer and Git nonzero fail',async () => {
assert.equal((await verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
assert.equal((await verifyCommitSignature({env:gateEnv(sha,`${other.primaryFingerprint},${good.primaryFingerprint}`,other.publicKey+good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,other.primaryFingerprint,good.publicKey),directory:repo}),/not pinned/);
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,other.publicKey),directory:repo}),/Git verification command failed/);
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory}),/Git verification command failed/);
});
sha = commit('tampered commit fixture',{sign:data=>good.sign(data.replace('tampered commit fixture','original commit fixture'))});
await t.test('a valid signature on different commit bytes is rejected',async () => {
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo}),/Git verification command failed/);
});
sha = commit('signing subkey fixture',sub);
await t.test('signing subkey is accepted only through the pinned primary fingerprint',async () => {
const result = await verifyCommitSignature({env:gateEnv(sha,sub.primaryFingerprint,sub.publicKey),directory:repo});
assert.equal(result.primaryFingerprint,sub.primaryFingerprint);assert.equal(result.signerFingerprint,sub.signerFingerprint);assert.notEqual(result.signerFingerprint,sub.primaryFingerprint);
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,sub.signerFingerprint,sub.publicKey),directory:repo}),/not pinned/);
const secondSha = commit('second signing subkey fixture',{sign:data=>sub.sign(data,1)});
const second = await verifyCommitSignature({env:gateEnv(secondSha,sub.primaryFingerprint,sub.publicKey),directory:repo});
assert.equal(second.primaryFingerprint,sub.primaryFingerprint);assert.equal(second.signerFingerprint,sub.signerFingerprints[1]);
});
const revoked = openPgpFixture({revoked:true});sha = commit('revoked fixture',revoked);
await t.test('real primary revocation rejects a previously created signature',async () => {
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,revoked.primaryFingerprint,revoked.publicKey),directory:repo}),/Git verification command failed|revoked/);
});
const expired = openPgpFixture({expired:true});sha = commit('expired key fixture',expired);
await t.test('expired signing key is rejected using current verifier time',async () => {
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,expired.primaryFingerprint,expired.publicKey),directory:repo}),/Git verification command failed|expired/);
});
sha = commit('forced verifier fixture',good);
await t.test('Git configuration cannot substitute another signature format or verifier',async () => {
git(['config','gpg.format','ssh']);git(['config','gpg.openpgp.program','/bin/true']);git(['config','gpg.program','/bin/true']);
assert.equal((await verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
});
} finally {await rm(directory,{recursive:true,force:true});}
});
const notificationEnv = {
ENABLE_MAIL_NOTIFICATION:'true',CI:'true',GITHUB_ACTIONS:'true',
MAIL_NOTIFICATION_WEBHOOK:'https://mailer.example.invalid/hook?token=DO_NOT_LOG',
MAIL_NOTIFICATION_TO:'[email protected]',DEPLOYMENT_STATUS:'success',DEPLOYMENT_PHASE:'complete',
GITHUB_SHA:'c'.repeat(40),GITHUB_REPOSITORY:'example/server-keys',GITHUB_RUN_ID:'123',
};
const deploymentConfig = {
baseUrl:'https://ssh.example.invalid',keySource:{type:'gist'},
signedRelease:{listId:'server-keys-production'},fallbackSources:['https://backup.example.invalid/server.keys'],
};
test('notification stays network-free unless explicitly enabled in CI with complete configuration',async () => {
let calls = 0;const fetchImpl = async () => {calls++;return {ok:true,status:200};};
for (const ENABLE_MAIL_NOTIFICATION of [undefined,'','false']) {
assert.deepEqual(await notifyDeployment({env:{ENABLE_MAIL_NOTIFICATION},fetchImpl}),{sent:false,reason:'disabled'});
}
for (const value of ['yes','TRUE','1']) await assert.rejects(notifyDeployment({env:{...notificationEnv,ENABLE_MAIL_NOTIFICATION:value},config:deploymentConfig,fetchImpl}),/exactly true/);
await assert.rejects(notifyDeployment({env:{...notificationEnv,CI:'false'},config:deploymentConfig,fetchImpl}),/only in GitHub Actions/);
await assert.rejects(notifyDeployment({env:{...notificationEnv,MAIL_NOTIFICATION_WEBHOOK:''},config:deploymentConfig,fetchImpl}),/required/);
for (const endpoint of ['http://mailer.example.invalid','https://user:[email protected]','https://mailer.example.invalid/#secret']) {
await assert.rejects(notifyDeployment({env:{...notificationEnv,MAIL_NOTIFICATION_WEBHOOK:endpoint},config:deploymentConfig,fetchImpl}),/HTTPS/);
}
assert.equal(calls,0);
});
test('notification JSON identifies deployment, configured mode/list/fallbacks and uses a bounded nonredirecting POST',async () => {
for (const [status,phase] of [['success','complete'],['failure','deployment'],['failure','remote-verification']]) {
const result = await notifyDeployment({env:{...notificationEnv,DEPLOYMENT_STATUS:status,DEPLOYMENT_PHASE:phase},config:deploymentConfig,fetchImpl:async (url,options) => {
assert.equal(url,notificationEnv.MAIL_NOTIFICATION_WEBHOOK);assert.equal(options.method,'POST');assert.equal(options.redirect,'error');
assert.equal(options.headers['content-type'],'application/json');assert.ok(options.signal instanceof AbortSignal);
const payload = JSON.parse(options.body);
assert.equal(payload.status,status);assert.equal(payload.phase,phase);assert.equal(payload.revision,notificationEnv.GITHUB_SHA);
assert.equal(payload.configuration.mode,'gist');assert.equal(payload.configuration.listId,'server-keys-production');
assert.deepEqual(payload.configuration.fallbackSources,deploymentConfig.fallbackSources);assert.match(payload.configurationScope,/not measured server state/);
assert.equal(payload.to,'[email protected]');assert.ok(!options.body.includes('DO_NOT_LOG'));
return {ok:true,status:202};
}});
assert.deepEqual(result,{sent:true});
}
const defaults = deploymentPayload({baseUrl:'https://ssh.example.invalid'}, {...notificationEnv,MAIL_NOTIFICATION_TO:''});
assert.equal(defaults.configuration.mode,'git');assert.equal(defaults.configuration.listId,null);assert.equal(defaults.to,null);
assert.deepEqual(defaults.configuration.fallbackSources,[]);
});
test('notification rejects ambiguous metadata and never exposes webhook tokens or retries failures',async () => {
for (const patch of [{DEPLOYMENT_STATUS:'unknown'},{DEPLOYMENT_PHASE:'unknown'},{GITHUB_SHA:'main'},{GITHUB_REPOSITORY:'evil\nrepo'},{GITHUB_RUN_ID:'$((1))'},{MAIL_NOTIFICATION_TO:'[email protected]\nBcc:[email protected]'}]) {
assert.throws(() => deploymentPayload(deploymentConfig,{...notificationEnv,...patch}));
}
let calls = 0;
await assert.rejects(notifyDeployment({env:notificationEnv,config:deploymentConfig,fetchImpl:async () => {calls++;throw Error(notificationEnv.MAIL_NOTIFICATION_WEBHOOK);}}),error=>!error.message.includes('DO_NOT_LOG') && /request failed/.test(error.message));
assert.equal(calls,1);
await assert.rejects(notifyDeployment({env:notificationEnv,config:deploymentConfig,fetchImpl:async () => ({ok:false,status:503})}),/HTTP 503/);
});
test('deployment workflow gates both entry points before repository execution and separates secret scopes',async () => {
const workflow = await readFile(new URL('../.github/workflows/deploy.yml',import.meta.url),'utf8');
const verify = workflow.split('\n verify:\n')[1].split('\n deploy:\n')[0];
const deploy = workflow.split('\n deploy:\n')[1].split('\n notify:\n')[0];
const notify = workflow.split('\n notify:\n')[1];
assert.match(workflow,/workflow_dispatch:/);assert.match(verify,/refs\/heads\/main/);assert.match(verify,/verify-commit-signature\.mjs/);
assert.ok(!verify.includes('${{ secrets.'));assert.ok(!verify.includes('environment: production'));assert.ok(!verify.includes('npm '));
assert.match(deploy,/needs: verify/);assert.match(deploy,/environment: production/);
assert.equal((workflow.match(/ref: \$\{\{ github.sha \}\}/g)||[]).length,3);
assert.match(notify,/needs\.deploy\.outputs\.attempted == 'true'/);assert.ok(!notify.includes('CLOUDFLARE_'));
assert.match(notify,/DEPLOYMENT_PHASE:/);assert.match(notify,/MAIL_NOTIFICATION_WEBHOOK/);
});