mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-13 07:43:57 +00:00
173 lines
14 KiB
JavaScript
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/);
|
|
});
|