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:
@@ -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()};
|
||||
}
|
||||
Reference in New Issue
Block a user