123 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
}
|