mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-13 07:43:57 +00:00
feat: pin client GPG trust and publish signed Git/Gist key releases
This commit is contained in:
+67
-13
@@ -7,20 +7,25 @@ import { execFileSync } from 'node:child_process';
|
||||
|
||||
export const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const digest = data => createHash('sha256').update(data).digest('hex');
|
||||
import { escapeHTML, commandsFor, renderKeys, renderCommands, renderPage } from '../cf-worker/src/view.js';
|
||||
export { escapeHTML, commandsFor, renderKeys, renderCommands };
|
||||
import { validateFallbackSources, parsePublicKeys } from '../cf-worker/src/key-source.js';
|
||||
import { escapeHTML, commandsFor, renderCommands, renderPage } from '../cf-worker/src/view.js';
|
||||
export { escapeHTML, commandsFor, renderCommands };
|
||||
import { validateFallbackSources, parsePublicKeys, validateKeySource, validateSignedReleaseConfig } from '../cf-worker/src/key-source.js';
|
||||
import {withoutKeyComments, parsePublishedSignedRelease, SIGNATURE_SEPARATOR} from '../cf-worker/src/signed-release.js';
|
||||
import {checkSigningCertificate, validatePublicCertificate} from './signature-tools.mjs';
|
||||
|
||||
|
||||
export function validateConfig(config) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) throw Error('Deployment configuration must be an object.');
|
||||
for (const key of Object.keys(config)) if (!['baseUrl','repositoryUrl','defaultCron','allowEmpty','fallbackSources'].includes(key)) throw Error(`Unknown configuration: ${key}`);
|
||||
for (const key of Object.keys(config)) if (!['baseUrl','repositoryUrl','defaultCron','allowEmpty','fallbackSources','keySource','signedRelease','clientDefaults'].includes(key)) throw Error(`Unknown configuration: ${key}`);
|
||||
if (typeof config.baseUrl !== 'string' || !/^https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?$/.test(config.baseUrl)) throw Error('baseUrl must be an HTTPS origin without paths, credentials, or shell metacharacters.');
|
||||
const url = new URL(config.baseUrl);
|
||||
if (url.origin !== config.baseUrl || !url.hostname.includes('.') || url.hostname.includes('..')) throw Error('baseUrl must be a canonical HTTPS origin.');
|
||||
if (typeof config.repositoryUrl !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repositoryUrl)) throw Error('repositoryUrl must identify a GitHub repository.');
|
||||
if (typeof config.allowEmpty !== 'boolean') throw Error('allowEmpty must be an explicit boolean.');
|
||||
validateFallbackSources(Object.hasOwn(config, 'fallbackSources') ? config.fallbackSources : []);
|
||||
validateKeySource(config.keySource);
|
||||
validateSignedReleaseConfig(config.signedRelease);
|
||||
validateClientDefaults(config.clientDefaults);
|
||||
const cron = config.defaultCron;
|
||||
if (typeof cron !== 'string' || cron.trim() !== cron || !/^[0-9*,/ -]+$/.test(cron) || cron.split(/ +/).length !== 5) throw Error('defaultCron must contain five numeric cron fields.');
|
||||
const bounds = [[0,59],[0,23],[1,31],[1,12],[0,7]];
|
||||
@@ -37,6 +42,18 @@ export function validateConfig(config) {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function validateClientDefaults(value = {}) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['gpgVerification', 'fingerprint', 'pinMode', 'listId', 'email'].includes(key))) throw Error('Invalid clientDefaults configuration.');
|
||||
const result = {gpgVerification: value.gpgVerification ?? 'off', fingerprint: value.fingerprint ?? '', pinMode: value.pinMode ?? 'primary', listId: value.listId ?? '', email: value.email ?? ''};
|
||||
if (!['off', 'on'].includes(result.gpgVerification) || !['primary', 'subkey'].includes(result.pinMode)) throw Error('Invalid client verification defaults.');
|
||||
if (typeof result.fingerprint !== 'string' || (result.fingerprint !== '' && !/^[A-Fa-f0-9]{40}$/.test(result.fingerprint))) throw Error('Client fingerprint must be empty or a full 40-character fingerprint.');
|
||||
if (typeof result.listId !== 'string' || result.listId === '-' || (result.listId !== '' && !/^[A-Za-z0-9._-]{1,64}$/.test(result.listId))) throw Error('Invalid client list ID.');
|
||||
if (result.gpgVerification === 'on' && (!result.fingerprint || !result.listId)) throw Error('Enabled verification defaults require a full fingerprint and list ID.');
|
||||
if (typeof result.email !== 'string' || (result.email !== '' && (!/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$/.test(result.email) || result.email.length > 254))) throw Error('Client email must be empty or a safe public email address.');
|
||||
result.fingerprint = result.fingerprint.toUpperCase();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function parseKeys(input) {
|
||||
// Use the exact runtime parser, then additionally verify against the build's OpenSSH tools.
|
||||
const keys = await parsePublicKeys(input);
|
||||
@@ -54,8 +71,9 @@ export async function parseKeys(input) {
|
||||
|
||||
export function makeKeysText(keys, revision, allowEmpty) {
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)) throw Error('Release revision must be a 40-character Git commit SHA.');
|
||||
if (typeof allowEmpty !== 'boolean') throw Error('allowEmpty must be an explicit boolean.');
|
||||
if (!keys.length && !allowEmpty) throw Error('No server public keys configured. Add keys/authorized_keys, or explicitly allow an empty revocation release.');
|
||||
const payload = keys.map(key => key.text + '\n').join('');
|
||||
const payload = withoutKeyComments(keys).map(key => key.text + '\n').join('');
|
||||
const sha256 = digest(payload);
|
||||
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${sha256}\n# end-metadata\n${payload}`;
|
||||
if (Buffer.byteLength(text) > 1024 * 1024) throw Error('Serialized key release exceeds client response limit.');
|
||||
@@ -65,26 +83,62 @@ export function makeKeysText(keys, revision, allowEmpty) {
|
||||
|
||||
export async function build({directory = root, outDirectory = join(directory,'cf-worker'), testFixture = false, revision, publishedAt} = {}) {
|
||||
const config = validateConfig(JSON.parse(await readFile(join(directory,'deployment.json'),'utf8')));
|
||||
const input = await readFile(join(directory,testFixture ? 'tests/fixtures/keys.pub' : 'keys/authorized_keys'),'utf8');
|
||||
const keys = await parseKeys(input);
|
||||
const input = config.keySource?.type === 'gist' && !testFixture ? '' : await readFile(join(directory,testFixture ? 'tests/fixtures/keys.pub' : 'keys/authorized_keys'),'utf8').catch(error => {if (error.code === 'ENOENT' && !testFixture) return ''; throw error;});
|
||||
let keys = [];
|
||||
revision ||= process.env.GITHUB_SHA || execFileSync('git',['rev-parse','HEAD'],{cwd:directory,encoding:'utf8'}).trim();
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)) throw Error('Release revision must be a 40-character Git commit SHA.');
|
||||
publishedAt ||= new Date().toISOString();
|
||||
const fallbackSources = validateFallbackSources(config.fallbackSources ?? []);
|
||||
const needsFallback = keys.length === 0 && !config.allowEmpty && fallbackSources.length > 0;
|
||||
const bundle = needsFallback ? {sha256:null,text:null} : makeKeysText(keys,revision,config.allowEmpty);
|
||||
const keySource = validateKeySource(config.keySource), signedRelease = validateSignedReleaseConfig(config.signedRelease);
|
||||
const optional = async path => { try { return await readFile(join(directory, path), 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
||||
let signed = null, publicCertificate = null;
|
||||
if (keySource.type === 'git' && !testFixture) {
|
||||
const documentFile = await optional('keys/ssh.keys'), bundleFile = await optional('keys/release.bundle');
|
||||
if (documentFile !== null && bundleFile !== null) throw Error('Use keys/ssh.keys plus its optional signature OR keys/release.bundle, not both.');
|
||||
if (documentFile !== null || bundleFile !== null) {
|
||||
const separator = bundleFile?.indexOf(SIGNATURE_SEPARATOR) ?? -1;
|
||||
if (bundleFile !== null && separator < 0) throw Error('release.bundle lacks its detached-signature separator.');
|
||||
const document = documentFile ?? bundleFile.slice(0, separator);
|
||||
const signature = documentFile !== null ? await optional('keys/ssh.keys.sig') : bundleFile.slice(separator + SIGNATURE_SEPARATOR.length);
|
||||
signed = await parsePublishedSignedRelease(document, signature, {listId: signedRelease?.listId, checkFreshness: false});
|
||||
keys = signed.keys;
|
||||
}
|
||||
}
|
||||
if (!signed) keys = withoutKeyComments(await parseKeys(input));
|
||||
if (signedRelease?.publicKeyFile) {
|
||||
publicCertificate = validatePublicCertificate(await readFile(join(directory, signedRelease.publicKeyFile), 'utf8'));
|
||||
if (signed && !signed.bundle) throw Error('Configured build signature verification requires a valid detached signature.');
|
||||
await checkSigningCertificate(publicCertificate, signedRelease.fingerprint, signed?.bundle);
|
||||
}
|
||||
const allowEmpty = signed ? signed.metadata.revoke : config.allowEmpty;
|
||||
const needsFallback = keySource.type === 'git' && keys.length === 0 && !allowEmpty && fallbackSources.length > 0;
|
||||
const needsSource = keySource.type === 'gist' || needsFallback;
|
||||
if (keySource.type === 'gist') keys = [];
|
||||
const bundle = needsSource ? {sha256:null,text:null} : signed ? {sha256:digest(keys.map(key=>key.text+'\n').join('')),text:signed.document} : makeKeysText(keys,revision,allowEmpty);
|
||||
const clientDefaults = validateClientDefaults(config.clientDefaults);
|
||||
if (!testFixture && !needsSource && clientDefaults.gpgVerification === 'on') {
|
||||
if (!signed?.signature) throw Error('Verification-enabled static Git releases require a signed document and detached signature.');
|
||||
if (signed.metadata.listId !== clientDefaults.listId) throw Error('Client default list ID differs from the static signed document.');
|
||||
if (signedRelease?.fingerprint && clientDefaults.pinMode === 'primary' && clientDefaults.fingerprint !== signedRelease.fingerprint) throw Error('Client primary fingerprint differs from the configured signing certificate.');
|
||||
}
|
||||
let script = await readFile(join(directory,'conf-sshd.sh'),'utf8');
|
||||
for (const [name,value] of Object.entries({'BASE_URL':config.baseUrl,'DEFAULT_CRON':config.defaultCron})) {
|
||||
if (!script.includes(`@@${name}@@`)) throw Error(`Script is missing @@${name}@@.`);
|
||||
script = script.replaceAll(`@@${name}@@`,value);
|
||||
}
|
||||
for (const [name, value] of Object.entries({DEFAULT_GPG_VERIFY:clientDefaults.gpgVerification,DEFAULT_GPG_FINGERPRINT:clientDefaults.fingerprint,DEFAULT_GPG_PIN_MODE:clientDefaults.pinMode,DEFAULT_LIST_ID:clientDefaults.listId,DEFAULT_GPG_EMAIL:clientDefaults.email})) script = script.replaceAll(`@@${name}@@`, value);
|
||||
if (/@@[A-Z_]+@@/.test(script)) throw Error('Unresolved script template token.');
|
||||
if (!script.includes('# conf-sshd-script-v2')) throw Error('Missing script format marker.');
|
||||
execFileSync('sh',['-n'],{input:script,stdio:['pipe','pipe','pipe']});
|
||||
const release = {schema:1,revision,publishedAt,keyCount:keys.length,allowEmpty:config.allowEmpty,sha256:bundle.sha256,testFixture,baseUrl:config.baseUrl,repositoryUrl:config.repositoryUrl,fallbackSources,needsFallback,keys,commands:commandsFor(config)};
|
||||
const release = {schema:1,revision,publishedAt,keyCount:keys.length,allowEmpty,sha256:bundle.sha256,keyRevision:signed?.metadata.documentSha256 ?? bundle.sha256,testFixture,baseUrl:config.baseUrl,repositoryUrl:config.repositoryUrl,fallbackSources,keySource,signedRelease,clientDefaults,publicCertificate,signedDocument:signed?.document ?? null,signature:signed?.signature ?? null,signatureAvailable:Boolean(signed?.signature),publicCertificateAvailable:Boolean(publicCertificate),signedBundle:signed?.bundle ?? null,signedMetadata:signed?.metadata ?? null,needsFallback,needsSource,keys,commands:commandsFor(config)};
|
||||
let html = await readFile(join(directory,'site/index.html'),'utf8');
|
||||
const publicDir = join(outDirectory,'public'), generatedDir = join(outDirectory,'generated');
|
||||
await rm(publicDir,{recursive:true,force:true});
|
||||
await mkdir(join(publicDir,'assets'),{recursive:true}); await mkdir(generatedDir,{recursive:true});
|
||||
// Git files are true Worker Static Assets as well as exact router response bytes (never Pages).
|
||||
if (!needsSource) await writeFile(join(publicDir,'ssh.keys'), signed?.document ?? bundle.text);
|
||||
if (signed?.signature) await writeFile(join(publicDir,'ssh.keys.sig'), signed.signature);
|
||||
if (publicCertificate) await writeFile(join(publicDir,'public-key.gpg'), publicCertificate);
|
||||
for (const [file,ext] of [['style','css'],['app','js']]) {
|
||||
const body = await readFile(join(directory,`site/assets/${file}.${ext}`));
|
||||
const name = `${file}.${digest(body).slice(0,12)}.${ext}`;
|
||||
@@ -92,9 +146,9 @@ export async function build({directory = root, outDirectory = join(directory,'cf
|
||||
html = html.replaceAll(`/assets/${file}.${ext}`,`/assets/${name}`);
|
||||
}
|
||||
release.pageTemplate = html;
|
||||
await writeFile(join(publicDir,'index.html'),renderPage(html,{...release,source:{type:needsFallback ? 'pending' : keys.length ? 'repository' : 'explicit-empty'}}));
|
||||
await writeFile(join(generatedDir,'release.js'),`// Generated release; do not edit.\nexport default ${JSON.stringify({...release,script,keysText:bundle.text})};\n`);
|
||||
await writeFile(join(generatedDir,'manifest.json'),JSON.stringify({...release,keys:undefined,commands:undefined,pageTemplate:undefined},null,2)+'\n');
|
||||
await writeFile(join(publicDir,'index.html'),renderPage(html,{...release,source:{type:needsSource ? 'pending' : keys.length ? 'repository' : 'explicit-empty'}}));
|
||||
await writeFile(join(generatedDir,'release.js'),`// Generated release; do not edit.\nexport default ${JSON.stringify({...release,script,keysText:signed?.document ?? bundle.text})};\n`);
|
||||
await writeFile(join(generatedDir,'manifest.json'),JSON.stringify({...release,keys:undefined,commands:undefined,pageTemplate:undefined,publicCertificate:undefined,signedDocument:undefined,signature:undefined,signedBundle:undefined},null,2)+'\n');
|
||||
return release;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Optional JSON webhook for a separately configured email delivery service.
|
||||
// This sends deployment configuration, not observed server/fleet state.
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
export function notificationEnabled(value) {
|
||||
if (value === undefined || value === '' || value === 'false') return false;
|
||||
if (value === 'true') return true;
|
||||
throw Error('ENABLE_MAIL_NOTIFICATION must be unset, false, or exactly true.');
|
||||
}
|
||||
|
||||
function httpsUrl(value, name) {
|
||||
let url;
|
||||
try { url = new URL(value); } catch { throw Error(`${name} must be an HTTPS URL.`); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw Error(`${name} must be HTTPS without URL credentials or fragments.`);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function deploymentPayload(config, env) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) throw Error('Deployment configuration must be an object.');
|
||||
const status = env.DEPLOYMENT_STATUS;
|
||||
if (!['success','failure','cancelled'].includes(status)) throw Error('DEPLOYMENT_STATUS must be success, failure, or cancelled.');
|
||||
const phase = env.DEPLOYMENT_PHASE;
|
||||
if (!['deployment','remote-verification','complete'].includes(phase)) throw Error('DEPLOYMENT_PHASE must identify the attempted deployment phase.');
|
||||
if (!/^[0-9a-f]{40}$/.test(env.GITHUB_SHA || '')) throw Error('GITHUB_SHA must be an exact 40-hex commit SHA.');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(env.GITHUB_REPOSITORY || '') || !/^\d+$/.test(env.GITHUB_RUN_ID || '')) throw Error('A valid GitHub repository and run ID are required.');
|
||||
const mode = config.keySource?.type ?? 'git';
|
||||
if (!['git','gist'].includes(mode)) throw Error('Unsupported deployment key source mode.');
|
||||
const listId = config.signedRelease?.listId ?? null;
|
||||
if (listId !== null && (typeof listId !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(listId))) throw Error('Invalid deployment list identity.');
|
||||
const fallbackSources = config.fallbackSources ?? [];
|
||||
if (!Array.isArray(fallbackSources) || fallbackSources.length > 5) throw Error('Invalid deployment fallback sources.');
|
||||
const to = env.MAIL_NOTIFICATION_TO || null;
|
||||
if (to !== null && (to.length > 254 || !/^[^\s@,<>]+@[^\s@,<>]+\.[^\s@,<>]+$/.test(to))) throw Error('MAIL_NOTIFICATION_TO must be one email address.');
|
||||
return {
|
||||
schema:1,
|
||||
event:'worker-deployment',
|
||||
status,
|
||||
phase,
|
||||
repository:env.GITHUB_REPOSITORY,
|
||||
revision:env.GITHUB_SHA,
|
||||
workflowRunUrl:`https://github.com/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`,
|
||||
to,
|
||||
configuration:{
|
||||
baseUrl:httpsUrl(config.baseUrl, 'Deployment baseUrl'),
|
||||
mode,
|
||||
listId,
|
||||
signedRelease:Boolean(config.signedRelease),
|
||||
fallbackSources:fallbackSources.map(value => httpsUrl(value,'Deployment fallback source')),
|
||||
},
|
||||
configurationScope:'deployment configuration only; not measured server state',
|
||||
};
|
||||
}
|
||||
|
||||
export async function notifyDeployment({env = process.env, config, fetchImpl = globalThis.fetch} = {}) {
|
||||
if (!notificationEnabled(env.ENABLE_MAIL_NOTIFICATION)) return {sent:false,reason:'disabled'};
|
||||
if (env.GITHUB_ACTIONS !== 'true' || env.CI !== 'true') throw Error('Deployment notification sending is allowed only in GitHub Actions CI.');
|
||||
if (!env.MAIL_NOTIFICATION_WEBHOOK) throw Error('MAIL_NOTIFICATION_WEBHOOK is required when notifications are enabled.');
|
||||
const endpoint = httpsUrl(env.MAIL_NOTIFICATION_WEBHOOK,'Notification webhook');
|
||||
const payload = deploymentPayload(config,env);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method:'POST',
|
||||
headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify(payload),
|
||||
redirect:'error',
|
||||
signal:AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch {
|
||||
// Provider errors may contain secret URL tokens. Do not print them or retry
|
||||
// automatically: a timed-out request may already have sent the email.
|
||||
throw Error('Deployment notification request failed; check the webhook service.');
|
||||
}
|
||||
if (!response.ok) throw Error(`Deployment notification was rejected (HTTP ${response.status}).`);
|
||||
// Do not log response bodies, the recipient, or the secret webhook URL.
|
||||
return {sent:true};
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
if (process.argv.length !== 2) throw Error('Usage: node scripts/notify-deployment.mjs');
|
||||
let config;
|
||||
if (notificationEnabled(process.env.ENABLE_MAIL_NOTIFICATION)) {
|
||||
try { config = JSON.parse(await readFile(new URL('../deployment.json',import.meta.url),'utf8')); }
|
||||
catch { throw Error('Cannot read deployment notification configuration.'); }
|
||||
}
|
||||
const result = await notifyDeployment({config});
|
||||
console.log(result.sent ? 'Deployment notification webhook accepted the request.' : 'Deployment notification disabled.');
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
// This preparation tool only reads PUBLIC SSH keys and writes the exact bytes to sign offline.
|
||||
// It never reads a signing private key, invokes gpg signing, or uploads anything.
|
||||
import {readFile, writeFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {makeSignedDocument, parseSignedDocument, SIGNATURE_SEPARATOR, validateSignature} from '../cf-worker/src/signed-release.js';
|
||||
|
||||
export async function prepare(argv) {
|
||||
const options = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
if (key === '--revoke') { options.revoke = true; continue; }
|
||||
if (!['--input', '--output', '--list-id', '--sequence', '--issued-at', '--expires-at', '--signature', '--bundle'].includes(key) || i + 1 >= argv.length || Object.hasOwn(options, key)) throw Error('Usage: node scripts/prepare-key-release.mjs --input PUBLIC_KEYS --output DOCUMENT --list-id ID --sequence N --issued-at EPOCH --expires-at EPOCH [--revoke]\nOr: --input DOCUMENT --signature SIGNATURE --bundle BUNDLE (pack only after offline signing).');
|
||||
options[key] = argv[++i];
|
||||
}
|
||||
if (options['--bundle']) {
|
||||
if (!options['--input'] || !options['--signature'] || Object.keys(options).some(key => !['--input', '--signature', '--bundle'].includes(key))) throw Error('Packing accepts only --input DOCUMENT --signature SIGNATURE --bundle BUNDLE.');
|
||||
const document = await readFile(options['--input'], 'utf8'), signature = await readFile(options['--signature'], 'utf8');
|
||||
await parseSignedDocument(document, {checkFreshness: false});
|
||||
validateSignature(signature);
|
||||
await writeFile(options['--bundle'], document + SIGNATURE_SEPARATOR + signature, {flag: 'wx', mode: 0o600});
|
||||
return 'Packed exact document and detached signature. Cryptographic verification is still required.';
|
||||
}
|
||||
if (!options['--input'] || !options['--output'] || !options['--list-id'] || options['--signature']) throw Error('Preparation requires --input, --output, --list-id, --sequence, --issued-at and --expires-at.');
|
||||
const numbers = {};
|
||||
for (const [key, field] of [['--sequence','sequence'], ['--issued-at','issuedAt'], ['--expires-at','expiresAt']]) {
|
||||
if (!/^(?:0|[1-9][0-9]{0,9})$/.test(options[key] ?? '')) throw Error(`${key} must be a canonical decimal integer.`);
|
||||
numbers[field] = Number(options[key]);
|
||||
}
|
||||
const document = await makeSignedDocument(await readFile(options['--input'], 'utf8'), {listId: options['--list-id'], ...numbers, revoke: Boolean(options.revoke)});
|
||||
await writeFile(options['--output'], document, {flag: 'wx', mode: 0o600});
|
||||
return 'Prepared comment-free bytes. Sign this exact file offline with an armored detached signature; never rewrite it afterward.';
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try { console.log(await prepare(process.argv.slice(2))); } catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {mkdtemp, writeFile, rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {SIGNED_RELEASE_LIMITS, parseSignedBundle} from '../cf-worker/src/signed-release.js';
|
||||
|
||||
export function validatePublicCertificate(certificate) {
|
||||
if (typeof certificate !== 'string' || Buffer.byteLength(certificate) > SIGNED_RELEASE_LIMITS.publicKeyBytes || !/^-----BEGIN PGP PUBLIC KEY BLOCK-----\r?\n[\s\S]+\r?\n-----END PGP PUBLIC KEY BLOCK-----\r?\n?$/.test(certificate) || /[^\x09\x0a\x0d\x20-\x7e]/.test(certificate) || (certificate.match(/-----BEGIN /g) || []).length !== 1) throw Error('Expected one bounded ASCII-armored PUBLIC OpenPGP certificate, never private material.');
|
||||
return certificate;
|
||||
}
|
||||
|
||||
/** Uses an ephemeral public-only keyring. Never loads the operator's keyring, signs, or retrieves keys. */
|
||||
export async function checkSigningCertificate(certificate, fingerprint, signed = null) {
|
||||
validatePublicCertificate(certificate);
|
||||
if (typeof fingerprint !== 'string' || !/^[A-Fa-f0-9]{40}$/.test(fingerprint)) throw Error('A full primary signing fingerprint is required.');
|
||||
fingerprint = fingerprint.toUpperCase();
|
||||
const home = await mkdtemp(join(tmpdir(), 'conf-sshd-public-verify-'));
|
||||
const args = ['--no-options', '--homedir', home, '--batch', '--no-tty', '--no-autostart', '--no-auto-key-retrieve', '--auto-key-locate', 'clear'];
|
||||
const gpg = more => {try {return execFileSync('gpg', [...args, ...more], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000, maxBuffer: 4 * 1024 * 1024});} catch {throw Error('GPG public verification command failed.');}};
|
||||
try {
|
||||
const certPath = join(home, 'certificate.asc');
|
||||
await writeFile(certPath, certificate, {mode: 0o600});
|
||||
const packets = gpg(['--list-packets', certPath]);
|
||||
if (/:secret (?:sub )?key packet:/.test(packets)) throw Error('Secret key packets are not accepted.');
|
||||
const listing = gpg(['--with-colons', '--show-keys', certPath]);
|
||||
const records = listing.split('\n').map(line => line.split(':'));
|
||||
const primaries = records.filter(record => record[0] === 'pub');
|
||||
if (primaries.length !== 1 || records.some(record => record[0] === 'sec' || record[0] === 'ssb')) throw Error('The public certificate must contain exactly one primary identity.');
|
||||
if (['r', 'e', 'd'].includes(primaries[0][1]) || (primaries[0][6] && Number(primaries[0][6]) <= Math.floor(Date.now()/1000))) throw Error('Signing public certificate is expired, revoked, or disabled.');
|
||||
const primaryIndex = records.findIndex(record => record[0] === 'pub');
|
||||
const actual = records.slice(primaryIndex + 1).find(record => record[0] === 'fpr')?.[9];
|
||||
if (actual !== fingerprint) throw Error('Signing public certificate fingerprint mismatch.');
|
||||
if (!signed) return fingerprint;
|
||||
const parsed = await parseSignedBundle(signed, {checkFreshness: false});
|
||||
const keyring = join(home, 'public.gpg');
|
||||
gpg(['--output', keyring, '--dearmor', certPath]);
|
||||
const documentPath = join(home, 'document'), signaturePath = join(home, 'signature.asc');
|
||||
await writeFile(documentPath, parsed.document, {mode: 0o600});
|
||||
await writeFile(signaturePath, parsed.signature, {mode: 0o600});
|
||||
let status;
|
||||
try { status = gpg(['--no-default-keyring', '--keyring', keyring, '--status-fd', '1', '--verify', signaturePath, documentPath]); }
|
||||
catch { throw Error('Detached OpenPGP signature verification failed.'); }
|
||||
validatePayloadSignatureStatus(status, fingerprint);
|
||||
return fingerprint;
|
||||
} finally { await rm(home, {recursive: true, force: true}); }
|
||||
}
|
||||
|
||||
export function validatePayloadSignatureStatus(output, fingerprint, 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('Detached 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 detached 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('Payload requires an OpenPGP v4 binary SHA-256/384/512 signature with a full primary fingerprint.');
|
||||
if (!/^\d+$/.test(timestamp) || !/^\d+$/.test(expires) || (Number(expires) !== 0 && Number(expires) <= now)) throw Error('Detached signature has expired or invalid timestamps.');
|
||||
if (primary.toUpperCase() !== fingerprint.toUpperCase()) throw Error('Detached signature primary identity is not pinned.');
|
||||
return {signerFingerprint:signer.toUpperCase(), primaryFingerprint:primary.toUpperCase()};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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; }
|
||||
}
|
||||
+78
-25
@@ -1,29 +1,82 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {parsePublicKeys} from '../cf-worker/src/key-source.js';
|
||||
const manifest=JSON.parse(await readFile(new URL('../cf-worker/generated/manifest.json',import.meta.url),'utf8'));
|
||||
if (manifest.testFixture) throw Error('Refusing to deploy a test-fixture build. Run the production build with real server public keys.');
|
||||
if (!process.argv.includes('--remote')) { console.log(`Production release verified: ${manifest.revision}`); }
|
||||
else {
|
||||
const timeout=()=>AbortSignal.timeout(20000);
|
||||
const metadataResponse=await fetch(manifest.baseUrl+'/release.json',{cache:'no-store',signal:timeout()});
|
||||
if(!metadataResponse.ok) throw Error(`Metadata endpoint returned ${metadataResponse.status}`);
|
||||
const metadata=await metadataResponse.json();
|
||||
if(metadata.revision!==manifest.revision||metadata.testFixture) throw Error('Remote release does not match this build.');
|
||||
if(!/^[0-9a-f]{64}$/.test(metadata.sha256)) throw Error('Remote key digest is invalid.');
|
||||
if(manifest.needsFallback) {
|
||||
if(metadata.source?.type!=='fallback'||!manifest.fallbackSources.includes(metadata.source.url)||metadata.keyCount<1)throw Error('Unexpected effective fallback source.');
|
||||
} else if(metadata.sha256!==manifest.sha256) throw Error('Remote repository keys differ from this build.');
|
||||
const keysResponse=await fetch(manifest.baseUrl+'/ssh.keys',{cache:'no-store',signal:timeout()});
|
||||
if(!keysResponse.ok) throw Error(`Keys endpoint returned ${keysResponse.status}`);
|
||||
const keysText=await keysResponse.text();
|
||||
const match=/^# conf-sshd-v1\n# revision ([0-9a-f]{40})\n# key-count (0|[1-9][0-9]*)\n# allow-empty (yes|no)\n# sha256 ([0-9a-f]{64})\n# end-metadata\n([\s\S]*)$/.exec(keysText);
|
||||
if(!match||match[1]!==manifest.revision)throw Error('Remote key manifest invalid.');
|
||||
const [, , count, empty, hash, payload]=match;
|
||||
if(createHash('sha256').update(payload).digest('hex')!==hash) throw Error('Remote key body does not match its response digest.');
|
||||
const keys=await parsePublicKeys(payload);
|
||||
if(keys.length!==Number(count)||(!keys.length&&(!manifest.allowEmpty||empty!=='yes')))throw Error('Remote key count/empty policy invalid.');
|
||||
if(!manifest.needsFallback&&hash!==manifest.sha256)throw Error('Remote repository key payload differs from this build.');
|
||||
// Dynamic fallback may legitimately change between the two requests; each response is checked independently.
|
||||
console.log(`Remote code release verified: ${metadata.revision}; key payload ${hash} (${count} keys).`);
|
||||
import {parseSignedDocument, SIGNATURE_SEPARATOR, SIGNED_RELEASE_LIMITS, withoutKeyComments, validateSignature} from '../cf-worker/src/signed-release.js';
|
||||
import {checkSigningCertificate} from './signature-tools.mjs';
|
||||
const hash = text => createHash('sha256').update(text).digest('hex');
|
||||
const root = new URL('../', import.meta.url);
|
||||
|
||||
async function remoteText(url, maxBytes, {allowMissing = false, fetchImpl = fetch} = {}) {
|
||||
const signal = AbortSignal.timeout(20000);
|
||||
const response = await fetchImpl(url, {cache: 'no-store', redirect: 'error', signal});
|
||||
if (allowMissing && response.status === 404) { await response.body?.cancel(); return null; }
|
||||
if (response.status !== 200) { await response.body?.cancel(); throw Error(`Endpoint returned HTTP ${response.status}.`); }
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return '';
|
||||
let bytes = new Uint8Array(Math.min(16384,maxBytes)), size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > maxBytes) throw Error('Remote response exceeds its size limit.');
|
||||
if (size > bytes.length) { const grown = new Uint8Array(Math.min(maxBytes, Math.max(size, bytes.length * 2))); grown.set(bytes); bytes = grown; }
|
||||
bytes.set(value,size-value.byteLength);
|
||||
}
|
||||
return new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0,size));
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
}
|
||||
|
||||
export async function verifyRemote(manifest, fetchImpl = fetch) {
|
||||
const read = (path, maxBytes, allowMissing = false) => remoteText(manifest.baseUrl + path, maxBytes, {allowMissing, fetchImpl});
|
||||
const metadata = JSON.parse(await read('/release.json', 64 * 1024));
|
||||
if (metadata.revision !== manifest.revision || metadata.testFixture) throw Error('Remote release does not match this build.');
|
||||
if (!/^[0-9a-f]{64}$/.test(metadata.sha256)) throw Error('Remote key digest is invalid.');
|
||||
const dynamic = manifest.needsSource || manifest.needsFallback || manifest.keySource?.type === 'gist';
|
||||
if (dynamic) {
|
||||
const source = metadata.source;
|
||||
const acceptedFallback = source?.type === 'fallback' && manifest.fallbackSources.includes(source.url);
|
||||
const acceptedGist = manifest.keySource?.type === 'gist' && source?.type === 'gist' && source.id === manifest.keySource.id && source.ownerId === manifest.keySource.ownerId && /^[0-9a-f]{40}$/.test(source.gistRevision);
|
||||
if (!acceptedFallback && !acceptedGist) throw Error('Unexpected effective dynamic source.');
|
||||
} else if (metadata.sha256 !== manifest.sha256) throw Error('Remote repository keys differ from this build.');
|
||||
const document = await read('/ssh.keys', SIGNED_RELEASE_LIMITS.documentBytes);
|
||||
let count, payloadHash, cryptoNote = "";
|
||||
if (document.startsWith('# conf-sshd-signed-v1\n')) {
|
||||
const parsed = await parseSignedDocument(document, {listId: manifest.signedRelease?.listId, checkFreshness: false});
|
||||
count = parsed.keys.length;
|
||||
payloadHash = hash(parsed.keys.map(key => key.text + '\n').join(''));
|
||||
if (!dynamic && parsed.metadata.documentSha256 !== manifest.signedMetadata?.documentSha256) throw Error('Remote signed document differs from this build.');
|
||||
// Each detached signature must validate THIS exact document; an update race fails safely and can be retried.
|
||||
const signature = await read('/ssh.keys.sig', SIGNED_RELEASE_LIMITS.signatureBytes, true);
|
||||
if (manifest.signedRelease?.publicKeyFile) {
|
||||
if (signature === null) throw Error('Remote signature is missing.');
|
||||
const certificate = await read('/public-key.gpg', SIGNED_RELEASE_LIMITS.publicKeyBytes);
|
||||
await checkSigningCertificate(certificate, manifest.signedRelease.fingerprint, document + SIGNATURE_SEPARATOR + signature);
|
||||
} else {
|
||||
if ((manifest.signatureAvailable || manifest.clientDefaults?.gpgVerification === 'on') && signature === null) throw Error('Expected remote signature is missing.');
|
||||
if (signature !== null) validateSignature(signature);
|
||||
cryptoNote = ' OpenPGP not checked: no build verification certificate configured.';
|
||||
}
|
||||
} else {
|
||||
const match = /^# conf-sshd-v1\n# revision ([0-9a-f]{40})\n# key-count (0|[1-9][0-9]*)\n# allow-empty (yes|no)\n# sha256 ([0-9a-f]{64})\n# end-metadata\n([\s\S]*)$/.exec(document);
|
||||
if (!match || match[1] !== manifest.revision) throw Error('Remote key manifest invalid.');
|
||||
const [, , countText, empty, digest, payload] = match;
|
||||
if (hash(payload) !== digest) throw Error('Remote key body does not match its response digest.');
|
||||
const keys = await parsePublicKeys(payload);
|
||||
count = keys.length; payloadHash = digest;
|
||||
if (keys.length !== Number(countText) || (!keys.length && (!manifest.allowEmpty || empty !== 'yes')) || withoutKeyComments(keys).map(key => key.text + '\n').join('') !== payload) throw Error('Remote key count/empty/canonical policy invalid.');
|
||||
if (manifest.clientDefaults?.gpgVerification === 'on') throw Error('Verification-enabled clients require a signed-format document.');
|
||||
}
|
||||
if (!dynamic && payloadHash !== manifest.sha256) throw Error('Remote repository payload differs from this build.');
|
||||
// Dynamic sources may change between requests; build revision is independent of key sequence/digest.
|
||||
return `Remote code release checked: ${metadata.revision}; key payload ${payloadHash} (${count} keys).${cryptoNote}`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const manifest = JSON.parse(await readFile(new URL('cf-worker/generated/manifest.json', root), 'utf8'));
|
||||
if (manifest.testFixture) throw Error('Refusing to deploy a test-fixture build. Run the production build with real server public keys.');
|
||||
if (!process.argv.includes('--remote')) console.log(`Production release checked: ${manifest.revision}`);
|
||||
else console.log(await verifyRemote(manifest));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user