release: the Linux / Windows updater signatures are made on the VDS (desktop-release add-remote, vds-key; tools/vds-sign.ts); the Mac no longer downloads those installers, verifies each returned .sig over its BLAKE2b-512 digest before publishing; release.ts --sign-local keeps the old path

This commit is contained in:
CRTkafa 2026-10-21 03:14:53 +07:00
parent d94bf67340
commit ef3c66e8f0
6 changed files with 444 additions and 51 deletions

View file

@ -16,6 +16,12 @@
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and
// evidence-<linux|windows>-<version>.json (the run and commit they came from; a commit other than the one of the
// folder's other evidence is refused: every artifact of a version comes from one commit).
// bun tools/desktop-release.ts add-remote --out <dir> --host <user@host> --assets <json> [--commit <sha>] [--run <url>] [--code-signed]
// The default since 21 Oct 2026: the VDS fetches the Linux / Windows files of the Forgejo release into its incoming
// folder, checks their SHA-256, signs them with its copy of the updater key (tools/vds-sign.ts); only the .sig
// texts come back and are verified here. The files stay on the VDS (.remote.json); publish checks them there.
// bun tools/desktop-release.ts vds-key [--host root@<host>]
// Installs or rotates the updater key in /root/.storebound-keys on the VDS and verifies a test signature here.
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta|rehearsal]
// [--forgejo-tag v<version>] [--yes]
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
@ -45,6 +51,7 @@ import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
import { versions } from './desktop-version';
import { listReleases, upsert as forgejoUpsert } from './forgejo-release';
import { isMacArtifact, scanSignedMac } from './mac-signature-scan';
import { parseSigLines, REMOTE_KEY, REMOTE_KEY_PASSWORD, REMOTE_KEYS, type RemoteItem, remoteSigProblems, remoteSignScript, signCommand, sshScript, verifyDigest } from './vds-sign';
import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan';
export const ROOT = resolve(import.meta.dir, '..');
@ -248,6 +255,15 @@ function privacyGate(paths: string[]): { files: number; bytes: number; hits: num
return { files: r.files, bytes: r.bytes, hits: 0 };
}
/** Files of the version folder that live only on the VDS (signed there, tools/vds-sign.ts): `.remote.json`, never
* uploaded. Their SHA-256 was checked on the VDS against the candidate's record; BLAKE2b-512 is what was signed. */
export interface RemoteFile { file: string; sha256: string; size: number; blake2b: string }
const REMOTE_JSON = '.remote.json';
function readRemote(out: string): RemoteFile[] {
const p = join(out, REMOTE_JSON);
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as RemoteFile[] : [];
}
function readManifest(out: string): Manifest | null {
const p = join(out, 'latest.json');
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Manifest : null;
@ -258,8 +274,9 @@ function writeVersionFiles(out: string, manifest: Manifest): void {
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
writeFileSync(join(out, 'latest.json.part'), `${JSON.stringify(manifest, null, 2)}\n`);
renameSync(join(out, 'latest.json.part'), join(out, 'latest.json'));
const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS');
writeFileSync(join(out, 'SHA256SUMS'), sumsText(files.map(file => ({ file, sha256: sha256(join(out, file)) }))));
const remote = readRemote(out);
const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS' && !remote.some(r => r.file === n));
writeFileSync(join(out, 'SHA256SUMS'), sumsText([...files.map(file => ({ file, sha256: sha256(join(out, file)) })), ...remote.map(r => ({ file: r.file, sha256: r.sha256 }))]));
}
function gitState(): { commit: string; dirty: boolean } {
@ -643,90 +660,187 @@ export function commitProblems(commit: string | undefined, evidence: Record<stri
return Object.entries(evidence).filter(([, c]) => c && c !== commit).map(([f, c]) => `${f} says commit ${c!.slice(0, 10)}, these files are from ${commit.slice(0, 10)}`);
}
function add(argv: string[]): void {
/** The Linux / Windows artifact name of a workflow file (`_<version>_` in it, kind from the extension). */
function addedName(n: string, version: string, codeSigned: boolean): string {
if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`);
const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null;
if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`);
return artifactName(version, kind, kind === 'nsis' ? codeSigned : true);
}
function addPrelude(argv: string[]) {
const a = args(argv);
const { version, drift } = versions();
if (!version || drift.length) fail('desktop version drift (bun tools/desktop-version.ts)');
const out = resolve(a.get('out') ?? join(homedir(), '.storebound-releases', 'desktop', version));
const files = a.rest.slice(1);
if (!files.length) fail('usage: add --out <dir> <setup.exe | .AppImage | .deb ...>');
mkdirSync(out, { recursive: true });
const commit = a.get('commit');
const otherEvidence = Object.fromEntries(readdirSync(out).filter(n => /^evidence-.*\.json$/.test(n))
.map(n => [n, (JSON.parse(readFileSync(join(out, n), 'utf8')) as { commit?: string }).commit]));
const commitBad = commitProblems(commit, otherEvidence);
if (commitBad.length) fail(`not one commit: ${commitBad.join('; ')}`);
const codeSigned = a.has('code-signed');
return { a, version, out, commit, codeSigned: a.has('code-signed') };
}
interface Added { name: string; from: string; sha256: string; size: number; sig: string }
/** latest.json, SHA256SUMS and evidence-<platform>-<version>.json for added Linux / Windows files. */
function recordAdded(p: ReturnType<typeof addPrelude>, added: Added[], checks: Record<string, unknown>): void {
const { a, version, out, commit, codeSigned } = p;
const platforms: Manifest['platforms'] = {};
const downloads: Partial<Record<DownloadKey, Download>> = {};
const added: string[] = [];
const sources: Record<string, string> = {};
for (const f of files.map(x => resolve(x))) {
const n = basename(f);
if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`);
const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null;
if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`);
const name = artifactName(version, kind, kind === 'nsis' ? codeSigned : true);
const dest = join(out, name);
copyFileSync(f, dest);
added.push(dest);
sources[name] = n;
}
const privacy = privacyGate(added);
for (const dest of added) {
const name = basename(dest);
const sig = signFile(dest);
const d: Download = { file: name, url: releaseUrl(version, name), sha256: sha256(dest), size: statSync(dest).size, signature: sig, codeSigned: name.endsWith('.exe') ? codeSigned : false };
if (name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: sig, url: d.url }; }
else if (name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: sig, url: d.url }; }
for (const x of added) {
const d: Download = { file: x.name, url: releaseUrl(version, x.name), sha256: x.sha256, size: x.size, signature: x.sig, codeSigned: x.name.endsWith('.exe') ? codeSigned : false };
if (x.name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: x.sig, url: d.url }; }
else if (x.name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: x.sig, url: d.url }; }
else downloads['linux-x86_64-deb'] = d;
say(`added ${name} (${mb(d.size)})`);
say(`added ${x.name} (${mb(x.size)})`);
}
const existing = readManifest(out);
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
writeVersionFiles(out, mergeManifest(existing, version, a.get('notes') ?? existing?.notes ?? `storebound desktop ${version}`, now, { platforms, downloads }));
// evidence per platform (stays local, like the macOS one): where the files came from and what was checked here
for (const platform of ['linux', 'windows'] as const) {
const mine = added.filter(d => (platform === 'windows') === d.endsWith('.exe'));
const mine = added.filter(d => (platform === 'windows') === d.name.endsWith('.exe'));
if (!mine.length) continue;
const evidence = {
product: 'storebound desktop', version, platform: `${platform}-x86_64`, addedAt: now, commit: commit ?? null, run: a.get('run') ?? null,
checks: { privacy, updaterSignature: 'minisign, verified against tauri.conf.json', codeSigned: platform === 'windows' ? codeSigned : false },
artifacts: mine.map(d => ({ file: basename(d), from: sources[basename(d)], size: statSync(d).size, sha256: sha256(d) })),
checks: { ...checks, codeSigned: platform === 'windows' ? codeSigned : false },
artifacts: mine.map(d => ({ file: d.name, from: d.from, size: d.size, sha256: d.sha256 })),
};
writeFileSync(join(out, `evidence-${platform}-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`);
}
say(`latest.json now has ${Object.keys(readManifest(out)!.platforms).join(', ')}${commit ? `; evidence: commit ${commit.slice(0, 10)}` : ''}`);
}
/** The fallback (--sign-local in tools/release.ts): the files are on this Mac and signed with ~/.storebound-keys. */
function add(argv: string[]): void {
const p = addPrelude(argv);
const files = p.a.rest.slice(1);
if (!files.length) fail('usage: add --out <dir> <setup.exe | .AppImage | .deb ...>');
const copied: { dest: string; from: string }[] = [];
for (const f of files.map(x => resolve(x))) {
const dest = join(p.out, addedName(basename(f), p.version, p.codeSigned));
copyFileSync(f, dest);
copied.push({ dest, from: basename(f) });
}
const privacy = privacyGate(copied.map(c => c.dest));
const added = copied.map(c => ({ name: basename(c.dest), from: c.from, sha256: sha256(c.dest), size: statSync(c.dest).size, sig: signFile(c.dest) }));
recordAdded(p, added, { privacy, updaterSignature: 'minisign on the Mac, verified against tauri.conf.json' });
}
/** The VDS signs (owner's decision 21 Oct 2026): `add-remote --out <dir> --host <user@host> --assets <json>` with
* [{name, url, sha256, size}] of the Forgejo release. The VDS fetches each file into its incoming folder, checks
* the SHA-256 and signs it; only the .sig texts come back, each verified here before anything is written. */
async function addRemote(argv: string[]): Promise<void> {
const p = addPrelude(argv);
const host = p.a.get('host') ?? fail('--host <user@host> is required');
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
const assets = JSON.parse(readFileSync(p.a.get('assets') ?? fail('--assets <json file> is required'), 'utf8')) as { name: string; url: string; sha256: string; size: number }[];
if (!assets.length) fail('--assets: no files');
const items: (RemoteItem & { from: string; size: number })[] = assets.map(x => ({ file: addedName(x.name, p.version, p.codeSigned), url: x.url, sha256: x.sha256, from: x.name, size: x.size }));
const dir = `${REMOTE_INCOMING}/${p.version}`;
say(`VDS ${host.split('@')[1]}: fetch ${items.length} files from Forgejo into ${dir}, check SHA-256, sign with ${REMOTE_KEY}`);
const outText = await sshScript(host, remoteSignScript(dir, items), { timeoutMs: 30 * 60_000, what: 'VDS fetch + sign', log: say }).catch((e: Error) => fail(e.message));
const got = parseSigLines(outText);
const problems = remoteSigProblems(items.map(i => i.file), got, confPubkey());
if (problems.length) fail(`signatures from the VDS refused, nothing written: ${problems.join('; ')}`);
const remote: RemoteFile[] = [];
const added: Added[] = items.map(i => {
const g = got.find(x => x.file === i.file)!;
if (i.size && g.size !== i.size) fail(`${i.file}: ${g.size} bytes on the VDS, the release says ${i.size}`);
remote.push({ file: i.file, sha256: i.sha256, size: g.size, blake2b: g.blake2b });
writeFileSync(join(p.out, `${i.file}.sig`), g.signature);
rmSync(join(p.out, i.file), { force: true }); // a stale local copy from a --sign-local run never wins
return { name: i.file, from: i.from, sha256: i.sha256, size: g.size, sig: g.signature };
});
const prev = readRemote(p.out).filter(r => !remote.some(x => x.file === r.file));
writeFileSync(join(p.out, REMOTE_JSON), `${JSON.stringify([...prev, ...remote], null, 2)}\n`);
say(`${added.length} signatures from the VDS verified against tauri.conf.json's key`);
recordAdded(p, added, { privacy: 'scanned in the build jobs (ci.yml, private terms)', updaterSignature: 'minisign on the VDS, verified on the Mac against tauri.conf.json (BLAKE2b-512 digest)', sha256: 'checked on the VDS against the candidate record' });
}
/** `vds-key --host <user@host>`: installs or updates the updater key (and its password file, if
* ~/.storebound-keys/updater.key.password exists) in /root/.storebound-keys on the VDS (dir 0700, files 0600, root),
* over ssh stdin (never printed, never on a command line), then signs a test string there and verifies it here. */
async function vdsKey(argv: string[]): Promise<void> {
const a = args(argv);
const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? (existsSync(join(KEYS_DIR, 'publish-host')) ? readFileSync(join(KEYS_DIR, 'publish-host'), 'utf8').trim() : '');
if (!/^root@[A-Za-z0-9.-]+$/.test(host)) fail(`the VDS host must be root@<host> (--host, STOREBOUND_PUBLISH_HOST or ~/.storebound-keys/publish-host), got ${host || 'nothing'}`);
const key = updaterKey();
const keyB64 = readFileSync(key).toString('base64');
const pwFile = `${key}.password`;
const pwB64 = existsSync(pwFile) ? readFileSync(pwFile).toString('base64') : '';
const test = `storebound-vds-key-check-${Date.now()}`;
const t = `/tmp/sb-key-check-${process.pid}`;
const script = [
'set -euo pipefail', 'umask 077',
`install -d -m 700 -o root -g root ${REMOTE_KEYS}`,
`base64 -d > ${REMOTE_KEY}.new <<'K'`, keyB64, 'K',
`chmod 600 ${REMOTE_KEY}.new && chown root:root ${REMOTE_KEY}.new && mv -f ${REMOTE_KEY}.new ${REMOTE_KEY}`,
pwB64 ? `base64 -d > ${REMOTE_KEY_PASSWORD}.new <<'K'
${pwB64}
K
chmod 600 ${REMOTE_KEY_PASSWORD}.new && mv -f ${REMOTE_KEY_PASSWORD}.new ${REMOTE_KEY_PASSWORD}` : `rm -f ${REMOTE_KEY_PASSWORD}`,
`chmod 700 ${REMOTE_KEYS}`,
`mkdir -p ${t} && printf '%s' '${test}' > ${t}/check.txt`,
'echo "signing a test string (the first run downloads the tauri CLI)"',
`${signCommand(`${t}/check.txt`)}`,
`printf 'SIG %s %s %s %s\n' check.txt "$(b2sum -l 512 ${t}/check.txt | cut -d' ' -f1)" "$(stat -c %s ${t}/check.txt)" "$(tr -d '\n' < ${t}/check.txt.sig)"`,
`rm -rf ${t}`,
`stat -c 'MODE %a %U %n' ${REMOTE_KEYS} ${REMOTE_KEYS}/*`,
].join('\n');
say(`installing the updater key on ${host.split('@')[1]}:${REMOTE_KEYS} (one ssh connection)`);
const out = await sshScript(host, `${script}\n`, { timeoutMs: 10 * 60_000, what: 'vds-key', log: say }).catch((e: Error) => fail(e.message));
const modes = out.split('\n').filter(l => l.startsWith('MODE '));
const bad = modes.filter(l => !/^MODE (700|600) root /.test(l));
if (!modes.length || bad.length) fail(`unexpected modes on the VDS: ${bad.join('; ') || 'none read'}`);
const [sig] = parseSigLines(out);
const blake = createHash('blake2b512').update(test).digest('hex');
if (!sig || sig.blake2b !== blake) fail('the VDS did not sign the test string');
const v = verifyDigest(blake, sig.signature, confPubkey(), 'check.txt');
if (!v.ok) fail(`the VDS's test signature does not verify against tauri.conf.json: ${v.reason}`);
say(`ok: ${modes.length} paths 0700/0600 root; the VDS signs with the key of tauri.conf.json (test string verified here)`);
}
// ---------------------------------------------------------------------------------------------------------------------
// verify / publish
function verifyFolder(out: string): { manifest: Manifest; files: string[] } {
function verifyFolder(out: string): { manifest: Manifest; files: string[]; remote: RemoteFile[] } {
if (!existsSync(join(out, 'latest.json')) || !existsSync(join(out, 'SHA256SUMS'))) fail(`${out}: no latest.json / SHA256SUMS`);
const manifest = readManifest(out)!;
const problems = manifestProblems(manifest);
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
const sums = readFileSync(join(out, 'SHA256SUMS'), 'utf8').trim().split('\n').map(l => { const [h, f] = l.split(/\s+/); return { h: h!, f: f! }; });
for (const { h, f } of sums) if (!existsSync(join(out, f)) || sha256(join(out, f)) !== h) fail(`SHA256SUMS: ${f} does not match`);
const files = readdirSync(out).filter(uploadable);
const remote = readRemote(out);
const remoteOf = (f: string) => remote.find(r => r.file === f);
for (const { h, f } of sums) {
const r = remoteOf(f);
if (r ? r.sha256 !== h : (!existsSync(join(out, f)) || sha256(join(out, f)) !== h)) fail(`SHA256SUMS: ${f} does not match`);
}
const files = [...new Set([...readdirSync(out).filter(uploadable), ...remote.map(r => r.file)])].sort();
for (const f of files) if (f !== 'SHA256SUMS' && !sums.some(s => s.f === f)) fail(`${f} is not in SHA256SUMS`);
const pub = confPubkey();
const check = (url: string, signature: string, what: string) => {
const file = url.split('/').pop()!;
if (!url.startsWith(releaseUrl(manifest.version, ''))) fail(`${what}: ${url} is not in /desktop/dl/${manifest.version}/`);
if (!files.includes(file)) fail(`${what}: ${file} is not in the folder`);
const v = verifySig(readFileSync(join(out, file)), signature, pub);
const r = remoteOf(file);
const v = r ? verifyDigest(r.blake2b, signature, pub, file) : verifySig(readFileSync(join(out, file)), signature, pub);
if (!v.ok) fail(`${what}: ${file}: ${v.reason}`);
};
for (const [k, p] of Object.entries(manifest.platforms)) check(p.url, p.signature, `platforms.${k}`);
for (const [k, d] of Object.entries(manifest.downloads ?? {})) {
check(d!.url, d!.signature, `downloads.${k}`);
if (sha256(join(out, d!.file)) !== d!.sha256 || statSync(join(out, d!.file)).size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`);
const r = remoteOf(d!.file);
const [h, size] = r ? [r.sha256, r.size] : [sha256(join(out, d!.file)), statSync(join(out, d!.file)).size];
if (h !== d!.sha256 || size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`);
}
privacyGate(files.filter(f => !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f)));
// the VDS-signed files were scanned in their build jobs (ci.yml, private terms) and are not on this Mac
privacyGate(files.filter(f => !remoteOf(f) && !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f)));
if (remote.length) say(`on the VDS only (signed there, SHA-256 checked there against the candidate): ${remote.map(r => r.file).join(', ')}`);
say(`verified ${manifest.version}: ${files.length} files, platforms ${Object.keys(manifest.platforms).join(', ')}, downloads ${Object.keys(manifest.downloads ?? {}).join(', ')}`);
return { manifest, files };
return { manifest, files, remote };
}
/** The npm CLI and the desktop app are one release: the folder must be the version of this checkout's package.json
@ -781,7 +895,9 @@ async function publish(argv: string[]): Promise<void> {
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
const channel = a.get('channel') ?? 'stable';
if (!(CHANNELS as readonly string[]).includes(channel)) fail(`--channel ${CHANNELS.join(', ')}`);
const { manifest, files } = verifyFolder(out);
const { manifest, files: all, remote } = verifyFolder(out);
// files signed on the VDS are already in its incoming folder: never rsynced or seeded, only checked there
const files = all.filter(f => !remote.some(r => r.file === f));
const v = manifest.version;
const together = releaseTogetherProblems(v, versions().version);
if (together.length) fail(together.join('; '));
@ -803,6 +919,7 @@ async function publish(argv: string[]): Promise<void> {
if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`);
console.log('\nsteps:');
for (const s of steps) console.log(` ${s[0]} ${s.slice(1).map(x => (x.includes(' ') ? `"${x}"` : x.replace(out, '<out>'))).join(' ')}`);
if (remote.length) console.log(` already on the server (signed there), SHA-256 checked again: ${remote.map(r => r.file).join(', ')}`);
if (seed.length) console.log(` the server fetches from Forgejo ${forgejoTag}: ${seed.map(x => x.file).join(', ')}`);
if (macFiles.length) console.log(` then attaches to Forgejo ${forgejoTag} from its copy: ${macFiles.join(', ')}`);
if (!a.has('yes')) { console.log('\ndry run: nothing uploaded. Every installed app on this channel offers the update once latest.json is live; run again with --yes to publish.'); return; }
@ -816,6 +933,12 @@ async function publish(argv: string[]): Promise<void> {
// the script goes to bash on stdin: the server's login shell may be zsh, which does not split "$pids"
run(['ssh', host, 'bash', '-s'], { input: `pids=''\n${lines.map(l => `(${l}) & pids="$pids $!"`).join('\n')}\nfor p in $pids; do wait $p || exit 1; done\n`, stdio: ['pipe', 'inherit', 'inherit'] });
}
if (remote.length) {
const dir = `${REMOTE_INCOMING}/${v}`;
say(`server: SHA-256 of the ${remote.length} files signed there`);
await sshScript(host, `set -euo pipefail\n${remote.map(r => `echo '${r.sha256} ${dir}/${r.file}' | sha256sum -c --quiet || { echo "${r.file} on the server is not the signed file"; exit 1; }`).join('\n')}\n`, { timeoutMs: 5 * 60_000, what: 'remote SHA-256 check', log: say })
.catch((e: Error) => fail(e.message));
}
say(`rsync (only what the server does not have yet) ...`);
run(rsync, { stdio: ['ignore', 'inherit', 'inherit'] });
if (macFiles.length) {
@ -860,6 +983,8 @@ if (import.meta.main) {
const cmd = argv[0];
if (cmd === 'mac') await mac(argv.slice(1));
else if (cmd === 'add') add(argv);
else if (cmd === 'add-remote') await addRemote(argv);
else if (cmd === 'vds-key') await vdsKey(argv);
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>')));
else if (cmd === 'publish') await publish(argv.slice(1));
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
@ -870,6 +995,6 @@ if (import.meta.main) {
const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' });
process.exit(r.status ?? 1);
}
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 30).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 40).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
}

View file

@ -7,6 +7,7 @@
// rehearsal/<n>: a prerelease on git.crt.fyi and the rehearsal
// channel of storebound.dev (no app reads it), never npm and never
// /Applications; everything it made is deleted at the end
// bun tools/release.ts <version> [--sign-local] fallback: download the Linux / Windows installers and sign here
// bun tools/release.ts <version> --resume --yes after a stop past the push: HEAD is origin/main, tagged v<version>,
// with that version; skips the bump and continues from step 4
// bun tools/release.ts npm-token once: the npm granular token from the clipboard to ~/.storebound-keys
@ -22,8 +23,10 @@
// 4. at once, in parallel: the Mac build (tools/desktop-release.ts mac: universal app, sign, notarize app and DMG,
// every check), Forgejo's ci run of the bump commit (version-only: the installers only, the parent's CI and
// matrix count) and the promotion of the tag (release.yml); this waits for the candidate (heartbeat, timeout)
// and downloads its Linux / Windows installers while the Mac builds;
// 5. add (updater signatures of the Linux / Windows files), the full acceptance matrix (9 runs), verify;
// (with --sign-local also downloads its Linux / Windows installers while the Mac builds);
// 5. add-remote: the VDS fetches the Linux / Windows files of the release, checks their SHA-256 and signs them with
// its copy of the updater key; only the .sig texts come back and are verified here (--sign-local: the old path,
// the files downloaded to this Mac in step 4 and signed here); the acceptance matrix (TESTS), verify;
// 6. publish: the VDS fetches the Linux / Windows files from Forgejo itself, rsync sends only the Mac's files,
// the VDS attaches them to the Forgejo release from its copy, then storebound.dev's latest.json;
// 7. /Applications/storebound.app: the running app is quit (gracefully, then its exact PID), the new one copied
@ -55,6 +58,18 @@ const TESTS = false;
// ---------------------------------------------------------------------------------------------------------------------
// Pure parts (tests/release.test.ts)
/** The Linux / Windows assets of the promoted release that the VDS signs, with the SHA-256 of the candidate's record
* (green.json). An installer without a recorded hash is refused: the VDS checks every byte against it. */
export function vdsSignAssets(assets: { name: string; size: number; browser_download_url: string }[], recorded: Record<string, string>): { name: string; url: string; sha256: string; size: number }[] {
const mine = assets.filter(a => /\.(AppImage|deb|exe)$/.test(a.name));
if (!mine.length) throw new Error('the release has no Linux / Windows installers');
return mine.map(a => {
const sha256 = recorded[a.name];
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`${a.name}: no SHA-256 in the candidate's record`);
return { name: a.name, url: a.browser_download_url, sha256, size: a.size };
}).sort((x, y) => x.name.localeCompare(y.name));
}
/** The CHANGELOG with its Unreleased section turned into the version's section and a new empty Unreleased on top. */
export function cutChangelog(text: string, version: string, date: string): string {
const lines = text.split('\n');
@ -234,6 +249,7 @@ async function main(): Promise<void> {
if (argv[0] === 'mac-acceptance') { const key = cliKey(ROOT); console.log(await macAcceptance(key, join(RELEASES, 'logs', `acceptance-${key}`))); return; }
const rehearsal = has('--rehearsal');
const resume = has('--resume');
const signLocal = has('--sign-local');
if (resume && rehearsal) fail('--resume is for a real version');
const positional = argv.filter(a => !a.startsWith('--'));
const pkgVersion = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!;
@ -342,15 +358,28 @@ async function main(): Promise<void> {
const s = await waitGreen(sha, 45);
return s;
});
const fetchP = candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl)));
// the Linux / Windows installers stay off this Mac: the VDS fetches and signs them (owner, 21 Oct 2026); only
// --sign-local (the fallback) downloads them here to sign with ~/.storebound-keys
const fetchP = signLocal ? candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl)))
: TESTS ? candP.then(s => phase('download acceptance runs', () => fetchCandidate(s, dl, /^acceptance-.*\.json$/))) : Promise.resolve([]);
const relP = candP.then(() => phase('Forgejo: release.yml promotes the tag', () => waitRelease(tag, 15)));
await Promise.all([macP, fetchP, relP, accP]);
const cand = await candP;
// ---------------------------------------------------------------- 5. add, acceptance matrix, verify
await phase('add Linux / Windows + acceptance matrix + verify', async () => {
const files = readdirSync(dl).filter(f => /\.(AppImage|deb|exe)$/.test(f)).map(f => join(dl, f));
sh(['bun', 'tools/desktop-release.ts', 'add', '--out', out, '--commit', sha, '--run', cand.record.run, ...files]);
if (signLocal) {
const files = readdirSync(dl).filter(f => /\.(AppImage|deb|exe)$/.test(f)).map(f => join(dl, f));
sh(['bun', 'tools/desktop-release.ts', 'add', '--out', out, '--commit', sha, '--run', cand.record.run, ...files]);
} else {
const rel = (await listReleases()).find(r => r.tag_name === tag) ?? fail(`no Forgejo release ${tag}`);
const plan = vdsSignAssets(rel.assets, cand.record.files);
const assetsFile = join(logDir, 'vds-sign-assets.json');
mkdirSync(logDir, { recursive: true });
writeFileSync(assetsFile, `${JSON.stringify(plan, null, 2)}\n`);
const code = await bg('VDS sign', ['bun', 'tools/desktop-release.ts', 'add-remote', '--out', out, '--host', host, '--assets', assetsFile, '--commit', sha, '--run', cand.record.run], join(logDir, 'add-remote.log'), { timeoutMs: 35 * 60_000 });
if (code !== 0) fail(`VDS signing failed: ${join(logDir, 'add-remote.log').replace(HOME, '~')} (fallback: --resume --sign-local)`);
}
if (TESTS) {
const accDir = mkdtempSync(join(tmpdir(), 'sb-acc-'));
for (const f of readdirSync(dl).filter(f => /^acceptance-.*\.json$/.test(f))) writeFileSync(join(accDir, f), readFileSync(join(dl, f)));

123
tools/vds-sign.ts Normal file
View file

@ -0,0 +1,123 @@
// Updater signatures of the Linux / Windows installers made on the VDS (the owner's decision, 21 Oct 2026): the files
// are already there (the VDS fetches them from the Forgejo release over its own network), so the Mac never downloads
// them just to sign. Only the small .sig texts come back; each is checked here against tauri.conf.json's key over the
// BLAKE2b-512 digest the VDS reports, and the file's SHA-256 is checked on the VDS against the candidate's record.
// The key lives on the VDS in /root/.storebound-keys/ (dir 0700, files 0600): `bun tools/desktop-release.ts vds-key`.
// Pure parts are tested in tests/vds-sign.test.ts; the runner spawns ssh with a timeout and a heartbeat.
import { spawn } from 'node:child_process';
import { createPublicKey, verify as edVerify } from 'node:crypto';
import { parsePublicKey, parseSignature, type VerifyResult } from '../cli/lib/minisign';
export const REMOTE_KEYS = '/root/.storebound-keys';
export const REMOTE_KEY = `${REMOTE_KEYS}/updater.key`;
/** Optional: the key's password (one line). Without the file the password is empty (how updater.key was made). */
export const REMOTE_KEY_PASSWORD = `${REMOTE_KEYS}/updater.key.password`;
/** The tauri signer the VDS runs (bun is on the host): pinned to the CLI version of apps/desktop. */
export const TAURI_CLI = '@tauri-apps/cli@2.12.1';
export interface RemoteItem { file: string; url: string; sha256: string }
export interface RemoteSig { file: string; blake2b: string; size: number; signature: string }
const SAFE_FILE = /^[A-Za-z0-9._-]+$/;
const SAFE_DIR = /^\/[A-Za-z0-9._/-]+$/;
const SAFE_URL = /^https:\/\/[A-Za-z0-9./_%:-]+$/;
/** The sign command of one file on the VDS (pure): password from the optional file, never on the command line;
* stdin from /dev/null so the signer can never wait for a prompt. */
export function signCommand(path: string): string {
if (!SAFE_DIR.test(path)) throw new Error(`unsafe path ${path}`);
return `TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat ${REMOTE_KEY_PASSWORD} 2>/dev/null || true)" timeout 300 bunx ${TAURI_CLI} signer sign -f ${REMOTE_KEY} '${path}' </dev/null >/dev/null`;
}
/** One SIG line per file: name, BLAKE2b-512 hex, size, the .sig text (tauri's one-line base64). */
const sigLine = (path: string, file: string) =>
`printf 'SIG %s %s %s %s\\n' '${file}' "$(b2sum -l 512 '${path}' | cut -d' ' -f1)" "$(stat -c %s '${path}')" "$(tr -d '\\n' < '${path}.sig')"`;
/**
* The bash script for `ssh host bash -s` (pure): fetch every file into `dir` in parallel (skipped when it is already
* there with the right SHA-256), check its SHA-256, sign it with the VDS's key and print SIG lines. Any failure exits
* non-zero with a line that says which file.
*/
export function remoteSignScript(dir: string, items: RemoteItem[]): string {
if (!SAFE_DIR.test(dir)) throw new Error(`unsafe dir ${dir}`);
if (!items.length) throw new Error('nothing to sign');
for (const x of items) {
if (!SAFE_FILE.test(x.file)) throw new Error(`unsafe file name ${x.file}`);
if (!SAFE_URL.test(x.url)) throw new Error(`unexpected url ${x.url}`);
if (!/^[0-9a-f]{64}$/.test(x.sha256)) throw new Error(`${x.file}: not a SHA-256`);
}
const lines = ['set -euo pipefail', `test -s ${REMOTE_KEY} || { echo "no updater key at ${REMOTE_KEY} (bun tools/desktop-release.ts vds-key)"; exit 3; }`,
`mkdir -p '${dir}'`, `cd '${dir}'`, "pids=''"];
for (const x of items) {
const p = `${dir}/${x.file}`;
lines.push(`( if ! echo '${x.sha256} ${p}' | sha256sum -c --quiet 2>/dev/null; then curl -fsSL --retry 2 --connect-timeout 20 --max-time 900 -o '${p}.part' '${x.url}' && mv -f '${p}.part' '${p}'; fi; echo '${x.sha256} ${p}' | sha256sum -c --quiet || { echo "FAIL ${x.file}: SHA-256 differs from the candidate's record"; exit 1; }; echo "fetched ${x.file}" ) & pids="$pids $!"`);
}
lines.push('for p in $pids; do wait $p || { echo "FAIL a download"; exit 1; }; done');
for (const x of items) {
const p = `${dir}/${x.file}`;
lines.push(`echo "signing ${x.file}"`, `rm -f '${p}.sig'`, `${signCommand(p)} || { echo "FAIL ${x.file}: the signer failed"; exit 1; }`, sigLine(p, x.file));
}
return `${lines.join('\n')}\n`;
}
/** The SIG lines of the script's output (pure). Other lines are progress. */
export function parseSigLines(out: string): RemoteSig[] {
const res: RemoteSig[] = [];
for (const l of out.split('\n')) {
const m = /^SIG (\S+) ([0-9a-f]{128}) (\d+) ([A-Za-z0-9+/]+={0,2})$/.exec(l.trim());
if (m) res.push({ file: m[1]!, blake2b: m[2]!, size: Number(m[3]), signature: m[4]! });
}
return res;
}
/** A minisign "ED" signature over a BLAKE2b-512 digest (pure): the same check as cli/lib/minisign verify, without the
* file's bytes. The trusted comment must name the file (tauri writes `file:<name>`). */
export function verifyDigest(blake2bHex: string, signature: string, publicKey: string, file?: string): VerifyResult {
try {
const pk = parsePublicKey(publicKey); const s = parseSignature(signature);
if (s.alg !== 'ED') return { ok: false, reason: `algorithm ${s.alg}, expected ED (prehashed)` };
if (pk.keyId !== s.keyId) return { ok: false, reason: `signed with key ${s.keyId}, expected ${pk.keyId}` };
if (!/^[0-9a-f]{128}$/.test(blake2bHex)) return { ok: false, reason: 'not a BLAKE2b-512 digest' };
const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: pk.key.toString('base64url') }, format: 'jwk' });
if (!edVerify(null, Buffer.from(blake2bHex, 'hex'), key, s.sig)) return { ok: false, reason: 'the signature does not match the file' };
if (!edVerify(null, Buffer.concat([s.sig, Buffer.from(s.trustedComment, 'utf8')]), key, s.globalSig)) return { ok: false, reason: 'the trusted comment was changed' };
if (file && !s.trustedComment.split(/\s+/).includes(`file:${file}`)) return { ok: false, reason: `the trusted comment does not name ${file}` };
return { ok: true, trustedComment: s.trustedComment };
} catch (e) { return { ok: false, reason: (e as Error).message }; }
}
/** Every expected file came back once, with a signature that verifies (pure). Problems, empty when all is well. */
export function remoteSigProblems(expected: string[], got: RemoteSig[], publicKey: string): string[] {
const problems: string[] = [];
for (const f of expected) {
const s = got.filter(g => g.file === f);
if (s.length !== 1) { problems.push(`${f}: ${s.length ? 'more than one' : 'no'} signature came back`); continue; }
const v = verifyDigest(s[0]!.blake2b, s[0]!.signature, publicKey, f);
if (!v.ok) problems.push(`${f}: ${v.reason}`);
}
for (const g of got) if (!expected.includes(g.file)) problems.push(`${g.file}: not asked for`);
return problems;
}
/** ssh with a script on stdin, a timeout, and a heartbeat with the last output line. Rejects with the tail. */
export function sshScript(host: string, script: string, o: { timeoutMs: number; what: string; heartbeatMs?: number; log?: (s: string) => void }): Promise<string> {
const log = o.log ?? ((s: string) => console.log(s));
return new Promise((res, rej) => {
const c = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=4', host, 'bash', '-s'], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = ''; let err = ''; let last = '(no output yet)';
const t0 = Date.now();
c.stdout.on('data', (d: Buffer) => { out += d; const t = d.toString().trim().split('\n').filter(l => !l.startsWith('SIG ')).at(-1); if (t) last = t; });
c.stderr.on('data', (d: Buffer) => { err += d; });
const beat = setInterval(() => log(` ${o.what}: ${Math.round((Date.now() - t0) / 1000)} s, ${last.slice(0, 150)}`), o.heartbeatMs ?? 30_000);
const timer = setTimeout(() => { c.kill('SIGTERM'); }, o.timeoutMs);
c.on('error', e => { clearInterval(beat); clearTimeout(timer); rej(new Error(`${o.what}: ssh did not start (${e.message})`)); });
c.on('close', (code, sig) => {
clearInterval(beat); clearTimeout(timer);
const tail = `${out}\n${err}`.trim().split('\n').filter(l => !l.startsWith('SIG ')).slice(-8).join('\n');
if (sig === 'SIGTERM') rej(new Error(`${o.what}: timeout after ${Math.round(o.timeoutMs / 60_000)} min on ${host}; last output:\n${tail}`));
else if (code !== 0) rej(new Error(`${o.what}: exit ${code} on ${host}${code === 255 ? ' (ssh: connection or login failed; not retried)' : ''}:\n${tail}`));
else res(out);
});
c.stdin.end(script);
});
}