feat: pin client GPG trust and publish signed Git/Gist key releases

This commit is contained in:
2026-10-13 10:50:56 +08:00
parent 2f2f6db4d6
commit d72aa6eda7
28 changed files with 3187 additions and 427 deletions
+37
View File
@@ -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; }
}