// 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`; } /** 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:`). */ 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 { 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); }); }