mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-13 07:43:57 +00:00
test: exercise real WKD and HKPS discovery in isolated CI
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user