storebound/tools/vds-sign.ts

123 lines
8.4 KiB
TypeScript

// 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);
});
}