Files
quickly-conf-sshd/scripts/test-live-discovery.mjs
T

179 lines
9.8 KiB
JavaScript

// Explicit external integration test. npm test imports this module but never
// invokes its network entry point. The public identity is a test-only fixture.
import {spawnSync} from 'node:child_process';
import {appendFileSync, chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const MAX_CERTIFICATE = 1024 * 1024;
const ATTEMPT_MS = 15_000;
// Deadline for starting/continuing network lookups. Each local verifier and
// private-agent cleanup also has its own timeout; CI supplies the hard job cap.
const NETWORK_BUDGET_MS = 120_000;
const MAX_ATTEMPTS = 2;
const fingerprint = value => typeof value === 'string' && /^[A-F0-9]{40}$/.test(value);
export function validateFixture(input) {
if (!input || typeof input !== 'object' || !input.testOnlyNotice?.includes('integration-test identity only') ||
typeof input.email !== 'string' || input.email.length > 254 || !/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$/.test(input.email) ||
!fingerprint(input.primaryFingerprint) || !fingerprint(input.signingSubkeyFingerprint) ||
input.primaryFingerprint === input.signingSubkeyFingerprint || !Array.isArray(input.keyservers) ||
input.keyservers.length < 1 || input.keyservers.length > 4 ||
input.keyservers.some(value => typeof value !== 'string' || !/^hkps:\/\/[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,5})?$/.test(value)) ||
new Set(input.keyservers).size !== input.keyservers.length) throw Error('Invalid public integration-test fixture.');
return input;
}
export function classifyFailure(result) {
if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGKILL') return 'timeout';
if (result.error?.code === 'ENOENT') return 'missing-tool';
if (result.error) return 'process-error';
const diagnostic = String(result.stderr ?? '').toLowerCase();
if (/no dirmngr|can.?t create socket|failed to start dirmngr|operation not permitted/.test(diagnostic)) return 'local-agent-unavailable';
if (/timed? out|timeout/.test(diagnostic)) return 'timeout';
if (/certificate.*(failed|invalid)|tls.*(failed|error)|not trusted/.test(diagnostic)) return 'transport-security-error';
if (/network is unreachable|no route to host|could not resolve|name or service not known|network error|connection refused/.test(diagnostic)) return 'network-unavailable';
if (/no data|not found|no public key/.test(diagnostic)) return 'key-not-found';
return 'gpg-error';
}
export function execute(command, args, {home, timeout = ATTEMPT_MS} = {}) {
return spawnSync(command, args, {
encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: MAX_CERTIFICATE + 64 * 1024,
env: {...process.env, LC_ALL: 'C', ...(home ? {HOME: home, GNUPGHOME: home} : {})},
});
}
// Extract just the production verifier functions. Never source or run the
// installer, its argument parser, dependencies installer, or SSH configuration.
export function verifierHarness(clientSource) {
const names = ['new_gpg_home', 'gpg_local', 'import_pinned_key'];
const functions = names.map(name => {
const matches = [...clientSource.matchAll(new RegExp(`^${name}\\(\\) \\{\\n[\\s\\S]*?^\\}\\n`, 'gm'))];
if (matches.length !== 1) throw Error(`Cannot extract client verifier function: ${name}.`);
return matches[0][0];
}).join('\n');
return `#!/bin/sh
set -eu
umask 077
LC_ALL=C
export LC_ALL
state=$1
certificate=$2
pinned_fingerprint=$3
pin_mode=$4
gpg_home=
temps=
fail() { printf 'client-certificate-rejected\\n' >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || fail; }
new_temp() { TEMP=$(mktemp "$1/verify.XXXXXX"); }
${functions}
import_pinned_key "$certificate"
printf '%s\\n' "$certificate_primary"
`;
}
export function validateCertificate(certificate, config, {
clientSource = readFileSync(join(repositoryRoot, 'conf-sshd.sh'), 'utf8'), runner = execute,
} = {}) {
if (typeof certificate !== 'string' || !certificate.length || Buffer.byteLength(certificate) > MAX_CERTIFICATE) return {ok: false, status: 'invalid-certificate-size'};
const home = mkdtempSync(join(tmpdir(), 'conf-sshd-certificate-check-'));
chmodSync(home, 0o700);
try {
const keyFile = join(home, 'public.asc'), harness = join(home, 'verify.sh');
writeFileSync(keyFile, certificate, {mode: 0o600});
writeFileSync(harness, verifierHarness(clientSource), {mode: 0o600});
for (const mode of ['primary', 'subkey']) {
// One clean home per pin mode, even though the function itself isolates its ring.
const state = mkdtempSync(join(home, `${mode}-`));
const pin = mode === 'primary' ? config.primaryFingerprint : config.signingSubkeyFingerprint;
const result = runner('/bin/sh', [harness, state, keyFile, pin, mode], {home: state, timeout: ATTEMPT_MS});
if (result.status !== 0 || result.error || result.stdout?.trim() !== config.primaryFingerprint) {
return {ok: false, status: result.error ? classifyFailure(result) : 'certificate-pin-or-capability-rejected', mode};
}
}
return {ok: true, status: 'validated-primary-and-subkey'};
} finally {
// The extracted verifier uses --no-autostart and a public-only keyring.
rmSync(home, {recursive: true, force: true});
}
}
export function discoveryProbes(config) {
return [
{source: 'wkd', queryMode: 'email', args: ['--auto-key-locate', 'clear,wkd', '--locate-external-keys', '--', config.email]},
...config.keyservers.map(server => ({
source: server, queryMode: 'primary',
args: ['--auto-key-locate', 'clear', '--keyserver', server, '--recv-keys', config.primaryFingerprint],
})),
];
}
export function runDiscovery(config, {runner = execute, validator = validateCertificate, now = Date.now} = {}) {
validateFixture(config);
const start = now(), deadline = start + NETWORK_BUDGET_MS, results = [];
for (const probe of discoveryProbes(config)) {
let status = 'budget-exhausted', attempts = 0;
for (; attempts < MAX_ATTEMPTS;) {
const remaining = deadline - now();
if (remaining <= 0) { status = 'budget-exhausted'; break; }
attempts++;
const home = mkdtempSync(join(tmpdir(), 'conf-sshd-live-discovery-'));
chmodSync(home, 0o700);
try {
const base = ['--batch', '--no-options', '--homedir', home, '--no-tty', '--no-auto-key-retrieve'];
const fetched = runner('gpg', [...base, '--status-fd', '1', ...probe.args], {home, timeout: Math.min(ATTEMPT_MS, remaining)});
if (fetched.status !== 0 || fetched.error) {
status = classifyFailure(fetched);
} else {
const exportRemaining = deadline - now();
if (exportRemaining <= 0) { status = 'budget-exhausted'; break; }
const exported = runner('gpg', [...base, '--no-autostart', '--auto-key-locate', 'clear', '--armor', '--export'], {home, timeout: Math.min(ATTEMPT_MS, exportRemaining)});
if (exported.status !== 0 || exported.error) status = `export-${classifyFailure(exported)}`;
else {
const validation = validator(exported.stdout, config);
status = validation.ok ? 'passed' : validation.status;
// A fetched but invalid certificate is a security failure, not a reason
// to hide the result by retrying until the server returns another key.
break;
}
}
} finally {
// Only services associated with this newly created private home are stopped.
runner('gpgconf', ['--homedir', home, '--kill', 'all'], {home, timeout: 5000});
rmSync(home, {recursive: true, force: true});
}
if (!['timeout', 'network-unavailable', 'key-not-found', 'gpg-error'].includes(status)) break;
}
results.push({source: probe.source, queryMode: probe.queryMode, status, attempts,
primaryFingerprint: config.primaryFingerprint, signingSubkeyFingerprint: config.signingSubkeyFingerprint});
}
return {schema: 'conf-sshd-live-discovery-v1', status: results.every(result => result.status === 'passed') ? 'passed' : 'failed', results};
}
export function summaryMarkdown(report) {
return ['## Public GPG discovery integration test', '',
`Overall: ${report.status}. Every source must pass; infrastructure failures are not skips.`, '',
...report.results.map(result => `- ${result.source} (${result.queryMode}): ${result.status}; attempts ${result.attempts}; primary ${result.primaryFingerprint ?? 'unavailable'}; signing subkey ${result.signingSubkeyFingerprint ?? 'unavailable'}`), '',
'This tests public certificate discovery and the client pin/capability checks. No user-signed key-list payload is tested, and no SSH or persistent user trust settings are changed.', ''].join('\n');
}
export function main() {
let report;
try {
const config = validateFixture(JSON.parse(readFileSync(join(repositoryRoot, 'tests/fixtures/live-discovery.json'), 'utf8')));
const missing = ['gpg', 'dirmngr', 'gpgconf'].find(command => execute(command, ['--version'], {timeout: 5000}).status !== 0);
report = missing ? {schema: 'conf-sshd-live-discovery-v1', status: 'failed', results: [{source: 'local-tools', queryMode: 'none', status: 'missing-tool', attempts: 0,
primaryFingerprint: config.primaryFingerprint, signingSubkeyFingerprint: config.signingSubkeyFingerprint}]} : runDiscovery(config);
} catch {
report = {schema: 'conf-sshd-live-discovery-v1', status: 'failed', results: [{source: 'local-harness', queryMode: 'none', status: 'fixture-or-harness-error', attempts: 0}]};
}
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryMarkdown(report));
return report.status === 'passed' ? 0 : 1;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main();